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
17 changes: 17 additions & 0 deletions HARDWARE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions deploy/agent.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/labelfab/agent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions src/labelfab/agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/labelfab/agent/source_mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``."""
Expand Down Expand Up @@ -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)
47 changes: 42 additions & 5 deletions src/labelfab/agent/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
26 changes: 26 additions & 0 deletions tests/test_source_mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions tests/test_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading