diff --git a/helper/dashtouch_helper/daemon.py b/helper/dashtouch_helper/daemon.py index e665a9f..c705663 100644 --- a/helper/dashtouch_helper/daemon.py +++ b/helper/dashtouch_helper/daemon.py @@ -65,7 +65,10 @@ def __init__(self, serial_number: str, port: str | None = None): # Mirrors the firmware's transient PAUSE state (see # docs/protocol.md). Updated from PAUSE_OK replies and # STATUS_OK's paused= field; never persisted. - "paused": False} + "paused": False, + # Why the board can't be picked out, while that's the + # case (currently: several usbmodem ports at once). + "port_problem": None} # -- connection lifecycle ----------------------------------------------- def _on_connect(self) -> None: @@ -297,7 +300,9 @@ def health(self) -> list[dict]: "label": "Device", "ok": connected, "state": "ok" if connected else "bad", - "detail": "Connected" if connected else "Not connected", + "detail": "Connected" if connected + else "More than one board is plugged in" if self.state["port_problem"] + else "Not connected", "fix": "help-not-connected", }) @@ -396,7 +401,21 @@ def run_forever(self) -> None: print(f"web UI: {origin}") print(f"(the full link, with your session token, is in {webui.URL_PATH})") while True: - port = self.port or serial_link.find_port() + try: + port = self.port or serial_link.find_port() + except serial_link.AmbiguousPortError as e: + # Letting this escape crash-looped the helper under + # launchd's KeepAlive until someone unplugged a board. Wait + # it out like any other absence, and say why once rather + # than every second. + if self.state["port_problem"] != str(e): + msg = f"{e} — unplug the one that isn't Dashboard Touch" + print(msg, file=sys.stderr) + self.log_event("helper", msg) + self.state["port_problem"] = str(e) + port = None + else: + self.state["port_problem"] = None if port is None: self.state["connected"] = False time.sleep(1.0) diff --git a/helper/tests/test_daemon.py b/helper/tests/test_daemon.py index 53e18ce..25e354a 100644 --- a/helper/tests/test_daemon.py +++ b/helper/tests/test_daemon.py @@ -752,3 +752,46 @@ def test_health_picks_up_a_repaired_keychain_without_a_restart(): rows = {r["id"]: r for r in d.health()} assert rows["pairing"]["state"] == "ok" assert rows["password"]["state"] == "ok" + + +def test_run_forever_waits_out_several_boards_instead_of_crashing(monkeypatch, capsys): + # With two usbmodem ports, find_port() raised straight out of + # run_forever: under launchd's KeepAlive, a crash-loop until a board was + # unplugged. The helper must keep looking, and connect once it can. + d = make_daemon() + monkeypatch.setattr(daemon.webui, "start", lambda self: "http://127.0.0.1:3274/?token=T") + found = iter([daemon.serial_link.AmbiguousPortError("several boards found: [a, b]")] * 3 + + ["/dev/fake"]) + + def find_port(): + r = next(found) + if isinstance(r, Exception): + raise r + return r + + monkeypatch.setattr(daemon.serial_link, "find_port", find_port) + monkeypatch.setattr(daemon.time, "sleep", lambda s: None) + opened = [] + monkeypatch.setattr(daemon.serial_link, "open_port", + lambda port: opened.append(port) or mock.Mock()) + + def stop(ser): + raise RuntimeError("stop test loop") + + monkeypatch.setattr(daemon.serial_link, "read_line", stop) + with pytest.raises(RuntimeError, match="stop test loop"): + d.run_forever() + assert opened == ["/dev/fake"] + assert d.state["port_problem"] is None + # Said once, not once per retry. + said = [e for e in d.events if "several boards found" in e["text"]] + assert len(said) == 1 + assert capsys.readouterr().err.count("several boards found") == 1 + + +def test_health_says_when_several_boards_are_the_reason(): + d = make_daemon() + d.state["port_problem"] = "several boards found: [a, b]" + rows = {r["id"]: r for r in d.health()} + assert rows["device"]["state"] == "bad" + assert rows["device"]["detail"] == "More than one board is plugged in"