- Serve GET /settings as JSON by removing duplicate HTML route (use /settings/page for the standalone UI). - Save global_brightness via PUT; broadcast to connected drivers; push saved level when outbound WS connects. - Zones UI loads brightness from GET /settings only (no localStorage). - Bump led-driver submodule for settings.save on brightness with save flag. - Extend API doc and endpoint tests for global_brightness. Co-authored-by: Cursor <cursoragent@cursor.com>
106 lines
4.5 KiB
Python
106 lines
4.5 KiB
Python
import json
|
||
import os
|
||
import binascii
|
||
|
||
|
||
def _settings_path():
|
||
"""Path to settings.json in project root (writable without root)."""
|
||
try:
|
||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
return os.path.join(base, "settings.json")
|
||
except Exception:
|
||
return "settings.json"
|
||
|
||
|
||
class Settings(dict):
|
||
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
if Settings.SETTINGS_FILE is None:
|
||
Settings.SETTINGS_FILE = _settings_path()
|
||
self.load() # Load settings from file during initialization
|
||
|
||
def generate_secret_key(self):
|
||
"""Generate a random secret key for session signing."""
|
||
try:
|
||
# Try to use os.urandom for secure random bytes
|
||
random_bytes = os.urandom(32)
|
||
return binascii.hexlify(random_bytes).decode('utf-8')
|
||
except (AttributeError, NotImplementedError):
|
||
# Fallback for MicroPython or systems without os.urandom
|
||
try:
|
||
import secrets
|
||
return secrets.token_hex(32)
|
||
except ImportError:
|
||
# Last resort: use a combination of time and random
|
||
import time
|
||
import random
|
||
random.seed(time.time())
|
||
return binascii.hexlify(bytes([random.randint(0, 255) for _ in range(32)])).decode('utf-8')
|
||
|
||
def set_defaults(self):
|
||
"""Set default settings if they don't exist."""
|
||
if 'session_secret_key' not in self:
|
||
self['session_secret_key'] = self.generate_secret_key()
|
||
# Save immediately when generating a new key
|
||
self.save()
|
||
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||
if 'wifi_channel' not in self:
|
||
self['wifi_channel'] = 6
|
||
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
|
||
if 'wifi_driver_ws_port' not in self:
|
||
self['wifi_driver_ws_port'] = 80
|
||
if 'wifi_driver_ws_path' not in self:
|
||
self['wifi_driver_ws_path'] = '/ws'
|
||
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is
|
||
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
|
||
if 'wifi_driver_hello_interval_s' not in self:
|
||
self['wifi_driver_hello_interval_s'] = 10.0
|
||
# Outbound WebSocket dial: total seconds to keep trying before first success
|
||
# (many devices booting at once need more than a short window).
|
||
if 'wifi_driver_connect_retry_window_s' not in self:
|
||
self['wifi_driver_connect_retry_window_s'] = 120.0
|
||
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
|
||
if 'wifi_driver_connect_stagger_max_s' not in self:
|
||
self['wifi_driver_connect_stagger_max_s'] = 2.5
|
||
# TCP/WebSocket open timeout per attempt (seconds).
|
||
if 'wifi_driver_ws_open_timeout' not in self:
|
||
self['wifi_driver_ws_open_timeout'] = 45.0
|
||
# Pause between outbound WebSocket dial attempts (seconds).
|
||
if 'wifi_driver_connect_retry_interval_s' not in self:
|
||
self['wifi_driver_connect_retry_interval_s'] = 2.0
|
||
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
|
||
if 'serial_enabled' not in self:
|
||
self['serial_enabled'] = False
|
||
# Zone UI global brightness (0–255); shared across browsers/devices.
|
||
if 'global_brightness' not in self:
|
||
self['global_brightness'] = 255
|
||
|
||
def save(self):
|
||
try:
|
||
j = json.dumps(self)
|
||
with open(self.SETTINGS_FILE, 'w') as file:
|
||
file.write(j)
|
||
print("Settings saved successfully.")
|
||
except Exception as e:
|
||
print(f"Error saving settings: {e}")
|
||
|
||
def load(self):
|
||
loaded_from_file = False
|
||
try:
|
||
with open(self.SETTINGS_FILE, 'r') as file:
|
||
loaded_settings = json.load(file)
|
||
self.update(loaded_settings)
|
||
loaded_from_file = True
|
||
print("Settings loaded successfully.")
|
||
except Exception as e:
|
||
print(f"Error loading settings")
|
||
self.clear()
|
||
finally:
|
||
# Ensure defaults are set even if file exists but is missing keys
|
||
self.set_defaults()
|
||
# Only save if file didn't exist or was invalid
|
||
if not loaded_from_file:
|
||
self.save()
|