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
81 changes: 81 additions & 0 deletions HARDWARE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <bdaddr>` **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.
9 changes: 6 additions & 3 deletions deploy/agent.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,19 @@ 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.
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]
Expand Down
1 change: 1 addition & 0 deletions src/labelfab/agent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 14 additions & 1 deletion src/labelfab/agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@
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,
SettingsConfigDict,
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.
Expand Down Expand Up @@ -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.
Expand Down
74 changes: 69 additions & 5 deletions src/labelfab/agent/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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 -------------------------------------------------- #

Expand Down Expand Up @@ -419,14 +469,28 @@ 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(
printer_id=self.config.agent.printer_id,
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,
)
)
12 changes: 12 additions & 0 deletions src/labelfab/contract/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions src/labelfab/device/d30.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/labelfab/device/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading