Refactor: Split TasmotaManager into modular structure
- Created modular Python files (main, utils, discovery, configuration, console_settings, unknown_devices, reporting, unifi_client) - Moved documentation files to docs/ - Moved data files to data/ - Removed old monolithic TasmotaManager.py and TasmotaManager_fixed.py - Updated .gitignore and pyproject.toml - All functionality preserved, command-line interface unchanged Version: 2.0.0
This commit is contained in:
parent
9764d28b04
commit
9c22168f79
55
.gitignore
vendored
55
.gitignore
vendored
@ -1,35 +1,54 @@
|
||||
# Python bytecode files
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Distribution / packaging
|
||||
dist/
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
# Virtual Environment
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Data files
|
||||
data/*.json
|
||||
data/temp/
|
||||
*.backup
|
||||
|
||||
# Configuration (keep example)
|
||||
network_configuration.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local configuration that might contain sensitive information
|
||||
#network_configuration.json
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Backup files
|
||||
*.backup
|
||||
|
||||
# Generated data files with sensitive network information
|
||||
current.json
|
||||
deprecated.json
|
||||
TasmotaDevices.json
|
||||
*.json.backup
|
||||
# Old/backup files
|
||||
TasmotaManager.py.bak
|
||||
TasmotaManager_fixed.py
|
||||
*.txt
|
||||
95
GitWorkflowRefactor.sh
Normal file
95
GitWorkflowRefactor.sh
Normal file
@ -0,0 +1,95 @@
|
||||
|
||||
#!/bin/bash
|
||||
# Git workflow script for refactoring
|
||||
|
||||
echo "TasmotaManager Refactoring - Git Workflow"
|
||||
echo "=========================================="
|
||||
|
||||
# Check if we're in a git repository
|
||||
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
echo "Error: Not a git repository"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for uncommitted changes
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "Warning: You have uncommitted changes"
|
||||
echo ""
|
||||
git status --short
|
||||
echo ""
|
||||
read -p "Do you want to commit these first? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
git add -A
|
||||
read -p "Enter commit message: " commit_msg
|
||||
git commit -m "$commit_msg"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Step 1: Creating backup branch..."
|
||||
current_branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
backup_branch="${current_branch}-pre-refactor-$(date +%Y%m%d)"
|
||||
git branch "$backup_branch"
|
||||
echo "Created backup branch: $backup_branch"
|
||||
|
||||
echo ""
|
||||
echo "Step 2: Running migration (dry run)..."
|
||||
python3 migrate_to_refactored.py --dry-run
|
||||
|
||||
echo ""
|
||||
read -p "Proceed with migration? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Migration cancelled"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Running migration..."
|
||||
python3 migrate_to_refactored.py
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Verifying refactoring..."
|
||||
if ! python3 verify_refactoring.py; then
|
||||
echo ""
|
||||
echo "Verification failed. Please review the errors."
|
||||
echo "You can restore from backup branch: git checkout $backup_branch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Step 5: Adding files to git..."
|
||||
git add -A
|
||||
|
||||
echo ""
|
||||
echo "Step 6: Showing changes..."
|
||||
git status
|
||||
|
||||
echo ""
|
||||
echo "Step 7: Committing refactoring..."
|
||||
git commit -m "Refactor: Split TasmotaManager into modular structure
|
||||
|
||||
- Created modular Python files (main, utils, discovery, etc.)
|
||||
- Moved documentation files to docs/
|
||||
- Moved data files to data/
|
||||
- Removed old monolithic TasmotaManager.py
|
||||
- Updated .gitignore and pyproject.toml
|
||||
- All functionality preserved, command-line interface unchanged
|
||||
|
||||
Version: 2.0.0
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Refactoring complete!"
|
||||
echo ""
|
||||
echo "Backup branch created: $backup_branch"
|
||||
echo "Current branch: $current_branch"
|
||||
echo ""
|
||||
echo "To push changes:"
|
||||
echo " git push origin $current_branch"
|
||||
echo ""
|
||||
echo "To restore from backup if needed:"
|
||||
echo " git checkout $backup_branch"
|
||||
echo "=========================================="
|
||||
@ -1,358 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
3028
TasmotaManager.py
3028
TasmotaManager.py
File diff suppressed because it is too large
Load Diff
@ -1,820 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import requests
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
import re # Import the regular expression module
|
||||
import time
|
||||
import argparse
|
||||
|
||||
# Disable SSL warnings
|
||||
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
|
||||
|
||||
class UnifiClient:
|
||||
def __init__(self, base_url, username, password, site_id, verify_ssl=True):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.site_id = site_id
|
||||
self.session = requests.Session()
|
||||
self.session.verify = verify_ssl
|
||||
|
||||
# Initialize cookie jar
|
||||
self.session.cookies.clear()
|
||||
|
||||
def _login(self) -> requests.Response: # Changed return type annotation
|
||||
"""Authenticate with the UniFi Controller."""
|
||||
login_url = f"{self.base_url}/api/auth/login"
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
payload = {
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"remember": False
|
||||
}
|
||||
try:
|
||||
response = self.session.post(
|
||||
login_url,
|
||||
json=payload,
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
if 'X-CSRF-Token' in response.headers:
|
||||
self.session.headers['X-CSRF-Token'] = response.headers['X-CSRF-Token']
|
||||
return response # Return the response object
|
||||
except requests.exceptions.RequestException as e:
|
||||
if hasattr(e, 'response') and e.response.status_code == 401:
|
||||
raise Exception("Authentication failed. Please verify your username and password.") from e
|
||||
raise
|
||||
|
||||
def get_clients(self) -> list:
|
||||
"""Get all clients from the UniFi Controller."""
|
||||
# Try the newer API endpoint first
|
||||
url = f"{self.base_url}/proxy/network/api/s/{self.site_id}/stat/sta"
|
||||
try:
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json().get('data', [])
|
||||
except requests.exceptions.RequestException as e:
|
||||
# If the newer endpoint fails, try the legacy endpoint
|
||||
url = f"{self.base_url}/api/s/{self.site_id}/stat/sta"
|
||||
try:
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json().get('data', [])
|
||||
except requests.exceptions.RequestException as e:
|
||||
# If both fail, try the v2 API endpoint
|
||||
url = f"{self.base_url}/v2/api/site/{self.site_id}/clients"
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json().get('data', [])
|
||||
|
||||
class TasmotaDiscovery:
|
||||
def __init__(self, debug: bool = False):
|
||||
"""Initialize the TasmotaDiscovery with optional debug mode."""
|
||||
log_level = logging.DEBUG if debug else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.config = None
|
||||
self.unifi_client = None
|
||||
|
||||
def load_config(self, config_path: Optional[str] = None) -> dict:
|
||||
"""Load configuration from JSON file."""
|
||||
if config_path is None:
|
||||
config_path = os.path.join(os.path.dirname(__file__), 'config.json')
|
||||
|
||||
self.logger.debug(f"Loading configuration from: {config_path}")
|
||||
try:
|
||||
with open(config_path, 'r') as config_file:
|
||||
self.config = json.load(config_file)
|
||||
self.logger.debug("Configuration loaded successfully from %s", config_path)
|
||||
return self.config
|
||||
except FileNotFoundError:
|
||||
self.logger.error(f"Configuration file not found at {config_path}")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError:
|
||||
self.logger.error("Invalid JSON in configuration file")
|
||||
sys.exit(1)
|
||||
|
||||
def setup_unifi_client(self):
|
||||
"""Set up the UniFi client with better error handling"""
|
||||
self.logger.debug("Setting up UniFi client")
|
||||
|
||||
if not self.config or 'unifi' not in self.config:
|
||||
raise ValueError("Missing UniFi configuration")
|
||||
|
||||
unifi_config = self.config['unifi']
|
||||
required_fields = ['host', 'username', 'password', 'site']
|
||||
missing_fields = [field for field in required_fields if field not in unifi_config]
|
||||
|
||||
if missing_fields:
|
||||
raise ValueError(f"Missing required UniFi configuration fields: {', '.join(missing_fields)}")
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Connecting to UniFi Controller at {unifi_config['host']}")
|
||||
self.unifi_client = UnifiClient(
|
||||
base_url=unifi_config['host'],
|
||||
username=unifi_config['username'],
|
||||
password=unifi_config['password'],
|
||||
site_id=unifi_config['site'],
|
||||
verify_ssl=False # Add this if using self-signed certificates
|
||||
)
|
||||
|
||||
# Test the connection by making a simple request
|
||||
response = self.unifi_client._login()
|
||||
if not response:
|
||||
raise ConnectionError(f"Failed to connect to UniFi controller: No response")
|
||||
|
||||
self.logger.debug("UniFi client setup successful")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error setting up UniFi client: {str(e)}")
|
||||
raise ConnectionError(f"Failed to connect to UniFi controller: {str(e)}")
|
||||
|
||||
def is_tasmota_device(self, device: dict) -> bool:
|
||||
"""Determine if a device is in the network_filter and not in exclude_patterns."""
|
||||
name = device.get('name', '').lower()
|
||||
hostname = device.get('hostname', '').lower()
|
||||
ip = device.get('ip', '')
|
||||
|
||||
# Check if device is in the configured network
|
||||
network_filters = self.config['unifi'].get('network_filter', {})
|
||||
for network in network_filters.values():
|
||||
if ip.startswith(network['subnet']):
|
||||
self.logger.debug(f"Checking device in network: {name} ({hostname}) IP: {ip}")
|
||||
|
||||
# Check exclusion patterns
|
||||
exclude_patterns = network.get('exclude_patterns', [])
|
||||
for pattern in exclude_patterns:
|
||||
pattern = pattern.lower()
|
||||
# Convert glob pattern to regex pattern
|
||||
pattern = pattern.replace('.', r'\.').replace('*', '.*')
|
||||
if re.match(f"^{pattern}$", name) or re.match(f"^{pattern}$", hostname):
|
||||
self.logger.debug(f"Excluding device due to pattern '{pattern}': {name} ({hostname})")
|
||||
return False
|
||||
|
||||
# Device is in the network and not excluded
|
||||
self.logger.debug(f"Found device in network: {name}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_tasmota_devices(self) -> list:
|
||||
"""Query UniFi controller and filter Tasmota devices."""
|
||||
devices = []
|
||||
self.logger.debug("Querying UniFi controller for devices")
|
||||
try:
|
||||
all_clients = self.unifi_client.get_clients()
|
||||
self.logger.debug(f"Found {len(all_clients)} total devices")
|
||||
|
||||
for device in all_clients:
|
||||
if self.is_tasmota_device(device):
|
||||
device_info = {
|
||||
"name": device.get('name', device.get('hostname', 'Unknown')),
|
||||
"ip": device.get('ip', ''),
|
||||
"mac": device.get('mac', ''),
|
||||
"last_seen": device.get('last_seen', ''),
|
||||
"hostname": device.get('hostname', ''),
|
||||
"notes": device.get('note', ''),
|
||||
}
|
||||
devices.append(device_info)
|
||||
|
||||
self.logger.debug(f"Found {len(devices)} Tasmota devices")
|
||||
return devices
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting devices from UniFi controller: {e}")
|
||||
return []
|
||||
|
||||
def save_tasmota_config(self, devices: list) -> None:
|
||||
"""Save Tasmota device information to a JSON file with device tracking."""
|
||||
filename = "current.json"
|
||||
self.logger.debug(f"Saving Tasmota configuration to {filename}")
|
||||
deprecated_filename = "deprecated.json"
|
||||
|
||||
current_devices = []
|
||||
deprecated_devices = []
|
||||
|
||||
# Load existing devices if file exists
|
||||
if os.path.exists(filename):
|
||||
try:
|
||||
with open(filename, 'r') as f:
|
||||
existing_config = json.load(f)
|
||||
current_devices = existing_config.get('tasmota', {}).get('devices', [])
|
||||
except json.JSONDecodeError:
|
||||
self.logger.error(f"Error reading {filename}, treating as empty")
|
||||
current_devices = []
|
||||
|
||||
# Load deprecated devices if file exists
|
||||
if os.path.exists(deprecated_filename):
|
||||
try:
|
||||
with open(deprecated_filename, 'r') as f:
|
||||
deprecated_config = json.load(f)
|
||||
deprecated_devices = deprecated_config.get('tasmota', {}).get('devices', [])
|
||||
except json.JSONDecodeError:
|
||||
self.logger.error(f"Error reading {deprecated_filename}, treating as empty")
|
||||
deprecated_devices = []
|
||||
|
||||
# Create new config
|
||||
new_devices = []
|
||||
moved_to_deprecated = []
|
||||
restored_from_deprecated = []
|
||||
removed_from_deprecated = []
|
||||
excluded_devices = []
|
||||
|
||||
# Check for excluded devices in current and deprecated lists
|
||||
network_filters = self.config['unifi'].get('network_filter', {})
|
||||
exclude_patterns = []
|
||||
for network in network_filters.values():
|
||||
exclude_patterns.extend(network.get('exclude_patterns', []))
|
||||
|
||||
# Function to check if device is excluded
|
||||
def is_device_excluded(device_name: str, hostname: str = '') -> bool:
|
||||
name = device_name.lower()
|
||||
hostname = hostname.lower()
|
||||
for pattern in exclude_patterns:
|
||||
pattern = pattern.lower()
|
||||
pattern = pattern.replace('.', r'\.').replace('*', '.*')
|
||||
if re.match(f"^{pattern}$", name) or re.match(f"^{pattern}$", hostname):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Process current devices
|
||||
for device in devices:
|
||||
device_name = device['name']
|
||||
device_hostname = device.get('hostname', '')
|
||||
device_ip = device['ip']
|
||||
device_mac = device['mac']
|
||||
|
||||
# Check if device should be excluded
|
||||
if is_device_excluded(device_name, device_hostname):
|
||||
print(f"Device {device_name} excluded by pattern - skipping")
|
||||
excluded_devices.append(device_name)
|
||||
continue
|
||||
|
||||
# Check in current devices
|
||||
existing_device = next((d for d in current_devices
|
||||
if d['name'] == device_name), None)
|
||||
|
||||
if existing_device:
|
||||
# Device exists, check if IP or MAC changed
|
||||
if existing_device['ip'] != device_ip or existing_device['mac'] != device_mac:
|
||||
moved_to_deprecated.append(existing_device)
|
||||
new_devices.append(device)
|
||||
print(f"Device {device_name} moved to deprecated (IP/MAC changed)")
|
||||
else:
|
||||
new_devices.append(existing_device) # Keep existing device
|
||||
else:
|
||||
# New device, check if it was in deprecated
|
||||
deprecated_device = next((d for d in deprecated_devices
|
||||
if d['name'] == device_name), None)
|
||||
if deprecated_device:
|
||||
removed_from_deprecated.append(device_name)
|
||||
print(f"Device {device_name} removed from deprecated (restored)")
|
||||
new_devices.append(device)
|
||||
print(f"Device {device_name} added to output file")
|
||||
|
||||
# Find devices that are no longer present
|
||||
current_names = {d['name'] for d in devices}
|
||||
for existing_device in current_devices:
|
||||
if existing_device['name'] not in current_names:
|
||||
if not is_device_excluded(existing_device['name'], existing_device.get('hostname', '')):
|
||||
moved_to_deprecated.append(existing_device)
|
||||
print(f"Device {existing_device['name']} moved to deprecated (no longer present)")
|
||||
|
||||
# Update deprecated devices list, excluding any excluded devices
|
||||
final_deprecated = []
|
||||
for device in deprecated_devices:
|
||||
if device['name'] not in removed_from_deprecated and not is_device_excluded(device['name'], device.get('hostname', '')):
|
||||
final_deprecated.append(device)
|
||||
elif is_device_excluded(device['name'], device.get('hostname', '')):
|
||||
print(f"Device {device['name']} removed from deprecated (excluded by pattern)")
|
||||
|
||||
final_deprecated.extend(moved_to_deprecated)
|
||||
|
||||
# Save new configuration
|
||||
config = {
|
||||
"tasmota": {
|
||||
"devices": new_devices,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"total_devices": len(new_devices)
|
||||
}
|
||||
}
|
||||
|
||||
# Save deprecated configuration
|
||||
deprecated_config = {
|
||||
"tasmota": {
|
||||
"devices": final_deprecated,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"total_devices": len(final_deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
# Backup existing file if it exists
|
||||
if os.path.exists(filename):
|
||||
try:
|
||||
backup_name = f"{filename}.backup"
|
||||
os.rename(filename, backup_name)
|
||||
self.logger.info(f"Created backup of existing configuration as {backup_name}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating backup: {e}")
|
||||
|
||||
# Save files
|
||||
try:
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(config, f, indent=4)
|
||||
with open(deprecated_filename, 'w') as f:
|
||||
json.dump(deprecated_config, f, indent=4)
|
||||
|
||||
self.logger.info(f"Successfully saved {len(new_devices)} Tasmota devices to {filename}")
|
||||
self.logger.info(f"Successfully saved {len(final_deprecated)} deprecated devices to {deprecated_filename}")
|
||||
|
||||
print("\nDevice Status Summary:")
|
||||
if excluded_devices:
|
||||
print("\nExcluded Devices:")
|
||||
for name in excluded_devices:
|
||||
print(f"- {name}")
|
||||
|
||||
if moved_to_deprecated:
|
||||
print("\nMoved to deprecated:")
|
||||
for device in moved_to_deprecated:
|
||||
print(f"- {device['name']}")
|
||||
|
||||
if removed_from_deprecated:
|
||||
print("\nRestored from deprecated:")
|
||||
for name in removed_from_deprecated:
|
||||
print(f"- {name}")
|
||||
|
||||
print("\nCurrent Tasmota Devices:")
|
||||
for device in new_devices:
|
||||
print(f"Name: {device['name']:<20} IP: {device['ip']:<15} MAC: {device['mac']}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving configuration: {e}")
|
||||
|
||||
def get_unknown_devices(self, use_current_json=True):
|
||||
"""Identify devices that match unknown_device_patterns from current.json."""
|
||||
self.logger.info("Identifying unknown devices for processing...")
|
||||
unknown_devices = []
|
||||
|
||||
try:
|
||||
source_file = 'current.json' if use_current_json else 'tasmota.json'
|
||||
with open(source_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
all_devices = data.get('tasmota', {}).get('devices', [])
|
||||
self.logger.debug(f"Loaded {len(all_devices)} devices from {source_file}")
|
||||
except FileNotFoundError:
|
||||
self.logger.error(f"{source_file} not found. Run discovery first.")
|
||||
return []
|
||||
except json.JSONDecodeError:
|
||||
self.logger.error(f"Invalid JSON format in {source_file}")
|
||||
return []
|
||||
|
||||
# Identify devices matching unknown_device_patterns
|
||||
network_filters = self.config['unifi'].get('network_filter', {})
|
||||
unknown_patterns = []
|
||||
for network in network_filters.values():
|
||||
unknown_patterns.extend(network.get('unknown_device_patterns', []))
|
||||
|
||||
for device in all_devices:
|
||||
name = device.get('name', '').lower()
|
||||
hostname = device.get('hostname', '').lower()
|
||||
|
||||
for pattern in unknown_patterns:
|
||||
pattern = pattern.lower()
|
||||
pattern = pattern.replace('.', r'\.').replace('*', '.*')
|
||||
if re.match(f"^{pattern}", name) or re.match(f"^{pattern}", hostname):
|
||||
self.logger.debug(f"Found unknown device: {name} ({hostname})")
|
||||
unknown_devices.append(device)
|
||||
break
|
||||
|
||||
self.logger.info(f"Found {len(unknown_devices)} unknown devices to process")
|
||||
return unknown_devices
|
||||
|
||||
def process_unknown_devices(self):
|
||||
"""Process unknown devices by checking for toggle button and configuring them.
|
||||
|
||||
This method:
|
||||
1. Identifies devices matching unknown_device_patterns
|
||||
2. Checks if each device has a toggle button (indicating it's a light/switch)
|
||||
3. Toggles the button at 1/2Hz while checking for hostname changes
|
||||
4. Prompts the user to enter a new name for the device in the console
|
||||
5. Once a name is entered, configures the device with the new hostname
|
||||
"""
|
||||
unknown_devices = self.get_unknown_devices()
|
||||
if not unknown_devices:
|
||||
self.logger.info("No unknown devices found to process")
|
||||
return
|
||||
|
||||
self.logger.info(f"Starting to process {len(unknown_devices)} unknown devices...")
|
||||
|
||||
for device in unknown_devices:
|
||||
name = device.get('name', 'Unknown')
|
||||
ip = device.get('ip')
|
||||
|
||||
if not ip:
|
||||
self.logger.warning(f"Skipping device {name} - no IP address")
|
||||
continue
|
||||
|
||||
self.logger.info(f"Processing unknown device: {name} at {ip}")
|
||||
|
||||
# Check if device has a toggle button
|
||||
try:
|
||||
# Get the main page to check for toggle button
|
||||
url = f"http://{ip}/"
|
||||
response = requests.get(url, timeout=5)
|
||||
|
||||
# Check if there's a toggle button in the response
|
||||
has_toggle = "toggle" in response.text.lower()
|
||||
|
||||
if has_toggle:
|
||||
self.logger.info(f"Device {name} has a toggle button, assuming it's a light switch or power plug")
|
||||
|
||||
# Start toggling at 1/2Hz
|
||||
original_hostname = device.get('hostname', '')
|
||||
toggle_state = False
|
||||
|
||||
# Temporarily disable all logging during toggling
|
||||
logging.disable(logging.CRITICAL)
|
||||
|
||||
try:
|
||||
# Clear console output and show prompt
|
||||
print("\n" + "="*50)
|
||||
print(f"DEVICE: {name} at IP: {ip}")
|
||||
print(f"Current hostname: {original_hostname}")
|
||||
print("="*50)
|
||||
print("The device is now toggling to help you identify it.")
|
||||
|
||||
# Start toggling in background while waiting for input
|
||||
import threading
|
||||
stop_toggle = threading.Event()
|
||||
|
||||
def toggle_device():
|
||||
toggle_state = False
|
||||
while not stop_toggle.is_set():
|
||||
toggle_state = not toggle_state
|
||||
toggle_cmd = "Power On" if toggle_state else "Power Off"
|
||||
toggle_url = f"http://{ip}/cm?cmnd={toggle_cmd}"
|
||||
try:
|
||||
requests.get(toggle_url, timeout=2)
|
||||
except:
|
||||
pass
|
||||
time.sleep(2.0) # 1/2Hz rate
|
||||
|
||||
# Start toggle thread
|
||||
toggle_thread = threading.Thread(target=toggle_device)
|
||||
toggle_thread.daemon = True
|
||||
toggle_thread.start()
|
||||
|
||||
# Prompt for new hostname
|
||||
print("\nPlease enter a new name for this device:")
|
||||
new_hostname = input("> ").strip()
|
||||
|
||||
# Stop toggling
|
||||
stop_toggle.set()
|
||||
toggle_thread.join(timeout=3)
|
||||
|
||||
if new_hostname and new_hostname != original_hostname:
|
||||
print(f"Setting new hostname to: {new_hostname}")
|
||||
else:
|
||||
print("No valid hostname entered, skipping device")
|
||||
new_hostname = ""
|
||||
|
||||
finally:
|
||||
# Re-enable logging
|
||||
logging.disable(logging.NOTSET)
|
||||
|
||||
# If a new hostname was entered, configure the device
|
||||
if new_hostname:
|
||||
self.logger.info(f"Configuring device with new hostname: {new_hostname}")
|
||||
self.configure_unknown_device(ip, new_hostname)
|
||||
else:
|
||||
self.logger.warning(f"No new hostname provided for {name}, skipping configuration")
|
||||
else:
|
||||
self.logger.info(f"Device {name} does not have a toggle button, skipping")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"Error connecting to {name} at {ip}: {str(e)}")
|
||||
|
||||
def configure_unknown_device(self, ip, hostname):
|
||||
"""Configure an unknown device with the given hostname and MQTT settings."""
|
||||
try:
|
||||
# Set Friendly Name
|
||||
friendly_name_url = f"http://{ip}/cm?cmnd=FriendlyName1%20{hostname}"
|
||||
response = requests.get(friendly_name_url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
self.logger.info(f"Set Friendly Name to {hostname}")
|
||||
else:
|
||||
self.logger.error(f"Failed to set Friendly Name to {hostname}")
|
||||
|
||||
# Enable MQTT if not already enabled
|
||||
mqtt_url = f"http://{ip}/cm?cmnd=SetOption3%20ON" # Enable MQTT
|
||||
response = requests.get(mqtt_url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
self.logger.info(f"Enabled MQTT for {hostname}")
|
||||
else:
|
||||
self.logger.error(f"Failed to enable MQTT for {hostname}")
|
||||
|
||||
# Configure MQTT settings
|
||||
mqtt_config = self.config.get('mqtt', {})
|
||||
if mqtt_config:
|
||||
# Get the base hostname (everything before the dash)
|
||||
hostname_base = hostname.split('-')[0] if '-' in hostname else hostname
|
||||
|
||||
mqtt_fields = {
|
||||
"MqttHost": mqtt_config.get('Host', ''),
|
||||
"MqttPort": mqtt_config.get('Port', 1883),
|
||||
"MqttUser": mqtt_config.get('User', ''),
|
||||
"MqttPassword": mqtt_config.get('Password', ''),
|
||||
"Topic": hostname_base if mqtt_config.get('Topic') == '%hostname_base%' else mqtt_config.get('Topic', ''),
|
||||
"FullTopic": mqtt_config.get('FullTopic', '%prefix%/%topic%/'),
|
||||
}
|
||||
|
||||
for setting, value in mqtt_fields.items():
|
||||
url = f"http://{ip}/cm?cmnd={setting}%20{value}"
|
||||
response = requests.get(url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
if setting != 'MqttPassword':
|
||||
self.logger.info(f"{hostname}: Set {setting} to {value}")
|
||||
else:
|
||||
self.logger.info(f"{hostname}: Set MQTT Password")
|
||||
else:
|
||||
self.logger.error(f"{hostname}: Failed to set {setting}")
|
||||
|
||||
# Save configuration (will reboot the device)
|
||||
save_url = f"http://{ip}/cm?cmnd=Restart%201"
|
||||
response = requests.get(save_url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
self.logger.info(f"Saved configuration and rebooted {hostname}")
|
||||
else:
|
||||
self.logger.error(f"Failed to save configuration for {hostname}")
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"Error configuring device at {ip}: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_device_details(self, use_current_json=True):
|
||||
"""Connect to each Tasmota device via HTTP, gather details and validate MQTT settings.
|
||||
Filters out devices matching unknown_device_patterns."""
|
||||
self.logger.info("Starting to gather detailed device information...")
|
||||
device_details = []
|
||||
|
||||
try:
|
||||
source_file = 'current.json' if use_current_json else 'tasmota.json'
|
||||
with open(source_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
all_devices = data.get('tasmota', {}).get('devices', [])
|
||||
self.logger.debug(f"Loaded {len(all_devices)} devices from {source_file}")
|
||||
except FileNotFoundError:
|
||||
self.logger.error(f"{source_file} not found. Run discovery first.")
|
||||
return
|
||||
except json.JSONDecodeError:
|
||||
self.logger.error(f"Invalid JSON format in {source_file}")
|
||||
return
|
||||
|
||||
# Filter out devices matching unknown_device_patterns
|
||||
devices = []
|
||||
network_filters = self.config['unifi'].get('network_filter', {})
|
||||
unknown_patterns = []
|
||||
for network in network_filters.values():
|
||||
unknown_patterns.extend(network.get('unknown_device_patterns', []))
|
||||
|
||||
for device in all_devices:
|
||||
name = device.get('name', '').lower()
|
||||
hostname = device.get('hostname', '').lower()
|
||||
|
||||
is_unknown = False
|
||||
for pattern in unknown_patterns:
|
||||
pattern = pattern.lower()
|
||||
pattern = pattern.replace('.', r'\.').replace('*', '.*')
|
||||
if re.match(f"^{pattern}", name) or re.match(f"^{pattern}", hostname):
|
||||
self.logger.debug(f"Skipping unknown device: {name} ({hostname})")
|
||||
is_unknown = True
|
||||
break
|
||||
|
||||
if not is_unknown:
|
||||
devices.append(device)
|
||||
|
||||
self.logger.debug(f"Processing {len(devices)} devices after filtering unknown devices")
|
||||
|
||||
mqtt_config = self.config.get('mqtt', {})
|
||||
if not mqtt_config:
|
||||
self.logger.error("MQTT configuration missing from config file")
|
||||
return
|
||||
|
||||
def check_mqtt_settings(ip, name, mqtt_status):
|
||||
"""Check and update MQTT settings if they don't match config"""
|
||||
# Get the base hostname (everything before the dash)
|
||||
hostname_base = name.split('-')[0] if '-' in name else name
|
||||
|
||||
mqtt_fields = {
|
||||
"Host": mqtt_config.get('Host', ''),
|
||||
"Port": mqtt_config.get('Port', 1883),
|
||||
"User": mqtt_config.get('User', ''),
|
||||
"Password": mqtt_config.get('Password', ''),
|
||||
"Topic": hostname_base if mqtt_config.get('Topic') == '%hostname_base%' else mqtt_config.get('Topic', ''),
|
||||
"FullTopic": mqtt_config.get('FullTopic', '%prefix%/%topic%/'),
|
||||
}
|
||||
|
||||
device_mqtt = mqtt_status.get('MqttHost', {})
|
||||
changes_needed = []
|
||||
force_password_update = False
|
||||
|
||||
# Check each MQTT setting
|
||||
if device_mqtt.get('Host') != mqtt_fields['Host']:
|
||||
changes_needed.append(('MqttHost', mqtt_fields['Host']))
|
||||
self.logger.debug(f"{name}: MQTT Host mismatch - Device: {device_mqtt.get('Host')}, Config: {mqtt_fields['Host']}")
|
||||
force_password_update = True
|
||||
|
||||
if device_mqtt.get('Port') != mqtt_fields['Port']:
|
||||
changes_needed.append(('MqttPort', mqtt_fields['Port']))
|
||||
self.logger.debug(f"{name}: MQTT Port mismatch - Device: {device_mqtt.get('Port')}, Config: {mqtt_fields['Port']}")
|
||||
force_password_update = True
|
||||
|
||||
if device_mqtt.get('User') != mqtt_fields['User']:
|
||||
changes_needed.append(('MqttUser', mqtt_fields['User']))
|
||||
self.logger.debug(f"{name}: MQTT User mismatch - Device: {device_mqtt.get('User')}, Config: {mqtt_fields['User']}")
|
||||
force_password_update = True
|
||||
|
||||
if device_mqtt.get('Topic') != mqtt_fields['Topic']:
|
||||
changes_needed.append(('Topic', mqtt_fields['Topic']))
|
||||
self.logger.debug(f"{name}: MQTT Topic mismatch - Device: {device_mqtt.get('Topic')}, Config: {mqtt_fields['Topic']}")
|
||||
force_password_update = True
|
||||
|
||||
if device_mqtt.get('FullTopic') != mqtt_fields['FullTopic']:
|
||||
changes_needed.append(('FullTopic', mqtt_fields['FullTopic']))
|
||||
self.logger.debug(f"{name}: MQTT FullTopic mismatch - Device: {device_mqtt.get('FullTopic')}, Config: {mqtt_fields['FullTopic']}")
|
||||
force_password_update = True
|
||||
|
||||
# Add password update if any MQTT setting changed or user was updated
|
||||
if force_password_update:
|
||||
changes_needed.append(('MqttPassword', mqtt_fields['Password']))
|
||||
self.logger.debug(f"{name}: MQTT Password will be updated")
|
||||
|
||||
# Check NoRetain setting - FIXED: Use the actual value from config with default of False
|
||||
no_retain = mqtt_config.get('NoRetain', False)
|
||||
if no_retain:
|
||||
changes_needed.append(('SetOption62', '1')) # 1 = No Retain
|
||||
else:
|
||||
changes_needed.append(('SetOption62', '0')) # 0 = Use Retain
|
||||
|
||||
# Apply changes if needed
|
||||
for setting, value in changes_needed:
|
||||
try:
|
||||
url = f"http://{ip}/cm?cmnd={setting}%20{value}"
|
||||
response = requests.get(url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
if setting != 'MqttPassword':
|
||||
self.logger.debug(f"{name}: Updated {setting} to {value}")
|
||||
else:
|
||||
self.logger.debug(f"{name}: Updated MQTT Password")
|
||||
else:
|
||||
self.logger.error(f"{name}: Failed to update {setting}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"{name}: Error updating {setting}: {str(e)}")
|
||||
|
||||
return len(changes_needed) > 0
|
||||
|
||||
for device in devices:
|
||||
if not isinstance(device, dict):
|
||||
self.logger.warning(f"Skipping invalid device entry: {device}")
|
||||
continue
|
||||
|
||||
name = device.get('name', 'Unknown')
|
||||
ip = device.get('ip')
|
||||
mac = device.get('mac')
|
||||
|
||||
if not ip:
|
||||
self.logger.warning(f"Skipping device {name} - no IP address")
|
||||
continue
|
||||
|
||||
self.logger.info(f"Checking device: {name} at {ip}")
|
||||
|
||||
try:
|
||||
# Get Status 2 for firmware version
|
||||
url_status = f"http://{ip}/cm?cmnd=Status%202"
|
||||
response = requests.get(url_status, timeout=5)
|
||||
status_data = response.json()
|
||||
|
||||
# Get Status 5 for network info
|
||||
url_network = f"http://{ip}/cm?cmnd=Status%205"
|
||||
response = requests.get(url_network, timeout=5)
|
||||
network_data = response.json()
|
||||
|
||||
# Get Status 6 for MQTT info
|
||||
url_mqtt = f"http://{ip}/cm?cmnd=Status%206"
|
||||
response = requests.get(url_mqtt, timeout=5)
|
||||
mqtt_data = response.json()
|
||||
|
||||
# Check and update MQTT settings if needed
|
||||
mqtt_updated = check_mqtt_settings(ip, name, mqtt_data)
|
||||
|
||||
device_detail = {
|
||||
"name": name,
|
||||
"ip": ip,
|
||||
"mac": mac,
|
||||
"version": status_data.get("StatusFWR", {}).get("Version", "Unknown"),
|
||||
"hostname": network_data.get("StatusNET", {}).get("Hostname", "Unknown"),
|
||||
"mqtt_status": "Updated" if mqtt_updated else "Verified",
|
||||
"last_checked": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"status": "online"
|
||||
}
|
||||
self.logger.info(f"Successfully got version for {name}: {device_detail['version']}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"Error connecting to {name} at {ip}: {str(e)}")
|
||||
device_detail = {
|
||||
"name": name,
|
||||
"ip": ip,
|
||||
"mac": mac,
|
||||
"version": "Unknown",
|
||||
"status": "offline",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
device_details.append(device_detail)
|
||||
time.sleep(0.5)
|
||||
|
||||
# Save all device details at once
|
||||
try:
|
||||
with open('TasmotaDevices.json', 'w') as f:
|
||||
json.dump(device_details, f, indent=2)
|
||||
self.logger.info(f"Device details saved to TasmotaDevices.json ({len(device_details)} devices)")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving device details: {e}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Tasmota Device Manager')
|
||||
parser.add_argument('--config', default='network_configuration.json',
|
||||
help='Path to configuration file')
|
||||
parser.add_argument('--debug', action='store_true',
|
||||
help='Enable debug logging')
|
||||
parser.add_argument('--skip-unifi', action='store_true',
|
||||
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')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set up logging
|
||||
log_level = logging.DEBUG if args.debug else logging.INFO
|
||||
logging.basicConfig(level=log_level,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S')
|
||||
|
||||
print("Starting Tasmota Device Discovery and Version Check...")
|
||||
|
||||
# Create TasmotaDiscovery instance
|
||||
discovery = TasmotaDiscovery(debug=args.debug)
|
||||
discovery.load_config(args.config)
|
||||
|
||||
try:
|
||||
if not args.skip_unifi:
|
||||
print("Step 1: Discovering Tasmota devices...")
|
||||
discovery.setup_unifi_client()
|
||||
tasmota_devices = discovery.get_tasmota_devices()
|
||||
discovery.save_tasmota_config(tasmota_devices)
|
||||
else:
|
||||
print("Skipping UniFi discovery, using existing current.json...")
|
||||
|
||||
print("\nStep 2: Getting detailed version information...")
|
||||
discovery.get_device_details(use_current_json=True)
|
||||
|
||||
if args.process_unknown:
|
||||
print("\nStep 3: Processing unknown devices...")
|
||||
discovery.process_unknown_devices()
|
||||
|
||||
print("\nProcess completed successfully!")
|
||||
print("- Device list saved to: current.json")
|
||||
print("- Detailed information saved to: TasmotaDevices.json")
|
||||
|
||||
except ConnectionError as e:
|
||||
print(f"Connection Error: {str(e)}")
|
||||
print("\nTrying to proceed with existing current.json...")
|
||||
try:
|
||||
discovery.get_device_details(use_current_json=True)
|
||||
print("\nSuccessfully retrieved device details from existing current.json")
|
||||
except Exception as inner_e:
|
||||
print(f"Error processing existing devices: {str(inner_e)}")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
if args.debug:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
260
configuration.py
Normal file
260
configuration.py
Normal file
@ -0,0 +1,260 @@
|
||||
"""Template and MQTT configuration management."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import json
|
||||
from typing import Dict, Optional, Tuple, List
|
||||
|
||||
from utils import send_tasmota_command, retry_command, get_hostname_base
|
||||
|
||||
|
||||
class ConfigurationManager:
|
||||
"""Handles template and MQTT configuration for Tasmota devices."""
|
||||
|
||||
def __init__(self, config: dict, logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize configuration manager.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.config = config
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def check_and_update_template(self, device: dict, device_details: dict) -> bool:
|
||||
"""
|
||||
Check and update device template if needed.
|
||||
|
||||
Args:
|
||||
device: Device info dictionary
|
||||
device_details: Detailed device information
|
||||
|
||||
Returns:
|
||||
bool: True if template was updated or already correct
|
||||
"""
|
||||
device_name = device.get('name', 'Unknown')
|
||||
device_ip = device.get('ip', '')
|
||||
|
||||
if not device_ip:
|
||||
self.logger.warning(f"{device_name}: No IP address available")
|
||||
return False
|
||||
|
||||
# Get hostname base for template matching
|
||||
hostname = device_details.get('StatusNET', {}).get('Hostname', device_name)
|
||||
hostname_base = get_hostname_base(hostname)
|
||||
|
||||
# Check if we have a template for this device
|
||||
device_list = self.config.get('device_list', {})
|
||||
template_config = None
|
||||
|
||||
for template_name, template_data in device_list.items():
|
||||
if hostname_base.lower() in template_name.lower():
|
||||
template_config = template_data
|
||||
self.logger.debug(f"{device_name}: Matched template '{template_name}'")
|
||||
break
|
||||
|
||||
if not template_config:
|
||||
self.logger.debug(f"{device_name}: No template match found for '{hostname_base}'")
|
||||
return True # No template to apply, consider it successful
|
||||
|
||||
expected_template = template_config.get('template')
|
||||
if not expected_template:
|
||||
self.logger.debug(f"{device_name}: Template config has no template string")
|
||||
return True
|
||||
|
||||
# Parse expected template
|
||||
try:
|
||||
expected_template_dict = json.loads(expected_template)
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"{device_name}: Invalid template JSON: {e}")
|
||||
return False
|
||||
|
||||
# Get current template
|
||||
current_template = device_details.get('StatusSTS', {}).get('Template')
|
||||
|
||||
if current_template == expected_template_dict:
|
||||
self.logger.debug(f"{device_name}: Template already correct")
|
||||
return True
|
||||
|
||||
# Apply template
|
||||
self.logger.info(f"{device_name}: Applying template")
|
||||
|
||||
# Send template command
|
||||
result, success = send_tasmota_command(
|
||||
device_ip, f"Template%20{expected_template}",
|
||||
timeout=10, logger=self.logger
|
||||
)
|
||||
|
||||
if not success:
|
||||
self.logger.error(f"{device_name}: Failed to set template")
|
||||
return False
|
||||
|
||||
# Wait a moment for template to be applied
|
||||
time.sleep(2)
|
||||
|
||||
# Send Module 0 to activate the template
|
||||
result, success = send_tasmota_command(
|
||||
device_ip, "Module%200",
|
||||
timeout=10, logger=self.logger
|
||||
)
|
||||
|
||||
if not success:
|
||||
self.logger.error(f"{device_name}: Failed to set Module 0")
|
||||
return False
|
||||
|
||||
self.logger.info(f"{device_name}: Template applied, restarting device")
|
||||
|
||||
# Restart device to apply changes
|
||||
send_tasmota_command(device_ip, "Restart%201", timeout=5, logger=self.logger)
|
||||
|
||||
# Wait for device to restart
|
||||
time.sleep(10)
|
||||
|
||||
# Verify template was applied
|
||||
result, success = send_tasmota_command(
|
||||
device_ip, "Status%200",
|
||||
timeout=10, logger=self.logger
|
||||
)
|
||||
|
||||
if success and result:
|
||||
new_template = result.get('StatusSTS', {}).get('Template')
|
||||
if new_template == expected_template_dict:
|
||||
self.logger.info(f"{device_name}: Template verified successfully")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"{device_name}: Template verification failed")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def configure_mqtt_settings(self, device: dict, device_details: dict) -> Tuple[bool, str]:
|
||||
"""
|
||||
Configure MQTT settings on a device.
|
||||
|
||||
Args:
|
||||
device: Device info dictionary
|
||||
device_details: Detailed device information
|
||||
|
||||
Returns:
|
||||
Tuple of (success, status_message)
|
||||
"""
|
||||
device_name = device.get('name', 'Unknown')
|
||||
device_ip = device.get('ip', '')
|
||||
|
||||
if not device_ip:
|
||||
return False, "No IP address"
|
||||
|
||||
mqtt_config = self.config.get('mqtt', {})
|
||||
|
||||
# Get hostname base for Topic substitution
|
||||
hostname = device_details.get('StatusNET', {}).get('Hostname', device_name)
|
||||
hostname_base = get_hostname_base(hostname)
|
||||
|
||||
# Get current MQTT settings
|
||||
current_mqtt = device_details.get('StatusMQT', {})
|
||||
|
||||
# Check if MQTT needs to be enabled
|
||||
mqtt_enabled = current_mqtt.get('MqttHost', '') != ''
|
||||
|
||||
if not mqtt_enabled:
|
||||
self.logger.info(f"{device_name}: Enabling MQTT")
|
||||
result, success = send_tasmota_command(
|
||||
device_ip, "SetOption3%201",
|
||||
timeout=5, logger=self.logger
|
||||
)
|
||||
if not success:
|
||||
return False, "Failed to enable MQTT"
|
||||
|
||||
# Build list of settings to update
|
||||
updates_needed = []
|
||||
|
||||
# Check each MQTT setting
|
||||
mqtt_host = mqtt_config.get('Host', '')
|
||||
if mqtt_host and current_mqtt.get('MqttHost', '') != mqtt_host:
|
||||
updates_needed.append(('MqttHost', mqtt_host))
|
||||
|
||||
mqtt_port = mqtt_config.get('Port', 1883)
|
||||
if current_mqtt.get('MqttPort', 0) != mqtt_port:
|
||||
updates_needed.append(('MqttPort', mqtt_port))
|
||||
|
||||
mqtt_user = mqtt_config.get('User', '')
|
||||
if mqtt_user and current_mqtt.get('MqttUser', '') != mqtt_user:
|
||||
updates_needed.append(('MqttUser', mqtt_user))
|
||||
|
||||
mqtt_password = mqtt_config.get('Password', '')
|
||||
# Note: Can't verify password from status, so always set it
|
||||
if mqtt_password:
|
||||
updates_needed.append(('MqttPassword', mqtt_password))
|
||||
|
||||
# Handle Topic with %hostname_base% substitution
|
||||
mqtt_topic = mqtt_config.get('Topic', '')
|
||||
if mqtt_topic:
|
||||
mqtt_topic = mqtt_topic.replace('%hostname_base%', hostname_base)
|
||||
if current_mqtt.get('Topic', '') != mqtt_topic:
|
||||
updates_needed.append(('Topic', mqtt_topic))
|
||||
|
||||
mqtt_full_topic = mqtt_config.get('FullTopic', '')
|
||||
if mqtt_full_topic and current_mqtt.get('FullTopic', '') != mqtt_full_topic:
|
||||
updates_needed.append(('FullTopic', mqtt_full_topic))
|
||||
|
||||
# Handle NoRetain (SetOption62)
|
||||
no_retain = mqtt_config.get('NoRetain', False)
|
||||
current_no_retain = current_mqtt.get('NoRetain', False)
|
||||
if no_retain != current_no_retain:
|
||||
updates_needed.append(('SetOption62', '1' if no_retain else '0'))
|
||||
|
||||
if not updates_needed:
|
||||
self.logger.debug(f"{device_name}: MQTT settings already correct")
|
||||
return True, "Already configured"
|
||||
|
||||
# Apply updates
|
||||
self.logger.info(f"{device_name}: Updating {len(updates_needed)} MQTT settings")
|
||||
|
||||
failed_updates = []
|
||||
for setting_name, setting_value in updates_needed:
|
||||
command = f"{setting_name}%20{setting_value}"
|
||||
|
||||
result, success = retry_command(
|
||||
lambda: send_tasmota_command(device_ip, command, timeout=5, logger=self.logger),
|
||||
max_attempts=3,
|
||||
delay=1.0,
|
||||
logger=self.logger,
|
||||
device_name=device_name
|
||||
)
|
||||
|
||||
if not success:
|
||||
failed_updates.append(setting_name)
|
||||
self.logger.warning(f"{device_name}: Failed to set {setting_name}")
|
||||
|
||||
if failed_updates:
|
||||
return False, f"Failed to set: {', '.join(failed_updates)}"
|
||||
|
||||
# Wait for settings to be applied
|
||||
time.sleep(2)
|
||||
|
||||
self.logger.info(f"{device_name}: MQTT settings updated successfully")
|
||||
return True, "Updated"
|
||||
|
||||
def get_device_details(self, device_ip: str, device_name: str = "Unknown") -> Optional[Dict]:
|
||||
"""
|
||||
Get detailed device information from Tasmota device.
|
||||
|
||||
Args:
|
||||
device_ip: Device IP address
|
||||
device_name: Device name for logging
|
||||
|
||||
Returns:
|
||||
dict: Device details or None if failed
|
||||
"""
|
||||
# Get Status 0 (all status info)
|
||||
result, success = send_tasmota_command(
|
||||
device_ip, "Status%200",
|
||||
timeout=10, logger=self.logger
|
||||
)
|
||||
|
||||
if not success or not result:
|
||||
self.logger.warning(f"{device_name}: Failed to get device details")
|
||||
return None
|
||||
|
||||
return result
|
||||
248
console_settings.py
Normal file
248
console_settings.py
Normal file
@ -0,0 +1,248 @@
|
||||
"""Console settings and parameter management."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from utils import send_tasmota_command, retry_command, get_hostname_base
|
||||
|
||||
|
||||
class ConsoleSettingsManager:
|
||||
"""Handles console parameter configuration for Tasmota devices."""
|
||||
|
||||
def __init__(self, config: dict, logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize console settings manager.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.config = config
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.command_failures = {} # Track failed commands by device
|
||||
|
||||
def apply_console_settings(self, device: dict, device_details: dict) -> Tuple[bool, str]:
|
||||
"""
|
||||
Apply console settings to a device.
|
||||
|
||||
Args:
|
||||
device: Device info dictionary
|
||||
device_details: Detailed device information
|
||||
|
||||
Returns:
|
||||
Tuple of (success, status_message)
|
||||
"""
|
||||
device_name = device.get('name', 'Unknown')
|
||||
device_ip = device.get('ip', '')
|
||||
|
||||
if not device_ip:
|
||||
return False, "No IP address"
|
||||
|
||||
# Get hostname base for template matching
|
||||
hostname = device_details.get('StatusNET', {}).get('Hostname', device_name)
|
||||
hostname_base = get_hostname_base(hostname)
|
||||
|
||||
# Find which console_set to use for this device
|
||||
console_set_name = self._get_console_set_name(hostname_base)
|
||||
|
||||
if not console_set_name:
|
||||
self.logger.debug(f"{device_name}: No console settings configured")
|
||||
return True, "No console settings"
|
||||
|
||||
# Get the console command list
|
||||
console_commands = self._get_console_commands(console_set_name)
|
||||
|
||||
if not console_commands:
|
||||
self.logger.debug(f"{device_name}: Console set '{console_set_name}' is empty")
|
||||
return True, "Empty console set"
|
||||
|
||||
self.logger.info(f"{device_name}: Applying {len(console_commands)} console settings from '{console_set_name}'")
|
||||
|
||||
# Apply each console command
|
||||
failed_commands = []
|
||||
|
||||
for command in console_commands:
|
||||
if not command or not command.strip():
|
||||
continue # Skip empty commands
|
||||
|
||||
success = self._apply_single_command(device_ip, device_name, command)
|
||||
|
||||
if not success:
|
||||
failed_commands.append(command)
|
||||
|
||||
# Track failures for summary
|
||||
if failed_commands:
|
||||
if device_name not in self.command_failures:
|
||||
self.command_failures[device_name] = []
|
||||
self.command_failures[device_name].extend(failed_commands)
|
||||
|
||||
if failed_commands:
|
||||
return False, f"Failed: {len(failed_commands)} commands"
|
||||
|
||||
self.logger.info(f"{device_name}: All console settings applied successfully")
|
||||
return True, "Applied"
|
||||
|
||||
def _get_console_set_name(self, hostname_base: str) -> Optional[str]:
|
||||
"""
|
||||
Get the console_set name for a device based on hostname.
|
||||
|
||||
Args:
|
||||
hostname_base: Base hostname of device
|
||||
|
||||
Returns:
|
||||
str: Console set name or None
|
||||
"""
|
||||
device_list = self.config.get('device_list', {})
|
||||
|
||||
for template_name, template_data in device_list.items():
|
||||
if hostname_base.lower() in template_name.lower():
|
||||
return template_data.get('console_set')
|
||||
|
||||
return None
|
||||
|
||||
def _get_console_commands(self, console_set_name: str) -> List[str]:
|
||||
"""
|
||||
Get console commands from a named console set.
|
||||
|
||||
Args:
|
||||
console_set_name: Name of the console set
|
||||
|
||||
Returns:
|
||||
list: List of console commands
|
||||
"""
|
||||
console_set = self.config.get('console_set', {})
|
||||
|
||||
if isinstance(console_set, dict):
|
||||
commands = console_set.get(console_set_name, [])
|
||||
if isinstance(commands, list):
|
||||
return commands
|
||||
|
||||
return []
|
||||
|
||||
def _apply_single_command(self, device_ip: str, device_name: str, command: str) -> bool:
|
||||
"""
|
||||
Apply a single console command to a device.
|
||||
|
||||
Args:
|
||||
device_ip: Device IP address
|
||||
device_name: Device name for logging
|
||||
command: Console command to apply
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
# Parse command into parameter and value
|
||||
parts = command.split(None, 1)
|
||||
if not parts:
|
||||
return True # Empty command, skip
|
||||
|
||||
param_name = parts[0]
|
||||
param_value = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
self.logger.debug(f"{device_name}: Setting {param_name} = {param_value}")
|
||||
|
||||
# Handle Retain parameters - set opposite first, then desired state
|
||||
if param_name.endswith('Retain'):
|
||||
opposite_value = 'Off' if param_value.lower() in ['on', '1', 'true'] else 'On'
|
||||
|
||||
# Set opposite first
|
||||
opposite_command = f"{param_name}%20{opposite_value}"
|
||||
result, success = send_tasmota_command(
|
||||
device_ip, opposite_command, timeout=5, logger=self.logger
|
||||
)
|
||||
|
||||
if not success:
|
||||
self.logger.warning(f"{device_name}: Failed to set {param_name} to opposite state")
|
||||
|
||||
time.sleep(0.5) # Brief delay between commands
|
||||
|
||||
# Send the actual command
|
||||
escaped_command = command.replace(' ', '%20')
|
||||
|
||||
result, success = retry_command(
|
||||
lambda: send_tasmota_command(device_ip, escaped_command, timeout=5, logger=self.logger),
|
||||
max_attempts=3,
|
||||
delay=1.0,
|
||||
logger=self.logger,
|
||||
device_name=device_name
|
||||
)
|
||||
|
||||
if not success:
|
||||
self.logger.error(f"{device_name}: Failed to set {param_name} after 3 attempts")
|
||||
return False
|
||||
|
||||
# Verify the command was applied (if possible)
|
||||
if not self._verify_command(device_ip, device_name, param_name, param_value):
|
||||
self.logger.warning(f"{device_name}: Verification failed for {param_name}")
|
||||
# Don't return False here - some commands can't be verified
|
||||
|
||||
# Check if this is a rule definition - if so, enable it
|
||||
if param_name.lower().startswith('rule'):
|
||||
rule_number = param_name.lower().replace('rule', '')
|
||||
if rule_number.isdigit():
|
||||
enable_command = f"Rule{rule_number}%201"
|
||||
|
||||
self.logger.debug(f"{device_name}: Enabling rule{rule_number}")
|
||||
|
||||
result, success = send_tasmota_command(
|
||||
device_ip, enable_command, timeout=5, logger=self.logger
|
||||
)
|
||||
|
||||
if not success:
|
||||
self.logger.warning(f"{device_name}: Failed to enable rule{rule_number}")
|
||||
|
||||
time.sleep(0.3) # Brief delay between commands
|
||||
return True
|
||||
|
||||
def _verify_command(self, device_ip: str, device_name: str,
|
||||
param_name: str, expected_value: str) -> bool:
|
||||
"""
|
||||
Verify a command was applied (where possible).
|
||||
|
||||
Args:
|
||||
device_ip: Device IP address
|
||||
device_name: Device name for logging
|
||||
param_name: Parameter name
|
||||
expected_value: Expected value
|
||||
|
||||
Returns:
|
||||
bool: True if verified or verification not possible
|
||||
"""
|
||||
# Only verify certain parameters
|
||||
verifiable = ['PowerOnState', 'SetOption']
|
||||
|
||||
if not any(param_name.startswith(v) for v in verifiable):
|
||||
return True # Can't verify, assume success
|
||||
|
||||
# Get current value
|
||||
result, success = send_tasmota_command(
|
||||
device_ip, param_name, timeout=5, logger=self.logger
|
||||
)
|
||||
|
||||
if not success or not result:
|
||||
return True # Can't verify, assume success
|
||||
|
||||
# Check if value matches
|
||||
current_value = result.get(param_name, '')
|
||||
|
||||
if str(current_value) == str(expected_value):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def print_failure_summary(self):
|
||||
"""Print summary of all command failures."""
|
||||
if not self.command_failures:
|
||||
return
|
||||
|
||||
self.logger.error("=" * 60)
|
||||
self.logger.error("COMMAND FAILURE SUMMARY")
|
||||
self.logger.error("=" * 60)
|
||||
|
||||
for device_name, failed_commands in self.command_failures.items():
|
||||
self.logger.error(f"\n{device_name}:")
|
||||
for cmd in failed_commands:
|
||||
self.logger.error(f" - {cmd}")
|
||||
|
||||
self.logger.error("=" * 60)
|
||||
@ -1,27 +0,0 @@
|
||||
Summary: MQTT Commands in Device Mode
|
||||
|
||||
Question: "When using the Device mode, are all of the MQTT commands being sent?"
|
||||
|
||||
Answer: Yes, all MQTT commands are being sent when using Device mode.
|
||||
|
||||
The code analysis shows that when using the --Device parameter:
|
||||
|
||||
1. The process_single_device method is called, which identifies the device and determines if it's a "normal" device or an "unknown" device (matching unknown_device_patterns).
|
||||
|
||||
2. For normal devices:
|
||||
- MQTT commands are sent through the get_device_details method
|
||||
- All MQTT settings are configured: Host, Port, User, Password, Topic, FullTopic
|
||||
- Console parameters including Retain settings and rules are also configured
|
||||
- Commands have retry logic with up to 3 attempts
|
||||
- Command failures are tracked and reported
|
||||
|
||||
3. For unknown devices:
|
||||
- MQTT commands are sent through the configure_unknown_device method
|
||||
- All the same MQTT settings are configured
|
||||
- Console parameters are also configured
|
||||
- The device is rebooted at the end to save the configuration
|
||||
- Commands do not have retry logic
|
||||
|
||||
The different handling between normal and unknown devices is by design, as unknown devices are being initially configured while normal devices are being verified/updated.
|
||||
|
||||
No code changes are needed as all MQTT commands are being properly sent in Device mode.
|
||||
281
discovery.py
Normal file
281
discovery.py
Normal file
@ -0,0 +1,281 @@
|
||||
"""Device discovery and filtering logic."""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
|
||||
from utils import match_pattern, send_tasmota_command, get_hostname_base, get_data_file_path, save_json_file
|
||||
from unifi_client import UnifiClient
|
||||
|
||||
|
||||
class TasmotaDiscovery:
|
||||
"""Handles discovery and filtering of Tasmota devices via UniFi."""
|
||||
|
||||
def __init__(self, config: dict, unifi_client: UnifiClient,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize discovery handler.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
unifi_client: Authenticated UniFi client
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.config = config
|
||||
self.unifi_client = unifi_client
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def is_tasmota_device(self, device: dict) -> bool:
|
||||
"""
|
||||
Check if a device should be considered a Tasmota device.
|
||||
|
||||
Args:
|
||||
device: Device dictionary from UniFi
|
||||
|
||||
Returns:
|
||||
bool: True if device matches network filter and is not excluded
|
||||
"""
|
||||
device_ip = device.get('ip', '')
|
||||
device_name = device.get('name', device.get('hostname', ''))
|
||||
|
||||
if not device_ip:
|
||||
return False
|
||||
|
||||
# Check if device is in any configured network
|
||||
network_filters = self.config.get('unifi', {}).get('network_filter', {})
|
||||
|
||||
for network_name, network_config in network_filters.items():
|
||||
subnet = network_config.get('subnet', '')
|
||||
|
||||
# Check if IP is in this subnet
|
||||
if not device_ip.startswith(subnet):
|
||||
continue
|
||||
|
||||
# Check if device is excluded
|
||||
if self.is_device_excluded(device, network_config):
|
||||
self.logger.debug(f"Device {device_name} ({device_ip}) is excluded")
|
||||
return False
|
||||
|
||||
# Device is in network and not excluded
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_device_excluded(self, device: dict, network_config: dict) -> bool:
|
||||
"""
|
||||
Check if a device matches any exclusion patterns.
|
||||
|
||||
Args:
|
||||
device: Device dictionary
|
||||
network_config: Network configuration with exclude_patterns
|
||||
|
||||
Returns:
|
||||
bool: True if device should be excluded
|
||||
"""
|
||||
device_name = device.get('name', '')
|
||||
device_hostname = device.get('hostname', '')
|
||||
exclude_patterns = network_config.get('exclude_patterns', [])
|
||||
|
||||
for pattern in exclude_patterns:
|
||||
if match_pattern(device_name, pattern) or match_pattern(device_hostname, pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_hostname_unknown(self, hostname: str, unknown_patterns: List[str]) -> bool:
|
||||
"""
|
||||
Check if a hostname matches unknown device patterns.
|
||||
|
||||
Args:
|
||||
hostname: Hostname to check
|
||||
unknown_patterns: List of patterns for unknown devices
|
||||
|
||||
Returns:
|
||||
bool: True if hostname matches any unknown pattern
|
||||
"""
|
||||
if not hostname:
|
||||
return False
|
||||
|
||||
for pattern in unknown_patterns:
|
||||
if match_pattern(hostname, pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_device_hostname(self, ip: str, device_name: str,
|
||||
timeout: int = 5, log_level: str = 'debug') -> Tuple[Optional[str], bool]:
|
||||
"""
|
||||
Get the self-reported hostname from a Tasmota device.
|
||||
|
||||
Args:
|
||||
ip: Device IP address
|
||||
device_name: Device name for logging
|
||||
timeout: Request timeout
|
||||
log_level: Logging level ('debug', 'info', etc.)
|
||||
|
||||
Returns:
|
||||
Tuple of (hostname, success)
|
||||
"""
|
||||
if log_level == 'debug':
|
||||
self.logger.debug(f"Getting self-reported hostname for {device_name} at {ip}")
|
||||
|
||||
result, success = send_tasmota_command(ip, "Status%205", timeout, self.logger)
|
||||
|
||||
if success and result:
|
||||
hostname = result.get('StatusNET', {}).get('Hostname')
|
||||
if hostname:
|
||||
if log_level == 'debug':
|
||||
self.logger.debug(f"Self-reported hostname: {hostname}")
|
||||
return hostname, True
|
||||
|
||||
return None, False
|
||||
|
||||
def get_tasmota_devices(self) -> List[Dict]:
|
||||
"""
|
||||
Query UniFi controller and filter Tasmota devices.
|
||||
|
||||
Returns:
|
||||
list: List of device info dictionaries
|
||||
"""
|
||||
devices = []
|
||||
self.logger.debug("Querying UniFi controller for devices")
|
||||
|
||||
try:
|
||||
all_clients = self.unifi_client.get_clients()
|
||||
self.logger.debug(f"Found {len(all_clients)} total devices")
|
||||
|
||||
# Get unknown device patterns
|
||||
network_filters = self.config['unifi'].get('network_filter', {})
|
||||
unknown_patterns = []
|
||||
for network in network_filters.values():
|
||||
unknown_patterns.extend(network.get('unknown_device_patterns', []))
|
||||
|
||||
for device in all_clients:
|
||||
if self.is_tasmota_device(device):
|
||||
# Determine connection type
|
||||
connection = "Unknown"
|
||||
if device.get('essid'):
|
||||
connection = f"Wireless - {device.get('essid')}"
|
||||
elif device.get('radio') or device.get('wifi'):
|
||||
connection = "Wireless"
|
||||
elif device.get('port') or device.get('switch_port') or device.get('switch'):
|
||||
connection = "Wired"
|
||||
|
||||
device_name = device.get('name', device.get('hostname', 'Unknown'))
|
||||
device_hostname = device.get('hostname', '')
|
||||
device_ip = device.get('ip', '')
|
||||
|
||||
# Check for UniFi hostname bug
|
||||
unifi_hostname_bug_detected = False
|
||||
device_reported_hostname = None
|
||||
|
||||
unifi_name_matches_unknown = (
|
||||
self.is_hostname_unknown(device_name, unknown_patterns) or
|
||||
self.is_hostname_unknown(device_hostname, unknown_patterns)
|
||||
)
|
||||
|
||||
if unifi_name_matches_unknown and device_ip:
|
||||
device_reported_hostname, success = self.get_device_hostname(
|
||||
device_ip, device_name, timeout=5
|
||||
)
|
||||
|
||||
if success:
|
||||
# Check if self-reported hostname matches unknown patterns
|
||||
device_hostname_base = device_reported_hostname.split('-')[0].lower()
|
||||
device_hostname_matches_unknown = self.is_hostname_unknown(
|
||||
device_hostname_base, unknown_patterns
|
||||
)
|
||||
|
||||
if not device_hostname_matches_unknown:
|
||||
unifi_hostname_bug_detected = True
|
||||
self.logger.info(
|
||||
f"UniFi OS hostname bug detected for {device_name}: "
|
||||
f"self-reported hostname '{device_reported_hostname}' "
|
||||
f"doesn't match unknown patterns"
|
||||
)
|
||||
|
||||
device_info = {
|
||||
"name": device_name,
|
||||
"ip": device_ip,
|
||||
"mac": device.get('mac', ''),
|
||||
"last_seen": device.get('last_seen', ''),
|
||||
"hostname": device_hostname,
|
||||
"notes": device.get('note', ''),
|
||||
"connection": connection,
|
||||
"unifi_hostname_bug_detected": unifi_hostname_bug_detected
|
||||
}
|
||||
devices.append(device_info)
|
||||
|
||||
self.logger.debug(f"Found {len(devices)} Tasmota devices")
|
||||
return devices
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting devices from UniFi controller: {e}")
|
||||
raise
|
||||
|
||||
def save_tasmota_config(self, devices: List[Dict], previous_data: Optional[Dict] = None):
|
||||
"""
|
||||
Save current devices and track changes.
|
||||
|
||||
Args:
|
||||
devices: List of current devices
|
||||
previous_data: Previously saved device data
|
||||
"""
|
||||
current_file = get_data_file_path('current.json')
|
||||
deprecated_file = get_data_file_path('deprecated.json')
|
||||
|
||||
# Save current devices
|
||||
save_json_file(current_file, devices, self.logger)
|
||||
|
||||
# Track deprecated devices
|
||||
if previous_data:
|
||||
current_ips = {d['ip'] for d in devices}
|
||||
deprecated = [d for d in previous_data if d.get('ip') not in current_ips]
|
||||
|
||||
if deprecated:
|
||||
self.logger.info(f"Found {len(deprecated)} deprecated devices")
|
||||
save_json_file(deprecated_file, deprecated, self.logger)
|
||||
|
||||
def get_unknown_devices(self, devices: List[Dict]) -> List[Dict]:
|
||||
"""
|
||||
Filter devices to find those matching unknown patterns.
|
||||
|
||||
Args:
|
||||
devices: List of all Tasmota devices
|
||||
|
||||
Returns:
|
||||
list: Devices matching unknown patterns
|
||||
"""
|
||||
network_filters = self.config['unifi'].get('network_filter', {})
|
||||
unknown_patterns = []
|
||||
for network in network_filters.values():
|
||||
unknown_patterns.extend(network.get('unknown_device_patterns', []))
|
||||
|
||||
unknown_devices = []
|
||||
for device in devices:
|
||||
device_name = device.get('name', '')
|
||||
device_hostname = device.get('hostname', '')
|
||||
|
||||
if (self.is_hostname_unknown(device_name, unknown_patterns) or
|
||||
self.is_hostname_unknown(device_hostname, unknown_patterns)):
|
||||
unknown_devices.append(device)
|
||||
|
||||
return unknown_devices
|
||||
|
||||
def is_ip_in_network_filter(self, ip: str) -> bool:
|
||||
"""
|
||||
Check if an IP address is in any configured network filter.
|
||||
|
||||
Args:
|
||||
ip: IP address to check
|
||||
|
||||
Returns:
|
||||
bool: True if IP is in a configured network
|
||||
"""
|
||||
network_filters = self.config.get('unifi', {}).get('network_filter', {})
|
||||
|
||||
for network_config in network_filters.values():
|
||||
subnet = network_config.get('subnet', '')
|
||||
if ip.startswith(subnet):
|
||||
return True
|
||||
|
||||
return False
|
||||
257
git_diff.txt
257
git_diff.txt
@ -1,257 +0,0 @@
|
||||
diff --git a/TasmotaManager.py b/TasmotaManager.py
|
||||
index dab1ef3..0d5accf 100644
|
||||
--- a/TasmotaManager.py
|
||||
+++ b/TasmotaManager.py
|
||||
@@ -547,10 +547,32 @@ class TasmotaDiscovery:
|
||||
self.logger.debug(f"{name}: No mqtt.config_other settings found in configuration")
|
||||
return False
|
||||
|
||||
- # Get Status 0 for device name from Configuration/Other page
|
||||
+ # Get Status 0 for device name from Configuration/Other page with increased timeout
|
||||
url_status0 = f"http://{ip}/cm?cmnd=Status%200"
|
||||
- response = requests.get(url_status0, timeout=5)
|
||||
- status0_data = response.json()
|
||||
+ try:
|
||||
+ self.logger.debug(f"{name}: Getting Status 0 with increased timeout (10 seconds)")
|
||||
+ response = requests.get(url_status0, timeout=10)
|
||||
+ status0_data = response.json()
|
||||
+
|
||||
+ # Log the actual response format for debugging
|
||||
+ self.logger.debug(f"{name}: Status 0 response: {status0_data}")
|
||||
+ except requests.exceptions.Timeout:
|
||||
+ self.logger.error(f"{name}: Timeout getting Status 0 (10 seconds) - device may be busy")
|
||||
+ # Try one more time with even longer timeout
|
||||
+ try:
|
||||
+ self.logger.debug(f"{name}: Retrying Status 0 with 20 second timeout")
|
||||
+ response = requests.get(url_status0, timeout=20)
|
||||
+ status0_data = response.json()
|
||||
+ self.logger.debug(f"{name}: Status 0 response on retry: {status0_data}")
|
||||
+ except requests.exceptions.Timeout:
|
||||
+ self.logger.error(f"{name}: Timeout getting Status 0 even with 20 second timeout")
|
||||
+ return False
|
||||
+ except requests.exceptions.RequestException as e:
|
||||
+ self.logger.error(f"{name}: Error getting Status 0 on retry: {str(e)}")
|
||||
+ return False
|
||||
+ except requests.exceptions.RequestException as e:
|
||||
+ self.logger.error(f"{name}: Error getting Status 0: {str(e)}")
|
||||
+ return False
|
||||
|
||||
# Extract device name from Status 0 response
|
||||
device_name = status0_data.get("Status", {}).get("DeviceName", "")
|
||||
@@ -560,13 +582,32 @@ class TasmotaDiscovery:
|
||||
|
||||
self.logger.debug(f"{name}: Device name from Configuration/Other page: {device_name}")
|
||||
|
||||
- # Get current template
|
||||
+ # Get current template with increased timeout
|
||||
url_template = f"http://{ip}/cm?cmnd=Template"
|
||||
- response = requests.get(url_template, timeout=5)
|
||||
- template_data = response.json()
|
||||
-
|
||||
- # Log the actual response format for debugging
|
||||
- self.logger.debug(f"{name}: Template response: {template_data}")
|
||||
+ try:
|
||||
+ self.logger.debug(f"{name}: Getting template with increased timeout (10 seconds)")
|
||||
+ response = requests.get(url_template, timeout=10)
|
||||
+ template_data = response.json()
|
||||
+
|
||||
+ # Log the actual response format for debugging
|
||||
+ self.logger.debug(f"{name}: Template response: {template_data}")
|
||||
+ except requests.exceptions.Timeout:
|
||||
+ self.logger.error(f"{name}: Timeout getting template (10 seconds) - device may be busy")
|
||||
+ # Try one more time with even longer timeout
|
||||
+ try:
|
||||
+ self.logger.debug(f"{name}: Retrying with 20 second timeout")
|
||||
+ response = requests.get(url_template, timeout=20)
|
||||
+ template_data = response.json()
|
||||
+ self.logger.debug(f"{name}: Template response on retry: {template_data}")
|
||||
+ except requests.exceptions.Timeout:
|
||||
+ self.logger.error(f"{name}: Timeout getting template even with 20 second timeout")
|
||||
+ return False
|
||||
+ except requests.exceptions.RequestException as e:
|
||||
+ self.logger.error(f"{name}: Error getting template on retry: {str(e)}")
|
||||
+ return False
|
||||
+ except requests.exceptions.RequestException as e:
|
||||
+ self.logger.error(f"{name}: Error getting template: {str(e)}")
|
||||
+ return False
|
||||
|
||||
# Extract current template - handle different response formats
|
||||
current_template = ""
|
||||
@@ -609,32 +650,49 @@ class TasmotaDiscovery:
|
||||
encoded_value = urllib.parse.quote(template_value)
|
||||
url = f"http://{ip}/cm?cmnd=Template%20{encoded_value}"
|
||||
|
||||
- response = requests.get(url, timeout=5)
|
||||
- if response.status_code == 200:
|
||||
- self.logger.info(f"{name}: Template updated successfully")
|
||||
-
|
||||
- # 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"
|
||||
- module_response = requests.get(module_url, timeout=5)
|
||||
-
|
||||
- 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"
|
||||
- restart_response = requests.get(restart_url, timeout=5)
|
||||
+ 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")
|
||||
|
||||
- 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")
|
||||
+ # 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)")
|
||||
+ 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 set module to 0")
|
||||
- else:
|
||||
- self.logger.error(f"{name}: Failed to update template")
|
||||
+ 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)}")
|
||||
else:
|
||||
self.logger.debug(f"{name}: Device name '{device_name}' matches key in config_other and template matches value")
|
||||
else:
|
||||
@@ -651,32 +709,49 @@ class TasmotaDiscovery:
|
||||
self.logger.info(f"{name}: Setting device name to: {matching_key}")
|
||||
|
||||
url = f"http://{ip}/cm?cmnd=DeviceName%20{matching_key}"
|
||||
- response = requests.get(url, timeout=5)
|
||||
- if response.status_code == 200:
|
||||
- self.logger.info(f"{name}: Device name updated successfully")
|
||||
-
|
||||
- # 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"
|
||||
- module_response = requests.get(module_url, timeout=5)
|
||||
-
|
||||
- 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"
|
||||
- restart_response = requests.get(restart_url, timeout=5)
|
||||
+ 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")
|
||||
|
||||
- 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")
|
||||
+ # 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)")
|
||||
+ 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 set module to 0")
|
||||
- else:
|
||||
- self.logger.error(f"{name}: Failed to update device name")
|
||||
+ 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)}")
|
||||
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")
|
||||
@@ -1108,14 +1183,9 @@ class TasmotaDiscovery:
|
||||
self.logger.info(f"{name}: Skipping {rule_enable_param} as it's already in config (uppercase version)")
|
||||
continue
|
||||
|
||||
- # Check if the lowercase version (rule1) is in the config
|
||||
- lowercase_rule_param = f"rule{rule_num}"
|
||||
- if lowercase_rule_param in console_params:
|
||||
- self.logger.info(f"{name}: Found lowercase {lowercase_rule_param} in config, will enable {rule_enable_param}")
|
||||
- # Don't continue - we want to enable the rule
|
||||
- else:
|
||||
- self.logger.info(f"{name}: No rule definition found in config, skipping auto-enable")
|
||||
- continue
|
||||
+ # If we're here, it means we found a rule definition earlier and added it to rules_to_enable
|
||||
+ # No need to check again if it's in console_params
|
||||
+ self.logger.info(f"{name}: Will enable {rule_enable_param} for rule definition found in config")
|
||||
else:
|
||||
# Simple check for any version of the rule enable command
|
||||
if any(p.lower() == rule_enable_param.lower() for p in console_params):
|
||||
diff --git a/network_configuration.json b/network_configuration.json
|
||||
index 2655006..32273e9 100644
|
||||
--- a/network_configuration.json
|
||||
+++ b/network_configuration.json
|
||||
@@ -44,6 +44,7 @@
|
||||
"PowerOnState": "3",
|
||||
"SetOption1": "0",
|
||||
"SetOption3": "1",
|
||||
+ "SetOption4": "1",
|
||||
"SetOption13": "0",
|
||||
"SetOption19": "0",
|
||||
"SetOption32": "8",
|
||||
309
main.py
Normal file
309
main.py
Normal file
@ -0,0 +1,309 @@
|
||||
"""Main entry point for TasmotaManager."""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from utils import load_json_file, ensure_data_directory, get_data_file_path, is_valid_ip, match_pattern
|
||||
from unifi_client import UnifiClient, AuthenticationError
|
||||
from discovery import TasmotaDiscovery
|
||||
from configuration import ConfigurationManager
|
||||
from console_settings import ConsoleSettingsManager
|
||||
from unknown_devices import UnknownDeviceProcessor
|
||||
from reporting import ReportGenerator
|
||||
|
||||
|
||||
def setup_logging(debug: bool = False) -> logging.Logger:
|
||||
"""
|
||||
Setup logging configuration.
|
||||
|
||||
Args:
|
||||
debug: Enable debug logging
|
||||
|
||||
Returns:
|
||||
Logger instance
|
||||
"""
|
||||
level = logging.DEBUG if debug else logging.INFO
|
||||
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
return logging.getLogger('TasmotaManager')
|
||||
|
||||
|
||||
def load_config(config_path: str, logger: logging.Logger) -> Optional[dict]:
|
||||
"""
|
||||
Load configuration file.
|
||||
|
||||
Args:
|
||||
config_path: Path to configuration file
|
||||
logger: Logger instance
|
||||
|
||||
Returns:
|
||||
Configuration dictionary or None
|
||||
"""
|
||||
config = load_json_file(config_path, logger)
|
||||
|
||||
if not config:
|
||||
logger.error(f"Failed to load configuration from {config_path}")
|
||||
return None
|
||||
|
||||
# Validate required sections
|
||||
required_sections = ['unifi', 'mqtt']
|
||||
for section in required_sections:
|
||||
if section not in config:
|
||||
logger.error(f"Configuration missing required section: {section}")
|
||||
return None
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def setup_unifi_client(config: dict, logger: logging.Logger) -> Optional[UnifiClient]:
|
||||
"""
|
||||
Setup UniFi client.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
logger: Logger instance
|
||||
|
||||
Returns:
|
||||
UnifiClient instance or None
|
||||
"""
|
||||
unifi_config = config.get('unifi', {})
|
||||
|
||||
try:
|
||||
client = UnifiClient(
|
||||
host=unifi_config['host'],
|
||||
username=unifi_config['username'],
|
||||
password=unifi_config['password'],
|
||||
site=unifi_config.get('site', 'default'),
|
||||
verify_ssl=False,
|
||||
logger=logger
|
||||
)
|
||||
return client
|
||||
|
||||
except AuthenticationError as e:
|
||||
logger.error(f"UniFi authentication failed: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to setup UniFi client: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def process_devices(devices: list, config_manager: ConfigurationManager,
|
||||
console_manager: ConsoleSettingsManager, logger: logging.Logger):
|
||||
"""
|
||||
Process all devices for configuration.
|
||||
|
||||
Args:
|
||||
devices: List of devices to process
|
||||
config_manager: Configuration manager instance
|
||||
console_manager: Console settings manager instance
|
||||
logger: Logger instance
|
||||
"""
|
||||
device_details_list = []
|
||||
stats = {'processed': 0, 'mqtt_updated': 0, 'console_updated': 0, 'failed': 0}
|
||||
|
||||
for device in devices:
|
||||
device_name = device.get('name', 'Unknown')
|
||||
device_ip = device.get('ip', '')
|
||||
|
||||
logger.info(f"\nProcessing: {device_name} ({device_ip})")
|
||||
|
||||
try:
|
||||
# Get device details
|
||||
device_details = config_manager.get_device_details(device_ip, device_name)
|
||||
|
||||
if not device_details:
|
||||
logger.warning(f"{device_name}: Could not get device details, skipping")
|
||||
stats['failed'] += 1
|
||||
continue
|
||||
|
||||
# Check and update template
|
||||
template_success = config_manager.check_and_update_template(device, device_details)
|
||||
|
||||
# Refresh device details after template update
|
||||
if template_success:
|
||||
device_details = config_manager.get_device_details(device_ip, device_name)
|
||||
|
||||
# Configure MQTT
|
||||
mqtt_success, mqtt_status = config_manager.configure_mqtt_settings(device, device_details)
|
||||
|
||||
if mqtt_success and mqtt_status == "Updated":
|
||||
stats['mqtt_updated'] += 1
|
||||
|
||||
# Apply console settings
|
||||
console_success, console_status = console_manager.apply_console_settings(device, device_details)
|
||||
|
||||
if console_success and console_status == "Applied":
|
||||
stats['console_updated'] += 1
|
||||
|
||||
# Save device details
|
||||
device_info = {
|
||||
**device,
|
||||
'mqtt_status': mqtt_status,
|
||||
'console_status': console_status,
|
||||
'firmware': device_details.get('StatusFWR', {}).get('Version', 'Unknown')
|
||||
}
|
||||
device_details_list.append(device_info)
|
||||
|
||||
stats['processed'] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{device_name}: Error during processing: {e}")
|
||||
stats['failed'] += 1
|
||||
|
||||
return device_details_list, stats
|
||||
|
||||
|
||||
def find_device_by_identifier(devices: list, identifier: str, logger: logging.Logger) -> Optional[dict]:
|
||||
"""
|
||||
Find a device by IP address or hostname.
|
||||
|
||||
Args:
|
||||
devices: List of devices
|
||||
identifier: IP address or hostname (with optional wildcards)
|
||||
logger: Logger instance
|
||||
|
||||
Returns:
|
||||
Device dictionary or None
|
||||
"""
|
||||
# Check if it's an IP address
|
||||
if is_valid_ip(identifier):
|
||||
for device in devices:
|
||||
if device.get('ip') == identifier:
|
||||
return device
|
||||
logger.error(f"No device found with IP: {identifier}")
|
||||
return None
|
||||
|
||||
# Search by hostname with pattern matching
|
||||
matches = []
|
||||
|
||||
for device in devices:
|
||||
device_name = device.get('name', '')
|
||||
device_hostname = device.get('hostname', '')
|
||||
|
||||
# Try exact match first
|
||||
if device_name.lower() == identifier.lower() or device_hostname.lower() == identifier.lower():
|
||||
return device
|
||||
|
||||
# Try pattern matching
|
||||
if match_pattern(device_name, identifier, match_entire_string=False) or \
|
||||
match_pattern(device_hostname, identifier, match_entire_string=False):
|
||||
matches.append(device)
|
||||
|
||||
if len(matches) == 0:
|
||||
logger.error(f"No device found matching: {identifier}")
|
||||
return None
|
||||
elif len(matches) == 1:
|
||||
return matches[0]
|
||||
else:
|
||||
logger.warning(f"Multiple devices match '{identifier}':")
|
||||
for device in matches:
|
||||
logger.warning(f" - {device.get('name')} ({device.get('ip')})")
|
||||
logger.info(f"Using first match: {matches[0].get('name')}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description='Tasmota Device Manager')
|
||||
parser.add_argument('--config', default='network_configuration.json',
|
||||
help='Path to configuration file')
|
||||
parser.add_argument('--debug', action='store_true',
|
||||
help='Enable debug logging')
|
||||
parser.add_argument('--skip-unifi', action='store_true',
|
||||
help='Skip UniFi discovery and use existing data')
|
||||
parser.add_argument('--process-unknown', action='store_true',
|
||||
help='Process unknown devices interactively')
|
||||
parser.add_argument('--unifi-hostname-report', action='store_true',
|
||||
help='Generate UniFi hostname comparison report')
|
||||
parser.add_argument('--Device', type=str,
|
||||
help='Process single device by IP or hostname')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
logger = setup_logging(args.debug)
|
||||
logger.info("TasmotaManager v2.0 starting")
|
||||
|
||||
# Ensure data directory exists
|
||||
ensure_data_directory()
|
||||
|
||||
# Load configuration
|
||||
config = load_config(args.config, logger)
|
||||
if not config:
|
||||
return 1
|
||||
|
||||
# Setup UniFi client
|
||||
unifi_client = setup_unifi_client(config, logger)
|
||||
if not unifi_client:
|
||||
return 1
|
||||
|
||||
# Create managers
|
||||
discovery = TasmotaDiscovery(config, unifi_client, logger)
|
||||
config_manager = ConfigurationManager(config, logger)
|
||||
console_manager = ConsoleSettingsManager(config, logger)
|
||||
unknown_processor = UnknownDeviceProcessor(config, config_manager, logger)
|
||||
report_gen = ReportGenerator(config, discovery, logger)
|
||||
|
||||
# Handle hostname report mode
|
||||
if args.unifi_hostname_report:
|
||||
report_gen.generate_unifi_hostname_report()
|
||||
return 0
|
||||
|
||||
# Get devices
|
||||
if args.skip_unifi:
|
||||
logger.info("Using existing device data")
|
||||
current_file = get_data_file_path('current.json')
|
||||
devices = load_json_file(current_file, logger)
|
||||
if not devices:
|
||||
logger.error("No existing device data found")
|
||||
return 1
|
||||
else:
|
||||
devices = discovery.get_tasmota_devices()
|
||||
|
||||
# Save device list
|
||||
previous_data = load_json_file(get_data_file_path('current.json'), logger)
|
||||
discovery.save_tasmota_config(devices, previous_data)
|
||||
|
||||
# Handle single device mode
|
||||
if args.Device:
|
||||
device = find_device_by_identifier(devices, args.Device, logger)
|
||||
if not device:
|
||||
return 1
|
||||
devices = [device]
|
||||
|
||||
# Handle unknown device processing
|
||||
if args.process_unknown:
|
||||
unknown_devices = discovery.get_unknown_devices(devices)
|
||||
unknown_processor.process_unknown_devices(unknown_devices)
|
||||
return 0
|
||||
|
||||
# Process all devices
|
||||
logger.info(f"\nProcessing {len(devices)} devices...")
|
||||
device_details_list, stats = process_devices(devices, config_manager, console_manager, logger)
|
||||
|
||||
# Save device details
|
||||
report_gen.save_device_details(device_details_list)
|
||||
|
||||
# Print summaries
|
||||
report_gen.print_processing_summary(
|
||||
stats['processed'],
|
||||
stats['mqtt_updated'],
|
||||
stats['console_updated'],
|
||||
stats['failed']
|
||||
)
|
||||
|
||||
console_manager.print_failure_summary()
|
||||
|
||||
logger.info("TasmotaManager completed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
116
migrate_to_refactored.py
Normal file
116
migrate_to_refactored.py
Normal file
@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migration script to organize files into the new refactored structure."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
def ensure_dir(path):
|
||||
"""Ensure directory exists."""
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
def move_file(src, dst, dry_run=False):
|
||||
"""Move a file if it exists."""
|
||||
if os.path.exists(src):
|
||||
if dry_run:
|
||||
print(f"Would move: {src} -> {dst}")
|
||||
else:
|
||||
ensure_dir(os.path.dirname(dst))
|
||||
shutil.move(src, dst)
|
||||
print(f"Moved: {src} -> {dst}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_file(path, dry_run=False):
|
||||
"""Delete a file if it exists."""
|
||||
if os.path.exists(path):
|
||||
if dry_run:
|
||||
print(f"Would delete: {path}")
|
||||
else:
|
||||
os.remove(path)
|
||||
print(f"Deleted: {path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Run migration."""
|
||||
dry_run = '--dry-run' in sys.argv
|
||||
|
||||
if dry_run:
|
||||
print("DRY RUN MODE - No files will be moved or deleted\n")
|
||||
|
||||
print("TasmotaManager Migration Script")
|
||||
print("=" * 60)
|
||||
|
||||
# Ensure directories exist
|
||||
print("\n1. Creating directories...")
|
||||
ensure_dir('data')
|
||||
ensure_dir('data/temp')
|
||||
ensure_dir('docs')
|
||||
ensure_dir('tests')
|
||||
|
||||
# Move documentation files to docs/
|
||||
print("\n2. Moving documentation files to docs/...")
|
||||
doc_files = [
|
||||
'CONSOLE_COMMANDS.md',
|
||||
'KNOWN_ISSUES.md',
|
||||
'blank_template_value_handling.md',
|
||||
'console_settings_optimization.md',
|
||||
'GITLAB_MIGRATION.md',
|
||||
'rule1_device_mode_verification.md',
|
||||
'self_reported_hostname_locations.md',
|
||||
'is_device_excluded_implementation.py'
|
||||
]
|
||||
|
||||
for doc_file in doc_files:
|
||||
move_file(doc_file, f'docs/{doc_file}', dry_run)
|
||||
|
||||
# Move data files to data/
|
||||
print("\n3. Moving data files to data/...")
|
||||
data_files = [
|
||||
'current.json',
|
||||
'current.json.backup',
|
||||
'deprecated.json',
|
||||
'TasmotaDevices.json',
|
||||
'TasmotaHostnameReport.json',
|
||||
'device_mode_mqtt_summary.txt',
|
||||
'mqtt_device_mode_analysis.txt',
|
||||
'git_diff.txt'
|
||||
]
|
||||
|
||||
for data_file in data_files:
|
||||
move_file(data_file, f'data/{data_file}', dry_run)
|
||||
|
||||
# Delete old Python files (assuming they're committed to git)
|
||||
print("\n4. Removing old Python files...")
|
||||
old_files = [
|
||||
'TasmotaManager.py',
|
||||
'TasmotaManager_fixed.py'
|
||||
]
|
||||
|
||||
for old_file in old_files:
|
||||
delete_file(old_file, dry_run)
|
||||
|
||||
# Delete temporary migration scripts
|
||||
print("\n5. Removing temporary migration files...")
|
||||
temp_files = [
|
||||
'file_migration_script.py',
|
||||
'refactoring_verification.py'
|
||||
]
|
||||
|
||||
for temp_file in temp_files:
|
||||
delete_file(temp_file, dry_run)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
if dry_run:
|
||||
print("DRY RUN COMPLETE - Run without --dry-run to apply changes")
|
||||
else:
|
||||
print("MIGRATION COMPLETE!")
|
||||
print("\nNext steps:")
|
||||
print("1. Test the new modules: python main.py --help")
|
||||
print("2. Commit the changes: git add -A && git commit -m 'Refactor: Split into modular structure'")
|
||||
print("3. The old TasmotaManager.py is in git history if you need it")
|
||||
print("=" * 60)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -1,31 +0,0 @@
|
||||
MQTT Command Handling in Device Mode Analysis
|
||||
|
||||
When using the --Device parameter to process a single device, the code follows these paths:
|
||||
|
||||
1. For normal devices (not matching unknown_device_patterns):
|
||||
- The process_single_device method creates a temporary current.json with just the target device
|
||||
- It then calls get_device_details(use_current_json=True)
|
||||
- get_device_details loads the device from current.json, filters out unknown devices, and processes the remaining devices
|
||||
- For each device, it sends MQTT commands to configure MQTT settings (Host, Port, User, Password, Topic, FullTopic)
|
||||
- It also sends commands to configure console parameters, including Retain settings and rules
|
||||
- All commands have retry logic with up to 3 attempts
|
||||
|
||||
2. For unknown devices (matching unknown_device_patterns):
|
||||
- The process_single_device method identifies the device as unknown
|
||||
- It then calls configure_unknown_device
|
||||
- configure_unknown_device sets the Friendly Name, enables MQTT, and configures MQTT settings
|
||||
- It also configures console parameters, including Retain settings and rules
|
||||
- Finally, it reboots the device to save the configuration
|
||||
- Commands do not have retry logic
|
||||
|
||||
Conclusion:
|
||||
All MQTT commands are being sent in Device mode, but there are two different paths depending on whether the device matches an unknown_device_pattern:
|
||||
1. Normal devices: Processed by get_device_details with retry logic
|
||||
2. Unknown devices: Processed by configure_unknown_device without retry logic, and the device is rebooted
|
||||
|
||||
The main differences are:
|
||||
1. Retry logic: Only normal devices have retry logic for commands
|
||||
2. Device reboot: Only unknown devices are rebooted
|
||||
3. Command failure tracking: Only normal devices track command failures for reporting
|
||||
|
||||
These differences are by design, as unknown devices are being initially configured while normal devices are being verified/updated.
|
||||
@ -1,30 +1,47 @@
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools>=64",
|
||||
"wheel"
|
||||
]
|
||||
requires = ["setuptools>=64", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tasmota-manager"
|
||||
version = "1.00"
|
||||
version = "2.0.0"
|
||||
description = "Discover, monitor, and manage Tasmota devices via UniFi Controller."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.6"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "TasmotaManager Contributors" }
|
||||
{ name = "TasmotaManager Contributors" }
|
||||
]
|
||||
dependencies = [
|
||||
"requests",
|
||||
"urllib3"
|
||||
"requests",
|
||||
"urllib3"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
# After installation, users can run `tasmota-manager` from the shell
|
||||
# which calls the main() function inside TasmotaManager.py
|
||||
"tasmota-manager" = "TasmotaManager:main"
|
||||
tasmota-manager = "main:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov",
|
||||
"black",
|
||||
"flake8",
|
||||
"mypy"
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
# This project is a single-module distribution (TasmotaManager.py)
|
||||
py-modules = ["TasmotaManager"]
|
||||
py-modules = [
|
||||
"main",
|
||||
"utils",
|
||||
"unifi_client",
|
||||
"discovery",
|
||||
"configuration",
|
||||
"console_settings",
|
||||
"unknown_devices",
|
||||
"reporting"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["*"]
|
||||
exclude = ["tests*", "docs*", "data*", ".venv*"]
|
||||
156
reporting.py
Normal file
156
reporting.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""Report generation for Tasmota devices."""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from utils import get_data_file_path, save_json_file, format_device_info
|
||||
from discovery import TasmotaDiscovery
|
||||
|
||||
|
||||
class ReportGenerator:
|
||||
"""Generates various reports for Tasmota devices."""
|
||||
|
||||
def __init__(self, config: dict, discovery: TasmotaDiscovery,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize report generator.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
discovery: Discovery handler instance
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.config = config
|
||||
self.discovery = discovery
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def generate_unifi_hostname_report(self) -> Dict:
|
||||
"""
|
||||
Generate a report comparing UniFi and Tasmota hostnames.
|
||||
|
||||
Returns:
|
||||
dict: Report data
|
||||
"""
|
||||
self.logger.info("Generating UniFi hostname report")
|
||||
|
||||
devices = self.discovery.get_tasmota_devices()
|
||||
|
||||
report = {
|
||||
'generated_at': datetime.now().isoformat(),
|
||||
'total_devices': len(devices),
|
||||
'devices': []
|
||||
}
|
||||
|
||||
for device in devices:
|
||||
device_ip = device.get('ip', '')
|
||||
device_name = device.get('name', 'Unknown')
|
||||
unifi_hostname = device.get('hostname', '')
|
||||
|
||||
# Get self-reported hostname
|
||||
tasmota_hostname, success = self.discovery.get_device_hostname(
|
||||
device_ip, device_name, timeout=5
|
||||
)
|
||||
|
||||
device_report = {
|
||||
'name': device_name,
|
||||
'ip': device_ip,
|
||||
'mac': device.get('mac', ''),
|
||||
'unifi_hostname': unifi_hostname,
|
||||
'tasmota_hostname': tasmota_hostname if success else 'N/A',
|
||||
'hostnames_match': tasmota_hostname == unifi_hostname if success else False,
|
||||
'connection': device.get('connection', 'Unknown'),
|
||||
'bug_detected': device.get('unifi_hostname_bug_detected', False)
|
||||
}
|
||||
|
||||
report['devices'].append(device_report)
|
||||
|
||||
# Save report
|
||||
report_file = get_data_file_path('TasmotaHostnameReport.json')
|
||||
save_json_file(report_file, report, self.logger)
|
||||
|
||||
# Print summary
|
||||
self._print_hostname_report_summary(report)
|
||||
|
||||
return report
|
||||
|
||||
def _print_hostname_report_summary(self, report: Dict):
|
||||
"""
|
||||
Print a summary of the hostname report.
|
||||
|
||||
Args:
|
||||
report: Report data dictionary
|
||||
"""
|
||||
print(f"\n{'='*70}")
|
||||
print("UniFi vs Tasmota Hostname Report")
|
||||
print(f"{'='*70}")
|
||||
print(f"Total devices: {report['total_devices']}")
|
||||
print(f"Generated: {report['generated_at']}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
mismatches = 0
|
||||
bug_detected = 0
|
||||
|
||||
for device in report['devices']:
|
||||
if not device['hostnames_match']:
|
||||
mismatches += 1
|
||||
if device['bug_detected']:
|
||||
bug_detected += 1
|
||||
|
||||
print(f"Hostname mismatches: {mismatches}")
|
||||
print(f"UniFi bug detected: {bug_detected}")
|
||||
print(f"\n{'='*70}")
|
||||
|
||||
if mismatches > 0:
|
||||
print("\nDevices with hostname mismatches:")
|
||||
print(f"{'Device':<25} {'UniFi Hostname':<25} {'Tasmota Hostname':<25}")
|
||||
print("-" * 75)
|
||||
|
||||
for device in report['devices']:
|
||||
if not device['hostnames_match']:
|
||||
name = device['name'][:24]
|
||||
unifi = device['unifi_hostname'][:24]
|
||||
tasmota = device['tasmota_hostname'][:24]
|
||||
bug = " [BUG]" if device['bug_detected'] else ""
|
||||
print(f"{name:<25} {unifi:<25} {tasmota:<25}{bug}")
|
||||
|
||||
print(f"\n{'='*70}\n")
|
||||
|
||||
def save_device_details(self, device_details: List[Dict]):
|
||||
"""
|
||||
Save detailed device information to file.
|
||||
|
||||
Args:
|
||||
device_details: List of detailed device info dictionaries
|
||||
"""
|
||||
output_file = get_data_file_path('TasmotaDevices.json')
|
||||
|
||||
# Add metadata
|
||||
output = {
|
||||
'generated_at': datetime.now().isoformat(),
|
||||
'total_devices': len(device_details),
|
||||
'devices': device_details
|
||||
}
|
||||
|
||||
save_json_file(output_file, output, self.logger)
|
||||
self.logger.info(f"Saved details for {len(device_details)} devices")
|
||||
|
||||
def print_processing_summary(self, processed: int, mqtt_updated: int,
|
||||
console_updated: int, failed: int):
|
||||
"""
|
||||
Print summary of processing results.
|
||||
|
||||
Args:
|
||||
processed: Number of devices processed
|
||||
mqtt_updated: Number with MQTT updates
|
||||
console_updated: Number with console updates
|
||||
failed: Number that failed
|
||||
"""
|
||||
print(f"\n{'='*60}")
|
||||
print("Processing Summary")
|
||||
print(f"{'='*60}")
|
||||
print(f"Total devices processed: {processed}")
|
||||
print(f"MQTT settings updated: {mqtt_updated}")
|
||||
print(f"Console settings applied: {console_updated}")
|
||||
print(f"Failed: {failed}")
|
||||
print(f"{'='*60}\n")
|
||||
15
tasmota_manager_refactor_notes.md
Normal file
15
tasmota_manager_refactor_notes.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Refactoring Notes
|
||||
|
||||
## Version 2.0 - Modular Structure
|
||||
|
||||
The TasmotaManager has been refactored from a single monolithic file into a modular structure for better maintainability and organization.
|
||||
|
||||
### Changes Made
|
||||
|
||||
#### 1. File Organization
|
||||
|
||||
**Old Structure:**
|
||||
- Single `TasmotaManager.py` file (~4000+ lines)
|
||||
- Documentation and data files mixed in root directory
|
||||
|
||||
**New Structure:**
|
||||
165
unifi_client.py
Normal file
165
unifi_client.py
Normal file
@ -0,0 +1,165 @@
|
||||
"""UniFi Controller API client."""
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
# Disable SSL warnings for self-signed certificates
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when authentication with UniFi controller fails."""
|
||||
pass
|
||||
|
||||
|
||||
class UniFiDataError(Exception):
|
||||
"""Raised when UniFi controller returns unexpected data."""
|
||||
pass
|
||||
|
||||
|
||||
class UnifiClient:
|
||||
"""Client for interacting with UniFi Controller API."""
|
||||
|
||||
def __init__(self, host: str, username: str, password: str, site: str = 'default',
|
||||
verify_ssl: bool = False, logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize UniFi client.
|
||||
|
||||
Args:
|
||||
host: UniFi controller URL (e.g., 'https://192.168.1.1')
|
||||
username: Username for authentication
|
||||
password: Password for authentication
|
||||
site: Site name (default: 'default')
|
||||
verify_ssl: Whether to verify SSL certificates
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.base_url = host.rstrip('/')
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.site_id = site
|
||||
self.verify_ssl = verify_ssl
|
||||
self.token = None
|
||||
self.session = requests.Session()
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
# Login to get session token
|
||||
self._login()
|
||||
|
||||
def _request_json(self, endpoint: str, method: str = 'GET',
|
||||
data: Optional[dict] = None) -> dict:
|
||||
"""
|
||||
Make a request to the UniFi API and return JSON response.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint path
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
data: Optional data for POST requests
|
||||
|
||||
Returns:
|
||||
dict: JSON response
|
||||
|
||||
Raises:
|
||||
UniFiDataError: If request fails or returns invalid data
|
||||
"""
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
|
||||
try:
|
||||
if method == 'GET':
|
||||
response = self.session.get(url, verify=self.verify_ssl, timeout=30)
|
||||
elif method == 'POST':
|
||||
response = self.session.post(url, json=data, verify=self.verify_ssl, timeout=30)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
try:
|
||||
json_response = response.json()
|
||||
except ValueError:
|
||||
raise UniFiDataError(f"Invalid JSON response from {endpoint}")
|
||||
|
||||
# Check for UniFi API error response
|
||||
if isinstance(json_response, dict):
|
||||
if json_response.get('meta', {}).get('rc') != 'ok':
|
||||
error_msg = json_response.get('meta', {}).get('msg', 'Unknown error')
|
||||
raise UniFiDataError(f"UniFi API error: {error_msg}")
|
||||
|
||||
return json_response
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"Request to {endpoint} failed: {e}")
|
||||
raise UniFiDataError(f"Request failed: {e}")
|
||||
|
||||
def _login(self):
|
||||
"""
|
||||
Authenticate with the UniFi controller.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails
|
||||
"""
|
||||
login_data = {
|
||||
'username': self.username,
|
||||
'password': self.password
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._request_json('/api/auth/login', method='POST', data=login_data)
|
||||
self.logger.debug("Successfully authenticated with UniFi controller")
|
||||
|
||||
except UniFiDataError as e:
|
||||
self.logger.error(f"Authentication failed: {e}")
|
||||
raise AuthenticationError(f"Failed to authenticate: {e}")
|
||||
|
||||
def get_clients(self) -> List[Dict]:
|
||||
"""
|
||||
Get all clients from the UniFi controller.
|
||||
|
||||
Returns:
|
||||
list: List of client dictionaries
|
||||
|
||||
Raises:
|
||||
UniFiDataError: If request fails
|
||||
"""
|
||||
endpoint = f'/api/s/{self.site_id}/stat/sta'
|
||||
|
||||
try:
|
||||
response = self._request_json(endpoint)
|
||||
|
||||
if isinstance(response, dict) and 'data' in response:
|
||||
clients = response['data']
|
||||
self.logger.debug(f"Retrieved {len(clients)} clients from UniFi controller")
|
||||
return clients
|
||||
else:
|
||||
raise UniFiDataError("Unexpected response format from UniFi controller")
|
||||
|
||||
except UniFiDataError as e:
|
||||
self.logger.error(f"Failed to get clients: {e}")
|
||||
raise
|
||||
|
||||
def get_devices(self) -> List[Dict]:
|
||||
"""
|
||||
Get all devices (APs, switches, etc.) from the UniFi controller.
|
||||
|
||||
Returns:
|
||||
list: List of device dictionaries
|
||||
|
||||
Raises:
|
||||
UniFiDataError: If request fails
|
||||
"""
|
||||
endpoint = f'/api/s/{self.site_id}/stat/device'
|
||||
|
||||
try:
|
||||
response = self._request_json(endpoint)
|
||||
|
||||
if isinstance(response, dict) and 'data' in response:
|
||||
devices = response['data']
|
||||
self.logger.debug(f"Retrieved {len(devices)} devices from UniFi controller")
|
||||
return devices
|
||||
else:
|
||||
raise UniFiDataError("Unexpected response format from UniFi controller")
|
||||
|
||||
except UniFiDataError as e:
|
||||
self.logger.error(f"Failed to get devices: {e}")
|
||||
raise
|
||||
206
unknown_devices.py
Normal file
206
unknown_devices.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""Unknown device processing and interactive setup."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from utils import send_tasmota_command, get_hostname_base
|
||||
from configuration import ConfigurationManager
|
||||
|
||||
|
||||
class UnknownDeviceProcessor:
|
||||
"""Handles processing of unknown/unconfigured Tasmota devices."""
|
||||
|
||||
def __init__(self, config: dict, config_manager: ConfigurationManager,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize unknown device processor.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
config_manager: Configuration manager instance
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.config = config
|
||||
self.config_manager = config_manager
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def process_unknown_devices(self, devices: list):
|
||||
"""
|
||||
Interactively process unknown devices.
|
||||
|
||||
Args:
|
||||
devices: List of unknown devices
|
||||
"""
|
||||
if not devices:
|
||||
self.logger.info("No unknown devices to process")
|
||||
return
|
||||
|
||||
self.logger.info(f"Found {len(devices)} unknown devices to process")
|
||||
|
||||
for device in devices:
|
||||
self._process_single_unknown_device(device)
|
||||
|
||||
def _process_single_unknown_device(self, device: dict):
|
||||
"""
|
||||
Process a single unknown device interactively.
|
||||
|
||||
Args:
|
||||
device: Device info dictionary
|
||||
"""
|
||||
device_name = device.get('name', 'Unknown')
|
||||
device_ip = device.get('ip', '')
|
||||
|
||||
if not device_ip:
|
||||
self.logger.warning(f"{device_name}: No IP address, skipping")
|
||||
return
|
||||
|
||||
self.logger.info(f"\n{'='*60}")
|
||||
self.logger.info(f"Processing unknown device: {device_name} ({device_ip})")
|
||||
self.logger.info(f"{'='*60}")
|
||||
|
||||
# Check if device has a power control
|
||||
result, success = send_tasmota_command(device_ip, "Power", timeout=5, logger=self.logger)
|
||||
|
||||
if not success:
|
||||
self.logger.warning(f"{device_name}: Cannot communicate with device, skipping")
|
||||
return
|
||||
|
||||
# Check if device has power control capability
|
||||
has_power = 'POWER' in result or 'POWER1' in result
|
||||
|
||||
if not has_power:
|
||||
self.logger.warning(f"{device_name}: Device has no power control, skipping toggle")
|
||||
new_hostname = self._prompt_for_hostname(device_name, device_ip, toggle=False)
|
||||
else:
|
||||
# Start toggling and prompt for hostname
|
||||
new_hostname = self._prompt_for_hostname_with_toggle(device_name, device_ip)
|
||||
|
||||
if not new_hostname:
|
||||
self.logger.info(f"{device_name}: Skipped (no hostname entered)")
|
||||
return
|
||||
|
||||
# Configure the device with new hostname
|
||||
self._configure_device(device_ip, device_name, new_hostname)
|
||||
|
||||
def _prompt_for_hostname_with_toggle(self, device_name: str, device_ip: str) -> Optional[str]:
|
||||
"""
|
||||
Prompt for hostname while toggling device power.
|
||||
|
||||
Args:
|
||||
device_name: Current device name
|
||||
device_ip: Device IP address
|
||||
|
||||
Returns:
|
||||
str: New hostname or None if cancelled
|
||||
"""
|
||||
import threading
|
||||
|
||||
self.logger.info(f"{device_name}: Toggling power to help identify device...")
|
||||
self.logger.info("The device will toggle on/off every 2 seconds")
|
||||
|
||||
# Flag to control toggle thread
|
||||
stop_toggle = threading.Event()
|
||||
|
||||
def toggle_power():
|
||||
"""Toggle power in background thread."""
|
||||
while not stop_toggle.is_set():
|
||||
send_tasmota_command(device_ip, "Power%20Toggle", timeout=3)
|
||||
time.sleep(2)
|
||||
|
||||
# Start toggle thread
|
||||
toggle_thread = threading.Thread(target=toggle_power, daemon=True)
|
||||
toggle_thread.start()
|
||||
|
||||
try:
|
||||
# Prompt for hostname
|
||||
new_hostname = self._prompt_for_hostname(device_name, device_ip, toggle=True)
|
||||
finally:
|
||||
# Stop toggling
|
||||
stop_toggle.set()
|
||||
toggle_thread.join(timeout=3)
|
||||
|
||||
# Turn off the device
|
||||
send_tasmota_command(device_ip, "Power%20Off", timeout=3)
|
||||
|
||||
return new_hostname
|
||||
|
||||
def _prompt_for_hostname(self, device_name: str, device_ip: str,
|
||||
toggle: bool = False) -> Optional[str]:
|
||||
"""
|
||||
Prompt user for new hostname.
|
||||
|
||||
Args:
|
||||
device_name: Current device name
|
||||
device_ip: Device IP address
|
||||
toggle: Whether device is currently toggling
|
||||
|
||||
Returns:
|
||||
str: New hostname or None if cancelled
|
||||
"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Unknown Device Found:")
|
||||
print(f" Current Name: {device_name}")
|
||||
print(f" IP Address: {device_ip}")
|
||||
if toggle:
|
||||
print(f" Status: Device is toggling to help identify it")
|
||||
print(f"{'='*60}")
|
||||
print(f"Enter new hostname for this device (or press Enter to skip):")
|
||||
|
||||
try:
|
||||
new_hostname = input("> ").strip()
|
||||
|
||||
if not new_hostname:
|
||||
return None
|
||||
|
||||
return new_hostname
|
||||
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nCancelled")
|
||||
return None
|
||||
|
||||
def _configure_device(self, device_ip: str, old_name: str, new_hostname: str):
|
||||
"""
|
||||
Configure device with new hostname and MQTT settings.
|
||||
|
||||
Args:
|
||||
device_ip: Device IP address
|
||||
old_name: Old device name
|
||||
new_hostname: New hostname to set
|
||||
"""
|
||||
self.logger.info(f"{old_name}: Configuring device with hostname '{new_hostname}'")
|
||||
|
||||
# Set Friendly Name 1
|
||||
command = f"FriendlyName1%20{new_hostname}"
|
||||
result, success = send_tasmota_command(device_ip, command, timeout=5, logger=self.logger)
|
||||
|
||||
if not success:
|
||||
self.logger.error(f"{old_name}: Failed to set hostname")
|
||||
return
|
||||
|
||||
self.logger.info(f"{old_name}: Hostname set to '{new_hostname}'")
|
||||
|
||||
# Set DeviceName (for MQTT)
|
||||
command = f"DeviceName%20{new_hostname}"
|
||||
send_tasmota_command(device_ip, command, timeout=5, logger=self.logger)
|
||||
|
||||
# Get device details for MQTT configuration
|
||||
device_details = self.config_manager.get_device_details(device_ip, new_hostname)
|
||||
|
||||
if not device_details:
|
||||
self.logger.warning(f"{new_hostname}: Could not get device details")
|
||||
else:
|
||||
# Configure MQTT settings
|
||||
device_info = {'name': new_hostname, 'ip': device_ip}
|
||||
success, status = self.config_manager.configure_mqtt_settings(device_info, device_details)
|
||||
|
||||
if success:
|
||||
self.logger.info(f"{new_hostname}: MQTT settings configured")
|
||||
else:
|
||||
self.logger.warning(f"{new_hostname}: MQTT configuration incomplete: {status}")
|
||||
|
||||
# Restart device to apply all changes
|
||||
self.logger.info(f"{new_hostname}: Restarting device to apply changes")
|
||||
send_tasmota_command(device_ip, "Restart%201", timeout=5, logger=self.logger)
|
||||
|
||||
self.logger.info(f"{new_hostname}: Configuration complete")
|
||||
233
utils.py
Normal file
233
utils.py
Normal file
@ -0,0 +1,233 @@
|
||||
"""Common utility functions used across the TasmotaManager modules."""
|
||||
|
||||
import re
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
from typing import Tuple, Optional, Any, Dict
|
||||
import requests
|
||||
|
||||
|
||||
def match_pattern(text: str, pattern: str, match_entire_string: bool = True) -> bool:
|
||||
"""
|
||||
Match a text string against a pattern that may contain wildcards.
|
||||
|
||||
Args:
|
||||
text: The text to match against
|
||||
pattern: The pattern which may contain * wildcards
|
||||
match_entire_string: If True, pattern must match the entire string
|
||||
|
||||
Returns:
|
||||
bool: True if the pattern matches, False otherwise
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
# Convert glob pattern to regex
|
||||
escaped = re.escape(pattern)
|
||||
regex_pattern = escaped.replace(r'\*', '.*')
|
||||
|
||||
if match_entire_string:
|
||||
regex_pattern = f'^{regex_pattern}$'
|
||||
|
||||
return bool(re.match(regex_pattern, text, re.IGNORECASE))
|
||||
|
||||
|
||||
def get_hostname_base(hostname: str) -> str:
|
||||
"""
|
||||
Extract the base hostname (everything before the first dash).
|
||||
|
||||
Args:
|
||||
hostname: Full hostname (e.g., "KitchenLamp-1234")
|
||||
|
||||
Returns:
|
||||
str: Base hostname (e.g., "KitchenLamp")
|
||||
"""
|
||||
if '-' in hostname:
|
||||
return hostname.split('-')[0]
|
||||
return hostname
|
||||
|
||||
|
||||
def send_tasmota_command(ip: str, command: str, timeout: int = 5,
|
||||
logger: Optional[logging.Logger] = None) -> Tuple[Optional[dict], bool]:
|
||||
"""
|
||||
Send a command to a Tasmota device via HTTP API.
|
||||
|
||||
Args:
|
||||
ip: Device IP address
|
||||
command: Tasmota command to send
|
||||
timeout: Request timeout in seconds
|
||||
logger: Optional logger for debug output
|
||||
|
||||
Returns:
|
||||
Tuple of (response_dict, success_bool)
|
||||
"""
|
||||
url = f"http://{ip}/cm?cmnd={command}"
|
||||
|
||||
try:
|
||||
if logger:
|
||||
logger.debug(f"Sending command to {ip}: {command}")
|
||||
|
||||
response = requests.get(url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
if logger:
|
||||
logger.debug(f"Response from {ip}: {result}")
|
||||
|
||||
return result, True
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
if logger:
|
||||
logger.warning(f"Timeout sending command to {ip}: {command}")
|
||||
return None, False
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
if logger:
|
||||
logger.warning(f"Error sending command to {ip}: {e}")
|
||||
return None, False
|
||||
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.error(f"Unexpected error sending command to {ip}: {e}")
|
||||
return None, False
|
||||
|
||||
|
||||
def retry_command(func, max_attempts: int = 3, delay: float = 1.0,
|
||||
logger: Optional[logging.Logger] = None, device_name: str = "") -> Tuple[Any, bool]:
|
||||
"""
|
||||
Retry a command function multiple times with delay between attempts.
|
||||
|
||||
Args:
|
||||
func: Function to call (should return tuple of (result, success))
|
||||
max_attempts: Maximum number of attempts
|
||||
delay: Delay in seconds between attempts
|
||||
logger: Optional logger for output
|
||||
device_name: Device name for logging
|
||||
|
||||
Returns:
|
||||
Tuple of (result, success)
|
||||
"""
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
result, success = func()
|
||||
|
||||
if success:
|
||||
return result, True
|
||||
|
||||
if attempt < max_attempts:
|
||||
if logger:
|
||||
logger.debug(f"{device_name}: Retry attempt {attempt}/{max_attempts}")
|
||||
time.sleep(delay)
|
||||
|
||||
return None, False
|
||||
|
||||
|
||||
def format_device_info(device: dict) -> str:
|
||||
"""
|
||||
Format device information for display.
|
||||
|
||||
Args:
|
||||
device: Device dictionary
|
||||
|
||||
Returns:
|
||||
str: Formatted device info string
|
||||
"""
|
||||
name = device.get('name', 'Unknown')
|
||||
ip = device.get('ip', 'Unknown')
|
||||
mac = device.get('mac', 'Unknown')
|
||||
connection = device.get('connection', 'Unknown')
|
||||
|
||||
return f"{name} ({ip}) - MAC: {mac}, Connection: {connection}"
|
||||
|
||||
|
||||
def load_json_file(filepath: str, logger: Optional[logging.Logger] = None) -> Optional[dict]:
|
||||
"""
|
||||
Load and parse a JSON file.
|
||||
|
||||
Args:
|
||||
filepath: Path to JSON file
|
||||
logger: Optional logger for error output
|
||||
|
||||
Returns:
|
||||
dict or None if file doesn't exist or can't be parsed
|
||||
"""
|
||||
if not os.path.exists(filepath):
|
||||
if logger:
|
||||
logger.debug(f"File not found: {filepath}")
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(filepath, 'r') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
if logger:
|
||||
logger.error(f"Error parsing JSON file {filepath}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.error(f"Error reading file {filepath}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_json_file(filepath: str, data: dict, logger: Optional[logging.Logger] = None) -> bool:
|
||||
"""
|
||||
Save data to a JSON file.
|
||||
|
||||
Args:
|
||||
filepath: Path to save JSON file
|
||||
data: Data to save
|
||||
logger: Optional logger for error output
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(filepath) if os.path.dirname(filepath) else '.', exist_ok=True)
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
return True
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.error(f"Error saving JSON file {filepath}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def is_valid_ip(ip_string: str) -> bool:
|
||||
"""
|
||||
Validate if a string is a valid IP address.
|
||||
|
||||
Args:
|
||||
ip_string: String to validate
|
||||
|
||||
Returns:
|
||||
bool: True if valid IP address
|
||||
"""
|
||||
import socket
|
||||
|
||||
try:
|
||||
socket.inet_aton(ip_string)
|
||||
return True
|
||||
except socket.error:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_data_directory():
|
||||
"""Ensure the data directory exists."""
|
||||
os.makedirs('data', exist_ok=True)
|
||||
os.makedirs('data/temp', exist_ok=True)
|
||||
|
||||
|
||||
def get_data_file_path(filename: str) -> str:
|
||||
"""
|
||||
Get the full path for a data file.
|
||||
|
||||
Args:
|
||||
filename: Name of the file
|
||||
|
||||
Returns:
|
||||
str: Full path in the data directory
|
||||
"""
|
||||
return os.path.join('data', filename)
|
||||
Loading…
Reference in New Issue
Block a user