Root Cause: console_settings.py was using "Rule{N} 1" command which BOTH
enables the rule AND sets the Once flag to ON. The Once flag causes rules
to fire only one time and then stop, requiring multiple button presses
before the rule would work again.
Solution: Changed rule enablement from "Rule{N} 1" to "Rule{N} 4"
- Rule 1 = Enable rule + Set Once ON (WRONG)
- Rule 4 = Enable rule only (CORRECT)
- Rule 5 = Set Once ON only
- Rule 6 = Set Once OFF only
This allows rules to fire repeatedly on every button press, fixing the
issue where devices like KitchenBar required multiple presses.
Changes:
- console_settings.py line 190: Use Rule{N} 4 instead of Rule{N} 1
- Added detailed comments explaining Tasmota rule command behavior
- Reverted SetOption32 changes (was red herring, not the actual issue)
Tested on KitchenBar (192.168.8.244) - button now responds on every press.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
258 lines
9.3 KiB
Python
258 lines
9.3 KiB
Python
"""Console settings and parameter management."""
|
|
|
|
import logging
|
|
import time
|
|
import threading
|
|
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
|
|
self._lock = threading.Lock() # Thread-safe access to command_failures
|
|
|
|
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:
|
|
with self._lock:
|
|
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():
|
|
# Use Rule{N} 4 to enable without setting Once flag
|
|
# Rule 1 = Enable + Once ON (WRONG - causes single-fire issue)
|
|
# Rule 4 = Enable only (CORRECT - allows repeated firing)
|
|
enable_command = f"Rule{rule_number}%204"
|
|
|
|
self.logger.debug(f"{device_name}: Enabling rule{rule_number} (Once=OFF)")
|
|
|
|
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."""
|
|
with self._lock:
|
|
if not self.command_failures:
|
|
return
|
|
|
|
self.logger.error("=" * 60)
|
|
self.logger.error("COMMAND FAILURE SUMMARY")
|
|
self.logger.error("=" * 60)
|
|
|
|
# Sort by device name for consistent output
|
|
for device_name in sorted(self.command_failures.keys()):
|
|
failed_commands = self.command_failures[device_name]
|
|
self.logger.error(f"\n{device_name}:")
|
|
for cmd in failed_commands:
|
|
self.logger.error(f" - {cmd}")
|
|
|
|
self.logger.error("=" * 60)
|