Release V1.00

This commit is contained in:
Mike Geppert 2025-08-17 16:47:03 -05:00
parent d4b29c2359
commit 142d825909
4 changed files with 758 additions and 85 deletions

View File

@ -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

358
TasmotaHostnameReport.json Normal file
View File

@ -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": []
}

View File

@ -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 <config_other>",
"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}")

View File

@ -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"