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

This commit is contained in:
2025-10-04 01:01:32 +13:00
parent a654527dc3
commit f2e775f6f5

View File

@@ -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 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})
@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})
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]
@async_handler
async def _on_change_delay(self, value: int):
self.delay = int(value)
self.persist_parameters({"delay": self.delay})
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
# ----------------------