Compare commits

...

21 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
5 changed files with 441 additions and 96 deletions

View File

@ -308,14 +308,14 @@ def main():
help='Generate UniFi hostname comparison report') help='Generate UniFi hostname comparison report')
parser.add_argument('--Device', type=str, parser.add_argument('--Device', type=str,
help='Process single device by IP or hostname') help='Process single device by IP or hostname')
parser.add_argument('--diff', nargs=2, metavar=('DEVICE1', 'DEVICE2'), parser.add_argument('--diff', nargs='+', metavar='DEVICE',
help='Compare two devices and show configuration differences') help='Compare devices: --diff DEVICE (vs config) or --diff DEVICE1 DEVICE2 (vs each other)')
args = parser.parse_args() args = parser.parse_args()
# Setup logging # Setup logging
logger = setup_logging(args.debug) logger = setup_logging(args.debug)
logger.info("TasmotaManager v2.0 starting") logger.info("TasmotaManager v2.1 starting")
# Ensure data directory exists # Ensure data directory exists
ensure_data_directory() ensure_data_directory()
@ -339,7 +339,11 @@ def main():
# Handle device comparison mode # Handle device comparison mode
if args.diff: if args.diff:
device_comp = DeviceComparison(logger) 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 # Get devices list to resolve names/IPs
devices = discovery.get_tasmota_devices() devices = discovery.get_tasmota_devices()
@ -347,16 +351,33 @@ def main():
# Find device 1 # Find device 1
device1 = find_device_by_identifier(devices, args.diff[0], logger) device1 = find_device_by_identifier(devices, args.diff[0], logger)
if not device1: if not device1:
logger.error(f"Device 1 not found: {args.diff[0]}") logger.error(f"Device not found: {args.diff[0]}")
return 1 return 1
# Find device 2 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) device2 = find_device_by_identifier(devices, args.diff[1], logger)
if not device2: if not device2:
logger.error(f"Device 2 not found: {args.diff[1]}") logger.error(f"Device 2 not found: {args.diff[1]}")
return 1 return 1
# Compare devices
comparison = device_comp.compare_devices( comparison = device_comp.compare_devices(
device1['ip'], device1['name'], device1['ip'], device1['name'],
device2['ip'], device2['name'] device2['ip'], device2['name']

View File

@ -226,6 +226,9 @@ class ConfigurationManager:
self.logger.debug(f"{device_name}: Raw FullTopic from device: '{current_full_topic}'") 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 # 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%' # This handles the case where the device returns '%20%prefix%' instead of '%prefix%'
while current_full_topic.startswith('%20'): while current_full_topic.startswith('%20'):
@ -236,7 +239,10 @@ class ConfigurationManager:
self.logger.debug(f"{device_name}: Comparing FullTopic: current='{current_full_topic}' vs expected='{mqtt_full_topic_normalized}'") 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)) updates_needed.append(('FullTopic', mqtt_full_topic_normalized))
# Handle NoRetain (SetOption62) # Handle NoRetain (SetOption62)
@ -255,7 +261,13 @@ class ConfigurationManager:
failed_updates = [] failed_updates = []
for setting_name, setting_value in updates_needed: 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( result, success = retry_command(
lambda: send_tasmota_command(device_ip, command, timeout=5, logger=self.logger), lambda: send_tasmota_command(device_ip, command, timeout=5, logger=self.logger),

View File

@ -41,12 +41,11 @@ class ConsoleSettingsManager:
if not device_ip: if not device_ip:
return False, "No IP address" return False, "No IP address"
# Get hostname base for template matching # Get device type from DeviceName for template matching
hostname = device_details.get('StatusNET', {}).get('Hostname', device_name) device_type = device_details.get('Status', {}).get('DeviceName', '')
hostname_base = get_hostname_base(hostname)
# Find which console_set to use for this device # 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: if not console_set_name:
self.logger.debug(f"{device_name}: No console settings configured") 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") self.logger.info(f"{device_name}: All console settings applied successfully")
return True, "Applied" 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: Args:
hostname_base: Base hostname of device device_type: Device type/DeviceName from Status
Returns: Returns:
str: Console set name or None str: Console set name or None
""" """
device_list = self.config.get('device_list', {}) 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(): 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') 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 return None
def _get_console_commands(self, console_set_name: str) -> List[str]: def _get_console_commands(self, console_set_name: str) -> List[str]:
@ -160,7 +179,13 @@ class ConsoleSettingsManager:
time.sleep(0.5) # Brief delay between commands time.sleep(0.5) # Brief delay between commands
# Send the actual command # 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') escaped_command = command.replace(' ', '%20')
result, success = retry_command( result, success = retry_command(
@ -175,28 +200,33 @@ class ConsoleSettingsManager:
self.logger.error(f"{device_name}: Failed to set {param_name} after 3 attempts") self.logger.error(f"{device_name}: Failed to set {param_name} after 3 attempts")
return False return False
# Verify the command was applied (if possible) # Verification disabled - it overwhelms devices with extra queries
if not self._verify_command(device_ip, device_name, param_name, param_value): # Trust that the command was applied if no error was returned
self.logger.warning(f"{device_name}: Verification failed for {param_name}")
# Don't return False here - some commands can't be verified
# Check if this is a rule definition - if so, enable it # Check if this is a rule definition - if so, enable it
if param_name.lower().startswith('rule'): if param_name.lower().startswith('rule'):
rule_number = param_name.lower().replace('rule', '') rule_number = param_name.lower().replace('rule', '')
if rule_number.isdigit(): if rule_number.isdigit():
# Use Rule{N} 4 to enable without setting Once flag # Wait for device to process the rule before enabling
# Rule 1 = Enable + Once ON (WRONG - causes single-fire issue) time.sleep(0.5)
# Rule 4 = Enable only (CORRECT - allows repeated firing)
enable_command = f"Rule{rule_number}%204" # 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)") self.logger.debug(f"{device_name}: Enabling rule{rule_number} (Once=OFF)")
result, success = send_tasmota_command( result, success = retry_command(
device_ip, enable_command, timeout=5, logger=self.logger 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: 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 time.sleep(0.3) # Brief delay between commands
return True return True
@ -230,9 +260,19 @@ class ConsoleSettingsManager:
return True # Can't verify, assume success return True # Can't verify, assume success
# Check if value matches # Check if value matches
current_value = result.get(param_name, '') current_value = str(result.get(param_name, ''))
expected = str(expected_value)
if str(current_value) == 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 True
return False return False

View File

@ -2,19 +2,22 @@
import logging import logging
from typing import Dict, List, Tuple, Optional from typing import Dict, List, Tuple, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
from utils import send_tasmota_command from utils import send_tasmota_command
class DeviceComparison: class DeviceComparison:
"""Compare configuration between two Tasmota devices.""" """Compare configuration between two Tasmota devices."""
def __init__(self, logger: Optional[logging.Logger] = None): def __init__(self, config: Optional[Dict] = None, logger: Optional[logging.Logger] = None):
""" """
Initialize device comparison. Initialize device comparison.
Args: Args:
config: Configuration dictionary (for comparing to config file)
logger: Optional logger instance logger: Optional logger instance
""" """
self.config = config
self.logger = logger or logging.getLogger(__name__) self.logger = logger or logging.getLogger(__name__)
def get_device_full_status(self, device_ip: str, device_name: str) -> Dict: def get_device_full_status(self, device_ip: str, device_name: str) -> Dict:
@ -87,6 +90,181 @@ class DeviceComparison:
return device_info 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, def compare_devices(self, device1_ip: str, device1_name: str,
device2_ip: str, device2_name: str) -> Dict: device2_ip: str, device2_name: str) -> Dict:
""" """
@ -103,9 +281,15 @@ class DeviceComparison:
""" """
self.logger.info(f"Comparing {device1_name} vs {device2_name}") self.logger.info(f"Comparing {device1_name} vs {device2_name}")
# Get full status from both devices # Get full status from both devices in parallel
device1 = self.get_device_full_status(device1_ip, device1_name) self.logger.info("Querying devices in parallel...")
device2 = self.get_device_full_status(device2_ip, device2_name)
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 # Compare and find differences
differences = { differences = {
@ -170,62 +354,56 @@ class DeviceComparison:
# Print firmware info # Print firmware info
print("\n" + "-" * 80) print("\n" + "-" * 80)
print("FIRMWARE DIFFERENCES") print("FIRMWARE DIFFERENCES")
print("-" * 80)
if comparison['firmware']: if comparison['firmware']:
self._print_differences(comparison['firmware'], device1['name'], device2['name']) self._print_differences(comparison['firmware'], device1['name'], device2['name'])
else: else:
print("No differences found") print("No differences found")
print() # Blank line after section
# Print network differences # Print network differences
print("\n" + "-" * 80)
print("NETWORK DIFFERENCES")
print("-" * 80) print("-" * 80)
print("NETWORK DIFFERENCES")
if comparison['network']: if comparison['network']:
self._print_differences(comparison['network'], device1['name'], device2['name']) self._print_differences(comparison['network'], device1['name'], device2['name'])
else: else:
print("No differences found") print("No differences found")
print() # Blank line after section
# Print MQTT differences # Print MQTT differences
print("\n" + "-" * 80)
print("MQTT DIFFERENCES")
print("-" * 80) print("-" * 80)
print("MQTT DIFFERENCES")
if comparison['mqtt']: if comparison['mqtt']:
self._print_differences(comparison['mqtt'], device1['name'], device2['name']) self._print_differences(comparison['mqtt'], device1['name'], device2['name'])
else: else:
print("No differences found") print("No differences found")
print() # Blank line after section
# Print SetOption differences # Print SetOption differences
print("\n" + "-" * 80)
print("SETOPTION DIFFERENCES")
print("-" * 80) print("-" * 80)
print("SETOPTION DIFFERENCES")
if comparison['setoptions']: if comparison['setoptions']:
self._print_differences(comparison['setoptions'], device1['name'], device2['name']) self._print_differences(comparison['setoptions'], device1['name'], device2['name'])
else: else:
print("No differences found") print("No differences found")
print() # Blank line after section
# Print Rule differences # Print Rule differences (special row format)
print("\n" + "-" * 80)
print("RULE DIFFERENCES")
print("-" * 80) print("-" * 80)
print("RULE DIFFERENCES")
if comparison['rules']: if comparison['rules']:
self._print_differences(comparison['rules'], device1['name'], device2['name']) self._print_rule_differences(comparison['rules'], device1['name'], device2['name'])
else: else:
print("No differences found") print("No differences found")
print() # Blank line after section
# Print other differences # Print other differences
print("\n" + "-" * 80)
print("OTHER CONFIGURATION DIFFERENCES")
print("-" * 80) print("-" * 80)
print("OTHER CONFIGURATION DIFFERENCES")
if comparison['other']: if comparison['other']:
self._print_differences(comparison['other'], device1['name'], device2['name']) self._print_differences(comparison['other'], device1['name'], device2['name'])
else: else:
print("No differences found") print("No differences found")
print() # Blank line after section
print("\n" + "=" * 80) print("\n" + "=" * 80)
print("END OF REPORT") print("END OF REPORT")
@ -233,18 +411,102 @@ class DeviceComparison:
def _print_differences(self, differences: List[Dict], device1_name: str, device2_name: str) -> None: def _print_differences(self, differences: List[Dict], device1_name: str, device2_name: str) -> None:
""" """
Print list of differences in readable format. Print list of differences in columnar format.
Args: Args:
differences: List of difference dictionaries differences: List of difference dictionaries
device1_name: First device name device1_name: First device name
device2_name: Second 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: for diff in differences:
key = diff['key'] 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'] val1 = diff['device1_value']
val2 = diff['device2_value'] val2 = diff['device2_value']
print(f"\n{key}:") print(f"\n{rule_num}:")
print(f" {device1_name:20} = {val1}")
print(f" {device2_name:20} = {val2}") # 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": { "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}", "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": { "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}", "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": { "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}", "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": { "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}", "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": { "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}", "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", "SetOption13 0",
"SetOption19 0", "SetOption19 0",
"SetOption32 8", "SetOption32 8",
"SetOption40 40", "SetOption40 0",
"SetOption53 1", "SetOption53 1",
"SetOption73 1", "SetOption73 1",
"rule1 on button1#state=10 do power0 toggle endon" "rule1 on button1#state=10 do power0 toggle endon"
@ -102,9 +102,19 @@
"SetOption13 0", "SetOption13 0",
"SetOption19 0", "SetOption19 0",
"SetOption32 8", "SetOption32 8",
"SetOption40 40", "SetOption40 0",
"SetOption53 1", "SetOption53 1",
"SetOption73 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"
] ]
} }
} }