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..39d2d34 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,55 @@ 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. + + 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 + 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 +405,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 +414,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