diff --git a/README.md b/README.md index 5b3c12a..99834bd 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,42 @@ 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` 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 - 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..892e2da 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,19 @@ 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'. 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": "0", + "validator": float, + }, } class PrintingOptionsSerializer(LabelPrinterBaseDriver.PrintingOptionsSerializer): @@ -91,20 +105,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 +152,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..82d0e67 --- /dev/null +++ b/inventree_label_dispatch/status.py @@ -0,0 +1,126 @@ +"""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") + labels = result.get("labels") or [] + printed = sum(label.get("copies_done", 0) for label in labels) + + if state in _GOOD_JOB_STATES: + return "CONNECTED", f"printed {printed or dispatched} label(s)" + + 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"): + text = f"{text} (tape partially consumed — check before reprinting)" + return member, text 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..5fac03d --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,217 @@ +"""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_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(), + _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 + + +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