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
This commit is contained in:
Mike Geppert 2026-01-07 20:09:26 -06:00
parent 72b21bc838
commit 7f00bb8d7b
2 changed files with 198 additions and 15 deletions

View File

@ -308,8 +308,8 @@ 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()
@ -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,20 +351,37 @@ 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:
device2 = find_device_by_identifier(devices, args.diff[1], logger) # Single device: compare to config file
if not device2: # Get device type from Status
logger.error(f"Device 2 not found: {args.diff[1]}") from utils import send_tasmota_command
return 1 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 device_type = result.get('Status', {}).get('DeviceName', '')
comparison = device_comp.compare_devices( if not device_type:
device1['ip'], device1['name'], logger.error(f"Could not determine device type for {device1['name']}")
device2['ip'], device2['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 # Print report
device_comp.print_comparison_report(comparison) device_comp.print_comparison_report(comparison)

View File

@ -9,13 +9,15 @@ 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:
@ -88,6 +90,166 @@ 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
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, def compare_devices(self, device1_ip: str, device1_name: str,
device2_ip: str, device2_name: str) -> Dict: device2_ip: str, device2_name: str) -> Dict:
""" """