130 lines
4.9 KiB
Python
130 lines
4.9 KiB
Python
import json
|
||
import os
|
||
import binascii
|
||
|
||
WIFI_CHANNEL_DEFAULT = 5
|
||
|
||
|
||
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"
|
||
|
||
|
||
_settings_singleton: "Settings | None" = None
|
||
|
||
|
||
class Settings(dict):
|
||
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
||
|
||
def __init__(self, *, quiet: bool = False):
|
||
super().__init__()
|
||
self._quiet = quiet
|
||
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'] = WIFI_CHANNEL_DEFAULT
|
||
# WebSocket URL of ESP-NOW bridge (Pi is client), e.g. ws://192.168.4.1/ws
|
||
if 'bridge_ws_url' not in self:
|
||
self['bridge_ws_url'] = ''
|
||
if 'wifi_interface' not in self:
|
||
self['wifi_interface'] = ''
|
||
if 'bridges' not in self:
|
||
self['bridges'] = []
|
||
if 'bridge_transport' not in self:
|
||
self['bridge_transport'] = 'serial'
|
||
if 'bridge_serial_port' not in self:
|
||
self['bridge_serial_port'] = ''
|
||
if 'bridge_serial_baudrate' not in self:
|
||
self['bridge_serial_baudrate'] = 115200
|
||
# Zone UI global brightness (0–255); shared across browsers/devices.
|
||
if 'global_brightness' not in self:
|
||
self['global_brightness'] = 255
|
||
# Sequence tile start: wait for beat or downbeat (server-owned).
|
||
if 'sequence_switch_wait' not in self:
|
||
self['sequence_switch_wait'] = 'beat'
|
||
elif str(self.get('sequence_switch_wait', '')).strip().lower() == 'phrase':
|
||
self['sequence_switch_wait'] = 'beat'
|
||
# Beat flash alignment delay (ms); applied by all UI clients polling audio status.
|
||
if 'audio_beat_phase_ms' not in self:
|
||
self['audio_beat_phase_ms'] = 0
|
||
# Input gain for beat detection (percent, 0–200).
|
||
if 'audio_input_volume' not in self:
|
||
self['audio_input_volume'] = 100
|
||
|
||
def save(self):
|
||
try:
|
||
j = json.dumps(self, indent=2, sort_keys=True)
|
||
with open(self.SETTINGS_FILE, 'w') as file:
|
||
file.write(j)
|
||
file.write("\n")
|
||
if not getattr(self, "_quiet", False):
|
||
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
|
||
if not getattr(self, "_quiet", False):
|
||
print("Settings loaded successfully.")
|
||
except Exception as e:
|
||
if not getattr(self, "_quiet", False):
|
||
print(f"Error loading settings: {e}")
|
||
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()
|
||
|
||
|
||
def get_settings() -> Settings:
|
||
"""Process-wide settings instance (avoid re-reading settings.json on every request)."""
|
||
global _settings_singleton
|
||
if _settings_singleton is None:
|
||
_settings_singleton = Settings()
|
||
return _settings_singleton
|
||
|
||
|
||
def reload_settings() -> Settings:
|
||
"""Re-read settings.json (e.g. after external file edit)."""
|
||
global _settings_singleton
|
||
_settings_singleton = Settings(quiet=True)
|
||
return _settings_singleton
|