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
12 changes: 10 additions & 2 deletions marlin_host/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -124,13 +124,15 @@ 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
self._startup_timeout = startup_timeout
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
Expand Down Expand Up @@ -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)
28 changes: 28 additions & 0 deletions tests/test_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading