From f2e775f6f5c867c087cc7f59adce23a7a2e53825 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sat, 4 Oct 2025 01:01:32 +1300 Subject: [PATCH] UI: replace 'on' with pattern 'alternating_phase' (MIDI note 36, grid label/icons); remove WebSocket usage; per-pattern parameters with state hydration; REST-only palette/state/parameters --- src/ui_client.py | 233 ++++++++++++++++++++++------------------------- 1 file changed, 108 insertions(+), 125 deletions(-) diff --git a/src/ui_client.py b/src/ui_client.py index db299b9..d062475 100644 --- a/src/ui_client.py +++ b/src/ui_client.py @@ -13,8 +13,7 @@ import os import mido import logging from async_tkinter_loop import async_handler, async_mainloop -import websockets -import websocket +# WebSocket removed; using REST API only import atexit import signal import urllib.request @@ -144,75 +143,7 @@ active_palette_color_border = "#FFD700" logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -class WebSocketClient: - """WebSocket client for communicating with the control server.""" - - def __init__(self, uri): - self.uri = uri - self.websocket = None - self.is_connected = False - self.reconnect_task = None - self._stop_reconnect = False - - async def connect(self): - """Establish WebSocket connection to control server.""" - if self.is_connected and self.websocket: - return - - try: - logging.info(f"Connecting to control server at {self.uri}...") - self.websocket = await websockets.connect(self.uri) - self.is_connected = True - logging.info("Connected to control server") - except Exception as e: - logging.error(f"Failed to connect to control server: {e}") - self.is_connected = False - self.websocket = None - - async def auto_reconnect(self, base_interval: float = 2.0, max_interval: float = 10.0): - """Background task to reconnect if the connection drops.""" - backoff = base_interval - while not self._stop_reconnect: - if not self.is_connected: - await self.connect() - # Exponential backoff when not connected - if not self.is_connected: - await asyncio.sleep(backoff) - backoff = min(max_interval, backoff * 1.5) - else: - backoff = base_interval - else: - # Connected; check again later - await asyncio.sleep(base_interval) - - async def send_message(self, message_type, data=None): - """Send a message to the control server.""" - if not self.is_connected or not self.websocket: - logging.warning("Not connected to control server") - return - - try: - message = { - "type": message_type, - "data": data or {} - } - await self.websocket.send(json.dumps(message)) - logging.debug(f"Sent message: {message}") - except Exception as e: - logging.error(f"Failed to send message: {e}") - self.is_connected = False - - async def close(self): - """Close WebSocket connection.""" - self._stop_reconnect = True - if self.websocket: - try: - await self.websocket.close() - except Exception: - pass - self.is_connected = False - self.websocket = None - logging.info("Disconnected from control server") +# WebSocket client removed class MidiController: @@ -320,7 +251,7 @@ class MidiController: # Pattern selection for specific MIDI notes logging.info(f"MIDI Note {msg.note}: {msg.velocity}") note_to_pattern = { - 36: "on", + 36: "alternating_phase", 37: "o", 38: "f", 39: "a", @@ -380,11 +311,8 @@ class MidiController: self.delay = int(value) * 4 if callable(self.on_parameters_change): self.on_parameters_change({"delay": self.delay}) - elif control == 27: # Beat sending toggle + elif control == 27: # Beat sending toggle (not used in REST; reserved) self.beat_sending_enabled = (value == 127) - await self.websocket_client.send_message("beat_toggle", { - "enabled": self.beat_sending_enabled - }) def close(self): """Close MIDI connection.""" @@ -405,11 +333,8 @@ class UIClient: # Restore last window geometry if available self.load_window_geometry() - # WebSocket client - self.websocket_client = WebSocketClient(CONTROL_SERVER_URI) - # MIDI controller - self.midi_controller = MidiController(self.websocket_client) + self.midi_controller = MidiController(None) # UI state self.current_pattern = "" @@ -421,8 +346,11 @@ class UIClient: self.n1 = 10 self.n2 = 10 self.n3 = 1 + self.n4 = 1 # Cache for per-pattern windows self.pattern_windows = {} + # Per-pattern parameters storage: n1..n4, delay are unique per pattern + self.pattern_params = {} # Color slots (8) and selected slot via MIDI self.color_slots = [] self.selected_color_slot = None @@ -701,11 +629,17 @@ class UIClient: frm.grid_columnconfigure(1, weight=1) return s - # Sliders: n1, n2, n3, delay - add_slider(0, "n1", 0, 127, self.n1, self._on_change_n1) - add_slider(1, "n2", 0, 127, self.n2, self._on_change_n2) - add_slider(2, "n3", 1, 127, self.n3, self._on_change_n3) - add_slider(3, "delay (ms)", 0, 1000, self.delay, self._on_change_delay) + # Fetch existing params for this pattern or defaults + pvals = self.get_params_for(pattern_name) + # Sliders: n1, n2, n3, optional n4 for segmented_movement, then delay + add_slider(0, "n1", 0, 255, pvals.get("n1", 0), lambda v, pn=pattern_name: self._on_change_param(pn, "n1", v)) + add_slider(1, "n2", 0, 255, pvals.get("n2", 0), lambda v, pn=pattern_name: self._on_change_param(pn, "n2", v)) + add_slider(2, "n3", 0, 255, pvals.get("n3", 0), lambda v, pn=pattern_name: self._on_change_param(pn, "n3", v)) + next_row = 3 + if pattern_name == "segmented_movement": + add_slider(next_row, "n4", 0, 255, pvals.get("n4", 0), lambda v, pn=pattern_name: self._on_change_param(pn, "n4", v)) + next_row += 1 + add_slider(next_row, "delay (ms)", 1, 1000, pvals.get("delay", 100), lambda v, pn=pattern_name: self._on_change_param(pn, "delay", v)) # Close button row btn_row = 4 @@ -720,33 +654,28 @@ class UIClient: pass @async_handler - async def _on_change_n1(self, value: int): - self.n1 = int(value) - self.persist_parameters({"n1": self.n1}) - @async_handler - async def _on_change_n2(self, value: int): - self.n2 = int(value) - self.persist_parameters({"n2": self.n2}) - - @async_handler - async def _on_change_n3(self, value: int): - self.n3 = int(value) if value >= 1 else 1 - self.persist_parameters({"n3": self.n3}) - - @async_handler - async def _on_change_delay(self, value: int): - self.delay = int(value) - self.persist_parameters({"delay": self.delay}) + async def _on_change_param(self, pattern_name: str, key: str, value: int): + val = int(value) + # Store per-pattern + self.set_param(pattern_name, key, val) + # If editing current pattern, persist immediately + if pattern_name == self.current_pattern: + self.persist_parameters({key: val}) + + def get_params_for(self, pattern_name: str) -> dict: + if pattern_name not in self.pattern_params: + self.pattern_params[pattern_name] = {"n1": 0, "n2": 0, "n3": 0, "n4": 0, "delay": 100} + return self.pattern_params[pattern_name] + + def set_param(self, pattern_name: str, key: str, value: int): + entry = self.get_params_for(pattern_name) + entry[key] = int(value) def setup_async_tasks(self): """Setup async tasks for WebSocket and MIDI.""" - # Connect to control server - self.root.after(100, async_handler(self.websocket_client.connect)) - # Start auto-reconnect background task - self.root.after(300, async_handler(self.start_ws_reconnect)) - # Fetch color palette shortly after connect - self.root.after(600, async_handler(self.fetch_color_palette)) + # Load full system state on startup (pattern, parameters, palette) + self.root.after(100, async_handler(self.fetch_full_state)) # Initialize MIDI self.root.after(200, async_handler(self.initialize_midi)) @@ -780,13 +709,7 @@ class UIClient: self.midi_controller.start_midi_listener() ) - @async_handler - async def start_ws_reconnect(self): - if not self.websocket_client.reconnect_task: - self.websocket_client._stop_reconnect = False - self.websocket_client.reconnect_task = asyncio.create_task( - self.websocket_client.auto_reconnect() - ) + # WebSocket reconnect removed def refresh_midi_ports(self): """Refresh MIDI ports list.""" @@ -833,10 +756,8 @@ class UIClient: def update_status_labels(self): """Update UI status labels.""" # Update connection status - if self.websocket_client.is_connected: - self.connection_status.config(text="Control Server: Connected", fg="green") - else: - self.connection_status.config(text="Control Server: Disconnected", fg="red") + # REST-only: show base URL + self.connection_status.config(text=f"API: {HTTP_BASE}", fg="green") # Update dial displays dial_values = [ @@ -863,7 +784,7 @@ class UIClient: # Update buttons icon_for = { # long names - "on": "🟢", "off": "⚫", "flicker": "✨", + "alternating_phase": "↔️", "off": "⚫", "flicker": "✨", "alternating": "↔️", "pulse": "💥", "rainbow": "🌈", "radiate": "🌟", "segmented_movement": "🔀", # short codes used by led-bar @@ -877,7 +798,7 @@ class UIClient: # Display names for UI (with line breaks for better display) display_names = { # long - "on": "on", + "alternating_phase": "alternating\nphase", "off": "off", "flicker": "flicker", "alternating": "alternating", @@ -975,6 +896,56 @@ class UIClient: # Reschedule self.root.after(200, self.update_status_labels) + @async_handler + async def fetch_full_state(self): + """GET /api/state to hydrate current pattern, parameters, and palette.""" + try: + req = urllib.request.Request(f"{HTTP_BASE}/api/state", method='GET') + with urllib.request.urlopen(req, timeout=2.0) as resp: + data = json.loads(resp.read().decode('utf-8')) + # Pattern + pat = data.get("pattern") + if isinstance(pat, str) and pat: + self.current_pattern = pat + # Parameters (for current pattern) + params = data.get("parameters") or {} + if isinstance(params, dict) and self.current_pattern: + # Normalize ints + norm = {} + for k in ("n1", "n2", "n3", "n4", "delay", "brightness"): + if k in params: + try: + norm[k] = int(params[k]) + except Exception: + pass + # Store per-pattern params + pstore = self.get_params_for(self.current_pattern) + pstore.update({k: v for k, v in norm.items() if k in ("n1", "n2", "n3", "n4", "delay")}) + # Update brightness display value + if "brightness" in norm: + self.midi_controller.brightness = norm["brightness"] + # Color palette + cp = data.get("color_palette") or {} + pal = cp.get("palette") + sel = cp.get("selected_indices") + if isinstance(pal, list) and len(pal) == 8: + self.color_slots = [ + (int(max(0, min(255, (c or {}).get("r", 0)))), + int(max(0, min(255, (c or {}).get("g", 0)))), + int(max(0, min(255, (c or {}).get("b", 0))))) + for c in pal + ] + if isinstance(sel, list) and len(sel) == 2: + try: + self.midi_controller.selected_indices = [int(sel[0]), int(sel[1])] + except Exception: + pass + except Exception as e: + logging.debug(f"Failed to fetch state (REST): {e}") + finally: + # Also refresh palette via dedicated call in case API version differs + self.fetch_color_palette() + @async_handler async def fetch_color_palette(self): """Request color palette from server and hydrate UI state.""" @@ -1072,7 +1043,7 @@ class UIClient: def get_bank1_patterns(self): return [ - "on", "off", "flicker", "alternating", + "alternating_phase", "off", "flicker", "alternating", "pulse", "rainbow", "radiate", "segmented_movement", "-", "-", "-", "-", "-", "-", "-", "-", ] @@ -1099,7 +1070,19 @@ class UIClient: data = json.dumps({"pattern": pattern}).encode('utf-8') req = urllib.request.Request(f"{HTTP_BASE}/api/pattern", data=data, method='POST', headers={'Content-Type': 'application/json'}) with urllib.request.urlopen(req, timeout=2.0) as resp: - _ = resp.read() + res = json.loads(resp.read().decode('utf-8')) + # If server returns parameters for this pattern, store and reflect them + if isinstance(res, dict): + params = res.get("parameters") or {} + if isinstance(params, dict): + # Normalize and store per-pattern + pstore = self.get_params_for(pattern) + for k in ("n1", "n2", "n3", "n4", "delay"): + if k in params: + try: + pstore[k] = int(params[k]) + except Exception: + pass except Exception as e: logging.debug(f"Pattern REST update failed: {e}") except Exception as e: @@ -1180,7 +1163,7 @@ class UIClient: self.selected_color_slot = slot_index rr, gg, bb = self.color_slots[slot_index] # Persist selection via REST (first index is used for pattern color per api.md) - asyncio.create_task(self.persist_selected_indices([slot_index, 1])) + self.persist_selected_indices([slot_index, 1]) # For immediate LED update, optional: send parameters API if required. Keeping color_change for now is removed per REST-only guidance. on_close() @@ -1216,7 +1199,7 @@ class UIClient: if b is not None: cb = int(b) self.color_slots[slot_index] = (cr, cg, cb) # Update palette colors on backend via REST (no immediate output) - asyncio.create_task(self.persist_palette()) + self.persist_palette() # Do not send color_change here; only send when user confirms via preview button # ----------------------