Merge branch 'inital' into 'master'
Inital See merge request tasmota/manager!31
This commit is contained in:
commit
8b42e7435e
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Python bytecode files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Local configuration that might contain sensitive information
|
||||||
|
network_configuration.json
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.backup
|
||||||
|
|
||||||
|
# Generated data files with sensitive network information
|
||||||
|
current.json
|
||||||
|
deprecated.json
|
||||||
|
TasmotaDevices.json
|
||||||
|
*.json.backup
|
||||||
108
GITLAB_MIGRATION.md
Normal file
108
GITLAB_MIGRATION.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# GitLab Migration Instructions
|
||||||
|
|
||||||
|
This document provides instructions for completing the migration of the TasmotaManager project to GitLab at 192.168.5.10.
|
||||||
|
|
||||||
|
## Completed Steps
|
||||||
|
|
||||||
|
The following steps have already been completed:
|
||||||
|
|
||||||
|
1. Git repository initialized in the TasmotaManager directory
|
||||||
|
2. `.gitignore` file created to exclude sensitive files:
|
||||||
|
- `network_configuration.json` (contains credentials)
|
||||||
|
- Data files with network information (`current.json`, `deprecated.json`, `TasmotaDevices.json`)
|
||||||
|
- Backup files
|
||||||
|
3. `README.md` file created with project documentation
|
||||||
|
4. Non-sensitive files added and committed to the local Git repository:
|
||||||
|
- `TasmotaManager.py`
|
||||||
|
- `.gitignore`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
## Remaining Steps
|
||||||
|
|
||||||
|
To complete the migration to GitLab, follow these steps:
|
||||||
|
|
||||||
|
### Option 1: Create a new repository in GitLab
|
||||||
|
|
||||||
|
1. Log in to GitLab at http://192.168.5.10
|
||||||
|
2. Navigate to the Manager project/group
|
||||||
|
3. Create a new project named "TasmotaManager"
|
||||||
|
4. Follow the instructions provided by GitLab to push an existing repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If using HTTP
|
||||||
|
git remote add gitlab http://192.168.5.10/Manager/TasmotaManager.git
|
||||||
|
git push -u gitlab inital
|
||||||
|
|
||||||
|
# If using SSH
|
||||||
|
git remote add gitlab git@192.168.5.10:Manager/TasmotaManager.git
|
||||||
|
git push -u gitlab inital
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Add as a subproject to the existing Manager project
|
||||||
|
|
||||||
|
If TasmotaManager should be a subproject or subdirectory within the Manager project:
|
||||||
|
|
||||||
|
1. Clone the Manager project:
|
||||||
|
```bash
|
||||||
|
git clone http://192.168.5.10/Manager.git
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a TasmotaManager directory within the Manager project:
|
||||||
|
```bash
|
||||||
|
cd Manager
|
||||||
|
mkdir TasmotaManager
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Copy the files from the local TasmotaManager repository:
|
||||||
|
```bash
|
||||||
|
cp /home/mgeppert/git_work/scripts/TasmotaManager/TasmotaManager.py TasmotaManager/
|
||||||
|
cp /home/mgeppert/git_work/scripts/TasmotaManager/.gitignore TasmotaManager/
|
||||||
|
cp /home/mgeppert/git_work/scripts/TasmotaManager/README.md TasmotaManager/
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add, commit, and push the changes:
|
||||||
|
```bash
|
||||||
|
git add TasmotaManager
|
||||||
|
git commit -m "Add TasmotaManager as a subproject"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Add as a Git submodule
|
||||||
|
|
||||||
|
If TasmotaManager should be maintained as a separate repository but included in the Manager project:
|
||||||
|
|
||||||
|
1. First, create the TasmotaManager repository in GitLab (see Option 1)
|
||||||
|
2. Clone the Manager project:
|
||||||
|
```bash
|
||||||
|
git clone http://192.168.5.10/Manager.git
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add the TasmotaManager as a submodule:
|
||||||
|
```bash
|
||||||
|
cd Manager
|
||||||
|
git submodule add http://192.168.5.10/TasmotaManager.git TasmotaManager
|
||||||
|
git commit -m "Add TasmotaManager as a submodule"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
Remember that sensitive configuration files are excluded from version control. After cloning or setting up the repository, you'll need to:
|
||||||
|
|
||||||
|
1. Create a `network_configuration.json` file with your UniFi Controller and MQTT settings
|
||||||
|
2. Run the script to generate the data files:
|
||||||
|
```bash
|
||||||
|
python TasmotaManager.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If you encounter authentication issues when pushing to GitLab:
|
||||||
|
|
||||||
|
1. Ensure you have the correct access rights to the repository
|
||||||
|
2. Try using a personal access token instead of password authentication:
|
||||||
|
```bash
|
||||||
|
git remote set-url gitlab http://username:token@192.168.5.10/Manager/TasmotaManager.git
|
||||||
|
```
|
||||||
|
|
||||||
|
3. If using SSH, ensure your SSH key is added to your GitLab account
|
||||||
98
README.md
98
README.md
@ -1,8 +1,96 @@
|
|||||||
# Sample GitLab Project
|
# TasmotaManager
|
||||||
|
|
||||||
This sample project shows how a project in GitLab looks for demonstration purposes. It contains issues, merge requests and Markdown files in many branches,
|
A Python utility for discovering, monitoring, and managing Tasmota devices on a network using UniFi Controller.
|
||||||
named and filled with lorem ipsum.
|
|
||||||
|
|
||||||
You can look around to get an idea how to structure your project and, when done, you can safely delete this project.
|
## Features
|
||||||
|
|
||||||
[Learn more about creating GitLab projects.](https://docs.gitlab.com/ee/gitlab-basics/create-project.html)
|
- Discovers Tasmota devices on the network via UniFi Controller API
|
||||||
|
- Tracks device changes over time (new, moved, deprecated devices)
|
||||||
|
- Checks and updates MQTT settings on Tasmota devices
|
||||||
|
- Generates detailed device information including firmware versions
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.6+
|
||||||
|
- UniFi Controller with API access
|
||||||
|
- Network with Tasmota devices
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- requests
|
||||||
|
- urllib3
|
||||||
|
- Standard library modules (json, logging, os, sys, datetime, re, time, argparse)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Clone this repository
|
||||||
|
2. Install required packages:
|
||||||
|
```bash
|
||||||
|
pip install requests urllib3
|
||||||
|
```
|
||||||
|
3. Create a configuration file (see below)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a `network_configuration.json` file with the following structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unifi": {
|
||||||
|
"host": "https://your-unifi-controller.local",
|
||||||
|
"username": "your-username",
|
||||||
|
"password": "your-password",
|
||||||
|
"site": "default",
|
||||||
|
"network_filter": {
|
||||||
|
"network_name": {
|
||||||
|
"name": "Human-readable name",
|
||||||
|
"subnet": "192.168.1",
|
||||||
|
"exclude_patterns": [
|
||||||
|
"device-to-exclude*"
|
||||||
|
],
|
||||||
|
"unknown_device_patterns": [
|
||||||
|
"tasmota*",
|
||||||
|
"ESP-*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mqtt": {
|
||||||
|
"Host": "mqtt-broker.local",
|
||||||
|
"Port": 1883,
|
||||||
|
"User": "mqtt-user",
|
||||||
|
"Password": "mqtt-password",
|
||||||
|
"Topic": "%hostname_base%",
|
||||||
|
"FullTopic": "%prefix%/%topic%/",
|
||||||
|
"NoRetain": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Basic usage:
|
||||||
|
```bash
|
||||||
|
python TasmotaManager.py
|
||||||
|
```
|
||||||
|
|
||||||
|
With options:
|
||||||
|
```bash
|
||||||
|
python TasmotaManager.py --config custom_config.json --debug --skip-unifi
|
||||||
|
```
|
||||||
|
|
||||||
|
Command-line options:
|
||||||
|
- `--config`: Path to configuration file (default: network_configuration.json)
|
||||||
|
- `--debug`: Enable debug logging
|
||||||
|
- `--skip-unifi`: Skip UniFi discovery and use existing current.json
|
||||||
|
|
||||||
|
## Output Files
|
||||||
|
|
||||||
|
The script generates several output files:
|
||||||
|
- `current.json`: List of currently active Tasmota devices
|
||||||
|
- `deprecated.json`: Devices that were previously active but are no longer present
|
||||||
|
- `TasmotaDevices.json`: Detailed information about each device
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
662
TasmotaManager.py
Normal file
662
TasmotaManager.py
Normal file
@ -0,0 +1,662 @@
|
|||||||
|
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 a Tasmota device."""
|
||||||
|
name = device.get('name', '').lower()
|
||||||
|
hostname = device.get('hostname', '').lower()
|
||||||
|
ip = device.get('ip', '')
|
||||||
|
|
||||||
|
# Check if device is in the configured NoT 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 NoT network: {name} ({hostname}) IP: {ip}")
|
||||||
|
|
||||||
|
# First 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
|
||||||
|
|
||||||
|
# If not excluded, check if it's a Tasmota device
|
||||||
|
matches = any([
|
||||||
|
name.startswith('tasmota'),
|
||||||
|
name.startswith('sonoff'),
|
||||||
|
name.endswith('-ts'),
|
||||||
|
hostname.startswith('tasmota'),
|
||||||
|
hostname.startswith('sonoff'),
|
||||||
|
hostname.startswith('esp-'),
|
||||||
|
any(hostname.endswith(suffix) for suffix in ['-fan', '-lamp', '-light', '-switch'])
|
||||||
|
])
|
||||||
|
if matches:
|
||||||
|
self.logger.debug(f"Found Tasmota device: {name}")
|
||||||
|
return True # Consider all non-excluded devices in NoT network as potential Tasmota devices
|
||||||
|
|
||||||
|
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_device_details(self, use_current_json=True):
|
||||||
|
"""Connect to each Tasmota device via HTTP, gather details and validate MQTT settings"""
|
||||||
|
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)
|
||||||
|
devices = data.get('tasmota', {}).get('devices', [])
|
||||||
|
self.logger.debug(f"Loaded {len(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
|
||||||
|
|
||||||
|
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
|
||||||
|
if mqtt_config.get('NoRetain', True):
|
||||||
|
changes_needed.append(('SetOption62', '1')) # 1 = No 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 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
|
||||||
|
if mqtt_config.get('NoRetain', True):
|
||||||
|
changes_needed.append(('SetOption62', '1')) # 1 = No 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
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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()
|
||||||
31
network_configuration.json
Normal file
31
network_configuration.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"unifi": {
|
||||||
|
"host": "https://192.168.6.1",
|
||||||
|
"username": "Tasmota",
|
||||||
|
"password": "TasmotaManager12!@",
|
||||||
|
"site": "default",
|
||||||
|
"network_filter": {
|
||||||
|
"NoT_network": {
|
||||||
|
"name": "NoT",
|
||||||
|
"subnet": "192.168.8",
|
||||||
|
"exclude_patterns": [
|
||||||
|
"homeassistant*",
|
||||||
|
"*sonos*"
|
||||||
|
],
|
||||||
|
"unknown_device_patterns": [
|
||||||
|
"tasmota*",
|
||||||
|
"ESP-*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mqtt": {
|
||||||
|
"Host": "homeassistant.NoT.mgeppert.com",
|
||||||
|
"Port": 1883,
|
||||||
|
"User": "mgeppert",
|
||||||
|
"Password": "mgeppert",
|
||||||
|
"Topic": "%hostname_base%",
|
||||||
|
"FullTopic": "%prefix%/%topic%/",
|
||||||
|
"NoRetain": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user