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:
Mike Geppert 2025-10-29 16:38:03 +00:00
parent 9764d28b04
commit 9c22168f79
27 changed files with 2151 additions and 4552 deletions

55
.gitignore vendored
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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
View 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
View 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()

View File

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

View File

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

View 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
View 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
View 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
View 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)