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 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..1d37230 100644 --- a/src/labelfab/agent/config.py +++ b/src/labelfab/agent/config.py @@ -102,6 +102,18 @@ 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. + #: + #: 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") @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..fea50ea 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. @@ -260,15 +293,19 @@ 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. """ + 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()