From 9c263808fcf8c835eba7ef6dccbc31e838d3e2ce Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Sun, 2 Aug 2026 21:44:12 +0200 Subject: [PATCH 1/4] feat(agent): refresh device truth on demand and on an interval 0.3.0 gave the status topic a memory. Two ways to refresh what it remembers, both reusing probe_device rather than inventing a second path to the printer. `probe` (or `status`) on the cmd topic surveys the printer immediately. That topic already exists and already carries `flush`, the agent already reads it, and the broker ACL already lets InvenTree write it -- so a refresh button in a consumer UI needs no new topic, no ACL change and no new credential. Like flush, it only enqueues a sentinel: the cmd handler runs on paho's network thread and nothing there may touch the printer. device.probe_interval_s re-surveys once the reading goes stale, off by default. Two clocks here and conflating them is the trap. Staleness is measured on seen_at, what the printer last *said*, so a printer that is being used is never probed -- printing keeps it fresh already. The rate limit is measured on the last *attempt*, because a failed probe deliberately leaves seen_at alone; without a separate attempt clock the stale condition would still hold a tick later and a sleeping printer would be dialled once a second, each attempt blocking the print loop for the connect timeout. A missed probe stays silent rather than publishing `disconnected`. That is reserved for a printer that could not take a job, which is news a producer needs. A background probe finding the D30 asleep is the normal state of a printer nobody is using, and flipping the page between connected and disconnected as it naps would make the distinction worthless. The reading ages instead, which is what device_seen_at is for. --- README.md | 14 ++++++-- deploy/agent.toml.example | 6 ++++ src/labelfab/agent/__main__.py | 6 ++-- src/labelfab/agent/config.py | 7 ++++ src/labelfab/agent/source_mqtt.py | 11 ++++++ src/labelfab/agent/worker.py | 34 ++++++++++++++++++ tests/test_source_mqtt.py | 26 ++++++++++++++ tests/test_worker.py | 60 +++++++++++++++++++++++++++++++ 8 files changed, 160 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4accd47..71c50f9 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,18 @@ It says so *and when it said so*. The printer is only reachable while it is bein to (it auto-powers-off, and a held-open socket just relocates that), so the agent remembers the last thing it heard and republishes it, stamped with `device_seen_at`. A consumer can then tell live truth from remembered truth instead of guessing, and no -printer is woken to keep a status page tidy. `device.probe_on_start` surveys the printer -once at startup when it happens to be awake. +printer is woken to keep a status page tidy. + +Three ways the reading gets refreshed, in increasing order of how much you have to ask +for them. `device.probe_on_start` surveys the printer at startup if it happens to be +awake. `device.probe_interval_s` re-surveys it once the reading goes stale — measured on +what the printer last *said*, so a printer that is being used is never probed at all. +And publishing `probe` to the `cmd` topic surveys it right now, which is what a refresh +button in a consumer UI should send. + +None of the three can wake a sleeping unit, so a miss is not a failure: the reading +simply keeps its old timestamp and goes on ageing honestly. Only a printer that could +not take a *job* is reported `disconnected`. Jams remain undetectable — the media bit distinguishes loaded from not, and nothing observed so far distinguishes a jam from either. diff --git a/deploy/agent.toml.example b/deploy/agent.toml.example index 0acedb4..a97b2f1 100644 --- a/deploy/agent.toml.example +++ b/deploy/agent.toml.example @@ -46,6 +46,12 @@ wake_dummy_feed = false # what was remembered from the last print. Cannot wake a sleeping unit, so a miss costs # one connect timeout and the stored status is published unchanged. probe_on_start = true +# Re-survey it when what we know is older than this, in seconds; 0 = off. Printing +# already refreshes the reading, so this only fires on a printer nobody is using. Leave +# it off on a battery unit that sleeps -- every miss costs a connect timeout. 900 is a +# reasonable starting point for a mains-powered one. A refresh from InvenTree's +# "Restart Machine" action arrives on the cmd topic and probes immediately, regardless. +probe_interval_s = 0 [tape] width_mm = 15.0 diff --git a/src/labelfab/agent/__main__.py b/src/labelfab/agent/__main__.py index be69b70..74b439c 100644 --- a/src/labelfab/agent/__main__.py +++ b/src/labelfab/agent/__main__.py @@ -88,7 +88,7 @@ def stop(self, *_: object) -> None: self.queue.put("\x00stop") # break the loop out of its blocking get def run(self) -> int: - from labelfab.agent.source_mqtt import FLUSH_COMMAND + from labelfab.agent.source_mqtt import FLUSH_COMMAND, PROBE_COMMAND signal.signal(signal.SIGINT, self.stop) signal.signal(signal.SIGTERM, self.stop) @@ -114,9 +114,11 @@ def run(self) -> int: if self.dir_source: self.dir_source.poll() continue - if item in ("\x00stop", FLUSH_COMMAND): + if item in ("\x00stop", FLUSH_COMMAND, PROBE_COMMAND): if item == FLUSH_COMMAND: self.worker.flush() + elif item == PROBE_COMMAND: + self.worker.probe_device() continue try: self.worker.submit(item) diff --git a/src/labelfab/agent/config.py b/src/labelfab/agent/config.py index 6903999..faf22fe 100644 --- a/src/labelfab/agent/config.py +++ b/src/labelfab/agent/config.py @@ -102,6 +102,13 @@ class DeviceSection(BaseModel): #: stored snapshot is published unchanged. On by default; turn it off if that #: timeout is in the way of a fast start. Never runs on a broker reconnect. probe_on_start: bool = True + #: Re-survey the printer when what we know is older than this, in seconds. ``0`` + #: disables it. Printing already refreshes the reading, so this only fires on a + #: printer nobody is using -- which is exactly when a status page would otherwise + #: age quietly. Off by default: on a unit that sleeps, every miss costs a connect + #: timeout on the print loop, and that is a bad trade for a page nobody is reading. + #: 900 (15 min) is a sensible starting point for a mains-powered printer. + probe_interval_s: float = Field(default=0.0, ge=0) @field_validator("density") @classmethod diff --git a/src/labelfab/agent/source_mqtt.py b/src/labelfab/agent/source_mqtt.py index 3c6f40b..42aef67 100644 --- a/src/labelfab/agent/source_mqtt.py +++ b/src/labelfab/agent/source_mqtt.py @@ -28,6 +28,11 @@ #: Sentinel the print loop recognises as "flush the pending strip now". FLUSH_COMMAND = "\x00flush" +#: Sentinel for "go ask the printer what it is, now". Both sentinels exist because the +#: cmd topic is handled on paho's network thread and nothing there may touch the +#: printer -- the command only ever becomes a queue item for the print loop to run. +PROBE_COMMAND = "\x00probe" + class MqttSource: """Subscribes ``jobs``/``cmd``, publishes ``results``/``progress``/``status``.""" @@ -182,3 +187,9 @@ def _handle_cmd(self, msg) -> None: body = msg.payload.decode("utf-8", "replace").strip().lower() if "flush" in body: self.enqueue(FLUSH_COMMAND) + # "probe" is what a consumer sends when a human pressed refresh: go and ask the + # printer rather than replaying what we remember. Checked before "status" so + # either word works -- this arrives from a UI, not from a machine, and being + # fussy about which synonym someone typed buys nothing. + elif "probe" in body or "status" in body: + self.enqueue(PROBE_COMMAND) diff --git a/src/labelfab/agent/worker.py b/src/labelfab/agent/worker.py index 89895cc..7df5841 100644 --- a/src/labelfab/agent/worker.py +++ b/src/labelfab/agent/worker.py @@ -102,6 +102,9 @@ def __init__( #: starts out knowing what the previous process learned instead of publishing a #: row of nulls until something happens to print. self._device = spool.device_snapshot() + #: When a probe was last *attempted*, successful or not. Distinct from the + #: snapshot's seen_at, which only moves when the printer actually answered. + self._last_probe_attempt = 0.0 self.coalescer = Coalescer( max_wait_s=config.strip.max_wait_s, max_length_mm=config.strip.max_length_mm, @@ -215,6 +218,36 @@ def tick(self) -> None: if self.coalescer.idle_expired(): self._flush() self.retry_queued() + self.probe_if_stale() + + def probe_if_stale(self) -> None: + """Re-survey the printer when what we know about it has gone stale. + + Two separate clocks, and conflating them is the trap. Staleness is measured on + ``seen_at`` -- what the printer last *told* us -- so a busy printer is never + probed at all, because printing keeps that fresh on its own. The rate limit is + measured on the last *attempt*, because a failed probe leaves ``seen_at`` + untouched by design: without this the stale condition would still hold on the + next tick and a sleeping printer would be dialled once a second, each attempt + blocking the print loop for the connect timeout. + + A miss is deliberately silent -- it does not publish ``disconnected``. That is + reserved for a printer that could not take a *job*, which is news a producer + needs. A background probe finding the D30 asleep is not news; it is the normal + state of a printer nobody is using, and flipping the status page between + connected and disconnected as it naps would make the distinction worthless. The + status simply ages instead, which is what ``device_seen_at`` exists to show. + """ + interval = self.config.device.probe_interval_s + if not interval: + return + now = self.clock() + if now - self._last_probe_attempt < interval: + return + seen = self._device.seen_at + if seen is not None and now - seen < interval: + return + self.probe_device() def retry_queued(self) -> None: """Re-submit jobs stalled on an offline printer, once the retry gap passes. @@ -269,6 +302,7 @@ def probe_device(self) -> bool: One attempt, no backoff. The retry ladder in ``_send`` exists to get a *job* onto tape; there is no job here and nothing is lost by giving up immediately. """ + self._last_probe_attempt = self.clock() printer = self.printer_factory() try: printer.connect() diff --git a/tests/test_source_mqtt.py b/tests/test_source_mqtt.py index 39ffede..8df82da 100644 --- a/tests/test_source_mqtt.py +++ b/tests/test_source_mqtt.py @@ -252,6 +252,32 @@ def test_a_non_retained_status_does_not_become_what_the_topic_holds(source): assert src.client.statuses[-1]["serial"] == SERIAL +@pytest.mark.parametrize("body", [b"probe", b"refresh status", b"STATUS", b" Probe "]) +def test_a_refresh_command_reaches_the_print_loop(tmp_path, monkeypatch, body): + """What InvenTree's machine action sends. It must only ever enqueue: this runs on + paho's network thread, and touching the printer from there would race the print + loop that exists precisely so nothing needs a lock.""" + import paho.mqtt.client as mqtt + + from labelfab.agent.source_mqtt import PROBE_COMMAND + + monkeypatch.setattr(mqtt, "Client", FakeClient) + enqueued: list[str] = [] + config = make_config() + config.mqtt.host = "broker.invalid" + src = MqttSource(config, Spool(tmp_path / "spool.db"), enqueued.append) + + class _Msg: + topic = "se/v1/print/d30-workshop/cmd" + payload = body + qos = 1 + mid = 3 + + src._on_message(src.client, None, _Msg()) + assert enqueued == [PROBE_COMMAND] + assert src.client.acked == [3] + + def test_a_flush_command_reaches_the_print_loop(tmp_path, monkeypatch): """The cmd topic is handled on paho's thread, so it must only ever enqueue.""" import paho.mqtt.client as mqtt diff --git a/tests/test_worker.py b/tests/test_worker.py index 8765035..84cd336 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -303,6 +303,66 @@ def test_a_sleeping_printer_leaves_the_stored_status_alone(tmp_path, clock): assert restarted.publisher.statuses == [] # nothing learned, nothing to say +def test_the_interval_probe_only_fires_once_the_reading_is_stale(harness, clock): + """Printing already refreshes the reading, so a busy printer must never be probed.""" + h = harness() + h.config.device.probe_interval_s = 100.0 + _reporting_factory(h) + h.submit(make_job("j", n_labels=1, flush=True)) + probes = len(h.transports) + + clock.advance(50) + h.worker.tick() + assert len(h.transports) == probes # still fresh, nothing to ask about + + clock.advance(60) # now 110s since the printer last said anything + h.worker.tick() + assert len(h.transports) == probes + 1 + assert h.publisher.statuses[-1].device_seen_at.timestamp() == clock.now + + +def test_a_sleeping_printer_is_not_dialled_every_tick(harness, clock): + """A failed probe leaves seen_at alone by design, so the stale condition still + holds a second later. Without a separate attempt clock this would connect once per + tick, each attempt blocking the print loop for the connect timeout.""" + h = harness() + h.config.device.probe_interval_s = 100.0 + h.offline = True + + clock.advance(200) + h.worker.tick() + assert len(h.transports) == 1 # one attempt + + for _ in range(20): # twenty ticks inside the interval + clock.advance(1) + h.worker.tick() + assert len(h.transports) == 1 # still one + + clock.advance(100) + h.worker.tick() + assert len(h.transports) == 2 # the interval has passed, try again + + +def test_a_missed_probe_does_not_announce_the_printer_disconnected(harness, clock): + """That is reserved for a printer that could not take a job. A background probe + finding the D30 asleep is the normal state of an idle printer, not news.""" + h = harness() + h.config.device.probe_interval_s = 10.0 + h.offline = True + + clock.advance(100) + h.worker.tick() + assert h.publisher.statuses == [] + + +def test_the_interval_probe_is_off_by_default(harness, clock): + h = harness() + h.offline = True + clock.advance(10_000) + h.worker.tick() + assert h.transports == [] + + def test_a_fault_is_captured_even_when_the_print_fails(harness): """The fault is usually *why* it failed, so closing without reading it loses it.""" h = harness() From 69096428f50d0e384226ace076d25fc6110dffc4 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Sun, 2 Aug 2026 21:51:53 +0200 Subject: [PATCH 2/4] docs(config): note that probe_interval_s is not what 'Enable Machine Ping' controls An operator who turns off InvenTree's Machine Ping expecting the printer to stop being contacted would be half right: that setting stops InvenTree re-reading the retained topic, a cheap read that never touches Bluetooth, and cannot reach this timer, which is the one that actually dials the printer. Says so where the knob is, since neither side is guessable from the other. Also records the ping's 5-minute cadence as the sensible floor for this value. --- src/labelfab/agent/config.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/labelfab/agent/config.py b/src/labelfab/agent/config.py index faf22fe..1d37230 100644 --- a/src/labelfab/agent/config.py +++ b/src/labelfab/agent/config.py @@ -108,6 +108,11 @@ class DeviceSection(BaseModel): #: age quietly. Off by default: on a unit that sleeps, every miss costs a connect #: timeout on the print loop, and that is a bad trade for a page nobody is reading. #: 900 (15 min) is a sensible starting point for a mains-powered printer. + #: + #: This is the agent's own timer and nothing on a consumer can reach it. InvenTree's + #: "Enable Machine Ping" governs how often *it* re-reads the retained topic -- a + #: cheap read that never touches Bluetooth -- so switching that off does not stop + #: this, and there is little point setting this below its 5-minute cadence. probe_interval_s: float = Field(default=0.0, ge=0) @field_validator("density") From 0cba3b9e41bb30d786c28d3aaa2882ec2cee63a4 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Sun, 2 Aug 2026 21:57:15 +0200 Subject: [PATCH 3/4] docs(hardware): measure the D30's discoverability, and why presence is not a shortcut Partly answers the open question on wake behaviour: it answers BR/EDR inquiry continuously while powered, not only for a window after wake, and disappears within about a minute of being switched off. Measured on fw 2.1.2 with auto_power=0. Three things worth writing down because each would otherwise be discovered the hard way. l2ping gets no response at all -- including while the unit was demonstrably up and RFCOMM connected 3.3s later -- so the usual liveness trick does not exist here. Presence must be judged from an RSSI update inside a scan window, not from the cached devices list. That list happens to self-clear today only because the printer is unpaired and BlueZ expires temporary devices; pairing it would make the entry permanent and a naive check would then report the printer online forever. And presence detection is not a cheap substitute for connecting, which was the reason to want it: a 3-8s inquiry window against a 5.2s failed connect is no saving, and the connect returns real device state when it succeeds while a scan returns a boolean. --- HARDWARE-NOTES.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/HARDWARE-NOTES.md b/HARDWARE-NOTES.md index cb59fc3..6cf58fe 100644 --- a/HARDWARE-NOTES.md +++ b/HARDWARE-NOTES.md @@ -409,6 +409,23 @@ be awake and not held by another BlueZ connection. > whether the answer is a retry loop, a wake sequence, or something else. Do not > paper over it with blind retries until it is understood. +**Discoverability, measured 2026-08-02** (fw 2.1.2, `auto_power=0`, unit on mains). +Answers part of the question above — how long it advertises — though not the pairing half: + +| | | +|---|---| +| Powered on | Answers **BR/EDR inquiry continuously**, not just for a window after wake. `Class: 0x00100680`, `Icon: printer`, name `D30`, RSSI −26 at bench distance | +| Powered off | Gone from inquiry. A 6 s window returns zero hits, reproducibly | +| `l2ping` | **No response, ever** — including while the unit was demonstrably up and RFCOMM connected 3.3 s later. Its stack does not implement L2CAP echo, so the usual liveness trick is unavailable | +| Pairing | `Paired: no`. RFCOMM connects without bonding, so BlueZ holds it as a *temporary* device and expires it from `bluetoothctl devices` within about a minute of it going away | + +Two consequences for anyone building on this. Presence must be judged from an **RSSI +update inside an active scan window** — the cached `devices` list is only self-clearing +because the unit is unpaired, and pairing it would make that entry permanent and the +check useless. And presence detection is not a cheap substitute for connecting: a 3–8 s +inquiry window against a **5.2 s failed connect** is no saving, and the connect returns +real device state when it succeeds while the scan returns only a boolean. + ### 1. Can we open a socket unprivileged? ```bash From 8711ecb9373ab4824c70c44825297c6526a5d7ce Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Sun, 2 Aug 2026 22:39:05 +0200 Subject: [PATCH 4/4] docs(worker): probe_device has three callers now, not one The docstring said 'called from the run loop at startup, and only there', which this same branch made false by adding the idle tick and the probe command. Reworded around the constraint that actually matters and still holds: every caller is on the print loop, and nothing may call it from paho's network thread. That is also why the probe command arrives as a queue sentinel rather than being serviced in the cmd handler -- the sentinel exists to move the work onto the loop. --- src/labelfab/agent/worker.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/labelfab/agent/worker.py b/src/labelfab/agent/worker.py index 7df5841..fea50ea 100644 --- a/src/labelfab/agent/worker.py +++ b/src/labelfab/agent/worker.py @@ -293,11 +293,14 @@ def probe_device(self) -> bool: inside the transport's connect timeout and leaves the remembered snapshot exactly as it was. - Deliberately called from the run loop at startup, and only there. Not from the - MQTT ``on_connect`` callback: that is paho's network thread, so probing from it - would touch the printer concurrently with a print -- the one thing the - single-threaded loop exists to make impossible -- and it fires on every broker - reconnect, which happens several times a day and says nothing about the printer. + Every caller is on the print loop, and that is the constraint rather than any + particular one of them: startup, the idle tick via ``probe_if_stale``, and the + ``probe`` command, which reaches here as a queue sentinel precisely so that it + does. Nothing may call this from paho's network thread -- an MQTT callback + probing the printer would touch it concurrently with a print, which is the one + thing the single-threaded loop exists to make impossible. That rules out + ``on_connect`` in particular, which would also fire on every broker reconnect, + several times a day, over a link that says nothing about the printer. One attempt, no backoff. The retry ladder in ``_send`` exists to get a *job* onto tape; there is no job here and nothing is lost by giving up immediately.