Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
85a9553
Use websocket + REST API instead of last.json
sastraxi Dec 27, 2025
a87a0c9
Fix v3 hardware
sastraxi Dec 27, 2025
1cc5f2d
Half-measure: last.json mtime for pedalboards, websocket for snapshots
sastraxi Dec 29, 2025
43958c4
Load pedalboards and create snapshots if they don't exist
sastraxi Dec 29, 2025
ba1a48a
Fix new snapshot sync
sastraxi Dec 29, 2025
845d36e
Reduce noisy logs
sastraxi Dec 29, 2025
374e60a
Be a good websocket citizen
sastraxi Dec 30, 2025
8874e88
Fix loading_end with -1
sastraxi Dec 31, 2025
b5ea086
Backport ignoring output_set logs
sastraxi Dec 31, 2025
51b28fd
Make websocket code resilient to not being installed
sastraxi Jan 2, 2026
86d4588
Bring back websocket handler
sastraxi Jan 7, 2026
3acec40
Merge branch 'feat/websocket-bridge' into feat/websocket-sync
sastraxi Feb 8, 2026
8778d01
Merge branch 'feat/websocket-bridge' into feat/websocket-sync
sastraxi Apr 18, 2026
eda89da
Merge branch 'feat/websocket-bridge' into feat/websocket-sync
sastraxi Apr 19, 2026
b76fd9f
Guard against self.current being None during pedalboard change detection
sastraxi Apr 19, 2026
ab193fd
Merge branch 'feat/websocket-bridge' into feat/websocket-sync
sastraxi Apr 19, 2026
fb0c4ad
Merge branch 'feat/websocket-bridge' into feat/websocket-sync
sastraxi Apr 20, 2026
8de9c65
Simplifications
sastraxi Apr 20, 2026
2d24b74
Simplify comments
sastraxi Apr 20, 2026
3786623
Use match statement for ws_protocol
sastraxi Apr 20, 2026
6ac1d60
Genericize and use for banks
sastraxi Apr 20, 2026
0bf2dfa
Merge branch 'feat/websocket-bridge' into feat/websocket-sync
sastraxi Apr 20, 2026
0f4d563
Merge branch 'feat/websocket-bridge' into feat/websocket-sync
sastraxi Apr 24, 2026
18cc057
Merge branch 'feat/websocket-bridge' into feat/websocket-sync
sastraxi May 9, 2026
cf160c5
Merge branch 'pistomp-v3' into feat/websocket-sync
sastraxi May 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 70 additions & 37 deletions modalapi/mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
import modalapi.pedalboard as Pedalboard
import common.parameter as Parameter
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 FileChangeMonitor, read_pedalboard_bundle

from pistomp.analogmidicontrol import AnalogMidiControl
from pistomp.footswitch import Footswitch
Expand Down Expand Up @@ -131,17 +134,30 @@ 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.last_json_monitor = FileChangeMonitor(os.path.join(self.data_dir, "last.json"))
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',
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,
Expand All @@ -153,10 +169,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
Expand All @@ -166,7 +187,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:
Expand Down Expand Up @@ -445,27 +466,46 @@ def poll_wifi(self):
def poll_system_info(self):
pass

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, msg: WebSocketMessage):
"""Handle incoming WebSocket message from MOD-UI"""
if isinstance(msg, LoadingEndMessage):
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.debug(f"WebSocket: Pre-switch snapshot changed to {msg.snapshot_id}")
self.next_pedalboard_preset_index = msg.snapshot_id
else:
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})")

# Timestamp changed
self.pedalboard_change_timestamp = ts
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()

def poll_modui_changes(self):
"""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(parse_message(msg))
except Exception as e:
logging.error(f"Error handling WebSocket message '{msg}': {e}")

# Check for pedalboard change via last.json
if self.last_json_monitor.check_for_change():
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))
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}")

if mod_bundle not in self.pedalboards:
self.load_pedalboards()

pb = self.pedalboards[mod_bundle]
self.set_current_pedalboard(pb)

Expand Down Expand Up @@ -503,19 +543,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):
# 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

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 read_pedalboard_bundle(self.last_json_monitor.path)

def set_current_pedalboard(self, pedalboard):
# Delete previous "current"
Expand All @@ -524,6 +553,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
Expand Down
145 changes: 98 additions & 47 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
# You should have received a copy of the GNU General Public License
# along with pi-stomp. If not, see <https://www.gnu.org/licenses/>.

from pistomp.handler import Handler

import json
import logging
import os
Expand All @@ -33,6 +31,9 @@
from pistomp.lcd320x240 import Lcd
from pistomp.hardware import Controller, Hardware
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 FileChangeMonitor, read_pedalboard_bundle

from pistomp.analogmidicontrol import AnalogMidiControl
from pistomp.encodermidicontrol import EncoderMidiControl
Expand Down Expand Up @@ -75,24 +76,36 @@ def __init__(self, audiocard, homedir):
self._lcd: Lcd | None = None
self._hardware: Hardware | None = 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"
self.data_dir = "/home/pistomp/data"

# 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

# 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.last_json_monitor = FileChangeMonitor(os.path.join(self.data_dir, "last.json"))
self.banks_monitor = FileChangeMonitor(self.banks_file)

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',
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,
Expand All @@ -106,12 +119,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
Expand All @@ -121,7 +139,7 @@ class Current:
def __init__(self, pedalboard: Pedalboard.Pedalboard):
self.pedalboard: Pedalboard.Pedalboard = pedalboard
self.presets: dict[int, str] = {}
self.preset_index: int = 0
self.preset_index: int = 0 # Assumes pedalboard loads at snapshot 0 (default behavior)
self.analog_controllers: dict[str, dict[str, Any]] = {} # { type: (plugin_name, param_name) }

def _rest_get(self, url: str) -> Response | None:
Expand Down Expand Up @@ -240,36 +258,76 @@ def universal_encoder_sw(self, value, obj=None):
if self._lcd is not None:
self._lcd.enc_sw(value)

def _handle_ws_message(self, msg: WebSocketMessage):
"""Handle incoming WebSocket message from MOD-UI."""
if isinstance(msg, LoadingEndMessage):
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)

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 = 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")
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.debug(f"WebSocket: Pre-switch snapshot changed to {msg.snapshot_id}")
self.next_pedalboard_preset_index = msg.snapshot_id
else:
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

self.current.preset_index = msg.snapshot_id
self.lcd.draw_title()

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 if self.current else None, mod_bundle))
pb = self.reload_pedalboard(mod_bundle)
self.set_current_pedalboard(pb)
"""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(parse_message(msg))
except Exception as e:
logging.error(f"Error handling WebSocket message '{msg}': {e}")

# Check for pedalboard change via last.json
if self.last_json_monitor.check_for_change():
self.lcd.draw_info_message("Loading...")
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}")

if mod_bundle not in self.pedalboards:
self.load_pedalboards()

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():
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
Expand Down Expand Up @@ -341,19 +399,8 @@ 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

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 read_pedalboard_bundle(self.last_json_monitor.path)

def set_current_pedalboard(self, pedalboard):
# Delete previous "current"
Expand All @@ -362,6 +409,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
Expand Down
Loading