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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions inventree_label_dispatch/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
49 changes: 37 additions & 12 deletions inventree_label_dispatch/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand All @@ -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 ----------------------------------------------------------- #

Expand Down Expand Up @@ -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 ------------------------------------------------------------ #

Expand Down
126 changes: 126 additions & 0 deletions inventree_label_dispatch/status.py
Original file line number Diff line number Diff line change
@@ -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
77 changes: 77 additions & 0 deletions tests/test_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading