From 142d82590913be8125e4bca00e3e485e712de5c6 Mon Sep 17 00:00:00 2001 From: Mike Geppert Date: Sun, 17 Aug 2025 16:47:03 -0500 Subject: [PATCH] Release V1.00 --- README.md | 11 +- TasmotaHostnameReport.json | 358 ++++++++++++++++++++++++++++ TasmotaManager.py | 472 ++++++++++++++++++++++++++++++------- pyproject.toml | 2 +- 4 files changed, 758 insertions(+), 85 deletions(-) create mode 100644 TasmotaHostnameReport.json diff --git a/README.md b/README.md index 016d18b..9cd7bc7 100644 --- a/README.md +++ b/README.md @@ -103,11 +103,18 @@ With options: python TasmotaManager.py --config custom_config.json --debug --skip-unifi ``` +Hostname report mode: +```bash +python TasmotaManager.py --unifi-hostname-report +# Saves JSON to TasmotaHostnameReport.json and prints a summary +``` + Command-line options: - `--config`: Path to configuration file (default: network_configuration.json) - `--debug`: Enable debug logging - `--skip-unifi`: Skip UniFi discovery and use existing current.json - `--process-unknown`: Process unknown devices (matching unknown_device_patterns) to set up names and MQTT +- `--unifi-hostname-report`: Generate a report comparing UniFi and Tasmota device hostnames - `--Device`: Process a single device by hostname or IP address ## Single Device Processing @@ -338,7 +345,7 @@ build-backend = "setuptools.build_meta" [project] name = "tasmota-manager" -version = "0.1.0" +version = "1.00" description = "Discover, monitor, and manage Tasmota devices via UniFi Controller." readme = "README.md" requires-python = ">=3.6" @@ -365,7 +372,7 @@ Build and install locally: - Install the build tool (once): `pip install build` - Build the distribution: `python -m build` - Artifacts will be placed in `dist/` (a .whl and a .tar.gz) -- Install the wheel: `pip install dist/tasmota_manager-0.1.0-py3-none-any.whl` +- Install the wheel: `pip install dist/tasmota_manager-1.00-py3-none-any.whl` - After install, run: `tasmota-manager --help` Optional: publish to PyPI diff --git a/TasmotaHostnameReport.json b/TasmotaHostnameReport.json new file mode 100644 index 0000000..e133ade --- /dev/null +++ b/TasmotaHostnameReport.json @@ -0,0 +1,358 @@ +{ + "generated_at": "2025-08-17T16:16:55.913762", + "total_tasmota_devices": 35, + "mismatch_count": 0, + "devices": [ + { + "ip": "192.168.8.184", + "mac": "a4:cf:12:ce:18:b0", + "unifi_name": "MCloset-6320", + "unifi_hostname": "MCloset-6320", + "device_hostname": "MCloset-6320", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Garage " + }, + { + "ip": "192.168.8.35", + "mac": "24:62:ab:15:db:84", + "unifi_name": "MBathFan2-7044", + "unifi_hostname": "MBathFan2-7044", + "device_hostname": "MBathFan2-7044", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Master Bedroom " + }, + { + "ip": "192.168.8.193", + "mac": "2c:f4:32:86:0c:bb", + "unifi_name": "HallGarage-3259", + "unifi_hostname": "HallGarage-3259", + "device_hostname": "HallGarage-3259", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.69", + "mac": "cc:50:e3:e7:75:3b", + "unifi_name": "OutdoorEntry-5435", + "unifi_hostname": "OutdoorEntry-5435", + "device_hostname": "OutdoorEntry-5435", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.146", + "mac": "d8:f1:5b:e7:f7:b2", + "unifi_name": "TheaterLamp-6066", + "unifi_hostname": "TheaterLamp-6066", + "device_hostname": "TheaterLamp-6066", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.224", + "mac": "60:01:94:fc:59:51", + "unifi_name": "LivingLamp-6481", + "unifi_hostname": "LivingLamp-6481", + "device_hostname": "LivingLamp-6481", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.211", + "mac": "2c:f4:32:86:05:9b", + "unifi_name": "KitchenPantry-1435", + "unifi_hostname": "KitchenPantry-1435", + "device_hostname": "KitchenPantry-1435", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.213", + "mac": "50:02:91:6c:fa:86", + "unifi_name": "LivingFan-6790", + "unifi_hostname": "LivingFan-6790", + "device_hostname": "LivingFan-6790", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.217", + "mac": "ec:fa:bc:b6:ed:c0", + "unifi_name": "BathLight-3520", + "unifi_hostname": "BathLight-3520", + "device_hostname": "BathLight-3520", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Bedroom " + }, + { + "ip": "192.168.8.61", + "mac": "50:02:91:6d:2a:9f", + "unifi_name": "BedCloset-2719", + "unifi_hostname": "BedCloset-2719", + "device_hostname": "BedCloset-2719", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Bedroom " + }, + { + "ip": "192.168.8.94", + "mac": "a4:cf:12:ce:c6:2d", + "unifi_name": "KitchenSink-1581", + "unifi_hostname": "KitchenSink-1581", + "device_hostname": "KitchenSink-1581", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.160", + "mac": "50:02:91:6c:f5:fb", + "unifi_name": "UtilLight-5627", + "unifi_hostname": "UtilLight-5627", + "device_hostname": "UtilLight-5627", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.42", + "mac": "a4:cf:12:ce:cb:3a", + "unifi_name": "MBathShower-2874", + "unifi_hostname": "MBathShower-2874", + "device_hostname": "MBathShower-2874", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Master Bedroom " + }, + { + "ip": "192.168.8.116", + "mac": "a4:cf:12:ce:7e:a5", + "unifi_name": "KitchenBar-7845", + "unifi_hostname": "KitchenBar-7845", + "device_hostname": "KitchenBar-7845", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.189", + "mac": "a4:cf:12:ce:7f:cc", + "unifi_name": "MasterLight-8140", + "unifi_hostname": "MasterLight-8140", + "device_hostname": "MasterLight-8140", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Master Bedroom " + }, + { + "ip": "192.168.8.208", + "mac": "a4:cf:12:ce:cb:e0", + "unifi_name": "KitchenMain-3040", + "unifi_hostname": "KitchenMain-3040", + "device_hostname": "KitchenMain-3040", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.84", + "mac": "d8:f1:5b:08:28:54", + "unifi_name": "TheaterMain-2132", + "unifi_hostname": "TheaterMain-2132", + "device_hostname": "TheaterMain-2132", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.113", + "mac": "50:02:91:6c:ff:63", + "unifi_name": "OfficeCloset-8035", + "unifi_hostname": "OfficeCloset-8035", + "device_hostname": "OfficeCloset-8035", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Office" + }, + { + "ip": "192.168.8.161", + "mac": "2c:f4:32:86:04:c0", + "unifi_name": "Dinning-1216", + "unifi_hostname": "Dinning-1216", + "device_hostname": "Dinning-1216", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.144", + "mac": "08:f9:e0:74:7b:f9", + "unifi_name": "LivingChina-7161", + "unifi_hostname": "LivingChina-7161", + "device_hostname": "LivingChina-7161", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.153", + "mac": "a4:cf:12:ce:7f:a8", + "unifi_name": "Garage-8104", + "unifi_hostname": "Garage-8104", + "device_hostname": "Garage-8104", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.237", + "mac": "50:02:91:6c:f5:5d", + "unifi_name": "UtilFan-5469", + "unifi_hostname": "UtilFan-5469", + "device_hostname": "UtilFan-5469", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.124", + "mac": "cc:50:e3:e7:6c:b9", + "unifi_name": "OutdoorBack-3257", + "unifi_hostname": "OutdoorBack-3257", + "device_hostname": "OutdoorBack-3257", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.196", + "mac": "ec:fa:bc:b6:eb:04", + "unifi_name": "MBathLight1-2820", + "unifi_hostname": "MBathLight1-2820", + "device_hostname": "MBathLight1-2820", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Master Bedroom " + }, + { + "ip": "192.168.8.106", + "mac": "2c:f4:32:86:05:67", + "unifi_name": "LivingLight-1383", + "unifi_hostname": "LivingLight-1383", + "device_hostname": "LivingLight-1383", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.112", + "mac": "cc:50:e3:e7:6c:ad", + "unifi_name": "OutdoorGarage-3245", + "unifi_hostname": "OutdoorGarage-3245", + "device_hostname": "OutdoorGarage-3245", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.101", + "mac": "24:62:ab:15:e1:e5", + "unifi_name": "BathShower-0485", + "unifi_hostname": "BathShower-0485", + "device_hostname": "BathShower-0485", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Office" + }, + { + "ip": "192.168.8.194", + "mac": "cc:50:e3:e7:6c:ff", + "unifi_name": "Hall-3327", + "unifi_hostname": "Hall-3327", + "device_hostname": "Hall-3327", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.48", + "mac": "08:f9:e0:74:8b:4f", + "unifi_name": "MasterLamp-2895", + "unifi_hostname": "MasterLamp-2895", + "device_hostname": "MasterLamp-2895", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Master Bedroom " + }, + { + "ip": "192.168.8.155", + "mac": "a4:cf:12:ce:20:6e", + "unifi_name": "MasterFan-0110", + "unifi_hostname": "MasterFan-0110", + "device_hostname": "MasterFan-0110", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Master Bedroom " + }, + { + "ip": "192.168.8.212", + "mac": "ec:fa:bc:56:d3:37", + "unifi_name": "BathFan-4919", + "unifi_hostname": "BathFan-4919", + "device_hostname": "BathFan-4919", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Office" + }, + { + "ip": "192.168.8.227", + "mac": "d8:f1:5b:bd:e5:ec", + "unifi_name": "BedLamp-1516", + "unifi_hostname": "BedLamp-1516", + "device_hostname": "BedLamp-1516", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + }, + { + "ip": "192.168.8.251", + "mac": "98:f4:ab:c9:a5:ee", + "unifi_name": "MBathSide-1518", + "unifi_hostname": "MBathSide-1518", + "device_hostname": "MBathSide-1518", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Master Bedroom " + }, + { + "ip": "192.168.8.218", + "mac": "50:02:91:6c:fa:8b", + "unifi_name": "OfficeLight-6795", + "unifi_hostname": "OfficeLight-6795", + "device_hostname": "OfficeLight-6795", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Office" + }, + { + "ip": "192.168.8.30", + "mac": "bc:dd:c2:b7:06:e4", + "unifi_name": "TheaterSide-1764", + "unifi_hostname": "TheaterSide-1764", + "device_hostname": "TheaterSide-1764", + "match": true, + "ssid": "Geppert_NoT", + "ap": "AP - Living Room" + } + ], + "mismatches": [] +} \ No newline at end of file diff --git a/TasmotaManager.py b/TasmotaManager.py index e5114a0..b8d8364 100755 --- a/TasmotaManager.py +++ b/TasmotaManager.py @@ -74,6 +74,24 @@ class UnifiClient: response.raise_for_status() return response.json().get('data', []) + def get_devices(self) -> list: + """Get UniFi network devices (e.g., Access Points) from the Controller.""" + # Try the newer API endpoint first + url = f"{self.base_url}/proxy/network/api/s/{self.site_id}/stat/device" + try: + response = self.session.get(url) + response.raise_for_status() + return response.json().get('data', []) + except requests.exceptions.RequestException: + # Try legacy endpoint + url = f"{self.base_url}/api/s/{self.site_id}/stat/device" + try: + response = self.session.get(url) + response.raise_for_status() + return response.json().get('data', []) + except requests.exceptions.RequestException: + return [] + class TasmotaDiscovery: def __init__(self, debug: bool = False): """Initialize the TasmotaDiscovery with optional debug mode.""" @@ -85,6 +103,11 @@ class TasmotaDiscovery: datefmt='%Y-%m-%d %H:%M:%S' ) self.logger = logging.getLogger(__name__) + # Redirect info logs to debug so all 'info' statements behave as debug-level + try: + self.logger.info = self.logger.debug + except Exception: + pass self.config = None self.unifi_client = None @@ -1039,58 +1062,112 @@ class TasmotaDiscovery: print(f"The config_other entry has a blank value for key: {device_name}") return False elif current_template != template_value: - # Template doesn't match, write value to template + # Template doesn't match, write value to template with retry and post-verification self.logger.info(f"{name}: Device name '{device_name}' matches key in config_other, but template doesn't match") self.logger.info(f"{name}: Setting template to: {template_value}") - - # URL encode the template value import urllib.parse encoded_value = urllib.parse.quote(template_value) - url = f"http://{ip}/cm?cmnd=Template%20{encoded_value}" - - try: - self.logger.debug(f"{name}: Setting template with 10 second timeout") - response = requests.get(url, timeout=10) - if response.status_code == 200: - self.logger.info(f"{name}: Template updated successfully") - + + max_attempts = 3 + last_error = None + for attempt in range(1, max_attempts + 1): + try: + url = f"http://{ip}/cm?cmnd=Template%20{encoded_value}" + self.logger.debug(f"{name}: Setting template (attempt {attempt}/{max_attempts})") + response = requests.get(url, timeout=10) + if response.status_code != 200: + last_error = f"HTTP {response.status_code}" + self.logger.warning(f"{name}: Failed to update template: {last_error}") + if attempt < max_attempts: + time.sleep(1) + continue + self.logger.info(f"{name}: Template command accepted") + # Activate the template by setting module to 0 (Template module) - self.logger.info(f"{name}: Activating template by setting module to 0") module_url = f"http://{ip}/cm?cmnd=Module%200" try: module_response = requests.get(module_url, timeout=10) - if module_response.status_code == 200: - self.logger.info(f"{name}: Module set to 0 successfully") - - # Restart the device to apply the template - self.logger.info(f"{name}: Restarting device to apply template") - restart_url = f"http://{ip}/cm?cmnd=Restart%201" - try: - restart_response = requests.get(restart_url, timeout=10) - if restart_response.status_code == 200: - self.logger.info(f"{name}: Device restart initiated successfully") - template_updated = True - else: - self.logger.error(f"{name}: Failed to restart device: HTTP {restart_response.status_code}") - except requests.exceptions.Timeout: - self.logger.error(f"{name}: Timeout restarting device (10 seconds)") - # Even though restart timed out, it might have worked - self.logger.info(f"{name}: Assuming restart was successful despite timeout") - template_updated = True - except requests.exceptions.RequestException as e: - self.logger.error(f"{name}: Error restarting device: {str(e)}") - else: - self.logger.error(f"{name}: Failed to set module to 0: HTTP {module_response.status_code}") - except requests.exceptions.Timeout: - self.logger.error(f"{name}: Timeout setting module to 0 (10 seconds)") + if module_response.status_code != 200: + last_error = f"HTTP {module_response.status_code}" + self.logger.warning(f"{name}: Failed to set module to 0: {last_error}") + if attempt < max_attempts: + time.sleep(1) + continue + self.logger.info(f"{name}: Module set to 0 successfully") except requests.exceptions.RequestException as e: - self.logger.error(f"{name}: Error setting module to 0: {str(e)}") - else: - self.logger.error(f"{name}: Failed to update template: HTTP {response.status_code}") - except requests.exceptions.Timeout: - self.logger.error(f"{name}: Timeout updating template (10 seconds)") - except requests.exceptions.RequestException as e: - self.logger.error(f"{name}: Error updating template: {str(e)}") + last_error = str(e) + self.logger.warning(f"{name}: Error setting module to 0: {last_error}") + if attempt < max_attempts: + time.sleep(1) + continue + + # Restart the device to apply the template + restart_url = f"http://{ip}/cm?cmnd=Restart%201" + try: + restart_response = requests.get(restart_url, timeout=10) + if restart_response.status_code != 200: + last_error = f"HTTP {restart_response.status_code}" + self.logger.warning(f"{name}: Failed to restart device: {last_error}") + else: + self.logger.info(f"{name}: Device restart initiated successfully") + except requests.exceptions.Timeout: + # Restart may time out due to reboot; log and proceed to verification + last_error = "Timeout" + self.logger.info(f"{name}: Restart timed out (device rebooting); proceeding to verification") + except requests.exceptions.RequestException as e: + last_error = str(e) + self.logger.warning(f"{name}: Error restarting device: {last_error}") + + # Post-update verification: poll the device for the new template + verified = False + for vtry in range(1, 4): + try: + # Wait a bit to let device come back + time.sleep(2 if vtry == 1 else 3) + vt_resp = requests.get(f"http://{ip}/cm?cmnd=Template", timeout=10) + vt_data = vt_resp.json() + # Extract template similarly to initial parse + new_template = "" + if "Template" in vt_data: + new_template = vt_data.get("Template", "") + elif isinstance(vt_data, dict) and len(vt_data) > 0: + first_key = next(iter(vt_data)) + if isinstance(vt_data[first_key], str) and "{" in vt_data[first_key]: + new_template = vt_data[first_key] + elif all(key in vt_data for key in ['NAME', 'GPIO', 'FLAG', 'BASE']): + new_template = json.dumps(vt_data) + if new_template == template_value: + self.logger.info(f"{name}: Template verification succeeded on attempt {vtry}") + template_updated = True + verified = True + break + except Exception as ve: + self.logger.debug(f"{name}: Template verification attempt {vtry} failed: {ve}") + if verified: + break + else: + last_error = last_error or "Verification failed" + self.logger.warning(f"{name}: Template verification failed (attempt {attempt}/{max_attempts})") + except requests.exceptions.Timeout: + last_error = "Timeout" + self.logger.warning(f"{name}: Timeout updating template (attempt {attempt}/{max_attempts})") + except requests.exceptions.RequestException as e: + last_error = str(e) + self.logger.warning(f"{name}: Error updating template: {last_error} (attempt {attempt}/{max_attempts})") + + if attempt < max_attempts: + time.sleep(1) + + if not template_updated: + # Track the failure for later reporting + if not hasattr(self, 'command_failures'): + self.command_failures = [] + self.command_failures.append({ + "device": name, + "ip": ip, + "command": "Template ", + "error": last_error + }) else: self.logger.debug(f"{name}: Device name '{device_name}' matches key in config_other and template matches value") else: @@ -1106,50 +1183,95 @@ class TasmotaDiscovery: self.logger.info(f"{name}: Template matches value for key '{matching_key}' in config_other") self.logger.info(f"{name}: Setting device name to: {matching_key}") - url = f"http://{ip}/cm?cmnd=DeviceName%20{matching_key}" - try: - self.logger.debug(f"{name}: Setting device name with 10 second timeout") - response = requests.get(url, timeout=10) - if response.status_code == 200: - self.logger.info(f"{name}: Device name updated successfully") - + max_attempts = 3 + last_error = None + for attempt in range(1, max_attempts + 1): + try: + url = f"http://{ip}/cm?cmnd=DeviceName%20{matching_key}" + self.logger.debug(f"{name}: Setting device name (attempt {attempt}/{max_attempts})") + response = requests.get(url, timeout=10) + if response.status_code != 200: + last_error = f"HTTP {response.status_code}" + self.logger.warning(f"{name}: Failed to update device name: {last_error}") + if attempt < max_attempts: + time.sleep(1) + continue + self.logger.info(f"{name}: Device name command accepted") + # Activate the template by setting module to 0 (Template module) - self.logger.info(f"{name}: Activating template by setting module to 0") module_url = f"http://{ip}/cm?cmnd=Module%200" try: module_response = requests.get(module_url, timeout=10) - if module_response.status_code == 200: - self.logger.info(f"{name}: Module set to 0 successfully") - - # Restart the device to apply the template - self.logger.info(f"{name}: Restarting device to apply template") - restart_url = f"http://{ip}/cm?cmnd=Restart%201" - try: - restart_response = requests.get(restart_url, timeout=10) - if restart_response.status_code == 200: - self.logger.info(f"{name}: Device restart initiated successfully") - template_updated = True - else: - self.logger.error(f"{name}: Failed to restart device: HTTP {restart_response.status_code}") - except requests.exceptions.Timeout: - self.logger.error(f"{name}: Timeout restarting device (10 seconds)") - # Even though restart timed out, it might have worked - self.logger.info(f"{name}: Assuming restart was successful despite timeout") - template_updated = True - except requests.exceptions.RequestException as e: - self.logger.error(f"{name}: Error restarting device: {str(e)}") - else: - self.logger.error(f"{name}: Failed to set module to 0: HTTP {module_response.status_code}") - except requests.exceptions.Timeout: - self.logger.error(f"{name}: Timeout setting module to 0 (10 seconds)") + if module_response.status_code != 200: + last_error = f"HTTP {module_response.status_code}" + self.logger.warning(f"{name}: Failed to set module to 0: {last_error}") + if attempt < max_attempts: + time.sleep(1) + continue + self.logger.info(f"{name}: Module set to 0 successfully") except requests.exceptions.RequestException as e: - self.logger.error(f"{name}: Error setting module to 0: {str(e)}") - else: - self.logger.error(f"{name}: Failed to update device name: HTTP {response.status_code}") - except requests.exceptions.Timeout: - self.logger.error(f"{name}: Timeout updating device name (10 seconds)") - except requests.exceptions.RequestException as e: - self.logger.error(f"{name}: Error updating device name: {str(e)}") + last_error = str(e) + self.logger.warning(f"{name}: Error setting module to 0: {last_error}") + if attempt < max_attempts: + time.sleep(1) + continue + + # Restart the device to apply the template + restart_url = f"http://{ip}/cm?cmnd=Restart%201" + try: + restart_response = requests.get(restart_url, timeout=10) + if restart_response.status_code != 200: + last_error = f"HTTP {restart_response.status_code}" + self.logger.warning(f"{name}: Failed to restart device: {last_error}") + else: + self.logger.info(f"{name}: Device restart initiated successfully") + except requests.exceptions.Timeout: + last_error = "Timeout" + self.logger.info(f"{name}: Restart timed out (device rebooting); proceeding to verification") + except requests.exceptions.RequestException as e: + last_error = str(e) + self.logger.warning(f"{name}: Error restarting device: {last_error}") + + # Post-update verification: poll Status 0 for the new device name + verified = False + for vtry in range(1, 4): + try: + time.sleep(2 if vtry == 1 else 3) + v_resp = requests.get(f"http://{ip}/cm?cmnd=Status%200", timeout=10) + v_data = v_resp.json() + new_name = v_data.get("Status", {}).get("DeviceName", "") + if new_name == matching_key: + self.logger.info(f"{name}: Device name verification succeeded on attempt {vtry}") + template_updated = True + verified = True + break + except Exception as ve: + self.logger.debug(f"{name}: Device name verification attempt {vtry} failed: {ve}") + if verified: + break + else: + last_error = last_error or "Verification failed" + self.logger.warning(f"{name}: Device name verification failed (attempt {attempt}/{max_attempts})") + except requests.exceptions.Timeout: + last_error = "Timeout" + self.logger.warning(f"{name}: Timeout updating device name (attempt {attempt}/{max_attempts})") + except requests.exceptions.RequestException as e: + last_error = str(e) + self.logger.warning(f"{name}: Error updating device name: {last_error} (attempt {attempt}/{max_attempts})") + + if attempt < max_attempts: + time.sleep(1) + + if not template_updated: + # Track the failure for later reporting + if not hasattr(self, 'command_failures'): + self.command_failures = [] + self.command_failures.append({ + "device": name, + "ip": ip, + "command": f"DeviceName {matching_key}", + "error": last_error + }) else: # No matches found, print detailed information about what's on the device self.logger.info(f"{name}: No matches found in config_other for either Device Name or Template") @@ -2082,6 +2204,9 @@ class TasmotaDiscovery: elif target_device.get('port') or target_device.get('switch_port') or target_device.get('switch'): connection = "Wired" + # Print the IP and Connection after verifying the device in UniFi + print(f"Verified in UniFi: IP: {device_ip} | Connection: {connection}") + # Create a device info dictionary # Add unifi_hostname_bug_detected flag to indicate when the UniFi OS hostname bug is detected # (when UniFi name matches unknown patterns but device's self-reported name doesn't) @@ -2388,6 +2513,181 @@ class TasmotaDiscovery: print("\n" + "="*80) + + def generate_unifi_hostname_report(self, timeout: int = 5, save_path: str = 'TasmotaHostnameReport.json', print_report: bool = True): + """Generate a report comparing UniFi-reported hostnames with Tasmota device hostnames. + Filters devices using network_filter (via is_tasmota_device). For each device, queries + the device to retrieve its self-reported hostname and compares it to the UniFi-reported + hostname or name. Saves a JSON report and optionally prints a human-readable summary. + """ + if not self.unifi_client: + # Attempt to set up UniFi client (requires self.config already loaded) + try: + self.setup_unifi_client() + except Exception as e: + self.logger.error(f"Cannot set up UniFi client: {e}") + return [] + + try: + all_clients = self.unifi_client.get_clients() + except Exception as e: + self.logger.error(f"Failed to retrieve clients from UniFi: {e}") + return [] + + # Build AP name maps to resolve human-friendly AP names + ap_mac_to_name = {} + bssid_to_name = {} + try: + ap_devices = self.unifi_client.get_devices() + for ap in ap_devices or []: + ap_name = ap.get('name') or ap.get('device_name') or ap.get('hostname') or '' + mac = (ap.get('mac') or '').lower() + if mac and ap_name: + ap_mac_to_name[mac] = ap_name + # Map BSSIDs from vap_table and radio_table to the AP name + vap_table = ap.get('vap_table') or [] + if isinstance(vap_table, dict): + vap_table = [vap_table] + for vap in vap_table: + b = (vap.get('bssid') or '').lower() + if b and ap_name: + bssid_to_name[b] = ap_name + radio_table = ap.get('radio_table') or [] + if isinstance(radio_table, dict): + radio_table = [radio_table] + for r in radio_table: + b = (r.get('bssid') or '').lower() + if b and ap_name: + bssid_to_name[b] = ap_name + except Exception as e: + self.logger.debug(f"Unable to fetch UniFi devices for AP mapping: {e}") + + report_entries = [] + mismatches = [] + total_considered = 0 + + for device in all_clients: + try: + if not self.is_tasmota_device(device): + continue + total_considered += 1 + + ip = device.get('ip', '') + mac = device.get('mac', '') + unifi_name = device.get('name') or device.get('hostname') or '' + unifi_hostname = device.get('hostname', '') or '' + + # Resolve Connected AP name using UniFi device map; SSID is not needed for output + ssid = device.get('essid') or device.get('ssid') or '' + raw_ap_name = device.get('ap_name') or device.get('ap') or '' + bssid = (device.get('bssid') or '').lower() + ap_mac = (device.get('ap_mac') or '').lower() + resolved_ap = '' + # Prefer mapping by ap_mac, then by bssid, then raw_ap_name, otherwise Unknown + if ap_mac and ap_mac in ap_mac_to_name: + resolved_ap = ap_mac_to_name.get(ap_mac, '') + elif bssid and bssid in bssid_to_name: + resolved_ap = bssid_to_name.get(bssid, '') + elif raw_ap_name: + resolved_ap = raw_ap_name + else: + resolved_ap = 'Unknown' + + device_hostname = '' + success = False + if ip: + try: + device_hostname, success = self.get_device_hostname(ip, unifi_name, timeout=timeout, log_level='info') + except Exception as e: + self.logger.debug(f"Error retrieving device hostname for {unifi_name} at {ip}: {e}") + + # Compare against UniFi's hostname if present; otherwise, fall back to name + unifi_compare = (unifi_hostname or unifi_name or '').strip() + match = bool(success) and device_hostname.strip().lower() == unifi_compare.lower() + + entry = { + 'ip': ip, + 'mac': mac, + 'unifi_name': unifi_name, + 'unifi_hostname': unifi_hostname, + 'device_hostname': device_hostname, + 'match': match, + 'ssid': ssid, + 'ap': resolved_ap + } + report_entries.append(entry) + if success and not match: + mismatches.append(entry) + except Exception as e: + self.logger.debug(f"Skipping device due to error: {e}") + continue + + # Save JSON report + try: + summary = { + 'generated_at': datetime.now().isoformat(), + 'total_tasmota_devices': total_considered, + 'mismatch_count': len(mismatches), + 'devices': report_entries, + 'mismatches': mismatches + } + with open(save_path, 'w') as f: + json.dump(summary, f, indent=2) + self.logger.info(f"Hostname report saved to {save_path}") + except Exception as e: + self.logger.error(f"Failed to save hostname report: {e}") + + if print_report: + sep = " | " # 3-char separator + def pad(text, width): + s = str(text) if text is not None else "" + if len(s) > width: + return s[:width] + return s.ljust(width) + def row(c1, c2, c3, c4): + return f"{pad(c1,20)}{sep}{pad(c2,20)}{sep}{pad(c3,15)}{sep}{pad(c4,20)}" + header_sep = ("-"*20) + " + " + ("-"*20) + " + " + ("-"*15) + " + " + ("-"*20) + print("\n" + "="*80) + print("Tasmota UniFi Hostname Report") + print("="*80) + print(f"Total Tasmota devices considered: {total_considered}") + print(f"Hostname mismatches: {len([d for d in report_entries if not d.get('match')])}") + matched = [d for d in report_entries if d.get('match')] + unknown = [d for d in report_entries if not d.get('match')] + def conn_value(d): + ap = (d.get('ap') or '').strip() + if not ap or ap.lower() == 'unknown': + return 'Unknown' + import re as _re + m = _re.match(r'^\s*ap\s*-\s*(.*)$', ap, flags=_re.IGNORECASE) + if m: + remainder = m.group(1).strip() + return f"AP - {remainder}" if remainder else 'AP -' + return f"AP - {ap}" + # Matched section + print("\nMatched devices:") + print(row("Hostname", "Device Hostname", "IP", "Conncted")) + print(header_sep) + for d in matched: + uni = d.get('unifi_hostname') or d.get('unifi_name') or '' + dev = d.get('device_hostname') or 'Unknown' + ip = d.get('ip') or '' + conn = conn_value(d) + print(row(uni, dev, ip, conn)) + # Unknown/Mismatched section + print("\nUnknown/Mismatched devices:") + print(row("Hostname", "Device Hostname", "IP", "Conncted")) + print(header_sep) + for d in unknown: + uni = d.get('unifi_hostname') or d.get('unifi_name') or '' + dev = d.get('device_hostname') or 'Unknown' + ip = d.get('ip') or '' + conn = conn_value(d) + print(row(uni, dev, ip, conn)) + print("="*80 + "\n") + + return report_entries + def main(): parser = argparse.ArgumentParser(description='Tasmota Device Manager') parser.add_argument('--config', default='network_configuration.json', @@ -2398,6 +2698,8 @@ def main(): help='Skip UniFi discovery and use existing current.json') parser.add_argument('--process-unknown', action='store_true', help='Process unknown devices (matching unknown_device_patterns) to set up names and MQTT') + parser.add_argument('--unifi-hostname-report', action='store_true', + help='Generate a report comparing UniFi and Tasmota device hostnames') parser.add_argument('--Device', help='Process a single device by hostname or IP address') @@ -2417,6 +2719,12 @@ def main(): discovery.load_config(args.config) try: + # Generate UniFi/Tasmota hostname report if requested + if args.unifi_hostname_report: + print("Generating UniFi/Tasmota hostname report...") + discovery.generate_unifi_hostname_report() + return 0 + # Process a single device if --Device parameter is provided if args.Device: print(f"Processing single device: {args.Device}") diff --git a/pyproject.toml b/pyproject.toml index cf92fe6..1ae3922 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "tasmota-manager" -version = "0.1.0" +version = "1.00" description = "Discover, monitor, and manage Tasmota devices via UniFi Controller." readme = "README.md" requires-python = ">=3.6"