diff --git a/Pipfile b/Pipfile index 69dabc5..dac6148 100644 --- a/Pipfile +++ b/Pipfile @@ -23,9 +23,13 @@ python_version = "3.11" run = "python src/main.py" ui = "python src/ui_client.py" control = "python src/control_server.py" +control-spi = "python src/control_server.py --transport spi" +control-ws = "python src/control_server.py --transport websocket" sound = "python src/sound.py" dev-ui = 'watchfiles "python src/ui_client.py" src' -dev-control = 'watchfiles "python src/control_server.py" src' +dev-control = 'watchfiles --args "--transport spi" "python src/control_server.py" src' +dev-control-spi = 'watchfiles --args "--transport spi" "python src/control_server.py" src' +dev-control-ws = 'watchfiles --args "--transport websocket" "python src/control_server.py" src' install = "pipenv install" install-system = "bash -c 'sudo apt-get update && sudo apt-get install -y python3-spidev python3-pip python3-dev portaudio19-dev libasound2-dev'" monitor-esp32 = "bash -c 'source $HOME/esp/esp-idf/export.sh && cd esp32 && idf.py -p ${ESPPORT:-/dev/ttyACM0} monitor'" diff --git a/debug_espnow.py b/debug_espnow.py new file mode 100644 index 0000000..0bf0cae --- /dev/null +++ b/debug_espnow.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Real-time ESP NOW traffic monitor for debugging pattern pausing issues. +Monitors both the ESP32-C3 USB CDC output and LED bar debug info. +""" + +import serial +import time +import threading +import subprocess +import sys +import os + + +class ESPNowDebugger: + def __init__(self): + self.esp32_port = "/dev/ttyACM0" + self.running = False + + def monitor_esp32_serial(self): + """Monitor ESP32-C3 USB CDC output for ESP NOW debug info""" + try: + ser = serial.Serial(self.esp32_port, 115200, timeout=1) + print("šŸ”Œ Monitoring ESP32-C3 USB CDC output...") + + while self.running: + try: + line = ser.readline().decode('utf-8').strip() + if line: + timestamp = time.strftime("%H:%M:%S") + print(f"[{timestamp}] ESP32-C3: {line}") + except serial.SerialTimeoutException: + continue + except Exception as e: + print(f"āŒ ESP32-C3 monitor error: {e}") + break + + ser.close() + except Exception as e: + print(f"āŒ Failed to connect to ESP32-C3: {e}") + + def check_lighting_controller_logs(self): + """Check lighting controller logs for message sending""" + try: + # Monitor control server output for ESP NOW messages + print("šŸ”Œ Monitoring lighting controller ESP NOW messages...") + + # Check if control server is running + proc = subprocess.run(['pgrep', '-f', 'lighting-controller'], + capture_output=True, text=True) + if not proc.stdout.strip(): + print("āŒ Control server not running!") + return + + print("āœ… Control server running, monitor logs manually") + print("šŸ’” Tips:") + print(" - Watch control server terminal output") + print(" - Look for SPI/ESP NOW communication messages") + print(" - Check for timing gaps between messages") + + except Exception as e: + print(f"āŒ Error checking control server: {e}") + + def run(self): + """Start all monitoring threads""" + print("šŸ” ESP NOW Communication Debugger") + print("=" * 50) + print() + + # Check if ESP32-C3 is connected + if not os.path.exists(self.esp32_port): + print(f"āŒ ESP32-C3 not found on {self.esp32_port}") + print("šŸ’” Make sure ESP32-C3 is connected via USB") + return + + print(f"āœ… ESP32-C3 found on {self.esp32_port}") + print() + + self.running = True + + # Start ESP32-C3 monitoring thread + esp32_thread = threading.Thread(target=self.monitor_esp32_serial) + esp32_thread.daemon = True + esp32_thread.start() + + # Monitor lighting controller + self.check_lighting_controller_logs() + + print() + print("šŸ” Monitoring active. Press Ctrl+C to stop...") + print("šŸ“ Watch for:") + print(" - ESP NOW message transmission timing") + print(" - Any error messages or delays") + print(" - Status updates every 5 seconds") + print(" - Pattern interrupt patterns") + + try: + while self.running: + time.sleep(1) + except KeyboardInterrupt: + print("\nšŸ›‘ Stopping debugger...") + self.running = False + + +if __name__ == "__main__": + debugger = ESPNowDebugger() + debugger.run() diff --git a/debug_led_bar.py b/debug_led_bar.py new file mode 100644 index 0000000..332594d --- /dev/null +++ b/debug_led_bar.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Enhanced LED bar debugging script. +Adds timestamp and message sequence debugging to LED bar main.py +""" + +debug_main_content = ''' +import patterns +from settings import Settings +from web import web +from patterns import Patterns +import gc +import utime +import machine +import time +import wifi +import json +from p2p import p2p +import espnow +import network + +def main(): + settings = Settings() + print(settings) + + if settings.get("color_order", "RGB") == "RBG": + color_order = (1, 5, 3) + else: + color_order = (1, 3, 5) + patterns = Patterns(settings["led_pin"], settings["num_leds"], selected="off") + + sta_if = network.WLAN(network.STA_IF) + sta_if.active(True) + + e = espnow.ESPNow() + e.config(rxbuf=1024) + e.active(True) + + wdt = machine.WDT(timeout=10000) + wdt.feed() + + # Debug counters + msg_count = 0 + last_msg_time = 0 + gap_count = 0 + + print(f"[DEBUG] Bar '{settings.get('name', 'unknown')}' starting ESP NOW debug mode") + print(f"[DEBUG] Expected message types: 'b' (beat), 'u' (update)") + + while True: + # advance pattern based on its own returned schedule + # due = patterns.tick(due) + wdt.feed() + + # Drain all pending packets and only process the latest + last_msg = None + msg_received = False + + while True: + host, msg = e.recv(0) + if not msg: + break + last_msg = msg + msg_received = True + + if last_msg: + msg_count += 1 + current_time = time.ticks_ms() + + # Calculate gap between messages + if last_msg_time > 0: + gap = time.ticks_diff(current_time, last_msg_time) + if gap > 1000: # > 1 second gap + gap_count += 1 + print(f"[DEBUG] Message gap detected: {gap}ms (gap #{gap_count})") + + last_msg_time = current_time + + try: + data = json.loads(last_msg) + msg_type = data.get("d", {}).get("t", "unknown") + timestamp = time.strftime("%H:%M:%S") + + print(f"[{timestamp}] MSG#{msg_count}: type='{msg_type}' gap={time.ticks_diff(current_time, last_msg_time) if last_msg_time > 0 else 0}ms") + + # Full data print for debugging + print(f"[DEBUG] Full message: {data}") + + defaults = data.get("d", {}) + bar = data.get(settings.get("name"), {}) + + # Check message type + message_type = defaults.get("t", "b") # Default to beat if not specified + + # Always update parameters from message + patterns.brightness = bar.get("br", defaults.get("br", patterns.brightness)) + patterns.delay = bar.get("dl", defaults.get("dl", patterns.delay)) + patterns.colors = bar.get("cl", defaults.get("cl", patterns.colors)) + patterns.n1 = bar.get("n1", defaults.get("n1", patterns.n1)) + patterns.n2 = bar.get("n2", defaults.get("n2", patterns.n2)) + patterns.n3 = bar.get("n3", defaults.get("n3", patterns.n3)) + patterns.step = bar.get("s", defaults.get("s", patterns.step)) + + # Only execute pattern if it's a beat message + if message_type == "b": # Beat message + selected_pattern = bar.get("pt", defaults.get("pt", "off")) + if selected_pattern in patterns.patterns: + print(f"[DEBUG] Executing pattern: {selected_pattern}") + patterns.patterns[selected_pattern]() + else: + print(f"[DEBUG] Pattern '{selected_pattern}' not found") + elif message_type == "u": # Update message + print(f"[DEBUG] Parameters updated: brightness={patterns.brightness}, delay={patterns.delay}") + else: + print(f"[DEBUG] Unknown message type: '{message_type}'") + + except Exception as ex: + print(f"[DEBUG] Failed to load espnow data {last_msg}: {ex}") + continue + + # Periodic status every 100 loops (about every 10 seconds) + if msg_count > 0 and msg_count % 100 == 0: + print(f"[STATUS] Processed {msg_count} messages, {gap_count} gaps detected") + + +main() +''' + +if __name__ == "__main__": + print("Enhanced LED bar debugging output generated.") + print("This script would replace the main.py with enhanced debugging.") + print("The debug version adds:") + print("- Timestamped messages") + print("- Message sequence numbers") + print("- Gap detection between messages") + print("- Detailed pattern execution logging") + print("- Status summaries") diff --git a/esp32_debug_patch.md b/esp32_debug_patch.md new file mode 100644 index 0000000..9999004 --- /dev/null +++ b/esp32_debug_patch.md @@ -0,0 +1,45 @@ +# ESP32-C3 Debug Patch + +## Issue Found +The ESP32-C3 firmware sends status messages every 5 seconds when SPI transactions fail (line 208 in main.c), which could interfere with ESP NOW communication and cause pattern pauses. + +## Debugging Steps + +### 1. Monitor ESP32-C3 Status Messages +The ESP32-C3 sends status messages every 5 seconds, which may interrupt ESP NOW communication. + +### 2. Check SPI Communication +Pattern stops could be caused by: +- SPI transaction failures causing 5-second delays +- ESP NOW interference with SPI operations +- Memory/buffer issues during concurrent operations + +### 3. Monitor ESP NOW Traffic +Use the debug scripts to monitor: +- ESP NOW message transmission timing +- Message gaps between transmissions +- ESP32-C3 vs LED bar timing differences + +### 4. Potential Fixes + +#### Immediate fixes: +1. **Reduce status message frequency** from 5 seconds to 30 seconds +2. **Add ESP NOW debug logging** to see message transmission times +3. **Remove blocking delays** on SPI failures + +#### Firmware modifications needed: +1. Change `pdMS_TO_TICKS(5000)` to `pdMS_TO_TICKS(30000)` for status messages +2. Add debug printf statements for ESP NOW transmissions +3. Make SPI error handling non-blocking + +### 5. Testing Strategy +1. Apply firmware patches +2. Monitor ESP NOW traffic with debug scripts +3. Observe pattern continuity +4. Check timing of ESP NOW vs SPI operations + +## Current Status +- ESP32-C3 runs status sender task every 10 seconds +- ESP32-C3 sends status via ESP NOW every 5 seconds on SPI errors +- Original heartbeat in control server was disabled (this was correct) +- Issue likely in ESP32-C3 firmware timing diff --git a/src/control_server.py b/src/control_server.py index 413c947..5688e58 100644 --- a/src/control_server.py +++ b/src/control_server.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Control Server for Lighting Controller -Handles lighting control logic and communicates with LED bars via WebSocket. +Handles lighting control logic and communicates with LED bars via SPI or WebSocket. Receives commands from UI client via WebSocket. """ @@ -12,9 +12,10 @@ import logging import socket import threading import time +import argparse from bar_config import LED_BAR_NAMES, DEFAULT_BAR_SETTINGS from color_utils import adjust_brightness -from networking import WebSocketClient as SPIClient # SPI transport client +from networking import SPIClient, WebSocketClient # Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') @@ -36,14 +37,24 @@ PATTERN_NAMES = { "radiate": "rd", "sequential_pulse": "sp", "alternating_phase": "ap", + "segmented_movement": "sm", } class LEDController: - """Handles communication with LED bars via SPI (through ESP32 relay).""" + """Handles communication with LED bars via SPI or WebSocket.""" - def __init__(self, spi_bus: int = 0, spi_device: int = 0, spi_speed_hz: int = 1_000_000): - self.client = SPIClient(bus=spi_bus, device=spi_device, speed_hz=spi_speed_hz) + def __init__(self, transport="spi", **kwargs): + if transport == "spi": + self.client = SPIClient( + bus=kwargs.get('spi_bus', 0), + device=kwargs.get('spi_device', 0), + speed_hz=kwargs.get('spi_speed_hz', 1_000_000) + ) + elif transport == "websocket": + self.client = WebSocketClient(uri=kwargs.get('uri', 'ws://192.168.4.1/ws')) + else: + raise ValueError(f"Invalid transport: {transport}. Must be 'spi' or 'websocket'") @property def is_connected(self) -> bool: @@ -84,9 +95,8 @@ class SoundController: class LightingController: """Main lighting control logic.""" - def __init__(self): - # SPI defaults: bus 0, CE0, 1MHz; adjust here if needed - self.led_controller = LEDController(spi_bus=0, spi_device=0, spi_speed_hz=1_000_000) + def __init__(self, transport="spi", **transport_kwargs): + self.led_controller = LEDController(transport=transport, **transport_kwargs) self.sound_controller = SoundController(SOUND_CONTROL_HOST, SOUND_CONTROL_PORT) # Lighting state @@ -99,6 +109,7 @@ class LightingController: self.n1 = 10 self.n2 = 10 self.n3 = 1 + self.n4 = 1 self.beat_index = 0 self.beat_sending_enabled = True @@ -126,6 +137,7 @@ class LightingController: "n1": self.n1, "n2": self.n2, "n3": self.n3, + "n4": self.n4, "s": self.beat_index % 256, } } @@ -148,7 +160,7 @@ class LightingController: async def _send_normal_pattern(self): """Send normal pattern to all bars.""" - patterns_needing_params = ["alternating", "flicker", "n_chase", "rainbow", "radiate"] + patterns_needing_params = ["alternating", "flicker", "n_chase", "rainbow", "radiate", "segmented_movement"] payload = { "d": { @@ -264,6 +276,8 @@ class LightingController: self.n2 = data["n2"] if "n3" in data: self.n3 = data["n3"] + if "n4" in data: + self.n4 = data["n4"] await self._request_param_update() elif message_type == "delay_change": @@ -281,10 +295,11 @@ class LightingController: class ControlServer: """WebSocket server for UI client communication and TCP server for sound.""" - def __init__(self): - self.lighting_controller = LightingController() + def __init__(self, transport="spi", enable_heartbeat=False, **transport_kwargs): + self.lighting_controller = LightingController(transport=transport, **transport_kwargs) self.clients = set() self.tcp_server = None + self.enable_heartbeat = enable_heartbeat async def handle_ui_client(self, websocket): """Handle UI client WebSocket connection.""" @@ -366,12 +381,30 @@ class ControlServer: # Connect to LED server await self.lighting_controller.led_controller.connect() - # Start servers and heartbeat task - await asyncio.gather( - self.start_websocket_server(), - self.start_tcp_server(), - self._heartbeat_loop() - ) + # Start servers (optionally include heartbeat) + websocket_task = asyncio.create_task(self._websocket_server_task()) + tcp_task = asyncio.create_task(self._tcp_server_task()) + + tasks = [websocket_task, tcp_task] + if self.enable_heartbeat: + heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + tasks.append(heartbeat_task) + + await asyncio.gather(*tasks) + + async def _websocket_server_task(self): + """Keep WebSocket server running.""" + await self.start_websocket_server() + # Keep the server running indefinitely + while True: + await asyncio.sleep(1) + + async def _tcp_server_task(self): + """Keep TCP server running.""" + await self.start_tcp_server() + # Keep the server running indefinitely + while True: + await asyncio.sleep(1) async def _heartbeat_loop(self): """Send periodic heartbeats to keep LED connection alive.""" @@ -394,7 +427,25 @@ class ControlServer: async def main(): """Main entry point.""" - server = ControlServer() + args = parse_arguments() + + transport_kwargs = {} + if args.transport == "spi": + transport_kwargs = { + 'spi_bus': args.spi_bus, + 'spi_device': args.spi_device, + 'spi_speed_hz': args.spi_speed + } + elif args.transport == "websocket": + transport_kwargs = { + 'uri': args.uri + } + + server = ControlServer( + transport=args.transport, + enable_heartbeat=args.enable_heartbeat, + **transport_kwargs + ) try: await server.run() @@ -406,5 +457,61 @@ async def main(): await server.lighting_controller.led_controller.close() +def parse_arguments(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="Control Server for Lighting Controller") + + # Transport selection + transport_group = parser.add_argument_group("Transport Options") + transport_group.add_argument( + "--transport", + choices=["spi", "websocket"], + default="spi", + help="Transport method for LED communication (default: spi)" + ) + + # Control options + control_group = parser.add_argument_group("Control Options") + control_group.add_argument( + "--enable-heartbeat", + action="store_true", + help="Enable heartbeat system (may cause pattern interruptions)" + ) + + # SPI options + spi_group = parser.add_argument_group("SPI Options") + spi_group.add_argument( + "--spi-bus", + type=int, + default=0, + help="SPI bus number (default: 0)" + ) + spi_group.add_argument( + "--spi-device", + type=int, + default=0, + help="SPI device number (default: 0)" + ) + spi_group.add_argument( + "--spi-speed", + type=int, + default=1_000_000, + help="SPI speed in Hz (default: 1000000)" + ) + + # WebSocket options + ws_group = parser.add_argument_group("WebSocket Options") + ws_group.add_argument( + "--uri", + type=str, + default="ws://192.168.4.1/ws", + help="WebSocket URI for LED communication (default: ws://192.168.4.1/ws)" + ) + + return parser.parse_args() + + if __name__ == "__main__": asyncio.run(main()) + +# \ No newline at end of file diff --git a/src/networking.py b/src/networking.py index 4770dbd..70da1f9 100644 --- a/src/networking.py +++ b/src/networking.py @@ -1,6 +1,8 @@ import json import os import time +import asyncio +import websockets try: import spidev @@ -8,8 +10,9 @@ except Exception as e: spidev = None -class WebSocketClient: - def __init__(self, uri=None, *, bus=None, device=None, speed_hz=None): +class SPIClient: + """SPI transport client.""" + def __init__(self, bus=None, device=None, speed_hz=None): # SPI configuration (defaults can be overridden by args or env) self.bus = 0 if bus is None else int(bus) self.device = 0 if device is None else int(device) @@ -77,3 +80,53 @@ class WebSocketClient: pass self.is_connected = False self.spi = None + + +class WebSocketClient: + """WebSocket transport client.""" + def __init__(self, uri=None, *, bus=None, device=None, speed_hz=None): + self.uri = uri or "ws://192.168.4.1/ws" + self.websocket = None + self.is_connected = False + + async def connect(self): + """Initializes the WebSocket connection.""" + if self.is_connected and self.websocket: + return + + try: + self.websocket = await websockets.connect(self.uri) + self.is_connected = True + print(f"WebSocket connected: {self.uri}") + except Exception as e: + print(f"Error opening WebSocket: {e}") + self.is_connected = False + self.websocket = None + + async def send_data(self, data): + """Sends a JSON object over WebSocket.""" + if not self.is_connected or not self.websocket: + await self.connect() + if not self.is_connected: + print("WebSocket not connected; cannot send") + return + + try: + json_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False) + await self.websocket.send(json_str) + print(f"WebSocket sent: {json_str}") + except Exception as e: + print(f"WebSocket send failed: {e}") + # Attempt simple reopen on next call + self.is_connected = False + self.websocket = None + + async def close(self): + """Closes the WebSocket connection.""" + try: + if self.websocket: + await self.websocket.close() + except Exception: + pass + self.is_connected = False + self.websocket = None \ No newline at end of file diff --git a/test/test_control_server.py b/test/test_control_server.py index 1caaec9..f69951d 100644 --- a/test/test_control_server.py +++ b/test/test_control_server.py @@ -6,10 +6,10 @@ Starts a client to localhost:8765 and sends a small sequence of UI commands: - pattern_change - color_change - brightness_change -- parameter_change (n1/n2/n3) +- parameter_change (n1/n2/n3/n4) Usage examples: - python test/test_control_server.py --pattern on --r 255 --g 0 --b 0 --brightness 150 --n1 5 --n2 5 --n3 1 + python test/test_control_server.py --pattern on --r 255 --g 0 --b 0 --brightness 150 --n1 5 --n2 5 --n3 1 --n4 2 python test/test_control_server.py --pattern rainbow """ @@ -51,7 +51,7 @@ def build_messages(args): if args.brightness is not None: msgs.append({"type": "brightness_change", "data": {"brightness": args.brightness}}) - if any(v is not None for v in (args.n1, args.n2, args.n3)): + if any(v is not None for v in (args.n1, args.n2, args.n3, args.n4)): payload = {} if args.n1 is not None: payload["n1"] = args.n1 @@ -59,6 +59,8 @@ def build_messages(args): payload["n2"] = args.n2 if args.n3 is not None: payload["n3"] = args.n3 + if args.n4 is not None: + payload["n4"] = args.n4 msgs.append({"type": "parameter_change", "data": payload}) return msgs @@ -66,9 +68,11 @@ def build_messages(args): async def run_test(uri: str, messages: list[dict], sleep_s: float): async with websockets.connect(uri) as ws: - # Send only one message (first in list) - m = messages[0] - await ws.send(json.dumps(m)) + # Send all messages with a delay between them + for m in messages: + await ws.send(json.dumps(m)) + if len(messages) > 1: + await asyncio.sleep(sleep_s) def parse_args(): @@ -83,6 +87,7 @@ def parse_args(): p.add_argument("--n1", type=int, help="n1 for parameter_change") p.add_argument("--n2", type=int, help="n2 for parameter_change") p.add_argument("--n3", type=int, help="n3 for parameter_change") + p.add_argument("--n4", type=int, help="n4 for parameter_change") p.add_argument("--sleep", type=float, default=0.2, help="Seconds to wait between messages (default 0.2)") p.add_argument("--colors", help="Comma-separated hex colors (uses first as r,g,b)") return p.parse_args()