From 2bc8112fdf8f66c91288725c32119cf799dfc70e Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Thu, 30 Jul 2026 01:41:53 +0200 Subject: [PATCH 1/3] feat(status): report the printer's own state, and the real print outcome The Machines page could say "connected" about a printer with no tape in it. Status collapsed to three cases -- unknown, disconnected, or connected -- so every fault the agent published looked healthy, and print_labels set PRINTING and never learned whether the job succeeded. Status mapping now uses the codes InvenTree already has. A media fault becomes NO_MEDIA (301) rather than a generic ERROR (500), because the two render differently and mean different things to whoever is looking: "go load tape" versus "go investigate". The status text carries firmware, battery *and* voltage, and the media state. Battery and voltage are both shown deliberately: the percentage reads 100% for as long as the unit is on charge, so on its own it cannot answer whether the printer will survive a long strip. Unreported media is not shown as OK. media_ok is a tri-state and a printer that has said nothing about its media reads as "media unreported" -- treating silence as health is exactly how the old mapping produced a confident, wrong answer. AWAIT_RESULT_S (default 15s, 0 disables) waits for the job's JobResult so the UI reports the actual outcome. It subscribes before publishing, because the results topic is not retained and subscribing afterwards races the agent and loses fast prints entirely; and it filters on job_id, because one printer serves every InvenTree user. A timeout leaves the status PRINTING rather than inventing an outcome -- strip mode legitimately holds labels until the coalescer flushes, which is longer than any wait worth blocking a Django-Q worker on. partial_tape_consumed is surfaced in the text, since a blind reprint after one double-prints. The mapping lives in status.py with no InvenTree imports, so it is unit-tested here; the driver stays the thin adapter it claims to be. The tests also pin every returned name against the real LabelPrinterStatus members, because a typo would otherwise surface as an AttributeError inside a Django worker. --- README.md | 33 ++++++ inventree_label_dispatch/dispatch.py | 44 +++++++ inventree_label_dispatch/driver.py | 47 ++++++-- inventree_label_dispatch/status.py | 108 +++++++++++++++++ tests/test_dispatch.py | 77 ++++++++++++ tests/test_status.py | 168 +++++++++++++++++++++++++++ 6 files changed, 465 insertions(+), 12 deletions(-) create mode 100644 inventree_label_dispatch/status.py create mode 100644 tests/test_status.py diff --git a/README.md b/README.md index 5b3c12a..ce09ecd 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,39 @@ the UI**. A Bluetooth D30 is asleep by default; surfacing that before someone cl print is most of the usability. Status is read from the printer's retained MQTT topic, so there is no polling loop in the Django worker. +## What the status actually says + +The retained status carries the printer's own report — firmware, battery percentage +*and* terminal voltage, and whether media is loaded — so the Machines page reflects the +device rather than just the agent's liveness. The mapping lives in `status.py`, free of +InvenTree imports so it is unit-tested here: + +| Printer reports | Machines status | +|---|---| +| nothing retained | `UNKNOWN` — the agent has never published | +| `state: idle`, media ok | `CONNECTED` | +| media bit clear (`0x06`) | **`NO_MEDIA`** — "load tape", not "investigate" | +| any other fault | `ERROR`, carrying the reason | +| agent will / shutdown | `DISCONNECTED` | + +Two deliberate distinctions. **Unreported media is not healthy media**: `media_ok` is a +tri-state, and a printer that has said nothing shows "media unreported" rather than +"media ok". And **battery percentage alone is not enough** — it pins at 100% whenever +the unit is on charge, so the voltage is shown beside it. + +## Print outcomes + +`AWAIT_RESULT_S` (default 15s, 0 disables) makes the driver wait for the job's +`JobResult` after dispatching, so the UI reports what actually happened instead of +"dispatched". It subscribes *before* publishing — the results topic is not retained, so +subscribing afterwards races the agent — and filters on `job_id`, because one printer +serves every user and a stranger's result must not be mistaken for yours. + +A timeout is an ordinary outcome, not a failure: strip mode holds labels until the +agent's coalescer flushes (30s by default), so the job can legitimately outlive any +wait short enough to block a Django-Q worker on. It stays `PRINTING`, and the next +status refresh picks up the truth from the retained topic. + ## How it works - Default path sends `{preset, vars}` — the producer stays dumb and layout ownership diff --git a/inventree_label_dispatch/dispatch.py b/inventree_label_dispatch/dispatch.py index d7fc1a5..4e0c236 100644 --- a/inventree_label_dispatch/dispatch.py +++ b/inventree_label_dispatch/dispatch.py @@ -103,6 +103,50 @@ def publish(job: dict, conn: Connection) -> None: client.disconnect() +def publish_awaiting_result(job: dict, conn: Connection, timeout_s: float) -> dict | None: + """Publish a job and wait for *its* ``JobResult``, or ``None`` on timeout. + + Subscribes before publishing. The results topic is **not** retained, so subscribing + afterwards races the agent and would lose the result of a fast print entirely. + + Filters on ``job_id``: one printer serves every InvenTree user, so another click's + result can land on this subscription and must not be mistaken for ours. + + A timeout is an ordinary outcome, not an error. Strip mode holds labels until the + coalescer flushes -- up to ``strip.max_wait_s`` on the agent, 30s by default -- so a + job can legitimately outlive any wait short enough to block a worker on. + """ + import queue + + printer_id = job["printer"]["id"] + job_id = job["job_id"] + received: queue.Queue = queue.Queue() + + def _on_message(_c, _u, msg): + try: + payload = json.loads(msg.payload) + except Exception: + return + if payload.get("job_id") == job_id: + received.put_nowait(payload) + + client = _new_client(conn, "job") + client.on_message = _on_message + client.connect(conn.host, conn.port, conn.keepalive_s) + client.subscribe(conn.topic(printer_id, "results"), qos=1) + client.loop_start() + try: + info = client.publish(conn.topic(printer_id, "jobs"), json.dumps(job), qos=1) + info.wait_for_publish(conn.timeout_s) + try: + return received.get(timeout=timeout_s) + except queue.Empty: + return None + finally: + client.loop_stop() + client.disconnect() + + def read_status(printer_id: str, conn: Connection) -> dict | None: """One-shot read of the retained status topic. ``None`` if nothing is retained. diff --git a/inventree_label_dispatch/driver.py b/inventree_label_dispatch/driver.py index 9529c5a..3c8b331 100644 --- a/inventree_label_dispatch/driver.py +++ b/inventree_label_dispatch/driver.py @@ -30,8 +30,9 @@ class only reads settings, turns items into label specs, and publishes. from rest_framework import serializers from .config import connection_from_env -from .dispatch import build_job, build_label, publish, read_status +from .dispatch import build_job, build_label, publish, publish_awaiting_result, read_status from .extractors import extract +from .status import classify_result, classify_status def _as_bool(value) -> bool: @@ -73,6 +74,17 @@ class LabelfabDriver(LabelPrinterBaseDriver): "default": False, "validator": bool, }, + "AWAIT_RESULT_S": { + "name": _("Wait for the print result (seconds)"), + "description": _( + "Block after dispatching to report the real outcome instead of just " + "'dispatched'. 0 disables. Note this holds a Django-Q worker for up to " + "this long, and strip mode may not flush until the agent's coalescer " + "window elapses, in which case the wait times out harmlessly." + ), + "default": "15", + "validator": float, + }, } class PrintingOptionsSerializer(LabelPrinterBaseDriver.PrintingOptionsSerializer): @@ -91,20 +103,20 @@ class PrintingOptionsSerializer(LabelPrinterBaseDriver.PrintingOptionsSerializer def init_machine(self, machine) -> None: self._refresh_status(machine) + def _apply(self, machine, member: str, text: str) -> None: + """Set a status by member name, so the mapping stays free of InvenTree imports.""" + machine.set_status(getattr(LabelPrinterStatus, member)) + machine.set_status_text(text) + def _refresh_status(self, machine) -> None: printer_id = machine.get_setting("PRINTER_ID", "D") try: status = read_status(printer_id, connection_from_env()) - except Exception: + except Exception as exc: machine.set_status(LabelPrinterStatus.UNKNOWN) - machine.set_status_text(_("could not reach the broker")) + machine.set_status_text(_("could not reach the broker: %(err)s") % {"err": str(exc)[:80]}) return - if status is None: - machine.set_status(LabelPrinterStatus.UNKNOWN) - elif status.get("state") == "disconnected": - machine.set_status(LabelPrinterStatus.DISCONNECTED) - else: - machine.set_status(LabelPrinterStatus.CONNECTED) + self._apply(machine, *classify_status(status)) # -- printing ----------------------------------------------------------- # @@ -138,14 +150,25 @@ def print_labels(self, machine, label, items, **kwargs): batch_mode="discrete" if tape_kind == "gap" else "strip", template_id=getattr(label, "pk", None), ) + conn = connection_from_env() + await_s = float(machine.get_setting("AWAIT_RESULT_S", "D") or 0) try: - publish(job, connection_from_env()) + # Publishing is the only step that can fail locally. A print that fails at + # the printer comes back as a JobResult, not an exception. + if await_s > 0: + result = publish_awaiting_result(job, conn, await_s) + else: + publish(job, conn) + result = None except Exception as exc: machine.set_status(LabelPrinterStatus.DISCONNECTED) machine.set_status_text(_("dispatch failed: %(err)s") % {"err": str(exc)[:120]}) raise - machine.set_status(LabelPrinterStatus.PRINTING) - machine.set_status_text(_("dispatched %(n)d label(s)") % {"n": len(labels)}) + + if await_s > 0: + self._apply(machine, *classify_result(result, dispatched=len(labels))) + else: + self._apply(machine, "PRINTING", _("dispatched %(n)d label(s)") % {"n": len(labels)}) # -- helpers ------------------------------------------------------------ # diff --git a/inventree_label_dispatch/status.py b/inventree_label_dispatch/status.py new file mode 100644 index 0000000..e6dbdcc --- /dev/null +++ b/inventree_label_dispatch/status.py @@ -0,0 +1,108 @@ +"""Turning what the printer reported into what the Machines page should say. + +Kept apart from :mod:`driver` and free of any InvenTree import on purpose. The driver +is thin by design and cannot be unit-tested here -- InvenTree is not a dependency of +this package's test environment -- so the decisions worth testing live here and return +a ``LabelPrinterStatus`` *member name* for the driver to look up. + +Two rules the mapping is built around: + +* **Silence is not health.** ``media_ok`` is ``None`` when the printer has not said + anything about its media, which is a different state from "media is fine". Reporting + the former as CONNECTED-and-fine is how a Bluetooth printer with no tape in it ends + up looking ready. +* **A media fault is not a generic error.** InvenTree has NO_MEDIA (301) for exactly + this, and it renders differently from ERROR (500). Using the specific code is the + difference between "go put tape in it" and "something is wrong, go investigate". +""" + +from __future__ import annotations + +#: Terminal job states that mean tape came out as intended. +_GOOD_JOB_STATES = frozenset({"completed"}) + + +def _describe(status: dict) -> str: + """A one-line summary of the device truth on the retained status topic.""" + bits: list[str] = [] + if firmware := status.get("firmware"): + bits.append(f"fw {firmware}") + + battery, voltage = status.get("battery_pct"), status.get("voltage_v") + if battery is not None and voltage is not None: + # Both, because the percentage pins at 100 on charge and the voltage does not. + bits.append(f"{battery}% ({voltage:.2f}V)") + elif battery is not None: + bits.append(f"{battery}%") + elif voltage is not None: + bits.append(f"{voltage:.2f}V") + + media = status.get("media_ok") + bits.append("media ok" if media else "no media" if media is False else "media unreported") + + if serial := status.get("serial"): + bits.append(serial) + return " · ".join(bits) + + +def classify_status(status: dict | None) -> tuple[str, str]: + """Map a retained ``PrinterStatus`` to ``(LabelPrinterStatus member, text)``. + + ``status`` is ``None`` when nothing is retained on the topic -- the agent has never + run, or its status was cleared. That is genuinely unknown, not disconnected. + """ + if status is None: + return "UNKNOWN", "no retained status; the print agent has not published yet" + + state = status.get("state") + detail = _describe(status) + + if state == "disconnected": + # The agent's MQTT will, or its own shutdown notice. The broker is reachable; + # the printer is not. + return "DISCONNECTED", f"agent reports the printer unreachable — {detail}" + + if state == "error": + reason = status.get("error") or "printer reported a fault" + # NO_MEDIA only when the printer actually said the media is bad. Any other + # fault -- material error, a cancelled print -- is a generic error. + member = "NO_MEDIA" if status.get("media_ok") is False else "ERROR" + return member, f"{reason} — {detail}" + + if state == "printing": + pending = status.get("pending_labels") or 0 + suffix = f", {pending} label(s) pending" if pending else "" + return "PRINTING", f"printing{suffix} — {detail}" + + if state == "idle": + # Idle but the media bit is clear: the agent has not faulted the batch (it may + # not have printed since), yet the printer is not ready. Say so. + if status.get("media_ok") is False: + return "NO_MEDIA", f"printer reports no media — {detail}" + return "CONNECTED", detail + + return "UNKNOWN", f"unrecognised agent state {state!r} — {detail}" + + +def classify_result(result: dict | None, *, dispatched: int) -> tuple[str, str]: + """Map a ``JobResult`` to ``(LabelPrinterStatus member, text)``. + + ``None`` means no result arrived inside the wait. That is not a failure: strip mode + deliberately holds labels until the coalescer flushes, so a job can legitimately + outlive any sane wait. Leave it PRINTING rather than inventing an outcome. + """ + if result is None: + return "PRINTING", f"dispatched {dispatched} label(s); awaiting confirmation" + + state = result.get("state") + if state in _GOOD_JOB_STATES: + printed = sum(label.get("copies_done", 0) for label in result.get("labels") or []) + return "CONNECTED", f"printed {printed or dispatched} label(s)" + + error = result.get("error") or f"job {state}" + if result.get("partial_tape_consumed"): + # Worth surfacing loudly: some tape came out, so a blind reprint double-prints. + error = f"{error} (tape partially consumed — check before reprinting)" + if state == "stalled": + return "DISCONNECTED", f"printer unreachable, job requeued — {error}" + return "ERROR", error diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index 1936b24..1cbfc28 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -78,3 +78,80 @@ def test_publish_sends_job_to_the_printers_jobs_topic(monkeypatch): assert topic == "se/v1/print/d30-workshop/jobs" assert qos == 1 assert json.loads(payload)["job_id"] == job["job_id"] + + +class _ReplyingClient(_FakeClient): + """A broker that answers each publish with results, in subscribe-then-publish order.""" + + def __init__(self, replies): + super().__init__() + self.replies = replies + self.subscribed = [] + self.on_message = None + self.order = [] + + def subscribe(self, topic, qos=0): + self.subscribed.append(topic) + self.order.append("subscribe") + + def publish(self, topic, payload, qos=0): + self.order.append("publish") + info = super().publish(topic, payload, qos) + for reply in self.replies: + self.on_message(self, None, type("Msg", (), {"payload": json.dumps(reply).encode()})()) + return info + + +def test_awaiting_result_subscribes_before_publishing(monkeypatch): + """The results topic is not retained, so subscribing after publish loses fast prints.""" + job = build_job("d30-workshop", [build_label({"preset": "stock_item", "vars": {"code": "S"}})]) + fake = _ReplyingClient([{"job_id": job["job_id"], "state": "completed"}]) + monkeypatch.setattr(dispatch, "_new_client", lambda conn, suffix: fake) + + result = dispatch.publish_awaiting_result(job, Connection(host="broker"), timeout_s=1.0) + + assert fake.order == ["subscribe", "publish"] + assert fake.subscribed == ["se/v1/print/d30-workshop/results"] + assert result["state"] == "completed" + + +def test_awaiting_result_ignores_another_clicks_result(monkeypatch): + """One printer serves every InvenTree user; a stranger's result is not ours.""" + job = build_job("d30-workshop", [build_label({"preset": "stock_item", "vars": {"code": "S"}})]) + fake = _ReplyingClient( + [ + {"job_id": "someone-elses-job", "state": "failed", "error": "not ours"}, + {"job_id": job["job_id"], "state": "completed"}, + ] + ) + monkeypatch.setattr(dispatch, "_new_client", lambda conn, suffix: fake) + + result = dispatch.publish_awaiting_result(job, Connection(host="broker"), timeout_s=1.0) + assert result["job_id"] == job["job_id"] + assert result["state"] == "completed" + + +def test_awaiting_result_returns_none_on_timeout(monkeypatch): + """A strip that has not flushed yet is an ordinary outcome, not an error.""" + job = build_job("d30-workshop", [build_label({"preset": "stock_item", "vars": {"code": "S"}})]) + fake = _ReplyingClient([]) # broker says nothing + monkeypatch.setattr(dispatch, "_new_client", lambda conn, suffix: fake) + + assert dispatch.publish_awaiting_result(job, Connection(host="broker"), timeout_s=0.05) is None + + +def test_awaiting_result_survives_a_malformed_payload(monkeypatch): + """A non-JSON message on the topic must not take down a print.""" + job = build_job("d30-workshop", [build_label({"preset": "stock_item", "vars": {"code": "S"}})]) + fake = _ReplyingClient([{"job_id": job["job_id"], "state": "completed"}]) + monkeypatch.setattr(dispatch, "_new_client", lambda conn, suffix: fake) + + original = fake.publish + + def publish_with_garbage(topic, payload, qos=0): + fake.on_message(fake, None, type("Msg", (), {"payload": b"not json"})()) + return original(topic, payload, qos) + + fake.publish = publish_with_garbage + result = dispatch.publish_awaiting_result(job, Connection(host="broker"), timeout_s=1.0) + assert result["state"] == "completed" diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..4512cc9 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,168 @@ +"""Mapping printer truth onto Machines-page status. No InvenTree, no broker. + +The member names asserted here must exist on InvenTree's ``LabelPrinterStatus``: +CONNECTED 100, UNKNOWN 101, PRINTING 110, WARNING 200, NO_MEDIA 301, PAPER_JAM 302, +DISCONNECTED 400, ERROR 500. +""" + +from __future__ import annotations + +import pytest + +from inventree_label_dispatch.status import classify_result, classify_status + +VALID_MEMBERS = { + "CONNECTED", + "UNKNOWN", + "PRINTING", + "WARNING", + "NO_MEDIA", + "PAPER_JAM", + "DISCONNECTED", + "ERROR", +} + + +def _healthy(**over) -> dict: + base = { + "v": 1, + "printer_id": "d30-workshop", + "state": "idle", + "model": "phomemo-d30", + "serial": "Q223P4C31420105", + "firmware": "2.1.2", + "battery_pct": 100, + "voltage_v": 4.17, + "media_ok": True, + "tape_width_mm": 15.0, + "pending_labels": 0, + "error": None, + } + base.update(over) + return base + + +def test_every_mapping_names_a_real_status_member(): + """A typo here silently becomes AttributeError inside the driver at runtime.""" + cases = [ + None, + _healthy(), + _healthy(state="printing", pending_labels=3), + _healthy(state="disconnected"), + _healthy(state="error", media_ok=False, error="media not ready"), + _healthy(state="error", error="material error 0x02"), + _healthy(state="banana"), + ] + for case in cases: + member, text = classify_status(case) + assert member in VALID_MEMBERS, member + assert text + + +def test_idle_and_healthy_is_connected_with_the_device_detail(): + member, text = classify_status(_healthy()) + assert member == "CONNECTED" + assert "fw 2.1.2" in text + assert "100% (4.17V)" in text # both, because percent pins at 100 on charge + assert "media ok" in text + + +def test_no_retained_status_is_unknown_not_disconnected(): + """Nothing published is genuinely unknown; claiming disconnected invents a fact.""" + member, _text = classify_status(None) + assert member == "UNKNOWN" + + +def test_a_media_fault_is_no_media_not_a_generic_error(): + """InvenTree renders NO_MEDIA differently: 'go load tape' vs 'go investigate'.""" + member, text = classify_status( + _healthy(state="error", media_ok=False, error="media not ready (paper=ERROR(0x88))") + ) + assert member == "NO_MEDIA" + assert "media not ready" in text + + +def test_a_non_media_fault_is_a_generic_error(): + member, text = classify_status(_healthy(state="error", error="printer cancelled the print")) + assert member == "ERROR" + assert "cancelled" in text + + +def test_idle_but_media_bit_clear_still_reports_no_media(): + """The agent may not have printed since the tape ran out, so state is still idle.""" + member, _text = classify_status(_healthy(media_ok=False)) + assert member == "NO_MEDIA" + + +def test_unreported_media_is_not_shown_as_ok(): + """Silence is not health -- the whole reason media_ok is a tri-state.""" + _member, text = classify_status(_healthy(media_ok=None)) + assert "media unreported" in text + assert "media ok" not in text + + +def test_printing_reports_the_pending_count(): + member, text = classify_status(_healthy(state="printing", pending_labels=4)) + assert member == "PRINTING" + assert "4 label(s) pending" in text + + +def test_missing_optional_fields_do_not_break_the_description(): + """An older agent publishes v1 without the new fields.""" + member, text = classify_status( + {"v": 1, "printer_id": "d30-workshop", "state": "idle", "model": "phomemo-d30"} + ) + assert member == "CONNECTED" + assert "media unreported" in text + + +# -- job outcomes ----------------------------------------------------------- # + + +def test_a_completed_job_reports_the_copies_actually_printed(): + member, text = classify_result( + { + "state": "completed", + "labels": [{"index": 0, "copies_done": 2}, {"index": 1, "copies_done": 1}], + }, + dispatched=2, + ) + assert member == "CONNECTED" + assert "printed 3 label(s)" in text + + +def test_no_result_inside_the_wait_stays_printing(): + """Strip mode holds labels until the coalescer flushes; that is not a failure.""" + member, text = classify_result(None, dispatched=2) + assert member == "PRINTING" + assert "awaiting confirmation" in text + + +def test_a_failed_job_is_an_error_carrying_the_reason(): + member, text = classify_result( + {"state": "failed", "error": "printer cancelled the print"}, dispatched=1 + ) + assert member == "ERROR" + assert "cancelled" in text + + +def test_partial_tape_is_flagged_because_a_blind_reprint_double_prints(): + _member, text = classify_result( + {"state": "failed", "error": "disconnected mid-strip", "partial_tape_consumed": True}, + dispatched=5, + ) + assert "tape partially consumed" in text + + +def test_a_stalled_job_reads_as_disconnected_not_failed(): + """Stalled means requeued and will retry, so it must not look like a lost job.""" + member, text = classify_result({"state": "stalled", "error": "printer asleep"}, dispatched=1) + assert member == "DISCONNECTED" + assert "requeued" in text + + +@pytest.mark.parametrize("state", ["completed", "partial", "failed", "stalled", "rejected"]) +def test_every_contract_job_state_maps_somewhere_valid(state): + member, text = classify_result({"state": state}, dispatched=1) + assert member in VALID_MEMBERS + assert text From 9c42e11a50eb7001e1aa5d52ac0f0ec26ec89a72 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Thu, 30 Jul 2026 01:52:22 +0200 Subject: [PATCH 2/3] feat(status): default AWAIT_RESULT_S to 0 Waiting for the result holds a Django-Q worker, and in strip mode the agent buffers labels until its coalescer flushes -- 30s by default -- so the wait would usually time out and leave the status to the retained topic anyway. Paying a worker for an outcome that mostly does not arrive in time is the wrong default; opt in on die-cut/discrete media, where a result comes back in a few seconds. --- README.md | 23 +++++++++++++---------- inventree_label_dispatch/driver.py | 10 ++++++---- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ce09ecd..99834bd 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,19 @@ the unit is on charge, so the voltage is shown beside it. ## Print outcomes -`AWAIT_RESULT_S` (default 15s, 0 disables) makes the driver wait for the job's -`JobResult` after dispatching, so the UI reports what actually happened instead of -"dispatched". It subscribes *before* publishing — the results topic is not retained, so -subscribing afterwards races the agent — and filters on `job_id`, because one printer -serves every user and a stranger's result must not be mistaken for yours. - -A timeout is an ordinary outcome, not a failure: strip mode holds labels until the -agent's coalescer flushes (30s by default), so the job can legitimately outlive any -wait short enough to block a Django-Q worker on. It stays `PRINTING`, and the next -status refresh picks up the truth from the retained topic. +`AWAIT_RESULT_S` makes the driver wait for the job's `JobResult` after dispatching, so +the UI reports what actually happened instead of "dispatched". It subscribes *before* +publishing — the results topic is not retained, so subscribing afterwards races the +agent — and filters on `job_id`, because one printer serves every user and a stranger's +result must not be mistaken for yours. + +**It is off by default (`0`).** The wait holds a Django-Q worker, and in strip mode the +agent deliberately buffers labels until its coalescer flushes — 30s by default — so the +wait would usually time out and leave the status to the retained topic regardless. +Enable it on die-cut/discrete media, where a result comes back in a few seconds. + +A timeout is an ordinary outcome, not a failure: the status stays `PRINTING` and the +next refresh picks up the truth from the retained topic. ## How it works diff --git a/inventree_label_dispatch/driver.py b/inventree_label_dispatch/driver.py index 3c8b331..892e2da 100644 --- a/inventree_label_dispatch/driver.py +++ b/inventree_label_dispatch/driver.py @@ -78,11 +78,13 @@ class LabelfabDriver(LabelPrinterBaseDriver): "name": _("Wait for the print result (seconds)"), "description": _( "Block after dispatching to report the real outcome instead of just " - "'dispatched'. 0 disables. Note this holds a Django-Q worker for up to " - "this long, and strip mode may not flush until the agent's coalescer " - "window elapses, in which case the wait times out harmlessly." + "'dispatched'. Off by default: it holds a Django-Q worker for up to this " + "long, and in strip mode the agent deliberately buffers labels until its " + "coalescer flushes (30s by default), so the wait usually times out and " + "the status is left to the retained topic anyway. Worth enabling on " + "die-cut/discrete media, where a result comes back in a few seconds." ), - "default": "15", + "default": "0", "validator": float, }, } From dbbf6ed22c497e3798f41dbf56010f4d9fce4994 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Thu, 30 Jul 2026 01:58:46 +0200 Subject: [PATCH 3/3] fix(status): map 'partial' explicitly, and flag moved tape on every failure Review asked what 'partial' means. It is terminal: the agent sets it from `any_printed and any_failed` when it finalises a job, so some tape came out and some labels did not. It was falling through to a bare 'job partial', which tells an operator nothing about what to reprint -- now it reports printed-of-total and the failed count. Writing that branch exposed a second bug in it: returning early meant a partial job never got the partial_tape_consumed warning, which is exactly the outcome where moved tape matters most. The warning is now appended after the branches, so every non-success state carries it. Also renamed test_every_mapping_names_a_real_status_member. It only catches typos in the strings classify_* returns; it cannot catch drift from InvenTree's enum, because VALID_MEMBERS is a local copy (InvenTree is deliberately not a test dependency). The old name claimed more than the test delivers. --- inventree_label_dispatch/status.py | 32 ++++++++++++++---- tests/test_status.py | 53 ++++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/inventree_label_dispatch/status.py b/inventree_label_dispatch/status.py index e6dbdcc..82d0e67 100644 --- a/inventree_label_dispatch/status.py +++ b/inventree_label_dispatch/status.py @@ -95,14 +95,32 @@ def classify_result(result: dict | None, *, dispatched: int) -> tuple[str, str]: return "PRINTING", f"dispatched {dispatched} label(s); awaiting confirmation" state = result.get("state") + labels = result.get("labels") or [] + printed = sum(label.get("copies_done", 0) for label in labels) + if state in _GOOD_JOB_STATES: - printed = sum(label.get("copies_done", 0) for label in result.get("labels") or []) return "CONNECTED", f"printed {printed or dispatched} label(s)" - error = result.get("error") or f"job {state}" + if state == "partial": + # Terminal, not in-flight: the agent sets this from `any_printed and any_failed` + # when it finalises the job. So some tape came out and some labels did not, and + # the operator needs the counts to know what to reprint -- "job partial" on its + # own tells them nothing. + failed = sum(1 for label in labels if label.get("state") == "failed") + detail = result.get("error") + member = "ERROR" + text = f"printed {printed} of {len(labels)} label(s), {failed} failed" + if detail: + text = f"{text} — {detail}" + elif state == "stalled": + member = "DISCONNECTED" + text = f"printer unreachable, job requeued — {result.get('error') or 'job stalled'}" + else: + member = "ERROR" + text = result.get("error") or f"job {state}" + + # Appended after the branches, not inside one: every non-success outcome can have + # moved tape, and a blind reprint after that double-prints whatever came out. if result.get("partial_tape_consumed"): - # Worth surfacing loudly: some tape came out, so a blind reprint double-prints. - error = f"{error} (tape partially consumed — check before reprinting)" - if state == "stalled": - return "DISCONNECTED", f"printer unreachable, job requeued — {error}" - return "ERROR", error + text = f"{text} (tape partially consumed — check before reprinting)" + return member, text diff --git a/tests/test_status.py b/tests/test_status.py index 4512cc9..5fac03d 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -42,8 +42,13 @@ def _healthy(**over) -> dict: return base -def test_every_mapping_names_a_real_status_member(): - """A typo here silently becomes AttributeError inside the driver at runtime.""" +def test_no_mapping_returns_a_misspelled_member_name(): + """A typo becomes AttributeError inside a Django worker rather than failing here. + + This catches typos, not drift from InvenTree's enum: VALID_MEMBERS is a local copy, + because InvenTree is deliberately not a test dependency of this package. Drift would + have to be caught by the integration environment. + """ cases = [ None, _healthy(), @@ -166,3 +171,47 @@ def test_every_contract_job_state_maps_somewhere_valid(state): member, text = classify_result({"state": state}, dispatched=1) assert member in VALID_MEMBERS assert text + + +def test_partial_is_terminal_and_says_what_actually_printed(): + """The agent sets partial from `any_printed and any_failed` when finalising a job. + + So it is terminal, not in-flight, and the operator needs the counts to know what to + reprint -- falling through to a bare "job partial" tells them nothing. + """ + member, text = classify_result( + { + "state": "partial", + "labels": [ + {"index": 0, "state": "printed", "copies_done": 2}, + {"index": 1, "state": "failed", "copies_done": 0}, + {"index": 2, "state": "failed", "copies_done": 0}, + ], + }, + dispatched=3, + ) + assert member == "ERROR" + assert "printed 2 of 3 label(s)" in text + assert "2 failed" in text + + +def test_partial_still_flags_partial_tape(): + _member, text = classify_result( + { + "state": "partial", + "partial_tape_consumed": True, + "labels": [{"index": 0, "state": "printed", "copies_done": 1}], + }, + dispatched=2, + ) + assert "tape partially consumed" in text + + +def test_a_rejected_job_reports_why(): + """Rejection is a contract problem -- wrong model, bad spec -- so the reason matters.""" + member, text = classify_result( + {"state": "rejected", "error": "this agent drives phomemo-d30, not phomemo-m110"}, + dispatched=1, + ) + assert member == "ERROR" + assert "phomemo-m110" in text