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
3 changes: 3 additions & 0 deletions common/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
import math
from typing import Any

# Teardown join budget for subprocesses
TEARDOWN_JOIN_S = 0.25


def LILV_FOREACH(collection, func):
itr = collection.begin()
Expand Down
1 change: 1 addition & 0 deletions emulator/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ def system_menu_reboot(self, arg):

def system_menu_restart_sound(self, arg):
logging.info("Emulator: restart sound is a no-op")
raise KeyboardInterrupt

def system_menu_reload(self, arg):
logging.info("Emulator: reload configs is a no-op")
Expand Down
3 changes: 2 additions & 1 deletion modalapi/ethernet/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from functools import cached_property
from typing import Optional

from common.util import TEARDOWN_JOIN_S
from pistomp.alsa_pcm import read_hw_params

# Contract with the JackBridge service: truncate-on-start, atomic-rewrite of a
Expand Down Expand Up @@ -76,7 +77,7 @@ def __init__(self) -> None:

def shutdown(self) -> None:
self._stop.set()
self._thread.join(timeout=2.0)
self._thread.join(timeout=TEARDOWN_JOIN_S)

# ----- background polling -----

Expand Down
31 changes: 11 additions & 20 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,7 @@ def _rest_get_with_retry(self, url: str) -> Response | None:
resp = self._rest_get(url)
if resp is not None and resp.status_code == 200:
return resp
logging.info(
"mod-ui not ready, retrying (%d/%d) in %ss...", attempt, len(STARTUP_REST_BACKOFF_S), delay
)
logging.info("mod-ui not ready, retrying (%d/%d) in %ss...", attempt, len(STARTUP_REST_BACKOFF_S), delay)
time.sleep(delay)
return self._rest_get(url)

Expand Down Expand Up @@ -450,9 +448,7 @@ def _handle_switch(self, event: SwitchEvent) -> bool:
# ControllerManager._bind_encoder_longpress.
if event.kind == SwitchEventKind.LONGPRESS and controller.midi_CC is not None:
key = f"{controller.midi_channel}:{controller.midi_CC}"
winner = self.effective_table.resolve(
ControlRef(cls=ControlClass.ANALOG, id=key), EventKind.LONGPRESS
)
winner = self.effective_table.resolve(ControlRef(cls=ControlClass.ANALOG, id=key), EventKind.LONGPRESS)
if winner is not None:
self._fire_row(winner, event)
return True
Expand All @@ -467,9 +463,7 @@ def _handle_footswitch(self, fs: Footswitch, kind: SwitchEventKind, timestamp: f
Handler._handle_footswitch imperative if-chain."""
if kind == SwitchEventKind.LONGPRESS:
key = fs.dispatch_key
winner = self.effective_table.resolve(
ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.LONGPRESS
)
winner = self.effective_table.resolve(ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.LONGPRESS)
if winner is not None:
self._fire_row(winner, SwitchEvent(controller=fs, kind=kind, timestamp=timestamp))
return True
Expand All @@ -479,9 +473,7 @@ def _handle_footswitch(self, fs: Footswitch, kind: SwitchEventKind, timestamp: f

# Short press
key = fs.dispatch_key
winner = self.effective_table.resolve(
ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.PRESS
)
winner = self.effective_table.resolve(ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.PRESS)
if winner is not None:
self._fire_row(winner, SwitchEvent(controller=fs, kind=kind, timestamp=timestamp))
return True
Expand Down Expand Up @@ -1245,7 +1237,7 @@ def _publish_bpm(self, param: Parameter) -> bool:
return self.set_mod_tap_tempo(param.value)

def _publish_audio(self, param: Parameter) -> bool:
""" A local ALSA write. No remote echo, so the send always lands."""
"""A local ALSA write. No remote echo, so the send always lands."""
self.audio_parameter_commit(param.symbol, param.value)
return True

Expand Down Expand Up @@ -1521,6 +1513,7 @@ def maybe_show_welcome(self):
if self.settings.get_setting(Token.WELCOME_SEEN):
return
from ui.welcome import WelcomePanel

self.lcd.pstack.push_panel(WelcomePanel(self))

def get_software_version(self) -> str:
Expand All @@ -1545,16 +1538,14 @@ def recovery_available(self) -> bool:
)

def system_menu_shutdown(self, arg):
self.lcd.splash_show(False)
logging.info("System Shutdown")
os.system("sudo systemctl --no-wall poweroff")
os._exit(0)
self.lcd.draw_message_dialog("Shutting down…", title="Please wait", dismissable=False)
os.system("sudo systemctl --no-wall --no-block poweroff")

def system_menu_reboot(self, arg):
self.lcd.splash_show(False)
logging.info("System Reboot")
os.system("sudo systemctl reboot")
os._exit(0)
self.lcd.draw_message_dialog("Rebooting…", title="Please wait", dismissable=False)
os.system("sudo systemctl --no-wall --no-block reboot")

def system_menu_recovery_mode(self, arg):
self.lcd.draw_info_message("Entering recovery mode...", refresh=True)
Expand Down Expand Up @@ -1752,8 +1743,8 @@ def restart_ui_stack(self) -> None:
logging.error("restart_ui_stack: %s", e)

def system_menu_restart_sound(self, arg):
self.lcd.splash_show()
logging.info("Restart sound engine (jack)")
self.lcd.draw_message_dialog("Restarting sound engine…", title="Please wait", dismissable=False)
os.system("sudo systemctl restart jack")

def system_disable_eq(self):
Expand Down
3 changes: 2 additions & 1 deletion modalapi/websocket_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import websockets
import uvloop
from common.parameter import Symbol
from common.util import TEARDOWN_JOIN_S

# Service will restart after this
MAX_RECONNECT_ATTEMPTS = 4
Expand Down Expand Up @@ -286,7 +287,7 @@ def stop(self):
self._worker.running = False
self._worker.signal_stop()
if self._thread and not sys.is_finalizing():
self._thread.join(timeout=2.0)
self._thread.join(timeout=TEARDOWN_JOIN_S)
logging.info(f"WebSocket worker stopped (sent={self._worker.messages_sent})")

def send_bpm(self, bpm: float) -> bool:
Expand Down
4 changes: 3 additions & 1 deletion modalapi/wifi/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar

from common.util import TEARDOWN_JOIN_S

if TYPE_CHECKING:
from .manager import WifiManager

Expand Down Expand Up @@ -201,4 +203,4 @@ def pending_op_count(self) -> int:

def shutdown(self) -> None:
self._cmd_queue.put(_SHUTDOWN_SENTINEL)
self._worker.join(timeout=2.0)
self._worker.join(timeout=TEARDOWN_JOIN_S)
4 changes: 3 additions & 1 deletion modalapi/wifi/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import threading
from typing import Callable, Optional

from common.util import TEARDOWN_JOIN_S

from . import ops
from .commands import CommandQueue
from .nmcli import nmcli, parse_kv_lines
Expand Down Expand Up @@ -72,7 +74,7 @@ def shutdown(self) -> None:
except Exception:
pass
if self.thread is not None:
self.thread.join(timeout=2.0)
self.thread.join(timeout=TEARDOWN_JOIN_S)

def _is_wifi_supported(self) -> bool:
if self.wireless_supported:
Expand Down
39 changes: 10 additions & 29 deletions pistomp/lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,9 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe
# Colors
self.background = (0, 0, 0)
self.foreground = (255, 255, 255)
self.color_splash_up = (70, 255, 70)
self.color_splash_down = (255, 20, 20)

# TODO get fonts from config.json
self.title_font = _make_font(font_path("DejaVuSans-Bold.ttf"), 26)
self.splash_font = _make_font(font_path("DejaVuSans.ttf"), 48)
self.small_font = _make_font(font_path("DejaVuSans.ttf"), 20)
self.tiny_font = _make_font(font_path("DejaVuSans.ttf"), 16)
self.subtitle_font = _make_font(font_path("DejaVuSans.ttf"), 14)
Expand Down Expand Up @@ -199,7 +196,6 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe
self.grid_panel: Optional[GridPanel] = None
self.w_footswitches = []
self.w_controls = []
self.w_splash = None
self.w_info_msg = None
self.w_subtitle: Optional[Subtitle] = None
self._subtitle_desc = "" # last selection description seen
Expand All @@ -208,8 +204,6 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe

# panels
self.pstack = PanelStack(display, image_format="RGB", use_dimming=True)
self.splash_panel = Panel(box=Box.xywh(0, 0, self.display_width, self.display_height))
self.pstack.push_panel(self.splash_panel, refresh=False)
self.main_panel = Panel(
box=Box.xywh(0, 0, self.display_width, self.display_height), persist_on_board_change=True
)
Expand All @@ -233,9 +227,6 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe
# the PanelStack, which is created earlier in this block.
self.wifi_menu: WifiMenu = WifiMenu(self)

if not display.has_system_splash:
self.splash_show(True)

#
# Main
#
Expand Down Expand Up @@ -330,9 +321,7 @@ def _poll_updates(self):
elif isinstance(icon.object, EncoderController):
enc = icon.object
midi_value = (
enc.bar_midi_value()
if enc.parameter is not None
else self.handler.encoder_fallback(enc)
enc.bar_midi_value() if enc.parameter is not None else self.handler.encoder_fallback(enc)
)
elif isinstance(icon.object, BlendMode):
ic = icon.object.input_controller
Expand Down Expand Up @@ -588,8 +577,8 @@ def menu_action(event, params):
self.pstack.push_panel(m)
return m

def draw_message_dialog(self, text, title="Error", on_dismiss=None):
d = MessageDialog(self.pstack, text, title=title, on_dismiss=on_dismiss)
def draw_message_dialog(self, text, title="Error", on_dismiss=None, dismissable=True):
d = MessageDialog(self.pstack, text, title=title, on_dismiss=on_dismiss, dismissable=dismissable)
self.pstack.push_panel(d)

def draw_plugins(self):
Expand Down Expand Up @@ -1083,18 +1072,6 @@ def draw_vu_calibration_dialog(self, symbol, value, commit_callback):
#
# General
#
def splash_show(self, boot=True):
color = self.color_splash_up if boot else self.color_splash_down
if self.w_splash is None:
self.w_splash = TextWidget(
box=Box.xywh(12, 80, self.display_width, self.display_height),
text="pi Stomp!",
font=self.splash_font,
parent=self.splash_panel,
)
self.w_splash.set_foreground(color)
self.splash_panel.refresh()

def cleanup(self):
# Walk every input-accepting panel (dialogs, tuner, plugin panels, …)
# so buried panels are destroyed too, not just the top-most one.
Expand All @@ -1104,7 +1081,7 @@ def cleanup(self):
self.pstack.pop_panel(self.footswitch_panel)
if self.main_panel_pushed and self.main_panel in self.pstack.stack:
self.pstack.pop_panel(self.main_panel)
self.splash_show(False)
self.pstack.refresh() # black screen

def clear(self):
pass
Expand Down Expand Up @@ -1252,8 +1229,12 @@ def draw_analog_assignments(self, controllers):
if k is None:
# Non-mapped control
name = "none"
control_type = ControlType.EXPRESSION if i == 0 else ControlType.KNOB # HACK cuz we don't know type of unmapped
subtitle = "Expression pedal (unassigned)" if control_type == ControlType.EXPRESSION else "Knob (unassigned)"
control_type = (
ControlType.EXPRESSION if i == 0 else ControlType.KNOB
) # HACK cuz we don't know type of unmapped
subtitle = (
"Expression pedal (unassigned)" if control_type == ControlType.EXPRESSION else "Knob (unassigned)"
)
color = accent_color_for(None)
text_color = color
control_label_fn = None
Expand Down
3 changes: 2 additions & 1 deletion plugins/audio_midi/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,8 @@ def _on_toggle_mute(self) -> None:

def _on_restart(self) -> None:
# Mirrors handler.system_menu_restart_sound — restarts jack (which
# cascades to mod-host/mod-ui). The splash covers the teardown.
# cascades to mod-host/mod-ui). The uncancellable dialog covers the
# teardown until this service is SIGTERMed.
self._handler.system_menu_restart_sound(None)

def wants_fast_tick(self) -> bool:
Expand Down
38 changes: 32 additions & 6 deletions tests/integration/test_system_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,44 @@

def test_system_menu_shutdown(modhandler_system: SystemFixture):
handler = modhandler_system.handler
with patch.object(handler.lcd, "cleanup"), patch("os.system") as mock_os, patch("os._exit") as mock_exit:
with patch("os.system") as mock_os:
handler.system_menu_shutdown(None)
mock_os.assert_called_once_with("sudo systemctl --no-wall poweroff")
mock_exit.assert_called_once_with(0)
mock_os.assert_called_once_with("sudo systemctl --no-wall --no-block poweroff")
assert handler.lcd.pstack.current is not None # uncancellable dialog stays up


def test_system_menu_reboot(modhandler_system: SystemFixture):
handler = modhandler_system.handler
with patch("os.system") as mock_os, patch("os._exit") as mock_exit:
with patch("os.system") as mock_os:
handler.system_menu_reboot(None)
mock_os.assert_called_once_with("sudo systemctl reboot")
mock_exit.assert_called_once_with(0)
mock_os.assert_called_once_with("sudo systemctl --no-wall --no-block reboot")
assert handler.lcd.pstack.current is not None # uncancellable dialog stays up


def test_shutdown_survives_polls_before_sigterm(modhandler_system: SystemFixture):
"""The loop keeps ticking between the poweroff request and systemd's SIGTERM."""
handler = modhandler_system.handler
with patch("os.system"):
handler.system_menu_shutdown(None)
handler.lcd.update_bypass(True, True)
handler.lcd.update_wifi({"hotspot_active": False, "wifi_connected": True})
handler.poll_lcd_updates()
assert handler.lcd.pstack.current is not None # dialog still up, not black-screened


def test_uncancellable_message_dialog_has_no_ok_button(modhandler_system: SystemFixture):
"""dismissable=False drops the Ok button and its selectable — nothing to
press, so the dialog swallows everything and can't be dismissed."""
handler = modhandler_system.handler
from pistomp.input.event import EncoderEvent
from tests.v3.nav_helpers import nav_encoder

handler.lcd.draw_message_dialog("Please wait…", dismissable=False)
d = handler.lcd.pstack.current
assert d is not None
assert d.sel_ref is None # no selectable Ok button

assert d.handle(EncoderEvent(controller=nav_encoder(handler), rotations=1)) is True # NAV swallowed


def test_system_menu_reload(modhandler_system: SystemFixture):
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Loading