diff --git a/TasmotaManager.py b/TasmotaManager.py index 371ec76..8ca304c 100644 --- a/TasmotaManager.py +++ b/TasmotaManager.py @@ -308,8 +308,8 @@ 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=2, metavar=('DEVICE1', 'DEVICE2'), - help='Compare two devices and show configuration differences') + 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() @@ -339,7 +339,11 @@ def main(): # Handle device comparison mode 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 devices = discovery.get_tasmota_devices() @@ -347,20 +351,37 @@ def main(): # Find device 1 device1 = find_device_by_identifier(devices, args.diff[0], logger) if not device1: - logger.error(f"Device 1 not found: {args.diff[0]}") + logger.error(f"Device not found: {args.diff[0]}") return 1 - # Find device 2 - 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 + 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 - # Compare devices - comparison = device_comp.compare_devices( - device1['ip'], device1['name'], - device2['ip'], device2['name'] - ) + 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) diff --git a/device_diff.py b/device_diff.py index 4d3e057..efa7237 100644 --- a/device_diff.py +++ b/device_diff.py @@ -9,13 +9,15 @@ from utils import send_tasmota_command class DeviceComparison: """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. 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: @@ -88,6 +90,166 @@ class DeviceComparison: 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 + 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 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: """