From 7c465e4b7b1c92b920ab6ba3a698466bdf0162b1 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 28 Aug 2026 18:40:49 -0400 Subject: [PATCH 1/4] Better --- emulator/stubs.py | 27 +-- modalapi/ethernet/manager.py | 157 +++++++++--------- .../test_ethernet_menu_disabled/0.png | Bin 7656 -> 7030 bytes .../0.png | Bin 0 -> 13903 bytes .../0.png | Bin 11552 -> 12332 bytes .../test_ethernet_menu_muted/0.png | Bin 12058 -> 12937 bytes tests/test_ethernet_manager.py | 155 ++++++++--------- tests/v3/test_ethernet_menu.py | 98 ++++------- ui/ethernet_menu.py | 112 +++++-------- 9 files changed, 237 insertions(+), 312 deletions(-) create mode 100644 tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_duplicate_adapters_warning/0.png diff --git a/emulator/stubs.py b/emulator/stubs.py index c31faf395..561fa7bf4 100644 --- a/emulator/stubs.py +++ b/emulator/stubs.py @@ -19,7 +19,7 @@ VirtualAudiocard — in-memory audiocard; no ALSA/hardware access. StubWifiManager — in-memory wifi; satisfies Mod/Modhandler's wifi_manager. -StubEthernetManager — pinned-up ethernet stub; no sysfs / systemctl / threads. +StubEthernetManager — pinned-up ethernet stub; no sysfs / pi-status / threads. StubRelay — no-op relay; satisfies the Relay interface without GPIO. """ @@ -258,18 +258,16 @@ def delete_connection(self, name: str) -> Optional[bytes]: class StubEthernetManager(EthernetManager): """Pinned-up ethernet stub for the emulator. - `carrier_up` is always True so the Wired Connection menu surface is always - reachable. `service_active` is flipped locally by start/stop so the menu - re-renders with the new state on the next poll tick — no real systemd unit - is touched. The base class' background polling thread is not started; we - override __init__ to skip it entirely so the emulator has no /sysfs or - systemctl dependencies. + `carrier_up` and `service_active` are always True, so the full status + screen is reachable. The Mac owns the real lifecycle, so there are no + start or stop verbs. __init__ skips the base thread, so the emulator + needs no sysfs or jackbridge-pi-status. """ def __init__(self) -> None: - # Deliberately skip super().__init__() — no thread, no sysfs polling. + # Skip super().__init__() — no thread, no sysfs polling. self.carrier_up = True - self.service_active = False + self.service_active = True # Signal a single initial render so the menu picks up our fake state. self._changed = True self._lock = threading.Lock() @@ -287,15 +285,8 @@ def read_jack_settings(self) -> tuple[Optional[int], Optional[int]]: def read_xrun_buckets(self) -> tuple[int, int, int]: return (0, 0, 0) - def start_service(self) -> None: - with self._lock: - self.service_active = True - self._changed = True - - def stop_service(self) -> None: - with self._lock: - self.service_active = False - self._changed = True + def read_netadapter_health(self) -> tuple[int, int, str]: + return (1, 6, "eth0") class StubJackMute(JackMute): diff --git a/modalapi/ethernet/manager.py b/modalapi/ethernet/manager.py index f4e59cdc2..b41ca1df7 100644 --- a/modalapi/ethernet/manager.py +++ b/modalapi/ethernet/manager.py @@ -15,36 +15,53 @@ # You should have received a copy of the GNU Affero General Public License # along with pi-stomp. If not, see . +"""Ethernet (wired) status — a read-only view of the pi JackBridge slave. + +The Mac drives the bridge lifecycle: this class only reports what the unit +does. There is no start or stop method here — that path caused the +duplicate-netadapter bug. + +`jackbridge-pi-status` supplies the data (pistomp-companion installs it). +The helper only reads: systemd state, the jack_lsp graph, the IP route, +and xrun counts. Like WifiManager, a background thread does all blocking +I/O and caches the result. The UI thread reads the cache through the +read_* methods. +""" + import logging import os import subprocess import threading -import time from functools import cached_property from typing import Optional from pistomp.alsa_pcm import read_hw_params -# Contract with the JackBridge service: truncate-on-start, atomic-rewrite of a -# bounded list (entries older than 15 min are dropped on each append). The UI -# just reads the whole file each poll. -SERVICE = "pi-stomp-jackbridge.service" -XRUN_FILE = "/tmp/pi-stomp-jackbridge.xruns" +# Source: pistomp-companion jackbridge/pi/bin/. It reaches the pi only +# through pi-gen-pistomp's `jackbridge` deb — add it to that deb's +# debian/rules install list, which is a separate manifest from +# companion's own install.sh. +STATUS_BIN = "/usr/local/libexec/jackbridge/jackbridge-pi-status" POLL_INTERVAL_S = 2.0 -class EthernetManager: - """Polls Ethernet carrier + JackBridge state on a background thread. +def _int(s: Optional[str]) -> int: + """Parse an int from status output. Return 0 for None, empty, or bad text.""" + if not s: + return 0 + try: + return int(s) + except ValueError: + return 0 - Mirrors the WifiManager pattern: all blocking I/O (sysfs, systemctl, - `ip`, `jack_*`, xrun file) runs on the poll thread and is cached under - a lock; the UI thread only reads cached values. `_changed` is flipped - when carrier/service-active flip so the handler's main poll loop can - notify the UI; field-only changes (IP, sample rate, xruns) are picked - up by the menu's periodic tick re-render without setting `_changed`. - Writes (start/stop service) are fire-and-forget via subprocess.Popen - so the UI thread never blocks on systemctl. +class EthernetManager: + """Poll the Ethernet carrier and the JackBridge status on a background thread. + + Read-only. `_changed` is set when the carrier or the service-active + state changes, so the handler poll loop can refresh the UI. Field + changes (IP, sample rate, xruns, port counts) appear on the next + render without `_changed`. """ @cached_property @@ -64,6 +81,9 @@ def iface(self) -> str: def __init__(self) -> None: self.carrier_up: bool = False self.service_active: bool = False + self._netadapters: int = 0 + self._ports_wired: int = 0 + self._route: str = "" self._ipv4: Optional[str] = None self._sample_rate: Optional[int] = None self._period: Optional[int] = None @@ -88,23 +108,36 @@ def _run(self) -> None: def _refresh(self) -> None: carrier = self._probe_carrier() - active = self._probe_service_active() if carrier else False + status = self._read_pi_status() if carrier else {} + + active = status.get("service") == "active" + netadapters = _int(status.get("netadapters")) + ports_wired = _int(status.get("ports_wired")) + route = status.get("route", "") or "" + xruns = ( + _int(status.get("xruns_1m")), + _int(status.get("xruns_5m")), + _int(status.get("xruns_15m")), + ) ipv4 = self._probe_ipv4() if carrier else None + if active: - # One /proc read for both — jack_samplerate/jack_bufsize each fork - # and join the RT graph to learn what hw_params already states. + # One /proc read. jack_samplerate and jack_bufsize each fork and + # join the RT graph to read what hw_params already holds. params = read_hw_params() sample_rate = int(params["rate"]) if "rate" in params else None period = int(params["period_size"]) if "period_size" in params else None - xruns = self._probe_xrun_buckets() else: sample_rate = period = None - xruns = (0, 0, 0) + with self._lock: if carrier != self.carrier_up or active != self.service_active: self._changed = True self.carrier_up = carrier self.service_active = active + self._netadapters = netadapters + self._ports_wired = ports_wired + self._route = route self._ipv4 = ipv4 self._sample_rate = sample_rate self._period = period @@ -124,12 +157,29 @@ def _probe_carrier(self) -> bool: return False @staticmethod - def _probe_service_active() -> bool: + def _read_pi_status() -> dict[str, str]: + """Run jackbridge-pi-status and parse its key=value lines. + + The helper only reads and always exits 0. Any error gives an empty + dict, which the callers treat as zeros. + """ try: - return subprocess.call(["systemctl", "is-active", "--quiet", SERVICE]) == 0 + out = subprocess.check_output( + [STATUS_BIN], + text=True, + timeout=3, + stderr=subprocess.DEVNULL, + ) except Exception as e: - logging.warning("systemctl is-active failed for %s: %s", SERVICE, e) - return False + logging.debug("jackbridge-pi-status failed: %s", e) + return {} + + status: dict[str, str] = {} + for line in out.splitlines(): + key, sep, val = line.partition("=") + if sep: + status[key] = val + return status def _probe_ipv4(self) -> Optional[str]: try: @@ -145,38 +195,7 @@ def _probe_ipv4(self) -> Optional[str]: return parts[i + 1] return None - @staticmethod - def _probe_xrun_buckets() -> tuple[int, int, int]: - # File format (produced by jackbridge-xrun-watcher): up to 15 lines, - # oldest first, " ". Each bucket covers - # [ts, ts+60); include it if its END (ts+60) is within the window so a - # freshly-rolled bucket counts for the 1-min query. - try: - with open(XRUN_FILE) as f: - lines = f.read().splitlines() - except OSError: - return (0, 0, 0) - now = time.time() - b1 = b5 = b15 = 0 - for line in lines: - parts = line.split() - if len(parts) != 2: - continue - try: - ts = float(parts[0]) - count = int(parts[1]) - except ValueError: - continue - dt = now - (ts + 60) - if dt < 60: - b1 += count - if dt < 300: - b5 += count - if dt < 900: - b15 += count - return b1, b5, b15 - - # ----- UI-thread reads (return cached values, no I/O) ----- + # ----- UI-thread reads (cached values; no I/O) ----- def read_ipv4(self) -> Optional[str]: with self._lock: @@ -190,21 +209,11 @@ def read_xrun_buckets(self) -> tuple[int, int, int]: with self._lock: return self._xruns - # ----- service control (non-blocking; bg poll picks up the state flip) ----- - - def start_service(self) -> None: - self._spawn_systemctl("start") - - def stop_service(self) -> None: - self._spawn_systemctl("stop") + def read_netadapter_health(self) -> tuple[int, int, str]: + """(netadapters, ports_wired, route). Cached; read from the UI thread. - @staticmethod - def _spawn_systemctl(verb: str) -> None: - try: - subprocess.Popen( - ["sudo", "systemctl", verb, SERVICE], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception as e: - logging.warning("systemctl %s %s failed to spawn: %s", verb, SERVICE, e) + `netadapters > 1` means the duplicate-slave bug. An empty route, or + a route on a wireless interface, means the Wi-Fi-escape case. + """ + with self._lock: + return self._netadapters, self._ports_wired, self._route diff --git a/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_disabled/0.png b/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_disabled/0.png index 2a22e5b946cc46aae9ca1df731a69369a11b9f29..3184ac1261c6c358b14550971e6ba23c897c3fa0 100644 GIT binary patch literal 7030 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 E0(6t5+yDRo literal 7656 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 YfJaP@v2MR}^xtJ=1$FrnS&NVV1<3*zKmY&$ diff --git a/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_duplicate_adapters_warning/0.png b/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_duplicate_adapters_warning/0.png new file mode 100644 index 0000000000000000000000000000000000000000..10be9c0895a266c6d8eaeb038544958e90c60ef4 GIT binary patch literal 13903 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 E4+=;2EC2ui literal 0 HcmV?d00001 diff --git a/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_enabled_with_stats/0.png b/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_enabled_with_stats/0.png index 4d50bc3cf5243f2540344a08fb6f8254bf90b684..a317a3381b6058bbf085fe83f71fb634a2b1a9d3 100644 GIT binary patch literal 12332 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 literal 11552 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 Q`1ilnl(ZD<6fDF43m667i~s-t literal 12058 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 diff --git a/tests/test_ethernet_manager.py b/tests/test_ethernet_manager.py index 49ae2ceea..85d62d97e 100644 --- a/tests/test_ethernet_manager.py +++ b/tests/test_ethernet_manager.py @@ -1,13 +1,17 @@ -"""Unit tests for EthernetManager — carrier/sysfs/systemctl/ip orchestration. +"""Unit tests for EthernetManager — carrier/sysfs + jackbridge-pi-status parsing. -The background polling thread is suppressed so each test drives state -explicitly via _refresh() or the static _probe_* helpers. All blocking I/O -runs on the poll thread; the public read_* accessors just return cached -values, so they're exercised via _refresh. +The polling thread is suppressed, so each test drives state through +_refresh() or the _probe_* helpers. All blocking I/O is mocked, so this +runs off-device. + +The start/stop verbs are gone (the Mac owns the lifecycle), so their +tests went with them. What remains: carrier detection via sysfs, IPv4 +parsing via `ip`, jackbridge-pi-status parsing, the "service=active +unlocks the hw_params read" gating, and the read_* accessor contract. """ from typing import Generator -from unittest.mock import patch, mock_open +from unittest.mock import mock_open, patch import pytest @@ -50,35 +54,60 @@ def test_probe_carrier_false_when_iface_missing(em: EthernetManager): assert em._probe_carrier() is False -# ---------- _probe_service_active ---------- +# ---------- _read_pi_status ---------- -def test_probe_service_active_true_on_exit_0(): - with patch("subprocess.call", return_value=0): - assert EthernetManager._probe_service_active() is True +def test_read_pi_status_parses_key_value_lines(): + out = ( + "service=active\n" + "netadapters=1\n" + "ports_wired=6\n" + "route=eth0\n" + "iface=eth0\n" + "xruns_1m=0\n" + "xruns_5m=2\n" + "xruns_15m=5\n" + "a line with no equals sign\n" + ) + with patch("subprocess.check_output", return_value=out): + s = EthernetManager._read_pi_status() + assert s["service"] == "active" + assert s["netadapters"] == "1" + assert s["ports_wired"] == "6" + assert s["route"] == "eth0" + assert s["xruns_15m"] == "5" + assert len(s) == 8 # the no-equals line is skipped -def test_probe_service_active_false_on_nonzero_exit(): - with patch("subprocess.call", return_value=3): - assert EthernetManager._probe_service_active() is False +def test_read_pi_status_empty_on_missing_helper(): + with patch("subprocess.check_output", side_effect=OSError("not installed")): + assert EthernetManager._read_pi_status() == {} -def test_probe_service_active_false_on_subprocess_error(): - with patch("subprocess.call", side_effect=OSError("boom")): - assert EthernetManager._probe_service_active() is False +# ---------- _refresh + drain_changed ---------- -# ---------- _refresh + drain_changed ---------- +def _pi_status(service="active", netadapters="1", ports_wired="6", + route="eth0", x1="0", x5="0", x15="0"): + return ( + f"service={service}\n" + f"netadapters={netadapters}\n" + f"ports_wired={ports_wired}\n" + f"route={route}\n" + f"iface=eth0\n" + f"xruns_1m={x1}\n" + f"xruns_5m={x5}\n" + f"xruns_15m={x15}\n" + ) def test_refresh_flips_changed_on_state_transition(em: EthernetManager): assert em.drain_changed() is False # baseline with ( patch.object(EthernetManager, "_probe_carrier", return_value=True), - patch.object(EthernetManager, "_probe_service_active", return_value=True), patch.object(EthernetManager, "_probe_ipv4", return_value="10.0.0.5/24"), + patch("subprocess.check_output", return_value=_pi_status(service="active")), patch(_HW, return_value={"rate": "48000", "period_size": "64"}), - patch.object(EthernetManager, "_probe_xrun_buckets", return_value=(0, 0, 0)), ): em._refresh() assert em.carrier_up is True @@ -89,48 +118,46 @@ def test_refresh_flips_changed_on_state_transition(em: EthernetManager): def test_refresh_no_change_keeps_flag_clear(em: EthernetManager): em.carrier_up = True - em.service_active = False + em.service_active = True with ( patch.object(EthernetManager, "_probe_carrier", return_value=True), - patch.object(EthernetManager, "_probe_service_active", return_value=False), - patch.object(EthernetManager, "_probe_ipv4", return_value=None), + patch.object(EthernetManager, "_probe_ipv4", return_value="10.0.0.5/24"), + patch("subprocess.check_output", return_value=_pi_status(service="active")), + patch(_HW, return_value={"rate": "48000", "period_size": "64"}), ): em._refresh() assert em.drain_changed() is False -def test_refresh_skips_systemctl_and_jack_when_carrier_down(em: EthernetManager): - """Optimization: if no cable, don't bother shelling out to systemctl/ip/jack.""" +def test_refresh_skips_status_and_jack_when_carrier_down(em: EthernetManager): + """Optimisation: with no cable, skip jackbridge-pi-status, ip, and jack.""" with ( patch.object(EthernetManager, "_probe_carrier", return_value=False), - patch.object(EthernetManager, "_probe_service_active") as mock_active, - patch.object(EthernetManager, "_probe_ipv4") as mock_ipv4, - patch(_HW) as mock_jack, - patch.object(EthernetManager, "_probe_xrun_buckets") as mock_xrun, + patch("subprocess.check_output") as m_co, + patch(_HW) as m_hw, ): em._refresh() - mock_active.assert_not_called() - mock_ipv4.assert_not_called() - mock_jack.assert_not_called() - mock_xrun.assert_not_called() - assert em.service_active is False - assert em.read_ipv4() is None + m_co.assert_not_called() + m_hw.assert_not_called() assert em.read_jack_settings() == (None, None) def test_refresh_caches_values_for_ui_thread(em): - """The public read_* accessors return whatever the last _refresh stored — no I/O.""" + """The read_* accessors return what the last _refresh stored, with no I/O.""" with ( patch.object(EthernetManager, "_probe_carrier", return_value=True), - patch.object(EthernetManager, "_probe_service_active", return_value=True), - patch.object(EthernetManager, "_probe_ipv4", return_value="169.254.1.2/16"), + patch.object(EthernetManager, "_probe_ipv4", return_value="10.0.0.5/24"), + patch("subprocess.check_output", return_value=_pi_status( + service="active", netadapters="2", ports_wired="4", route="eth0", + x1="1", x5="2", x15="3", + )), patch(_HW, return_value={"rate": "48000", "period_size": "64"}), - patch.object(EthernetManager, "_probe_xrun_buckets", return_value=(1, 2, 3)), ): em._refresh() - assert em.read_ipv4() == "169.254.1.2/16" + assert em.read_ipv4() == "10.0.0.5/24" assert em.read_jack_settings() == (48000, 64) assert em.read_xrun_buckets() == (1, 2, 3) + assert em.read_netadapter_health() == (2, 4, "eth0") # ---------- _probe_ipv4 ---------- @@ -160,21 +187,20 @@ def test_probe_ipv4_returns_none_on_command_error(em: EthernetManager): "subformat: STD\n" "channels: 2\n" "rate: 48000 (48000/1)\n" - "period_size: 64\n" - "buffer_size: 128\n" + "period_size: 128\n" + "buffer_size: 512\n" ) def test_read_hw_params_takes_first_token_of_each_value(): with patch("builtins.open", mock_open(read_data=_HW_PARAMS)): params = read_hw_params() - assert params["rate"] == "48000" # not "48000 (48000/1)" - assert params["period_size"] == "64" + assert params["rate"] == "48000" + assert params["period_size"] == "128" assert params["channels"] == "2" def test_read_hw_params_empty_when_pcm_closed(): - # The file exists but reads "closed" when nothing holds the device. with patch("builtins.open", mock_open(read_data="closed\n")): assert read_hw_params() == {} @@ -182,44 +208,3 @@ def test_read_hw_params_empty_when_pcm_closed(): def test_read_hw_params_empty_off_device(): with patch("builtins.open", side_effect=FileNotFoundError()): assert read_hw_params() == {} - - -# ---------- _probe_xrun_buckets ---------- - - -def test_probe_xrun_buckets_zero_when_file_missing(): - with patch("builtins.open", side_effect=OSError): - assert EthernetManager._probe_xrun_buckets() == (0, 0, 0) - - -def test_probe_xrun_buckets_bins_by_age(): - # File format: " " per line, bucket end at ts+60. - # now=1000; buckets centered so their END (ts+60) gives dt = now-(ts+60): - # ts=910, count=2 -> dt=30 -> 1m,5m,15m - # ts=740, count=3 -> dt=200 -> 5m,15m - # ts=340, count=5 -> dt=600 -> 15m - # ts=-260, count=7 -> dt=1200 -> none - # garbage and a malformed 1-field line are skipped. - data = "910 2\n740 3\n340 5\n-260 7\ngarbage\n970\n" - with patch("builtins.open", mock_open(read_data=data)), patch("time.time", return_value=1000.0): - b1, b5, b15 = EthernetManager._probe_xrun_buckets() - assert (b1, b5, b15) == (2, 5, 10) - - -# ---------- start_service / stop_service ---------- - - -def test_start_service_spawns_systemctl_non_blocking(em: EthernetManager): - with patch("subprocess.Popen") as m: - em.start_service() - m.assert_called_once() - args, _ = m.call_args - assert args[0] == ["sudo", "systemctl", "start", "pi-stomp-jackbridge.service"] - - -def test_stop_service_spawns_systemctl_non_blocking(em: EthernetManager): - with patch("subprocess.Popen") as m: - em.stop_service() - m.assert_called_once() - args, _ = m.call_args - assert args[0] == ["sudo", "systemctl", "stop", "pi-stomp-jackbridge.service"] diff --git a/tests/v3/test_ethernet_menu.py b/tests/v3/test_ethernet_menu.py index 8dec301fa..f414ec33b 100644 --- a/tests/v3/test_ethernet_menu.py +++ b/tests/v3/test_ethernet_menu.py @@ -3,6 +3,9 @@ Replaces the live EthernetManager (sysfs polling thread) and JackMute (subprocess) on the handler with controllable fakes, then exercises the menu's render and action paths. + +The menu is *read-only* since the "Mac owns the lifecycle" change: there +is no Enable/Disable button anymore, only status rows + Mute MOD + Back. """ from typing import Optional @@ -15,18 +18,17 @@ class FakeEthernetManager: - """Mirrors the EthernetManager surface used by EthernetMenu, with no I/O.""" + """Mirrors the read-only EthernetManager surface used by EthernetMenu.""" def __init__(self, carrier_up=True, service_active=False, ipv4="169.254.125.193/16", jack=(48000, 128), - xruns=(0, 0, 0)): + xruns=(0, 0, 0), health=(1, 6, "eth0")): self.carrier_up = carrier_up self.service_active = service_active self._ipv4 = ipv4 self._jack = jack self._xruns = xruns - self.start_calls = 0 - self.stop_calls = 0 + self._health = health def read_ipv4(self) -> Optional[str]: return self._ipv4 @@ -37,13 +39,8 @@ def read_jack_settings(self): def read_xrun_buckets(self): return self._xruns - def start_service(self) -> None: - self.start_calls += 1 - self.service_active = True - - def stop_service(self) -> None: - self.stop_calls += 1 - self.service_active = False + def read_netadapter_health(self): + return self._health def shutdown(self) -> None: pass @@ -51,7 +48,7 @@ def shutdown(self) -> None: @pytest.fixture def ethernet_env(v3_system): - """Replace the live ethernet_manager and jack_mute with fakes; yield (lcd, fake_em, fake_mute).""" + """Replace ethernet_manager and jack_mute with fakes; yield (lcd, fake_em, fake_mute).""" handler = v3_system.handler handler.ethernet_manager.shutdown() fake_em = FakeEthernetManager() @@ -73,7 +70,7 @@ def _open(lcd) -> EthernetMenu: def test_ethernet_menu_disabled(ethernet_env, snapshot): - """Service inactive — only IP shown, toggle says Enable, MOD not muted.""" + """Service inactive — only IP shown, MOD not muted.""" lcd, em, _ = ethernet_env em.service_active = False _open(lcd) @@ -81,7 +78,7 @@ def test_ethernet_menu_disabled(ethernet_env, snapshot): def test_ethernet_menu_enabled_with_stats(ethernet_env, snapshot): - """Service active — sample rate, period, xrun buckets visible.""" + """Service active — sample rate, period, xrun buckets, link ports visible.""" lcd, em, _ = ethernet_env em.service_active = True em._xruns = (1, 3, 7) @@ -89,6 +86,15 @@ def test_ethernet_menu_enabled_with_stats(ethernet_env, snapshot): snapshot() +def test_ethernet_menu_duplicate_adapters_warning(ethernet_env, snapshot): + """netadapters > 1 surfaces the duplicate-slave warning row.""" + lcd, em, _ = ethernet_env + em.service_active = True + em._health = (2, 6, "eth0") # two netadapters contend for one stream + _open(lcd) + snapshot() + + def test_ethernet_menu_muted(ethernet_env, snapshot): """Service active + MOD muted → button reads "Unmute MOD".""" lcd, em, mute = ethernet_env @@ -99,7 +105,7 @@ def test_ethernet_menu_muted(ethernet_env, snapshot): def test_ethernet_menu_cable_disconnected(ethernet_env, snapshot): - """No carrier → dialog reports the cable is disconnected, no toggle row.""" + """No carrier → dialog reports the cable is disconnected.""" lcd, em, _ = ethernet_env em.carrier_up = False _open(lcd) @@ -111,22 +117,6 @@ def test_ethernet_menu_cable_disconnected(ethernet_env, snapshot): # --------------------------------------------------------------------------- -def test_enable_calls_start_service(ethernet_env): - lcd, em, _ = ethernet_env - em.service_active = False - menu = _open(lcd) - menu._on_toggle_service() - assert em.start_calls == 1 - - -def test_disable_calls_stop_service(ethernet_env): - lcd, em, _ = ethernet_env - em.service_active = True - menu = _open(lcd) - menu._on_toggle_service() - assert em.stop_calls == 1 - - def test_toggle_mute_when_unmuted_calls_mute(ethernet_env): lcd, em, mute = ethernet_env em.service_active = True @@ -145,14 +135,20 @@ def test_toggle_mute_when_muted_calls_unmute(ethernet_env): assert mute.is_muted() is False +def test_menu_has_no_enable_disable_button(ethernet_env): + """The Enable/Disable verb went with the Mac-owns-lifecycle change. + Nothing on this screen may let a musician toggle the service.""" + lcd, em, _ = ethernet_env + em.service_active = False + menu = _open(lcd) + assert menu._panel is not None + labels = [w.text for w in menu._panel.sel_list] + assert "Enable" not in labels + assert "Disable" not in labels + + # --------------------------------------------------------------------------- # In-place update regression tests -# -# The dialog used to be torn down and rebuilt on every 2-second tick and on -# every button press, which both (a) destroyed the widget under the user's -# finger and (b) forced a full-screen redraw. The current implementation -# mutates a small set of widgets in place via set_text(), which takes the -# per-widget dirty-rect path. These tests guard that contract. # --------------------------------------------------------------------------- @@ -164,8 +160,6 @@ def test_tick_does_not_rebuild_panel(ethernet_env): first_panel = menu._panel menu.tick() assert menu._panel is first_panel, "tick() must not pop+rebuild the dialog" - # The xrun widgets must still be the same instances — that's how we know - # set_text mutated in place rather than _render recreating them. assert len(menu._xrun_widgets) == 3 widget_ids = [id(w) for w in menu._xrun_widgets] menu.tick() @@ -194,20 +188,6 @@ def test_tick_noop_when_service_inactive(ethernet_env): assert menu._panel is first_panel # still untouched -def test_toggle_service_updates_button_label(ethernet_env): - lcd, em, _ = ethernet_env - em.service_active = False - menu = _open(lcd) - toggle = menu._toggle_btn - assert toggle is not None - assert toggle.text == "Enable" - menu._on_toggle_service() - assert menu._panel is toggle.parent # no rebuild - assert toggle.text == "Disable" # mutated in place - menu._on_toggle_service() - assert toggle.text == "Enable" - - def test_toggle_mute_updates_button_label(ethernet_env): lcd, em, mute = ethernet_env em.service_active = True @@ -258,15 +238,3 @@ def test_back_pops_panel(ethernet_env): menu._on_back() assert menu._panel is None assert lcd.pstack.current is not panel - - -def test_enable_then_state_flip_shows_disable(ethernet_env): - """After Enable fires, the toggle button optimistically reads 'Disable'.""" - lcd, em, _ = ethernet_env - em.service_active = False - menu = _open(lcd) - menu._on_toggle_service() # StubFake flips service_active to True synchronously - # Find the toggle widget by walking the panel's selectable list. - assert menu._panel is not None - labels = [w.text for w in menu._panel.sel_list] - assert "Disable" in labels diff --git a/ui/ethernet_menu.py b/ui/ethernet_menu.py index fbf96385c..b55ca98ef 100644 --- a/ui/ethernet_menu.py +++ b/ui/ethernet_menu.py @@ -51,30 +51,31 @@ class _EthernetHost(Protocol): class EthernetMenu: - """The Wired Connection sub-screen: status readout + enable/disable toggle. + """The Wired Connection sub-screen. Status readout only. - A single Dialog is pushed onto the panel stack; re-renders are done by - popping and rebuilding (mirroring WifiMenu.notify_status_change). State - comes from EthernetManager, which polls carrier + service-active on a - background thread; this class touches the panel stack only from the UI - thread (via handler poll-loop callbacks). + The Enable/Disable toggle is gone (see modalapi/ethernet/manager.py). + pi-stomp-jackbridge starts when the Mac asks for it and stays under + systemd supervision. A musician has nothing to toggle here. The repair + verbs live in the Mac menu. + + One Dialog sits on the panel stack. A re-render pops and rebuilds it + (like WifiMenu.notify_status_change). State comes from EthernetManager, + which polls on a background thread. This class touches the panel stack + only from the UI thread. """ def __init__(self, lcd: "Lcd") -> None: self.lcd: "Lcd" = lcd self._panel: Optional[Dialog] = None - # Remembered across pop/rebuild so periodic re-renders don't yank focus - # back to the toggle button after the user has moved selection. - # One of: 'back', 'toggle', 'mute', or None. + # Kept across a pop/rebuild so a re-render holds the user's selection. + # One of: 'back', 'mute', or None. self._last_selected_role: Optional[str] = None self._role_widgets: dict[str, object] = {} - # Refs to widgets that mutate in place — reset on every _render(). - # tick() updates the xrun rows via set_text(); the action handlers - # update their own button label the same way. None of these paths - # rebuild the dialog, so the buttons don't vanish under the user's - # finger and the SPI blit stays a precise clip. + # Widgets that mutate in place. Reset on every _render(). tick() + # updates the xrun rows with set_text(); the mute handler updates its + # own label the same way. Neither path rebuilds the dialog, so a + # button never moves under the user's finger. self._xrun_widgets: list[TextWidget] = [] - self._toggle_btn: Optional[TextWidget] = None self._mute_btn: Optional[TextWidget] = None def _capture_selected_role(self) -> None: @@ -117,10 +118,10 @@ def open(self, event: object = None, widget: object = None) -> None: self._render() def notify_change(self) -> None: - """Carrier or service-active flipped — re-render if we're on top. + """Carrier or service-active changed. Re-render if this panel is on top. - If the cable was pulled, pop the sub-screen and surface the - disconnected dialog so the user isn't left looking at a stale IP.""" + If the cable was pulled, pop the sub-screen and show the + disconnected dialog so no stale IP stays on screen.""" if self._panel is None or self._pstack.current is not self._panel: return if not self._manager.carrier_up: @@ -132,11 +133,11 @@ def notify_change(self) -> None: self._render() def tick(self) -> None: - """Update the xrun counters while we're on top, without rebuilding - the dialog. The rest of the rows and the buttons are static between - state flips; mutating them via set_text() takes the per-widget - dirty-rect path so the buttons stay put (no reblit under the user's - finger, no full-screen redraw on the SPI bus).""" + """Update the xrun counters in place while this panel is on top. + + The other rows and the buttons are static between state changes. + set_text() takes the per-widget dirty-rect path, so the buttons stay + put and the SPI bus sees no full redraw.""" if self._panel is None or self._pstack.current is not self._panel: return if not self._manager.carrier_up: @@ -159,13 +160,13 @@ def _render(self) -> None: self._panel = None self._pstack.pop_panel(old) - # Old widgets were just destroyed — drop our refs so tick() / actions - # don't try to mutate zombies if a render races a poll. + # The old widgets are gone. Drop the refs so a render that races a + # poll does not touch a dead widget. self._xrun_widgets = [] - self._toggle_btn = None self._mute_btn = None active = self._manager.service_active + n_adapters, n_wired, route = self._manager.read_netadapter_health() d = Dialog(width=DIALOG_W, height=DIALOG_H, title="Ethernet Audio Interface", auto_destroy=True) font = _make_font(_FONTS_DIR / "DejaVuSans.ttf", 14) @@ -179,9 +180,13 @@ def _render(self) -> None: rows.append(("xruns 1m:", str(b1))) rows.append(("xruns 5m:", str(b5))) rows.append(("xruns 15m:", str(b15))) + rows.append(("Link ports:", f"{n_wired}/6 wired")) + if n_adapters > 1: + rows.append(("Adapters:", f"⚠ {n_adapters} (duplicate)")) + if not route or route.startswith("wl"): + rows.append(("Route:", f"⚠ {route or 'none'}")) muted = self._mute.is_muted() - toggle_label = "Disable" if active else "Enable" mute_label = "Unmute MOD" if muted else "Mute MOD" line_h = 18 @@ -215,29 +220,8 @@ def _render(self) -> None: ) d.add_sel_widget(back_btn) - # Toggle sits to the right of back; constructed with the wider of the - # two labels so the box is the same size in both states. Without - # this, swapping "Enable"→"Disable" via set_text() would clip the - # trailing "e" inside the original (narrower) box. - assert back_btn.box - toggle_x = back_btn.box.x0 + back_btn.box.width + 6 - toggle_btn = TextWidget( - box=Box.xywh(toggle_x, btn_y, 0, 0), - text="Disable", - parent=d, - outline=1, - sel_width=3, - outline_radius=5, - action=self._on_toggle_service, - align=WidgetAlign.NONE, - name="ethernet_toggle_btn", - ) - toggle_btn.set_text(toggle_label) - d.add_sel_widget(toggle_btn) - self._toggle_btn = toggle_btn - - # Mute button: same trick — size to fit "Unmute MOD" so a set_text - # back to "Mute MOD" doesn't leave dead space at the right edge. + # Size the mute button for "Unmute MOD" so a set_text back to + # "Mute MOD" leaves no dead space at the right edge. mute_btn = TextWidget( box=Box.xywh(0, btn_y, 0, 0), text="Unmute MOD", @@ -257,13 +241,13 @@ def _render(self) -> None: d.add_sel_widget(mute_btn) self._mute_btn = mute_btn - # Stash refs by role so re-renders can preserve selection (panel pop - # blows away widget identity, so we track which role was selected). - self._role_widgets = {"back": back_btn, "toggle": toggle_btn, "mute": mute_btn} + # Track which role holds the selection. A panel pop destroys widget + # identity, so a re-render restores selection by role, not by ref. + self._role_widgets = {"back": back_btn, "mute": mute_btn} - # Restore selection from before the rebuild when possible, so periodic - # ticks and unrelated actions (Mute) don't drag focus back to Toggle. - restore_target = self._role_widgets.get(self._last_selected_role or "toggle", toggle_btn) + # Keep the selection across a rebuild so ticks and the Mute action + # do not move the focus. + restore_target = self._role_widgets.get(self._last_selected_role or "mute", mute_btn) d.sel_widget(restore_target) self._panel = d @@ -275,20 +259,8 @@ def _show_disconnected_dialog(self) -> None: # ----- actions ----- - def _on_toggle_service(self, _event: object = None, _widget: object = None) -> None: - # Optimistic update: show the *new* state immediately, before the - # background poll observes systemctl's effect. The bg poll's - # notify_change() will re-render the dialog (full rebuild is needed - # there anyway, because the row *set* changes when service_active - # flips) and reconcile any drift. - if self._manager.service_active: - self._manager.stop_service() - new_label = "Enable" - else: - self._manager.start_service() - new_label = "Disable" - if self._toggle_btn is not None: - self._toggle_btn.set_text(new_label) + # No _on_toggle_service: the Enable/Disable button is gone. The Mac menu + # owns start and stop; this screen only renders. def _on_toggle_mute(self, _event: object = None, _widget: object = None) -> None: if self._mute.is_muted(): From c5480b0d873e910af117542a329fb5f68c3908c7 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 28 Aug 2026 21:41:36 -0400 Subject: [PATCH 2/4] Healin' --- emulator/stubs.py | 3 + modalapi/ethernet/manager.py | 19 +++-- .../test_ethernet_menu_link_resyncing/0.png | Bin 0 -> 13932 bytes tests/test_ethernet_manager.py | 67 ++++++++++++++++-- tests/v3/test_ethernet_menu.py | 27 ++++++- ui/ethernet_menu.py | 9 ++- 6 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_link_resyncing/0.png diff --git a/emulator/stubs.py b/emulator/stubs.py index 561fa7bf4..b9b9f5276 100644 --- a/emulator/stubs.py +++ b/emulator/stubs.py @@ -288,6 +288,9 @@ def read_xrun_buckets(self) -> tuple[int, int, int]: def read_netadapter_health(self) -> tuple[int, int, str]: return (1, 6, "eth0") + def read_link_health(self) -> tuple[bool, int]: + return (False, 0) + class StubJackMute(JackMute): """In-memory mute state; no JACK calls. Lets the menu's Mute/Unmute MOD diff --git a/modalapi/ethernet/manager.py b/modalapi/ethernet/manager.py index b41ca1df7..9b22eaed4 100644 --- a/modalapi/ethernet/manager.py +++ b/modalapi/ethernet/manager.py @@ -23,7 +23,7 @@ `jackbridge-pi-status` supplies the data (pistomp-companion installs it). The helper only reads: systemd state, the jack_lsp graph, the IP route, -and xrun counts. Like WifiManager, a background thread does all blocking +xrun counts, and netadapter link restarts. Like WifiManager, a background thread does all blocking I/O and caches the result. The UI thread reads the cache through the read_* methods. """ @@ -58,8 +58,8 @@ def _int(s: Optional[str]) -> int: class EthernetManager: """Poll the Ethernet carrier and the JackBridge status on a background thread. - Read-only. `_changed` is set when the carrier or the service-active - state changes, so the handler poll loop can refresh the UI. Field + Read-only. `_changed` is set when the carrier, the service-active state + or the link-resyncing state changes, so the handler poll loop can refresh the UI. Field changes (IP, sample rate, xruns, port counts) appear on the next render without `_changed`. """ @@ -84,6 +84,8 @@ def __init__(self) -> None: self._netadapters: int = 0 self._ports_wired: int = 0 self._route: str = "" + self._link_resyncing: bool = False + self._net_restarts: int = 0 self._ipv4: Optional[str] = None self._sample_rate: Optional[int] = None self._period: Optional[int] = None @@ -114,6 +116,8 @@ def _refresh(self) -> None: netadapters = _int(status.get("netadapters")) ports_wired = _int(status.get("ports_wired")) route = status.get("route", "") or "" + link_resyncing = status.get("link") == "resyncing" + net_restarts = _int(status.get("net_restarts")) xruns = ( _int(status.get("xruns_1m")), _int(status.get("xruns_5m")), @@ -131,13 +135,15 @@ def _refresh(self) -> None: sample_rate = period = None with self._lock: - if carrier != self.carrier_up or active != self.service_active: + if carrier != self.carrier_up or active != self.service_active or link_resyncing != self._link_resyncing: self._changed = True self.carrier_up = carrier self.service_active = active self._netadapters = netadapters self._ports_wired = ports_wired self._route = route + self._link_resyncing = link_resyncing + self._net_restarts = net_restarts self._ipv4 = ipv4 self._sample_rate = sample_rate self._period = period @@ -209,6 +215,11 @@ def read_xrun_buckets(self) -> tuple[int, int, int]: with self._lock: return self._xruns + def read_link_health(self) -> tuple[bool, int]: + """(resyncing, restarts). Cached; read from the UI thread.""" + with self._lock: + return self._link_resyncing, self._net_restarts + def read_netadapter_health(self) -> tuple[int, int, str]: """(netadapters, ports_wired, route). Cached; read from the UI thread. diff --git a/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_link_resyncing/0.png b/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_link_resyncing/0.png new file mode 100644 index 0000000000000000000000000000000000000000..486fa49bb43d5f015468341255c1f3da9f32f720 GIT binary patch literal 13932 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 Optional[str]: return self._ipv4 @@ -42,6 +50,9 @@ def read_xrun_buckets(self): def read_netadapter_health(self): return self._health + def read_link_health(self): + return self._link + def shutdown(self) -> None: pass @@ -95,6 +106,16 @@ def test_ethernet_menu_duplicate_adapters_warning(ethernet_env, snapshot): snapshot() +def test_ethernet_menu_link_resyncing(ethernet_env, snapshot): + """netadapter is restarting its link — the port count is replaced by the + one row that reports it, plus the remedy.""" + lcd, em, _ = ethernet_env + em.service_active = True + em._link = (True, 4) + _open(lcd) + snapshot() + + def test_ethernet_menu_muted(ethernet_env, snapshot): """Service active + MOD muted → button reads "Unmute MOD".""" lcd, em, mute = ethernet_env diff --git a/ui/ethernet_menu.py b/ui/ethernet_menu.py index b55ca98ef..1b2c488b0 100644 --- a/ui/ethernet_menu.py +++ b/ui/ethernet_menu.py @@ -167,6 +167,7 @@ def _render(self) -> None: active = self._manager.service_active n_adapters, n_wired, route = self._manager.read_netadapter_health() + resyncing, restarts = self._manager.read_link_health() d = Dialog(width=DIALOG_W, height=DIALOG_H, title="Ethernet Audio Interface", auto_destroy=True) font = _make_font(_FONTS_DIR / "DejaVuSans.ttf", 14) @@ -180,7 +181,13 @@ def _render(self) -> None: rows.append(("xruns 1m:", str(b1))) rows.append(("xruns 5m:", str(b5))) rows.append(("xruns 15m:", str(b15))) - rows.append(("Link ports:", f"{n_wired}/6 wired")) + if resyncing: + # The ports stay wired through a netadapter restart, so the + # port count (unfortunately) continues to read healthy + rows.append(("Link:", f"⚠ resyncing (x{restarts})")) + rows.append(("", "Restart JackBridge on Host")) + else: + rows.append(("Link ports:", f"{n_wired}/6 wired")) if n_adapters > 1: rows.append(("Adapters:", f"⚠ {n_adapters} (duplicate)")) if not route or route.startswith("wl"): From ad1e6f4826fc006b2b69f408a881dc0a9fed13c8 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 28 Aug 2026 18:40:49 -0400 Subject: [PATCH 3/4] Better --- emulator/stubs.py | 27 +-- modalapi/ethernet/manager.py | 157 +++++++++--------- .../test_ethernet_menu_disabled/0.png | Bin 7656 -> 7030 bytes .../0.png | Bin 0 -> 13903 bytes .../0.png | Bin 11552 -> 12332 bytes .../test_ethernet_menu_muted/0.png | Bin 12058 -> 12937 bytes tests/test_ethernet_manager.py | 155 ++++++++--------- tests/v3/test_ethernet_menu.py | 98 ++++------- ui/ethernet_menu.py | 112 +++++-------- 9 files changed, 237 insertions(+), 312 deletions(-) create mode 100644 tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_duplicate_adapters_warning/0.png diff --git a/emulator/stubs.py b/emulator/stubs.py index c31faf395..561fa7bf4 100644 --- a/emulator/stubs.py +++ b/emulator/stubs.py @@ -19,7 +19,7 @@ VirtualAudiocard — in-memory audiocard; no ALSA/hardware access. StubWifiManager — in-memory wifi; satisfies Mod/Modhandler's wifi_manager. -StubEthernetManager — pinned-up ethernet stub; no sysfs / systemctl / threads. +StubEthernetManager — pinned-up ethernet stub; no sysfs / pi-status / threads. StubRelay — no-op relay; satisfies the Relay interface without GPIO. """ @@ -258,18 +258,16 @@ def delete_connection(self, name: str) -> Optional[bytes]: class StubEthernetManager(EthernetManager): """Pinned-up ethernet stub for the emulator. - `carrier_up` is always True so the Wired Connection menu surface is always - reachable. `service_active` is flipped locally by start/stop so the menu - re-renders with the new state on the next poll tick — no real systemd unit - is touched. The base class' background polling thread is not started; we - override __init__ to skip it entirely so the emulator has no /sysfs or - systemctl dependencies. + `carrier_up` and `service_active` are always True, so the full status + screen is reachable. The Mac owns the real lifecycle, so there are no + start or stop verbs. __init__ skips the base thread, so the emulator + needs no sysfs or jackbridge-pi-status. """ def __init__(self) -> None: - # Deliberately skip super().__init__() — no thread, no sysfs polling. + # Skip super().__init__() — no thread, no sysfs polling. self.carrier_up = True - self.service_active = False + self.service_active = True # Signal a single initial render so the menu picks up our fake state. self._changed = True self._lock = threading.Lock() @@ -287,15 +285,8 @@ def read_jack_settings(self) -> tuple[Optional[int], Optional[int]]: def read_xrun_buckets(self) -> tuple[int, int, int]: return (0, 0, 0) - def start_service(self) -> None: - with self._lock: - self.service_active = True - self._changed = True - - def stop_service(self) -> None: - with self._lock: - self.service_active = False - self._changed = True + def read_netadapter_health(self) -> tuple[int, int, str]: + return (1, 6, "eth0") class StubJackMute(JackMute): diff --git a/modalapi/ethernet/manager.py b/modalapi/ethernet/manager.py index f4e59cdc2..b41ca1df7 100644 --- a/modalapi/ethernet/manager.py +++ b/modalapi/ethernet/manager.py @@ -15,36 +15,53 @@ # You should have received a copy of the GNU Affero General Public License # along with pi-stomp. If not, see . +"""Ethernet (wired) status — a read-only view of the pi JackBridge slave. + +The Mac drives the bridge lifecycle: this class only reports what the unit +does. There is no start or stop method here — that path caused the +duplicate-netadapter bug. + +`jackbridge-pi-status` supplies the data (pistomp-companion installs it). +The helper only reads: systemd state, the jack_lsp graph, the IP route, +and xrun counts. Like WifiManager, a background thread does all blocking +I/O and caches the result. The UI thread reads the cache through the +read_* methods. +""" + import logging import os import subprocess import threading -import time from functools import cached_property from typing import Optional from pistomp.alsa_pcm import read_hw_params -# Contract with the JackBridge service: truncate-on-start, atomic-rewrite of a -# bounded list (entries older than 15 min are dropped on each append). The UI -# just reads the whole file each poll. -SERVICE = "pi-stomp-jackbridge.service" -XRUN_FILE = "/tmp/pi-stomp-jackbridge.xruns" +# Source: pistomp-companion jackbridge/pi/bin/. It reaches the pi only +# through pi-gen-pistomp's `jackbridge` deb — add it to that deb's +# debian/rules install list, which is a separate manifest from +# companion's own install.sh. +STATUS_BIN = "/usr/local/libexec/jackbridge/jackbridge-pi-status" POLL_INTERVAL_S = 2.0 -class EthernetManager: - """Polls Ethernet carrier + JackBridge state on a background thread. +def _int(s: Optional[str]) -> int: + """Parse an int from status output. Return 0 for None, empty, or bad text.""" + if not s: + return 0 + try: + return int(s) + except ValueError: + return 0 - Mirrors the WifiManager pattern: all blocking I/O (sysfs, systemctl, - `ip`, `jack_*`, xrun file) runs on the poll thread and is cached under - a lock; the UI thread only reads cached values. `_changed` is flipped - when carrier/service-active flip so the handler's main poll loop can - notify the UI; field-only changes (IP, sample rate, xruns) are picked - up by the menu's periodic tick re-render without setting `_changed`. - Writes (start/stop service) are fire-and-forget via subprocess.Popen - so the UI thread never blocks on systemctl. +class EthernetManager: + """Poll the Ethernet carrier and the JackBridge status on a background thread. + + Read-only. `_changed` is set when the carrier or the service-active + state changes, so the handler poll loop can refresh the UI. Field + changes (IP, sample rate, xruns, port counts) appear on the next + render without `_changed`. """ @cached_property @@ -64,6 +81,9 @@ def iface(self) -> str: def __init__(self) -> None: self.carrier_up: bool = False self.service_active: bool = False + self._netadapters: int = 0 + self._ports_wired: int = 0 + self._route: str = "" self._ipv4: Optional[str] = None self._sample_rate: Optional[int] = None self._period: Optional[int] = None @@ -88,23 +108,36 @@ def _run(self) -> None: def _refresh(self) -> None: carrier = self._probe_carrier() - active = self._probe_service_active() if carrier else False + status = self._read_pi_status() if carrier else {} + + active = status.get("service") == "active" + netadapters = _int(status.get("netadapters")) + ports_wired = _int(status.get("ports_wired")) + route = status.get("route", "") or "" + xruns = ( + _int(status.get("xruns_1m")), + _int(status.get("xruns_5m")), + _int(status.get("xruns_15m")), + ) ipv4 = self._probe_ipv4() if carrier else None + if active: - # One /proc read for both — jack_samplerate/jack_bufsize each fork - # and join the RT graph to learn what hw_params already states. + # One /proc read. jack_samplerate and jack_bufsize each fork and + # join the RT graph to read what hw_params already holds. params = read_hw_params() sample_rate = int(params["rate"]) if "rate" in params else None period = int(params["period_size"]) if "period_size" in params else None - xruns = self._probe_xrun_buckets() else: sample_rate = period = None - xruns = (0, 0, 0) + with self._lock: if carrier != self.carrier_up or active != self.service_active: self._changed = True self.carrier_up = carrier self.service_active = active + self._netadapters = netadapters + self._ports_wired = ports_wired + self._route = route self._ipv4 = ipv4 self._sample_rate = sample_rate self._period = period @@ -124,12 +157,29 @@ def _probe_carrier(self) -> bool: return False @staticmethod - def _probe_service_active() -> bool: + def _read_pi_status() -> dict[str, str]: + """Run jackbridge-pi-status and parse its key=value lines. + + The helper only reads and always exits 0. Any error gives an empty + dict, which the callers treat as zeros. + """ try: - return subprocess.call(["systemctl", "is-active", "--quiet", SERVICE]) == 0 + out = subprocess.check_output( + [STATUS_BIN], + text=True, + timeout=3, + stderr=subprocess.DEVNULL, + ) except Exception as e: - logging.warning("systemctl is-active failed for %s: %s", SERVICE, e) - return False + logging.debug("jackbridge-pi-status failed: %s", e) + return {} + + status: dict[str, str] = {} + for line in out.splitlines(): + key, sep, val = line.partition("=") + if sep: + status[key] = val + return status def _probe_ipv4(self) -> Optional[str]: try: @@ -145,38 +195,7 @@ def _probe_ipv4(self) -> Optional[str]: return parts[i + 1] return None - @staticmethod - def _probe_xrun_buckets() -> tuple[int, int, int]: - # File format (produced by jackbridge-xrun-watcher): up to 15 lines, - # oldest first, " ". Each bucket covers - # [ts, ts+60); include it if its END (ts+60) is within the window so a - # freshly-rolled bucket counts for the 1-min query. - try: - with open(XRUN_FILE) as f: - lines = f.read().splitlines() - except OSError: - return (0, 0, 0) - now = time.time() - b1 = b5 = b15 = 0 - for line in lines: - parts = line.split() - if len(parts) != 2: - continue - try: - ts = float(parts[0]) - count = int(parts[1]) - except ValueError: - continue - dt = now - (ts + 60) - if dt < 60: - b1 += count - if dt < 300: - b5 += count - if dt < 900: - b15 += count - return b1, b5, b15 - - # ----- UI-thread reads (return cached values, no I/O) ----- + # ----- UI-thread reads (cached values; no I/O) ----- def read_ipv4(self) -> Optional[str]: with self._lock: @@ -190,21 +209,11 @@ def read_xrun_buckets(self) -> tuple[int, int, int]: with self._lock: return self._xruns - # ----- service control (non-blocking; bg poll picks up the state flip) ----- - - def start_service(self) -> None: - self._spawn_systemctl("start") - - def stop_service(self) -> None: - self._spawn_systemctl("stop") + def read_netadapter_health(self) -> tuple[int, int, str]: + """(netadapters, ports_wired, route). Cached; read from the UI thread. - @staticmethod - def _spawn_systemctl(verb: str) -> None: - try: - subprocess.Popen( - ["sudo", "systemctl", verb, SERVICE], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception as e: - logging.warning("systemctl %s %s failed to spawn: %s", verb, SERVICE, e) + `netadapters > 1` means the duplicate-slave bug. An empty route, or + a route on a wireless interface, means the Wi-Fi-escape case. + """ + with self._lock: + return self._netadapters, self._ports_wired, self._route diff --git a/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_disabled/0.png b/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_disabled/0.png index 2a22e5b946cc46aae9ca1df731a69369a11b9f29..3184ac1261c6c358b14550971e6ba23c897c3fa0 100644 GIT binary patch literal 7030 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 E0(6t5+yDRo literal 7656 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 YfJaP@v2MR}^xtJ=1$FrnS&NVV1<3*zKmY&$ diff --git a/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_duplicate_adapters_warning/0.png b/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_duplicate_adapters_warning/0.png new file mode 100644 index 0000000000000000000000000000000000000000..10be9c0895a266c6d8eaeb038544958e90c60ef4 GIT binary patch literal 13903 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 E4+=;2EC2ui literal 0 HcmV?d00001 diff --git a/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_enabled_with_stats/0.png b/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_enabled_with_stats/0.png index 4d50bc3cf5243f2540344a08fb6f8254bf90b684..a317a3381b6058bbf085fe83f71fb634a2b1a9d3 100644 GIT binary patch literal 12332 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 literal 11552 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 Q`1ilnl(ZD<6fDF43m667i~s-t literal 12058 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 diff --git a/tests/test_ethernet_manager.py b/tests/test_ethernet_manager.py index 49ae2ceea..85d62d97e 100644 --- a/tests/test_ethernet_manager.py +++ b/tests/test_ethernet_manager.py @@ -1,13 +1,17 @@ -"""Unit tests for EthernetManager — carrier/sysfs/systemctl/ip orchestration. +"""Unit tests for EthernetManager — carrier/sysfs + jackbridge-pi-status parsing. -The background polling thread is suppressed so each test drives state -explicitly via _refresh() or the static _probe_* helpers. All blocking I/O -runs on the poll thread; the public read_* accessors just return cached -values, so they're exercised via _refresh. +The polling thread is suppressed, so each test drives state through +_refresh() or the _probe_* helpers. All blocking I/O is mocked, so this +runs off-device. + +The start/stop verbs are gone (the Mac owns the lifecycle), so their +tests went with them. What remains: carrier detection via sysfs, IPv4 +parsing via `ip`, jackbridge-pi-status parsing, the "service=active +unlocks the hw_params read" gating, and the read_* accessor contract. """ from typing import Generator -from unittest.mock import patch, mock_open +from unittest.mock import mock_open, patch import pytest @@ -50,35 +54,60 @@ def test_probe_carrier_false_when_iface_missing(em: EthernetManager): assert em._probe_carrier() is False -# ---------- _probe_service_active ---------- +# ---------- _read_pi_status ---------- -def test_probe_service_active_true_on_exit_0(): - with patch("subprocess.call", return_value=0): - assert EthernetManager._probe_service_active() is True +def test_read_pi_status_parses_key_value_lines(): + out = ( + "service=active\n" + "netadapters=1\n" + "ports_wired=6\n" + "route=eth0\n" + "iface=eth0\n" + "xruns_1m=0\n" + "xruns_5m=2\n" + "xruns_15m=5\n" + "a line with no equals sign\n" + ) + with patch("subprocess.check_output", return_value=out): + s = EthernetManager._read_pi_status() + assert s["service"] == "active" + assert s["netadapters"] == "1" + assert s["ports_wired"] == "6" + assert s["route"] == "eth0" + assert s["xruns_15m"] == "5" + assert len(s) == 8 # the no-equals line is skipped -def test_probe_service_active_false_on_nonzero_exit(): - with patch("subprocess.call", return_value=3): - assert EthernetManager._probe_service_active() is False +def test_read_pi_status_empty_on_missing_helper(): + with patch("subprocess.check_output", side_effect=OSError("not installed")): + assert EthernetManager._read_pi_status() == {} -def test_probe_service_active_false_on_subprocess_error(): - with patch("subprocess.call", side_effect=OSError("boom")): - assert EthernetManager._probe_service_active() is False +# ---------- _refresh + drain_changed ---------- -# ---------- _refresh + drain_changed ---------- +def _pi_status(service="active", netadapters="1", ports_wired="6", + route="eth0", x1="0", x5="0", x15="0"): + return ( + f"service={service}\n" + f"netadapters={netadapters}\n" + f"ports_wired={ports_wired}\n" + f"route={route}\n" + f"iface=eth0\n" + f"xruns_1m={x1}\n" + f"xruns_5m={x5}\n" + f"xruns_15m={x15}\n" + ) def test_refresh_flips_changed_on_state_transition(em: EthernetManager): assert em.drain_changed() is False # baseline with ( patch.object(EthernetManager, "_probe_carrier", return_value=True), - patch.object(EthernetManager, "_probe_service_active", return_value=True), patch.object(EthernetManager, "_probe_ipv4", return_value="10.0.0.5/24"), + patch("subprocess.check_output", return_value=_pi_status(service="active")), patch(_HW, return_value={"rate": "48000", "period_size": "64"}), - patch.object(EthernetManager, "_probe_xrun_buckets", return_value=(0, 0, 0)), ): em._refresh() assert em.carrier_up is True @@ -89,48 +118,46 @@ def test_refresh_flips_changed_on_state_transition(em: EthernetManager): def test_refresh_no_change_keeps_flag_clear(em: EthernetManager): em.carrier_up = True - em.service_active = False + em.service_active = True with ( patch.object(EthernetManager, "_probe_carrier", return_value=True), - patch.object(EthernetManager, "_probe_service_active", return_value=False), - patch.object(EthernetManager, "_probe_ipv4", return_value=None), + patch.object(EthernetManager, "_probe_ipv4", return_value="10.0.0.5/24"), + patch("subprocess.check_output", return_value=_pi_status(service="active")), + patch(_HW, return_value={"rate": "48000", "period_size": "64"}), ): em._refresh() assert em.drain_changed() is False -def test_refresh_skips_systemctl_and_jack_when_carrier_down(em: EthernetManager): - """Optimization: if no cable, don't bother shelling out to systemctl/ip/jack.""" +def test_refresh_skips_status_and_jack_when_carrier_down(em: EthernetManager): + """Optimisation: with no cable, skip jackbridge-pi-status, ip, and jack.""" with ( patch.object(EthernetManager, "_probe_carrier", return_value=False), - patch.object(EthernetManager, "_probe_service_active") as mock_active, - patch.object(EthernetManager, "_probe_ipv4") as mock_ipv4, - patch(_HW) as mock_jack, - patch.object(EthernetManager, "_probe_xrun_buckets") as mock_xrun, + patch("subprocess.check_output") as m_co, + patch(_HW) as m_hw, ): em._refresh() - mock_active.assert_not_called() - mock_ipv4.assert_not_called() - mock_jack.assert_not_called() - mock_xrun.assert_not_called() - assert em.service_active is False - assert em.read_ipv4() is None + m_co.assert_not_called() + m_hw.assert_not_called() assert em.read_jack_settings() == (None, None) def test_refresh_caches_values_for_ui_thread(em): - """The public read_* accessors return whatever the last _refresh stored — no I/O.""" + """The read_* accessors return what the last _refresh stored, with no I/O.""" with ( patch.object(EthernetManager, "_probe_carrier", return_value=True), - patch.object(EthernetManager, "_probe_service_active", return_value=True), - patch.object(EthernetManager, "_probe_ipv4", return_value="169.254.1.2/16"), + patch.object(EthernetManager, "_probe_ipv4", return_value="10.0.0.5/24"), + patch("subprocess.check_output", return_value=_pi_status( + service="active", netadapters="2", ports_wired="4", route="eth0", + x1="1", x5="2", x15="3", + )), patch(_HW, return_value={"rate": "48000", "period_size": "64"}), - patch.object(EthernetManager, "_probe_xrun_buckets", return_value=(1, 2, 3)), ): em._refresh() - assert em.read_ipv4() == "169.254.1.2/16" + assert em.read_ipv4() == "10.0.0.5/24" assert em.read_jack_settings() == (48000, 64) assert em.read_xrun_buckets() == (1, 2, 3) + assert em.read_netadapter_health() == (2, 4, "eth0") # ---------- _probe_ipv4 ---------- @@ -160,21 +187,20 @@ def test_probe_ipv4_returns_none_on_command_error(em: EthernetManager): "subformat: STD\n" "channels: 2\n" "rate: 48000 (48000/1)\n" - "period_size: 64\n" - "buffer_size: 128\n" + "period_size: 128\n" + "buffer_size: 512\n" ) def test_read_hw_params_takes_first_token_of_each_value(): with patch("builtins.open", mock_open(read_data=_HW_PARAMS)): params = read_hw_params() - assert params["rate"] == "48000" # not "48000 (48000/1)" - assert params["period_size"] == "64" + assert params["rate"] == "48000" + assert params["period_size"] == "128" assert params["channels"] == "2" def test_read_hw_params_empty_when_pcm_closed(): - # The file exists but reads "closed" when nothing holds the device. with patch("builtins.open", mock_open(read_data="closed\n")): assert read_hw_params() == {} @@ -182,44 +208,3 @@ def test_read_hw_params_empty_when_pcm_closed(): def test_read_hw_params_empty_off_device(): with patch("builtins.open", side_effect=FileNotFoundError()): assert read_hw_params() == {} - - -# ---------- _probe_xrun_buckets ---------- - - -def test_probe_xrun_buckets_zero_when_file_missing(): - with patch("builtins.open", side_effect=OSError): - assert EthernetManager._probe_xrun_buckets() == (0, 0, 0) - - -def test_probe_xrun_buckets_bins_by_age(): - # File format: " " per line, bucket end at ts+60. - # now=1000; buckets centered so their END (ts+60) gives dt = now-(ts+60): - # ts=910, count=2 -> dt=30 -> 1m,5m,15m - # ts=740, count=3 -> dt=200 -> 5m,15m - # ts=340, count=5 -> dt=600 -> 15m - # ts=-260, count=7 -> dt=1200 -> none - # garbage and a malformed 1-field line are skipped. - data = "910 2\n740 3\n340 5\n-260 7\ngarbage\n970\n" - with patch("builtins.open", mock_open(read_data=data)), patch("time.time", return_value=1000.0): - b1, b5, b15 = EthernetManager._probe_xrun_buckets() - assert (b1, b5, b15) == (2, 5, 10) - - -# ---------- start_service / stop_service ---------- - - -def test_start_service_spawns_systemctl_non_blocking(em: EthernetManager): - with patch("subprocess.Popen") as m: - em.start_service() - m.assert_called_once() - args, _ = m.call_args - assert args[0] == ["sudo", "systemctl", "start", "pi-stomp-jackbridge.service"] - - -def test_stop_service_spawns_systemctl_non_blocking(em: EthernetManager): - with patch("subprocess.Popen") as m: - em.stop_service() - m.assert_called_once() - args, _ = m.call_args - assert args[0] == ["sudo", "systemctl", "stop", "pi-stomp-jackbridge.service"] diff --git a/tests/v3/test_ethernet_menu.py b/tests/v3/test_ethernet_menu.py index 8dec301fa..f414ec33b 100644 --- a/tests/v3/test_ethernet_menu.py +++ b/tests/v3/test_ethernet_menu.py @@ -3,6 +3,9 @@ Replaces the live EthernetManager (sysfs polling thread) and JackMute (subprocess) on the handler with controllable fakes, then exercises the menu's render and action paths. + +The menu is *read-only* since the "Mac owns the lifecycle" change: there +is no Enable/Disable button anymore, only status rows + Mute MOD + Back. """ from typing import Optional @@ -15,18 +18,17 @@ class FakeEthernetManager: - """Mirrors the EthernetManager surface used by EthernetMenu, with no I/O.""" + """Mirrors the read-only EthernetManager surface used by EthernetMenu.""" def __init__(self, carrier_up=True, service_active=False, ipv4="169.254.125.193/16", jack=(48000, 128), - xruns=(0, 0, 0)): + xruns=(0, 0, 0), health=(1, 6, "eth0")): self.carrier_up = carrier_up self.service_active = service_active self._ipv4 = ipv4 self._jack = jack self._xruns = xruns - self.start_calls = 0 - self.stop_calls = 0 + self._health = health def read_ipv4(self) -> Optional[str]: return self._ipv4 @@ -37,13 +39,8 @@ def read_jack_settings(self): def read_xrun_buckets(self): return self._xruns - def start_service(self) -> None: - self.start_calls += 1 - self.service_active = True - - def stop_service(self) -> None: - self.stop_calls += 1 - self.service_active = False + def read_netadapter_health(self): + return self._health def shutdown(self) -> None: pass @@ -51,7 +48,7 @@ def shutdown(self) -> None: @pytest.fixture def ethernet_env(v3_system): - """Replace the live ethernet_manager and jack_mute with fakes; yield (lcd, fake_em, fake_mute).""" + """Replace ethernet_manager and jack_mute with fakes; yield (lcd, fake_em, fake_mute).""" handler = v3_system.handler handler.ethernet_manager.shutdown() fake_em = FakeEthernetManager() @@ -73,7 +70,7 @@ def _open(lcd) -> EthernetMenu: def test_ethernet_menu_disabled(ethernet_env, snapshot): - """Service inactive — only IP shown, toggle says Enable, MOD not muted.""" + """Service inactive — only IP shown, MOD not muted.""" lcd, em, _ = ethernet_env em.service_active = False _open(lcd) @@ -81,7 +78,7 @@ def test_ethernet_menu_disabled(ethernet_env, snapshot): def test_ethernet_menu_enabled_with_stats(ethernet_env, snapshot): - """Service active — sample rate, period, xrun buckets visible.""" + """Service active — sample rate, period, xrun buckets, link ports visible.""" lcd, em, _ = ethernet_env em.service_active = True em._xruns = (1, 3, 7) @@ -89,6 +86,15 @@ def test_ethernet_menu_enabled_with_stats(ethernet_env, snapshot): snapshot() +def test_ethernet_menu_duplicate_adapters_warning(ethernet_env, snapshot): + """netadapters > 1 surfaces the duplicate-slave warning row.""" + lcd, em, _ = ethernet_env + em.service_active = True + em._health = (2, 6, "eth0") # two netadapters contend for one stream + _open(lcd) + snapshot() + + def test_ethernet_menu_muted(ethernet_env, snapshot): """Service active + MOD muted → button reads "Unmute MOD".""" lcd, em, mute = ethernet_env @@ -99,7 +105,7 @@ def test_ethernet_menu_muted(ethernet_env, snapshot): def test_ethernet_menu_cable_disconnected(ethernet_env, snapshot): - """No carrier → dialog reports the cable is disconnected, no toggle row.""" + """No carrier → dialog reports the cable is disconnected.""" lcd, em, _ = ethernet_env em.carrier_up = False _open(lcd) @@ -111,22 +117,6 @@ def test_ethernet_menu_cable_disconnected(ethernet_env, snapshot): # --------------------------------------------------------------------------- -def test_enable_calls_start_service(ethernet_env): - lcd, em, _ = ethernet_env - em.service_active = False - menu = _open(lcd) - menu._on_toggle_service() - assert em.start_calls == 1 - - -def test_disable_calls_stop_service(ethernet_env): - lcd, em, _ = ethernet_env - em.service_active = True - menu = _open(lcd) - menu._on_toggle_service() - assert em.stop_calls == 1 - - def test_toggle_mute_when_unmuted_calls_mute(ethernet_env): lcd, em, mute = ethernet_env em.service_active = True @@ -145,14 +135,20 @@ def test_toggle_mute_when_muted_calls_unmute(ethernet_env): assert mute.is_muted() is False +def test_menu_has_no_enable_disable_button(ethernet_env): + """The Enable/Disable verb went with the Mac-owns-lifecycle change. + Nothing on this screen may let a musician toggle the service.""" + lcd, em, _ = ethernet_env + em.service_active = False + menu = _open(lcd) + assert menu._panel is not None + labels = [w.text for w in menu._panel.sel_list] + assert "Enable" not in labels + assert "Disable" not in labels + + # --------------------------------------------------------------------------- # In-place update regression tests -# -# The dialog used to be torn down and rebuilt on every 2-second tick and on -# every button press, which both (a) destroyed the widget under the user's -# finger and (b) forced a full-screen redraw. The current implementation -# mutates a small set of widgets in place via set_text(), which takes the -# per-widget dirty-rect path. These tests guard that contract. # --------------------------------------------------------------------------- @@ -164,8 +160,6 @@ def test_tick_does_not_rebuild_panel(ethernet_env): first_panel = menu._panel menu.tick() assert menu._panel is first_panel, "tick() must not pop+rebuild the dialog" - # The xrun widgets must still be the same instances — that's how we know - # set_text mutated in place rather than _render recreating them. assert len(menu._xrun_widgets) == 3 widget_ids = [id(w) for w in menu._xrun_widgets] menu.tick() @@ -194,20 +188,6 @@ def test_tick_noop_when_service_inactive(ethernet_env): assert menu._panel is first_panel # still untouched -def test_toggle_service_updates_button_label(ethernet_env): - lcd, em, _ = ethernet_env - em.service_active = False - menu = _open(lcd) - toggle = menu._toggle_btn - assert toggle is not None - assert toggle.text == "Enable" - menu._on_toggle_service() - assert menu._panel is toggle.parent # no rebuild - assert toggle.text == "Disable" # mutated in place - menu._on_toggle_service() - assert toggle.text == "Enable" - - def test_toggle_mute_updates_button_label(ethernet_env): lcd, em, mute = ethernet_env em.service_active = True @@ -258,15 +238,3 @@ def test_back_pops_panel(ethernet_env): menu._on_back() assert menu._panel is None assert lcd.pstack.current is not panel - - -def test_enable_then_state_flip_shows_disable(ethernet_env): - """After Enable fires, the toggle button optimistically reads 'Disable'.""" - lcd, em, _ = ethernet_env - em.service_active = False - menu = _open(lcd) - menu._on_toggle_service() # StubFake flips service_active to True synchronously - # Find the toggle widget by walking the panel's selectable list. - assert menu._panel is not None - labels = [w.text for w in menu._panel.sel_list] - assert "Disable" in labels diff --git a/ui/ethernet_menu.py b/ui/ethernet_menu.py index fbf96385c..b55ca98ef 100644 --- a/ui/ethernet_menu.py +++ b/ui/ethernet_menu.py @@ -51,30 +51,31 @@ class _EthernetHost(Protocol): class EthernetMenu: - """The Wired Connection sub-screen: status readout + enable/disable toggle. + """The Wired Connection sub-screen. Status readout only. - A single Dialog is pushed onto the panel stack; re-renders are done by - popping and rebuilding (mirroring WifiMenu.notify_status_change). State - comes from EthernetManager, which polls carrier + service-active on a - background thread; this class touches the panel stack only from the UI - thread (via handler poll-loop callbacks). + The Enable/Disable toggle is gone (see modalapi/ethernet/manager.py). + pi-stomp-jackbridge starts when the Mac asks for it and stays under + systemd supervision. A musician has nothing to toggle here. The repair + verbs live in the Mac menu. + + One Dialog sits on the panel stack. A re-render pops and rebuilds it + (like WifiMenu.notify_status_change). State comes from EthernetManager, + which polls on a background thread. This class touches the panel stack + only from the UI thread. """ def __init__(self, lcd: "Lcd") -> None: self.lcd: "Lcd" = lcd self._panel: Optional[Dialog] = None - # Remembered across pop/rebuild so periodic re-renders don't yank focus - # back to the toggle button after the user has moved selection. - # One of: 'back', 'toggle', 'mute', or None. + # Kept across a pop/rebuild so a re-render holds the user's selection. + # One of: 'back', 'mute', or None. self._last_selected_role: Optional[str] = None self._role_widgets: dict[str, object] = {} - # Refs to widgets that mutate in place — reset on every _render(). - # tick() updates the xrun rows via set_text(); the action handlers - # update their own button label the same way. None of these paths - # rebuild the dialog, so the buttons don't vanish under the user's - # finger and the SPI blit stays a precise clip. + # Widgets that mutate in place. Reset on every _render(). tick() + # updates the xrun rows with set_text(); the mute handler updates its + # own label the same way. Neither path rebuilds the dialog, so a + # button never moves under the user's finger. self._xrun_widgets: list[TextWidget] = [] - self._toggle_btn: Optional[TextWidget] = None self._mute_btn: Optional[TextWidget] = None def _capture_selected_role(self) -> None: @@ -117,10 +118,10 @@ def open(self, event: object = None, widget: object = None) -> None: self._render() def notify_change(self) -> None: - """Carrier or service-active flipped — re-render if we're on top. + """Carrier or service-active changed. Re-render if this panel is on top. - If the cable was pulled, pop the sub-screen and surface the - disconnected dialog so the user isn't left looking at a stale IP.""" + If the cable was pulled, pop the sub-screen and show the + disconnected dialog so no stale IP stays on screen.""" if self._panel is None or self._pstack.current is not self._panel: return if not self._manager.carrier_up: @@ -132,11 +133,11 @@ def notify_change(self) -> None: self._render() def tick(self) -> None: - """Update the xrun counters while we're on top, without rebuilding - the dialog. The rest of the rows and the buttons are static between - state flips; mutating them via set_text() takes the per-widget - dirty-rect path so the buttons stay put (no reblit under the user's - finger, no full-screen redraw on the SPI bus).""" + """Update the xrun counters in place while this panel is on top. + + The other rows and the buttons are static between state changes. + set_text() takes the per-widget dirty-rect path, so the buttons stay + put and the SPI bus sees no full redraw.""" if self._panel is None or self._pstack.current is not self._panel: return if not self._manager.carrier_up: @@ -159,13 +160,13 @@ def _render(self) -> None: self._panel = None self._pstack.pop_panel(old) - # Old widgets were just destroyed — drop our refs so tick() / actions - # don't try to mutate zombies if a render races a poll. + # The old widgets are gone. Drop the refs so a render that races a + # poll does not touch a dead widget. self._xrun_widgets = [] - self._toggle_btn = None self._mute_btn = None active = self._manager.service_active + n_adapters, n_wired, route = self._manager.read_netadapter_health() d = Dialog(width=DIALOG_W, height=DIALOG_H, title="Ethernet Audio Interface", auto_destroy=True) font = _make_font(_FONTS_DIR / "DejaVuSans.ttf", 14) @@ -179,9 +180,13 @@ def _render(self) -> None: rows.append(("xruns 1m:", str(b1))) rows.append(("xruns 5m:", str(b5))) rows.append(("xruns 15m:", str(b15))) + rows.append(("Link ports:", f"{n_wired}/6 wired")) + if n_adapters > 1: + rows.append(("Adapters:", f"⚠ {n_adapters} (duplicate)")) + if not route or route.startswith("wl"): + rows.append(("Route:", f"⚠ {route or 'none'}")) muted = self._mute.is_muted() - toggle_label = "Disable" if active else "Enable" mute_label = "Unmute MOD" if muted else "Mute MOD" line_h = 18 @@ -215,29 +220,8 @@ def _render(self) -> None: ) d.add_sel_widget(back_btn) - # Toggle sits to the right of back; constructed with the wider of the - # two labels so the box is the same size in both states. Without - # this, swapping "Enable"→"Disable" via set_text() would clip the - # trailing "e" inside the original (narrower) box. - assert back_btn.box - toggle_x = back_btn.box.x0 + back_btn.box.width + 6 - toggle_btn = TextWidget( - box=Box.xywh(toggle_x, btn_y, 0, 0), - text="Disable", - parent=d, - outline=1, - sel_width=3, - outline_radius=5, - action=self._on_toggle_service, - align=WidgetAlign.NONE, - name="ethernet_toggle_btn", - ) - toggle_btn.set_text(toggle_label) - d.add_sel_widget(toggle_btn) - self._toggle_btn = toggle_btn - - # Mute button: same trick — size to fit "Unmute MOD" so a set_text - # back to "Mute MOD" doesn't leave dead space at the right edge. + # Size the mute button for "Unmute MOD" so a set_text back to + # "Mute MOD" leaves no dead space at the right edge. mute_btn = TextWidget( box=Box.xywh(0, btn_y, 0, 0), text="Unmute MOD", @@ -257,13 +241,13 @@ def _render(self) -> None: d.add_sel_widget(mute_btn) self._mute_btn = mute_btn - # Stash refs by role so re-renders can preserve selection (panel pop - # blows away widget identity, so we track which role was selected). - self._role_widgets = {"back": back_btn, "toggle": toggle_btn, "mute": mute_btn} + # Track which role holds the selection. A panel pop destroys widget + # identity, so a re-render restores selection by role, not by ref. + self._role_widgets = {"back": back_btn, "mute": mute_btn} - # Restore selection from before the rebuild when possible, so periodic - # ticks and unrelated actions (Mute) don't drag focus back to Toggle. - restore_target = self._role_widgets.get(self._last_selected_role or "toggle", toggle_btn) + # Keep the selection across a rebuild so ticks and the Mute action + # do not move the focus. + restore_target = self._role_widgets.get(self._last_selected_role or "mute", mute_btn) d.sel_widget(restore_target) self._panel = d @@ -275,20 +259,8 @@ def _show_disconnected_dialog(self) -> None: # ----- actions ----- - def _on_toggle_service(self, _event: object = None, _widget: object = None) -> None: - # Optimistic update: show the *new* state immediately, before the - # background poll observes systemctl's effect. The bg poll's - # notify_change() will re-render the dialog (full rebuild is needed - # there anyway, because the row *set* changes when service_active - # flips) and reconcile any drift. - if self._manager.service_active: - self._manager.stop_service() - new_label = "Enable" - else: - self._manager.start_service() - new_label = "Disable" - if self._toggle_btn is not None: - self._toggle_btn.set_text(new_label) + # No _on_toggle_service: the Enable/Disable button is gone. The Mac menu + # owns start and stop; this screen only renders. def _on_toggle_mute(self, _event: object = None, _widget: object = None) -> None: if self._mute.is_muted(): From 0ce993f1e660ed65c0173f6bc30859b494411668 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 28 Aug 2026 21:41:36 -0400 Subject: [PATCH 4/4] Healin' --- emulator/stubs.py | 3 + modalapi/ethernet/manager.py | 19 +++-- .../test_ethernet_menu_link_resyncing/0.png | Bin 0 -> 13932 bytes tests/test_ethernet_manager.py | 67 ++++++++++++++++-- tests/v3/test_ethernet_menu.py | 27 ++++++- ui/ethernet_menu.py | 9 ++- 6 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_link_resyncing/0.png diff --git a/emulator/stubs.py b/emulator/stubs.py index 561fa7bf4..b9b9f5276 100644 --- a/emulator/stubs.py +++ b/emulator/stubs.py @@ -288,6 +288,9 @@ def read_xrun_buckets(self) -> tuple[int, int, int]: def read_netadapter_health(self) -> tuple[int, int, str]: return (1, 6, "eth0") + def read_link_health(self) -> tuple[bool, int]: + return (False, 0) + class StubJackMute(JackMute): """In-memory mute state; no JACK calls. Lets the menu's Mute/Unmute MOD diff --git a/modalapi/ethernet/manager.py b/modalapi/ethernet/manager.py index b41ca1df7..9b22eaed4 100644 --- a/modalapi/ethernet/manager.py +++ b/modalapi/ethernet/manager.py @@ -23,7 +23,7 @@ `jackbridge-pi-status` supplies the data (pistomp-companion installs it). The helper only reads: systemd state, the jack_lsp graph, the IP route, -and xrun counts. Like WifiManager, a background thread does all blocking +xrun counts, and netadapter link restarts. Like WifiManager, a background thread does all blocking I/O and caches the result. The UI thread reads the cache through the read_* methods. """ @@ -58,8 +58,8 @@ def _int(s: Optional[str]) -> int: class EthernetManager: """Poll the Ethernet carrier and the JackBridge status on a background thread. - Read-only. `_changed` is set when the carrier or the service-active - state changes, so the handler poll loop can refresh the UI. Field + Read-only. `_changed` is set when the carrier, the service-active state + or the link-resyncing state changes, so the handler poll loop can refresh the UI. Field changes (IP, sample rate, xruns, port counts) appear on the next render without `_changed`. """ @@ -84,6 +84,8 @@ def __init__(self) -> None: self._netadapters: int = 0 self._ports_wired: int = 0 self._route: str = "" + self._link_resyncing: bool = False + self._net_restarts: int = 0 self._ipv4: Optional[str] = None self._sample_rate: Optional[int] = None self._period: Optional[int] = None @@ -114,6 +116,8 @@ def _refresh(self) -> None: netadapters = _int(status.get("netadapters")) ports_wired = _int(status.get("ports_wired")) route = status.get("route", "") or "" + link_resyncing = status.get("link") == "resyncing" + net_restarts = _int(status.get("net_restarts")) xruns = ( _int(status.get("xruns_1m")), _int(status.get("xruns_5m")), @@ -131,13 +135,15 @@ def _refresh(self) -> None: sample_rate = period = None with self._lock: - if carrier != self.carrier_up or active != self.service_active: + if carrier != self.carrier_up or active != self.service_active or link_resyncing != self._link_resyncing: self._changed = True self.carrier_up = carrier self.service_active = active self._netadapters = netadapters self._ports_wired = ports_wired self._route = route + self._link_resyncing = link_resyncing + self._net_restarts = net_restarts self._ipv4 = ipv4 self._sample_rate = sample_rate self._period = period @@ -209,6 +215,11 @@ def read_xrun_buckets(self) -> tuple[int, int, int]: with self._lock: return self._xruns + def read_link_health(self) -> tuple[bool, int]: + """(resyncing, restarts). Cached; read from the UI thread.""" + with self._lock: + return self._link_resyncing, self._net_restarts + def read_netadapter_health(self) -> tuple[int, int, str]: """(netadapters, ports_wired, route). Cached; read from the UI thread. diff --git a/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_link_resyncing/0.png b/tests/snapshots/v3/test_ethernet_menu/test_ethernet_menu_link_resyncing/0.png new file mode 100644 index 0000000000000000000000000000000000000000..486fa49bb43d5f015468341255c1f3da9f32f720 GIT binary patch literal 13932 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 Optional[str]: return self._ipv4 @@ -42,6 +50,9 @@ def read_xrun_buckets(self): def read_netadapter_health(self): return self._health + def read_link_health(self): + return self._link + def shutdown(self) -> None: pass @@ -95,6 +106,16 @@ def test_ethernet_menu_duplicate_adapters_warning(ethernet_env, snapshot): snapshot() +def test_ethernet_menu_link_resyncing(ethernet_env, snapshot): + """netadapter is restarting its link — the port count is replaced by the + one row that reports it, plus the remedy.""" + lcd, em, _ = ethernet_env + em.service_active = True + em._link = (True, 4) + _open(lcd) + snapshot() + + def test_ethernet_menu_muted(ethernet_env, snapshot): """Service active + MOD muted → button reads "Unmute MOD".""" lcd, em, mute = ethernet_env diff --git a/ui/ethernet_menu.py b/ui/ethernet_menu.py index b55ca98ef..1b2c488b0 100644 --- a/ui/ethernet_menu.py +++ b/ui/ethernet_menu.py @@ -167,6 +167,7 @@ def _render(self) -> None: active = self._manager.service_active n_adapters, n_wired, route = self._manager.read_netadapter_health() + resyncing, restarts = self._manager.read_link_health() d = Dialog(width=DIALOG_W, height=DIALOG_H, title="Ethernet Audio Interface", auto_destroy=True) font = _make_font(_FONTS_DIR / "DejaVuSans.ttf", 14) @@ -180,7 +181,13 @@ def _render(self) -> None: rows.append(("xruns 1m:", str(b1))) rows.append(("xruns 5m:", str(b5))) rows.append(("xruns 15m:", str(b15))) - rows.append(("Link ports:", f"{n_wired}/6 wired")) + if resyncing: + # The ports stay wired through a netadapter restart, so the + # port count (unfortunately) continues to read healthy + rows.append(("Link:", f"⚠ resyncing (x{restarts})")) + rows.append(("", "Restart JackBridge on Host")) + else: + rows.append(("Link ports:", f"{n_wired}/6 wired")) if n_adapters > 1: rows.append(("Adapters:", f"⚠ {n_adapters} (duplicate)")) if not route or route.startswith("wl"):