#!/usr/bin/env python3 """ Local development web server - imports and runs src.main with port 5000 """ import sys import os import asyncio import signal # Add project root, src, and lib to path project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) src_path = os.path.join(project_root, 'src') lib_path = os.path.join(project_root, 'lib') # Add to path in the right order - src must be first so 'models' and 'controllers' can be imported # This ensures imports like 'from models.preset import Preset' work sys.path.insert(0, src_path) sys.path.insert(0, lib_path) sys.path.insert(0, project_root) # Mock MicroPython modules before importing main class MockMachine: class WDT: def __init__(self, timeout): pass def feed(self): pass class MockESPNow: def __init__(self): self.active_value = False self.peers = [] self.websocket_client = None # Store single WebSocket connection def active(self, value): self.active_value = value print(f"[MOCK] ESPNow active: {value}") def add_peer(self, peer): self.peers.append(peer) if hasattr(peer, 'hex'): print(f"[MOCK] Added peer: {peer.hex()}") else: print(f"[MOCK] Added peer: {peer}") def register_websocket(self, ws): """Register a WebSocket connection to forward ESPNow data to.""" self.websocket_client = ws print(f"[MOCK] Registered WebSocket client") def unregister_websocket(self, ws): """Unregister a WebSocket connection.""" if self.websocket_client == ws: self.websocket_client = None print(f"[MOCK] Unregistered WebSocket client") async def asend(self, peer, data): if hasattr(peer, 'hex'): print(f"[MOCK] Would send to {peer.hex()}: {data}") else: print(f"[MOCK] Would send to {peer}: {data}") # Forward data to the connected WebSocket client if self.websocket_client: try: await self.websocket_client.send(data) print(f"[MOCK] Forwarded to WebSocket client") except Exception as e: print(f"[MOCK] WebSocket client disconnected: {e}") self.websocket_client = None class MockAIOESPNow: def __init__(self): self.espnow = MockESPNow() def active(self, value): self.espnow.active(value) return self.espnow def add_peer(self, peer): self.espnow.add_peer(peer) async def asend(self, peer, data): await self.espnow.asend(peer, data) # Store reference to mock instance for WebSocket registration @property def mock_instance(self): return self.espnow # Create mock ESPNow instance and store reference for WebSocket registration mock_espnow_instance = MockESPNow() mock_aioespnow = MockAIOESPNow() mock_aioespnow.espnow = mock_espnow_instance # Use the shared instance # Create mock ESPNow instance and store reference for WebSocket registration mock_espnow_instance = MockESPNow() mock_aioespnow = MockAIOESPNow() mock_aioespnow.espnow = mock_espnow_instance # Use the shared instance # Install mocks in sys.modules before any imports sys.modules['machine'] = MockMachine() # Store the mock instance in the module so it can be accessed aioespnow_module = type('module', (), {'AIOESPNow': MockAIOESPNow, '_mock_instance': mock_espnow_instance})() sys.modules['aioespnow'] = aioespnow_module class MockWLAN: def __init__(self, interface): self.interface = interface def active(self, value): print(f"[MOCK] WLAN({self.interface}) active: {value}") sys.modules['network'] = type('module', (), { 'WLAN': MockWLAN, 'STA_IF': 0 })() # Mock asyncio.sleep_ms for regular Python _original_sleep = asyncio.sleep async def sleep_ms(ms): await _original_sleep(ms / 1000.0) # Patch asyncio.sleep_ms asyncio.sleep_ms = sleep_ms # Patch sys.print_exception for regular Python (MicroPython has this, regular Python doesn't) if not hasattr(sys, 'print_exception'): import traceback sys.print_exception = lambda e, file=None: traceback.print_exception(type(e), e, e.__traceback__, file=file) # Patch builtins.open to redirect /db/ paths to project db directory import builtins _original_open = builtins.open def patched_open(file, mode='r', *args, **kwargs): if isinstance(file, str): if file.startswith('/db/'): # Redirect to project db directory filename = os.path.basename(file) file = os.path.join(project_root, 'db', filename) elif not os.path.isabs(file): # For relative paths starting with templates/ or static/, # always resolve to src/ directory if file.startswith('templates/') or file.startswith('static/'): file = os.path.join(src_path, file) # For other relative paths, check if they exist in current dir # If not, try src/ directory elif not os.path.exists(file): src_file = os.path.join(src_path, file) if os.path.exists(src_file): file = src_file return _original_open(file, mode, *args, **kwargs) builtins.open = patched_open # Also patch os.mkdir to handle /db path original_mkdir = os.mkdir def patched_mkdir(path): if path == "/db": # Use project db directory instead db_path = os.path.join(project_root, "db") if not os.path.exists(db_path): os.makedirs(db_path, exist_ok=True) else: original_mkdir(path) os.mkdir = patched_mkdir # Create a flag to stop the infinite loop _stop_flag = False # Patch gc.collect to check stop flag import gc as gc_module _original_collect = gc_module.collect def collect(): global _stop_flag if _stop_flag: raise KeyboardInterrupt("Stop requested") return _original_collect() gc_module.collect = collect # Change to src directory for file paths (where templates and static are) # main.py expects templates/ and static/ to be relative to the working directory os.chdir(src_path) # Override settings path for local development # Import settings module and patch the path before main imports it import importlib.util spec = importlib.util.spec_from_file_location("settings", os.path.join(src_path, "settings.py")) settings_module = importlib.util.module_from_spec(spec) sys.modules['settings'] = settings_module spec.loader.exec_module(settings_module) settings_module.Settings.SETTINGS_FILE = os.path.join(project_root, 'settings.json') # Patch the Model class file path before importing # We need to monkey-patch the model.py file's behavior import importlib.util model_spec = importlib.util.spec_from_file_location("models.model", os.path.join(src_path, "models", "model.py")) model_module = importlib.util.module_from_spec(model_spec) # Patch os.mkdir in the model module's context original_mkdir = os.mkdir def patched_mkdir(path): if path == "/db": db_path = os.path.join(project_root, "db") if not os.path.exists(db_path): os.makedirs(db_path, exist_ok=True) else: original_mkdir(path) # Set up the module's namespace with patched os model_module.__dict__['os'] = type('os', (), {'mkdir': patched_mkdir, 'path': os.path})() model_spec.loader.exec_module(model_module) sys.modules['models.model'] = model_module # Now patch the Model class to fix file paths # The issue is that Model.__init__ sets self.file and immediately calls load() # before we can patch it. We need to replace __init__ completely. # Also clear any existing singleton instances Model = model_module.Model # Clear singleton instances for all Model subclasses for attr_name in dir(model_module): attr = getattr(model_module, attr_name) if isinstance(attr, type) and issubclass(attr, Model) and attr != Model: if hasattr(attr, '_instance'): delattr(attr, '_instance') original_save = Model.save original_load = Model.load original_set_defaults = Model.set_defaults def patched_init(self): # Only initialize once (check if already initialized) if hasattr(self, '_initialized'): return # Create db directory if it doesn't exist (use project db, not /db) db_path = os.path.join(project_root, "db") if not os.path.exists(db_path): os.makedirs(db_path, exist_ok=True) self.class_name = self.__class__.__name__ # Set file path to project db directory from the start self.file = os.path.join(project_root, 'db', f"{self.class_name.lower()}.json") super(Model, self).__init__() # Now call load with the correct path already set # Call the patched load method (defined below) Model.load(self) self._initialized = True def patched_save(self): # Ensure file path is correct before saving (this will also fix print statements) if hasattr(self, 'file') and self.file.startswith('/db/'): filename = os.path.basename(self.file) self.file = os.path.join(project_root, 'db', filename) # Also ensure the directory exists db_dir = os.path.dirname(self.file) if not os.path.exists(db_dir): os.makedirs(db_dir, exist_ok=True) return original_save(self) def patched_load(self): # Ensure file path is correct before loading if hasattr(self, 'file') and self.file.startswith('/db/'): filename = os.path.basename(self.file) self.file = os.path.join(project_root, 'db', filename) try: with open(self.file, 'r') as file: import json loaded_settings = json.load(file) # Use dict.update() directly, not the subclass's update() method dict.update(self, loaded_settings) print(f"{self.class_name} loaded successfully.") except FileNotFoundError: # File doesn't exist yet - this is normal on first run print(f"No existing {self.class_name} file found, creating defaults.") self.set_defaults() self.save() except Exception as e: # Other errors - log and create defaults print(f"Error loading {self.class_name}: {type(e).__name__}: {e}") self.set_defaults() self.save() # Apply patches - load must be patched before init uses it Model.load = patched_load Model.__init__ = patched_init Model.save = patched_save # Patch with_websocket decorator before importing main to register WebSocket connections from microdot.websocket import with_websocket as original_with_websocket def patched_with_websocket(f): """Patched with_websocket decorator that registers connections with mock ESPNow.""" @original_with_websocket async def wrapped_handler(request, ws): # Register WebSocket connection with mock ESPNow mock_espnow_instance.register_websocket(ws) try: # Call original handler await f(request, ws) finally: # Unregister when connection closes mock_espnow_instance.unregister_websocket(ws) return wrapped_handler # Now import main (which will use the patched settings module and model) # Import as a module file directly to avoid package import issues main_spec = importlib.util.spec_from_file_location("main", os.path.join(src_path, "main.py")) main_module = importlib.util.module_from_spec(main_spec) # Patch with_websocket in the main module before executing it main_module.__dict__['with_websocket'] = patched_with_websocket main_spec.loader.exec_module(main_module) main = main_module.main def signal_handler(sig, frame): """Handle Ctrl+C gracefully.""" global _stop_flag print("\nShutting down server...") _stop_flag = True # Force exit since main has an infinite loop sys.exit(0) async def run_web(): """Run main with port 5000.""" print("Starting LED Controller Web Server (Local Development)") print("=" * 60) print(f"Server will run on http://localhost:5000") print("Press Ctrl+C to stop") print("=" * 60) # Set up signal handler signal.signal(signal.SIGINT, signal_handler) try: # Call main with port 5000 await main(port=5000) except KeyboardInterrupt: print("\nShutting down server...") except Exception as e: print(f"Error: {e}") raise if __name__ == "__main__": try: asyncio.run(run_web()) except KeyboardInterrupt: print("\nExiting...") except SystemExit: pass