From f91397caa465745494ed6a1dff051fde7baf21aa Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Thu, 30 Jul 2026 01:37:42 +0200 Subject: [PATCH 1/2] feat(agent): publish device truth on the status topic, not just agent 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. --- HARDWARE-NOTES.md | 81 +++++++++++++++++++++++ deploy/agent.toml.example | 9 ++- src/labelfab/agent/__main__.py | 1 + src/labelfab/agent/config.py | 15 ++++- src/labelfab/agent/worker.py | 74 +++++++++++++++++++-- src/labelfab/contract/models.py | 12 ++++ src/labelfab/device/d30.py | 26 +++++++- src/labelfab/device/feedback.py | 17 +++++ src/labelfab/device/protocol.py | 20 +++++- src/labelfab/device/responses.py | 57 +++++++++++++++- tests/test_agent_config.py | 22 +++++++ tests/test_device.py | 43 +++++++++++- tests/test_feedback.py | 52 +++++++++++++++ tests/test_worker.py | 110 +++++++++++++++++++++++++++++++ 14 files changed, 522 insertions(+), 17 deletions(-) diff --git a/HARDWARE-NOTES.md b/HARDWARE-NOTES.md index cd332c2..cb59fc3 100644 --- a/HARDWARE-NOTES.md +++ b/HARDWARE-NOTES.md @@ -563,3 +563,84 @@ tshark -r d30-ios.pklg -Y 'btatt.opcode==0x52 || btatt.opcode==0x12 || btatt.opc `0x52` = write command (phone→printer, handle `0x0008`), `0x1b` = notification (printer→phone, handle `0x000a`). Reassemble by concatenating all `0x0008` writes and splitting on the `1d763000` marker. + +--- + +## Session 2026-07-30 — live SPP verification on fw 2.1.2 + +All of the below was measured over Classic SPP against unit `Q223P4C31420105` +(fw `2.1.2`, hw `1.0.3`), driven from the relocatable interpreter with no +`socket.AF_BLUETOOTH`. Independently corroborated by the printer's own info label, +which reads `SN: Q223P4C31420105 / MAC: AAFDFD6B9F5F / VER: 2.1.2.B` — three values +matching what the parser decoded, so the tag table is right rather than merely +self-consistent. + +### Three previously-unknown response tags ✅ + +| Query | Reply | Decode | +|---|---|---| +| `VOLTAGE` `1f111f` | `1a 2f 01 a0` | **`0x2F`**, 2 bytes big-endian, 10 mV units → 4.16 V | +| `SENSOR_INFO` `1f111d` | `1a 2d 02 00 … e6 00 …` | **`0x2D`**, 13-byte payload, field layout still unknown | +| `HARDWARE_VERSION` `1f1133` | `1a 11 01 00 03` | **`0x11`**, 3 bytes → `1.0.3` | + +`0x2F` sampled 4.16 → 4.17 V over a few seconds on charge, against `4.09V` on the +discharged unit's own info label. **This is the battery signal worth having:** `BATTERY` +(`0x04`) returned `0x64` = 100% the entire time the unit was charging, so it cannot +answer "will this survive a long strip". Voltage can. + +### Opcodes that exist and do nothing ❌ + +Sent, no reply, no observable effect. All three were `DECOMPILED`-only guesses: + +| Opcode | Expected | Actual | +|---|---|---| +| `PRINT_TEST_PAGE` `1f1127` | built-in self test | **nothing** — no frame, no tape movement | +| `ALL_ERROR` `1f1128` | comprehensive error word | **nothing** | +| `LABEL_WIDTH` `1f1118` | head width | **nothing** — closes the "ask the printer its own width" idea for good | + +The head width therefore still has to be measured, not asked. `LABEL_WIDTH` staying +silent is consistent with the earlier `--head-width` probe finding. + +### `print_complete` timing — the margin was far too small 🔑 + +`0x0F` arrives **~3.0 s after the last raster byte** (measured: 4.1 s after the write +began, of which 1.1 s was the write). Same order as the ~2.4 s noted earlier, and +roughly **7×** what the head's line rate predicts — a 200-line label computes to 0.42 s. + +`post_print_margin_s` was `0.3`, so the budget was ~0.72 s for a 200-line label and +expired before the printer ever answered. `await_print_complete` therefore *never* +succeeded on short labels and silently fell back to the duration guess it exists to +replace. Now `3.5`, which costs nothing on the happy path because the wait returns as +soon as the frame lands. + +### Over-wide rasters are refused, not truncated ⚠️ + +A **120 px** raster (15 mm tape × 7.992 px/mm) came back `print_cancelled` +(`1a 0b b8`) and printed **nothing**. The identical label at **96 px** printed and +answered `print_complete`. The head is 96 dots and it would rather refuse than clip. + +This mattered in production: `device.raster_width_px` was declared in the config and +read by *nothing*, while the worker rendered at `tape.width_mm` — whose shipped default +is 15 mm. Every job would have been cancelled. Rendering is now capped at the head. + +### Density 1 (light) is scannable ✅ + +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. Light is +enough for codes on this stock, and is the gentler default for head and tape. + +### Reconnect and wedged-link recovery + +The D30 accepts **one** RFCOMM connection. Reconnecting immediately after a close gives +`EBUSY` (errno 16) while the old session tears down — so a reconnect needs a short +delay, which the agent's 1/3/9 s backoff already provides. + +A link can wedge harder than that: an ACL stays listed by `hcitool con` +(`state 1 ... AUTH ENCRYPT`) and every connect returns `EBUSY` or `EALREADY`. +`hcitool dc ` **times out** and does not clear it. What does: + +```bash +sudo hciconfig hci1 reset # tears down every link from our side +``` + +After that the printer answered on the first attempt. diff --git a/deploy/agent.toml.example b/deploy/agent.toml.example index a399bd5..92cde54 100644 --- a/deploy/agent.toml.example +++ b/deploy/agent.toml.example @@ -28,9 +28,11 @@ username = "d30-workshop" # password comes from LABELFAB_MQTT__PASSWORD / the systemd credential, never here. [device] -# afbluetooth = Classic SPP (RFCOMM). ble = Bluetooth LE / GATT, for D30 units that -# expose no SPP record (sdptool browse is empty, pairing resolves a GATT profile). -transport = "ble" # afbluetooth | ble | serial | fake +# afbluetooth = Classic SPP (RFCOMM), the default: RFCOMM fragments and credits in +# the link layer, so long strips need no chunking or ACK bookkeeping. ble = Bluetooth +# LE / GATT, for D30 units that expose no SPP record (sdptool browse is empty and +# pairing resolves a GATT profile) -- fully viable, just slower on long strips. +transport = "afbluetooth" # afbluetooth | ble | serial | fake mac = "" # set after `bluetoothctl pair/trust` channel = 1 # afbluetooth only # ble only: the GATT write characteristic (ff02 on the D30) and optional adapter. @@ -38,6 +40,7 @@ ble_write_uuid = "0000ff02-0000-1000-8000-00805f9b34fb" ble_adapter = "" # e.g. hci1; blank = default adapter raster_width_px = 96 # 96 = 12mm head (verified); 120 = 15mm (day-1 hypothesis) pace_factor = 1.2 # lower until a long strip garbles, then +50% +density = 1 # 1 light | 2 medium | 4 heavy; raise if codes misscan wake_dummy_feed = false [tape] diff --git a/src/labelfab/agent/__main__.py b/src/labelfab/agent/__main__.py index 3ab8c44..0fcde99 100644 --- a/src/labelfab/agent/__main__.py +++ b/src/labelfab/agent/__main__.py @@ -33,6 +33,7 @@ def make_printer_factory(config: Config) -> Callable[[], PhomemoD30]: dcfg = D30Config( pace_factor=config.device.pace_factor, wake_dummy_feed=config.device.wake_dummy_feed, + density=config.device.density, ) def factory() -> PhomemoD30: diff --git a/src/labelfab/agent/config.py b/src/labelfab/agent/config.py index 693a30d..c10dfa8 100644 --- a/src/labelfab/agent/config.py +++ b/src/labelfab/agent/config.py @@ -20,7 +20,7 @@ from pathlib import Path from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, @@ -28,6 +28,7 @@ TomlConfigSettingsSource, ) +from labelfab.device.protocol import DENSITIES, DENSITY_LIGHT from labelfab.device.transport import DEFAULT_TRANSPORT, TRANSPORTS #: Where the packaged config lives; overridable for tests and the dir-only mode. @@ -85,12 +86,24 @@ class DeviceSection(BaseModel): raster_width_px: int = 96 #: Throttle multiplier for long strips; tuned down until a strip garbles, +50%. pace_factor: float = 1.2 + #: Print density: 1 light / 2 medium / 4 heavy. Light is gentlest on the head and + #: the tape and is what HARDWARE-NOTES recommends trying first; raise it if codes + #: stop scanning. Until this existed the agent could not set density at all. + density: int = DENSITY_LIGHT #: A blank feed on the first print after a wake, if bring-up finds faint labels. wake_dummy_feed: bool = False #: Drop the socket between batches: the D30 auto-sleeps, so a held-open socket #: just relocates the failure. Reconnect-per-batch is cheaper to reason about. idle_disconnect: bool = True + @field_validator("density") + @classmethod + def _density_is_known(cls, value: int) -> int: + # Fail at config load rather than on the first print attempt. + if value not in DENSITIES: + raise ValueError(f"density {value} is not one of {DENSITIES}") + return value + class TapeSection(BaseModel): """The media physically loaded in the printer. diff --git a/src/labelfab/agent/worker.py b/src/labelfab/agent/worker.py index 10d6392..da32ed4 100644 --- a/src/labelfab/agent/worker.py +++ b/src/labelfab/agent/worker.py @@ -26,7 +26,14 @@ from labelfab.agent.config import Config from labelfab.agent.publisher import Publisher from labelfab.agent.spool import Spool -from labelfab.contract import JobResult, LabelResult, PrinterStatus, PrintJob, TapeSpec +from labelfab.contract import ( + PX_PER_MM, + JobResult, + LabelResult, + PrinterStatus, + PrintJob, + TapeSpec, +) from labelfab.device.d30 import MODEL, PhomemoD30 from labelfab.device.errors import D30Error from labelfab.render import RenderConfig, concat_strip, render_label, to_device @@ -90,6 +97,12 @@ def __init__( self._stalled: dict[str, float] = {} #: Latest device serial reported by the printer, on either transport. self._device_serial: str | None = None + self._device_firmware: str | None = None + self._device_battery_pct: int | None = None + self._device_voltage_v: float | None = None + self._device_media_ok: bool | None = None + #: Last fault the printer reported, or None. Drives the "error" status state. + self._device_fault: str | None = None self.coalescer = Coalescer( max_wait_s=config.strip.max_wait_s, max_length_mm=config.strip.max_length_mm, @@ -112,9 +125,26 @@ def _render_cfg(self) -> RenderConfig: def _loaded_tape(self) -> TapeSpec: """The media in the printer, per the agent's [tape] config. Authoritative over - whatever a job claims -- a producer cannot know what tape was last loaded.""" + whatever a job claims -- a producer cannot know what tape was last loaded. + + Capped at the print head. The head is 96 dots (12mm) and the tape can be wider; + rendering to the full tape width produces a raster the printer *refuses*, + answering ``print_cancelled`` (0x0B) and printing nothing. Verified on hardware: + a 120px raster on 15mm tape was cancelled, the same label at 96px printed. Until + now ``device.raster_width_px`` was declared and read by nothing, so the shipped + default of 15mm tape cancelled every job. + """ + head_mm = self.config.device.raster_width_px / PX_PER_MM + width_mm = min(self.config.tape.width_mm, head_mm) + if width_mm < self.config.tape.width_mm: + log.debug( + "tape is %.1fmm but the head covers %.1fmm; rendering at the head width " + "(use tape.offset_px to position it on the wider stock)", + self.config.tape.width_mm, + head_mm, + ) return TapeSpec( - width_mm=self.config.tape.width_mm, + width_mm=width_mm, kind=self.config.tape.kind, length_mm=self.config.tape.length_mm, ) @@ -254,7 +284,7 @@ def _print_strip(self, batch: Batch) -> None: self._acc[pl.job_id].partial_tape = True # tape moved, unknowable amount for job_id in batch.job_ids: self._maybe_finalize(job_id) - self._publish_status("idle") + self._publish_status(self._settled_state()) def _run_discrete(self, job_id: str, queued: list[tuple[int, object]]) -> None: for index, image in queued: @@ -276,7 +306,7 @@ def _run_discrete(self, job_id: str, queued: list[tuple[int, object]]) -> None: else: self._fail_copy(job_id, index) # wrote but failed: one label lost self._maybe_finalize(job_id) - self._publish_status("idle") + self._publish_status(self._settled_state()) def _stall(self, job_ids: list[str]) -> None: now = self.clock() @@ -306,6 +336,8 @@ def _send(self, raster, *, is_strip: bool) -> _Send: printer = self.printer_factory() try: printer.connect() + # Our extra status queries, kept out of the pinned session sequence. + printer.refresh_telemetry() except D30Error as exc: # never wrote a byte printer.close() last = str(exc) @@ -320,6 +352,9 @@ def _send(self, raster, *, is_strip: bool) -> _Send: try: printer.print_raster(raster) except D30Error as exc: + # Capture first: a media fault is usually the reason this failed, and + # closing without reading it throws away the only useful diagnosis. + self._capture_feedback(printer) printer.close() last = str(exc) if is_strip: @@ -342,9 +377,24 @@ def _capture_feedback(self, printer: PhomemoD30) -> None: silently skip; the previous ``None`` guard would have hidden exactly that. """ fb = printer.feedback + # Only overwrite what it actually reported this time: a connection that + # answered nothing must not blank out a serial we already know. The fault is + # the exception -- it is recomputed every time, so a cleared media error + # clears the status rather than latching an error forever. if fb.serial: self._device_serial = fb.serial + if fb.firmware: + self._device_firmware = fb.firmware + if fb.battery_pct is not None: + self._device_battery_pct = fb.battery_pct + if fb.voltage_v is not None: + self._device_voltage_v = fb.voltage_v + if fb.paper_ok is not None: + self._device_media_ok = fb.paper_ok + self._device_fault = fb.fault() log.info("device feedback: %s", fb.summary()) + if self._device_fault: + log.warning("printer reports a fault: %s", self._device_fault) # -- result accounting -------------------------------------------------- # @@ -419,6 +469,15 @@ def _reject(self, job: PrintJob, reason: str) -> None: # -- status ------------------------------------------------------------- # + def _settled_state(self) -> str: + """What to publish once a batch is done. + + "idle" unless the printer is complaining. Kept explicit rather than folded into + ``_publish_status`` so "printing" and "disconnected" -- which are facts about + the link, not the media -- are never silently rewritten. + """ + return "error" if self._device_fault else "idle" + def _publish_status(self, state: str, *, pending: int = 0) -> None: self.publisher.publish_status( PrinterStatus( @@ -426,7 +485,12 @@ def _publish_status(self, state: str, *, pending: int = 0) -> None: state=state, # type: ignore[arg-type] model=MODEL, serial=self._device_serial, + firmware=self._device_firmware, + battery_pct=self._device_battery_pct, + voltage_v=self._device_voltage_v, + media_ok=self._device_media_ok, tape_width_mm=self.config.tape.width_mm, pending_labels=pending, + error=self._device_fault, ) ) diff --git a/src/labelfab/contract/models.py b/src/labelfab/contract/models.py index e7b0220..8b5b0ee 100644 --- a/src/labelfab/contract/models.py +++ b/src/labelfab/contract/models.py @@ -245,6 +245,18 @@ class PrinterStatus(Base): #: Device serial, as reported by the printer. Available on both transports -- #: SPP answers queries too; we simply never read the socket before. serial: str | None = None + #: Firmware the printer reports (``0x07``), e.g. ``"2.1.2"``. Lets a consumer gate + #: on firmware without anyone having to read the label on the box. + firmware: str | None = None + #: Battery percentage (``0x04``). Pins at 100 while the unit is on charge, so + #: ``voltage_v`` is the better signal for "is this about to die mid-strip". + battery_pct: int | None = None + #: Battery terminal voltage (``0x2F``). Observed 4.17V charging, 4.09V discharged. + voltage_v: float | None = None + #: Whether the printer says media is loaded and feeding (``0x06`` bit 0). ``None`` + #: means it has not said -- which is not the same as OK, and must not be shown as + #: healthy. + media_ok: bool | None = None tape_width_mm: float | None = None pending_labels: int = 0 error: str | None = None diff --git a/src/labelfab/device/d30.py b/src/labelfab/device/d30.py index 08feef6..82ce545 100644 --- a/src/labelfab/device/d30.py +++ b/src/labelfab/device/d30.py @@ -32,6 +32,7 @@ MAX_FRAME_LINES, print_preamble, session_setup, + telemetry_refresh, ) from labelfab.device.transport import Transport from labelfab.render.raster import DeviceRaster @@ -54,8 +55,15 @@ class D30Config: pace_factor: float = 1.2 #: Pause between session-setup packets. inter_packet_delay_s: float = 0.02 - #: Extra settle time after a print, on top of the computed duration. - post_print_margin_s: float = 0.3 + #: Slack on top of the computed print duration when waiting for ``0x0F``. + #: Measured on fw 2.1.2: the frame lands ~3.0s after the last raster byte, which is + #: the same order as the ~2.4s in HARDWARE-NOTES and *far* more than the head's own + #: line rate predicts (a 200-line label computes to 0.42s). At the old 0.3s the + #: budget expired before the printer ever answered, so await_print_complete could + #: not succeed on short labels -- it silently degraded to the duration guess it + #: exists to replace. Costs nothing on the happy path: the wait returns as soon as + #: the frame arrives. + post_print_margin_s: float = 3.5 #: Send a short blank feed on the first print after a wake. Some units print the #: first label faint otherwise; confirmed or ruled out during bring-up. wake_dummy_feed: bool = False @@ -127,6 +135,20 @@ def connect(self) -> None: self.sleep(self.config.inter_packet_delay_s) self._initialised = True + def refresh_telemetry(self) -> None: + """Send the extra status queries that the vendor session set omits. + + Separate from :meth:`connect` on purpose: ``session_setup`` is pinned + byte-for-byte to the captured vendor sequence, which is what makes a wire diff + against the vendor app meaningful, so our own additions do not belong inside it. + Replies land in ``feedback`` asynchronously like every other status frame. + """ + packet = telemetry_refresh() + if not packet: + return + self.transport.write(packet) + self.transport.flush() + def close(self) -> None: """Idempotent, and never raises.""" self._initialised = False diff --git a/src/labelfab/device/feedback.py b/src/labelfab/device/feedback.py index c9ed5cf..550600f 100644 --- a/src/labelfab/device/feedback.py +++ b/src/labelfab/device/feedback.py @@ -87,6 +87,23 @@ def firmware(self) -> str | None: def battery_pct(self) -> int | None: return self.state.battery_pct + @property + def voltage_v(self) -> float | None: + """Battery terminal voltage, or ``None`` if never queried.""" + return self.state.voltage_v + + @property + def material_error(self) -> int | None: + return self.state.material_error + + @property + def print_cancelled(self) -> bool: + return self.state.print_cancelled + + def fault(self) -> str | None: + """What the printer is complaining about, or ``None``. See DeviceState.fault.""" + return self.state.fault() + @property def paper_ok(self) -> bool | None: """``None`` means the printer has not reported -- not the same as OK. diff --git a/src/labelfab/device/protocol.py b/src/labelfab/device/protocol.py index 1a1a0fb..233f40d 100644 --- a/src/labelfab/device/protocol.py +++ b/src/labelfab/device/protocol.py @@ -113,13 +113,13 @@ def _cfg(sub: int) -> bytes: ALL_ERROR = Command( "ALL_ERROR", _ctrl(0x28), D, note="comprehensive error word; would decode the PAPER_STATE bits" ) -HARDWARE_VERSION = Command("HARDWARE_VERSION", _ctrl(0x33), D) +HARDWARE_VERSION = Command("HARDWARE_VERSION", _ctrl(0x33), V, note="-> 0x11, 3 bytes; 01 00 03") COMM_VERSION = Command( "COMM_VERSION", _ctrl(0x34), D, note="protocol version; useful for feature gating" ) -SENSOR_INFO = Command("SENSOR_INFO", _ctrl(0x1D), D) +SENSOR_INFO = Command("SENSOR_INFO", _ctrl(0x1D), V, note="-> 0x2D, 13 bytes, layout unknown") SENSOR_HEAT = Command("SENSOR_HEAT", _ctrl(0x3A), D) -VOLTAGE = Command("VOLTAGE", _ctrl(0x1F), D) +VOLTAGE = Command("VOLTAGE", _ctrl(0x1F), V, note="-> 0x2F, big-endian 10mV units") CHARGE_MODE = Command("CHARGE_MODE", _ctrl(0x43), D) COMPRESS_TYPE = Command("COMPRESS_TYPE", _ctrl(0x51), D, note="whether minilzo raster is supported") COMPRESS_SIZE = Command("COMPRESS_SIZE", _ctrl(0x36), D) @@ -280,6 +280,20 @@ def auto_shutdown_minutes(minutes: int) -> bytes: AUTO_POWER_TIME, ) +#: Read-only queries we send *in addition* to the vendor session set, for our own +#: status reporting. Kept separate so ``session_setup`` stays byte-identical to the +#: capture -- that fidelity is what makes a wire diff against the vendor app meaningful. +#: +#: VOLTAGE is here because BATTERY pins at 100% whenever the unit is on charge, so it +#: cannot answer "will this survive a long strip". VOLTAGE can: 4.17V on charge against +#: 4.09V discharged, same unit. +TELEMETRY_QUERIES: tuple[Command, ...] = (VOLTAGE,) + + +def telemetry_refresh() -> bytes: + """One write carrying the extra status queries. Empty tuple -> empty bytes.""" + return b"".join(c() for c in TELEMETRY_QUERIES) + def session_setup(*, density: int = DENSITY_MEDIUM, batched: bool = False) -> tuple[bytes, ...]: """Connect-time sequence: identify the printer, then set density. diff --git a/src/labelfab/device/responses.py b/src/labelfab/device/responses.py index f022011..f20b57b 100644 --- a/src/labelfab/device/responses.py +++ b/src/labelfab/device/responses.py @@ -55,6 +55,16 @@ def _pct(b: bytes) -> int: return b[0] +def _decivolts(b: bytes) -> float: + """Battery terminal voltage, big-endian in 10mV units. + + Worth having alongside ``battery``: that one pins at 100% on charge, while this + tracked 4.16 -> 4.17V over a few seconds on the bench and read 4.09V on a + discharged unit's self-test page. + """ + return int.from_bytes(b, "big") / 100 + + def _version(b: bytes) -> str: return ".".join(str(x) for x in b) @@ -81,12 +91,15 @@ def _serial(b: bytes) -> str: 0x0C: TagSpec("label_type", 1, lambda b: b[0], "✓"), 0x0E: TagSpec("p1000_state", 1), 0x0F: TagSpec("print_complete", 1, lambda b: b[0], "✓ arrives ~2.4s after the raster"), + 0x11: TagSpec("hardware_version", 3, _version, "✓ HARDWARE_VERSION reply; 01 00 03 -> 1.0.3"), 0x15: TagSpec("consumable_remaining", 3, None, "ribbon / RFID / carbon belt"), 0x16: TagSpec( "reset_paper_ok", 4, None, "✓ VERIFY_PAPER ack; the vendor app discards these 4 bytes" ), 0x17: TagSpec("bt_chip_type", 1, lambda b: b[0], "✓"), 0x20: TagSpec("unknown_20", 1), + 0x2D: TagSpec("sensor_info", 13, None, "✓ SENSOR_INFO reply; field layout not decoded"), + 0x2F: TagSpec("voltage_v", 2, _decivolts, "✓ VOLTAGE reply; big-endian, 10mV units"), 0x31: TagSpec("rfid_number", 3), 0x35: TagSpec("charging", 1, lambda b: b[0] == 2), 0x3B: TagSpec( @@ -335,6 +348,46 @@ def paper_ok(self) -> bool | None: p = self.paper return None if p is None else p.ok + @property + def voltage_v(self) -> float | None: + """Battery terminal voltage. More useful than ``battery_pct`` while charging.""" + v = self.values.get("voltage_v") + return v if isinstance(v, float) else None + + @property + def material_error(self) -> int | None: + """The ``0x3F`` consumable/material code. Non-zero is a fault.""" + v = self.values.get("material_error") + return v if isinstance(v, int) else None + + @property + def print_cancelled(self) -> bool: + """Whether the printer reported ``0x0B``. + + Observed when a raster is wider than the head: the D30 refuses the job rather + than printing a truncated label, so this is the difference between "we sent + bytes" and "it declined them". + """ + return "print_cancelled" in self.values + + def fault(self) -> str | None: + """What the printer is complaining about, or ``None`` if nothing. + + Only reports what it actually told us. A printer that has said nothing yields + ``None`` here, which is not a clean bill of health -- silence and health are + different states, and ``paper_ok`` stays ``None`` to keep them apart. + """ + problems = [] + paper = self.paper + if paper is not None and not paper.ok: + problems.append(f"media not ready ({paper})") + material = self.material_error + if material: + problems.append(f"material error 0x{material:02x}") + if self.print_cancelled: + problems.append("printer cancelled the print") + return "; ".join(problems) or None + def summary(self) -> str: parts = [f"{self.acks} acks"] if self.serial: @@ -343,6 +396,8 @@ def summary(self) -> str: parts.append(f"fw={self.firmware}") if self.battery_pct is not None: parts.append(f"battery={self.battery_pct}%") + if self.voltage_v is not None: + parts.append(f"{self.voltage_v:.2f}V") if self.paper is not None: parts.append(str(self.paper)) if self.prints_completed: @@ -350,7 +405,7 @@ def summary(self) -> str: extra = [ f"{k}={v}" for k, v in sorted(self.values.items()) - if k not in {"serial", "firmware", "battery", "paper_state"} + if k not in {"serial", "firmware", "battery", "paper_state", "voltage_v"} ] if extra: parts.append("[" + ", ".join(extra) + "]") diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index a16db41..640e6dc 100644 --- a/tests/test_agent_config.py +++ b/tests/test_agent_config.py @@ -32,3 +32,25 @@ def test_env_overrides_toml(tmp_path, monkeypatch): cfg = load(toml) assert cfg.mqtt.host == "from-file" # untouched keys still come from the file assert cfg.mqtt.password == "from-env" # secret arrives from the environment + + +def test_density_defaults_to_light_and_reaches_the_driver(): + """The agent had no density knob at all, so D30Config's medium default always won.""" + from labelfab.agent.__main__ import make_printer_factory + + cfg = Config() + assert cfg.device.density == 1 # light + + cfg.device.transport = "fake" + printer = make_printer_factory(cfg)() + assert printer.config.density == 1 + + +def test_an_unknown_density_is_rejected_at_load(): + import pytest + from pydantic import ValidationError + + from labelfab.agent.config import DeviceSection + + with pytest.raises(ValidationError, match="not one of"): + DeviceSection(density=3) diff --git a/tests/test_device.py b/tests/test_device.py index 9c50bd2..680649b 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -100,8 +100,7 @@ def test_init_sequence_content_is_pinned(): printer, transport, _ = _printer(inter_packet_delay_s=0) printer.connect() assert transport.buf.hex() == ( - "1f11381f11121f11131f11091f11111f11191f1107" - "1f11081f110e1f110a1f110202" + "1f11381f11121f11131f11091f11111f11191f11071f11081f110e1f110a1f110202" ) @@ -422,3 +421,43 @@ def test_media_error_is_visible_through_the_driver(): assert printer.paper_ok is False transport.inject(bytes.fromhex("1a0689")) assert printer.paper_ok is True + + +def _printer_that_answers_after(seconds: float, margin: float): + """A printer whose ``0x0F`` lands ``seconds`` into the post-print wait.""" + transport = FakeTransport() + elapsed = 0.0 + printer = PhomemoD30( + transport, + D30Config(pace_factor=0.0, post_print_margin_s=margin), + sleep=lambda s: _advance(s), + ) + + def _advance(s: float) -> None: + nonlocal elapsed + elapsed += s + if elapsed >= seconds and printer.feedback.prints_completed == 0: + transport.inject(bytes.fromhex("1a0f0c")) # print_complete, as captured + + printer.connect() + transport.inject(bytes.fromhex("1a0689")) # a live link, so the wait polls + return printer + + +def test_completion_wait_covers_the_printers_real_reporting_latency(): + """``0x0F`` lands ~3s after the raster on fw 2.1.2, not within the head's line rate. + + A 200-line label computes to 0.42s of printing, so the budget is almost entirely + ``post_print_margin_s``. If that is short, the wait gives up before the printer + ever answers and silently degrades to the duration guess it exists to replace. + """ + printer = _printer_that_answers_after(3.0, margin=3.5) + printer.print_raster(printer.self_test(96, 200)) + assert printer.feedback.prints_completed == 1 + + +def test_the_old_margin_would_have_missed_it(): + """Pins the regression: 0.3s of slack cannot see a 3s reply.""" + printer = _printer_that_answers_after(3.0, margin=0.3) + printer.print_raster(printer.self_test(96, 200)) + assert printer.feedback.prints_completed == 0 diff --git a/tests/test_feedback.py b/tests/test_feedback.py index 237c01c..28d81f3 100644 --- a/tests/test_feedback.py +++ b/tests/test_feedback.py @@ -175,3 +175,55 @@ def test_leading_garbage_is_resynced(): p = StatusParser() frames = p.feed(bytes.fromhex("ffff") + bytes.fromhex("1a0458")) assert [f.name for f in frames] == ["battery"] + + +def test_voltage_decodes_to_volts(): + """``0x2F`` is big-endian in 10mV units -- observed 4.16/4.17V on a charging unit.""" + p = StatusParser() + (frame,) = p.feed(bytes.fromhex("1a2f01a1")) + assert frame.name == "voltage_v" + assert frame.value == 4.17 + assert not p.unknown_tags + + +def test_voltage_sensor_and_hardware_tags_are_no_longer_unknown(): + """All three were seen live on fw 2.1.2 and used to cost a warning apiece.""" + p = StatusParser() + frames = p.feed(bytes.fromhex("1a2f01a01a110100031a2d0200000000000000e600000000")) + assert [f.name for f in frames] == ["voltage_v", "hardware_version", "sensor_info"] + assert frames[1].value == "1.0.3" + assert not p.unknown_tags + + +def test_fault_is_none_when_the_printer_has_said_nothing(): + """Silence is not health, so it must not read as a fault either.""" + fb = DeviceFeedback() + assert fb.fault() is None + assert fb.paper_ok is None + + +def test_media_bit_clear_is_a_fault(): + fb = DeviceFeedback() + fb.ingest(bytes.fromhex("1a0688")) # bit0 clear + assert fb.paper_ok is False + assert "media not ready" in (fb.fault() or "") + + +def test_media_fault_clears_when_the_stripe_goes_back_in(): + """Observed live: 0x89 -> 0x88 on pulling the stripe, 0x89 again on replacing it.""" + fb = DeviceFeedback() + fb.ingest(bytes.fromhex("1a0688")) + assert fb.fault() is not None + fb.ingest(bytes.fromhex("1a0689")) + assert fb.paper_ok is True + assert fb.fault() is None + + +def test_material_error_and_cancellation_are_faults(): + fb = DeviceFeedback() + fb.ingest(bytes.fromhex("1a3f02")) + assert "material error 0x02" in (fb.fault() or "") + fb2 = DeviceFeedback() + fb2.ingest(bytes.fromhex("1a0b01")) + assert fb2.print_cancelled + assert "cancelled" in (fb2.fault() or "") diff --git a/tests/test_worker.py b/tests/test_worker.py index 7e7974a..2bb4047 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -2,9 +2,11 @@ from __future__ import annotations +import pytest from conftest import make_job from labelfab.device import INIT_PACKETS, print_preamble +from labelfab.device.escpos import GS_V0 def _first_body_byte() -> int: @@ -157,3 +159,111 @@ def test_a_transient_connect_failure_still_gets_every_attempt(harness): attempts = _counting_factory(h, D30ConnectError("printer is asleep")) h.submit(make_job("j", n_labels=1, flush=True)) assert len(attempts) == h.worker.max_attempts + + +def _reporting_factory(harness, *frames: str, media: str = "1a0689"): + """A printer that volunteers real status frames on connect, as the D30 does.""" + from labelfab.device import FakeTransport, PhomemoD30 + from labelfab.device.d30 import D30Config + + class _Reporting(FakeTransport): + def open(self) -> None: + super().open() + self.inject(bytes.fromhex("1a08" + b"Q223P4C31420105".hex())) + self.inject(bytes.fromhex("1a07020102")) + self.inject(bytes.fromhex("1a0464")) + self.inject(bytes.fromhex("1a2f01a1")) + self.inject(bytes.fromhex(media)) + for extra in frames: + self.inject(bytes.fromhex(extra)) + + def factory() -> PhomemoD30: + transport = _Reporting(fail_after_bytes=harness.fail_after_bytes) + harness.transports.append(transport) + return PhomemoD30(transport, D30Config(pace_factor=0.0), sleep=lambda _s: None) + + harness.worker.printer_factory = factory + + +def test_status_carries_what_the_printer_reported(harness): + """InvenTree should learn firmware/battery/voltage/media, not just idle-vs-printing.""" + h = harness() + _reporting_factory(h) + h.submit(make_job("j", n_labels=1, flush=True)) + + status = h.publisher.statuses[-1] + assert status.state == "idle" + assert status.serial == "Q223P4C31420105" + assert status.firmware == "2.1.2" + assert status.battery_pct == 100 + assert status.voltage_v == 4.17 + assert status.media_ok is True + assert status.error is None + + +def test_a_media_fault_publishes_error_not_idle(harness): + """A printed batch with the media bit clear must not settle as healthy.""" + h = harness() + _reporting_factory(h, media="1a0688") # bit0 clear + h.submit(make_job("j", n_labels=1, flush=True)) + + status = h.publisher.statuses[-1] + assert status.state == "error" + assert status.media_ok is False + assert "media not ready" in (status.error or "") + + +def test_a_fault_clears_on_the_next_good_batch(harness): + """Otherwise a transient media error latches and the printer looks broken forever.""" + h = harness() + _reporting_factory(h, media="1a0688") + h.submit(make_job("a", n_labels=1, flush=True)) + assert h.publisher.statuses[-1].state == "error" + + _reporting_factory(h, media="1a0689") + h.submit(make_job("b", idempotency_key="b", n_labels=1, flush=True)) + settled = h.publisher.statuses[-1] + assert settled.state == "idle" + assert settled.error is None + assert settled.media_ok is True + + +def test_a_fault_is_captured_even_when_the_print_fails(harness): + """The fault is usually *why* it failed, so closing without reading it loses it.""" + h = harness() + h.fail_after_bytes = 40 # die mid-frame + _reporting_factory(h, media="1a0688") + h.submit(make_job("j", n_labels=1, flush=True)) + + status = h.publisher.statuses[-1] + assert status.state == "error" + assert "media not ready" in (status.error or "") + + +def test_render_is_capped_at_the_print_head(harness): + """15mm tape with a 96-dot head must not render 120px: the printer refuses it. + + Verified on hardware -- a 120px raster came back print_cancelled (0x0B) and printed + nothing, while the same label at 96px printed. device.raster_width_px was declared + and used nowhere, so the shipped 15mm default cancelled every job. + """ + from labelfab.contract import PX_PER_MM + + h = harness() + h.config.tape.width_mm = 15.0 + h.config.device.raster_width_px = 96 + assert h.worker._loaded_tape().width_mm == pytest.approx(96 / PX_PER_MM) + + h.submit(make_job("j", n_labels=1, flush=True)) + body = bytes(h.last_transport.buf) + # GS v 0 then xL xH: bytes per line must be the head's 12, not the tape's 15. + idx = body.index(GS_V0) + len(GS_V0) + assert body[idx] == 12, f"raster is {body[idx]} bytes/line, head is 12" + + +def test_narrower_tape_than_the_head_is_left_alone(harness): + """12mm head, 6mm tape: cap must not widen anything.""" + h = harness() + h.config.tape.width_mm = 6.0 + h.config.device.raster_width_px = 96 + assert h.worker._loaded_tape().width_mm == 6.0 From da22e042e179f96d757178c8a104e95a799a5e96 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Thu, 30 Jul 2026 01:55:15 +0200 Subject: [PATCH 2/2] docs(feedback): note that only paper_state is re-queried on connect 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. --- src/labelfab/device/responses.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/labelfab/device/responses.py b/src/labelfab/device/responses.py index f20b57b..39d2d34 100644 --- a/src/labelfab/device/responses.py +++ b/src/labelfab/device/responses.py @@ -376,6 +376,15 @@ def fault(self) -> str | None: Only reports what it actually told us. A printer that has said nothing yields ``None`` here, which is not a clean bill of health -- silence and health are different states, and ``paper_ok`` stays ``None`` to keep them apart. + + Known asymmetry between the three: ``paper_state`` is re-queried on every + connect (it is in the vendor session set), but ``material_error`` and + ``print_cancelled`` only ever arrive unsolicited. Nothing can poll them -- + ``ALL_ERROR`` (``1f1128``) is the opcode that would, and it was verified inert + on fw 2.1.2. So a material fault raised on one connection is not re-asserted on + the next unless the printer volunteers it again, and the status falls back to + whatever ``paper_state`` says. Worth knowing before trusting a clean fault() as + proof the consumable is fine. """ problems = [] paper = self.paper