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