diff --git a/emulator/bootstrap.py b/emulator/bootstrap.py index 81dd49bec..a46313d6f 100644 --- a/emulator/bootstrap.py +++ b/emulator/bootstrap.py @@ -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() diff --git a/modalapi/mod.py b/modalapi/mod.py index 72a73b923..833d13726 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -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() @@ -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 @@ -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 diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 510f89ebb..96554bb41 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -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...") @@ -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 # diff --git a/modalapi/pedalboard_monitor.py b/modalapi/pedalboard_monitor.py index 0487f7d82..8479e7101 100644 --- a/modalapi/pedalboard_monitor.py +++ b/modalapi/pedalboard_monitor.py @@ -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}") diff --git a/modalapistomp.py b/modalapistomp.py index 421cd6cc1..2907c57c8 100755 --- a/modalapistomp.py +++ b/modalapistomp.py @@ -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 @@ -44,6 +46,7 @@ EMULATOR_HOSTS = ("emulator_v1", "emulator_v2", "emulator_v3") + def main(): sys.settrace @@ -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__)) @@ -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]) @@ -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 diff --git a/pistomp/handler.py b/pistomp/handler.py index 80f7a5959..ee441b520 100755 --- a/pistomp/handler.py +++ b/pistomp/handler.py @@ -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() @@ -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) diff --git a/tests/test_failfast_startup.py b/tests/test_failfast_startup.py index 1a306dd3b..76574606a 100644 --- a/tests/test_failfast_startup.py +++ b/tests/test_failfast_startup.py @@ -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 @@ -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 @@ -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) diff --git a/tests/v1/test_pedalboards.py b/tests/v1/test_pedalboards.py new file mode 100644 index 000000000..01fd79be9 --- /dev/null +++ b/tests/v1/test_pedalboards.py @@ -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"