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