Skip to content

feat(agent): publish device truth on the status topic, not just agent state - #9

Merged
impuls42 merged 2 commits into
mainfrom
feat/device-status-feedback
Jul 30, 2026
Merged

feat(agent): publish device truth on the status topic, not just agent state#9
impuls42 merged 2 commits into
mainfrom
feat/device-status-feedback

Conversation

@impuls42

Copy link
Copy Markdown
Member

Pairs with sengine-cloud/inventree-label-dispatch#3, which consumes this.

The problem

The retained PrinterStatus carried idle / printing / disconnected — facts about the agent. Everything the D30 actually reports was decoded and then dropped. InvenTree's machine driver reads that topic, so the Machines page could say "connected" about a printer with no tape in it.

PrinterStatus now carries firmware, battery_pct, voltage_v and media_ok, and settles to state="error" with the reason in error. DeviceFeedback.fault() folds the three ways the printer complains — media bit clear (0x06), material error (0x3F), print cancelled (0x0B) — into one line, recomputed per batch so a cleared media error clears the status instead of latching.

Feedback is now captured on the failure path too. It was only read after a successful print, discarding the diagnosis in exactly the case where one existed — the fault is usually why the print failed.

Three response tags were being logged as unknown and skipped

All observed live on fw 2.1.2 this session:

Query Reply Decode
VOLTAGE 1f111f 1a 2f 01 a0 0x2F — 2 bytes BE, 10 mV units → 4.16 V
SENSOR_INFO 1f111d 1a 2d 02 … e6 … 0x2D — 13 bytes, layout unknown
HARDWARE_VERSION 1f1133 1a 11 01 00 03 0x11 — 3 bytes → 1.0.3

Voltage earns its place because BATTERY returned 100% for the entire time the unit was on charge, so it cannot answer "will this survive a long strip". Voltage tracked 4.16 → 4.17 V, against 4.09 V on the discharged unit's own info label.

It is queried through a separate TELEMETRY_QUERIES set rather than added to session_setup, because that sequence is pinned byte-for-byte to the vendor capture and a wire diff against the vendor app is worth keeping meaningful. The existing guard test enforces that; I reverted my first attempt when it tripped.

Three defects found while verifying against hardware

1. await_print_complete could never succeed. post_print_margin_s was 0.3 s, but 0x0F lands ~3.0 s after the last raster byte — roughly 7× what the head's line rate predicts (a 200-line label computes to 0.42 s). The budget expired before the printer answered, so the feature silently degraded to the duration guess it exists to replace. Now 3.5 s; costs nothing on the happy path since the wait returns as soon as the frame lands.

2. The shipped default cancelled every job. device.raster_width_px was declared in config and read by nothing, while the worker rendered at tape.width_mm — default 15 mm, i.e. 120 px against a 96-dot head. The printer refuses an over-wide raster rather than clipping it:

120px raster -> 1a 0b b8  (print_cancelled), nothing printed
 96px raster -> print_complete, label printed

Rendering is capped at the head now, and raster_width_px finally does something.

3. No density knob existed. The agent never passed one, so D30Config's medium default always won whatever an operator configured. Added, defaulting to light.

Verification

Everything above was measured over SPP against Q223P4C31420105, from the relocatable interpreter with no socket.AF_BLUETOOTH.

The tag table is corroborated externally: the printer's own info label reads SN: Q223P4C31420105 / MAC: AAFDFD6B9F5F / VER: 2.1.2.B — three values matching what the parser decoded, so it is right rather than merely self-consistent.

The feedback loop caught a real failure unprompted: the 120 px attempt came back print_cancelled and fault() reported "printer cancelled the print" — a case that was previously invisible.

Light density is scannable, not assumed: a QR printed at density = 1 was photographed by a webcam at an angle and decoded correctly as its payload by zxing-cpp — as-is, upscaled, and autocontrasted.

Also documented in HARDWARE-NOTES: three opcodes that exist and do nothing (PRINT_TEST_PAGE 1f1127, ALL_ERROR 1f1128, LABEL_WIDTH 1f1118 — the last closing the "ask the printer its own width" idea), and wedged-link recovery (hcitool dc times out; hciconfig hciN reset works).

234 tests pass, ruff clean. agent.toml.example switches to afbluetooth per #8.

… state

The retained PrinterStatus carried idle/printing/disconnected -- facts about the
agent, not the printer. Everything the D30 actually reports was decoded and then
dropped on the floor. InvenTree's machine driver reads that topic, so the Machines
page could say "connected" about a printer with no tape in it.

PrinterStatus now carries firmware, battery_pct, voltage_v and media_ok, and
settles to state="error" with the reason in `error` when the printer is
complaining. DeviceFeedback grew fault(), which folds the three ways it complains
-- media bit clear (0x06), material error (0x3F), print cancelled (0x0B) -- into
one line. It is recomputed per batch, so a cleared media error clears the status
instead of latching.

Feedback is now captured on the *failure* path too. It was only read after a
successful print, which threw away the diagnosis in exactly the case where one
was available: the fault is usually why the print failed.

Three response tags were being logged as unknown and skipped. All three were
observed live on fw 2.1.2 this session:

  0x2F  VOLTAGE          2 bytes big-endian, 10mV units -> 4.16V
  0x2D  SENSOR_INFO      13 bytes, layout still unknown
  0x11  HARDWARE_VERSION 3 bytes -> 1.0.3

Voltage earns its place because BATTERY reported 100% for the entire time the unit
was on charge, so it cannot answer "will this survive a long strip". Voltage
tracked 4.16 -> 4.17V against 4.09V on the discharged unit's own info label.
It is queried via a separate TELEMETRY_QUERIES set rather than being added to
session_setup, because that sequence is pinned byte-for-byte to the vendor capture
and a wire diff against the vendor app is worth keeping meaningful.

Three defects found while verifying against the hardware:

post_print_margin_s was 0.3s, but 0x0F lands ~3.0s after the last raster byte --
roughly 7x what the head's line rate predicts. The budget expired before the
printer ever answered, so await_print_complete could not succeed on short labels
and silently degraded to the duration guess it exists to replace. Now 3.5s, which
costs nothing on the happy path since the wait returns as soon as the frame lands.

device.raster_width_px was declared in the config and read by nothing, while the
worker rendered at tape.width_mm -- default 15mm, which is 120px against a 96-dot
head. The printer refuses an over-wide raster rather than clipping it: 120px came
back print_cancelled (1a0bb8) and printed nothing, the same label at 96px printed.
So the shipped default cancelled every job. Rendering is capped at the head now.

The agent had no density knob at all, so D30Config's medium default always won
whatever an operator wanted. Added, defaulting to light -- verified scannable by
photographing a light-density QR with a webcam and decoding it with zxing-cpp.

agent.toml.example switches to the afbluetooth transport: SPP is the throughput
choice per HARDWARE-NOTES, and the relocatable interpreter can address RFCOMM as
of #8.
The review asked whether a material fault survives a reconnect. It does not, and
nothing can make it: material_error (0x3F) and print_cancelled (0x0B) arrive only
unsolicited, and ALL_ERROR (1f1128) -- the opcode that would poll them -- was
verified inert on fw 2.1.2 this session. Only paper_state is in the vendor session
set and therefore re-asserted per connection. Recorded on fault() so a clean return
is not mistaken for proof the consumable is fine.
@impuls42
impuls42 merged commit 0f345d6 into main Jul 30, 2026
1 check passed
impuls42 added a commit that referenced this pull request Jul 30, 2026
RFCOMM addressing without libbluetooth (#8), and the printer's own state on the
status topic (#9) -- including three defects that made the shipped defaults print
nothing: an over-wide raster the printer refused, a completion wait that always
expired, and a density knob the agent could not reach.
impuls42 added a commit that referenced this pull request Aug 2, 2026
…e status topic (#13)

* fix(agent): stop reconnects and restarts erasing device truth from the status topic

The retained status carried nulls for serial, firmware, battery_pct, voltage_v and
media_ok almost all of the time, so InvenTree's Machines page read "CONNECTED — media
unreported" for a printer the agent had fully identified on its last job.

Two causes, and the second is the one that actually bites. _on_connect published a
*hardcoded* bare PrinterStatus, retained. That fires on every reconnect, not only the
first connect, and paho reconnects silently -- the broker's Istio route caps at a 24h
timeout and flaps besides -- so it ran several times a day. It was not merely
uninformative, it was lossy: it overwrote a retained message that had been correct a
moment earlier, and the printer is asleep between jobs and cannot be asked again.
Evidence was an agent 25h up with NRestarts=0 and every device field null.

The rarer cause is the one the issue was filed about: a fresh process knew nothing at
all, because the device fields lived in six attributes on PrintWorker that were only
ever filled inside a send.

Both are the same missing idea -- nowhere to keep what the printer said. DeviceSnapshot
is that place, persisted to the spool DB after every capture and read back at startup.
The MQTT source now remembers the last retained status it published, seeded from the
spool, and republishes *that* on connect. The will and the shutdown notice carry it too:
losing the link is news about reachability, not grounds for forgetting the serial.

Remembered truth published as though it were live is its own kind of lie, so
PrinterStatus gains device_seen_at. An absolute UTC instant rather than an age, because
the message is retained -- an age is computed once and then sits on the broker getting
wronger. It only advances when the printer actually reported something, so a connection
that answered nothing cannot launder three-day-old media state into looking current.

device.probe_on_start (on by default) surveys the printer once at startup, so an agent
that comes up next to an awake printer publishes live truth immediately instead of
waiting for the next job. It cannot wake a sleeping unit -- AUTO_POWER_TIME really does
power the radio down and only the button brings it back -- so a miss costs one connect
timeout and the stored snapshot is published unchanged. It runs on the print loop and
only at startup, never from on_connect: that callback is paho's network thread, and
probing from it would touch the printer concurrently with a print, which is exactly what
the single-threaded loop exists to prevent.

Collecting the snapshot also removed the duplicated PrinterStatus construction between
worker.py and source_mqtt.py, which is why the two could disagree about what the topic
should say in the first place.

tests/test_source_mqtt.py is new -- that module had no coverage, which is how a
hardcoded blank status survived in the one callback that runs most often.

The README still claimed "there is no read channel" and that low battery was
undetectable. That was falsified by #9; corrected here.

Closes #12

* fix(agent): re-arm the will on connect so it stops describing startup forever

Review flagged that the will goes stale after start(): paho bakes it into the CONNECT
packet, so the payload the broker holds is fixed for the life of a connection and an
ungraceful drop weeks in would have published boot-time device truth.

Half of that is unavoidable and stays true -- nothing can change what fires if *this*
connection dies. The other half is not: paho rebuilds CONNECT from the same _will_*
fields on every automatic reconnect (verified in 2.1.0 -- will_set has no connected-state
guard, and _send_connect reads _will_payload at packet-build time). Re-arming from
_on_connect therefore bounds the staleness by the reconnect interval, a few hours here,
instead of by the process lifetime.

Armed from paho's network thread, which is also the thread that builds the packet, so
the topic and payload can never come from different arming passes.

* test(agent): pin that a restart corrects the stale will it followed

Review on the consumer PR pointed out that a fault learned mid-session cannot reach the
will -- the broker holds it from CONNECT -- so an agent killed after a media fault
publishes the older, healthier reading over the newer one.

Nothing prevents that, and neither suggested remedy helps: on_disconnect fires with no
connection to publish on, and a SIGKILL runs no callback at all. What bounds it is
recovery. The unit is Restart=always with RestartSec=5, and a restarted agent seeds
_last_status from the spool -- which does hold the fault -- and republishes on connect,
so the stale row is seconds wide rather than indefinite.

That property was load-bearing in the argument and untested, which is a bad combination.
Now asserted end to end: arm a healthy will, learn a fault mid-session, restart on the
same spool, and check the republished status carries the fault and a device_seen_at
strictly newer than the will's.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant