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:
229
src/ui_client.py
229
src/ui_client.py
@@ -13,8 +13,7 @@ import os
|
|||||||
import mido
|
import mido
|
||||||
import logging
|
import logging
|
||||||
from async_tkinter_loop import async_handler, async_mainloop
|
from async_tkinter_loop import async_handler, async_mainloop
|
||||||
import websockets
|
# WebSocket removed; using REST API only
|
||||||
import websocket
|
|
||||||
import atexit
|
import atexit
|
||||||
import signal
|
import signal
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -144,75 +143,7 @@ active_palette_color_border = "#FFD700"
|
|||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
|
||||||
|
|
||||||
class WebSocketClient:
|
# WebSocket client removed
|
||||||
"""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")
|
|
||||||
|
|
||||||
|
|
||||||
class MidiController:
|
class MidiController:
|
||||||
@@ -320,7 +251,7 @@ class MidiController:
|
|||||||
# Pattern selection for specific MIDI notes
|
# Pattern selection for specific MIDI notes
|
||||||
logging.info(f"MIDI Note {msg.note}: {msg.velocity}")
|
logging.info(f"MIDI Note {msg.note}: {msg.velocity}")
|
||||||
note_to_pattern = {
|
note_to_pattern = {
|
||||||
36: "on",
|
36: "alternating_phase",
|
||||||
37: "o",
|
37: "o",
|
||||||
38: "f",
|
38: "f",
|
||||||
39: "a",
|
39: "a",
|
||||||
@@ -380,11 +311,8 @@ class MidiController:
|
|||||||
self.delay = int(value) * 4
|
self.delay = int(value) * 4
|
||||||
if callable(self.on_parameters_change):
|
if callable(self.on_parameters_change):
|
||||||
self.on_parameters_change({"delay": self.delay})
|
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)
|
self.beat_sending_enabled = (value == 127)
|
||||||
await self.websocket_client.send_message("beat_toggle", {
|
|
||||||
"enabled": self.beat_sending_enabled
|
|
||||||
})
|
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Close MIDI connection."""
|
"""Close MIDI connection."""
|
||||||
@@ -405,11 +333,8 @@ class UIClient:
|
|||||||
# Restore last window geometry if available
|
# Restore last window geometry if available
|
||||||
self.load_window_geometry()
|
self.load_window_geometry()
|
||||||
|
|
||||||
# WebSocket client
|
|
||||||
self.websocket_client = WebSocketClient(CONTROL_SERVER_URI)
|
|
||||||
|
|
||||||
# MIDI controller
|
# MIDI controller
|
||||||
self.midi_controller = MidiController(self.websocket_client)
|
self.midi_controller = MidiController(None)
|
||||||
|
|
||||||
# UI state
|
# UI state
|
||||||
self.current_pattern = ""
|
self.current_pattern = ""
|
||||||
@@ -421,8 +346,11 @@ class UIClient:
|
|||||||
self.n1 = 10
|
self.n1 = 10
|
||||||
self.n2 = 10
|
self.n2 = 10
|
||||||
self.n3 = 1
|
self.n3 = 1
|
||||||
|
self.n4 = 1
|
||||||
# Cache for per-pattern windows
|
# Cache for per-pattern windows
|
||||||
self.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
|
# Color slots (8) and selected slot via MIDI
|
||||||
self.color_slots = []
|
self.color_slots = []
|
||||||
self.selected_color_slot = None
|
self.selected_color_slot = None
|
||||||
@@ -701,11 +629,17 @@ class UIClient:
|
|||||||
frm.grid_columnconfigure(1, weight=1)
|
frm.grid_columnconfigure(1, weight=1)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
# Sliders: n1, n2, n3, delay
|
# Fetch existing params for this pattern or defaults
|
||||||
add_slider(0, "n1", 0, 127, self.n1, self._on_change_n1)
|
pvals = self.get_params_for(pattern_name)
|
||||||
add_slider(1, "n2", 0, 127, self.n2, self._on_change_n2)
|
# Sliders: n1, n2, n3, optional n4 for segmented_movement, then delay
|
||||||
add_slider(2, "n3", 1, 127, self.n3, self._on_change_n3)
|
add_slider(0, "n1", 0, 255, pvals.get("n1", 0), lambda v, pn=pattern_name: self._on_change_param(pn, "n1", v))
|
||||||
add_slider(3, "delay (ms)", 0, 1000, self.delay, self._on_change_delay)
|
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
|
# Close button row
|
||||||
btn_row = 4
|
btn_row = 4
|
||||||
@@ -720,33 +654,28 @@ class UIClient:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@async_handler
|
@async_handler
|
||||||
async def _on_change_n1(self, value: int):
|
|
||||||
self.n1 = int(value)
|
|
||||||
self.persist_parameters({"n1": self.n1})
|
|
||||||
|
|
||||||
@async_handler
|
@async_handler
|
||||||
async def _on_change_n2(self, value: int):
|
async def _on_change_param(self, pattern_name: str, key: str, value: int):
|
||||||
self.n2 = int(value)
|
val = int(value)
|
||||||
self.persist_parameters({"n2": self.n2})
|
# 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
|
def get_params_for(self, pattern_name: str) -> dict:
|
||||||
async def _on_change_n3(self, value: int):
|
if pattern_name not in self.pattern_params:
|
||||||
self.n3 = int(value) if value >= 1 else 1
|
self.pattern_params[pattern_name] = {"n1": 0, "n2": 0, "n3": 0, "n4": 0, "delay": 100}
|
||||||
self.persist_parameters({"n3": self.n3})
|
return self.pattern_params[pattern_name]
|
||||||
|
|
||||||
@async_handler
|
def set_param(self, pattern_name: str, key: str, value: int):
|
||||||
async def _on_change_delay(self, value: int):
|
entry = self.get_params_for(pattern_name)
|
||||||
self.delay = int(value)
|
entry[key] = int(value)
|
||||||
self.persist_parameters({"delay": self.delay})
|
|
||||||
|
|
||||||
def setup_async_tasks(self):
|
def setup_async_tasks(self):
|
||||||
"""Setup async tasks for WebSocket and MIDI."""
|
"""Setup async tasks for WebSocket and MIDI."""
|
||||||
# Connect to control server
|
# Load full system state on startup (pattern, parameters, palette)
|
||||||
self.root.after(100, async_handler(self.websocket_client.connect))
|
self.root.after(100, async_handler(self.fetch_full_state))
|
||||||
# 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))
|
|
||||||
|
|
||||||
# Initialize MIDI
|
# Initialize MIDI
|
||||||
self.root.after(200, async_handler(self.initialize_midi))
|
self.root.after(200, async_handler(self.initialize_midi))
|
||||||
@@ -780,13 +709,7 @@ class UIClient:
|
|||||||
self.midi_controller.start_midi_listener()
|
self.midi_controller.start_midi_listener()
|
||||||
)
|
)
|
||||||
|
|
||||||
@async_handler
|
# WebSocket reconnect removed
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
def refresh_midi_ports(self):
|
def refresh_midi_ports(self):
|
||||||
"""Refresh MIDI ports list."""
|
"""Refresh MIDI ports list."""
|
||||||
@@ -833,10 +756,8 @@ class UIClient:
|
|||||||
def update_status_labels(self):
|
def update_status_labels(self):
|
||||||
"""Update UI status labels."""
|
"""Update UI status labels."""
|
||||||
# Update connection status
|
# Update connection status
|
||||||
if self.websocket_client.is_connected:
|
# REST-only: show base URL
|
||||||
self.connection_status.config(text="Control Server: Connected", fg="green")
|
self.connection_status.config(text=f"API: {HTTP_BASE}", fg="green")
|
||||||
else:
|
|
||||||
self.connection_status.config(text="Control Server: Disconnected", fg="red")
|
|
||||||
|
|
||||||
# Update dial displays
|
# Update dial displays
|
||||||
dial_values = [
|
dial_values = [
|
||||||
@@ -863,7 +784,7 @@ class UIClient:
|
|||||||
# Update buttons
|
# Update buttons
|
||||||
icon_for = {
|
icon_for = {
|
||||||
# long names
|
# long names
|
||||||
"on": "🟢", "off": "⚫", "flicker": "✨",
|
"alternating_phase": "↔️", "off": "⚫", "flicker": "✨",
|
||||||
"alternating": "↔️", "pulse": "💥", "rainbow": "🌈",
|
"alternating": "↔️", "pulse": "💥", "rainbow": "🌈",
|
||||||
"radiate": "🌟", "segmented_movement": "🔀",
|
"radiate": "🌟", "segmented_movement": "🔀",
|
||||||
# short codes used by led-bar
|
# short codes used by led-bar
|
||||||
@@ -877,7 +798,7 @@ class UIClient:
|
|||||||
# Display names for UI (with line breaks for better display)
|
# Display names for UI (with line breaks for better display)
|
||||||
display_names = {
|
display_names = {
|
||||||
# long
|
# long
|
||||||
"on": "on",
|
"alternating_phase": "alternating\nphase",
|
||||||
"off": "off",
|
"off": "off",
|
||||||
"flicker": "flicker",
|
"flicker": "flicker",
|
||||||
"alternating": "alternating",
|
"alternating": "alternating",
|
||||||
@@ -975,6 +896,56 @@ class UIClient:
|
|||||||
# Reschedule
|
# Reschedule
|
||||||
self.root.after(200, self.update_status_labels)
|
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_handler
|
||||||
async def fetch_color_palette(self):
|
async def fetch_color_palette(self):
|
||||||
"""Request color palette from server and hydrate UI state."""
|
"""Request color palette from server and hydrate UI state."""
|
||||||
@@ -1072,7 +1043,7 @@ class UIClient:
|
|||||||
|
|
||||||
def get_bank1_patterns(self):
|
def get_bank1_patterns(self):
|
||||||
return [
|
return [
|
||||||
"on", "off", "flicker", "alternating",
|
"alternating_phase", "off", "flicker", "alternating",
|
||||||
"pulse", "rainbow", "radiate", "segmented_movement",
|
"pulse", "rainbow", "radiate", "segmented_movement",
|
||||||
"-", "-", "-", "-", "-", "-", "-", "-",
|
"-", "-", "-", "-", "-", "-", "-", "-",
|
||||||
]
|
]
|
||||||
@@ -1099,7 +1070,19 @@ class UIClient:
|
|||||||
data = json.dumps({"pattern": pattern}).encode('utf-8')
|
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'})
|
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:
|
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:
|
except Exception as e:
|
||||||
logging.debug(f"Pattern REST update failed: {e}")
|
logging.debug(f"Pattern REST update failed: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1180,7 +1163,7 @@ class UIClient:
|
|||||||
self.selected_color_slot = slot_index
|
self.selected_color_slot = slot_index
|
||||||
rr, gg, bb = self.color_slots[slot_index]
|
rr, gg, bb = self.color_slots[slot_index]
|
||||||
# Persist selection via REST (first index is used for pattern color per api.md)
|
# 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.
|
# For immediate LED update, optional: send parameters API if required. Keeping color_change for now is removed per REST-only guidance.
|
||||||
on_close()
|
on_close()
|
||||||
|
|
||||||
@@ -1216,7 +1199,7 @@ class UIClient:
|
|||||||
if b is not None: cb = int(b)
|
if b is not None: cb = int(b)
|
||||||
self.color_slots[slot_index] = (cr, cg, cb)
|
self.color_slots[slot_index] = (cr, cg, cb)
|
||||||
# Update palette colors on backend via REST (no immediate output)
|
# 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
|
# Do not send color_change here; only send when user confirms via preview button
|
||||||
|
|
||||||
# ----------------------
|
# ----------------------
|
||||||
|
Reference in New Issue
Block a user