diff --git a/marlin_host/host.py b/marlin_host/host.py index c388864..bfd8d29 100644 --- a/marlin_host/host.py +++ b/marlin_host/host.py @@ -15,7 +15,7 @@ from __future__ import annotations import time -from collections.abc import Iterable, Iterator, Mapping +from collections.abc import Callable, Iterable, Iterator, Mapping from dataclasses import dataclass from typing import TYPE_CHECKING @@ -124,6 +124,7 @@ def __init__( reliable: bool = False, max_resends: int = DEFAULT_MAX_RESENDS, connect_probes: int = DEFAULT_CONNECT_PROBES, + on_action: Callable[[MarlinResponse], None] | None = None, ) -> None: self._t = transport self._idle_timeout = idle_timeout @@ -131,6 +132,7 @@ def __init__( self._reliable = reliable self._max_resends = max_resends self._connect_probes = connect_probes + self._on_action = on_action self._line_number = 0 self._halted = False self._connected = False @@ -428,6 +430,12 @@ def _await_terminal( continue # a `Resend:` follows this recoverable line error raise ProtocolError(resp.message or resp.raw) - # echo / reports / start / wait / unknown — not terminal; collect if asked. + # Board-initiated host action (//action:pause/resume/cancel/prompt, e.g. + # M600/runout/LCD). Non-terminal; deliver to the consumer (it would + # otherwise be silently dropped) before falling through to collect. + if resp.kind is MarlinResponseKind.ACTION and self._on_action is not None: + self._on_action(resp) + + # echo / reports / start / wait / unknown / action — not terminal; collect if asked. if collect is not None: collect.append(resp) diff --git a/tests/test_host.py b/tests/test_host.py index dd6bb26..cd870d7 100644 --- a/tests/test_host.py +++ b/tests/test_host.py @@ -406,3 +406,31 @@ def test_temperatures_reads_fields_from_the_ok_line() -> None: def test_temperatures_empty_when_board_reports_none() -> None: host = MarlinHost(FakeTransport(responder=lambda _line: ["ok"])) assert host.temperatures() == {} + + +def test_on_action_delivers_board_initiated_host_actions() -> None: + # //action:pause (e.g. M600/runout/LCD) arrives mid-command; without a + # callback it is silently dropped, so the consumer must be able to observe it. + seen: list[str] = [] + host = MarlinHost( + FakeTransport(responder=lambda _line: ["//action:pause", "ok"]), + on_action=lambda r: seen.append(r.action or r.raw), + ) + assert host.send("G1 X10").is_ack + assert seen == ["pause"] + + +def test_actions_without_a_callback_are_dropped_not_fatal() -> None: + host = MarlinHost(FakeTransport(responder=lambda _line: ["//action:cancel", "ok"])) + assert host.send("G1 X10").is_ack # no callback registered -> no error + + +def test_on_action_fires_during_streaming() -> None: + seen: list[str] = [] + replies = iter([["ok"], ["//action:paused", "ok"], ["ok"]]) + host = MarlinHost( + FakeTransport(responder=lambda _line: next(replies)), + on_action=lambda r: seen.append(r.action or r.raw), + ) + list(host.stream(["G1 X1", "G1 X2", "G1 X3"])) + assert seen == ["paused"]