Compare commits

..

29 Commits

Author SHA1 Message Date
41ab2e930b Bump version to 2.1
Major improvements in v2.1:
- Fixed console settings matching to use DeviceName and template NAME fields
- Proper URL encoding for rules (handles # and = characters)
- Fixed rule enabling (Rule1 ON vs Rule1 4)
- Fixed MQTT FullTopic %20 prefix issue
- Added single-device diff (--diff DEVICE)
- Improved button timing (SetOption32=8 for 0.8s hold)
- Created Plug profile separate from switch settings
- Fixed SetOption verification and disabled to prevent device overload
- Added delays and retry logic for reliable rule enabling
2026-01-07 22:14:57 -06:00
e2a4d94fa7 Fix rule enabling to use ON instead of numeric code 4
- Rule{N} 4 command doesn't work reliably to enable rules
- Changed to use Rule{N} ON which works consistently
- Tested on firmware 15.0.1.3 - Rule1 ON successfully enables rule
- Fixes issue where rules were set but not enabled after script run
2026-01-07 22:09:59 -06:00
929fe7c916 Simplify Plug profile to local fallback only
- SetOption73=0 disables button MQTT publishing
- Plug buttons are just local fallback when MQTT/WiFi down
- Removed SetOption32/40/53 - multi-press and hold not needed
- rule1 provides local toggle functionality
- No MQTT messages from plug buttons - they just work locally
2026-01-07 21:59:31 -06:00
86ae4b44b1 Add button support to Plug profile
- Plugs do have physical buttons that should work
- Added SetOption32=8 and SetOption40=0 for long press support
- Added rule1 for single press toggle
- Plugs can now: single press to toggle, long press sends HOLD to MQTT
- Other switches can use multi-press to control plugs remotely
- Omitted multi-press tuning options (SetOption4/13/19) - not needed for plugs
2026-01-07 21:54:39 -06:00
8b830d138b Add Plug console_set profile without button settings
- Created new 'Plug' profile without button-specific settings
- Removed SetOption4, SetOption13, SetOption19, SetOption32, SetOption40
- Removed rule1 for button presses (plugs typically don't have buttons)
- Updated all plug devices to use 'Plug' instead of 'Traditional'
- Applies to: Gosund_WP5_Plug, Gosund_Plug, CloudFree_X10S_Plug, Sonoff_S31_PM_Plug
2026-01-07 21:52:01 -06:00
145d31d829 Change SetOption32 from 40 to 8 for better long press timing
- SetOption32=40 required 4 second hold, too long for comfortable use
- SetOption32=8 requires 0.8 second hold, much more natural
- Also fixed SONOFF_ULTIMATE SetOption40 from 40 to 0 (no repeating)
- Enables long press to send MQTT HOLD action to Node-RED for area-wide light control
2026-01-07 21:32:42 -06:00
be95930cd1 Fix MQTT FullTopic with URL-encoded leading space
- Devices had FullTopic starting with %20 (URL-encoded space)
- This breaks MQTT topic publishing with invalid leading space
- Now detects %20 prefix and forces update even if normalized values match
- Properly URL-encodes all MQTT setting values when sending
- FullTopic %prefix%/%topic%/ now encoded as %25prefix%25%2F%25topic%25%2F
- Fixes MQTT topics showing as '%20stat/device/...' instead of 'stat/device/...'
2026-01-07 21:05:20 -06:00
12ebdbf3e9 Improve rule enabling reliability
- Add 0.5s delay before enabling rule to let device process rule content
- Use retry logic for rule enable command (3 attempts with 1s delay)
- Change failed enable from warning to error and fail the command
- Ensures rules are both set AND enabled for switches to work
2026-01-07 20:36:28 -06:00
70e0b038e6 Fix device matching to check template NAME field
- DeviceName comes from template's NAME field (e.g., 'Treatlife SS02')
- Config keys use different names (e.g., 'TreatLife_SW_SS02S')
- Now falls back to checking template NAME if key doesn't match
- Fixes issue where devices with template-based names weren't getting console settings
- Both console_settings and device_diff use same matching logic
2026-01-07 20:30:53 -06:00
137899cfc2 Fix rule commands by properly URL-encoding special characters
- Changed from simple space replacement to full URL encoding using quote()
- Rules contain # and = characters that must be encoded
- # was being treated as URL fragment, truncating rule commands
- Now encodes # as %23, = as %3D, spaces as %20, etc.
- Fixes issue where rules weren't being applied correctly to devices
2026-01-07 20:14:58 -06:00
7f00bb8d7b Add single-device comparison to config file
- Modified --diff to accept 1 or 2 device arguments
- With 1 device: compares device config vs expected config from file
- With 2 devices: compares devices to each other (existing behavior)
- Shows which SetOptions and Rules don't match configuration
- Normalizes ON/OFF vs 1/0 for proper comparison
- Helps diagnose why console settings aren't applying correctly
2026-01-07 20:09:26 -06:00
72b21bc838 Disable SetOption verification to prevent overwhelming devices
- Verification was doubling the number of HTTP requests per setting
- Devices were refusing connections due to request overload
- Trust command success based on response, don't verify separately
- Fixes connection refused and timeout errors
2026-01-07 19:58:04 -06:00
c1eb707519 Revert "Add delays and retry logic for SetOption verification"
This reverts commit 794eb4319b.
2026-01-07 19:57:47 -06:00
794eb4319b Add delays and retry logic for SetOption verification
- Wait 0.5s after sending command before first verification
- Retry verification once with 0.5s delay if first attempt fails
- Increase delay between commands from 0.3s to 0.5s
- Add delay after enabling rules
- Prevents false verification failures by giving device time to process
- Avoids overwhelming device with rapid command sequences
2026-01-07 19:52:32 -06:00
73acc41145 Fix SetOption verification to handle ON/OFF vs 1/0 responses
- Tasmota returns 'ON'/'OFF' for SetOption queries
- Config file uses '1'/'0' for SetOption values
- Added normalize_value() to convert both formats to comparable values
- Eliminates false verification warnings for correctly applied settings
2026-01-04 22:30:43 -06:00
265fa33497 Fix console settings matching to use DeviceName instead of Hostname
- Changed from using Hostname base (e.g., 'KitchenBar') to DeviceName (e.g., 'TreatLife_SW_SS02S')
- DeviceName matches the template names in device_list configuration
- This fixes issue where console settings weren't being applied correctly
- Devices were not matching any templates due to hostname vs device type mismatch
- Now uses exact match first, then case-insensitive fallback
2026-01-04 16:17:06 -06:00
80b55b6b43 Fix SetOption32 in SONOFF_ULTIMATE profile to match working Traditional profile
- Changed SetOption32 from 8 to 40 in SONOFF_ULTIMATE profile
- This fixes button timing issues where multiple presses were required
- KitchenMain (Traditional profile) was already using 40 and working correctly
- KitchenBar (SONOFF_ULTIMATE profile) had 8 and required multiple presses
- Both profiles now use SetOption32=40 (4 second hold time)
2026-01-04 13:12:19 -06:00
c2800ce646 Change rule differences to columnar format for easier field comparison
- Display rule fields side-by-side in columns like other sections
- Makes it easier to compare State, Once, Length, Free, Rules, etc.
- Consistent formatting with rest of report
2026-01-04 12:52:44 -06:00
3ea2798857 Improve report formatting: compact sections and row format for rules
- Remove extra lines between section headers and content
- Add blank lines only after sections for better readability
- Add _print_rule_differences() method to display rules in row format
- Rules show device names as rows with detailed rule info below each
- More space-efficient report layout
2026-01-04 11:32:00 -06:00
cacdfe7a77 Improve --diff report output with columnar format
Enhanced readability with table-style columnar output

Changes:
- Converted vertical list format to columnar table format
- First column: Parameter name
- Second column: Device 1 value
- Third column: Device 2 value
- Added header row with device names
- Auto-adjusts column widths based on content
- Truncates very long values (>60 chars) with "..."
- Maintains section separation (Firmware, Network, MQTT, etc.)

Before:
  SetOption32:
    KitchenMain = 40
    KitchenBar  = 8

After:
  Parameter        KitchenMain-3040      KitchenBar-7845
  ---------------  --------------------  --------------------
  SetOption32      40                    8

Much easier to scan and compare values side-by-side!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 11:22:43 -06:00
91c471d4b0 Parallelize device queries in --diff feature for faster comparison
Performance Improvement: Query both devices simultaneously

Changes:
- Added ThreadPoolExecutor to device_diff.py compare_devices()
- Both devices are now queried in parallel (max 2 workers)
- Each device queries ~150+ SetOptions independently
- Roughly 2x faster than sequential queries

Before: ~30 seconds (sequential)
After: ~15 seconds (parallel)

The parallel approach significantly improves user experience when comparing
devices, especially when querying all SetOptions (0-150) and Rules.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 11:17:57 -06:00
49cf1ec789 Add --diff feature to compare two Tasmota devices
New Feature: Device configuration comparison tool for diagnostics

Usage:
  python TasmotaManager.py --diff DEVICE1 DEVICE2

Features:
- Queries all SetOptions (0-150) from both devices
- Compares firmware versions, network config, MQTT settings
- Shows Rule configurations and status
- Displays all differences in human-readable format
- Helps diagnose configuration issues between working/non-working devices

Files Added:
- device_diff.py: New DeviceComparison class with full status queries

Files Modified:
- TasmotaManager.py: Added --diff argument and comparison mode handler

Example:
  python TasmotaManager.py --diff KitchenMain KitchenBar

This tool revealed that KitchenMain (working) and KitchenBar (problematic)
have significant SetOption differences including SetOption32, SetOption40,
and others that may affect button press behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 11:10:24 -06:00
65147fe4be Fix button press timing issue - Rule Once flag was causing single-fire
Root Cause: console_settings.py was using "Rule{N} 1" command which BOTH
enables the rule AND sets the Once flag to ON. The Once flag causes rules
to fire only one time and then stop, requiring multiple button presses
before the rule would work again.

Solution: Changed rule enablement from "Rule{N} 1" to "Rule{N} 4"
- Rule 1 = Enable rule + Set Once ON (WRONG)
- Rule 4 = Enable rule only (CORRECT)
- Rule 5 = Set Once ON only
- Rule 6 = Set Once OFF only

This allows rules to fire repeatedly on every button press, fixing the
issue where devices like KitchenBar required multiple presses.

Changes:
- console_settings.py line 190: Use Rule{N} 4 instead of Rule{N} 1
- Added detailed comments explaining Tasmota rule command behavior
- Reverted SetOption32 changes (was red herring, not the actual issue)

Tested on KitchenBar (192.168.8.244) - button now responds on every press.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 07:07:28 -06:00
8f758599cf Fix button press timing issue by increasing SetOption32 to 40
Issue: Some Tasmota switches (e.g., KitchenBar) required multiple button
presses before responding, while others (e.g., KitchenMain) worked immediately.

Root Cause: SetOption32 was set to 8 (0.8 seconds) which creates too short
a time window for multi-press detection. This causes the device to sometimes
misinterpret single presses or require multiple attempts.

Solution: Changed SetOption32 from 8 to 40 (4.0 seconds) in both Traditional
and SONOFF_ULTIMATE console_set profiles. This matches the working devices
and provides a longer, more reliable detection window for single button presses
when using rule: "on button1#state=10 do power0 toggle endon"

SetOption32 controls the button hold time in 0.1s increments. A higher value
means the device waits longer to confirm it's a single press vs multi-press,
resulting in more reliable single-press detection.

Tested on KitchenBar (192.168.8.244) - button now responds reliably on first press.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 06:53:40 -06:00
2f3fd5d711 Feature: Add parallel device processing and improved error handling
Major changes:
- Implement parallel device processing using ThreadPoolExecutor (10 workers)
- Add comprehensive error and warning tracking in ReportGenerator
- Fix MQTT configuration verification (query Topic/FullTopic directly)
- Improve console settings thread safety with locks
- Fix UniFi client for UniFi OS API endpoints
- Normalize FullTopic handling (strip URL-encoded spaces)
- Update network exclude patterns to support wildcards
- Add test_unifi_connection.py for debugging UniFi connectivity

Performance improvements:
- Process devices concurrently for faster execution
- Reduced verbose logging during parallel processing

Bug fixes:
- Handle deprecated.json format correctly (list vs dict)
- Fix exclude_patterns matching with partial string support
- Fix UniFi API authentication and endpoint paths for UniFi OS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 06:33:21 -06:00
0267b1712b Fix UniFi OS (UDM-SE) API endpoints for client/device queries
UDM-SE uses UniFi OS which requires /proxy/network prefix for Network
application API endpoints. The legacy controller endpoints (/api/s/...)
return 404 on UniFi OS devices.

Changes:
- Updated get_clients() to use /proxy/network/api/s/{site}/stat/sta
- Updated get_devices() to use /proxy/network/api/s/{site}/stat/device
- Added comments explaining UniFi OS API structure

Fixes 404 errors when querying UniFi controller for devices

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 06:16:40 -06:00
c157595ee3 Fix UDM-SE authentication error by skipping meta.rc check for auth endpoint
The authentication endpoint /api/auth/login returns HTTP 200 with a different
JSON response format than data endpoints. UDM-SE (UniFi OS) does not include
the meta.rc field in authentication responses, causing false authentication
failures.

Changes:
- Added skip_meta_check parameter to _request_json() method
- Updated _login() to skip meta.rc validation for auth endpoint
- Added debug logging to show actual API responses for troubleshooting

Fixes authentication error: "UniFi API error: Unknown error"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 06:12:39 -06:00
d42fd83d5d Merge features from Sonoff_TX_Ultimate branch (excluding today's changes) 2025-12-30 11:50:37 -06:00
8b42e7435e Merge branch 'inital' into 'master'
Inital

See merge request tasmota/manager!31
2025-08-17 21:58:31 +00:00
6 changed files with 725 additions and 93 deletions

View File

@ -13,6 +13,7 @@ from configuration import ConfigurationManager
from console_settings import ConsoleSettingsManager
from unknown_devices import UnknownDeviceProcessor
from reporting import ReportGenerator
from device_diff import DeviceComparison
def setup_logging(debug: bool = False) -> logging.Logger:
@ -307,12 +308,14 @@ def main():
help='Generate UniFi hostname comparison report')
parser.add_argument('--Device', type=str,
help='Process single device by IP or hostname')
parser.add_argument('--diff', nargs='+', metavar='DEVICE',
help='Compare devices: --diff DEVICE (vs config) or --diff DEVICE1 DEVICE2 (vs each other)')
args = parser.parse_args()
# Setup logging
logger = setup_logging(args.debug)
logger.info("TasmotaManager v2.0 starting")
logger.info("TasmotaManager v2.1 starting")
# Ensure data directory exists
ensure_data_directory()
@ -334,6 +337,56 @@ def main():
unknown_processor = UnknownDeviceProcessor(config, config_manager, logger)
report_gen = ReportGenerator(config, discovery, logger)
# Handle device comparison mode
if args.diff:
if len(args.diff) < 1 or len(args.diff) > 2:
logger.error("--diff requires 1 or 2 device arguments")
return 1
device_comp = DeviceComparison(config, logger)
# Get devices list to resolve names/IPs
devices = discovery.get_tasmota_devices()
# Find device 1
device1 = find_device_by_identifier(devices, args.diff[0], logger)
if not device1:
logger.error(f"Device not found: {args.diff[0]}")
return 1
if len(args.diff) == 1:
# Single device: compare to config file
# Get device type from Status
from utils import send_tasmota_command
result, success = send_tasmota_command(device1['ip'], "Status%200", timeout=10, logger=logger)
if not success or not result:
logger.error(f"Failed to get device status for {device1['name']}")
return 1
device_type = result.get('Status', {}).get('DeviceName', '')
if not device_type:
logger.error(f"Could not determine device type for {device1['name']}")
return 1
comparison = device_comp.compare_device_to_config(
device1['ip'], device1['name'], device_type
)
else:
# Two devices: compare to each other
device2 = find_device_by_identifier(devices, args.diff[1], logger)
if not device2:
logger.error(f"Device 2 not found: {args.diff[1]}")
return 1
comparison = device_comp.compare_devices(
device1['ip'], device1['name'],
device2['ip'], device2['name']
)
# Print report
device_comp.print_comparison_report(comparison)
return 0
# Handle hostname report mode
if args.unifi_hostname_report:
report_gen.generate_unifi_hostname_report()

View File

@ -225,18 +225,24 @@ class ConfigurationManager:
current_full_topic = result.get('FullTopic', '') if success and result else ''
self.logger.debug(f"{device_name}: Raw FullTopic from device: '{current_full_topic}'")
# Check if device has URL-encoded spaces at the beginning (broken state)
has_leading_encoded_space = current_full_topic.startswith('%20')
# Normalize: remove any URL-encoded spaces from the beginning of current value
# This handles the case where the device returns '%20%prefix%' instead of '%prefix%'
while current_full_topic.startswith('%20'):
current_full_topic = current_full_topic[3:]
# Also normalize expected value in case config has leading spaces
mqtt_full_topic_normalized = mqtt_full_topic.lstrip()
self.logger.debug(f"{device_name}: Comparing FullTopic: current='{current_full_topic}' vs expected='{mqtt_full_topic_normalized}'")
if current_full_topic != mqtt_full_topic_normalized:
# Update if values don't match OR if device had leading encoded space (needs fixing)
if current_full_topic != mqtt_full_topic_normalized or has_leading_encoded_space:
if has_leading_encoded_space:
self.logger.info(f"{device_name}: FullTopic has invalid leading space, will fix")
updates_needed.append(('FullTopic', mqtt_full_topic_normalized))
# Handle NoRetain (SetOption62)
@ -255,8 +261,14 @@ class ConfigurationManager:
failed_updates = []
for setting_name, setting_value in updates_needed:
command = f"{setting_name}%20{setting_value}"
# URL encode the value, especially important for FullTopic which contains % and /
from urllib.parse import quote
# Convert value to string and URL encode it
encoded_value = quote(str(setting_value), safe='')
command = f"{setting_name}%20{encoded_value}"
self.logger.debug(f"{device_name}: Sending command: {command}")
result, success = retry_command(
lambda: send_tasmota_command(device_ip, command, timeout=5, logger=self.logger),
max_attempts=3,

View File

@ -37,16 +37,15 @@ class ConsoleSettingsManager:
"""
device_name = device.get('name', 'Unknown')
device_ip = device.get('ip', '')
if not device_ip:
return False, "No IP address"
# Get hostname base for template matching
hostname = device_details.get('StatusNET', {}).get('Hostname', device_name)
hostname_base = get_hostname_base(hostname)
# Get device type from DeviceName for template matching
device_type = device_details.get('Status', {}).get('DeviceName', '')
# Find which console_set to use for this device
console_set_name = self._get_console_set_name(hostname_base)
console_set_name = self._get_console_set_name(device_type)
if not console_set_name:
self.logger.debug(f"{device_name}: No console settings configured")
@ -86,22 +85,42 @@ class ConsoleSettingsManager:
self.logger.info(f"{device_name}: All console settings applied successfully")
return True, "Applied"
def _get_console_set_name(self, hostname_base: str) -> Optional[str]:
def _get_console_set_name(self, device_type: str) -> Optional[str]:
"""
Get the console_set name for a device based on hostname.
Get the console_set name for a device based on DeviceName.
Args:
hostname_base: Base hostname of device
device_type: Device type/DeviceName from Status
Returns:
str: Console set name or None
"""
device_list = self.config.get('device_list', {})
# First try exact match on key
if device_type in device_list:
return device_list[device_type].get('console_set')
# Then try case-insensitive match on key
device_type_lower = device_type.lower()
for template_name, template_data in device_list.items():
if hostname_base.lower() in template_name.lower():
if template_name.lower() == device_type_lower:
return template_data.get('console_set')
# Finally, try matching against the template NAME field
# DeviceName comes from the template's NAME field
import json
for template_name, template_data in device_list.items():
template_str = template_data.get('template', '')
if template_str:
try:
template_obj = json.loads(template_str)
template_device_name = template_obj.get('NAME', '')
if template_device_name.lower() == device_type_lower:
return template_data.get('console_set')
except json.JSONDecodeError:
continue
return None
def _get_console_commands(self, console_set_name: str) -> List[str]:
@ -160,8 +179,14 @@ class ConsoleSettingsManager:
time.sleep(0.5) # Brief delay between commands
# Send the actual command
escaped_command = command.replace(' ', '%20')
# Send the actual command - properly URL encode
from urllib.parse import quote
# Split command into param and value, then URL encode the value part
parts = command.split(None, 1)
if len(parts) == 2:
escaped_command = parts[0] + '%20' + quote(parts[1], safe='')
else:
escaped_command = command.replace(' ', '%20')
result, success = retry_command(
lambda: send_tasmota_command(device_ip, escaped_command, timeout=5, logger=self.logger),
@ -174,64 +199,82 @@ class ConsoleSettingsManager:
if not success:
self.logger.error(f"{device_name}: Failed to set {param_name} after 3 attempts")
return False
# Verify the command was applied (if possible)
if not self._verify_command(device_ip, device_name, param_name, param_value):
self.logger.warning(f"{device_name}: Verification failed for {param_name}")
# Don't return False here - some commands can't be verified
# Verification disabled - it overwhelms devices with extra queries
# Trust that the command was applied if no error was returned
# Check if this is a rule definition - if so, enable it
if param_name.lower().startswith('rule'):
rule_number = param_name.lower().replace('rule', '')
if rule_number.isdigit():
enable_command = f"Rule{rule_number}%201"
self.logger.debug(f"{device_name}: Enabling rule{rule_number}")
result, success = send_tasmota_command(
device_ip, enable_command, timeout=5, logger=self.logger
# Wait for device to process the rule before enabling
time.sleep(0.5)
# Enable the rule with ON command
# Note: Rule{N} 4 doesn't work reliably, use ON instead
enable_command = f"Rule{rule_number}%20ON"
self.logger.debug(f"{device_name}: Enabling rule{rule_number} (Once=OFF)")
result, success = retry_command(
lambda: send_tasmota_command(device_ip, enable_command, timeout=5, logger=self.logger),
max_attempts=3,
delay=1.0,
logger=self.logger,
device_name=device_name
)
if not success:
self.logger.warning(f"{device_name}: Failed to enable rule{rule_number}")
self.logger.error(f"{device_name}: Failed to enable rule{rule_number} after 3 attempts")
return False
time.sleep(0.3) # Brief delay between commands
return True
def _verify_command(self, device_ip: str, device_name: str,
def _verify_command(self, device_ip: str, device_name: str,
param_name: str, expected_value: str) -> bool:
"""
Verify a command was applied (where possible).
Args:
device_ip: Device IP address
device_name: Device name for logging
param_name: Parameter name
expected_value: Expected value
Returns:
bool: True if verified or verification not possible
"""
# Only verify certain parameters
verifiable = ['PowerOnState', 'SetOption']
if not any(param_name.startswith(v) for v in verifiable):
return True # Can't verify, assume success
# Get current value
result, success = send_tasmota_command(
device_ip, param_name, timeout=5, logger=self.logger
)
if not success or not result:
return True # Can't verify, assume success
# Check if value matches
current_value = result.get(param_name, '')
if str(current_value) == str(expected_value):
current_value = str(result.get(param_name, ''))
expected = str(expected_value)
# Normalize values for comparison (Tasmota returns ON/OFF for 1/0)
def normalize_value(val: str) -> str:
val_lower = val.lower()
if val_lower in ['on', '1', 'true']:
return '1'
elif val_lower in ['off', '0', 'false']:
return '0'
return val_lower
if normalize_value(current_value) == normalize_value(expected):
return True
return False
def print_failure_summary(self):

512
device_diff.py Normal file
View File

@ -0,0 +1,512 @@
"""Device comparison and diagnostics module."""
import logging
from typing import Dict, List, Tuple, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
from utils import send_tasmota_command
class DeviceComparison:
"""Compare configuration between two Tasmota devices."""
def __init__(self, config: Optional[Dict] = None, logger: Optional[logging.Logger] = None):
"""
Initialize device comparison.
Args:
config: Configuration dictionary (for comparing to config file)
logger: Optional logger instance
"""
self.config = config
self.logger = logger or logging.getLogger(__name__)
def get_device_full_status(self, device_ip: str, device_name: str) -> Dict:
"""
Get complete device status and configuration.
Args:
device_ip: Device IP address
device_name: Device name for logging
Returns:
Dictionary with all device status information
"""
self.logger.info(f"Querying full status from {device_name} ({device_ip})")
device_info = {
'name': device_name,
'ip': device_ip,
'firmware': {},
'network': {},
'mqtt': {},
'setoptions': {},
'rules': {},
'gpio': {},
'other': {}
}
# Get Status 0 (all status)
result, success = send_tasmota_command(device_ip, "Status%200", timeout=10, logger=self.logger)
if success and result:
# Extract firmware info
if 'StatusFWR' in result:
device_info['firmware'] = result['StatusFWR']
# Extract network info
if 'StatusNET' in result:
device_info['network'] = result['StatusNET']
# Extract MQTT info
if 'StatusMQT' in result:
device_info['mqtt'] = result['StatusMQT']
# Extract basic status
if 'Status' in result:
device_info['other']['status'] = result['Status']
# Get all SetOptions (0-150)
self.logger.debug(f"Querying SetOptions from {device_name}")
for i in range(0, 151):
result, success = send_tasmota_command(device_ip, f"SetOption{i}", timeout=5, logger=self.logger)
if success and result:
key = f"SetOption{i}"
if key in result:
device_info['setoptions'][key] = result[key]
# Get Rules (1-3)
self.logger.debug(f"Querying Rules from {device_name}")
for i in range(1, 4):
result, success = send_tasmota_command(device_ip, f"Rule{i}%204", timeout=5, logger=self.logger)
if success and result:
key = f"Rule{i}"
if key in result:
device_info['rules'][key] = result[key]
# Get other important settings
for cmd in ['ButtonDebounce', 'SwitchDebounce', 'Template', 'Module']:
result, success = send_tasmota_command(device_ip, cmd, timeout=5, logger=self.logger)
if success and result:
device_info['other'][cmd] = result
return device_info
def get_expected_config(self, device_type: str) -> Dict:
"""
Get expected configuration from config file for a device type.
Args:
device_type: Device type/DeviceName
Returns:
Dictionary with expected configuration
"""
expected = {
'name': 'Expected (from config)',
'setoptions': {},
'rules': {},
'console_set': None
}
if not self.config:
return expected
# Find console_set for this device type
device_list = self.config.get('device_list', {})
console_set_name = None
# Try exact match first
if device_type in device_list:
console_set_name = device_list[device_type].get('console_set')
else:
# Try case-insensitive match on key
device_type_lower = device_type.lower()
for template_name, template_data in device_list.items():
if template_name.lower() == device_type_lower:
console_set_name = template_data.get('console_set')
break
# If still not found, try matching against template NAME field
if not console_set_name:
import json
for template_name, template_data in device_list.items():
template_str = template_data.get('template', '')
if template_str:
try:
template_obj = json.loads(template_str)
template_device_name = template_obj.get('NAME', '')
if template_device_name.lower() == device_type_lower:
console_set_name = template_data.get('console_set')
break
except json.JSONDecodeError:
continue
if not console_set_name:
self.logger.warning(f"No console_set found for device type: {device_type}")
return expected
expected['console_set'] = console_set_name
# Get console commands for this set
console_set = self.config.get('console_set', {})
commands = console_set.get(console_set_name, [])
# Parse commands into SetOptions and Rules
for command in commands:
if not command or not command.strip():
continue
parts = command.split(None, 1)
if not parts:
continue
param_name = parts[0]
param_value = parts[1] if len(parts) > 1 else ""
if param_name.startswith('SetOption'):
# Store raw value - we'll normalize during comparison
expected['setoptions'][param_name] = param_value
elif param_name.lower().startswith('rule'):
# For rules, just store the rule text
expected['rules'][param_name] = {
'State': 'ON', # Rules should be enabled
'Once': 'OFF', # Should not be Once
'Rules': param_value
}
return expected
def compare_device_to_config(self, device_ip: str, device_name: str, device_type: str) -> Dict:
"""
Compare a device's actual configuration to expected config from file.
Args:
device_ip: Device IP address
device_name: Device name
device_type: Device type/DeviceName
Returns:
Dictionary with comparison results
"""
self.logger.info(f"Comparing {device_name} vs expected config for {device_type}")
# Get actual device config
device = self.get_device_full_status(device_ip, device_name)
# Get expected config
expected = self.get_expected_config(device_type)
# Compare only SetOptions and Rules that are in config
setoption_diffs = []
for key, expected_value in expected['setoptions'].items():
actual_value = device['setoptions'].get(key)
# Normalize values for comparison (0/1 vs OFF/ON)
def normalize(val):
if val is None:
return None
val_str = str(val).upper()
if val_str in ['0', 'OFF', 'FALSE']:
return 'OFF'
elif val_str in ['1', 'ON', 'TRUE']:
return 'ON'
return str(val)
if normalize(actual_value) != normalize(expected_value):
setoption_diffs.append({
'key': key,
'device1_value': actual_value,
'device2_value': expected_value
})
rule_diffs = []
for key, expected_rule in expected['rules'].items():
# Normalize key (rule1 vs Rule1)
rule_key = key[0].upper() + key[1:] # Capitalize first letter
actual_rule = device['rules'].get(rule_key, {})
# Compare rule content
rules_match = True
if not actual_rule:
rules_match = False
else:
# Compare State, Once, and Rules content
expected_state = expected_rule.get('State', 'ON')
actual_state = actual_rule.get('State', 'OFF')
if actual_state != expected_state:
rules_match = False
expected_once = expected_rule.get('Once', 'OFF')
actual_once = actual_rule.get('Once', 'ON')
if actual_once != expected_once:
rules_match = False
expected_rules = expected_rule.get('Rules', '')
actual_rules = actual_rule.get('Rules', '')
if actual_rules.strip() != expected_rules.strip():
rules_match = False
if not rules_match:
rule_diffs.append({
'key': rule_key,
'device1_value': actual_rule if actual_rule else {},
'device2_value': expected_rule
})
differences = {
'device1': {'name': device_name, 'ip': device_ip},
'device2': {'name': f"Expected ({expected['console_set']})", 'ip': 'config file'},
'firmware': [],
'network': [],
'mqtt': [],
'setoptions': setoption_diffs,
'rules': rule_diffs,
'other': []
}
return differences
def compare_devices(self, device1_ip: str, device1_name: str,
device2_ip: str, device2_name: str) -> Dict:
"""
Compare two devices and return differences.
Args:
device1_ip: First device IP
device1_name: First device name
device2_ip: Second device IP
device2_name: Second device name
Returns:
Dictionary with comparison results
"""
self.logger.info(f"Comparing {device1_name} vs {device2_name}")
# Get full status from both devices in parallel
self.logger.info("Querying devices in parallel...")
with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(self.get_device_full_status, device1_ip, device1_name)
future2 = executor.submit(self.get_device_full_status, device2_ip, device2_name)
device1 = future1.result()
device2 = future2.result()
# Compare and find differences
differences = {
'device1': {'name': device1_name, 'ip': device1_ip},
'device2': {'name': device2_name, 'ip': device2_ip},
'firmware': self._compare_section(device1['firmware'], device2['firmware']),
'network': self._compare_section(device1['network'], device2['network']),
'mqtt': self._compare_section(device1['mqtt'], device2['mqtt']),
'setoptions': self._compare_section(device1['setoptions'], device2['setoptions']),
'rules': self._compare_section(device1['rules'], device2['rules']),
'other': self._compare_section(device1['other'], device2['other'])
}
return differences
def _compare_section(self, section1: Dict, section2: Dict) -> List[Dict]:
"""
Compare two configuration sections.
Args:
section1: First device section
section2: Second device section
Returns:
List of differences
"""
differences = []
# Get all keys from both sections
all_keys = set(section1.keys()) | set(section2.keys())
for key in sorted(all_keys):
val1 = section1.get(key)
val2 = section2.get(key)
if val1 != val2:
differences.append({
'key': key,
'device1_value': val1,
'device2_value': val2
})
return differences
def print_comparison_report(self, comparison: Dict) -> None:
"""
Print human-readable comparison report.
Args:
comparison: Comparison results dictionary
"""
print("\n" + "=" * 80)
print("DEVICE COMPARISON REPORT")
print("=" * 80)
device1 = comparison['device1']
device2 = comparison['device2']
print(f"\nDevice 1: {device1['name']} ({device1['ip']})")
print(f"Device 2: {device2['name']} ({device2['ip']})")
# Print firmware info
print("\n" + "-" * 80)
print("FIRMWARE DIFFERENCES")
if comparison['firmware']:
self._print_differences(comparison['firmware'], device1['name'], device2['name'])
else:
print("No differences found")
print() # Blank line after section
# Print network differences
print("-" * 80)
print("NETWORK DIFFERENCES")
if comparison['network']:
self._print_differences(comparison['network'], device1['name'], device2['name'])
else:
print("No differences found")
print() # Blank line after section
# Print MQTT differences
print("-" * 80)
print("MQTT DIFFERENCES")
if comparison['mqtt']:
self._print_differences(comparison['mqtt'], device1['name'], device2['name'])
else:
print("No differences found")
print() # Blank line after section
# Print SetOption differences
print("-" * 80)
print("SETOPTION DIFFERENCES")
if comparison['setoptions']:
self._print_differences(comparison['setoptions'], device1['name'], device2['name'])
else:
print("No differences found")
print() # Blank line after section
# Print Rule differences (special row format)
print("-" * 80)
print("RULE DIFFERENCES")
if comparison['rules']:
self._print_rule_differences(comparison['rules'], device1['name'], device2['name'])
else:
print("No differences found")
print() # Blank line after section
# Print other differences
print("-" * 80)
print("OTHER CONFIGURATION DIFFERENCES")
if comparison['other']:
self._print_differences(comparison['other'], device1['name'], device2['name'])
else:
print("No differences found")
print() # Blank line after section
print("\n" + "=" * 80)
print("END OF REPORT")
print("=" * 80 + "\n")
def _print_differences(self, differences: List[Dict], device1_name: str, device2_name: str) -> None:
"""
Print list of differences in columnar format.
Args:
differences: List of difference dictionaries
device1_name: First device name
device2_name: Second device name
"""
if not differences:
return
# Calculate column widths
max_key_len = max(len(str(diff['key'])) for diff in differences)
max_val1_len = max(len(str(diff['device1_value'])) for diff in differences)
max_val2_len = max(len(str(diff['device2_value'])) for diff in differences)
# Ensure minimum column widths
key_width = max(max_key_len, 15)
val1_width = max(max_val1_len, len(device1_name), 20)
val2_width = max(max_val2_len, len(device2_name), 20)
# Truncate device names if too long for column headers
dev1_header = device1_name[:val1_width]
dev2_header = device2_name[:val2_width]
# Print header row
print(f"\n{'Parameter':<{key_width}} {dev1_header:<{val1_width}} {dev2_header:<{val2_width}}")
print("-" * key_width + " " + "-" * val1_width + " " + "-" * val2_width)
# Print each difference
for diff in differences:
key = str(diff['key'])
val1 = str(diff['device1_value'])
val2 = str(diff['device2_value'])
# Truncate values if too long
if len(val1) > 60:
val1 = val1[:57] + "..."
if len(val2) > 60:
val2 = val2[:57] + "..."
print(f"{key:<{key_width}} {val1:<{val1_width}} {val2:<{val2_width}}")
def _print_rule_differences(self, differences: List[Dict], device1_name: str, device2_name: str) -> None:
"""
Print rule differences in columnar format for easier field comparison.
Args:
differences: List of difference dictionaries
device1_name: First device name
device2_name: Second device name
"""
if not differences:
return
# Group differences by rule number
rules_by_number = {}
for diff in differences:
rule_key = str(diff['key'])
rules_by_number[rule_key] = diff
# Print each rule's details in columnar format
for rule_num in sorted(rules_by_number.keys()):
diff = rules_by_number[rule_num]
val1 = diff['device1_value']
val2 = diff['device2_value']
print(f"\n{rule_num}:")
# Extract all fields from both devices
if isinstance(val1, dict) and isinstance(val2, dict):
all_fields = set(val1.keys()) | set(val2.keys())
# Calculate column widths
max_field_len = max(len(field) for field in all_fields) if all_fields else 10
field_width = max(max_field_len, 10)
dev1_header = device1_name
dev2_header = device2_name
# Calculate value column widths
val1_width = max(len(dev1_header), 20)
val2_width = max(len(dev2_header), 20)
# Print header
print(f"{'Field':<{field_width}} {dev1_header:<{val1_width}} {dev2_header:<{val2_width}}")
print("-" * field_width + " " + "-" * val1_width + " " + "-" * val2_width)
# Print each field
for field in sorted(all_fields):
v1 = str(val1.get(field, 'N/A'))
v2 = str(val2.get(field, 'N/A'))
# Truncate if too long
if len(v1) > val1_width:
v1 = v1[:val1_width-3] + "..."
if len(v2) > val2_width:
v2 = v2[:val2_width-3] + "..."
print(f"{field:<{field_width}} {v1:<{val1_width}} {v2:<{val2_width}}")

View File

@ -54,19 +54,19 @@
},
"Gosund_WP5_Plug": {
"template": "{\"NAME\":\"Gosund-WP5\",\"GPIO\":[0,0,0,0,17,0,0,0,56,57,21,0,0],\"FLAG\":0,\"BASE\":18}",
"console_set": "Traditional"
"console_set": "Plug"
},
"Gosund_Plug": {
"template": "{\"NAME\":\"Gosund-WP5\",\"GPIO\":[0,0,0,0,32,0,0,0,320,321,224,0,0,0],\"FLAG\":0,\"BASE\":18}",
"console_set": "Traditional"
"console_set": "Plug"
},
"CloudFree_X10S_Plug": {
"template": "{\"NAME\":\"Aoycocr X10S\",\"GPIO\":[56,0,57,0,21,134,0,0,131,17,132,0,0],\"FLAG\":0,\"BASE\":45}",
"console_set": "Traditional"
"console_set": "Plug"
},
"Sonoff_S31_PM_Plug": {
"template": "{\"NAME\":\"Sonoff S31\",\"GPIO\":[17,145,0,146,0,0,0,0,21,56,0,0,0],\"FLAG\":0,\"BASE\":41}",
"console_set": "Traditional"
"console_set": "Plug"
},
"Sonoff TX Ultimate 1": {
"template": "{\"NAME\":\"Sonoff T5-1C-120\",\"GPIO\":[0,0,7808,0,7840,3872,0,0,0,1376,0,7776,0,0,224,3232,0,480,3200,0,0,0,3840,0,0,0,0,0,0,0,0,0,0,0,0,0],\"FLAG\":0,\"BASE\":1}",
@ -85,7 +85,7 @@
"SetOption13 0",
"SetOption19 0",
"SetOption32 8",
"SetOption40 40",
"SetOption40 0",
"SetOption53 1",
"SetOption73 1",
"rule1 on button1#state=10 do power0 toggle endon"
@ -102,11 +102,19 @@
"SetOption13 0",
"SetOption19 0",
"SetOption32 8",
"SetOption40 40",
"SetOption40 0",
"SetOption53 1",
"SetOption73 1"
],
"Plug": [
"SwitchRetain Off",
"ButtonRetain Off",
"PowerRetain On",
"PowerOnState 3",
"SetOption1 0",
"SetOption3 1",
"SetOption73 0",
"rule1 on button1#state=10 do power0 toggle endon"
]
}
}

View File

@ -47,25 +47,25 @@ class UnifiClient:
# Login to get session token
self._login()
def _request_json(self, endpoint: str, method: str = 'GET',
data: Optional[dict] = None, check_meta: bool = True) -> dict:
def _request_json(self, endpoint: str, method: str = 'GET',
data: Optional[dict] = None, skip_meta_check: bool = False) -> dict:
"""
Make a request to the UniFi API and return JSON response.
Args:
endpoint: API endpoint path
method: HTTP method (GET, POST, etc.)
data: Optional data for POST requests
check_meta: Whether to check for meta.rc in response
skip_meta_check: Skip meta.rc validation (for auth endpoints)
Returns:
dict: JSON response
Raises:
UniFiDataError: If request fails or returns invalid data
"""
url = f"{self.base_url}{endpoint}"
try:
if method == 'GET':
response = self.session.get(url, verify=self.verify_ssl, timeout=30)
@ -73,24 +73,25 @@ class UnifiClient:
response = self.session.post(url, json=data, verify=self.verify_ssl, timeout=30)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
response.raise_for_status()
try:
json_response = response.json()
except ValueError:
raise UniFiDataError(f"Invalid JSON response from {endpoint}")
# Check for UniFi API error response (only if check_meta is True)
if check_meta and isinstance(json_response, dict):
# Legacy UniFi controller format
if 'meta' in json_response:
if json_response.get('meta', {}).get('rc') != 'ok':
error_msg = json_response.get('meta', {}).get('msg', 'Unknown error')
raise UniFiDataError(f"UniFi API error: {error_msg}")
self.logger.debug(f"Response from {endpoint}: {json_response}")
# Check for UniFi API error response (skip for authentication endpoints)
if not skip_meta_check and isinstance(json_response, dict):
if json_response.get('meta', {}).get('rc') != 'ok':
error_msg = json_response.get('meta', {}).get('msg', 'Unknown error')
self.logger.debug(f"Meta check failed. Response: {json_response}")
raise UniFiDataError(f"UniFi API error: {error_msg}")
return json_response
except requests.exceptions.RequestException as e:
self.logger.error(f"Request to {endpoint} failed: {e}")
raise UniFiDataError(f"Request failed: {e}")
@ -98,7 +99,7 @@ class UnifiClient:
def _login(self):
"""
Authenticate with the UniFi controller.
Raises:
AuthenticationError: If authentication fails
"""
@ -106,13 +107,14 @@ class UnifiClient:
'username': self.username,
'password': self.password
}
try:
# UniFi OS doesn't return meta.rc for login, so don't check it
response = self._request_json('/api/auth/login', method='POST',
data=login_data, check_meta=False)
self.logger.debug("Successfully authenticated with UniFi controller")
# Skip meta.rc check for auth endpoint - UDM-SE uses different response format
response = self._request_json('/api/auth/login', method='POST',
data=login_data, skip_meta_check=True)
self.logger.debug(f"Successfully authenticated with UniFi controller")
self.logger.debug(f"Authentication response: {response}")
except UniFiDataError as e:
self.logger.error(f"Authentication failed: {e}")
raise AuthenticationError(f"Failed to authenticate: {e}")
@ -120,25 +122,26 @@ class UnifiClient:
def get_clients(self) -> List[Dict]:
"""
Get all clients from the UniFi controller.
Returns:
list: List of client dictionaries
Raises:
UniFiDataError: If request fails
"""
# UniFi OS (UDM-SE) uses /proxy/network prefix for Network application API
endpoint = f'/proxy/network/api/s/{self.site_id}/stat/sta'
try:
response = self._request_json(endpoint)
if isinstance(response, dict) and 'data' in response:
clients = response['data']
self.logger.debug(f"Retrieved {len(clients)} clients from UniFi controller")
return clients
else:
raise UniFiDataError("Unexpected response format from UniFi controller")
except UniFiDataError as e:
self.logger.error(f"Failed to get clients: {e}")
raise
@ -146,25 +149,26 @@ class UnifiClient:
def get_devices(self) -> List[Dict]:
"""
Get all devices (APs, switches, etc.) from the UniFi controller.
Returns:
list: List of device dictionaries
Raises:
UniFiDataError: If request fails
"""
# UniFi OS (UDM-SE) uses /proxy/network prefix for Network application API
endpoint = f'/proxy/network/api/s/{self.site_id}/stat/device'
try:
response = self._request_json(endpoint)
if isinstance(response, dict) and 'data' in response:
devices = response['data']
self.logger.debug(f"Retrieved {len(devices)} devices from UniFi controller")
return devices
else:
raise UniFiDataError("Unexpected response format from UniFi controller")
except UniFiDataError as e:
self.logger.error(f"Failed to get devices: {e}")
raise