feat(agent): publish device truth on the status topic, not just agent state - #9
Merged
Conversation
… 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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pairs with sengine-cloud/inventree-label-dispatch#3, which consumes this.
The problem
The retained
PrinterStatuscarriedidle/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.PrinterStatusnow carriesfirmware,battery_pct,voltage_vandmedia_ok, and settles tostate="error"with the reason inerror.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:
VOLTAGE1f111f1a 2f 01 a00x2F— 2 bytes BE, 10 mV units → 4.16 VSENSOR_INFO1f111d1a 2d 02 … e6 …0x2D— 13 bytes, layout unknownHARDWARE_VERSION1f11331a 11 01 00 030x11— 3 bytes → 1.0.3Voltage earns its place because
BATTERYreturned 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_QUERIESset rather than added tosession_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_completecould never succeed.post_print_margin_swas0.3 s, but0x0Flands ~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. Now3.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_pxwas declared in config and read by nothing, while the worker rendered attape.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:Rendering is capped at the head now, and
raster_width_pxfinally 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 nosocket.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_cancelledandfault()reported "printer cancelled the print" — a case that was previously invisible.Light density is scannable, not assumed: a QR printed at
density = 1was photographed by a webcam at an angle and decoded correctly as its payload byzxing-cpp— as-is, upscaled, and autocontrasted.Also documented in HARDWARE-NOTES: three opcodes that exist and do nothing (
PRINT_TEST_PAGE1f1127,ALL_ERROR1f1128,LABEL_WIDTH1f1118— the last closing the "ask the printer its own width" idea), and wedged-link recovery (hcitool dctimes out;hciconfig hciN resetworks).234 tests pass, ruff clean.
agent.toml.exampleswitches toafbluetoothper #8.