Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions emulator/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ def bootstrap_emulator(version: EmulatorVersion, cwd: str):
current_bundle = handler.get_current_pedalboard_bundle_path()
if current_bundle and current_bundle in handler.pedalboards:
handler.set_current_pedalboard(handler.pedalboards[current_bundle])
else:
handler.pedalboard_change()
elif handler.pedalboard_list:
from modalapi.pedalboard_monitor import write_last_json
pb = handler.pedalboard_list[0]
write_last_json(handler.last_json_monitor.path, pb.bundle)
handler.pedalboard_change(pb)
handler.set_current_pedalboard(pb)

handler.system_info_load()

Expand Down
31 changes: 14 additions & 17 deletions modalapi/mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ def top_encoder_sw(self, value):
self.preset_change()
self.top_encoder_mode = TopEncoderMode.PRESET_SELECT
elif mode == TopEncoderMode.PEDALBOARD_SELECTED:
self.pedalboard_change()
self.pedalboard_change(self.pedalboard_list[self.selected_pedalboard_index])
self.top_encoder_mode = TopEncoderMode.DEFAULT
elif mode == TopEncoderMode.SYSTEM_MENU:
self.menu_action()
Expand Down Expand Up @@ -340,7 +340,7 @@ def universal_encoder_sw(self, value):
self.system_menu_show()
elif mode == UniversalEncoderMode.PEDALBOARD_SELECT:
self.universal_encoder_mode = UniversalEncoderMode.LOADING
self.pedalboard_change()
self.pedalboard_change(self.pedalboard_list[self.selected_pedalboard_index])
self.universal_encoder_mode = UniversalEncoderMode.DEFAULT
elif mode == UniversalEncoderMode.PRESET_SELECT:
self.universal_encoder_mode = UniversalEncoderMode.LOADING
Expand Down Expand Up @@ -733,25 +733,22 @@ def pedalboard_select(self, direction):
self.lcd.draw_title(self.pedalboard_list[next_idx].title, None, True, False, highlight_only)
self.selected_pedalboard_index = next_idx

def pedalboard_change(self):
def pedalboard_change(self, pedalboard: Pedalboard.Pedalboard) -> None:
logging.info("Pedalboard change")
if self.selected_pedalboard_index < len(self.pedalboard_list):
self.lcd.draw_info_message("Loading...")
self.lcd.draw_info_message("Loading...")

resp1 = req.get(self.root_uri + "reset")
if resp1.status_code != 200:
logging.error("Bad Reset request")
resp1 = req.get(self.root_uri + "reset")
if resp1.status_code != 200:
logging.error("Bad Reset request")

uri = self.root_uri + "pedalboard/load_bundle/"
bundlepath = self.pedalboard_list[self.selected_pedalboard_index].bundle
data = {"bundlepath": bundlepath}
resp2 = req.post(uri, data)
if resp2.status_code != 200:
logging.error("Bad Rest request: %s %s status: %d" % (uri, data, resp2.status_code))
uri = self.root_uri + "pedalboard/load_bundle/"
data = {"bundlepath": pedalboard.bundle}
resp2 = req.post(uri, data)
if resp2.status_code != 200:
logging.error("Bad Rest request: %s %s status: %d" % (uri, data, resp2.status_code))

# Now that it's presumably changed, load the dynamic "current" data
self.set_current_pedalboard(self.pedalboard_list[self.selected_pedalboard_index])
self.bot_encoder_mode = BotEncoderMode.DEFAULT
self.set_current_pedalboard(pedalboard)
self.bot_encoder_mode = BotEncoderMode.DEFAULT

#
# Preset Stuff
Expand Down
13 changes: 2 additions & 11 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ def _redraw_after_binding(self, controller, is_footswitch):
else:
self.lcd.draw_analog_assignments(self.current.analog_controllers)

def pedalboard_change(self, pedalboard=None):
def pedalboard_change(self, pedalboard: Pedalboard.Pedalboard) -> None:
logging.info("Pedalboard change")
self.lcd.draw_info_message("Loading...")

Expand All @@ -654,20 +654,11 @@ def pedalboard_change(self, pedalboard=None):
logging.error("Bad Reset request")

uri = self.root_uri + "pedalboard/load_bundle/"

if pedalboard is None:
pedalboard = self.pedalboard_list[0]
#self.set_current_pedalboard(pedalboard) # TODO is this necessary?
bundlepath = pedalboard.bundle
data = {"bundlepath": bundlepath}
data = {"bundlepath": pedalboard.bundle}
resp2 = self._rest_post(uri, data=data)
if resp2 is None or resp2.status_code != 200:
logging.error("Bad Rest request: %s %s" % (uri, data))

# Now that it's presumably changed, load the dynamic "current" data
# TODO this seems to be no longer required since the MOD pedalboard change will call this via poll_modui_changes()
#self.set_current_pedalboard(pedalboard)

#
# Preset Stuff
#
Expand Down
10 changes: 10 additions & 0 deletions modalapi/pedalboard_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,13 @@ def read_pedalboard_bundle(last_json_path: str) -> Optional[str]:
except (json.JSONDecodeError, IOError) as e:
logging.warning(f"Failed to read {last_json_path}: {e}")
return None


def write_last_json(last_json_path: str, bundle: str) -> None:
"""Write last.json with the given bundle path."""
try:
with open(last_json_path, "w") as f:
json.dump({"bank": -2, "pedalboard": bundle, "supportsDividers": True}, f)
logging.info(f"Wrote {last_json_path} with bundle: {bundle}")
except IOError as e:
logging.error(f"Failed to write {last_json_path}: {e}")
16 changes: 13 additions & 3 deletions modalapistomp.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@

from rtmidi.midiutil import open_midioutput

from modalapi.pedalboard_monitor import write_last_json

from pistomp.audiocard import Audiocard
import pistomp.audiocardfactory as Audiocardfactory
import pistomp.config as config
Expand All @@ -44,6 +46,7 @@

EMULATOR_HOSTS = ("emulator_v1", "emulator_v2", "emulator_v3")


def main():
sys.settrace

Expand Down Expand Up @@ -87,7 +90,7 @@ def main():
logging.getLogger().setLevel(log_level)

# Disable websockets library debug logging (too noisy)
logging.getLogger('websockets').setLevel(logging.WARNING)
logging.getLogger("websockets").setLevel(logging.WARNING)

# Current Working Dir
cwd = os.path.dirname(os.path.realpath(__file__))
Expand Down Expand Up @@ -145,8 +148,14 @@ def main():
# Load the current pedalboard as "current"
current_pedal_board_bundle = handler.get_current_pedalboard_bundle_path()
if not current_pedal_board_bundle:
# Apparently, no pedalboard is currently loaded so just change to the default
handler.pedalboard_change()
# last.json missing or malformed — reset to first known pedalboard
if not handler.pedalboard_list:
logging.error("No pedalboards found; cannot recover from missing/malformed last.json")
sys.exit(1)
pb = handler.pedalboard_list[0]
write_last_json(handler.last_json_monitor.path, pb.bundle)
handler.pedalboard_change(pb)
handler.set_current_pedalboard(pb)
else:
handler.set_current_pedalboard(handler.pedalboards[current_pedal_board_bundle])

Expand All @@ -173,6 +182,7 @@ def main():

elif is_emulator:
from emulator.bootstrap import bootstrap_emulator

handler, midiout = bootstrap_emulator(args.host[0], cwd)

assert handler is not None
Expand Down
6 changes: 4 additions & 2 deletions pistomp/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ def set_mod_tap_tempo(self, bpm):
def load_banks(self):
raise NotImplementedError()

def pedalboard_change(self, pedalboard: Any) -> None:
raise NotImplementedError()

def poll_indicators(self):
raise NotImplementedError()

Expand All @@ -121,8 +124,7 @@ def _apply_midi_binding(self, instance, symbol, binding):
# a pedalboard reload. Idempotent: replayed connect-dump maps are no-ops.
if self.current is None:
return
plugin = next((p for p in self.current.pedalboard.plugins
if p is not None and p.instance_id == instance), None)
plugin = next((p for p in self.current.pedalboard.plugins if p is not None and p.instance_id == instance), None)
if plugin is None or plugin.parameters is None:
return
param = plugin.parameters.get(symbol)
Expand Down
67 changes: 66 additions & 1 deletion tests/test_failfast_startup.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Startup must propagate WebSocket bridge failures — pi-stomp depends on it."""
"""Startup failure modes and recovery paths."""

import json
from pathlib import Path
Expand All @@ -7,6 +7,7 @@
import pytest

import common.token as Token
from modalapi.pedalboard_monitor import write_last_json

with patch("pistomp.settings.Settings.load_settings"), patch("pistomp.settings.Settings.set_setting"):
from modalapi.modhandler import Modhandler
Expand Down Expand Up @@ -43,6 +44,70 @@ def test_modhandler_init_propagates_ws_bridge_construction_failure(tmp_path):
Modhandler(MagicMock(), str(PROJECT_ROOT), data_dir=str(data_dir))


def test_missing_last_json_recovery(tmp_path):
"""Missing last.json: startup writes it with the first pedalboard and sets handler.current."""
_reset_modhandler_singleton()
data_dir = tmp_path / "data"
data_dir.mkdir()
# Intentionally no last.json

with (
patch("requests.get") as mock_get,
patch("requests.post") as mock_post,
patch("pistomp.settings.Settings"),
patch("modalapi.pedalboard.Pedalboard.load_bundle"),
patch("modalapi.wifi.WifiManager"),
patch("subprocess.check_output", return_value=b"SystemState=running"),
patch("modalapi.modhandler.AsyncWebSocketBridge", return_value=MagicMock()),
):
def get_side_effect(url, **kwargs):
resp = MagicMock()
resp.status_code = 200
resp.text = (
json.dumps([
{Token.TITLE: "First Rig", Token.BUNDLE: "/path/to/first.pedalboard"},
{Token.TITLE: "Second Rig", Token.BUNDLE: "/path/to/second.pedalboard"},
])
if "pedalboard/list" in url
else json.dumps({"0": "Default"})
if "snapshot/list" in url
else "{}"
)
return resp

mock_get.side_effect = get_side_effect
mock_post.return_value = MagicMock(status_code=200, text="{}")

handler = Modhandler(MagicMock(), str(PROJECT_ROOT), data_dir=str(data_dir))
handler.settings.get_setting.return_value = None
handler.add_hardware(MagicMock())
handler.add_lcd(MagicMock())
handler.load_pedalboards()

# Precondition: last.json absent → None
assert handler.get_current_pedalboard_bundle_path() is None
assert handler.pedalboard_list

# Recovery sequence (mirrors modalapistomp.py startup branch)
pb = handler.pedalboard_list[0]
write_last_json(handler.last_json_monitor.path, pb.bundle)
handler.pedalboard_change(pb)
handler.set_current_pedalboard(pb)

# last.json written with the first pedalboard
last = json.loads((data_dir / "last.json").read_text())
assert last["pedalboard"] == "/path/to/first.pedalboard"
assert last["bank"] == -2

# handler.current is set and points to the right pedalboard
assert handler.current is not None
assert handler.current.pedalboard.bundle == "/path/to/first.pedalboard"

# mod-ui received a load_bundle POST for the first pedalboard
post_urls = [c.args[0] for c in mock_post.call_args_list]
assert any("load_bundle" in u for u in post_urls)


def test_modhandler_init_propagates_ws_bridge_start_failure(tmp_path):
_reset_modhandler_singleton()
data_dir = _data_dir(tmp_path)
Expand Down
66 changes: 66 additions & 0 deletions tests/v1/test_pedalboards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Pedalboard switching via the v1 (Mod) encoder state machines and LCD menu.

The LCD menu calls handler.pedalboard_change(pedalboard) with the chosen board.
Before the fix, mod.py's pedalboard_change() took no argument and silently used
selected_pedalboard_index, so the menu could load the wrong board.

The encoder tests also confirm the full encoder→load_bundle integration path.
"""

import pistomp.switchstate as switchstate
from modalapi.mod import TopEncoderMode, UniversalEncoderMode
from tests.types import SystemFixtureLegacy


def _load_bundle_url(mock_post):
calls = [c for c in mock_post.call_args_list if "load_bundle" in c.args[0]]
assert calls, "no load_bundle POST found"
return calls[0].args[1]["bundlepath"]


def test_v1_pedalboard_change_loads_passed_board_not_selected_index(v1_system: SystemFixtureLegacy, get_urls):
"""pedalboard_change(pb) loads pb regardless of selected_pedalboard_index.

This is the regression the fix addressed: the LCD menu passes the chosen
pedalboard as an argument; the old no-arg form ignored it and used
selected_pedalboard_index, potentially loading the wrong board.
"""
handler = v1_system.handler
mock_post = v1_system.mock_post

# Index deliberately left at 0 (first board) to expose the old bug
handler.selected_pedalboard_index = 0
target_pb = handler.pedalboard_list[1] # second board: /path/to/new.pedalboard

handler.pedalboard_change(target_pb)

assert _load_bundle_url(mock_post) == "/path/to/new.pedalboard"
assert handler.current.pedalboard.bundle == "/path/to/new.pedalboard"


def test_v1_top_encoder_pedalboard_change(v1_system: SystemFixtureLegacy, get_urls):
"""Top encoder: PEDALBOARD_SELECTED + RELEASED fires load_bundle for the selected board."""
handler = v1_system.handler
mock_post = v1_system.mock_post

handler.selected_pedalboard_index = 1 # second board: /path/to/new.pedalboard
handler.top_encoder_mode = TopEncoderMode.PEDALBOARD_SELECTED

handler.top_encoder_sw(switchstate.Value.RELEASED)

assert _load_bundle_url(mock_post) == "/path/to/new.pedalboard"
assert handler.current.pedalboard.bundle == "/path/to/new.pedalboard"


def test_v1_universal_encoder_pedalboard_change(v1_system: SystemFixtureLegacy, get_urls):
"""Universal encoder: PEDALBOARD_SELECT + RELEASED fires load_bundle for the selected board."""
handler = v1_system.handler
mock_post = v1_system.mock_post

handler.selected_pedalboard_index = 1 # second board: /path/to/new.pedalboard
handler.universal_encoder_mode = UniversalEncoderMode.PEDALBOARD_SELECT

handler.universal_encoder_sw(switchstate.Value.RELEASED)

assert _load_bundle_url(mock_post) == "/path/to/new.pedalboard"
assert handler.current.pedalboard.bundle == "/path/to/new.pedalboard"