Compare commits
19 Commits
improve-di
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 41ab2e930b | |||
| e2a4d94fa7 | |||
| 929fe7c916 | |||
| 86ae4b44b1 | |||
| 8b830d138b | |||
| 145d31d829 | |||
| be95930cd1 | |||
| 12ebdbf3e9 | |||
| 70e0b038e6 | |||
| 137899cfc2 | |||
| 7f00bb8d7b | |||
| 72b21bc838 | |||
| c1eb707519 | |||
| 794eb4319b | |||
| 73acc41145 | |||
| 265fa33497 | |||
| 80b55b6b43 | |||
| c2800ce646 | |||
| 3ea2798857 |
@ -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']
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
269
device_diff.py
269
device_diff.py
@ -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,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:
|
||||||
"""
|
"""
|
||||||
@ -177,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")
|
||||||
@ -281,3 +452,61 @@ class DeviceComparison:
|
|||||||
val2 = val2[:57] + "..."
|
val2 = val2[:57] + "..."
|
||||||
|
|
||||||
print(f"{key:<{key_width}} {val1:<{val1_width}} {val2:<{val2_width}}")
|
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}}")
|
||||||
|
|||||||
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user