From 85a9553b84ff33aded6b9441a28d88c730b5d257 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 27 Dec 2025 14:34:44 -0500 Subject: [PATCH 01/16] Use websocket + REST API instead of last.json --- modalapi/mod.py | 71 ++++++++------- modalapi/modhandler.py | 81 ++++++++++-------- modalapi/websocket_bridge.py | 39 ++++++++- modalapi/ws_protocol.py | 161 +++++++++++++++++++++++++++++++++++ 4 files changed, 284 insertions(+), 68 deletions(-) create mode 100644 modalapi/ws_protocol.py diff --git a/modalapi/mod.py b/modalapi/mod.py index b9490919..15f47e0a 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -27,6 +27,8 @@ import modalapi.pedalboard as Pedalboard import modalapi.parameter as Parameter import modalapi.wifi as Wifi +from modalapi.websocket_bridge import AsyncWebSocketBridge +from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.footswitch import Footswitch @@ -166,7 +168,7 @@ class Current: def __init__(self, pedalboard): self.pedalboard = pedalboard self.presets = {} - self.preset_index = 0 + self.preset_index = 0 # Assumes pedalboard loads at snapshot 0 (default behavior) self.analog_controllers = {} # { type: (plugin_name, param_name) } class Deep: @@ -442,29 +444,41 @@ def poll_wifi(self): if self.current_menu == MenuType.MENU_INFO: self.system_info_update_wifi() - def poll_modui_changes(self): - # This poll looks for changes made via the MOD UI and tries to sync the pi-Stomp hardware - - # Look for a change of pedalboard - # - # If the pedalboard_modification_file timestamp has changed, extract the bundle path and set current pedalboard - # - # TODO this is an interim solution until better MOD-UI to pi-stomp event communication is added - # - if Path(self.pedalboard_modification_file).exists(): - ts = os.path.getmtime(self.pedalboard_modification_file) - if ts == self.pedalboard_change_timestamp: - return + def _handle_ws_message(self, raw_message: str): + """Handle incoming WebSocket message from MOD-UI using typed protocol.""" + msg = parse_message(raw_message) - # Timestamp changed - self.pedalboard_change_timestamp = ts + if isinstance(msg, LoadingEndMessage): + # Pedalboard finished loading + logging.info(f"WebSocket: Pedalboard loaded, snapshot={msg.snapshot_id}") self.lcd.draw_info_message("Loading...") mod_bundle = self.get_pedalboard_bundle_from_mod() - if mod_bundle: - logging.info("Pedalboard changed via MOD from: %s to: %s" % - (self.current.pedalboard.bundle, mod_bundle)) + if mod_bundle and mod_bundle != self.current.pedalboard.bundle: + logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}") pb = self.pedalboards[mod_bundle] self.set_current_pedalboard(pb) + # Update snapshot index + self.current.preset_index = msg.snapshot_id + + elif isinstance(msg, PedalSnapshotMessage): + # Snapshot changed within current pedalboard + logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") + self.current.preset_index = msg.snapshot_id + # Update LCD title with new snapshot name + self.update_lcd_title() + + def poll_modui_changes(self): + """Poll for changes from MOD-UI via WebSocket messages.""" + if self.ws_bridge is None: + return + + # Process all pending messages from WebSocket + messages = self.ws_bridge.get_received_messages() + for msg in messages: + try: + self._handle_ws_message(msg) + except Exception as e: + logging.error(f"Error handling WebSocket message '{msg}': {e}") # # Pedalboard Stuff @@ -501,18 +515,17 @@ def load_pedalboards(self): #logging.debug("Preset: %s" % self.get_current_preset_name()) def get_pedalboard_bundle_from_mod(self): - # Assumes the caller has already checked for existence of the file - mod_bundle = None - with open(self.pedalboard_modification_file, 'r') as file: - j = json.load(file) - mod_bundle = util.DICT_GET(j, 'pedalboard') - return mod_bundle + """Get current pedalboard bundle path from MOD-UI via REST API.""" + try: + resp = req.get(self.root_uri + "pedalboard/current") + if resp.status_code == 200: + return resp.text.strip() + except Exception as e: + logging.error(f"Failed to get current pedalboard from MOD: {e}") + return None def get_current_pedalboard_bundle_path(self): - mod_bundle = None - if Path(self.pedalboard_modification_file).exists(): - mod_bundle = self.get_pedalboard_bundle_from_mod() - return mod_bundle + return self.get_pedalboard_bundle_from_mod() def set_current_pedalboard(self, pedalboard): # Delete previous "current" diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 94bcc8e1..d346c452 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -28,6 +28,8 @@ import modalapi.pedalboard as Pedalboard import modalapi.wifi as Wifi import pistomp.settings as Settings +from modalapi.websocket_bridge import AsyncWebSocketBridge +from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.encodermidicontrol import EncoderMidiControl @@ -84,11 +86,6 @@ def __init__(self, audiocard, homedir): self.banks = {} self.current_bank = None - # This file is modified when the pedalboard is changed via MOD UI - self.pedalboard_modification_file = os.path.join(self.data_dir, "last.json") - self.pedalboard_change_timestamp = os.path.getmtime(self.pedalboard_modification_file)\ - if Path(self.pedalboard_modification_file).exists() else 0 - self.wifi_manager = Wifi.WifiManager() # Callback function map. Key is the user specified name, value is function from this handler @@ -118,7 +115,7 @@ class Current: def __init__(self, pedalboard): self.pedalboard = pedalboard self.presets = {} - self.preset_index = 0 + self.preset_index = 0 # Assumes pedalboard loads at snapshot 0 (default behavior) self.analog_controllers = {} # { type: (plugin_name, param_name) } def add_hardware(self, hardware): @@ -213,27 +210,40 @@ def universal_encoder_sw(self, value, obj=None): if self.lcd is not None: self.lcd.enc_sw(value) + def _handle_ws_message(self, raw_message: str): + """Handle incoming WebSocket message from MOD-UI using typed protocol.""" + msg = parse_message(raw_message) + + if isinstance(msg, LoadingEndMessage): + # Pedalboard finished loading + logging.info(f"WebSocket: Pedalboard loaded, snapshot={msg.snapshot_id}") + self.lcd.draw_info_message("Loading...") + mod_bundle = self.get_pedalboard_bundle_from_mod() + if mod_bundle and mod_bundle != self.current.pedalboard.bundle: + logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}") + pb = self.reload_pedalboard(mod_bundle) + self.set_current_pedalboard(pb) + # Update snapshot index + self.current.preset_index = msg.snapshot_id + + elif isinstance(msg, PedalSnapshotMessage): + # Snapshot changed within current pedalboard + logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") + self.current.preset_index = msg.snapshot_id + self.lcd.draw_title(self.current.pedalboard.title, msg.snapshot_name, False, True, False) + def poll_modui_changes(self): - # This poll looks for changes made via the MOD UI and tries to sync the pi-Stomp hardware - - # Look for a change of pedalboard - # - # If the pedalboard_modification_file timestamp has changed, extract the bundle path and set current pedalboard - # - # TODO this is an interim solution until better MOD-UI to pi-stomp event communication is added - # - if Path(self.pedalboard_modification_file).exists(): - ts = os.path.getmtime(self.pedalboard_modification_file) - if ts != self.pedalboard_change_timestamp: - # Timestamp changed - self.pedalboard_change_timestamp = ts - self.lcd.draw_info_message("Loading...") - mod_bundle = self.get_pedalboard_bundle_from_mod() - if mod_bundle: - logging.info("Pedalboard changed via MOD from: %s to: %s" % - (self.current.pedalboard.bundle, mod_bundle)) - pb = self.reload_pedalboard(mod_bundle) - self.set_current_pedalboard(pb) + """Poll for changes from MOD-UI via WebSocket messages.""" + if self.ws_bridge is None: + return + + # Process all pending messages from WebSocket + messages = self.ws_bridge.get_received_messages() + for msg in messages: + try: + self._handle_ws_message(msg) + except Exception as e: + logging.error(f"Error handling WebSocket message '{msg}': {e}") # Look for a change in banks file if Path(self.banks_file).exists(): @@ -320,18 +330,17 @@ def reload_pedalboard(self, bundle): return pedalboard def get_pedalboard_bundle_from_mod(self): - # Assumes the caller has already checked for existence of the file - mod_bundle = None - with open(self.pedalboard_modification_file, 'r') as file: - j = json.load(file) - mod_bundle = util.DICT_GET(j, 'pedalboard') - return mod_bundle + """Get current pedalboard bundle path from MOD-UI via REST API.""" + try: + resp = req.get(self.root_uri + "pedalboard/current") + if resp.status_code == 200: + return resp.text.strip() + except Exception as e: + logging.error(f"Failed to get current pedalboard from MOD: {e}") + return None def get_current_pedalboard_bundle_path(self): - mod_bundle = None - if Path(self.pedalboard_modification_file).exists(): - mod_bundle = self.get_pedalboard_bundle_from_mod() - return mod_bundle + return self.get_pedalboard_bundle_from_mod() def set_current_pedalboard(self, pedalboard): # Delete previous "current" diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py index f74942db..4ef431d5 100644 --- a/modalapi/websocket_bridge.py +++ b/modalapi/websocket_bridge.py @@ -54,6 +54,7 @@ def __init__(self, ws_url: str = 'ws://localhost:80/websocket', max_queue_size: self.ws_url = ws_url self.backpressure_threshold = backpressure_threshold self.command_queue: queue.Queue = queue.Queue(maxsize=max_queue_size) + self.received_queue: queue.Queue = queue.Queue() # Thread-safe queue for incoming messages self.loop: Optional[asyncio.AbstractEventLoop] = None self.ws: Optional[websockets.WebSocketClientProtocol] = None self.thread: Optional[threading.Thread] = None @@ -62,6 +63,7 @@ def __init__(self, ws_url: str = 'ws://localhost:80/websocket', max_queue_size: # Metrics self.messages_sent = 0 self.messages_dropped = 0 + self.messages_received = 0 self.backpressure_events = 0 self.backpressure_active = False # Track if we're currently in backpressure state @@ -136,6 +138,21 @@ def get_stats(self) -> dict: return stats + def get_received_messages(self) -> list: + """ + Get all pending received messages from server (non-blocking). + + Called from main thread to process server messages. + Returns list of message strings. + """ + messages = [] + try: + while True: + messages.append(self.received_queue.get_nowait()) + except queue.Empty: + pass + return messages + def clear_queue(self) -> int: """ Clear all pending messages from the queue. @@ -192,7 +209,11 @@ async def _async_worker(self): logging.info(f"WebSocket connected to {self.ws_url}") retry_delay = 1.0 # Reset retry delay on successful connect - await self._process_queue(ws) + # Run send and receive tasks concurrently + await asyncio.gather( + self._send_messages(ws), + self._receive_messages(ws) + ) except (websockets.exceptions.WebSocketException, OSError, ConnectionRefusedError) as e: logging.error(f"WebSocket connection error: {e}") @@ -206,8 +227,8 @@ async def _async_worker(self): self.ws = None await asyncio.sleep(retry_delay) - async def _process_queue(self, ws): - """Process messages from queue and send via WebSocket.""" + async def _send_messages(self, ws): + """Send messages from queue to WebSocket.""" while self.running: try: # Get message from thread-safe queue (non-blocking) @@ -258,6 +279,18 @@ async def _process_queue(self, ws): logging.error(f"Error sending message '{msg[:50]}...': {e}") # Continue processing other messages + async def _receive_messages(self, ws): + """Receive messages from WebSocket and queue them for main thread.""" + try: + async for message in ws: + self.received_queue.put(message) + self.messages_received += 1 + logging.debug(f"Received message from server: {message[:100]}") + except websockets.exceptions.ConnectionClosed: + logging.debug("WebSocket receive loop closed") + except Exception as e: + logging.error(f"Error receiving message: {e}") + def _get_write_buffer_size(self, ws) -> int: """ Get the WebSocket's TCP write buffer size. diff --git a/modalapi/ws_protocol.py b/modalapi/ws_protocol.py new file mode 100644 index 00000000..9a223e5a --- /dev/null +++ b/modalapi/ws_protocol.py @@ -0,0 +1,161 @@ +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pi-stomp. If not, see . + +""" +Type-safe WebSocket protocol for MOD-UI communication. + +Defines message types received from mod-ui WebSocket server. +""" + +from dataclasses import dataclass +from typing import Union +import logging + + +@dataclass +class LoadingStartMessage: + """Pedalboard loading started.""" + is_default: bool + + +@dataclass +class LoadingEndMessage: + """Pedalboard loading finished.""" + snapshot_id: int + + +@dataclass +class PedalSnapshotMessage: + """Snapshot changed within current pedalboard.""" + snapshot_id: int + snapshot_name: str + + +@dataclass +class SizeMessage: + """Pedalboard canvas size.""" + width: int + height: int + + +@dataclass +class AddHwPortMessage: + """Hardware port appeared (JACK).""" + port_name: str + port_type: str # "audio" or "midi" + is_output: bool + title: str + index: int + + +@dataclass +class RemoveHwPortMessage: + """Hardware port disappeared.""" + port_name: str + + +@dataclass +class TrueBypassMessage: + """True bypass state changed.""" + left: int + right: int + + +@dataclass +class UnknownMessage: + """Message type we don't handle yet.""" + raw: str + + +# Union of all message types +WebSocketMessage = Union[ + LoadingStartMessage, + LoadingEndMessage, + PedalSnapshotMessage, + SizeMessage, + AddHwPortMessage, + RemoveHwPortMessage, + TrueBypassMessage, + UnknownMessage, +] + + +def parse_message(raw_message: str) -> WebSocketMessage: + """ + Parse raw WebSocket message string into typed message object. + + Args: + raw_message: Raw message string from WebSocket + + Returns: + Typed message object + """ + parts = raw_message.split(' ', 2) + if not parts: + return UnknownMessage(raw=raw_message) + + cmd = parts[0] + + try: + if cmd == "loading_start": + is_default = bool(int(parts[1])) if len(parts) > 1 else False + return LoadingStartMessage(is_default=is_default) + + elif cmd == "loading_end": + snapshot_id = int(parts[1]) if len(parts) > 1 else 0 + return LoadingEndMessage(snapshot_id=snapshot_id) + + elif cmd == "pedal_snapshot": + snapshot_id = int(parts[1]) if len(parts) > 1 else 0 + snapshot_name = parts[2] if len(parts) > 2 else "" + return PedalSnapshotMessage(snapshot_id=snapshot_id, snapshot_name=snapshot_name) + + elif cmd == "size": + width = int(parts[1]) if len(parts) > 1 else 0 + height = int(parts[2].split()[0]) if len(parts) > 2 else 0 + return SizeMessage(width=width, height=height) + + elif cmd == "add_hw_port": + # Format: add_hw_port /graph/{name} {type} {isOutput} {title} {index} + if len(parts) > 1: + details = parts[1].split(' ', 4) + port_name = details[0] if len(details) > 0 else "" + port_type = details[1] if len(details) > 1 else "" + is_output = bool(int(details[2])) if len(details) > 2 else False + title = details[3] if len(details) > 3 else "" + index = int(details[4]) if len(details) > 4 else 0 + return AddHwPortMessage( + port_name=port_name, + port_type=port_type, + is_output=is_output, + title=title, + index=index + ) + + elif cmd == "remove_hw_port": + port_name = parts[1] if len(parts) > 1 else "" + return RemoveHwPortMessage(port_name=port_name) + + elif cmd == "truebypass": + left = int(parts[1]) if len(parts) > 1 else 0 + right = int(parts[2].split()[0]) if len(parts) > 2 else 0 + return TrueBypassMessage(left=left, right=right) + + except (ValueError, IndexError) as e: + logging.warning(f"Failed to parse WebSocket message '{raw_message}': {e}") + return UnknownMessage(raw=raw_message) + + # Unknown command + return UnknownMessage(raw=raw_message) From a87a0c9a07962ee65d00835505eebd5968a806b1 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 27 Dec 2025 14:41:51 -0500 Subject: [PATCH 02/16] Fix v3 hardware --- modalapi/modhandler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index d346c452..757f1561 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -230,7 +230,7 @@ def _handle_ws_message(self, raw_message: str): # Snapshot changed within current pedalboard logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") self.current.preset_index = msg.snapshot_id - self.lcd.draw_title(self.current.pedalboard.title, msg.snapshot_name, False, True, False) + self.lcd.draw_title() def poll_modui_changes(self): """Poll for changes from MOD-UI via WebSocket messages.""" From 1cc5f2d2f3b22575f346eacb9814e1acd21027c1 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 29 Dec 2025 02:04:26 -0500 Subject: [PATCH 03/16] Half-measure: last.json mtime for pedalboards, websocket for snapshots --- modalapi/mod.py | 77 ++++++++++++++++------------------ modalapi/modhandler.py | 71 +++++++++++++++---------------- modalapi/pedalboard_monitor.py | 76 +++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 76 deletions(-) create mode 100644 modalapi/pedalboard_monitor.py diff --git a/modalapi/mod.py b/modalapi/mod.py index 15f47e0a..b2098fde 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -29,6 +29,7 @@ import modalapi.wifi as Wifi from modalapi.websocket_bridge import AsyncWebSocketBridge from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage +from modalapi.pedalboard_monitor import PedalboardMonitor from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.footswitch import Footswitch @@ -133,15 +134,15 @@ def __init__(self, audiocard, homedir): self.current = None # pointer to Current class self.deep = None # pointer to current Deep class + # Stores snapshot index from loading_end until pedalboard change is detected + self.next_pedalboard_preset_index = None + self.selected_menu_index = 0 self.menu_items = None self.current_menu = MenuType.MENU_NONE - # This file is modified when the pedalboard is changed via MOD UI - self.pedalboard_modification_file = "/home/pistomp/data/last.json" - self.pedalboard_change_timestamp = os.path.getmtime(self.pedalboard_modification_file)\ - if Path(self.pedalboard_modification_file).exists() else 0 - + self.data_dir = "/home/pistomp/data" + self.pedalboard_monitor = PedalboardMonitor(self.data_dir) self.wifi_manager = Wifi.WifiManager() # Callback function map. Key is the user specified name, value is function from this handler @@ -449,36 +450,36 @@ def _handle_ws_message(self, raw_message: str): msg = parse_message(raw_message) if isinstance(msg, LoadingEndMessage): - # Pedalboard finished loading - logging.info(f"WebSocket: Pedalboard loaded, snapshot={msg.snapshot_id}") + logging.info(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}") + self.next_pedalboard_preset_index = msg.snapshot_id + + elif isinstance(msg, PedalSnapshotMessage): + if self.next_pedalboard_preset_index is not None: + logging.info(f"WebSocket: Pre-switch snapshot changed to {msg.snapshot_id}") + self.next_pedalboard_preset_index = msg.snapshot_id + else: + logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") + self.current.preset_index = msg.snapshot_id + self.update_lcd_title() + + def poll_modui_changes(self): + """Poll for changes from MOD-UI""" + if self.ws_bridge is not None: + messages = self.ws_bridge.get_received_messages() + for msg in messages: + try: + self._handle_ws_message(msg) + except Exception as e: + logging.error(f"Error handling WebSocket message '{msg}': {e}") + + # Check for pedalboard change via last.json + if self.pedalboard_monitor.check_for_change(): self.lcd.draw_info_message("Loading...") - mod_bundle = self.get_pedalboard_bundle_from_mod() + mod_bundle = self.pedalboard_monitor.get_current_pedalboard_bundle() if mod_bundle and mod_bundle != self.current.pedalboard.bundle: logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}") pb = self.pedalboards[mod_bundle] self.set_current_pedalboard(pb) - # Update snapshot index - self.current.preset_index = msg.snapshot_id - - elif isinstance(msg, PedalSnapshotMessage): - # Snapshot changed within current pedalboard - logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") - self.current.preset_index = msg.snapshot_id - # Update LCD title with new snapshot name - self.update_lcd_title() - - def poll_modui_changes(self): - """Poll for changes from MOD-UI via WebSocket messages.""" - if self.ws_bridge is None: - return - - # Process all pending messages from WebSocket - messages = self.ws_bridge.get_received_messages() - for msg in messages: - try: - self._handle_ws_message(msg) - except Exception as e: - logging.error(f"Error handling WebSocket message '{msg}': {e}") # # Pedalboard Stuff @@ -514,18 +515,8 @@ def load_pedalboards(self): #logging.debug("Preset: %s %d" % (bund, self.host.pedalboard_preset)) # this value not initialized #logging.debug("Preset: %s" % self.get_current_preset_name()) - def get_pedalboard_bundle_from_mod(self): - """Get current pedalboard bundle path from MOD-UI via REST API.""" - try: - resp = req.get(self.root_uri + "pedalboard/current") - if resp.status_code == 200: - return resp.text.strip() - except Exception as e: - logging.error(f"Failed to get current pedalboard from MOD: {e}") - return None - def get_current_pedalboard_bundle_path(self): - return self.get_pedalboard_bundle_from_mod() + return self.pedalboard_monitor.get_current_pedalboard_bundle() def set_current_pedalboard(self, pedalboard): # Delete previous "current" @@ -534,6 +525,10 @@ def set_current_pedalboard(self, pedalboard): # Create a new "current" self.current = self.Current(pedalboard) + if self.next_pedalboard_preset_index is not None: + self.current.preset_index = self.next_pedalboard_preset_index + self.next_pedalboard_preset_index = None + # Load Pedalboard specific config (overrides default set during initial hardware init) config_file = Path(pedalboard.bundle) / "config.yml" cfg = None diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 757f1561..7c3f7002 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -30,6 +30,7 @@ import pistomp.settings as Settings from modalapi.websocket_bridge import AsyncWebSocketBridge from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage +from modalapi.pedalboard_monitor import PedalboardMonitor from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.encodermidicontrol import EncoderMidiControl @@ -75,6 +76,9 @@ def __init__(self, audiocard, homedir): self.current = None # pointer to Current class self.lcd = None + # Stores snapshot index from loading_end until pedalboard change is detected + self.next_pedalboard_preset_index = None + # Backup self.backup_dir = "/media/usb0/backups" self.backup_file = "pistomp_backup.zip" @@ -86,6 +90,8 @@ def __init__(self, audiocard, homedir): self.banks = {} self.current_bank = None + self.pedalboard_monitor = PedalboardMonitor(self.data_dir) + self.wifi_manager = Wifi.WifiManager() # Callback function map. Key is the user specified name, value is function from this handler @@ -215,35 +221,36 @@ def _handle_ws_message(self, raw_message: str): msg = parse_message(raw_message) if isinstance(msg, LoadingEndMessage): - # Pedalboard finished loading - logging.info(f"WebSocket: Pedalboard loaded, snapshot={msg.snapshot_id}") + logging.info(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}") + self.next_pedalboard_preset_index = msg.snapshot_id + + elif isinstance(msg, PedalSnapshotMessage): + if self.next_pedalboard_preset_index is not None: + logging.info(f"WebSocket: Pre-switch snapshot changed to {msg.snapshot_id}") + self.next_pedalboard_preset_index = msg.snapshot_id + else: + logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") + self.current.preset_index = msg.snapshot_id + self.lcd.draw_title() + + def poll_modui_changes(self): + """Poll for changes from MOD-UI""" + if self.ws_bridge is not None: + messages = self.ws_bridge.get_received_messages() + for msg in messages: + try: + self._handle_ws_message(msg) + except Exception as e: + logging.error(f"Error handling WebSocket message '{msg}': {e}") + + # Check for pedalboard change via last.json + if self.pedalboard_monitor.check_for_change(): self.lcd.draw_info_message("Loading...") - mod_bundle = self.get_pedalboard_bundle_from_mod() + mod_bundle = self.pedalboard_monitor.get_current_pedalboard_bundle() if mod_bundle and mod_bundle != self.current.pedalboard.bundle: logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}") pb = self.reload_pedalboard(mod_bundle) self.set_current_pedalboard(pb) - # Update snapshot index - self.current.preset_index = msg.snapshot_id - - elif isinstance(msg, PedalSnapshotMessage): - # Snapshot changed within current pedalboard - logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") - self.current.preset_index = msg.snapshot_id - self.lcd.draw_title() - - def poll_modui_changes(self): - """Poll for changes from MOD-UI via WebSocket messages.""" - if self.ws_bridge is None: - return - - # Process all pending messages from WebSocket - messages = self.ws_bridge.get_received_messages() - for msg in messages: - try: - self._handle_ws_message(msg) - except Exception as e: - logging.error(f"Error handling WebSocket message '{msg}': {e}") # Look for a change in banks file if Path(self.banks_file).exists(): @@ -329,18 +336,8 @@ def reload_pedalboard(self, bundle): return pedalboard - def get_pedalboard_bundle_from_mod(self): - """Get current pedalboard bundle path from MOD-UI via REST API.""" - try: - resp = req.get(self.root_uri + "pedalboard/current") - if resp.status_code == 200: - return resp.text.strip() - except Exception as e: - logging.error(f"Failed to get current pedalboard from MOD: {e}") - return None - def get_current_pedalboard_bundle_path(self): - return self.get_pedalboard_bundle_from_mod() + return self.pedalboard_monitor.get_current_pedalboard_bundle() def set_current_pedalboard(self, pedalboard): # Delete previous "current" @@ -349,6 +346,10 @@ def set_current_pedalboard(self, pedalboard): # Create a new "current" self.current = self.Current(pedalboard) + if self.next_pedalboard_preset_index is not None: + self.current.preset_index = self.next_pedalboard_preset_index + self.next_pedalboard_preset_index = None + # Load Pedalboard specific config (overrides default set during initial hardware init) config_file = Path(pedalboard.bundle) / "config.yml" cfg = None diff --git a/modalapi/pedalboard_monitor.py b/modalapi/pedalboard_monitor.py new file mode 100644 index 00000000..c7e18091 --- /dev/null +++ b/modalapi/pedalboard_monitor.py @@ -0,0 +1,76 @@ +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pi-stomp. If not, see . + +""" +Monitor for pedalboard changes via last.json file. +""" + +import json +import logging +import os +from pathlib import Path +from typing import Optional + + +class PedalboardMonitor: + """ + Because the loading_end WebSocket message does not include the + pedalboard name, we monitor last.json for changes to get it + (happens ~4s after loading_end message). + """ + + def __init__(self, data_dir: str): + """ + Args: + data_dir: Path to MOD-UI data directory (usually /home/pistomp/data) + """ + self.last_state_file = os.path.join(data_dir, "last.json") + self.last_state_timestamp = self._get_current_timestamp() + + def _get_current_timestamp(self) -> float: + if Path(self.last_state_file).exists(): + return os.path.getmtime(self.last_state_file) + return 0.0 + + def check_for_change(self) -> bool: + """ + Check if last.json has been modified since last check. + + Returns: + True if file has changed, False otherwise + """ + current_timestamp = self._get_current_timestamp() + if current_timestamp != self.last_state_timestamp: + self.last_state_timestamp = current_timestamp + return True + return False + + def get_current_pedalboard_bundle(self) -> Optional[str]: + """ + Read current pedalboard bundle name from last.json. + + Returns: + Pedalboard name, or None if file doesn't exist or can't be read + """ + if not Path(self.last_state_file).exists(): + return None + + try: + with open(self.last_state_file, "r") as f: + data = json.load(f) + return data.get("pedalboard") + except (json.JSONDecodeError, IOError) as e: + logging.warning(f"Failed to read {self.last_state_file}: {e}") + return None From 43958c42b72b44fb7871a5fe8cd197eda63a1699 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 29 Dec 2025 02:56:37 -0500 Subject: [PATCH 04/16] Load pedalboards and create snapshots if they don't exist --- modalapi/mod.py | 8 ++++++++ modalapi/modhandler.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/modalapi/mod.py b/modalapi/mod.py index b2098fde..bad5a0fc 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -459,6 +459,10 @@ def _handle_ws_message(self, raw_message: str): self.next_pedalboard_preset_index = msg.snapshot_id else: logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") + + if msg.snapshot_id not in self.current.presets: + self.current.presets[msg.snapshot_id] = msg.snapshot_name + self.current.preset_index = msg.snapshot_id self.update_lcd_title() @@ -478,6 +482,10 @@ def poll_modui_changes(self): mod_bundle = self.pedalboard_monitor.get_current_pedalboard_bundle() if mod_bundle and mod_bundle != self.current.pedalboard.bundle: logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}") + + if mod_bundle not in self.pedalboards: + self.load_pedalboards() + pb = self.pedalboards[mod_bundle] self.set_current_pedalboard(pb) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 7c3f7002..937365fe 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -230,6 +230,10 @@ def _handle_ws_message(self, raw_message: str): self.next_pedalboard_preset_index = msg.snapshot_id else: logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") + + if msg.snapshot_id not in self.current.presets: + self.current.presets[msg.snapshot_id] = msg.snapshot_name + self.current.preset_index = msg.snapshot_id self.lcd.draw_title() @@ -249,6 +253,10 @@ def poll_modui_changes(self): mod_bundle = self.pedalboard_monitor.get_current_pedalboard_bundle() if mod_bundle and mod_bundle != self.current.pedalboard.bundle: logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}") + + if mod_bundle not in self.pedalboards: + self.load_pedalboards() + pb = self.reload_pedalboard(mod_bundle) self.set_current_pedalboard(pb) From ba1a48affdd4220eb63d4c0e171aa9c93b56283a Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 29 Dec 2025 12:34:46 -0500 Subject: [PATCH 05/16] Fix new snapshot sync --- modalapi/modhandler.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 937365fe..c8ec0712 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -226,8 +226,23 @@ def _handle_ws_message(self, raw_message: str): elif isinstance(msg, PedalSnapshotMessage): if self.next_pedalboard_preset_index is not None: - logging.info(f"WebSocket: Pre-switch snapshot changed to {msg.snapshot_id}") - self.next_pedalboard_preset_index = msg.snapshot_id + # Check if we're still on the same pedalboard (stale flag from previous load) + mod_bundle = self.pedalboard_monitor.get_current_pedalboard_bundle() + if mod_bundle and self.current and mod_bundle == self.current.pedalboard.bundle: + # Same pedalboard - this is a new snapshot on current board, not a pre-switch + logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name}) - clearing stale pre-switch flag") + self.next_pedalboard_preset_index = None + + if msg.snapshot_id not in self.current.presets: + self.current.presets[msg.snapshot_id] = msg.snapshot_name + + self.current.preset_index = msg.snapshot_id + self._handle_collage_mode_snapshot_change(msg.snapshot_id) + self.lcd.draw_title() + else: + # Different pedalboard pending - this is a legitimate pre-switch update + logging.info(f"WebSocket: Pre-switch snapshot changed to {msg.snapshot_id}") + self.next_pedalboard_preset_index = msg.snapshot_id else: logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") @@ -259,6 +274,13 @@ def poll_modui_changes(self): pb = self.reload_pedalboard(mod_bundle) self.set_current_pedalboard(pb) + elif mod_bundle and self.next_pedalboard_preset_index is not None: + # Same pedalboard reloaded with a pending snapshot - apply it now + logging.info(f"Applying pending snapshot {self.next_pedalboard_preset_index} to current pedalboard") + self.current.preset_index = self.next_pedalboard_preset_index + self._handle_collage_mode_snapshot_change(self.next_pedalboard_preset_index) + self.next_pedalboard_preset_index = None + self.lcd.draw_title() # Look for a change in banks file if Path(self.banks_file).exists(): From 845d36eeee2759b05cb92ef64a3a731259513825 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 29 Dec 2025 12:34:54 -0500 Subject: [PATCH 06/16] Reduce noisy logs --- modalapi/websocket_bridge.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py index 4ef431d5..66c3e44e 100644 --- a/modalapi/websocket_bridge.py +++ b/modalapi/websocket_bridge.py @@ -32,6 +32,11 @@ logging.error("websockets library not installed. Run: pip install websockets") raise +def should_log_message(message: str) -> bool: + if message == "ping": + return False + return not message.startswith(('stats ', 'sys_stats ')) + class AsyncWebSocketBridge: """ @@ -285,7 +290,8 @@ async def _receive_messages(self, ws): async for message in ws: self.received_queue.put(message) self.messages_received += 1 - logging.debug(f"Received message from server: {message[:100]}") + if should_log_message(message): + logging.debug(f"Received message from server: {message[:100]}") except websockets.exceptions.ConnectionClosed: logging.debug("WebSocket receive loop closed") except Exception as e: From 374e60abc46c0a8018720fe4da45e56fc940c30f Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 29 Dec 2025 23:00:38 -0500 Subject: [PATCH 07/16] Be a good websocket citizen --- modalapi/websocket_bridge.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py index 66c3e44e..7efcca85 100644 --- a/modalapi/websocket_bridge.py +++ b/modalapi/websocket_bridge.py @@ -288,6 +288,16 @@ async def _receive_messages(self, ws): """Receive messages from WebSocket and queue them for main thread.""" try: async for message in ws: + + # We must respond to pings and data_ready immediately + # Otherwise, other websocket clients break + if message == "ping": + await ws.send("pong") + continue + elif message.startswith("data_ready "): + await ws.send(message) + continue + self.received_queue.put(message) self.messages_received += 1 if should_log_message(message): From 8874e886003325b49270d5650f0b2b3c078ce9e6 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Wed, 31 Dec 2025 12:31:50 -0500 Subject: [PATCH 08/16] Fix loading_end with -1 --- modalapi/modhandler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index c8ec0712..e2517367 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -222,7 +222,8 @@ def _handle_ws_message(self, raw_message: str): if isinstance(msg, LoadingEndMessage): logging.info(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}") - self.next_pedalboard_preset_index = msg.snapshot_id + # Sometimes mod-ui sends us -1 for preset index, but shows 0 anyway ("Default") + self.next_pedalboard_preset_index = max(0, msg.snapshot_id) elif isinstance(msg, PedalSnapshotMessage): if self.next_pedalboard_preset_index is not None: From b5ea08601f70ee8e533fdc2412a61f6bdd36df29 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Wed, 31 Dec 2025 12:48:15 -0500 Subject: [PATCH 09/16] Backport ignoring output_set logs --- modalapi/websocket_bridge.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py index 7efcca85..9a6b66f6 100644 --- a/modalapi/websocket_bridge.py +++ b/modalapi/websocket_bridge.py @@ -33,9 +33,10 @@ raise def should_log_message(message: str) -> bool: + """Filter out high-frequency messages to reduce log spam.""" if message == "ping": return False - return not message.startswith(('stats ', 'sys_stats ')) + return not message.startswith(('output_set ', 'stats ', 'sys_stats ')) class AsyncWebSocketBridge: From 51b28fd91c10393bc030cec76349ebe4e625b0d0 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 2 Jan 2026 11:59:08 -0500 Subject: [PATCH 10/16] Make websocket code resilient to not being installed --- modalapi/websocket_bridge.py | 48 ++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py index 9a6b66f6..90403a7f 100644 --- a/modalapi/websocket_bridge.py +++ b/modalapi/websocket_bridge.py @@ -28,15 +28,19 @@ try: import websockets + + WEBSOCKETS_AVAILABLE = True except ImportError: - logging.error("websockets library not installed. Run: pip install websockets") + WEBSOCKETS_AVAILABLE = False + websockets = None raise + def should_log_message(message: str) -> bool: """Filter out high-frequency messages to reduce log spam.""" if message == "ping": return False - return not message.startswith(('output_set ', 'stats ', 'sys_stats ')) + return not message.startswith(("output_set ", "stats ", "sys_stats ")) class AsyncWebSocketBridge: @@ -48,7 +52,9 @@ class AsyncWebSocketBridge: no functional changes to timing). """ - def __init__(self, ws_url: str = 'ws://localhost:80/websocket', max_queue_size: int = 100, backpressure_threshold: int = 8192): + def __init__( + self, ws_url: str = "ws://localhost:80/websocket", max_queue_size: int = 100, backpressure_threshold: int = 8192 + ): """ Initialize WebSocket bridge. @@ -75,6 +81,10 @@ def __init__(self, ws_url: str = 'ws://localhost:80/websocket', max_queue_size: def start(self): """Start background async worker thread.""" + if not WEBSOCKETS_AVAILABLE: + logging.warning("websockets library not installed. Run: pip3 install websockets") + return + if self.running: logging.warning("WebSocket bridge already running") return @@ -87,6 +97,7 @@ def start(self): def stop(self): """Stop background worker and cleanup.""" if not self.running: + logging.warning("WebSocket bridge not running") return self.running = False @@ -107,7 +118,7 @@ def send_parameter(self, instance_id: str, symbol: str, value: float) -> bool: True if queued successfully, False if queue full (backpressure) """ # Strip leading slash if present (instance_id may be "/StompBox_fuzz" or "StompBox_fuzz") - instance_id = instance_id.lstrip('/') + instance_id = instance_id.lstrip("/") msg = f"param_set /graph/{instance_id}/{symbol} {value}" try: @@ -131,16 +142,16 @@ def get_queue_depth(self) -> int: def get_stats(self) -> dict: """Get performance statistics.""" stats = { - 'queue_depth': self.get_queue_depth(), - 'messages_sent': self.messages_sent, - 'messages_dropped': self.messages_dropped, - 'backpressure_events': self.backpressure_events, - 'backpressure_active': self.backpressure_active, + "queue_depth": self.get_queue_depth(), + "messages_sent": self.messages_sent, + "messages_dropped": self.messages_dropped, + "backpressure_events": self.backpressure_events, + "backpressure_active": self.backpressure_active, } # Add write buffer size if available if self.ws: - stats['write_buffer_bytes'] = self._get_write_buffer_size(self.ws) + stats["write_buffer_bytes"] = self._get_write_buffer_size(self.ws) return stats @@ -206,9 +217,9 @@ async def _async_worker(self): # Connect to WebSocket async with websockets.connect( self.ws_url, - max_queue=32, # Allow 32 messages queued in websockets lib + max_queue=32, # Allow 32 messages queued in websockets lib write_limit=65536, # 64 KiB write buffer - ping_interval=None, # Disable automatic pings (we'll use manual ping for backpressure) + ping_interval=None, # Disable automatic pings (we'll use manual ping for backpressure) close_timeout=1.0, # Quick close on shutdown ) as ws: self.ws = ws @@ -216,10 +227,7 @@ async def _async_worker(self): retry_delay = 1.0 # Reset retry delay on successful connect # Run send and receive tasks concurrently - await asyncio.gather( - self._send_messages(ws), - self._receive_messages(ws) - ) + await asyncio.gather(self._send_messages(ws), self._receive_messages(ws)) except (websockets.exceptions.WebSocketException, OSError, ConnectionRefusedError) as e: logging.error(f"WebSocket connection error: {e}") @@ -268,14 +276,13 @@ async def _send_messages(self, ws): # Falling edge - exiting backpressure self.backpressure_active = False logging.info( - f"WebSocket backpressure CLEAR: {buffer_size} bytes buffered, " - f"queue={self.get_queue_depth()}" + f"WebSocket backpressure CLEAR: {buffer_size} bytes buffered, queue={self.get_queue_depth()}" ) # Periodically log stats if self.messages_sent % 1000 == 0: stats = self.get_stats() - stats['write_buffer_bytes'] = buffer_size + stats["write_buffer_bytes"] = buffer_size logging.debug(f"WebSocket stats: {stats}") except websockets.exceptions.ConnectionClosed as e: @@ -289,7 +296,6 @@ async def _receive_messages(self, ws): """Receive messages from WebSocket and queue them for main thread.""" try: async for message in ws: - # We must respond to pings and data_ready immediately # Otherwise, other websocket clients break if message == "ping": @@ -323,7 +329,7 @@ def _get_write_buffer_size(self, ws) -> int: Number of bytes in write buffer, or 0 if unavailable """ try: - if hasattr(ws, 'transport') and ws.transport: + if hasattr(ws, "transport") and ws.transport: return ws.transport.get_write_buffer_size() return 0 except Exception: From 86d4588866e2ade40350005f195fc0f8d52859f7 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Wed, 7 Jan 2026 18:58:38 -0500 Subject: [PATCH 11/16] Bring back websocket handler --- modalapi/mod.py | 19 +++++++++++++++++++ modalapi/modhandler.py | 21 +++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/modalapi/mod.py b/modalapi/mod.py index bad5a0fc..4b39c8df 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -145,6 +145,20 @@ def __init__(self, audiocard, homedir): self.pedalboard_monitor = PedalboardMonitor(self.data_dir) self.wifi_manager = Wifi.WifiManager() + # WebSocket bridge for MOD-UI communication + self.ws_bridge = None + try: + self.ws_bridge = AsyncWebSocketBridge( + ws_url='ws://localhost:80/websocket', + max_queue_size=100, + backpressure_threshold=8192 # 8 KB + ) + self.ws_bridge.start() + logging.info("WebSocket bridge started") + except Exception as e: + logging.warning(f"Failed to initialize WebSocket bridge: {e}") + + # Callback function map. Key is the user specified name, value is function from this handler # Used for calling handler callbacks pointed to by names which may be user set in the config file self.callbacks = {"set_mod_tap_tempo": self.set_mod_tap_tempo, @@ -156,10 +170,15 @@ def __del__(self): logging.info("Handler cleanup") if self.wifi_manager: del self.wifi_manager + if self.ws_bridge is not None: + self.ws_bridge.stop() def cleanup(self): if self.lcd is not None: self.lcd.cleanup() + if self.ws_bridge is not None: + self.ws_bridge.stop() + logging.info("WebSocket bridge stopped") # Container for dynamic data which is unique to the "current" pedalboard # The self.current pointed above will point to this object which gets diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index e2517367..b4669e8c 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -13,8 +13,6 @@ # You should have received a copy of the GNU General Public License # along with pi-stomp. If not, see . -from pistomp.handler import Handler - import json import logging import os @@ -94,6 +92,19 @@ def __init__(self, audiocard, homedir): self.wifi_manager = Wifi.WifiManager() + # WebSocket bridge for MOD-UI communication + self.ws_bridge = None + try: + self.ws_bridge = AsyncWebSocketBridge( + ws_url='ws://localhost:80/websocket', + max_queue_size=100, + backpressure_threshold=8192 # 8 KB + ) + self.ws_bridge.start() + logging.info("WebSocket bridge started") + except Exception as e: + logging.warning(f"Failed to initialize WebSocket bridge: {e}") + # Callback function map. Key is the user specified name, value is function from this handler # Used for calling handler callbacks pointed to by names which may be user set in the config file self.callbacks = {"set_mod_tap_tempo": self.set_mod_tap_tempo, @@ -107,11 +118,17 @@ def __del__(self): logging.info("Handler cleanup") if self.wifi_manager: del self.wifi_manager + if self.ws_bridge is not None: + self.ws_bridge.stop() + def cleanup(self): if self.lcd is not None: self.lcd.cleanup() if self.hardware is not None: self.hardware.cleanup() + if self.ws_bridge is not None: + self.ws_bridge.stop() + logging.info("WebSocket bridge stopped") # Container for dynamic data which is unique to the "current" pedalboard # The self.current pointed above will point to this object which gets From b76fd9f59efb9ec3c63315ba0b6759a0ca523251 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 19 Apr 2026 14:22:12 -0400 Subject: [PATCH 12/16] Guard against self.current being None during pedalboard change detection At startup self.current can be None before the first pedalboard loads, causing a crash if the pedalboard monitor fires before initialization is complete. --- modalapi/modhandler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index b4669e8c..af62a592 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -284,7 +284,7 @@ def poll_modui_changes(self): if self.pedalboard_monitor.check_for_change(): self.lcd.draw_info_message("Loading...") mod_bundle = self.pedalboard_monitor.get_current_pedalboard_bundle() - if mod_bundle and mod_bundle != self.current.pedalboard.bundle: + if mod_bundle and self.current and mod_bundle != self.current.pedalboard.bundle: logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}") if mod_bundle not in self.pedalboards: From 8de9c65fcfc771e5e2349dccf021742e1b33fbb9 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 19 Apr 2026 20:54:46 -0400 Subject: [PATCH 13/16] Simplifications --- modalapi/mod.py | 24 +++++++++++------------- modalapi/modhandler.py | 22 ++++++++++------------ 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/modalapi/mod.py b/modalapi/mod.py index 7a8ca145..ff77b2d9 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -28,7 +28,7 @@ import modalapi.parameter as Parameter import modalapi.wifi as Wifi from modalapi.websocket_bridge import AsyncWebSocketBridge -from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage +from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage, WebSocketMessage from modalapi.pedalboard_monitor import PedalboardMonitor from pistomp.analogmidicontrol import AnalogMidiControl @@ -150,7 +150,6 @@ def __init__(self, audiocard, homedir): try: self.ws_bridge = AsyncWebSocketBridge( ws_url='ws://localhost:80/websocket', - max_queue_size=100, backpressure_threshold=8192 # 8 KB ) self.ws_bridge.start() @@ -464,20 +463,22 @@ def poll_wifi(self): if self.current_menu == MenuType.MENU_INFO: self.system_info_update_wifi() - def _handle_ws_message(self, raw_message: str): - """Handle incoming WebSocket message from MOD-UI using typed protocol.""" - msg = parse_message(raw_message) + def poll_system_info(self): + pass + def _handle_ws_message(self, msg: WebSocketMessage): + """Handle incoming WebSocket message from MOD-UI""" if isinstance(msg, LoadingEndMessage): - logging.info(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}") + logging.debug(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}") self.next_pedalboard_preset_index = msg.snapshot_id elif isinstance(msg, PedalSnapshotMessage): if self.next_pedalboard_preset_index is not None: - logging.info(f"WebSocket: Pre-switch snapshot changed to {msg.snapshot_id}") + logging.debug(f"WebSocket: Pre-switch snapshot changed to {msg.snapshot_id}") self.next_pedalboard_preset_index = msg.snapshot_id else: - logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") + assert self.current is not None, "Received snapshot message but no current pedalboard is set" + logging.debug(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") if msg.snapshot_id not in self.current.presets: self.current.presets[msg.snapshot_id] = msg.snapshot_name @@ -485,16 +486,13 @@ def _handle_ws_message(self, raw_message: str): self.current.preset_index = msg.snapshot_id self.update_lcd_title() - def poll_system_info(self): - pass - def poll_modui_changes(self): - """Poll for changes from MOD-UI""" + """Poll for changes from MOD-UI: websockets and file watching""" if self.ws_bridge is not None: messages = self.ws_bridge.get_received_messages() for msg in messages: try: - self._handle_ws_message(msg) + self._handle_ws_message(parse_message(msg)) except Exception as e: logging.error(f"Error handling WebSocket message '{msg}': {e}") diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index af62a592..5ec4fc88 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -27,7 +27,7 @@ import modalapi.wifi as Wifi import pistomp.settings as Settings from modalapi.websocket_bridge import AsyncWebSocketBridge -from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage +from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage, WebSocketMessage from modalapi.pedalboard_monitor import PedalboardMonitor from pistomp.analogmidicontrol import AnalogMidiControl @@ -97,7 +97,6 @@ def __init__(self, audiocard, homedir): try: self.ws_bridge = AsyncWebSocketBridge( ws_url='ws://localhost:80/websocket', - max_queue_size=100, backpressure_threshold=8192 # 8 KB ) self.ws_bridge.start() @@ -233,12 +232,10 @@ def universal_encoder_sw(self, value, obj=None): if self.lcd is not None: self.lcd.enc_sw(value) - def _handle_ws_message(self, raw_message: str): - """Handle incoming WebSocket message from MOD-UI using typed protocol.""" - msg = parse_message(raw_message) - + def _handle_ws_message(self, msg: WebSocketMessage): + """Handle incoming WebSocket message from MOD-UI.""" if isinstance(msg, LoadingEndMessage): - logging.info(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}") + logging.debug(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}") # Sometimes mod-ui sends us -1 for preset index, but shows 0 anyway ("Default") self.next_pedalboard_preset_index = max(0, msg.snapshot_id) @@ -248,7 +245,7 @@ def _handle_ws_message(self, raw_message: str): mod_bundle = self.pedalboard_monitor.get_current_pedalboard_bundle() if mod_bundle and self.current and mod_bundle == self.current.pedalboard.bundle: # Same pedalboard - this is a new snapshot on current board, not a pre-switch - logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name}) - clearing stale pre-switch flag") + logging.debug(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name}) - clearing stale pre-switch flag") self.next_pedalboard_preset_index = None if msg.snapshot_id not in self.current.presets: @@ -259,10 +256,11 @@ def _handle_ws_message(self, raw_message: str): self.lcd.draw_title() else: # Different pedalboard pending - this is a legitimate pre-switch update - logging.info(f"WebSocket: Pre-switch snapshot changed to {msg.snapshot_id}") + logging.debug(f"WebSocket: Pre-switch snapshot changed to {msg.snapshot_id}") self.next_pedalboard_preset_index = msg.snapshot_id else: - logging.info(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") + assert self.current is not None, "Received snapshot message but no current pedalboard is set" + logging.debug(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name})") if msg.snapshot_id not in self.current.presets: self.current.presets[msg.snapshot_id] = msg.snapshot_name @@ -271,12 +269,12 @@ def _handle_ws_message(self, raw_message: str): self.lcd.draw_title() def poll_modui_changes(self): - """Poll for changes from MOD-UI""" + """Poll for changes from MOD-UI: websockets and file watching""" if self.ws_bridge is not None: messages = self.ws_bridge.get_received_messages() for msg in messages: try: - self._handle_ws_message(msg) + self._handle_ws_message(parse_message(msg)) except Exception as e: logging.error(f"Error handling WebSocket message '{msg}': {e}") From 2d24b74bfedbacc664fc74f2543434c8140e988e Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 19 Apr 2026 21:00:36 -0400 Subject: [PATCH 14/16] Simplify comments --- modalapi/pedalboard_monitor.py | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/modalapi/pedalboard_monitor.py b/modalapi/pedalboard_monitor.py index c7e18091..33164cf9 100644 --- a/modalapi/pedalboard_monitor.py +++ b/modalapi/pedalboard_monitor.py @@ -14,7 +14,9 @@ # along with pi-stomp. If not, see . """ -Monitor for pedalboard changes via last.json file. +Because the loading_end WebSocket message does not include the +pedalboard name, we monitor last.json for changes to get it +(happens ~4s after loading_end message). """ import json @@ -25,17 +27,9 @@ class PedalboardMonitor: - """ - Because the loading_end WebSocket message does not include the - pedalboard name, we monitor last.json for changes to get it - (happens ~4s after loading_end message). - """ + """Supplements WebSocket messages by monitoring last.json for pedalboard changes.""" - def __init__(self, data_dir: str): - """ - Args: - data_dir: Path to MOD-UI data directory (usually /home/pistomp/data) - """ + def __init__(self, data_dir: str = "/home/pistomp/data"): self.last_state_file = os.path.join(data_dir, "last.json") self.last_state_timestamp = self._get_current_timestamp() @@ -45,12 +39,7 @@ def _get_current_timestamp(self) -> float: return 0.0 def check_for_change(self) -> bool: - """ - Check if last.json has been modified since last check. - - Returns: - True if file has changed, False otherwise - """ + """Check if last.json has been modified since last check.""" current_timestamp = self._get_current_timestamp() if current_timestamp != self.last_state_timestamp: self.last_state_timestamp = current_timestamp @@ -58,12 +47,7 @@ def check_for_change(self) -> bool: return False def get_current_pedalboard_bundle(self) -> Optional[str]: - """ - Read current pedalboard bundle name from last.json. - - Returns: - Pedalboard name, or None if file doesn't exist or can't be read - """ + """Read current pedalboard bundle name from last.json.""" if not Path(self.last_state_file).exists(): return None From 3786623635f61fe77b47f265ae3a0da28c5011e2 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 19 Apr 2026 21:10:04 -0400 Subject: [PATCH 15/16] Use match statement for ws_protocol --- modalapi/ws_protocol.py | 132 +++++++++++++++++++++++----------------- 1 file changed, 75 insertions(+), 57 deletions(-) diff --git a/modalapi/ws_protocol.py b/modalapi/ws_protocol.py index 9a223e5a..3812f4d0 100644 --- a/modalapi/ws_protocol.py +++ b/modalapi/ws_protocol.py @@ -27,18 +27,21 @@ @dataclass class LoadingStartMessage: """Pedalboard loading started.""" + is_default: bool @dataclass class LoadingEndMessage: """Pedalboard loading finished.""" + snapshot_id: int @dataclass class PedalSnapshotMessage: """Snapshot changed within current pedalboard.""" + snapshot_id: int snapshot_name: str @@ -46,6 +49,7 @@ class PedalSnapshotMessage: @dataclass class SizeMessage: """Pedalboard canvas size.""" + width: int height: int @@ -53,6 +57,7 @@ class SizeMessage: @dataclass class AddHwPortMessage: """Hardware port appeared (JACK).""" + port_name: str port_type: str # "audio" or "midi" is_output: bool @@ -63,12 +68,14 @@ class AddHwPortMessage: @dataclass class RemoveHwPortMessage: """Hardware port disappeared.""" + port_name: str @dataclass class TrueBypassMessage: """True bypass state changed.""" + left: int right: int @@ -76,6 +83,7 @@ class TrueBypassMessage: @dataclass class UnknownMessage: """Message type we don't handle yet.""" + raw: str @@ -93,69 +101,79 @@ class UnknownMessage: def parse_message(raw_message: str) -> WebSocketMessage: - """ - Parse raw WebSocket message string into typed message object. - - Args: - raw_message: Raw message string from WebSocket - - Returns: - Typed message object - """ - parts = raw_message.split(' ', 2) - if not parts: - return UnknownMessage(raw=raw_message) - - cmd = parts[0] - + """Parse raw WebSocket message string into typed message object.""" try: - if cmd == "loading_start": - is_default = bool(int(parts[1])) if len(parts) > 1 else False - return LoadingStartMessage(is_default=is_default) - - elif cmd == "loading_end": - snapshot_id = int(parts[1]) if len(parts) > 1 else 0 - return LoadingEndMessage(snapshot_id=snapshot_id) - - elif cmd == "pedal_snapshot": - snapshot_id = int(parts[1]) if len(parts) > 1 else 0 - snapshot_name = parts[2] if len(parts) > 2 else "" - return PedalSnapshotMessage(snapshot_id=snapshot_id, snapshot_name=snapshot_name) - - elif cmd == "size": - width = int(parts[1]) if len(parts) > 1 else 0 - height = int(parts[2].split()[0]) if len(parts) > 2 else 0 - return SizeMessage(width=width, height=height) + match raw_message.split(" ", 2): + # Format: loading_start {isDefault} + case ["loading_start", flag, *_]: + return LoadingStartMessage(is_default=bool(int(flag))) + case ["loading_start", *_]: + return LoadingStartMessage(is_default=False) + + # Format: loading_end {snapshotId} + case ["loading_end", sid, *_]: + return LoadingEndMessage(snapshot_id=int(sid)) + case ["loading_end"]: + return LoadingEndMessage(snapshot_id=0) + + # Format: pedal_snapshot {snapshotId} {snapshotName} + case ["pedal_snapshot", sid, name]: + return PedalSnapshotMessage(snapshot_id=int(sid), snapshot_name=name) + case ["pedal_snapshot", sid]: + return PedalSnapshotMessage(snapshot_id=int(sid), snapshot_name="") + case ["pedal_snapshot"]: + return PedalSnapshotMessage(snapshot_id=0, snapshot_name="") + + # Format: size {width} {height} + case ["size", w, h_trailing]: + return SizeMessage(width=int(w), height=int(h_trailing.split()[0])) + case ["size", w]: + return SizeMessage(width=int(w), height=0) + case ["size"]: + return SizeMessage(width=0, height=0) - elif cmd == "add_hw_port": # Format: add_hw_port /graph/{name} {type} {isOutput} {title} {index} - if len(parts) > 1: - details = parts[1].split(' ', 4) - port_name = details[0] if len(details) > 0 else "" - port_type = details[1] if len(details) > 1 else "" - is_output = bool(int(details[2])) if len(details) > 2 else False - title = details[3] if len(details) > 3 else "" - index = int(details[4]) if len(details) > 4 else 0 - return AddHwPortMessage( - port_name=port_name, - port_type=port_type, - is_output=is_output, - title=title, - index=index - ) - - elif cmd == "remove_hw_port": - port_name = parts[1] if len(parts) > 1 else "" - return RemoveHwPortMessage(port_name=port_name) - - elif cmd == "truebypass": - left = int(parts[1]) if len(parts) > 1 else 0 - right = int(parts[2].split()[0]) if len(parts) > 2 else 0 - return TrueBypassMessage(left=left, right=right) + case ["add_hw_port", port_name, rest]: + match rest.split(" ", 3): + case [port_type, is_out, title, index]: + return AddHwPortMessage( + port_name=port_name, + port_type=port_type, + is_output=bool(int(is_out)), + title=title, + index=int(index), + ) + case [port_type, is_out, title]: + return AddHwPortMessage( + port_name=port_name, port_type=port_type, is_output=bool(int(is_out)), title=title, index=0 + ) + case [port_type, is_out]: + return AddHwPortMessage( + port_name=port_name, port_type=port_type, is_output=bool(int(is_out)), title="", index=0 + ) + case [port_type]: + return AddHwPortMessage( + port_name=port_name, port_type=port_type, is_output=False, title="", index=0 + ) + case ["add_hw_port", port_name]: + return AddHwPortMessage(port_name=port_name, port_type="", is_output=False, title="", index=0) + + # Format: remove_hw_port /graph/{name} + case ["remove_hw_port", port_name, *_]: + return RemoveHwPortMessage(port_name=port_name) + case ["remove_hw_port"]: + return RemoveHwPortMessage(port_name="") + + # Format: truebypass {left} {right} + case ["truebypass", left, right_trailing]: + return TrueBypassMessage(left=int(left), right=int(right_trailing.split()[0])) + case ["truebypass", left]: + return TrueBypassMessage(left=int(left), right=0) + case ["truebypass"]: + return TrueBypassMessage(left=0, right=0) except (ValueError, IndexError) as e: logging.warning(f"Failed to parse WebSocket message '{raw_message}': {e}") return UnknownMessage(raw=raw_message) - # Unknown command return UnknownMessage(raw=raw_message) From 6ac1d608d4173da1dbfb5b9c1ab4cfc57b77a6c5 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 19 Apr 2026 21:16:30 -0400 Subject: [PATCH 16/16] Genericize and use for banks --- modalapi/mod.py | 10 +++---- modalapi/modhandler.py | 24 +++++++-------- modalapi/pedalboard_monitor.py | 55 +++++++++++++++++----------------- 3 files changed, 42 insertions(+), 47 deletions(-) diff --git a/modalapi/mod.py b/modalapi/mod.py index ff77b2d9..6dfaedae 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -29,7 +29,7 @@ import modalapi.wifi as Wifi from modalapi.websocket_bridge import AsyncWebSocketBridge from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage, WebSocketMessage -from modalapi.pedalboard_monitor import PedalboardMonitor +from modalapi.pedalboard_monitor import FileChangeMonitor, read_pedalboard_bundle from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.footswitch import Footswitch @@ -142,7 +142,7 @@ def __init__(self, audiocard, homedir): self.current_menu = MenuType.MENU_NONE self.data_dir = "/home/pistomp/data" - self.pedalboard_monitor = PedalboardMonitor(self.data_dir) + self.last_json_monitor = FileChangeMonitor(os.path.join(self.data_dir, "last.json")) self.wifi_manager = Wifi.WifiManager() # WebSocket bridge for MOD-UI communication @@ -497,9 +497,9 @@ def poll_modui_changes(self): logging.error(f"Error handling WebSocket message '{msg}': {e}") # Check for pedalboard change via last.json - if self.pedalboard_monitor.check_for_change(): + if self.last_json_monitor.check_for_change(): self.lcd.draw_info_message("Loading...") - mod_bundle = self.pedalboard_monitor.get_current_pedalboard_bundle() + mod_bundle = read_pedalboard_bundle(self.last_json_monitor.path) if mod_bundle and mod_bundle != self.current.pedalboard.bundle: logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}") @@ -544,7 +544,7 @@ def load_pedalboards(self): #logging.debug("Preset: %s" % self.get_current_preset_name()) def get_current_pedalboard_bundle_path(self): - return self.pedalboard_monitor.get_current_pedalboard_bundle() + return read_pedalboard_bundle(self.last_json_monitor.path) def set_current_pedalboard(self, pedalboard): # Delete previous "current" diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 5ec4fc88..26cdd804 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -28,7 +28,7 @@ import pistomp.settings as Settings from modalapi.websocket_bridge import AsyncWebSocketBridge from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage, WebSocketMessage -from modalapi.pedalboard_monitor import PedalboardMonitor +from modalapi.pedalboard_monitor import FileChangeMonitor, read_pedalboard_bundle from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.encodermidicontrol import EncoderMidiControl @@ -84,11 +84,11 @@ def __init__(self, audiocard, homedir): # Banks self.banks_file = os.path.join(self.data_dir, "banks.json") - self.banks_file_timestamp = os.path.getmtime(self.banks_file) if Path(self.banks_file).exists() else 0 self.banks = {} self.current_bank = None - self.pedalboard_monitor = PedalboardMonitor(self.data_dir) + self.last_json_monitor = FileChangeMonitor(os.path.join(self.data_dir, "last.json")) + self.banks_monitor = FileChangeMonitor(self.banks_file) self.wifi_manager = Wifi.WifiManager() @@ -242,7 +242,7 @@ def _handle_ws_message(self, msg: WebSocketMessage): elif isinstance(msg, PedalSnapshotMessage): if self.next_pedalboard_preset_index is not None: # Check if we're still on the same pedalboard (stale flag from previous load) - mod_bundle = self.pedalboard_monitor.get_current_pedalboard_bundle() + mod_bundle = read_pedalboard_bundle(self.last_json_monitor.path) if mod_bundle and self.current and mod_bundle == self.current.pedalboard.bundle: # Same pedalboard - this is a new snapshot on current board, not a pre-switch logging.debug(f"WebSocket: Snapshot changed to {msg.snapshot_id} ({msg.snapshot_name}) - clearing stale pre-switch flag") @@ -279,9 +279,9 @@ def poll_modui_changes(self): logging.error(f"Error handling WebSocket message '{msg}': {e}") # Check for pedalboard change via last.json - if self.pedalboard_monitor.check_for_change(): + if self.last_json_monitor.check_for_change(): self.lcd.draw_info_message("Loading...") - mod_bundle = self.pedalboard_monitor.get_current_pedalboard_bundle() + mod_bundle = read_pedalboard_bundle(self.last_json_monitor.path) if mod_bundle and self.current and mod_bundle != self.current.pedalboard.bundle: logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}") @@ -299,13 +299,9 @@ def poll_modui_changes(self): self.lcd.draw_title() # Look for a change in banks file - if Path(self.banks_file).exists(): - ts = os.path.getmtime(self.banks_file) - if ts != self.banks_file_timestamp: - # Timestamp changed - logging.info("Reloading banks file: %s" % self.banks_file) - self.banks_file_timestamp = ts - self.load_banks() + if self.banks_monitor.check_for_change(): + logging.info("Reloading banks file: %s" % self.banks_file) + self.load_banks() # # Bank Stuff @@ -383,7 +379,7 @@ def reload_pedalboard(self, bundle): return pedalboard def get_current_pedalboard_bundle_path(self): - return self.pedalboard_monitor.get_current_pedalboard_bundle() + return read_pedalboard_bundle(self.last_json_monitor.path) def set_current_pedalboard(self, pedalboard): # Delete previous "current" diff --git a/modalapi/pedalboard_monitor.py b/modalapi/pedalboard_monitor.py index 33164cf9..0487f7d8 100644 --- a/modalapi/pedalboard_monitor.py +++ b/modalapi/pedalboard_monitor.py @@ -14,9 +14,10 @@ # along with pi-stomp. If not, see . """ -Because the loading_end WebSocket message does not include the -pedalboard name, we monitor last.json for changes to get it -(happens ~4s after loading_end message). +Monitors a file for modification-time changes. + +Used to detect when MOD-UI writes last.json (pedalboard change) or +banks.json (bank list change) without polling the WebSocket. """ import json @@ -26,35 +27,33 @@ from typing import Optional -class PedalboardMonitor: - """Supplements WebSocket messages by monitoring last.json for pedalboard changes.""" +class FileChangeMonitor: + """Detects when a file has been modified since the last check.""" - def __init__(self, data_dir: str = "/home/pistomp/data"): - self.last_state_file = os.path.join(data_dir, "last.json") - self.last_state_timestamp = self._get_current_timestamp() + def __init__(self, file_path: str): + self.path = file_path + self._last_timestamp = self._current_timestamp() - def _get_current_timestamp(self) -> float: - if Path(self.last_state_file).exists(): - return os.path.getmtime(self.last_state_file) - return 0.0 + def _current_timestamp(self) -> float: + p = Path(self.path) + return os.path.getmtime(self.path) if p.exists() else 0.0 def check_for_change(self) -> bool: - """Check if last.json has been modified since last check.""" - current_timestamp = self._get_current_timestamp() - if current_timestamp != self.last_state_timestamp: - self.last_state_timestamp = current_timestamp + """Return True (and update baseline) if the file was modified since last call.""" + ts = self._current_timestamp() + if ts != self._last_timestamp: + self._last_timestamp = ts return True return False - def get_current_pedalboard_bundle(self) -> Optional[str]: - """Read current pedalboard bundle name from last.json.""" - if not Path(self.last_state_file).exists(): - return None - - try: - with open(self.last_state_file, "r") as f: - data = json.load(f) - return data.get("pedalboard") - except (json.JSONDecodeError, IOError) as e: - logging.warning(f"Failed to read {self.last_state_file}: {e}") - return None + +def read_pedalboard_bundle(last_json_path: str) -> Optional[str]: + """Read the current pedalboard bundle name from last.json.""" + if not Path(last_json_path).exists(): + return None + try: + with open(last_json_path, "r") as f: + return json.load(f).get("pedalboard") + except (json.JSONDecodeError, IOError) as e: + logging.warning(f"Failed to read {last_json_path}: {e}") + return None