Release V1.00
This commit is contained in:
parent
d4b29c2359
commit
142d825909
11
README.md
11
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
|
||||
|
||||
358
TasmotaHostnameReport.json
Normal file
358
TasmotaHostnameReport.json
Normal 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": []
|
||||
}
|
||||
@ -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}")
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user