From f2fd08020f24e9808a69b6f3ec81d17b3944880b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 15:24:58 +0000 Subject: [PATCH 01/12] docs(designs): add First-Print pipeline design Design for the first end-to-end print pipeline targeting the Brother PT-P750W. Introduces the PrinterBackend Protocol, the PTouchBackend adapter wrapping the ptouch library, the PTP750W driver/bridge combo, a PrintService orchestrator, and the POST /print + GET /jobs/{id} REST surface. Includes error hierarchy, HTTP status mapping, retry policy, testing strategy (unit + integration via mock backend + manual hardware smoke), and explicit acceptance criteria. Marked as Draft pending review. Refs #22 --- docs/designs/2026-05-15-first-print.md | 403 +++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 docs/designs/2026-05-15-first-print.md diff --git a/docs/designs/2026-05-15-first-print.md b/docs/designs/2026-05-15-first-print.md new file mode 100644 index 0000000..c5c3623 --- /dev/null +++ b/docs/designs/2026-05-15-first-print.md @@ -0,0 +1,403 @@ +# First-Print Pipeline Design + +- **Status:** Draft (brainstorming complete 2026-05-15) +- **Tracking issue:** #22 (master) +- **Branch:** `feat/first-print-design` + +## Goal + +End-to-end pipeline from REST endpoint to a physical print on a Brother PT-P750W. After this phase the hub can: + +- accept a print request via `POST /print`, +- resolve a template (either via an integration plugin lookup OR with raw payload data), +- render the label, +- enqueue the job in the existing `PrintQueue` and process it asynchronously, +- physically print on a network-reachable PT-P750W, +- expose status via `GET /jobs/{job_id}` for polling. + +**Definition of Done:** A manual smoke test (`backend/scripts/smoke_first_print.py`) prints a QR-only label successfully on real hardware. + +## Scope + +In scope: + +- `PrinterBackend` Protocol as the extension point for hardware adapters. +- `PTouchBackend` as the first concrete adapter, wrapping the `ptouch` library. +- `PTP750WDriver` (PrinterModel, see ADR 0004) and `PTP750WPrinter` (bridge to PrintQueue's `_PrinterLike` Protocol). +- `PrintService` orchestrating lookup → render → enqueue. +- REST endpoints `POST /print` and `GET /jobs/{job_id}`. +- App lifespan initialization with backend selection from settings. +- A mock backend (under `tests/`) for unit and integration tests. +- A manual hardware smoke script against a real PT-P750W. + +Out of scope (deferred to later phases): + +- SQLite persistence for jobs (Phase 5). +- Multiple printer instances and routing between them. +- Web UI and template editor (Phase 7). +- Cross-job auto-retry. +- `brother-ql` backend for the QL series. + +## Architecture + +```mermaid +flowchart LR + Client[Client] + API[FastAPI /print] + PS[PrintService] + AL[AppLookupService] + REG[IntegrationRegistry] + LR[LabelRenderer] + TL[TemplateLoader] + PQ[PrintQueue] + PR[PTP750WPrinter] + DR[PTP750WDriver] + BE[PrinterBackend] + HW[(PT-P750W)] + + Client -->|POST /print| API + API --> PS + PS --> AL --> REG + PS --> TL + PS --> LR + PS -->|enqueue| PQ + PQ -->|worker| PR + PR --> DR + PR --> BE + BE -->|raster bytes| HW + Client -->|GET /jobs/id| API +``` + +### Component map + +| Component | File | Responsibility | +|---|---|---| +| `PrinterBackend` Protocol | `app/printer_backends/base.py` | Transport + encoding contract: `print_image`, `send_bytes`, `query_status` | +| `PTouchBackend` | `app/printer_backends/ptouch_backend.py` | Wraps the `ptouch` library; synchronous I/O is dispatched via `asyncio.to_thread` | +| `MockPrinterBackend` | `tests/_fakes/mock_backend.py` | Test double, no network I/O | +| Exceptions | `app/printer_backends/exceptions.py` | `PrinterError` hierarchy | +| `PTP750WDriver` | `app/printer_models/ptp750w.py` | PrinterModel driver, holds model-specific constants | +| `PTP750WPrinter` | `app/services/printers.py` | Bridge: PrinterModel + Backend → PrintQueue's `_PrinterLike` | +| `PrintService` | `app/services/print_service.py` | Use-case orchestrator | +| REST routes | `app/api/routes/print.py` | `POST /print`, `GET /jobs/{id}`, exception mapping | +| Lifespan init | `app/main.py` | Backend selection, queue start/stop | +| Settings | `app/config.py` | `printer_backend`, `printer_pt_host`, ... | + +## Backend Protocol + +### Contract + +```python +@runtime_checkable +class PrinterBackend(Protocol): + backend_id: str + host: str + + async def print_image( + self, + image: Image.Image, + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + ) -> None: ... + + async def send_bytes(self, raster: bytes) -> None: ... + + async def query_status(self) -> StatusBlock: ... +``` + +**Hybrid-API rationale:** + +- `print_image` is the high-level path — the caller hands in a PIL image plus a `TapeSpec` and the backend encodes and sends. +- `send_bytes` is the escape hatch for future raw raster experiments (template editor, power users). The caller is responsible for validation. +- `query_status` is the cheap pre-print check and health probe. + +### `PTouchBackend` implementation + +- Constructor takes `host: str` and a `ptouch.printers.*` class (default `PT_P750W`). +- All `ptouch` calls are synchronous and dispatched via `asyncio.to_thread`. +- `query_status` parses the ptouch status block into our `StatusBlock` dataclass. +- `print_image` validates against the cached status (see Error handling) and calls `printer.print(label, auto_cut=..., high_resolution=...)`. +- `send_bytes` opens a raw TCP connection to `host:9100`, writes the bytes, and closes. + +### `PTP750WDriver` (PrinterModel) + +- `model_id = "PT-P750W"`, `dpi=(180, 180)`, `print_head_pins=128`. +- `width_to_pixels(tape_spec)` returns `tape_spec.print_area_pins`. +- `build_print_job` raises `NotImplementedError` — encoding is done inside `ptouch`, not in the driver. +- `query_status(host="", port=9100, timeout_s=5.0)` delegates to `self._backend.query_status()`. The `host` argument is ignored because the backend is already bound to a connection. + +### `PTP750WPrinter` (bridge to PrintQueue) + +Adapter that combines PrinterModel + backend into the shape `PrintQueue._PrinterLike` expects (`async def print_image(image, *, tape_mm, **options)`): + +```python +class PTP750WPrinter: + printer_id: str # "PT-P750W@" + + async def print_image(self, image, *, tape_mm, **options): + tape_spec = self._tape_registry.for_pt_series(tape_mm) + await self._backend.print_image( + image, tape_spec, + auto_cut=options.get("auto_cut", True), + high_resolution=options.get("high_resolution", False), + ) +``` + +## Data Flow + +### POST /print (async + job ID) + +1. Client sends a `PrintRequest` (template ID + either `lookup` OR `data` + options). +2. The API calls `PrintService.submit_print_job(request)`. +3. PrintService loads the template via `TemplateLoader.get(template_id)`. On miss → `TemplateNotFoundError` (404, synchronous). +4. PrintService resolves `LabelData`: + - When `lookup` is set: `AppLookupService.lookup(app, identifier)` → `LabelData`. On failure → `LookupFailedError` (502, synchronous). + - When `data` is set: `LabelData.from_dict(data)`. +5. PrintService calls `LabelRenderer.render(template, label_data)` → PIL image. +6. PrintService calls `PrintQueue.enqueue(image, tape_mm=template.tape_mm, options=...)` → `job_id`. +7. The API responds `202 {job_id, status: "queued"}`. +8. The queue worker dequeues (existing FSM) and calls `PTP750WPrinter.print_image(...)`. +9. The backend runs pre-print validation and prints. +10. Job status transitions: `queued → running → done` (or `→ failed` with `error_code`). + +### GET /jobs/{job_id} + +- Lookup in the in-memory job store of `PrintQueue`. +- 404 when the job ID does not exist (or the TTL has expired). +- Response contains `status`, `error_code`, `error_message`, `error_detail`, timestamps. + +### Persistence + +In-memory store with a 5-minute TTL (existing). No database in this phase. + +## REST Schemas + +```python +class PrintLookupRequest(BaseModel): + app: str + identifier: str + +class PrintOptions(BaseModel): + copies: int = Field(1, ge=1, le=10) + auto_cut: bool = True + high_resolution: bool = False + +class PrintRequest(BaseModel): + template_id: str + lookup: PrintLookupRequest | None = None + data: dict[str, str] | None = None + options: PrintOptions = PrintOptions() + + @model_validator(mode="after") + def _exactly_one_source(self) -> Self: + if (self.lookup is None) == (self.data is None): + raise ValueError("Exactly one of `lookup` or `data` must be set.") + return self + +class PrintJobResponse(BaseModel): + job_id: str + status: Literal["queued"] + +class PrintJobStatusResponse(BaseModel): + job_id: str + status: Literal["queued", "running", "done", "failed"] + error_code: str | None = None + error_message: str | None = None + error_detail: dict[str, Any] | None = None + created_at: datetime + started_at: datetime | None = None + finished_at: datetime | None = None +``` + +## App Lifespan + +```python +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + settings = get_settings() + + TemplateLoader.load_dir(_SEED_TEMPLATES_DIR) # already from Phase 4 + + backend = _build_backend(settings) + driver = PTP750WDriver(backend=backend) + printer = PTP750WPrinter(driver=driver, backend=backend, tape_registry=tape_registry) + queue = PrintQueue(printer=printer, max_concurrency=settings.printer_max_concurrency) + await queue.start() + + app.state.print_queue = queue + app.state.print_service = PrintService( + template_loader=TemplateLoader, + renderer=LabelRenderer(), + print_queue=queue, + integration_registry=IntegrationRegistry, + ) + + try: + yield + finally: + await queue.stop(timeout_s=settings.printer_queue_timeout_s) + + +def _build_backend(settings: Settings) -> PrinterBackend: + if settings.printer_backend == "mock": + # Import locally so production deployments without test deps still start + from tests._fakes.mock_backend import MockPrinterBackend + return MockPrinterBackend() + if settings.printer_backend == "ptouch": + if not settings.printer_pt_host: + raise ConfigurationError("printer_pt_host required for printer_backend=ptouch") + return PTouchBackend( + host=settings.printer_pt_host, + ptouch_printer_cls=_resolve_ptouch_model(settings.printer_pt_model), + ) + raise ConfigurationError(f"unknown printer_backend: {settings.printer_backend}") +``` + +Open question: the mock backend currently lives under `tests/_fakes/`. If we want it to be usable for local development without hardware, we should consider moving it to `app/printer_backends/mock_backend.py` instead and feature-flagging it via settings. To be decided during the implementation plan. + +## Settings + +```python +class Settings(BaseSettings): + # ...existing fields... + printer_backend: Literal["ptouch", "mock"] = "ptouch" + printer_pt_host: str | None = None + printer_pt_model: str = "PT-P750W" + printer_max_concurrency: int = 1 + printer_queue_timeout_s: float = 30.0 +``` + +The env-var prefix `PRINTER_HUB_` is already established. Example: `PRINTER_HUB_PRINTER_PT_HOST=`. + +## Error Handling + +### Exception hierarchy + +```python +class PrinterError(Exception): ... +class PrinterOfflineError(PrinterError): ... +class TapeMismatchError(PrinterError): + expected_mm: int + loaded_mm: int | None +class TapeEmptyError(PrinterError): ... +class PrinterCoverOpenError(PrinterError): ... +class PrintFailedError(PrinterError): ... +class StatusQueryFailedError(PrinterError): ... +``` + +Plus `TemplateNotFoundError` and `LookupFailedError` on the lookup/template side. + +### Pre-print validation (inside `PTouchBackend.print_image`) + +1. `query_status()` with retry/back-off. On final network failure → `PrinterOfflineError`. +2. Check hardware state: `tape_empty`, `cover_open`, `error_flags` → specific exception. +3. Tape match: `status.loaded_tape_mm == tape_spec.tape_mm`. Otherwise → `TapeMismatchError`. +4. Validate image dimensions against `tape_spec.print_area_pins`. Otherwise → `PrintFailedError` with an explanatory message. +5. Synchronous ptouch print dispatched via `asyncio.to_thread`. + +### HTTP status mapping + +| Exception | HTTP | `error_code` | +|---|---|---| +| `TemplateNotFoundError` | 404 | `template_not_found` | +| `LookupFailedError` | 502 | `integration_lookup_failed` | +| `TapeMismatchError` | 409 | `tape_mismatch` | +| `TapeEmptyError` | 409 | `tape_empty` | +| `PrinterCoverOpenError` | 409 | `printer_cover_open` | +| `PrinterOfflineError` | 503 | `printer_offline` | +| `StatusQueryFailedError` | 503 | `printer_status_unavailable` | +| `PrintFailedError` | 500 | `print_failed` | + +**Important:** `TemplateNotFoundError` and `LookupFailedError` are mapped to HTTP errors **synchronously** in the POST handler (they happen before the enqueue). All other errors come from the worker and end up in the job record (`status="failed"`). + +### Retry policy + +- `query_status` retries 3 times with back-off (0s, 1s, 2s) on `socket.timeout` / `OSError`. +- No cross-job auto-retries. If a job fails, the user fixes the cause and posts again. + +### Logging + +- `INFO` on each job state transition. +- `WARNING` on retry attempts. +- `ERROR` with `exc_info=True` on failed outcomes. +- `DEBUG` (opt-in) for raw bytes. + +## Testing Strategy + +### Test pyramid + +- **Unit (pure):** exception hierarchy, settings validation, Pydantic validators. +- **Unit (with mocks):** backend, driver, bridge, `PrintService`. +- **Integration:** FastAPI `AsyncClient` + `MockPrinterBackend`, end-to-end via `POST /print` → `GET /jobs/{id}`. +- **Hardware smoke (manual):** `scripts/smoke_first_print.py` against a real PT-P750W. + +### Unit tests + +| File | Focus | +|---|---| +| `tests/printer_backends/test_exceptions.py` | Hierarchy, `TapeMismatchError` fields accessible | +| `tests/printer_backends/test_ptouch_backend.py` | ptouch via monkeypatch; all error paths; retry back-off | +| `tests/printer_models/test_ptp750w_driver.py` | Tape→pixel mapping; `build_print_job` raises | +| `tests/services/test_pt_printer_bridge.py` | Bridge calls backend with correct `TapeSpec`; `_PrinterLike` conformance | +| `tests/services/test_print_service.py` | Lookup/render/enqueue order; raw `data` path bypasses integration | +| `tests/api/test_print_routes.py` | 202 with `job_id`; XOR validation; `GET /jobs/{id}` for all statuses | +| `tests/test_lifespan.py` | Mock backend starts; `queue.stop()` runs in `finally` | +| `tests/test_settings_printer.py` | `printer_pt_host` required when `printer_backend=ptouch` | + +### Integration tests + +`tests/api/test_print_e2e.py` with scenarios: + +- Happy path (raw data) → `done`; mock backend received exactly one image with correct dimensions. +- Tape mismatch (mock `loaded_tape_mm=12`, template `tape_mm=24`) → `failed`, `error_code=tape_mismatch`. +- Offline (mock `offline=True`) → `failed` after 3 retries, `error_code=printer_offline`. +- Template not found → 404 synchronous. +- Lookup failure → 502 synchronous. +- Cover open / tape empty → `failed` with the matching `error_code`. + +### Hardware smoke + +`backend/scripts/smoke_first_print.py` — starts the lifespan in-process, prints `qr-only-24mm` with `primary_id="SMOKE-001"`, validates success. Not in CI. Run manually at phase close. + +Two additional manual scenarios: + +1. Swap the tape mid-job → verify `tape_mismatch`. +2. Power off the printer mid-job → verify `printer_offline` + retry. + +### CI gates + +- `ruff check .` and `ruff format --check .` (both). +- `mypy --strict app`. +- `pytest --cov=app --cov-fail-under=80`. + +## Acceptance Criteria + +1. `POST /print` with `template_id="qr-only-24mm"` and `data={"primary_id": "X"}` returns 202 with a `job_id`. +2. `GET /jobs/{job_id}` returns statuses in sequence: `queued`, `running`, `done`. +3. The mock backend received exactly the expected image (dimensions match the `TapeSpec`). +4. Tape mismatch ends as `failed` with the correct `error_code` and `error_detail`. +5. Printer offline ends as `failed` after exactly 3 status query attempts. +6. Template-not-found is a synchronous 404; no job record is created. +7. Lookup failure is a synchronous 502; no job record is created. +8. Lifespan shutdown stops the queue within `printer_queue_timeout_s`. +9. Settings without `printer_pt_host` and `printer_backend=ptouch` fail at app start with a clear error. +10. `scripts/smoke_first_print.py` prints successfully on a real PT-P750W (verified manually). +11. Coverage ≥80%; `ruff check`, `ruff format --check`, and `mypy --strict` all green. + +## Open Questions and Risks + +- **ptouch status parsing:** which fields the `ptouch` library exposes from the status block must be verified in plan Phase 1 (read `ptouch.printers.PT_P750W.get_status`). If the fields are insufficient (e.g. no `loaded_tape_mm`), we need a fall-back raw parser following the Brother Raster Command Reference (PT-Series). +- **PT-P750W transport:** the design assumes network TCP. The library may force a specific connection class — the class lookup in `_resolve_ptouch_model` is a plain string map and easy to extend. +- **In-memory TTL drift:** the 5-minute TTL could expire during very long polls. Not blocking for First-Print; will be revisited together with the persistence work in Phase 5. +- **Mock backend location:** see App-Lifespan section above — open decision whether to move the mock backend from `tests/` into `app/` for local dev use without hardware. + +## References + +- ADR 0004 — Plugin architecture for printer models +- ADR 0005 — Print queue is mandatory +- ADR 0011 — OpenAPI as API contract +- `docs/architecture.md` — high-level overview +- Brother Raster Command Reference (PT-Series) — `docs/research/` +- Master tracking issue: #22 From 6c55a844a42f2e6699aeeeeaf41cdf43e7d1f1d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 15:32:25 +0000 Subject: [PATCH 02/12] docs(designs): address Gemini review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move PTP750WPrinter bridge to app/printer_models/pt.py per ADR 0004 - Rename `printer_id` field to `id` to match `_PrinterLike` Protocol in print_queue.py - Use the real TapeRegistry.lookup_pt(width_mm, media_type) signature; introduce per-print `media_type` option with a configurable default - PrintQueue is instantiated with `printers=[...]` (no max_concurrency argument); remove `printer_max_concurrency` from settings — the queue gives each printer its own dedicated worker by design - Move MockPrinterBackend from tests/_fakes/ to app/printer_backends/ so production lifespan never imports from tests; mock stays usable for local dev without hardware - Clarify `send_bytes` uses asyncio.open_connection (non-blocking) Refs #22 --- docs/designs/2026-05-15-first-print.md | 44 +++++++++++++++++++------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/docs/designs/2026-05-15-first-print.md b/docs/designs/2026-05-15-first-print.md index c5c3623..6c2d988 100644 --- a/docs/designs/2026-05-15-first-print.md +++ b/docs/designs/2026-05-15-first-print.md @@ -74,10 +74,10 @@ flowchart LR |---|---|---| | `PrinterBackend` Protocol | `app/printer_backends/base.py` | Transport + encoding contract: `print_image`, `send_bytes`, `query_status` | | `PTouchBackend` | `app/printer_backends/ptouch_backend.py` | Wraps the `ptouch` library; synchronous I/O is dispatched via `asyncio.to_thread` | -| `MockPrinterBackend` | `tests/_fakes/mock_backend.py` | Test double, no network I/O | +| `MockPrinterBackend` | `app/printer_backends/mock_backend.py` | Mock for tests and local dev without hardware | | Exceptions | `app/printer_backends/exceptions.py` | `PrinterError` hierarchy | -| `PTP750WDriver` | `app/printer_models/ptp750w.py` | PrinterModel driver, holds model-specific constants | -| `PTP750WPrinter` | `app/services/printers.py` | Bridge: PrinterModel + Backend → PrintQueue's `_PrinterLike` | +| `PTP750WDriver` | `app/printer_models/pt.py` (extends existing module) | PrinterModel driver, holds model-specific constants | +| `PTP750WPrinter` | `app/printer_models/pt.py` (extends existing module) | Bridge: PrinterModel + Backend → PrintQueue's `_PrinterLike`. Per ADR 0004 model-specific code lives in `printer_models/.py`. | | `PrintService` | `app/services/print_service.py` | Use-case orchestrator | | REST routes | `app/api/routes/print.py` | `POST /print`, `GET /jobs/{id}`, exception mapping | | Lifespan init | `app/main.py` | Backend selection, queue start/stop | @@ -119,7 +119,7 @@ class PrinterBackend(Protocol): - All `ptouch` calls are synchronous and dispatched via `asyncio.to_thread`. - `query_status` parses the ptouch status block into our `StatusBlock` dataclass. - `print_image` validates against the cached status (see Error handling) and calls `printer.print(label, auto_cut=..., high_resolution=...)`. -- `send_bytes` opens a raw TCP connection to `host:9100`, writes the bytes, and closes. +- `send_bytes` opens a raw TCP connection to `host:9100` via `asyncio.open_connection` (so the call is non-blocking inside an `async def`), writes the bytes, and closes. ### `PTP750WDriver` (PrinterModel) @@ -134,10 +134,21 @@ Adapter that combines PrinterModel + backend into the shape `PrintQueue._Printer ```python class PTP750WPrinter: - printer_id: str # "PT-P750W@" + # `_PrinterLike` (print_queue.py) requires the attribute `id: str`. + id: str # e.g. "pt-p750w@" + + def __init__( + self, + driver: PTP750WDriver, + backend: PrinterBackend, + tape_registry: TapeRegistry, + *, + default_media_type: MediaType = MediaType.LAMINATED, + ) -> None: ... async def print_image(self, image, *, tape_mm, **options): - tape_spec = self._tape_registry.for_pt_series(tape_mm) + media_type = options.get("media_type", self._default_media_type) + tape_spec = self._tape_registry.lookup_pt(tape_mm, media_type) await self._backend.print_image( image, tape_spec, auto_cut=options.get("auto_cut", True), @@ -145,6 +156,8 @@ class PTP750WPrinter: ) ``` +**Note on TapeRegistry API:** the existing `TapeRegistry.lookup_pt(width_mm, media_type)` requires an explicit `MediaType` (laminated, non-laminated, etc.). The bridge falls back to a `default_media_type` injected at construction time (typically `MediaType.LAMINATED` because TZe-Tapes dominate PT-Series use), and lets a per-print `options["media_type"]` override it. This avoids silently picking a tape the printer doesn't have loaded. + ## Data Flow ### POST /print (async + job ID) @@ -223,10 +236,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: backend = _build_backend(settings) driver = PTP750WDriver(backend=backend) printer = PTP750WPrinter(driver=driver, backend=backend, tape_registry=tape_registry) - queue = PrintQueue(printer=printer, max_concurrency=settings.printer_max_concurrency) + queue = PrintQueue(printers=[printer]) # matches current __init__ signature await queue.start() app.state.print_queue = queue + app.state.printer_id = printer.id # callers reference this on enqueue() app.state.print_service = PrintService( template_loader=TemplateLoader, renderer=LabelRenderer(), @@ -242,8 +256,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: def _build_backend(settings: Settings) -> PrinterBackend: if settings.printer_backend == "mock": - # Import locally so production deployments without test deps still start - from tests._fakes.mock_backend import MockPrinterBackend + # MockPrinterBackend lives under app/printer_backends/ so this import works + # in production deployments too — it stays useful for local dev without hardware. + from app.printer_backends.mock_backend import MockPrinterBackend return MockPrinterBackend() if settings.printer_backend == "ptouch": if not settings.printer_pt_host: @@ -255,7 +270,11 @@ def _build_backend(settings: Settings) -> PrinterBackend: raise ConfigurationError(f"unknown printer_backend: {settings.printer_backend}") ``` -Open question: the mock backend currently lives under `tests/_fakes/`. If we want it to be usable for local development without hardware, we should consider moving it to `app/printer_backends/mock_backend.py` instead and feature-flagging it via settings. To be decided during the implementation plan. +**Mock backend lives in `app/`, not `tests/`** — the earlier open question is resolved. Rationale: + +- Importing from `tests/` in production code is a maintainability anti-pattern (Gemini-flagged). +- Local dev without real hardware is a real use case (`PRINTER_HUB_PRINTER_BACKEND=mock`), so the mock needs to ship with the application. +- Tests still pick the mock up the same way — just import path moves to `app.printer_backends.mock_backend`. ## Settings @@ -265,12 +284,13 @@ class Settings(BaseSettings): printer_backend: Literal["ptouch", "mock"] = "ptouch" printer_pt_host: str | None = None printer_pt_model: str = "PT-P750W" - printer_max_concurrency: int = 1 printer_queue_timeout_s: float = 30.0 ``` The env-var prefix `PRINTER_HUB_` is already established. Example: `PRINTER_HUB_PRINTER_PT_HOST=`. +Per-printer concurrency is **not** a setting — `PrintQueue` already gives each printer its own dedicated worker (one in flight per printer at a time). Concurrency would only become a knob if a single physical printer could process several jobs in parallel, which Brother PT-Series doesn't. + ## Error Handling ### Exception hierarchy @@ -391,7 +411,7 @@ Two additional manual scenarios: - **ptouch status parsing:** which fields the `ptouch` library exposes from the status block must be verified in plan Phase 1 (read `ptouch.printers.PT_P750W.get_status`). If the fields are insufficient (e.g. no `loaded_tape_mm`), we need a fall-back raw parser following the Brother Raster Command Reference (PT-Series). - **PT-P750W transport:** the design assumes network TCP. The library may force a specific connection class — the class lookup in `_resolve_ptouch_model` is a plain string map and easy to extend. - **In-memory TTL drift:** the 5-minute TTL could expire during very long polls. Not blocking for First-Print; will be revisited together with the persistence work in Phase 5. -- **Mock backend location:** see App-Lifespan section above — open decision whether to move the mock backend from `tests/` into `app/` for local dev use without hardware. +- **Default `MediaType` for the bridge:** `MediaType.LAMINATED` covers the common TZe-Tape case, but the bridge accepts a per-print `options["media_type"]` override. If a request comes in with the wrong media type, the pre-print status check will catch the mismatch via `TapeMismatchError`. ## References From 7fb6bf3432548ea4a9df75d7b43b99a9e1968e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 15:49:47 +0000 Subject: [PATCH 03/12] =?UTF-8?q?docs(designs):=20extensibility=20?= =?UTF-8?q?=E2=80=94=20driver/backend=20registries=20+=20entry=5Fpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make printer extension a day-one property of First-Print rather than a phase-2 retrofit. Concretely: - Driver discovery is plugin-based: ModelRegistry walks the `label_hub.printer_models` entry-points group on app start, the built-in PT-Series driver self-registers, third-party drivers ship as separate pip packages. - Backend discovery mirrors the same pattern with a new BackendRegistry on the `label_hub.printer_backends` group; the built-in `ptouch` and `mock` backends self-register. - Backends expose `from_settings(settings) → PrinterBackend` so the lifespan stays trivial and series-agnostic (no per-backend if-tree). - The bridge to PrintQueue's `_PrinterLike` becomes `Driver.make_queue_printer(...)` — a factory method on the driver, not a separate exported class. Subclassed drivers inherit it for free, the PT-specific `_PTPQueuePrinter` adapter stays private to `pt.py`. - Settings: `printer_model` (series-agnostic string, resolved against ModelRegistry) replaces the PT-only `printer_pt_model` literal. Both `printer_backend` and `printer_model` are plain strings now so third-party plugins can be selected without code change. New "Extensibility" section walks through five concrete extension paths from smallest to largest intervention: subclass-in-series, decorator backend (for vendor-lib quirks), subclass driver (model-specific status/encoding), new series with new backend, and third-party pip package via entry_points. Acceptance criteria + test plan extended for plugin-discovery and unknown-plugin failure modes. Lifecycle hooks (before_print/after_print) are deliberately out of scope for First-Print (YAGNI) — paths 2 and 3 cover every realistic case today. Refs #22 --- docs/designs/2026-05-15-first-print.md | 213 +++++++++++++++++++------ 1 file changed, 168 insertions(+), 45 deletions(-) diff --git a/docs/designs/2026-05-15-first-print.md b/docs/designs/2026-05-15-first-print.md index 6c2d988..7ebc89b 100644 --- a/docs/designs/2026-05-15-first-print.md +++ b/docs/designs/2026-05-15-first-print.md @@ -23,7 +23,7 @@ In scope: - `PrinterBackend` Protocol as the extension point for hardware adapters. - `PTouchBackend` as the first concrete adapter, wrapping the `ptouch` library. -- `PTP750WDriver` (PrinterModel, see ADR 0004) and `PTP750WPrinter` (bridge to PrintQueue's `_PrinterLike` Protocol). +- `PTP750WDriver` (PrinterModel, see ADR 0004) plus a private `_PTPQueuePrinter` bridge it produces via `make_queue_printer(...)` for the PrintQueue's `_PrinterLike` Protocol. - `PrintService` orchestrating lookup → render → enqueue. - REST endpoints `POST /print` and `GET /jobs/{job_id}`. - App lifespan initialization with backend selection from settings. @@ -50,7 +50,7 @@ flowchart LR LR[LabelRenderer] TL[TemplateLoader] PQ[PrintQueue] - PR[PTP750WPrinter] + PR[_PTPQueuePrinter bridge] DR[PTP750WDriver] BE[PrinterBackend] HW[(PT-P750W)] @@ -76,8 +76,9 @@ flowchart LR | `PTouchBackend` | `app/printer_backends/ptouch_backend.py` | Wraps the `ptouch` library; synchronous I/O is dispatched via `asyncio.to_thread` | | `MockPrinterBackend` | `app/printer_backends/mock_backend.py` | Mock for tests and local dev without hardware | | Exceptions | `app/printer_backends/exceptions.py` | `PrinterError` hierarchy | -| `PTP750WDriver` | `app/printer_models/pt.py` (extends existing module) | PrinterModel driver, holds model-specific constants | -| `PTP750WPrinter` | `app/printer_models/pt.py` (extends existing module) | Bridge: PrinterModel + Backend → PrintQueue's `_PrinterLike`. Per ADR 0004 model-specific code lives in `printer_models/.py`. | +| `PTP750WDriver` | `app/printer_models/pt.py` (extends existing module) | PrinterModel driver + `make_queue_printer(...)` factory that returns a `_PrinterLike`. Per ADR 0004 all PT-specific code lives in this file. | +| `ModelRegistry` | `app/printer_models/registry.py` (existing, extended) | Discovers driver plugins via `setuptools entry_points` (group `label_hub.printer_models`); ships with the built-in PT-Series driver registered | +| Backend registry | `app/printer_backends/__init__.py` (new) | Discovers backend plugins via `setuptools entry_points` (group `label_hub.printer_backends`); ships with `ptouch` + `mock` registered | | `PrintService` | `app/services/print_service.py` | Use-case orchestrator | | REST routes | `app/api/routes/print.py` | `POST /print`, `GET /jobs/{id}`, exception mapping | | Lifespan init | `app/main.py` | Backend selection, queue start/stop | @@ -121,30 +122,58 @@ class PrinterBackend(Protocol): - `print_image` validates against the cached status (see Error handling) and calls `printer.print(label, auto_cut=..., high_resolution=...)`. - `send_bytes` opens a raw TCP connection to `host:9100` via `asyncio.open_connection` (so the call is non-blocking inside an `async def`), writes the bytes, and closes. -### `PTP750WDriver` (PrinterModel) +### `PTP750WDriver` (PrinterModel + queue-printer factory) -- `model_id = "PT-P750W"`, `dpi=(180, 180)`, `print_head_pins=128`. -- `width_to_pixels(tape_spec)` returns `tape_spec.print_area_pins`. -- `build_print_job` raises `NotImplementedError` — encoding is done inside `ptouch`, not in the driver. -- `query_status(host="", port=9100, timeout_s=5.0)` delegates to `self._backend.query_status()`. The `host` argument is ignored because the backend is already bound to a connection. - -### `PTP750WPrinter` (bridge to PrintQueue) - -Adapter that combines PrinterModel + backend into the shape `PrintQueue._PrinterLike` expects (`async def print_image(image, *, tape_mm, **options)`): +The driver implements the existing `PrinterModel` Protocol AND provides a factory method that produces a `_PrinterLike` for the queue. Keeping the bridge as a method on the driver means: subclassing the driver automatically inherits the bridge, and `printer_models/pt.py` stays the single home for PT-specific code (ADR 0004). ```python -class PTP750WPrinter: - # `_PrinterLike` (print_queue.py) requires the attribute `id: str`. - id: str # e.g. "pt-p750w@" +class PTP750WDriver: + # PrinterModel attrs + model_id = "PT-P750W" + pjl_signatures = ["PT-P750W"] + snmp_model_oid_value_substr = "PT-P750W" + dpi = (180, 180) + print_head_pins = 128 + + def __init__(self, backend: PrinterBackend) -> None: + self._backend = backend + + # --- PrinterModel methods --- + async def query_status(self, host: str = "", port: int = 9100, timeout_s: float = 5.0): + # host is bound to the backend already; argument kept for Protocol compat + return await self._backend.query_status() + + def width_to_pixels(self, tape_spec: TapeSpec) -> int: + return tape_spec.print_area_pins + + def build_print_job(self, image, tape_spec, auto_cut=True, high_resolution=False) -> bytes: + raise NotImplementedError( + "PT-P750W uses high-level backend.print_image; " + "raw raster encoding stays inside the ptouch library." + ) - def __init__( + # --- queue-printer factory --- + def make_queue_printer( self, - driver: PTP750WDriver, - backend: PrinterBackend, tape_registry: TapeRegistry, *, default_media_type: MediaType = MediaType.LAMINATED, - ) -> None: ... + ) -> "_PTPQueuePrinter": + """Return a `_PrinterLike` bound to this driver + its backend.""" + return _PTPQueuePrinter( + driver=self, + backend=self._backend, + tape_registry=tape_registry, + default_media_type=default_media_type, + ) + + +class _PTPQueuePrinter: + """Internal `_PrinterLike` adapter — not part of the public API. Use the + driver's `make_queue_printer(...)` factory to construct one. + """ + # `_PrinterLike` (print_queue.py) requires `id: str`. + id: str # e.g. "pt-p750w@" async def print_image(self, image, *, tape_mm, **options): media_type = options.get("media_type", self._default_media_type) @@ -156,7 +185,13 @@ class PTP750WPrinter: ) ``` -**Note on TapeRegistry API:** the existing `TapeRegistry.lookup_pt(width_mm, media_type)` requires an explicit `MediaType` (laminated, non-laminated, etc.). The bridge falls back to a `default_media_type` injected at construction time (typically `MediaType.LAMINATED` because TZe-Tapes dominate PT-Series use), and lets a per-print `options["media_type"]` override it. This avoids silently picking a tape the printer doesn't have loaded. +**Why the factory pattern:** + +- A custom driver (e.g. `class PTP710BTDriver(PTP750WDriver)`) inherits `make_queue_printer` for free — no second adapter class to subclass. +- The PT-series adapter (`_PTPQueuePrinter`) is shared across every PT model and stays private to `pt.py`. +- The QL series will mirror this in `ql.py` with its own `_QLQueuePrinter` adapter; no cross-series coupling. + +**TapeRegistry API:** `TapeRegistry.lookup_pt(width_mm, media_type)` requires an explicit `MediaType` (laminated, non-laminated, ...). The PT adapter takes a `default_media_type` from the factory (typically `MediaType.LAMINATED` because TZe-Tapes dominate PT use) and lets a per-print `options["media_type"]` override it. The pre-print status check catches a mismatched physical tape via `TapeMismatchError`. ## Data Flow @@ -171,7 +206,7 @@ class PTP750WPrinter: 5. PrintService calls `LabelRenderer.render(template, label_data)` → PIL image. 6. PrintService calls `PrintQueue.enqueue(image, tape_mm=template.tape_mm, options=...)` → `job_id`. 7. The API responds `202 {job_id, status: "queued"}`. -8. The queue worker dequeues (existing FSM) and calls `PTP750WPrinter.print_image(...)`. +8. The queue worker dequeues (existing FSM) and calls `print_image(...)` on the `_PrinterLike` returned by `driver.make_queue_printer(...)`. 9. The backend runs pre-print validation and prints. 10. Job status transitions: `queued → running → done` (or `→ failed` with `error_code`). @@ -233,9 +268,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: TemplateLoader.load_dir(_SEED_TEMPLATES_DIR) # already from Phase 4 + # Discover all driver + backend plugins via entry_points (built-ins ship pre-registered) + ModelRegistry.ensure_discovered() + BackendRegistry.ensure_discovered() + backend = _build_backend(settings) - driver = PTP750WDriver(backend=backend) - printer = PTP750WPrinter(driver=driver, backend=backend, tape_registry=tape_registry) + driver_cls = ModelRegistry.find_by_model_id(settings.printer_model) + driver = driver_cls(backend=backend) + printer = driver.make_queue_printer(tape_registry) queue = PrintQueue(printers=[printer]) # matches current __init__ signature await queue.start() @@ -255,21 +295,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: def _build_backend(settings: Settings) -> PrinterBackend: - if settings.printer_backend == "mock": - # MockPrinterBackend lives under app/printer_backends/ so this import works - # in production deployments too — it stays useful for local dev without hardware. - from app.printer_backends.mock_backend import MockPrinterBackend - return MockPrinterBackend() - if settings.printer_backend == "ptouch": - if not settings.printer_pt_host: - raise ConfigurationError("printer_pt_host required for printer_backend=ptouch") - return PTouchBackend( - host=settings.printer_pt_host, - ptouch_printer_cls=_resolve_ptouch_model(settings.printer_pt_model), - ) - raise ConfigurationError(f"unknown printer_backend: {settings.printer_backend}") + """Backend factory — discovers backend implementations via entry_points + (group `label_hub.printer_backends`) and instantiates the one named by + `settings.printer_backend`. The built-in `ptouch` and `mock` backends + self-register; third-party backends ship as separate pip packages. + """ + backend_factory = BackendRegistry.find_by_backend_id(settings.printer_backend) + return backend_factory.from_settings(settings) ``` +Each backend factory exposes a tiny `from_settings(settings) → PrinterBackend` class method so the lifespan code stays trivial and series-agnostic. `PTouchBackend.from_settings` checks `printer_pt_host` and looks up the ptouch class for the configured `printer_model`; `MockPrinterBackend.from_settings` ignores host/model and returns a fresh mock. + **Mock backend lives in `app/`, not `tests/`** — the earlier open question is resolved. Rationale: - Importing from `tests/` in production code is a maintainability anti-pattern (Gemini-flagged). @@ -281,16 +317,98 @@ def _build_backend(settings: Settings) -> PrinterBackend: ```python class Settings(BaseSettings): # ...existing fields... - printer_backend: Literal["ptouch", "mock"] = "ptouch" - printer_pt_host: str | None = None - printer_pt_model: str = "PT-P750W" + printer_backend: str = "ptouch" # backend_id; default built-ins: "ptouch", "mock" + printer_model: str = "PT-P750W" # model_id resolved against ModelRegistry + printer_pt_host: str | None = None # required for ptouch backend printer_queue_timeout_s: float = 30.0 ``` The env-var prefix `PRINTER_HUB_` is already established. Example: `PRINTER_HUB_PRINTER_PT_HOST=`. +`printer_backend` and `printer_model` are plain strings (not `Literal[...]`), so a freshly installed third-party plugin can be selected without a code change. Validation happens at app start — if either value does not resolve to a registered plugin, the lifespan fails fast with a clear error. + Per-printer concurrency is **not** a setting — `PrintQueue` already gives each printer its own dedicated worker (one in flight per printer at a time). Concurrency would only become a knob if a single physical printer could process several jobs in parallel, which Brother PT-Series doesn't. +## Extensibility — Adding More Printers Without Core Changes + +The core (PrintQueue, LabelRenderer, TemplateLoader, PrintService, REST routes, both Protocols) does not change when new hardware is added. Five extension paths cover the realistic scenarios, ordered from smallest to largest intervention: + +### Path 1 — New model in the same series (e.g. PT-P900) + +Add a class to `app/printer_models/pt.py`: + +```python +class PTP900Driver(PTP750WDriver): + model_id = "PT-P900" + pjl_signatures = ["PT-P900"] + snmp_model_oid_value_substr = "PT-P900" + dpi = (360, 360) + print_head_pins = 454 +``` + +Register at import time (via the module-level `ModelRegistry.register(PTP900Driver)` call that already exists in `pt.py`). The bridge is inherited via `make_queue_printer`. Users select it with `PRINTER_HUB_PRINTER_MODEL=PT-P900`. **One file, no core change.** + +### Path 2 — Decorator backend (smallest fix for vendor-library bugs) + +When the existing backend is 95% right but one method needs a patch — e.g. PT-P710BT firmware lies about `tape_empty`: + +```python +class QuirkyPTP710BTBackend: + backend_id = "ptouch-p710bt-quirk" + def __init__(self, inner: PTouchBackend) -> None: + self._inner = inner + self.host = inner.host + async def query_status(self) -> StatusBlock: + status = await self._inner.query_status() + if status.tape_empty and self._secondary_check_says_ok(): + status = replace(status, tape_empty=False) + return status + async def print_image(self, image, tape_spec, **kw): + await self._inner.print_image(image, tape_spec, **kw) + async def send_bytes(self, raster): + await self._inner.send_bytes(raster) +``` + +One wrapper class implementing `PrinterBackend` again. `ptouch` library stays untouched, no driver change. + +### Path 3 — Subclass driver (model-specific status / encoding) + +When the anomaly lives in the driver layer (status-block parsing, raster encoding for one model), subclass the closest driver and override the affected method. The bridge factory is inherited; no other changes are needed. + +### Path 4 — New series with its own backend (e.g. QL via `brother-ql`) + +``` +app/printer_models/ql.py (new — QL drivers, QL tape data) +app/printer_backends/brother_ql.py (new — wraps brother-ql library) +``` + +Both register via `ensure_discovered()` at app start. `PrinterBackend` Protocol is unchanged — its contract already covers `brother-ql`. **Core unchanged, two new files.** + +### Path 5 — Third-party driver / backend as a separate pip package + +External package ships its own `pyproject.toml`: + +```toml +[project.entry-points."label_hub.printer_models"] +zebra-zd420 = "label_hub_zebra.driver:ZebraZD420Driver" + +[project.entry-points."label_hub.printer_backends"] +zebra-zpl = "label_hub_zebra.backend:ZebraZPLBackend" +``` + +`pip install label-hub-zebra-driver` → at app start the discovery loop in `app/printer_models/__init__.py` and `app/printer_backends/__init__.py` picks them up automatically. User sets `PRINTER_HUB_PRINTER_MODEL=zebra-zd420` and `PRINTER_HUB_PRINTER_BACKEND=zebra-zpl`. **Zero edits in the core repository.** + +### What this means for First-Print scope + +To enable paths 1, 2, 4 and 5 from day one, First-Print delivers: + +1. `ModelRegistry` is driven by `setuptools entry_points` (group `label_hub.printer_models`); built-in PT driver self-registers. +2. A new `BackendRegistry` mirrors that for backends (group `label_hub.printer_backends`); built-in `ptouch` and `mock` backends self-register. +3. Backends expose `from_settings(settings) → PrinterBackend` so the lifespan stays trivial. +4. Driver exposes `make_queue_printer(...)` so subclasses inherit the bridge. + +Path 3 (subclass driver) needs no extra plumbing — it falls out of the inheritance model. Lifecycle hooks (pre/post-print) are deliberately **out of scope** for First-Print (YAGNI): paths 2 and 3 cover every realistic case today. They can be added to `PrinterModel` later as optional methods with a default no-op if a concrete need arises. + ## Error Handling ### Exception hierarchy @@ -363,8 +481,10 @@ Plus `TemplateNotFoundError` and `LookupFailedError` on the lookup/template side | `tests/services/test_pt_printer_bridge.py` | Bridge calls backend with correct `TapeSpec`; `_PrinterLike` conformance | | `tests/services/test_print_service.py` | Lookup/render/enqueue order; raw `data` path bypasses integration | | `tests/api/test_print_routes.py` | 202 with `job_id`; XOR validation; `GET /jobs/{id}` for all statuses | -| `tests/test_lifespan.py` | Mock backend starts; `queue.stop()` runs in `finally` | -| `tests/test_settings_printer.py` | `printer_pt_host` required when `printer_backend=ptouch` | +| `tests/test_lifespan.py` | Mock backend starts; plugin discovery runs; `queue.stop()` runs in `finally` | +| `tests/test_settings_printer.py` | `printer_pt_host` required when `printer_backend=ptouch`; unknown `printer_model` / `printer_backend` fail fast at startup | +| `tests/printer_models/test_registry.py` | `ModelRegistry.find_by_model_id` returns the right driver; entry-point discovery picks up a fake plugin | +| `tests/printer_backends/test_registry.py` | `BackendRegistry.find_by_backend_id` returns the right backend factory; built-in `ptouch` + `mock` are pre-registered | ### Integration tests @@ -403,15 +523,18 @@ Two additional manual scenarios: 7. Lookup failure is a synchronous 502; no job record is created. 8. Lifespan shutdown stops the queue within `printer_queue_timeout_s`. 9. Settings without `printer_pt_host` and `printer_backend=ptouch` fail at app start with a clear error. -10. `scripts/smoke_first_print.py` prints successfully on a real PT-P750W (verified manually). -11. Coverage ≥80%; `ruff check`, `ruff format --check`, and `mypy --strict` all green. +10. An unknown `printer_model` or `printer_backend` fails at app start with a clear error listing the registered options. +11. A fake driver plugin registered via `setuptools entry_points` is picked up by `ModelRegistry.ensure_discovered()` in a test, demonstrating that third-party drivers work end-to-end without core changes. +12. `scripts/smoke_first_print.py` prints successfully on a real PT-P750W (verified manually). +13. Coverage ≥80%; `ruff check`, `ruff format --check`, and `mypy --strict` all green. ## Open Questions and Risks - **ptouch status parsing:** which fields the `ptouch` library exposes from the status block must be verified in plan Phase 1 (read `ptouch.printers.PT_P750W.get_status`). If the fields are insufficient (e.g. no `loaded_tape_mm`), we need a fall-back raw parser following the Brother Raster Command Reference (PT-Series). -- **PT-P750W transport:** the design assumes network TCP. The library may force a specific connection class — the class lookup in `_resolve_ptouch_model` is a plain string map and easy to extend. +- **PT-P750W transport:** the design assumes network TCP. The ptouch class lookup happens inside `PTouchBackend.from_settings` and is easy to extend if a future model needs a different connection class. - **In-memory TTL drift:** the 5-minute TTL could expire during very long polls. Not blocking for First-Print; will be revisited together with the persistence work in Phase 5. - **Default `MediaType` for the bridge:** `MediaType.LAMINATED` covers the common TZe-Tape case, but the bridge accepts a per-print `options["media_type"]` override. If a request comes in with the wrong media type, the pre-print status check will catch the mismatch via `TapeMismatchError`. +- **Lifecycle hooks (`before_print` / `after_print`):** intentionally **not** part of First-Print. Adding them would extend the `PrinterModel` Protocol once (with no-op defaults). Deferred until a concrete need surfaces — paths 2 and 3 in the Extensibility section cover every realistic case today. ## References From ae96393e423f60b40bb3f42ec928d6500b0edfaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 15:59:26 +0000 Subject: [PATCH 04/12] docs(designs): address Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the existing code; fixes for all eight findings: - PrintQueue API: replace `enqueue(...)` with the real `submit(printer_id, image, tape_mm, **options) → job_id` signature throughout the data flow and acceptance criteria. - Job state names align with the real `JobState` enum (queued / paused / printing / completed / failed / cancelled). Replaces the made-up `running` / `done` states in the data-flow, REST schema, integration tests, and acceptance criteria. - TTL claim corrected: `_jobs` has no eviction today (queue source has a TODO about unbounded growth). Documented as planned for Phase 5 with the practical reasoning why First-Print can ship without it. - `PrintOptions` shared-default-instance anti-pattern fixed: `Field(default_factory=PrintOptions)` plus `frozen=True` on the model. - Raw-data payload schema is now explicit: a dedicated `RawLabelData` Pydantic model mirroring `LabelData`'s shape (title, primary_id, qr_payload, secondary as list-coerced-to-tuple), validated by the framework. `source_app` is set to "manual" by PrintService rather than accepted from the client. - PTP750WDriver Protocol conformance: `build_print_job` is now described as delegating to the ptouch library's internal raster builder (exact entry point to confirm in plan Phase 1, fallback to a raw encoder per Brother Raster Command Reference). `query_status` raises on a non-matching host argument rather than silently ignoring it. - `send_bytes` flow specifies the full lifecycle: write → `drain()` → `close()` → `wait_closed()` to avoid truncated raster streams under backpressure. - Settings: keep the existing `pt750w_host`/`pt750w_port` naming. Add only `printer_backend`, `printer_model`, `printer_queue_timeout_s`. Backend `from_settings` knows which existing host field to read. Refs #22 --- docs/designs/2026-05-15-first-print.md | 101 +++++++++++++++++++------ 1 file changed, 77 insertions(+), 24 deletions(-) diff --git a/docs/designs/2026-05-15-first-print.md b/docs/designs/2026-05-15-first-print.md index 7ebc89b..0bf78e6 100644 --- a/docs/designs/2026-05-15-first-print.md +++ b/docs/designs/2026-05-15-first-print.md @@ -82,7 +82,7 @@ flowchart LR | `PrintService` | `app/services/print_service.py` | Use-case orchestrator | | REST routes | `app/api/routes/print.py` | `POST /print`, `GET /jobs/{id}`, exception mapping | | Lifespan init | `app/main.py` | Backend selection, queue start/stop | -| Settings | `app/config.py` | `printer_backend`, `printer_pt_host`, ... | +| Settings | `app/config.py` | `printer_backend`, `printer_model`, reuses existing `pt750w_host` / `pt750w_port` | ## Backend Protocol @@ -120,7 +120,7 @@ class PrinterBackend(Protocol): - All `ptouch` calls are synchronous and dispatched via `asyncio.to_thread`. - `query_status` parses the ptouch status block into our `StatusBlock` dataclass. - `print_image` validates against the cached status (see Error handling) and calls `printer.print(label, auto_cut=..., high_resolution=...)`. -- `send_bytes` opens a raw TCP connection to `host:9100` via `asyncio.open_connection` (so the call is non-blocking inside an `async def`), writes the bytes, and closes. +- `send_bytes` opens a raw TCP connection to `host:9100` via `asyncio.open_connection`. The implementation sequence is `writer.write(raster)` → `await writer.drain()` (flush pending data and respect backpressure) → `writer.close()` → `await writer.wait_closed()`. Skipping `drain`/`wait_closed` can truncate the raster stream when the kernel buffer is small, so both are mandatory. ### `PTP750WDriver` (PrinterModel + queue-printer factory) @@ -140,17 +140,35 @@ class PTP750WDriver: # --- PrinterModel methods --- async def query_status(self, host: str = "", port: int = 9100, timeout_s: float = 5.0): - # host is bound to the backend already; argument kept for Protocol compat + # The backend is already bound to a host. If the caller passes an explicit + # non-empty host that doesn't match, that's a programmer error — fail loudly + # rather than silently querying a different printer. + if host and host != self._backend.host: + raise ValueError( + f"Driver bound to backend.host={self._backend.host!r}; " + f"got host={host!r}. Construct a new driver/backend pair instead." + ) return await self._backend.query_status() def width_to_pixels(self, tape_spec: TapeSpec) -> int: return tape_spec.print_area_pins def build_print_job(self, image, tape_spec, auto_cut=True, high_resolution=False) -> bytes: - raise NotImplementedError( - "PT-P750W uses high-level backend.print_image; " - "raw raster encoding stays inside the ptouch library." - ) + """Encode an image into the Brother raster byte stream for PT-Series. + + Used by callers that need the raw bytes (raw `send_bytes` path, future + export-to-file, or a backend without an in-library encoder). The + First-Print happy path goes through `backend.print_image()` and does + **not** call this method. + + Implementation: delegates to the ptouch library's internal raster + builder (e.g. `ptouch.label.ImageLabel(image, ...).encode()`). The + exact entry point will be confirmed in implementation Phase 1 — if + ptouch does not expose a public encoder, the fallback is a raw + encoder built from the Brother Raster Command Reference. + """ + # impl details deferred to plan + ... # --- queue-printer factory --- def make_queue_printer( @@ -202,23 +220,25 @@ class _PTPQueuePrinter: 3. PrintService loads the template via `TemplateLoader.get(template_id)`. On miss → `TemplateNotFoundError` (404, synchronous). 4. PrintService resolves `LabelData`: - When `lookup` is set: `AppLookupService.lookup(app, identifier)` → `LabelData`. On failure → `LookupFailedError` (502, synchronous). - - When `data` is set: `LabelData.from_dict(data)`. + - When `data` is set: PrintService constructs `LabelData(**raw.model_dump(), source_app="manual")` from the validated `RawLabelData`. The list-to-tuple coercion for `secondary` happens during the `LabelData` construction (since `LabelData.secondary: tuple[str, ...]`). 5. PrintService calls `LabelRenderer.render(template, label_data)` → PIL image. -6. PrintService calls `PrintQueue.enqueue(image, tape_mm=template.tape_mm, options=...)` → `job_id`. +6. PrintService calls `PrintQueue.submit(printer_id, image, tape_mm=template.tape_mm, **options)` → `job_id`. The `printer_id` is the only printer's `id` from `app.state.printer_id` (First-Print supports one printer; multi-printer routing comes with the persistence work). 7. The API responds `202 {job_id, status: "queued"}`. 8. The queue worker dequeues (existing FSM) and calls `print_image(...)` on the `_PrinterLike` returned by `driver.make_queue_printer(...)`. 9. The backend runs pre-print validation and prints. -10. Job status transitions: `queued → running → done` (or `→ failed` with `error_code`). +10. Job status transitions: `queued → printing → completed` (or `→ failed` with `error_code`). These are the literal `JobState` enum values from `app/services/job_lifecycle.py`. The other enum members (`paused`, `cancelled`) are not produced by the First-Print path but remain visible in `/jobs/{id}` for forward compatibility. ### GET /jobs/{job_id} - Lookup in the in-memory job store of `PrintQueue`. -- 404 when the job ID does not exist (or the TTL has expired). +- 404 when the job ID does not exist (since there is no eviction in First-Print, this is only the case for unknown/typo job IDs; the dict survives until process restart). - Response contains `status`, `error_code`, `error_message`, `error_detail`, timestamps. ### Persistence -In-memory store with a 5-minute TTL (existing). No database in this phase. +In-memory store, no eviction (the current `PrintQueue._jobs` dict keeps every job for the process lifetime — there is a TODO in the queue source about unbounded growth). A bounded job store with TTL or LRU eviction is **planned for Phase 5** alongside SQLite persistence; not part of First-Print. + +For First-Print the practical impact is acceptable: jobs are tiny (a few KB each — PNG payload + metadata), the maintainer's deployment prints maybe dozens of labels per day, and a periodic process restart (e.g. via Watchtower image updates) acts as a coarse eviction in practice. If memory becomes an issue before Phase 5, the simplest stop-gap is a startup-time cap on the dict size with FIFO eviction — kept out of scope here to avoid scope creep. ## REST Schemas @@ -228,15 +248,30 @@ class PrintLookupRequest(BaseModel): identifier: str class PrintOptions(BaseModel): + model_config = ConfigDict(frozen=True) copies: int = Field(1, ge=1, le=10) auto_cut: bool = True high_resolution: bool = False +class RawLabelData(BaseModel): + """Payload shape accepted by the raw-data path. Validated into a + `LabelData` instance inside `PrintService` via `LabelData.model_validate`. + Mirrors `LabelData` field-by-field, but lets the client pass `secondary` + as a JSON array (Pydantic coerces to tuple). + """ + title: str + primary_id: str + qr_payload: str + secondary: list[str] = Field(default_factory=list) + # source_app is set to "manual" by PrintService — not accepted from the client + class PrintRequest(BaseModel): template_id: str lookup: PrintLookupRequest | None = None - data: dict[str, str] | None = None - options: PrintOptions = PrintOptions() + data: RawLabelData | None = None + # Use default_factory so each PrintRequest gets its own PrintOptions instance + # rather than sharing a single mutable default (Pydantic anti-pattern). + options: PrintOptions = Field(default_factory=PrintOptions) @model_validator(mode="after") def _exactly_one_source(self) -> Self: @@ -250,7 +285,7 @@ class PrintJobResponse(BaseModel): class PrintJobStatusResponse(BaseModel): job_id: str - status: Literal["queued", "running", "done", "failed"] + status: JobState # queued | paused | printing | completed | failed | cancelled error_code: str | None = None error_message: str | None = None error_detail: dict[str, Any] | None = None @@ -280,7 +315,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await queue.start() app.state.print_queue = queue - app.state.printer_id = printer.id # callers reference this on enqueue() + app.state.printer_id = printer.id # PrintService passes this to PrintQueue.submit(...) app.state.print_service = PrintService( template_loader=TemplateLoader, renderer=LabelRenderer(), @@ -304,7 +339,7 @@ def _build_backend(settings: Settings) -> PrinterBackend: return backend_factory.from_settings(settings) ``` -Each backend factory exposes a tiny `from_settings(settings) → PrinterBackend` class method so the lifespan code stays trivial and series-agnostic. `PTouchBackend.from_settings` checks `printer_pt_host` and looks up the ptouch class for the configured `printer_model`; `MockPrinterBackend.from_settings` ignores host/model and returns a fresh mock. +Each backend factory exposes a tiny `from_settings(settings) → PrinterBackend` class method so the lifespan code stays trivial and series-agnostic. `PTouchBackend.from_settings` reads `settings.pt750w_host` / `settings.pt750w_port` (existing PT-Series fields) and looks up the ptouch class for the configured `printer_model`; `MockPrinterBackend.from_settings` ignores host/model and returns a fresh mock. **Mock backend lives in `app/`, not `tests/`** — the earlier open question is resolved. Rationale: @@ -314,16 +349,34 @@ Each backend factory exposes a tiny `from_settings(settings) → PrinterBackend` ## Settings +First-Print **reuses the existing per-model fields** in `app/config.py` (e.g. `pt750w_host`, `pt750w_port`, `ql820_host`, `ql820_port`) rather than renaming them. New fields are added on top: + ```python class Settings(BaseSettings): - # ...existing fields... + # --- existing (kept as-is) --- + pt750w_host: str = "" + pt750w_port: int = 9100 + ql820_host: str = "" + ql820_port: int = 9100 + # ... + + # --- NEW for First-Print --- printer_backend: str = "ptouch" # backend_id; default built-ins: "ptouch", "mock" printer_model: str = "PT-P750W" # model_id resolved against ModelRegistry - printer_pt_host: str | None = None # required for ptouch backend printer_queue_timeout_s: float = 30.0 ``` -The env-var prefix `PRINTER_HUB_` is already established. Example: `PRINTER_HUB_PRINTER_PT_HOST=`. +Env-var prefix `PRINTER_HUB_` is established. Examples: + +``` +PRINTER_HUB_PT750W_HOST= # existing +PRINTER_HUB_PRINTER_MODEL=PT-P750W # new +PRINTER_HUB_PRINTER_BACKEND=ptouch # new +``` + +`PTouchBackend.from_settings(settings)` reads `settings.pt750w_host` / `settings.pt750w_port` for PT-Series, similarly `BrotherQLBackend.from_settings` (when QL lands) reads `settings.ql820_*`. The mapping from `printer_model` to the right host field lives inside each backend's `from_settings`; the lifespan does not need to know. + +If multi-printer or multi-instance support becomes a real requirement, the per-model host/port pairs will be refactored into a more general structure (e.g. a list of printer configs). That refactor is deferred — it has no design-shaping impact today. `printer_backend` and `printer_model` are plain strings (not `Literal[...]`), so a freshly installed third-party plugin can be selected without a code change. Validation happens at app start — if either value does not resolve to a registered plugin, the lifespan fails fast with a clear error. @@ -482,7 +535,7 @@ Plus `TemplateNotFoundError` and `LookupFailedError` on the lookup/template side | `tests/services/test_print_service.py` | Lookup/render/enqueue order; raw `data` path bypasses integration | | `tests/api/test_print_routes.py` | 202 with `job_id`; XOR validation; `GET /jobs/{id}` for all statuses | | `tests/test_lifespan.py` | Mock backend starts; plugin discovery runs; `queue.stop()` runs in `finally` | -| `tests/test_settings_printer.py` | `printer_pt_host` required when `printer_backend=ptouch`; unknown `printer_model` / `printer_backend` fail fast at startup | +| `tests/test_settings_printer.py` | Empty `pt750w_host` with `printer_backend=ptouch` + `printer_model=PT-P750W` fails fast; unknown `printer_model` / `printer_backend` fail fast at startup | | `tests/printer_models/test_registry.py` | `ModelRegistry.find_by_model_id` returns the right driver; entry-point discovery picks up a fake plugin | | `tests/printer_backends/test_registry.py` | `BackendRegistry.find_by_backend_id` returns the right backend factory; built-in `ptouch` + `mock` are pre-registered | @@ -490,7 +543,7 @@ Plus `TemplateNotFoundError` and `LookupFailedError` on the lookup/template side `tests/api/test_print_e2e.py` with scenarios: -- Happy path (raw data) → `done`; mock backend received exactly one image with correct dimensions. +- Happy path (raw data) → `completed`; mock backend received exactly one image with correct dimensions. - Tape mismatch (mock `loaded_tape_mm=12`, template `tape_mm=24`) → `failed`, `error_code=tape_mismatch`. - Offline (mock `offline=True`) → `failed` after 3 retries, `error_code=printer_offline`. - Template not found → 404 synchronous. @@ -515,14 +568,14 @@ Two additional manual scenarios: ## Acceptance Criteria 1. `POST /print` with `template_id="qr-only-24mm"` and `data={"primary_id": "X"}` returns 202 with a `job_id`. -2. `GET /jobs/{job_id}` returns statuses in sequence: `queued`, `running`, `done`. +2. `GET /jobs/{job_id}` returns statuses in sequence: `queued`, `printing`, `completed` (the literal `JobState` enum values). 3. The mock backend received exactly the expected image (dimensions match the `TapeSpec`). 4. Tape mismatch ends as `failed` with the correct `error_code` and `error_detail`. 5. Printer offline ends as `failed` after exactly 3 status query attempts. 6. Template-not-found is a synchronous 404; no job record is created. 7. Lookup failure is a synchronous 502; no job record is created. 8. Lifespan shutdown stops the queue within `printer_queue_timeout_s`. -9. Settings without `printer_pt_host` and `printer_backend=ptouch` fail at app start with a clear error. +9. Empty `pt750w_host` with `printer_backend=ptouch` and `printer_model=PT-P750W` fails at app start with a clear error. 10. An unknown `printer_model` or `printer_backend` fails at app start with a clear error listing the registered options. 11. A fake driver plugin registered via `setuptools entry_points` is picked up by `ModelRegistry.ensure_discovered()` in a test, demonstrating that third-party drivers work end-to-end without core changes. 12. `scripts/smoke_first_print.py` prints successfully on a real PT-P750W (verified manually). From 78cdab2d856988a7b906ddaa0d01e02c9fd66454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 16:14:54 +0000 Subject: [PATCH 05/12] docs(designs): address Gemini 7fb6bf3 review (YAGNI on send_bytes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings on the extensibility commit: A — Removed `send_bytes` from `PrinterBackend` Protocol. No concrete caller exists in First-Print, the second TCP path would compete with the ptouch library for the printer's single TCP session (Brother PT hardware limit), and Customization Path 2 (decorator backend) does not need it. The hook can be added back additively if a real use case appears. Decorator-backend example in the Extensibility section trimmed to two methods. B — Resource Busy on parallel TCP/9100 connections: resolved by removing `send_bytes`. A note about the single-session limit is now part of the YAGNI rationale. C — `query_status(host=...)` Protocol mismatch: the current bound- backend driver still raises on a non-matching host (no behavioural change). The cleaner alternative — refactoring the Protocol so `query_status` takes no `host` — is documented in Open Questions as a follow-up PR that touches every PrinterModel call site, out of scope for First-Print. D — `build_print_job` Protocol relaxation: kept the delegation-to- ptouch-encoder approach. The alternative — splitting rasterization into a `RasterizablePrinterModel` sub-protocol — is documented alongside C as a candidate follow-up. Either resolution will work, the decision depends on whether ptouch exposes a public encoder. Refs #22 --- docs/designs/2026-05-15-first-print.md | 35 ++++++++++++++++---------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/designs/2026-05-15-first-print.md b/docs/designs/2026-05-15-first-print.md index 0bf78e6..440df44 100644 --- a/docs/designs/2026-05-15-first-print.md +++ b/docs/designs/2026-05-15-first-print.md @@ -72,7 +72,7 @@ flowchart LR | Component | File | Responsibility | |---|---|---| -| `PrinterBackend` Protocol | `app/printer_backends/base.py` | Transport + encoding contract: `print_image`, `send_bytes`, `query_status` | +| `PrinterBackend` Protocol | `app/printer_backends/base.py` | Transport + encoding contract: `print_image` + `query_status` | | `PTouchBackend` | `app/printer_backends/ptouch_backend.py` | Wraps the `ptouch` library; synchronous I/O is dispatched via `asyncio.to_thread` | | `MockPrinterBackend` | `app/printer_backends/mock_backend.py` | Mock for tests and local dev without hardware | | Exceptions | `app/printer_backends/exceptions.py` | `PrinterError` hierarchy | @@ -103,24 +103,22 @@ class PrinterBackend(Protocol): high_resolution: bool = False, ) -> None: ... - async def send_bytes(self, raster: bytes) -> None: ... - async def query_status(self) -> StatusBlock: ... ``` -**Hybrid-API rationale:** +**Two-method surface, deliberate YAGNI:** -- `print_image` is the high-level path — the caller hands in a PIL image plus a `TapeSpec` and the backend encodes and sends. -- `send_bytes` is the escape hatch for future raw raster experiments (template editor, power users). The caller is responsible for validation. +- `print_image` is the only print path. Caller hands in a PIL image plus a `TapeSpec`; the backend encodes and sends. - `query_status` is the cheap pre-print check and health probe. +An earlier draft included a `send_bytes(raster: bytes)` escape hatch for raw raster experiments (template editor, custom encoders). It was removed: there is no concrete caller in First-Print, adding it now means parallel TCP/9100 connection logic that must be tested and maintained, and Brother PT hardware allows only one TCP session at a time — opening a raw socket while `ptouch` holds one would cause `Resource Busy` errors. The hook can be added back additively if a real use case appears (Customization Path 2 — decorator backend — does not need `send_bytes`). + ### `PTouchBackend` implementation - Constructor takes `host: str` and a `ptouch.printers.*` class (default `PT_P750W`). - All `ptouch` calls are synchronous and dispatched via `asyncio.to_thread`. - `query_status` parses the ptouch status block into our `StatusBlock` dataclass. - `print_image` validates against the cached status (see Error handling) and calls `printer.print(label, auto_cut=..., high_resolution=...)`. -- `send_bytes` opens a raw TCP connection to `host:9100` via `asyncio.open_connection`. The implementation sequence is `writer.write(raster)` → `await writer.drain()` (flush pending data and respect backpressure) → `writer.close()` → `await writer.wait_closed()`. Skipping `drain`/`wait_closed` can truncate the raster stream when the kernel buffer is small, so both are mandatory. ### `PTP750WDriver` (PrinterModel + queue-printer factory) @@ -143,6 +141,12 @@ class PTP750WDriver: # The backend is already bound to a host. If the caller passes an explicit # non-empty host that doesn't match, that's a programmer error — fail loudly # rather than silently querying a different printer. + # + # Note on Protocol shape: the `host` argument originally implies a stateless + # driver. With the bound-backend design the driver is stateful and `host` is + # dead. A cleaner long-term fix is to refactor `PrinterModel.query_status` + # to take no `host` argument; that is a follow-up PR that touches the + # Protocol itself and is intentionally not bundled with First-Print. if host and host != self._backend.host: raise ValueError( f"Driver bound to backend.host={self._backend.host!r}; " @@ -156,16 +160,21 @@ class PTP750WDriver: def build_print_job(self, image, tape_spec, auto_cut=True, high_resolution=False) -> bytes: """Encode an image into the Brother raster byte stream for PT-Series. - Used by callers that need the raw bytes (raw `send_bytes` path, future - export-to-file, or a backend without an in-library encoder). The First-Print happy path goes through `backend.print_image()` and does - **not** call this method. + **not** call this method. It is kept here for callers that need raw + bytes (export-to-file, debugging) and for Protocol conformance. Implementation: delegates to the ptouch library's internal raster builder (e.g. `ptouch.label.ImageLabel(image, ...).encode()`). The exact entry point will be confirmed in implementation Phase 1 — if ptouch does not expose a public encoder, the fallback is a raw encoder built from the Brother Raster Command Reference. + + Alternative considered: refactoring `PrinterModel` so rasterization + is in a separate sub-protocol (`RasterizablePrinterModel`). That is + a cleaner shape if a backend genuinely cannot expose an encoder + and is flagged as a possible follow-up. For First-Print we keep the + Protocol unchanged and assume the ptouch encoder is reachable. """ # impl details deferred to plan ... @@ -418,8 +427,6 @@ class QuirkyPTP710BTBackend: return status async def print_image(self, image, tape_spec, **kw): await self._inner.print_image(image, tape_spec, **kw) - async def send_bytes(self, raster): - await self._inner.send_bytes(raster) ``` One wrapper class implementing `PrinterBackend` again. `ptouch` library stays untouched, no driver change. @@ -585,9 +592,11 @@ Two additional manual scenarios: - **ptouch status parsing:** which fields the `ptouch` library exposes from the status block must be verified in plan Phase 1 (read `ptouch.printers.PT_P750W.get_status`). If the fields are insufficient (e.g. no `loaded_tape_mm`), we need a fall-back raw parser following the Brother Raster Command Reference (PT-Series). - **PT-P750W transport:** the design assumes network TCP. The ptouch class lookup happens inside `PTouchBackend.from_settings` and is easy to extend if a future model needs a different connection class. -- **In-memory TTL drift:** the 5-minute TTL could expire during very long polls. Not blocking for First-Print; will be revisited together with the persistence work in Phase 5. +- **Job eviction:** the current `_jobs` dict grows unbounded (existing TODO). Bounded eviction is part of the Phase 5 persistence work; deliberately not in First-Print. - **Default `MediaType` for the bridge:** `MediaType.LAMINATED` covers the common TZe-Tape case, but the bridge accepts a per-print `options["media_type"]` override. If a request comes in with the wrong media type, the pre-print status check will catch the mismatch via `TapeMismatchError`. - **Lifecycle hooks (`before_print` / `after_print`):** intentionally **not** part of First-Print. Adding them would extend the `PrinterModel` Protocol once (with no-op defaults). Deferred until a concrete need surfaces — paths 2 and 3 in the Extensibility section cover every realistic case today. +- **PrinterModel Protocol shape — possible follow-up:** two parts of the current Protocol are awkward with the bound-backend driver design: (a) `query_status(host=...)` has a dead `host` argument, and (b) `build_print_job` is required even when a backend owns encoding. A clean follow-up PR would refactor the Protocol — either make `query_status` stateless-less (`query_status()` without `host`) or split rasterization into a `RasterizablePrinterModel` sub-protocol. Bundling that into First-Print would mean touching every existing PrinterModel call site; out of scope here. +- **Raw-bytes path:** removed for now (`send_bytes` is **not** part of `PrinterBackend`). If a real caller appears — e.g. a future template editor that wants to send pre-encoded raster, or a non-library backend — `send_bytes` can be added additively to the Protocol with no breaking change. Until then, every print goes through `print_image`. ## References From c0a37254ac283b6d3fb8e443380b6c1e2f983867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 17:06:39 +0000 Subject: [PATCH 06/12] docs(designs): add SNMP-hybrid for discovery + live status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The print path uses TCP/9100 single-session (ptouch holds it during a print). SNMP on UDP/161 gives us a parallel channel for the things TCP cannot serve: * Model auto-discovery (ADR 0004) — read Brother private OID 1.3.6.1.4.1.2435.2.3.9.1.1.7.0 → PJL string → ModelRegistry.find_by_pjl. * Live status during a running print — hrPrinterStatus and hrPrinterDetectedErrorState are reachable while ptouch holds the print socket. ESC i S is retained for pre-print validation (it returns tape_mm and media_type directly as integers/enums; SNMP would need string parsing for the equivalent info). Spec additions: * New SNMP helper component in the component map and architecture diagram (sits beside the backend, not behind it). * Two new async helpers: query_model_pjl() and query_live_status(). * New settings: printer_discover_via_snmp (default True), printer_snmp_community (default 'public'). printer_model becomes the fallback when SNMP fails or is disabled. * Lifespan flow documented with the three valid configurations (auto+fallback, auto-only, manual). * GET /jobs/{job_id} returns a `live` sub-object while the job is printing; null otherwise. SNMP failure at request time is non-fatal — the live block is just omitted. * SnmpDiscoveryError and SnmpQueryError added to the exception hierarchy; SnmpDiscoveryError is the only one that can prevent app start. * Four new acceptance criteria (12-14 + renumbered tail). Refs #22 --- docs/designs/2026-05-15-first-print.md | 110 +++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 7 deletions(-) diff --git a/docs/designs/2026-05-15-first-print.md b/docs/designs/2026-05-15-first-print.md index 440df44..5d8a793 100644 --- a/docs/designs/2026-05-15-first-print.md +++ b/docs/designs/2026-05-15-first-print.md @@ -23,6 +23,9 @@ In scope: - `PrinterBackend` Protocol as the extension point for hardware adapters. - `PTouchBackend` as the first concrete adapter, wrapping the `ptouch` library. +- **SNMP query helpers** for two purposes that the print TCP channel cannot serve: + 1. **Model discovery at startup** — read the printer's PJL string (`1.3.6.1.4.1.2435.2.3.9.1.1.7.0`) and resolve it via `ModelRegistry.find_by_pjl`. Fulfils ADR 0004. + 2. **Live status during print** — Brother PT-Series allows only **one** TCP/9100 session, so ESC i S cannot be polled while ptouch holds the print connection. SNMP runs on UDP/161 and stays usable. - `PTP750WDriver` (PrinterModel, see ADR 0004) plus a private `_PTPQueuePrinter` bridge it produces via `make_queue_printer(...)` for the PrintQueue's `_PrinterLike` Protocol. - `PrintService` orchestrating lookup → render → enqueue. - REST endpoints `POST /print` and `GET /jobs/{job_id}`. @@ -53,6 +56,7 @@ flowchart LR PR[_PTPQueuePrinter bridge] DR[PTP750WDriver] BE[PrinterBackend] + SNMP[SNMP helper] HW[(PT-P750W)] Client -->|POST /print| API @@ -64,10 +68,14 @@ flowchart LR PQ -->|worker| PR PR --> DR PR --> BE - BE -->|raster bytes| HW + BE -->|TCP/9100 raster| HW + SNMP -->|UDP/161 query| HW + API -.->|GET /jobs/id live phase| SNMP Client -->|GET /jobs/id| API ``` +The SNMP helper sits beside the backend, not behind it. The backend keeps owning print + pre-print ESC i S validation on TCP/9100; SNMP owns discovery and during-print live status on UDP/161. Both can run concurrently against the same physical printer. + ### Component map | Component | File | Responsibility | @@ -80,9 +88,10 @@ flowchart LR | `ModelRegistry` | `app/printer_models/registry.py` (existing, extended) | Discovers driver plugins via `setuptools entry_points` (group `label_hub.printer_models`); ships with the built-in PT-Series driver registered | | Backend registry | `app/printer_backends/__init__.py` (new) | Discovers backend plugins via `setuptools entry_points` (group `label_hub.printer_backends`); ships with `ptouch` + `mock` registered | | `PrintService` | `app/services/print_service.py` | Use-case orchestrator | -| REST routes | `app/api/routes/print.py` | `POST /print`, `GET /jobs/{id}`, exception mapping | -| Lifespan init | `app/main.py` | Backend selection, queue start/stop | -| Settings | `app/config.py` | `printer_backend`, `printer_model`, reuses existing `pt750w_host` / `pt750w_port` | +| SNMP helper | `app/printer_backends/snmp_helper.py` (new) | UDP/161 queries: PJL-string for model discovery, `hrPrinterStatus` + `hrPrinterDetectedErrorState` for live status | +| REST routes | `app/api/routes/print.py` | `POST /print`, `GET /jobs/{id}` (includes live SNMP phase when the queue says the job is printing), exception mapping | +| Lifespan init | `app/main.py` | SNMP-discovery → resolve model from PJL → backend selection → queue start/stop | +| Settings | `app/config.py` | `printer_backend`, `printer_model`, `printer_discover_via_snmp`, `printer_snmp_community`, reuses existing `pt750w_host` / `pt750w_port` | ## Backend Protocol @@ -371,7 +380,9 @@ class Settings(BaseSettings): # --- NEW for First-Print --- printer_backend: str = "ptouch" # backend_id; default built-ins: "ptouch", "mock" - printer_model: str = "PT-P750W" # model_id resolved against ModelRegistry + printer_model: str = "PT-P750W" # fallback model_id when SNMP discovery is off or fails + printer_discover_via_snmp: bool = True # try SNMP first, fall back to printer_model + printer_snmp_community: str = "public" # SNMP v2c community (read-only) printer_queue_timeout_s: float = 30.0 ``` @@ -469,6 +480,85 @@ To enable paths 1, 2, 4 and 5 from day one, First-Print delivers: Path 3 (subclass driver) needs no extra plumbing — it falls out of the inheritance model. Lifecycle hooks (pre/post-print) are deliberately **out of scope** for First-Print (YAGNI): paths 2 and 3 cover every realistic case today. They can be added to `PrinterModel` later as optional methods with a default no-op if a concrete need arises. +## SNMP — discovery + live status + +The print path uses TCP/9100 (single session, owned by ptouch during a print). SNMP gives us a second, parallel channel for the things TCP/9100 cannot serve. + +### Two helper functions, both async + +```python +# app/printer_backends/snmp_helper.py +async def query_model_pjl(host: str, *, community: str = "public", timeout_s: float = 3.0) -> str: + """Read Brother private OID 1.3.6.1.4.1.2435.2.3.9.1.1.7.0 → PJL identification string. + Example reply: 'MFG:Brother;CMD:PJL;MDL:PT-P750W;CLS:PRINTER;DES:Brother PT-P750W;' + """ + +async def query_live_status(host: str, *, community: str = "public", timeout_s: float = 3.0) -> LiveStatus: + """Read standard Host-Resources Printer-MIB: + * hrPrinterStatus 1.3.6.1.2.1.25.3.5.1.1.1 → idle | printing | warmup | other + * hrPrinterDetectedErrorState 1.3.6.1.2.1.25.3.5.1.2.1 → 8-byte bitmask + Returns a small LiveStatus dataclass — used by GET /jobs/{id} to surface + "really printing right now" vs "queued behind another job". + """ +``` + +Both helpers use `pysnmp.hlapi.v3arch.asyncio.get_cmd` (asyncio-native, no thread dispatch needed). `pysnmp>=6.2` is already pinned in `pyproject.toml`. + +### What SNMP gives us that ESC i S does not + +| Use case | ESC i S (TCP/9100) | SNMP (UDP/161) | +|---|---|---| +| Pre-print validation (tape mm, media type) | direct integer/byte | text description — parsing needed | +| **Model auto-discovery (PJL string for ADR 0004)** | **no** — only series/model code bytes | **yes** — full `MFG:Brother;...;MDL:PT-P750W;...` | +| **Live status during a running print** | **no** — TCP is busy | **yes** — UDP independent | +| Brother-specific error bits (jam, overheating) | yes | no — only standard `hrPrinterDetectedErrorState` | + +We keep both. ESC i S in `PTouchBackend.print_image` does the detailed pre-print check it is already good at. SNMP does discovery at startup and live-status during print. + +### Lifespan discovery flow + +```python +async def _resolve_printer_model(settings: Settings, host: str) -> str: + """Returns the model_id that ModelRegistry.find_by_model_id will use.""" + if not settings.printer_discover_via_snmp: + return settings.printer_model + try: + pjl = await query_model_pjl(host, community=settings.printer_snmp_community) + except SnmpDiscoveryError as exc: + if settings.printer_model: + log.warning("SNMP discovery failed (%s); falling back to printer_model=%r", exc, settings.printer_model) + return settings.printer_model + raise # no fallback configured → fail fast + driver = ModelRegistry.find_by_pjl(pjl) + return driver.model_id +``` + +Three configurations: + +| `printer_discover_via_snmp` | `printer_model` (env) | Behaviour | +|---|---|---| +| `True` (default) | `""` | SNMP must succeed; failure raises at app start | +| `True` (default) | `"PT-P750W"` | Try SNMP, fall back to env var if SNMP fails (warned in log) | +| `False` | `"PT-P750W"` | Skip SNMP entirely, use env var | +| `False` | `""` | Refused at app start | + +### Live-status path + +`GET /jobs/{job_id}` keeps reading the in-memory FSM (Job.state, error_code, ...) as today. **Additionally**: when `job.state == JobState.PRINTING`, the route handler calls `query_live_status(host)` and attaches the returned `LiveStatus` to the response as a sub-object. Failure of the SNMP query is non-fatal — the live block is just omitted. + +Schema sketch: + +```python +class LiveStatus(BaseModel): + model_config = ConfigDict(frozen=True) + hr_printer_status: Literal["other", "unknown", "idle", "printing", "warmup"] + error_flags: list[str] # decoded bit names from hrPrinterDetectedErrorState + +class PrintJobStatusResponse(BaseModel): + # ...existing fields... + live: LiveStatus | None = None # populated only while job.state == PRINTING +``` + ## Error Handling ### Exception hierarchy @@ -483,6 +573,8 @@ class TapeEmptyError(PrinterError): ... class PrinterCoverOpenError(PrinterError): ... class PrintFailedError(PrinterError): ... class StatusQueryFailedError(PrinterError): ... +class SnmpDiscoveryError(PrinterError): ... # SNMP unreachable / OID missing +class SnmpQueryError(PrinterError): ... # live-status SNMP failed at runtime (non-fatal) ``` Plus `TemplateNotFoundError` and `LookupFailedError` on the lookup/template side. @@ -507,6 +599,7 @@ Plus `TemplateNotFoundError` and `LookupFailedError` on the lookup/template side | `PrinterOfflineError` | 503 | `printer_offline` | | `StatusQueryFailedError` | 503 | `printer_status_unavailable` | | `PrintFailedError` | 500 | `print_failed` | +| `SnmpDiscoveryError` | 503 (only at app start; never reaches a request) | `snmp_discovery_failed` | **Important:** `TemplateNotFoundError` and `LookupFailedError` are mapped to HTTP errors **synchronously** in the POST handler (they happen before the enqueue). All other errors come from the worker and end up in the job record (`status="failed"`). @@ -585,8 +678,11 @@ Two additional manual scenarios: 9. Empty `pt750w_host` with `printer_backend=ptouch` and `printer_model=PT-P750W` fails at app start with a clear error. 10. An unknown `printer_model` or `printer_backend` fails at app start with a clear error listing the registered options. 11. A fake driver plugin registered via `setuptools entry_points` is picked up by `ModelRegistry.ensure_discovered()` in a test, demonstrating that third-party drivers work end-to-end without core changes. -12. `scripts/smoke_first_print.py` prints successfully on a real PT-P750W (verified manually). -13. Coverage ≥80%; `ruff check`, `ruff format --check`, and `mypy --strict` all green. +12. SNMP discovery resolves a stubbed PJL-string reply (`MFG:Brother;...;MDL:PT-P750W;...`) to the `PTP750WDriver` via `ModelRegistry.find_by_pjl` in an integration test. +13. With `printer_discover_via_snmp=True` and an unreachable host, app start fails with `SnmpDiscoveryError` **only when** `printer_model` is empty; with a populated `printer_model` the lifespan falls back to it and logs a warning. +14. `GET /jobs/{job_id}` for a job in `JobState.PRINTING` includes a `live` sub-object with `hr_printer_status` and `error_flags`; for any other job state `live` is `None`. +15. `scripts/smoke_first_print.py` prints successfully on a real PT-P750W (verified manually). +16. Coverage ≥80%; `ruff check`, `ruff format --check`, and `mypy --strict` all green. ## Open Questions and Risks From 30c627a157b2672ef9c7ba184bb9a2542dfd7309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 17:12:39 +0000 Subject: [PATCH 07/12] docs(plans): add First-Print implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive TDD-strict implementation plan tracking the First-Print pipeline design (docs/designs/2026-05-15-first-print.md). 17 phases, ~60 atomic tasks, each with failing test first → minimal implementation → green test → commit. Conventional Commits with the new printer-backends scope (added in Phase 0). Every commit footer ends with Refs #22. Covers: * Phase 0 — commitlint scope extension * Phase 1 — ptouch entry-points doc, Brother ESC i S wire format, Brother SNMP OIDs (Brother private PJL OID + Host-Resources MIB) * Phase 2 — PrinterError hierarchy (incl. SnmpDiscoveryError, SnmpQueryError) * Phase 3 — PrinterBackend Protocol (print_image + query_status only, send_bytes deliberately omitted) * Phase 4 — MockPrinterBackend in app/printer_backends/ * Phase 5 — BackendRegistry + entry_points discovery * Phase 6 — ESC i S status_query helper, PTouchBackend, and the SNMP helper module (query_model_pjl + query_live_status + LiveStatus) * Phase 7 — ModelRegistry.find_by_model_id + entry_points discovery * Phase 8 — PTP750WDriver + make_queue_printer + _PTPQueuePrinter * Phase 9 — Pydantic schemas (PrintRequest, RawLabelData, PrintOptions, PrintJobResponse, PrintJobStatusResponse incl. optional `live` block) * Phase 10 — PrintService orchestrator * Phase 11 — REST routes incl. live SNMP block while job is PRINTING * Phase 12 — Settings (printer_backend, printer_model, printer_discover_via_snmp, printer_snmp_community, printer_queue_timeout_s) * Phase 13 — Lifespan with SNMP-first model discovery and printer_model fallback * Phase 14 — Integration tests (happy path, tape mismatch, offline, SNMP discovery) * Phase 15 — Hardware smoke script + gated hardware test * Phase 16 — Final verification + handoff (no auto-push) Implementer subagents do NOT push — orchestrator pushes after human code review. Refs #22 --- docs/plans/2026-05-15-first-print.md | 4542 ++++++++++++++++++++++++++ 1 file changed, 4542 insertions(+) create mode 100644 docs/plans/2026-05-15-first-print.md diff --git a/docs/plans/2026-05-15-first-print.md b/docs/plans/2026-05-15-first-print.md new file mode 100644 index 0000000..b7b4272 --- /dev/null +++ b/docs/plans/2026-05-15-first-print.md @@ -0,0 +1,4542 @@ +# First-Print Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Tracking-Issue: #22. + +**Goal:** Deliver the first end-to-end print pipeline (`POST /print` → real Brother PT-P750W) per `docs/designs/2026-05-15-first-print.md`. + +**Architecture:** Three-layer separation — `_PrinterLike` (queue), `PrinterModel` (driver, in `printer_models/pt.py`), `PrinterBackend` (transport, in `printer_backends/`). Plugin discovery via setuptools entry_points for both drivers and backends. Bridge to `PrintQueue` is a factory method on the driver (`make_queue_printer`). + +**Tech Stack:** Python 3.12 + FastAPI + Pydantic 2 + `ptouch` lib + `pysnmp>=6.2` (asyncio API) + `pytest`. TDD-strict (failing test before any production code). Conventional Commits (existing scope-enum extended with `printer-backends`). Every commit body ends with `Refs #22`. **No `Co-Authored-By: Claude`** lines in commits. + +**Spec discoveries that override design assumptions:** +- `ptouch` library exposes **no public status-query API** — the design's `query_status` must be implemented directly via a raw `asyncio` socket sending `ESC i S` (3 bytes) and parsing the 32-byte reply per Brother Raster Command Reference (PT-Series). +- The ptouch class is `ptouch.PTP750W` (no underscore). The connection class is `ptouch.ConnectionNetwork(host, port=9100, timeout=5.0)`. The label class is `ptouch.Label(image, tape)`. +- ptouch has its own exception hierarchy: `PrinterConnectionError`, `PrinterNetworkError`, `PrinterTimeoutError`, `PrinterWriteError`, `PrinterPermissionError`, `PrinterNotFoundError`. The `PTouchBackend` wraps these into our `PrinterError` family. +- ptouch has **no SNMP** capability. `pysnmp>=6.2` is already pinned and exposes an asyncio API (`pysnmp.hlapi.v3arch.asyncio.get_cmd`). The plan implements a dedicated `snmp_helper.py` module for: + 1. **Model discovery** — Brother private OID `1.3.6.1.4.1.2435.2.3.9.1.1.7.0` returns the PJL identification string (`MFG:Brother;CMD:PJL;MDL:PT-P750W;CLS:PRINTER;...`). Used at lifespan startup to resolve the driver via `ModelRegistry.find_by_pjl` (already exists in the codebase, ADR 0004). + 2. **Live status during a running print** — `hrPrinterStatus` (`1.3.6.1.2.1.25.3.5.1.1.1`) and `hrPrinterDetectedErrorState` (`1.3.6.1.2.1.25.3.5.1.2.1`) are reachable on UDP/161 while ptouch holds the print TCP/9100 session. +- ESC i S and SNMP coexist: ESC i S delivers Brother-specific tape/media bytes directly as integers, perfect for pre-print validation; SNMP serves discovery and during-print checks that ESC i S cannot. + +--- + +## File structure + +| Path | Role | +|---|---| +| `backend/app/printer_backends/__init__.py` | `BackendRegistry` class + entry-point discovery | +| `backend/app/printer_backends/base.py` | `PrinterBackend` Protocol | +| `backend/app/printer_backends/exceptions.py` | `PrinterError` hierarchy | +| `backend/app/printer_backends/status_query.py` | Raw ESC i S socket helper + reply parser | +| `backend/app/printer_backends/snmp_helper.py` | SNMP helpers: `query_model_pjl` (discovery) + `query_live_status` (during-print) + `LiveStatus` dataclass | +| `backend/app/printer_backends/ptouch_backend.py` | `PTouchBackend` wrapping the ptouch library | +| `backend/app/printer_backends/mock_backend.py` | `MockPrinterBackend` for tests + local dev | +| `backend/app/printer_models/pt.py` (modified) | Add `PTP750WDriver` + `_PTPQueuePrinter` | +| `backend/app/printer_models/registry.py` (modified) | Add `find_by_model_id` + `ensure_discovered` (entry_points) | +| `backend/app/printer_models/__init__.py` (modified) | Trigger ModelRegistry discovery | +| `backend/app/schemas/print_request.py` | `PrintRequest`, `PrintOptions`, `RawLabelData`, `PrintLookupRequest` | +| `backend/app/schemas/print_response.py` | `PrintJobResponse`, `PrintJobStatusResponse` | +| `backend/app/services/print_service.py` | `PrintService` orchestrator | +| `backend/app/api/routes/print.py` | `POST /print`, `GET /jobs/{job_id}` + exception mapper | +| `backend/app/main.py` (modified) | Lifespan + route registration | +| `backend/app/config.py` (modified) | Add `printer_backend`, `printer_model`, `printer_queue_timeout_s` | +| `backend/scripts/smoke_first_print.py` | Manual hardware smoke (not in CI) | +| `backend/tests/unit/printer_backends/test_exceptions.py` | `PrinterError` hierarchy + field access | +| `backend/tests/unit/printer_backends/test_status_query.py` | ESC i S byte-format + reply parser | +| `backend/tests/unit/printer_backends/test_mock_backend.py` | Mock surface + introspection | +| `backend/tests/unit/printer_backends/test_ptouch_backend.py` | ptouch via monkeypatch — all error paths | +| `backend/tests/unit/printer_backends/test_snmp_helper.py` | `query_model_pjl` + `query_live_status` via stubbed pysnmp `get_cmd` | +| `backend/tests/unit/printer_backends/test_registry.py` | `BackendRegistry` + entry_point discovery | +| `backend/tests/integration/test_snmp_discovery.py` | Lifespan resolves model from stubbed PJL → driver picked up | +| `backend/docs/brother-snmp-oids.md` | OID reference (1.3.6.1.4.1.2435… + Host-Resources Printer MIB) | +| `backend/tests/unit/printer_models/test_pt_driver.py` | `PTP750WDriver` + bridge | +| `backend/tests/unit/printer_models/test_registry.py` (modified) | Add `find_by_model_id` + discovery tests | +| `backend/tests/unit/services/test_print_service.py` | Lookup/render/enqueue orchestration | +| `backend/tests/unit/schemas/test_print_request.py` | XOR validation, `RawLabelData` shape | +| `backend/tests/unit/api/test_print_routes.py` | 202 + GET /jobs/{id} + exception mapper | +| `backend/tests/unit/test_lifespan.py` | Lifespan start/stop, plugin discovery | +| `backend/tests/unit/test_config_printer.py` | Settings field defaults + types | +| `backend/tests/integration/test_print_e2e.py` | Full POST → GET cycle via AsyncClient + MockBackend | +| `backend/tests/hardware/test_pt_p750w_smoke.py` | Real-hardware smoke (gated by `--hardware`) | +| `commitlint.config.cjs` (modified) | Add `printer-backends` scope | +| `backend/pyproject.toml` (modified) | Register `ptouch` + `mock` as `label_hub.printer_backends` entry-points | + +--- + +## Conventions for every task + +- **TDD:** write failing test first, see it fail (right reason), implement, see it pass, commit. +- **One responsibility per task** — touches a small number of files; commit at task end. +- **Run gates locally before committing:** + ```bash + cd backend + ruff check . + ruff format --check . + mypy app + pytest -q + ``` + All four must pass. `ruff format --check` is a separate gate from `ruff check` — running only the latter misses style drift. +- **Commit message footer:** every commit body ends with a blank line then `Refs #22`. +- **No `Co-Authored-By: Claude`** in any commit (per repo policy on AI contributions). +- **Implementer must NOT push** — orchestrator pushes after user review. + +--- + +## Phase 0 — Branch setup + commitlint scope + +### Task 0.1: Verify branch + add `printer-backends` scope to commitlint + +**Files:** +- Modify: `commitlint.config.cjs` + +- [ ] **Step 1: Confirm branch** + +```bash +cd /opt/repos/label-printer-hub +git rev-parse --abbrev-ref HEAD +``` + +Expected: `feat/first-print-plan` (or a fresh `feat/first-print` cut from it after the plan PR merges) + +- [ ] **Step 2: Add the new scope** + +Edit `commitlint.config.cjs`, insert `'printer-backends'` into the `scope-enum` array (sorted alphabetically before `pwa`): + +```javascript + 'printer-backends', + 'printer-models', +``` + +- [ ] **Step 3: Smoke-check commitlint locally** + +```bash +echo "feat(printer-backends): test scope" | npx commitlint +``` + +Expected: exit 0 (no errors). + +- [ ] **Step 4: Commit** + +```bash +git add commitlint.config.cjs +git commit -m "$(cat <<'EOF' +build(ci): add printer-backends scope to commitlint + +First-Print introduces app/printer_backends/, which is conceptually +separate from app/printer_models/ (driver vs. transport). Add the +matching commit scope so backend-layer changes can be tagged correctly +in the changelog. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 1 — Discovery: pin ptouch + status block layout + +The design pinned `ptouch>=1.1.0` (existing dep). One assumption — `ptouch` exposes a public status query — turned out to be wrong (only an internal `_cmd_print_information` exists, and that is a *send* command, not a query). Phase 1 captures the wire-level status query we need to implement ourselves, and pins the ptouch entry-points the rest of the plan relies on. + +### Task 1.1: Document ptouch entry-points and status-query gap + +**Files:** +- Create: `backend/docs/ptouch-integration.md` (concise reference, kept under 200 lines) + +- [ ] **Step 1: Write the file** + +```markdown +# ptouch library — entry points used by First-Print + +Version: `ptouch>=1.1.0` (pinned in pyproject.toml). + +## Classes we use + +- `ptouch.ConnectionNetwork(host: str, port: int = 9100, timeout: float = 5.0)` +- `ptouch.PTP750W(connection, use_compression=None, high_resolution=None)` (subclass of `LabelPrinter`) +- `ptouch.Label(image: PIL.Image.Image, tape: type[Tape] | Tape)` +- Tape classes: `ptouch.LaminatedTape4mm` ... `ptouch.LaminatedTape24mm` (size suffix matches `tape_mm`). +- Print method: `LabelPrinter.print(label, margin_mm=None, high_resolution=None, feed=True, auto_cut=None, half_cut=None)` + +## ptouch exception hierarchy (caught by PTouchBackend and rewrapped) + +- `ptouch.PrinterConnectionError` — generic connection problem +- `ptouch.PrinterNetworkError` — network-layer failure (DNS, refused) +- `ptouch.PrinterTimeoutError` — TCP timeout +- `ptouch.PrinterWriteError` — write failure mid-print +- `ptouch.PrinterPermissionError` — USB-permission issue (n/a for network) +- `ptouch.PrinterNotFoundError` — host unreachable + +## Status query — NOT exposed by ptouch + +`LabelPrinter` has only `_cmd_print_information` (private, and a send command). There is no `get_status` / `query_status` method. We implement status query ourselves: ESC i S over a raw asyncio socket, see Brother Raster Command Reference (PT-Series). +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/docs/ptouch-integration.md +git commit -m "$(cat <<'EOF' +docs(printer-backends): record ptouch entry-points + status gap + +Reference for implementers: pinned ptouch classes we depend on, +exception hierarchy to wrap, and the explicit gap that ptouch +exposes no public status-query API — so PTouchBackend must +implement ESC i S over a raw socket. + +Refs #22 +EOF +)" +``` + +### Task 1.2: Document ESC i S request + 32-byte reply layout + +**Files:** +- Create: `backend/docs/brother-status-block.md` (single-page wire reference) + +- [ ] **Step 1: Write the file** + +```markdown +# Brother PT-Series status block (ESC i S) + +Source: Brother Raster Command Reference, PT-Series. + +## Request + +3 bytes sent on TCP port 9100: + +``` +0x1B 0x69 0x53 +``` + +(ASCII: ESC, 'i', 'S') + +## Reply + +32 bytes received. Offsets are 0-based, little-endian where applicable: + +| Offset | Length | Field | +|---|---|---| +| 0 | 1 | Print head mark (0x80) | +| 1 | 1 | Size of reply (0x20 = 32) | +| 2 | 1 | Brother code (0x42 'B') | +| 3 | 1 | Series code | +| 4 | 1 | Model code | +| 5 | 1 | Country (0x30 = '0') | +| 6 | 1 | Reserved | +| 7 | 1 | Reserved | +| 8 | 1 | Error information 1 (bit 0=no media, 1=end of media, 2=cutter jam, 3=printer in use, 4=printer turned off) | +| 9 | 1 | Error information 2 (bit 0=replace media, 4=cover open, 5=overheating) | +| 10 | 1 | Media width (mm) | +| 11 | 1 | Media type (0x00 none, 0x01 laminated, 0x03 non-laminated, 0x11 heat-shrink-2:1, ...) | +| 12 | 1 | Number of colors (always 1 for PT-Series) | +| 13 | 1 | Fonts | +| 14 | 1 | Japanese fonts | +| 15 | 1 | Mode | +| 16 | 1 | Density | +| 17 | 1 | Media length (mm; 0 for tape) | +| 18 | 1 | Status type (0x00 reply-to-status, 0x01 phase-change, 0x02 error, 0x05 notification, 0x06 phase-change-notification) | +| 19 | 1 | Phase type (0x00 receiving / 0x01 printing) | +| 20 | 2 | Phase number high/low | +| 22 | 1 | Notification number | +| 23 | 1 | Expansion area length | +| 24 | 1 | Tape colour information | +| 25 | 1 | Text colour information | +| 26 | 4 | Hardware settings | +| 30 | 2 | Reserved | + +## Error decoding + +`tape_empty` ← bit 0 OR bit 1 of byte 8 set +`cover_open` ← bit 4 of byte 9 set +`error_flags` ← raw value of (byte8, byte9) packed +`loaded_tape_mm` ← byte 10 (0 → no tape inserted) +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/docs/brother-status-block.md +git commit -m "$(cat <<'EOF' +docs(printer-backends): wire-level Brother status block (ESC i S) + +Single-page reference for the 3-byte request + 32-byte reply +implemented in PTouchBackend.query_status(). Pulled from Brother +Raster Command Reference (PT-Series). + +Refs #22 +EOF +)" +``` + +### Task 1.3: Document SNMP OIDs (discovery + live status) + +**Files:** +- Create: `backend/docs/brother-snmp-oids.md` + +- [ ] **Step 1: Write the file** + +```markdown +# Brother SNMP OIDs used by First-Print + +`pysnmp>=6.2` (asyncio API in `pysnmp.hlapi.v3arch.asyncio`). + +## Discovery + +| OID | Returns | Used for | +|---|---|---| +| `1.3.6.1.4.1.2435.2.3.9.1.1.7.0` | PJL identification string: `MFG:Brother;CMD:PJL;MDL:PT-P750W;CLS:PRINTER;DES:Brother PT-P750W;` | Lifespan startup → `ModelRegistry.find_by_pjl(...)` | + +## Live status during print (Host-Resources Printer MIB, RFC 1213) + +| OID | Returns | Mapping | +|---|---|---| +| `1.3.6.1.2.1.25.3.5.1.1.1` (`hrPrinterStatus`) | Integer: 1=other, 2=unknown, 3=idle, 4=printing, 5=warmup | string in `LiveStatus.hr_printer_status` | +| `1.3.6.1.2.1.25.3.5.1.2.1` (`hrPrinterDetectedErrorState`) | OCTET STRING of bytes; bits select errors | list of bit names in `LiveStatus.error_flags` | + +### `hrPrinterDetectedErrorState` bit map (byte 0, MSB first) + +| Bit | Name | Notes | +|---|---|---| +| 0 | lowPaper | not used by PT-Series | +| 1 | noPaper | maps to tape empty/end | +| 2 | lowToner | not applicable | +| 3 | noToner | not applicable | +| 4 | doorOpen | cover open | +| 5 | jammed | media jam | +| 6 | offline | printer reports offline | +| 7 | serviceRequested | hard fault, contact service | + +Byte 1: inputTrayMissing, outputTrayMissing, markerSupplyMissing, outputFull, inputTrayEmpty, overduePreventMaint — none relevant for PT-Series tape devices in First-Print. + +## Authentication + +SNMPv2c, community read-only. Default community is `public`; configurable via `printer_snmp_community` setting. The PT-P750W is on the LAN/Tailscale, not on the open internet, so v2c is sufficient. + +## Why this and not ESC i S + +| Job | ESC i S (TCP/9100) | SNMP (UDP/161) | +|---|---|---| +| Pre-print tape match | direct (byte 10) | needs string parsing | +| Discovery (PJL) | not available | **only path** | +| During-print status | blocked by ptouch's TCP session | **runs in parallel** | +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/docs/brother-snmp-oids.md +git commit -m "$(cat <<'EOF' +docs(printer-backends): Brother SNMP OIDs reference + +Pins the two SNMP queries First-Print depends on: + +* 1.3.6.1.4.1.2435.2.3.9.1.1.7.0 — Brother private PJL string, used + for lifespan model discovery via ModelRegistry.find_by_pjl +* 1.3.6.1.2.1.25.3.5.1.1.1 / .2.1 — standard Host-Resources Printer + MIB hrPrinterStatus + hrPrinterDetectedErrorState, used for live + status while a print is running. + +Notes on the bitmap of hrPrinterDetectedErrorState and why SNMP is +not enough on its own (no direct tape_mm — uses ESC i S for that). + +Refs #22 +EOF +)" +``` + +--- + +## Phase 2 — Exceptions + +### Task 2.1: PrinterError hierarchy with TDD + +**Files:** +- Create: `backend/app/printer_backends/__init__.py` (empty for now; populated in Phase 6) +- Create: `backend/app/printer_backends/exceptions.py` +- Create: `backend/tests/unit/printer_backends/__init__.py` +- Create: `backend/tests/unit/printer_backends/test_exceptions.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/printer_backends/test_exceptions.py +from __future__ import annotations + +import pytest + +from app.printer_backends.exceptions import ( + PrinterCoverOpenError, + PrinterError, + PrinterOfflineError, + PrintFailedError, + SnmpDiscoveryError, + SnmpQueryError, + StatusQueryFailedError, + TapeEmptyError, + TapeMismatchError, +) + + +class TestHierarchy: + @pytest.mark.parametrize( + "exc_cls", + [ + PrinterOfflineError, + TapeMismatchError, + TapeEmptyError, + PrinterCoverOpenError, + PrintFailedError, + StatusQueryFailedError, + SnmpDiscoveryError, + SnmpQueryError, + ], + ) + def test_subclasses_printer_error(self, exc_cls: type[Exception]) -> None: + assert issubclass(exc_cls, PrinterError) + + def test_printer_error_is_exception(self) -> None: + assert issubclass(PrinterError, Exception) + + +class TestTapeMismatchFields: + def test_carries_expected_and_loaded(self) -> None: + err = TapeMismatchError(expected_mm=18, loaded_mm=12) + assert err.expected_mm == 18 + assert err.loaded_mm == 12 + + def test_loaded_can_be_none_for_no_tape(self) -> None: + err = TapeMismatchError(expected_mm=18, loaded_mm=None) + assert err.loaded_mm is None + + def test_str_mentions_both_values(self) -> None: + err = TapeMismatchError(expected_mm=18, loaded_mm=12) + s = str(err) + assert "18" in s and "12" in s +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/printer_backends/test_exceptions.py -q +``` + +Expected: `ModuleNotFoundError: No module named 'app.printer_backends.exceptions'` or equivalent. + +- [ ] **Step 3: Implement** + +```python +# backend/app/printer_backends/__init__.py +"""Printer-backend layer — transport implementations behind a common Protocol. + +The registry lives here; see app.printer_backends.base for the Protocol contract. +""" +``` + +```python +# backend/app/printer_backends/exceptions.py +"""Exception hierarchy raised by PrinterBackend implementations. + +PrinterError is the root; HTTP-mapping is done in app.api.routes.print. +""" + +from __future__ import annotations + + +class PrinterError(Exception): + """Base class for any backend / hardware failure.""" + + +class PrinterOfflineError(PrinterError): + """Cannot reach the printer's TCP endpoint after retries.""" + + +class TapeMismatchError(PrinterError): + """Loaded tape width does not match the requested tape.""" + + def __init__(self, *, expected_mm: int, loaded_mm: int | None) -> None: + self.expected_mm = expected_mm + self.loaded_mm = loaded_mm + if loaded_mm is None: + super().__init__(f"Expected {expected_mm}mm tape, no tape loaded") + else: + super().__init__(f"Expected {expected_mm}mm tape, loaded {loaded_mm}mm") + + +class TapeEmptyError(PrinterError): + """Status block reports tape end / no media.""" + + +class PrinterCoverOpenError(PrinterError): + """Status block reports cover open.""" + + +class PrintFailedError(PrinterError): + """Encoding or transport failure during print().""" + + +class StatusQueryFailedError(PrinterError): + """The 32-byte ESC i S reply could not be parsed.""" + + +class SnmpDiscoveryError(PrinterError): + """SNMP model-discovery query at lifespan startup failed.""" + + +class SnmpQueryError(PrinterError): + """Live-status SNMP query failed at request time. Non-fatal — the live + block is omitted from the response. + """ +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/printer_backends/test_exceptions.py -q +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/printer_backends/__init__.py \ + backend/app/printer_backends/exceptions.py \ + backend/tests/unit/printer_backends/__init__.py \ + backend/tests/unit/printer_backends/test_exceptions.py +git commit -m "$(cat <<'EOF' +feat(printer-backends): PrinterError hierarchy + +Adds the exception family raised by PrinterBackend implementations: +PrinterError → PrinterOfflineError, TapeMismatchError (with +expected_mm + loaded_mm fields), TapeEmptyError, PrinterCoverOpenError, +PrintFailedError, StatusQueryFailedError. + +These are wrapped into HTTP status codes by the /print route handler +(Phase 11) and mapped onto JobState=failed records. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 3 — PrinterBackend Protocol + +### Task 3.1: PrinterBackend Protocol with `@runtime_checkable` + +**Files:** +- Create: `backend/app/printer_backends/base.py` +- Create: `backend/tests/unit/printer_backends/test_base.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/printer_backends/test_base.py +from __future__ import annotations + +import io + +import pytest +from PIL import Image + +from app.models.tape import TapeSpec +from app.printer_backends.base import PrinterBackend +from app.services.status_block import MediaType, StatusBlock + + +class _Compliant: + backend_id = "compliant" + host = "1.2.3.4" + + async def print_image( + self, + image: Image.Image, + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + ) -> None: + return None + + async def query_status(self) -> StatusBlock: # pragma: no cover - shape only + return StatusBlock( + tape_empty=False, + cover_open=False, + error_flags=0, + loaded_tape_mm=24, + media_type=MediaType.LAMINATED, + ) + + +class _Incomplete: + backend_id = "incomplete" + host = "x" + # No print_image / query_status + + +def test_protocol_accepts_compliant_class() -> None: + assert isinstance(_Compliant(), PrinterBackend) + + +def test_protocol_rejects_incomplete_class() -> None: + assert not isinstance(_Incomplete(), PrinterBackend) +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/printer_backends/test_base.py -q +``` + +Expected: `ModuleNotFoundError` for `app.printer_backends.base`. + +- [ ] **Step 3: Implement** + +```python +# backend/app/printer_backends/base.py +"""PrinterBackend Protocol — transport contract used by drivers. + +Two-method surface (print_image + query_status). A raw `send_bytes` escape +hatch was deliberately removed during design: there is no concrete caller +in First-Print, and opening a second TCP/9100 session in parallel with +ptouch would hit Brother's single-session limit (Resource Busy). The +hook can be added back additively if a future caller needs it. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from PIL import Image + +from app.models.tape import TapeSpec +from app.services.status_block import StatusBlock + + +@runtime_checkable +class PrinterBackend(Protocol): + """Transport + encoding contract for a single bound printer.""" + + backend_id: str + host: str + + async def print_image( + self, + image: Image.Image, + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + ) -> None: + """Encode and send `image`. Raises a PrinterError subtype on failure.""" + + async def query_status(self) -> StatusBlock: + """Send ESC i S, parse the 32-byte reply, return a StatusBlock.""" +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/printer_backends/test_base.py -q +``` + +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/printer_backends/base.py \ + backend/tests/unit/printer_backends/test_base.py +git commit -m "$(cat <<'EOF' +feat(printer-backends): PrinterBackend Protocol + +Two-method runtime_checkable Protocol: print_image (encode + send) +and query_status (ESC i S equivalent). Backends are bound to one +host at construction time. send_bytes raw-raster escape hatch is +deliberately omitted — see design doc for rationale. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 4 — MockPrinterBackend + +### Task 4.1: MockPrinterBackend with introspection + failure modes + +**Files:** +- Create: `backend/app/printer_backends/mock_backend.py` +- Create: `backend/tests/unit/printer_backends/test_mock_backend.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/printer_backends/test_mock_backend.py +from __future__ import annotations + +import pytest +from PIL import Image + +from app.models.tape import TapeSpec +from app.printer_backends.base import PrinterBackend +from app.printer_backends.exceptions import ( + PrinterCoverOpenError, + PrinterOfflineError, + TapeEmptyError, + TapeMismatchError, +) +from app.printer_backends.mock_backend import MockPrinterBackend +from app.services.status_block import MediaType, StatusBlock + + +@pytest.fixture +def tape_24() -> TapeSpec: + return TapeSpec( + width_mm=24, + media_type=MediaType.LAMINATED, + print_area_pins=128, + print_area_dots=128, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=1000, + cutter_min_length_mm=24.5, + ) + + +@pytest.fixture +def img_128() -> Image.Image: + return Image.new("1", (200, 128)) + + +def test_mock_satisfies_protocol() -> None: + assert isinstance(MockPrinterBackend(), PrinterBackend) + + +async def test_query_status_default(tmp_path) -> None: + backend = MockPrinterBackend() + status = await backend.query_status() + assert status.tape_empty is False + assert status.cover_open is False + assert status.loaded_tape_mm == 24 + assert isinstance(status, StatusBlock) + + +async def test_print_records_image(img_128: Image.Image, tape_24: TapeSpec) -> None: + backend = MockPrinterBackend() + await backend.print_image(img_128, tape_24) + assert len(backend.printed_images) == 1 + assert backend.printed_images[0].size == img_128.size + + +async def test_offline_raises(img_128: Image.Image, tape_24: TapeSpec) -> None: + backend = MockPrinterBackend(offline=True) + with pytest.raises(PrinterOfflineError): + await backend.query_status() + + +async def test_tape_empty_raises(img_128: Image.Image, tape_24: TapeSpec) -> None: + backend = MockPrinterBackend(tape_empty=True) + with pytest.raises(TapeEmptyError): + await backend.print_image(img_128, tape_24) + + +async def test_cover_open_raises(img_128: Image.Image, tape_24: TapeSpec) -> None: + backend = MockPrinterBackend(cover_open=True) + with pytest.raises(PrinterCoverOpenError): + await backend.print_image(img_128, tape_24) + + +async def test_tape_mismatch_raises(img_128: Image.Image, tape_24: TapeSpec) -> None: + backend = MockPrinterBackend(loaded_tape_mm=12) + with pytest.raises(TapeMismatchError) as exc: + await backend.print_image(img_128, tape_24) + assert exc.value.expected_mm == 24 + assert exc.value.loaded_mm == 12 +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/printer_backends/test_mock_backend.py -q +``` + +Expected: `ModuleNotFoundError: No module named 'app.printer_backends.mock_backend'`. + +- [ ] **Step 3: Implement** + +```python +# backend/app/printer_backends/mock_backend.py +"""In-memory PrinterBackend used by tests and local development. + +Satisfies the PrinterBackend Protocol without touching the network. Failure +modes are configurable via the constructor so integration tests can drive +every error path (offline, tape empty, cover open, tape mismatch). +""" + +from __future__ import annotations + +from PIL import Image + +from app.models.tape import TapeSpec +from app.printer_backends.exceptions import ( + PrinterCoverOpenError, + PrinterOfflineError, + TapeEmptyError, + TapeMismatchError, +) +from app.services.status_block import MediaType, StatusBlock + + +class MockPrinterBackend: + """No-I/O PrinterBackend for tests + local dev. + + Construct with failure-mode flags to exercise error paths. Use + `printed_images` to assert what was actually sent. + """ + + backend_id = "mock" + + def __init__( + self, + host: str = "mock://test", + *, + loaded_tape_mm: int = 24, + loaded_media_type: MediaType = MediaType.LAMINATED, + tape_empty: bool = False, + cover_open: bool = False, + offline: bool = False, + ) -> None: + self.host = host + self._loaded_tape_mm = loaded_tape_mm + self._loaded_media_type = loaded_media_type + self._tape_empty = tape_empty + self._cover_open = cover_open + self._offline = offline + self.printed_images: list[Image.Image] = [] + self.status_query_count: int = 0 + + @classmethod + def from_settings(cls, settings: object) -> "MockPrinterBackend": # noqa: ARG003 + """Settings are ignored — mock is environment-agnostic.""" + return cls() + + async def query_status(self) -> StatusBlock: + self.status_query_count += 1 + if self._offline: + raise PrinterOfflineError(f"mock backend marked offline at {self.host!r}") + return StatusBlock( + tape_empty=self._tape_empty, + cover_open=self._cover_open, + error_flags=0, + loaded_tape_mm=self._loaded_tape_mm, + media_type=self._loaded_media_type, + ) + + async def print_image( + self, + image: Image.Image, + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + ) -> None: + status = await self.query_status() + if status.tape_empty: + raise TapeEmptyError() + if status.cover_open: + raise PrinterCoverOpenError() + if status.loaded_tape_mm != tape_spec.width_mm: + raise TapeMismatchError( + expected_mm=tape_spec.width_mm, + loaded_mm=status.loaded_tape_mm, + ) + self.printed_images.append(image.copy()) +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/printer_backends/test_mock_backend.py -q +``` + +Expected: 7 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/printer_backends/mock_backend.py \ + backend/tests/unit/printer_backends/test_mock_backend.py +git commit -m "$(cat <<'EOF' +feat(printer-backends): MockPrinterBackend + +In-memory PrinterBackend used by unit/integration tests and by local +dev runs without real hardware (PRINTER_HUB_PRINTER_BACKEND=mock). + +Failure modes are constructor flags: offline, tape_empty, cover_open, +loaded_tape_mm. The backend records every printed image so tests can +assert dimensions and order. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 5 — BackendRegistry with entry_points + +### Task 5.1: BackendRegistry + ensure_discovered + find_by_backend_id + +**Files:** +- Modify: `backend/app/printer_backends/__init__.py` +- Create: `backend/tests/unit/printer_backends/test_registry.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/printer_backends/test_registry.py +from __future__ import annotations + +import pytest + +from app.printer_backends import BackendRegistry, UnknownBackendError +from app.printer_backends.mock_backend import MockPrinterBackend + + +@pytest.fixture(autouse=True) +def reset_registry() -> None: + BackendRegistry._factories.clear() + BackendRegistry._discovered = False + + +def test_register_and_find_by_backend_id() -> None: + BackendRegistry.register("mock", MockPrinterBackend) + assert BackendRegistry.find_by_backend_id("mock") is MockPrinterBackend + + +def test_unknown_backend_raises_with_registered_list() -> None: + BackendRegistry.register("mock", MockPrinterBackend) + with pytest.raises(UnknownBackendError) as exc: + BackendRegistry.find_by_backend_id("zebra-zpl") + msg = str(exc.value) + assert "zebra-zpl" in msg + assert "mock" in msg # available options listed + + +def test_duplicate_registration_rejected() -> None: + BackendRegistry.register("mock", MockPrinterBackend) + with pytest.raises(ValueError): + BackendRegistry.register("mock", MockPrinterBackend) + + +def test_ensure_discovered_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: + calls = {"n": 0} + + def fake_iter(group: str): + calls["n"] += 1 + return [] + + monkeypatch.setattr( + "app.printer_backends.entry_points", + fake_iter, + ) + BackendRegistry.ensure_discovered() + BackendRegistry.ensure_discovered() + assert calls["n"] == 1 # second call short-circuits + + +def test_entry_point_discovery_registers_backend(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeEntryPoint: + name = "mock" + + def load(self) -> type[MockPrinterBackend]: + return MockPrinterBackend + + def fake_iter(group: str): + assert group == "label_hub.printer_backends" + return [FakeEntryPoint()] + + monkeypatch.setattr("app.printer_backends.entry_points", fake_iter) + BackendRegistry.ensure_discovered() + assert BackendRegistry.find_by_backend_id("mock") is MockPrinterBackend +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/printer_backends/test_registry.py -q +``` + +Expected: `ImportError: cannot import name 'BackendRegistry'`. + +- [ ] **Step 3: Implement** + +```python +# backend/app/printer_backends/__init__.py +"""Printer-backend layer + plugin registry. + +Built-in backends (`ptouch`, `mock`) ship pre-registered via setuptools +entry_points (group `label_hub.printer_backends`). Third-party backends +register the same way from their own pip package, with zero core changes. +""" + +from __future__ import annotations + +import logging +from importlib.metadata import entry_points +from typing import ClassVar, Protocol + + +class UnknownBackendError(Exception): + """Raised when settings.printer_backend names a backend that is not registered.""" + + +class _BackendFactory(Protocol): + """Class object that exposes from_settings(settings) -> PrinterBackend.""" + + backend_id: str + + @classmethod + def from_settings(cls, settings: object) -> object: ... + + +_logger = logging.getLogger(__name__) + + +class BackendRegistry: + """Class-level registry of PrinterBackend factory classes.""" + + _factories: ClassVar[dict[str, type]] = {} + _discovered: ClassVar[bool] = False + + @classmethod + def register(cls, backend_id: str, factory: type) -> None: + if backend_id in cls._factories: + raise ValueError(f"backend_id {backend_id!r} is already registered") + cls._factories[backend_id] = factory + + @classmethod + def find_by_backend_id(cls, backend_id: str) -> type: + try: + return cls._factories[backend_id] + except KeyError as exc: + available = ", ".join(sorted(cls._factories)) or "" + raise UnknownBackendError( + f"Unknown printer_backend {backend_id!r}. Available: {available}" + ) from exc + + @classmethod + def ensure_discovered(cls) -> None: + """Walk the `label_hub.printer_backends` entry-points group once.""" + if cls._discovered: + return + cls._discovered = True + for ep in entry_points(group="label_hub.printer_backends"): + try: + factory_cls = ep.load() + except Exception: + _logger.exception("Failed to load printer-backend entry-point %r", ep.name) + continue + try: + cls.register(ep.name, factory_cls) + except (ValueError, TypeError): + _logger.exception("Failed to register printer-backend %r", ep.name) +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/printer_backends/test_registry.py -q +``` + +Expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/printer_backends/__init__.py \ + backend/tests/unit/printer_backends/test_registry.py +git commit -m "$(cat <<'EOF' +feat(printer-backends): BackendRegistry + entry_points discovery + +Class-level registry of backend factory classes, populated at app +start via setuptools entry_points (group label_hub.printer_backends). +ensure_discovered() is idempotent. find_by_backend_id raises +UnknownBackendError with the list of registered options when the +requested backend is missing. + +Third-party backends ship as pip packages declaring an entry-point +in this group; no core changes required. + +Refs #22 +EOF +)" +``` + +### Task 5.2: Register built-in `mock` backend via pyproject.toml entry-points + +**Files:** +- Modify: `backend/pyproject.toml` +- Create: `backend/tests/unit/printer_backends/test_builtin_registration.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/printer_backends/test_builtin_registration.py +from __future__ import annotations + +from importlib.metadata import entry_points + + +def test_mock_backend_is_declared_in_entry_points() -> None: + names = {ep.name for ep in entry_points(group="label_hub.printer_backends")} + assert "mock" in names +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/printer_backends/test_builtin_registration.py -q +``` + +Expected: `AssertionError: assert 'mock' in set()`. + +- [ ] **Step 3: Implement — add to pyproject.toml** + +Insert the new entry-points group below the existing `[project.entry-points."label_hub.integrations"]` block: + +```toml +[project.entry-points."label_hub.printer_backends"] +mock = "app.printer_backends.mock_backend:MockPrinterBackend" +``` + +(`ptouch` will be added in Phase 6 once `PTouchBackend` exists.) + +- [ ] **Step 4: Reinstall to refresh entry-points + verify** + +```bash +cd backend +pip install -e . +pytest tests/unit/printer_backends/test_builtin_registration.py -q +``` + +Expected: 1 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/pyproject.toml \ + backend/tests/unit/printer_backends/test_builtin_registration.py +git commit -m "$(cat <<'EOF' +feat(printer-backends): register mock backend in entry_points + +Declares mock = app.printer_backends.mock_backend:MockPrinterBackend +under the label_hub.printer_backends group so BackendRegistry. +ensure_discovered() picks it up at app start. + +ptouch backend will be registered alongside in a later commit once +PTouchBackend exists. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 6 — PTouchBackend (status query + print) + +### Task 6.1: Status-query helper (ESC i S over asyncio socket) + +**Files:** +- Create: `backend/app/printer_backends/status_query.py` +- Create: `backend/tests/unit/printer_backends/test_status_query.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/printer_backends/test_status_query.py +from __future__ import annotations + +import asyncio +import pytest + +from app.printer_backends.exceptions import ( + PrinterOfflineError, + StatusQueryFailedError, +) +from app.printer_backends.status_query import ( + ESC_I_S_REQUEST, + parse_status_reply, + query_status_over_socket, +) +from app.services.status_block import MediaType + + +def test_esc_i_s_request_bytes() -> None: + assert ESC_I_S_REQUEST == b"\x1bi\x53" # ESC, 'i', 'S' (0x53) + assert len(ESC_I_S_REQUEST) == 3 + + +def test_parse_reply_happy_path() -> None: + reply = bytearray(32) + reply[0] = 0x80 # head mark + reply[1] = 0x20 # size = 32 + reply[2] = ord("B") + reply[8] = 0x00 # error info 1 + reply[9] = 0x00 # error info 2 + reply[10] = 24 # tape width mm + reply[11] = 0x01 # laminated + sb = parse_status_reply(bytes(reply)) + assert sb.loaded_tape_mm == 24 + assert sb.media_type == MediaType.LAMINATED + assert sb.tape_empty is False + assert sb.cover_open is False + assert sb.error_flags == 0 + + +def test_parse_reply_tape_empty_flag() -> None: + reply = bytearray(32) + reply[0] = 0x80 + reply[1] = 0x20 + reply[2] = ord("B") + reply[8] = 0x01 # bit 0 = no media + sb = parse_status_reply(bytes(reply)) + assert sb.tape_empty is True + + +def test_parse_reply_cover_open_flag() -> None: + reply = bytearray(32) + reply[0] = 0x80 + reply[1] = 0x20 + reply[2] = ord("B") + reply[9] = 0x10 # bit 4 = cover open + sb = parse_status_reply(bytes(reply)) + assert sb.cover_open is True + + +def test_parse_reply_wrong_length_raises() -> None: + with pytest.raises(StatusQueryFailedError): + parse_status_reply(b"\x00" * 16) + + +def test_parse_reply_bad_head_marker_raises() -> None: + reply = bytearray(32) + reply[0] = 0xFF # wrong head mark + with pytest.raises(StatusQueryFailedError): + parse_status_reply(bytes(reply)) + + +async def test_query_status_over_socket_uses_open_connection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify the helper uses asyncio.open_connection (non-blocking I/O).""" + captured: dict[str, object] = {} + + class FakeReader: + async def readexactly(self, n: int) -> bytes: + captured["read_n"] = n + reply = bytearray(32) + reply[0] = 0x80 + reply[1] = 0x20 + reply[2] = ord("B") + reply[10] = 24 + reply[11] = 0x01 + return bytes(reply) + + class FakeWriter: + def write(self, data: bytes) -> None: + captured["wrote"] = data + + async def drain(self) -> None: + captured["drained"] = True + + def close(self) -> None: + captured["closed"] = True + + async def wait_closed(self) -> None: + captured["wait_closed"] = True + + async def fake_open_connection(host: str, port: int): # noqa: ARG001 + captured["host"] = host + captured["port"] = port + return FakeReader(), FakeWriter() + + monkeypatch.setattr("asyncio.open_connection", fake_open_connection) + sb = await query_status_over_socket("1.2.3.4", 9100, timeout_s=1.0) + assert captured["host"] == "1.2.3.4" + assert captured["port"] == 9100 + assert captured["wrote"] == ESC_I_S_REQUEST + assert captured["drained"] is True + assert captured["closed"] is True + assert captured["wait_closed"] is True + assert captured["read_n"] == 32 + assert sb.loaded_tape_mm == 24 + + +async def test_query_status_offline_raises(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_open_connection(*_a, **_kw): + raise ConnectionRefusedError("nope") + + monkeypatch.setattr("asyncio.open_connection", fake_open_connection) + with pytest.raises(PrinterOfflineError): + await query_status_over_socket("1.2.3.4", 9100, timeout_s=0.1) + + +async def test_query_status_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_open_connection(*_a, **_kw): + await asyncio.sleep(10) # will be cancelled by timeout + raise AssertionError("unreachable") + + monkeypatch.setattr("asyncio.open_connection", fake_open_connection) + with pytest.raises(PrinterOfflineError): + await query_status_over_socket("1.2.3.4", 9100, timeout_s=0.01) +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/printer_backends/test_status_query.py -q +``` + +Expected: `ImportError: cannot import name 'ESC_I_S_REQUEST'`. + +- [ ] **Step 3: Implement** + +```python +# backend/app/printer_backends/status_query.py +"""Brother PT-Series status query — ESC i S over a raw asyncio socket. + +Sends a 3-byte command (0x1B 0x69 0x53) and parses the 32-byte reply per the +Brother Raster Command Reference (PT-Series). The ptouch library does not +expose this — only an internal _cmd_print_information send command exists. + +See backend/docs/brother-status-block.md for the wire format. +""" + +from __future__ import annotations + +import asyncio + +from app.printer_backends.exceptions import ( + PrinterOfflineError, + StatusQueryFailedError, +) +from app.services.status_block import MediaType, StatusBlock + +ESC_I_S_REQUEST: bytes = b"\x1bi\x53" +_STATUS_REPLY_LEN: int = 32 +_HEAD_MARK: int = 0x80 +_SIZE_BYTE: int = 0x20 +_BRAND_BYTE: int = ord("B") + +_MEDIA_TYPE_LOOKUP: dict[int, MediaType] = { + 0x00: MediaType.NONE, + 0x01: MediaType.LAMINATED, + 0x03: MediaType.NON_LAMINATED, + 0x11: MediaType.HEAT_SHRINK_2_1, + 0x17: MediaType.HEAT_SHRINK_3_1, +} + + +def parse_status_reply(reply: bytes) -> StatusBlock: + """Parse the 32-byte ESC i S response. Raise StatusQueryFailedError if malformed.""" + if len(reply) != _STATUS_REPLY_LEN: + raise StatusQueryFailedError( + f"Expected {_STATUS_REPLY_LEN} bytes, got {len(reply)}" + ) + if reply[0] != _HEAD_MARK or reply[2] != _BRAND_BYTE: + raise StatusQueryFailedError( + f"Bad reply header: head={reply[0]:#x} brand={reply[2]:#x}" + ) + err1 = reply[8] + err2 = reply[9] + return StatusBlock( + tape_empty=bool(err1 & 0x03), # bit 0 (no media) | bit 1 (end of media) + cover_open=bool(err2 & 0x10), # bit 4 + error_flags=(err1 << 8) | err2, + loaded_tape_mm=reply[10], + media_type=_MEDIA_TYPE_LOOKUP.get(reply[11], MediaType.NONE), + ) + + +async def query_status_over_socket( + host: str, + port: int = 9100, + *, + timeout_s: float = 5.0, +) -> StatusBlock: + """Open a TCP connection, write ESC i S, read 32 bytes, parse.""" + try: + async with asyncio.timeout(timeout_s): + reader, writer = await asyncio.open_connection(host, port) + except (OSError, asyncio.TimeoutError) as exc: + raise PrinterOfflineError(f"cannot reach {host}:{port}: {exc}") from exc + + try: + writer.write(ESC_I_S_REQUEST) + await writer.drain() + try: + async with asyncio.timeout(timeout_s): + reply = await reader.readexactly(_STATUS_REPLY_LEN) + except (OSError, asyncio.TimeoutError, asyncio.IncompleteReadError) as exc: + raise PrinterOfflineError(f"status read failed: {exc}") from exc + finally: + writer.close() + try: + await writer.wait_closed() + except OSError: + pass + + return parse_status_reply(reply) +``` + +- [ ] **Step 4: Verify MediaType enum has the values used above** + +```bash +grep -n "^class MediaType\| [A-Z_]* =" backend/app/services/status_block.py | head -10 +``` + +If `NONE` / `NON_LAMINATED` / `HEAT_SHRINK_2_1` / `HEAT_SHRINK_3_1` are missing, file a follow-up issue — they are part of `status_block.MediaType` per the design and the existing renderer code. (Adjust the `_MEDIA_TYPE_LOOKUP` dict to match the actual enum members if the names diverge.) + +- [ ] **Step 5: Run — verify pass** + +```bash +cd backend && pytest tests/unit/printer_backends/test_status_query.py -q +``` + +Expected: 8 passed. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/printer_backends/status_query.py \ + backend/tests/unit/printer_backends/test_status_query.py +git commit -m "$(cat <<'EOF' +feat(printer-backends): ESC i S status query over asyncio socket + +Implements the wire-level Brother status query that ptouch does not +expose. Uses asyncio.open_connection so the call is non-blocking +inside an async def; flushes via drain(), closes cleanly via +close() + wait_closed() to avoid truncating mid-transfer. + +Parser decodes the 32-byte reply into a StatusBlock: loaded_tape_mm, +media_type, tape_empty (bits 0|1 of err1), cover_open (bit 4 of err2), +and the raw err_flags for diagnostics. + +Refs #22 +EOF +)" +``` + +### Task 6.2: PTouchBackend wrapping the ptouch library + +**Files:** +- Create: `backend/app/printer_backends/ptouch_backend.py` +- Create: `backend/tests/unit/printer_backends/test_ptouch_backend.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/printer_backends/test_ptouch_backend.py +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from PIL import Image + +from app.models.tape import TapeSpec +from app.printer_backends.base import PrinterBackend +from app.printer_backends.exceptions import ( + PrinterCoverOpenError, + PrinterOfflineError, + PrintFailedError, + TapeEmptyError, + TapeMismatchError, +) +from app.printer_backends.ptouch_backend import PTouchBackend +from app.services.status_block import MediaType, StatusBlock + + +@pytest.fixture +def tape_24() -> TapeSpec: + return TapeSpec( + width_mm=24, + media_type=MediaType.LAMINATED, + print_area_pins=128, + print_area_dots=128, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=1000, + cutter_min_length_mm=24.5, + ) + + +@pytest.fixture +def img_128() -> Image.Image: + return Image.new("1", (200, 128)) + + +@pytest.fixture +def healthy_status() -> StatusBlock: + return StatusBlock( + tape_empty=False, + cover_open=False, + error_flags=0, + loaded_tape_mm=24, + media_type=MediaType.LAMINATED, + ) + + +def test_satisfies_protocol() -> None: + assert isinstance(PTouchBackend(host="1.2.3.4"), PrinterBackend) + + +def test_backend_id() -> None: + assert PTouchBackend(host="x").backend_id == "ptouch" + + +async def test_query_status_delegates_to_socket_helper( + monkeypatch: pytest.MonkeyPatch, healthy_status: StatusBlock +) -> None: + async def fake_query(host: str, port: int, *, timeout_s: float) -> StatusBlock: + assert host == "10.0.0.5" + assert port == 9100 + return healthy_status + + monkeypatch.setattr( + "app.printer_backends.ptouch_backend.query_status_over_socket", + fake_query, + ) + backend = PTouchBackend(host="10.0.0.5") + status = await backend.query_status() + assert status is healthy_status + + +async def test_query_status_retries_on_offline(monkeypatch: pytest.MonkeyPatch) -> None: + attempts = {"n": 0} + + async def fake_query(*_a, **_kw): + attempts["n"] += 1 + raise PrinterOfflineError("nope") + + async def fast_sleep(_s: float) -> None: + return None + + monkeypatch.setattr( + "app.printer_backends.ptouch_backend.query_status_over_socket", + fake_query, + ) + monkeypatch.setattr("asyncio.sleep", fast_sleep) + backend = PTouchBackend(host="x") + with pytest.raises(PrinterOfflineError): + await backend.query_status() + assert attempts["n"] == 3 + + +async def test_print_image_validates_status_first( + monkeypatch: pytest.MonkeyPatch, + img_128: Image.Image, + tape_24: TapeSpec, +) -> None: + """tape_empty status must raise BEFORE invoking the ptouch printer.""" + bad_status = StatusBlock( + tape_empty=True, + cover_open=False, + error_flags=1, + loaded_tape_mm=0, + media_type=MediaType.NONE, + ) + + async def fake_query(*_a, **_kw): + return bad_status + + monkeypatch.setattr( + "app.printer_backends.ptouch_backend.query_status_over_socket", + fake_query, + ) + ptouch_print = MagicMock() + monkeypatch.setattr( + "app.printer_backends.ptouch_backend._ptouch_print", + ptouch_print, + ) + backend = PTouchBackend(host="x") + with pytest.raises(TapeEmptyError): + await backend.print_image(img_128, tape_24) + ptouch_print.assert_not_called() + + +async def test_print_image_raises_tape_mismatch( + monkeypatch: pytest.MonkeyPatch, + img_128: Image.Image, + tape_24: TapeSpec, +) -> None: + async def fake_query(*_a, **_kw): + return StatusBlock( + tape_empty=False, cover_open=False, error_flags=0, + loaded_tape_mm=12, media_type=MediaType.LAMINATED, + ) + + monkeypatch.setattr( + "app.printer_backends.ptouch_backend.query_status_over_socket", + fake_query, + ) + backend = PTouchBackend(host="x") + with pytest.raises(TapeMismatchError) as exc: + await backend.print_image(img_128, tape_24) + assert exc.value.expected_mm == 24 + assert exc.value.loaded_mm == 12 + + +async def test_print_image_invokes_ptouch_when_healthy( + monkeypatch: pytest.MonkeyPatch, + img_128: Image.Image, + tape_24: TapeSpec, + healthy_status: StatusBlock, +) -> None: + async def fake_query(*_a, **_kw): + return healthy_status + + monkeypatch.setattr( + "app.printer_backends.ptouch_backend.query_status_over_socket", + fake_query, + ) + captured: dict[str, Any] = {} + + def fake_print(host: str, port: int, image, tape_mm, *, auto_cut, high_resolution): + captured["host"] = host + captured["port"] = port + captured["tape_mm"] = tape_mm + captured["auto_cut"] = auto_cut + captured["high_resolution"] = high_resolution + + monkeypatch.setattr( + "app.printer_backends.ptouch_backend._ptouch_print", + fake_print, + ) + backend = PTouchBackend(host="10.0.0.5") + await backend.print_image(img_128, tape_24, auto_cut=True, high_resolution=False) + assert captured["host"] == "10.0.0.5" + assert captured["port"] == 9100 + assert captured["tape_mm"] == 24 + assert captured["auto_cut"] is True + + +async def test_print_image_wraps_ptouch_exception( + monkeypatch: pytest.MonkeyPatch, + img_128: Image.Image, + tape_24: TapeSpec, + healthy_status: StatusBlock, +) -> None: + import ptouch as _ptouch_mod + + async def fake_query(*_a, **_kw): + return healthy_status + + def fake_print(*_a, **_kw): + raise _ptouch_mod.PrinterWriteError("disk full") + + monkeypatch.setattr( + "app.printer_backends.ptouch_backend.query_status_over_socket", + fake_query, + ) + monkeypatch.setattr( + "app.printer_backends.ptouch_backend._ptouch_print", + fake_print, + ) + backend = PTouchBackend(host="x") + with pytest.raises(PrintFailedError) as exc: + await backend.print_image(img_128, tape_24) + assert "disk full" in str(exc.value) + + +def test_from_settings_reads_pt750w_host() -> None: + class S: + pt750w_host = "10.0.0.5" + pt750w_port = 9100 + printer_model = "PT-P750W" + + backend = PTouchBackend.from_settings(S()) # type: ignore[arg-type] + assert backend.host == "10.0.0.5" + + +def test_from_settings_empty_host_raises() -> None: + class S: + pt750w_host = "" + pt750w_port = 9100 + printer_model = "PT-P750W" + + with pytest.raises(ValueError, match="pt750w_host"): + PTouchBackend.from_settings(S()) # type: ignore[arg-type] +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/printer_backends/test_ptouch_backend.py -q +``` + +Expected: `ModuleNotFoundError: No module named 'app.printer_backends.ptouch_backend'`. + +- [ ] **Step 3: Implement** + +```python +# backend/app/printer_backends/ptouch_backend.py +"""PTouchBackend — wraps the `ptouch` Python library for Brother PT-Series. + +Status queries go through query_status_over_socket (the library does not +expose them). Print calls go through ptouch.LabelPrinter.print() inside +asyncio.to_thread (the library is synchronous). All ptouch exceptions are +caught and rewrapped as our PrinterError subtypes. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import ptouch +from PIL import Image + +from app.models.tape import TapeSpec +from app.printer_backends.exceptions import ( + PrintFailedError, + PrinterOfflineError, + PrinterCoverOpenError, + TapeEmptyError, + TapeMismatchError, +) +from app.printer_backends.status_query import query_status_over_socket +from app.services.status_block import StatusBlock + +_logger = logging.getLogger(__name__) + +_RETRY_BACKOFFS: tuple[float, ...] = (0.0, 1.0, 2.0) + +# Lookup of model_id -> ptouch printer class. PT-P750W and a small set of +# sibling models that share the same wire protocol. Extend as needed. +_PTOUCH_PRINTER_CLASSES: dict[str, type] = { + "PT-P750W": ptouch.PTP750W, + "PT-E550W": ptouch.PTE550W, + "PT-P900": ptouch.PTP900, + "PT-P900W": ptouch.PTP900W, + "PT-P910BT": ptouch.PTP910BT, + "PT-P950NW": ptouch.PTP950NW, +} + +# Lookup of tape_mm -> ptouch laminated tape class. The PTouchBackend +# defaults to laminated; non-laminated / heat-shrink variants pick a +# different class in a future media_type-aware revision. +_PTOUCH_TAPE_CLASSES: dict[int, type] = { + 4: ptouch.LaminatedTape3_5mm, + 6: ptouch.LaminatedTape6mm, + 9: ptouch.LaminatedTape9mm, + 12: ptouch.LaminatedTape12mm, + 18: ptouch.LaminatedTape18mm, + 24: ptouch.LaminatedTape24mm, + 36: ptouch.LaminatedTape36mm, +} + + +def _ptouch_print( + host: str, + port: int, + image: Image.Image, + tape_mm: int, + *, + auto_cut: bool, + high_resolution: bool, +) -> None: + """Synchronous helper: open connection, send one Label, close. + + Lives at module level so tests can monkeypatch it. + """ + try: + tape_cls = _PTOUCH_TAPE_CLASSES[tape_mm] + except KeyError as exc: + raise PrintFailedError(f"No ptouch tape class for {tape_mm}mm") from exc + connection = ptouch.ConnectionNetwork(host, port=port, timeout=10.0) + printer = ptouch.PTP750W( + connection=connection, + high_resolution=high_resolution, + ) + label = ptouch.Label(image=image, tape=tape_cls) + printer.print(label, auto_cut=auto_cut, high_resolution=high_resolution) + + +class PTouchBackend: + """PrinterBackend backed by the ptouch library.""" + + backend_id = "ptouch" + + def __init__(self, host: str, *, port: int = 9100, model_id: str = "PT-P750W") -> None: + if not host: + raise ValueError("PTouchBackend requires a non-empty host") + if model_id not in _PTOUCH_PRINTER_CLASSES: + raise ValueError( + f"Unknown printer_model {model_id!r}; " + f"known: {sorted(_PTOUCH_PRINTER_CLASSES)}" + ) + self.host = host + self._port = port + self._model_id = model_id + + @classmethod + def from_settings(cls, settings: Any) -> "PTouchBackend": + host = getattr(settings, "pt750w_host", "") or "" + if not host: + raise ValueError( + "Empty pt750w_host with printer_backend=ptouch — " + "set PRINTER_HUB_PT750W_HOST to the printer's IP/hostname." + ) + return cls( + host=host, + port=int(getattr(settings, "pt750w_port", 9100)), + model_id=str(getattr(settings, "printer_model", "PT-P750W")), + ) + + async def query_status(self) -> StatusBlock: + last_exc: Exception | None = None + for delay in _RETRY_BACKOFFS: + if delay: + _logger.warning("retrying status query in %.1fs", delay) + await asyncio.sleep(delay) + try: + return await query_status_over_socket(self.host, self._port, timeout_s=5.0) + except PrinterOfflineError as exc: + last_exc = exc + assert last_exc is not None + raise last_exc + + async def print_image( + self, + image: Image.Image, + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + ) -> None: + status = await self.query_status() + if status.tape_empty: + raise TapeEmptyError() + if status.cover_open: + raise PrinterCoverOpenError() + if status.loaded_tape_mm != tape_spec.width_mm: + raise TapeMismatchError( + expected_mm=tape_spec.width_mm, + loaded_mm=status.loaded_tape_mm, + ) + + try: + await asyncio.to_thread( + _ptouch_print, + self.host, + self._port, + image, + tape_spec.width_mm, + auto_cut=auto_cut, + high_resolution=high_resolution, + ) + except ( + ptouch.PrinterConnectionError, + ptouch.PrinterNetworkError, + ptouch.PrinterTimeoutError, + ptouch.PrinterNotFoundError, + ) as exc: + raise PrinterOfflineError(str(exc)) from exc + except (ptouch.PrinterWriteError, ptouch.PrinterPermissionError) as exc: + raise PrintFailedError(str(exc)) from exc +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/printer_backends/test_ptouch_backend.py -q +``` + +Expected: 9 passed. + +- [ ] **Step 5: Register ptouch in pyproject.toml entry-points** + +Add to the `[project.entry-points."label_hub.printer_backends"]` block: + +```toml +ptouch = "app.printer_backends.ptouch_backend:PTouchBackend" +``` + +Reinstall + verify: + +```bash +cd backend && pip install -e . && python -c " +from importlib.metadata import entry_points +print(sorted(ep.name for ep in entry_points(group='label_hub.printer_backends'))) +" +``` + +Expected: `['mock', 'ptouch']`. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/printer_backends/ptouch_backend.py \ + backend/tests/unit/printer_backends/test_ptouch_backend.py \ + backend/pyproject.toml +git commit -m "$(cat <<'EOF' +feat(printer-backends): PTouchBackend wrapping ptouch library + +Implements PrinterBackend against the ptouch Python library: + +* query_status — uses the raw-socket ESC i S helper, retries 3 times + with back-off (0s, 1s, 2s) on PrinterOfflineError. +* print_image — pre-validates against query_status (tape_empty, + cover_open, tape mismatch); dispatches the synchronous ptouch.print + via asyncio.to_thread; wraps ptouch's exception family into our + PrinterError subtypes. +* from_settings — reads pt750w_host / pt750w_port and looks up the + ptouch printer class from printer_model; raises clearly on empty + host or unknown model. + +Built-in ptouch backend registered under the label_hub.printer_backends +entry-points group. + +Refs #22 +EOF +)" +``` + +### Task 6.3: SNMP helper — query_model_pjl + query_live_status + +**Files:** +- Create: `backend/app/printer_backends/snmp_helper.py` +- Create: `backend/tests/unit/printer_backends/test_snmp_helper.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/printer_backends/test_snmp_helper.py +from __future__ import annotations + +from typing import Any + +import pytest + +from app.printer_backends.exceptions import SnmpDiscoveryError, SnmpQueryError +from app.printer_backends.snmp_helper import ( + BROTHER_PJL_OID, + HR_PRINTER_DETECTED_ERROR_STATE_OID, + HR_PRINTER_STATUS_OID, + LiveStatus, + decode_error_flags, + query_live_status, + query_model_pjl, +) + + +def test_oid_constants() -> None: + assert BROTHER_PJL_OID == "1.3.6.1.4.1.2435.2.3.9.1.1.7.0" + assert HR_PRINTER_STATUS_OID == "1.3.6.1.2.1.25.3.5.1.1.1" + assert HR_PRINTER_DETECTED_ERROR_STATE_OID == "1.3.6.1.2.1.25.3.5.1.2.1" + + +def test_decode_error_flags_no_paper() -> None: + # Byte 0 bit 1 (0x40) = noPaper + assert "noPaper" in decode_error_flags(b"\x40\x00") + + +def test_decode_error_flags_door_open() -> None: + # Byte 0 bit 4 (0x08) = doorOpen + assert "doorOpen" in decode_error_flags(b"\x08\x00") + + +def test_decode_error_flags_jammed() -> None: + # Byte 0 bit 5 (0x04) = jammed + assert "jammed" in decode_error_flags(b"\x04\x00") + + +def test_decode_error_flags_empty_when_no_bits() -> None: + assert decode_error_flags(b"\x00\x00") == [] + + +async def test_query_model_pjl_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: + """Stubbed pysnmp.get_cmd returns a PJL string for the Brother private OID.""" + expected_pjl = "MFG:Brother;CMD:PJL;MDL:PT-P750W;CLS:PRINTER;DES:Brother PT-P750W;" + captured: dict[str, Any] = {} + + async def fake_get_cmd(engine, community, transport, ctx, *oids): # noqa: ARG001 + from pysnmp.smi import rfc1902 + # Inspect args + captured["oids"] = [str(oid[0]) for oid in oids] + captured["community"] = community.communityName + # Return (errorIndication, errorStatus, errorIndex, varBinds) + ok_pdu = (None, None, 0, [(oids[0][0], rfc1902.OctetString(expected_pjl))]) + return ok_pdu + + monkeypatch.setattr("app.printer_backends.snmp_helper.get_cmd", fake_get_cmd) + pjl = await query_model_pjl("10.0.0.5", community="public", timeout_s=1.0) + assert pjl == expected_pjl + assert BROTHER_PJL_OID in captured["oids"][0] + + +async def test_query_model_pjl_unreachable_raises(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_get_cmd(*_a, **_kw): + return ("requestTimedOut", None, 0, []) + + monkeypatch.setattr("app.printer_backends.snmp_helper.get_cmd", fake_get_cmd) + with pytest.raises(SnmpDiscoveryError, match="timed out"): + await query_model_pjl("10.0.0.5", community="public", timeout_s=1.0) + + +async def test_query_live_status_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: + """Stubbed pysnmp returns hrPrinterStatus=4 (printing) + errorState bytes.""" + from pysnmp.smi import rfc1902 + + async def fake_get_cmd(engine, community, transport, ctx, *oids): # noqa: ARG001 + return ( + None, None, 0, + [ + (oids[0][0], rfc1902.Integer(4)), # printing + (oids[1][0], rfc1902.OctetString(b"\x40\x00")), # noPaper bit + ], + ) + + monkeypatch.setattr("app.printer_backends.snmp_helper.get_cmd", fake_get_cmd) + ls = await query_live_status("10.0.0.5", community="public", timeout_s=1.0) + assert isinstance(ls, LiveStatus) + assert ls.hr_printer_status == "printing" + assert "noPaper" in ls.error_flags + + +async def test_query_live_status_failure_is_separate_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_get_cmd(*_a, **_kw): + return ("requestTimedOut", None, 0, []) + + monkeypatch.setattr("app.printer_backends.snmp_helper.get_cmd", fake_get_cmd) + with pytest.raises(SnmpQueryError): + await query_live_status("10.0.0.5", community="public", timeout_s=1.0) +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/printer_backends/test_snmp_helper.py -q +``` + +Expected: `ModuleNotFoundError: No module named 'app.printer_backends.snmp_helper'`. + +- [ ] **Step 3: Implement** + +```python +# backend/app/printer_backends/snmp_helper.py +"""SNMP query helpers — discovery (PJL string) + live status. + +Uses pysnmp's asyncio API; the call is fully non-blocking, no thread +dispatch needed. SNMPv2c with a configurable community (default 'public'). +The PT-P750W lives on the LAN/Tailscale, not the open internet, so v2c +is fine here. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Literal + +from pysnmp.hlapi.v3arch.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + UdpTransportTarget, + get_cmd, +) + +from app.printer_backends.exceptions import SnmpDiscoveryError, SnmpQueryError + +_log = logging.getLogger(__name__) + +BROTHER_PJL_OID = "1.3.6.1.4.1.2435.2.3.9.1.1.7.0" +HR_PRINTER_STATUS_OID = "1.3.6.1.2.1.25.3.5.1.1.1" +HR_PRINTER_DETECTED_ERROR_STATE_OID = "1.3.6.1.2.1.25.3.5.1.2.1" + +_PRINTER_STATUS_MAP: dict[int, Literal["other", "unknown", "idle", "printing", "warmup"]] = { + 1: "other", + 2: "unknown", + 3: "idle", + 4: "printing", + 5: "warmup", +} + +# (byte_index, bit_mask, name) — MSB first per RFC 1759 +_ERROR_BITS: tuple[tuple[int, int, str], ...] = ( + (0, 0x80, "lowPaper"), + (0, 0x40, "noPaper"), + (0, 0x20, "lowToner"), + (0, 0x10, "noToner"), + (0, 0x08, "doorOpen"), + (0, 0x04, "jammed"), + (0, 0x02, "offline"), + (0, 0x01, "serviceRequested"), + (1, 0x80, "inputTrayMissing"), + (1, 0x40, "outputTrayMissing"), + (1, 0x20, "markerSupplyMissing"), + (1, 0x10, "outputFull"), + (1, 0x08, "inputTrayEmpty"), + (1, 0x04, "overduePreventMaint"), +) + + +def decode_error_flags(blob: bytes) -> list[str]: + """Decode the hrPrinterDetectedErrorState OCTET STRING into bit names.""" + out: list[str] = [] + for byte_idx, mask, name in _ERROR_BITS: + if byte_idx < len(blob) and blob[byte_idx] & mask: + out.append(name) + return out + + +@dataclass(frozen=True) +class LiveStatus: + """Live phase + error flags read from SNMP during a print.""" + + hr_printer_status: Literal["other", "unknown", "idle", "printing", "warmup"] + error_flags: list[str] + + +async def query_model_pjl(host: str, *, community: str = "public", timeout_s: float = 3.0) -> str: + """Read Brother private OID → PJL identification string. + + Raises SnmpDiscoveryError on any failure (timeout, OID missing, refused). + """ + error_indication, error_status, _, var_binds = await get_cmd( + SnmpEngine(), + CommunityData(community, mpModel=1), # mpModel=1 → SNMPv2c + UdpTransportTarget((host, 161), timeout=timeout_s, retries=0), + ContextData(), + ObjectType(ObjectIdentity(BROTHER_PJL_OID)), + ) + if error_indication: + raise SnmpDiscoveryError(f"SNMP discovery timed out / failed: {error_indication}") + if error_status: + raise SnmpDiscoveryError(f"SNMP returned error status: {error_status}") + if not var_binds: + raise SnmpDiscoveryError("Empty SNMP reply for PJL OID") + return str(var_binds[0][1]) + + +async def query_live_status( + host: str, *, community: str = "public", timeout_s: float = 3.0 +) -> LiveStatus: + """Read hrPrinterStatus + hrPrinterDetectedErrorState in one round trip.""" + error_indication, error_status, _, var_binds = await get_cmd( + SnmpEngine(), + CommunityData(community, mpModel=1), + UdpTransportTarget((host, 161), timeout=timeout_s, retries=0), + ContextData(), + ObjectType(ObjectIdentity(HR_PRINTER_STATUS_OID)), + ObjectType(ObjectIdentity(HR_PRINTER_DETECTED_ERROR_STATE_OID)), + ) + if error_indication: + raise SnmpQueryError(f"SNMP live-status timed out / failed: {error_indication}") + if error_status: + raise SnmpQueryError(f"SNMP returned error status: {error_status}") + if len(var_binds) < 2: + raise SnmpQueryError("Incomplete SNMP reply") + + raw_status = int(var_binds[0][1]) + raw_error_blob = bytes(var_binds[1][1]) + return LiveStatus( + hr_printer_status=_PRINTER_STATUS_MAP.get(raw_status, "other"), + error_flags=decode_error_flags(raw_error_blob), + ) +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/printer_backends/test_snmp_helper.py -q +``` + +Expected: all 9 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/printer_backends/snmp_helper.py \ + backend/tests/unit/printer_backends/test_snmp_helper.py +git commit -m "$(cat <<'EOF' +feat(printer-backends): SNMP helpers (discovery + live status) + +Two async helpers built on pysnmp's asyncio API: + +* query_model_pjl(host) — reads Brother private OID 1.3.6.1.4.1.2435. + 2.3.9.1.1.7.0 → full PJL identification string. Used by the lifespan + to resolve the model via ModelRegistry.find_by_pjl (ADR 0004). +* query_live_status(host) — reads Host-Resources Printer MIB + hrPrinterStatus + hrPrinterDetectedErrorState in one round trip, + returns LiveStatus { hr_printer_status, error_flags } for the + /jobs/{id} response while a print is running. + +Failure modes are separate: SnmpDiscoveryError stops app start; +SnmpQueryError is non-fatal at request time (live block is omitted). +SNMPv2c with a configurable community (default 'public'); printer is +on the LAN/Tailscale, so v2c is fine. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 7 — ModelRegistry entry_points discovery + find_by_model_id + +### Task 7.1: Extend ModelRegistry + +**Files:** +- Modify: `backend/app/printer_models/registry.py` +- Modify: `backend/tests/unit/printer_models/test_registry.py` + +- [ ] **Step 1: Write the failing test (append to existing test_registry.py)** + +```python +# backend/tests/unit/printer_models/test_registry.py — APPEND +import pytest +from app.printer_models.registry import ( + ModelNotFoundError, + ModelRegistry, +) + + +class _FakeDriver: + model_id = "FAKE-001" + pjl_signatures = ["FAKE-001"] + snmp_model_oid_value_substr = "FAKE-001" + dpi = (180, 180) + print_head_pins = 128 + + def __init__(self, backend: object) -> None: + self._backend = backend + + +@pytest.fixture(autouse=True) +def reset_registry() -> None: + saved = list(ModelRegistry._models) + ModelRegistry._models.clear() + ModelRegistry._discovered = False + yield + ModelRegistry._models.clear() + ModelRegistry._models.extend(saved) + ModelRegistry._discovered = True + + +def test_find_by_model_id_returns_class() -> None: + ModelRegistry.register(_FakeDriver) + cls = ModelRegistry.find_by_model_id("FAKE-001") + assert cls is _FakeDriver + + +def test_find_by_model_id_unknown_lists_available() -> None: + ModelRegistry.register(_FakeDriver) + with pytest.raises(ModelNotFoundError) as exc: + ModelRegistry.find_by_model_id("PT-P750W") + msg = str(exc.value) + assert "PT-P750W" in msg + assert "FAKE-001" in msg + + +def test_ensure_discovered_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: + calls = {"n": 0} + + def fake_iter(group: str): + calls["n"] += 1 + assert group == "label_hub.printer_models" + return [] + + monkeypatch.setattr("app.printer_models.registry.entry_points", fake_iter) + ModelRegistry.ensure_discovered() + ModelRegistry.ensure_discovered() + assert calls["n"] == 1 + + +def test_entry_point_discovery_registers_fake_plugin(monkeypatch: pytest.MonkeyPatch) -> None: + class _EP: + name = "fake" + + def load(self) -> type[_FakeDriver]: + return _FakeDriver + + def fake_iter(group: str): + return [_EP()] + + monkeypatch.setattr("app.printer_models.registry.entry_points", fake_iter) + ModelRegistry.ensure_discovered() + assert ModelRegistry.find_by_model_id("FAKE-001") is _FakeDriver +``` + +Adjust the existing `ModelRegistry.register` test only if it stops passing — `register` semantics did not change. + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/printer_models/test_registry.py -q +``` + +Expected: missing `find_by_model_id`, `ensure_discovered`, or `entry_points` symbol. + +- [ ] **Step 3: Implement (edit registry.py)** + +Add to `backend/app/printer_models/registry.py`: + +```python +from importlib.metadata import entry_points + +# ... existing class body ... + + _discovered: ClassVar[bool] = False + + @classmethod + def find_by_model_id(cls, model_id: str) -> type: + """Return the *class* of the driver matching `model_id` (PrinterModel attr).""" + # Note: existing register() stores *instances*; we accept either form. + for entry in cls._models: + entry_cls = entry if isinstance(entry, type) else type(entry) + if getattr(entry_cls, "model_id", None) == model_id: + return entry_cls + available = ", ".join( + sorted( + { + getattr(entry if isinstance(entry, type) else type(entry), "model_id", "?") + for entry in cls._models + } + ) + ) or "" + raise ModelNotFoundError( + f"Unknown printer_model {model_id!r}. Available: {available}" + ) + + @classmethod + def ensure_discovered(cls) -> None: + """Walk the `label_hub.printer_models` entry-points group once.""" + if cls._discovered: + return + cls._discovered = True + import logging + log = logging.getLogger(__name__) + for ep in entry_points(group="label_hub.printer_models"): + try: + driver_cls = ep.load() + except Exception: + log.exception("Failed to load printer-model entry-point %r", ep.name) + continue + try: + cls.register(driver_cls) + except (ValueError, TypeError): + log.exception("Failed to register printer-model %r", ep.name) +``` + +Also relax `register` to accept either a class or an instance — drivers that come via `entry_points.load()` are class objects. Adjust the existing `register` validation accordingly: + +```python + @classmethod + def register(cls, model: PrinterModel) -> None: + """Append *model* (class or instance) to the registry.""" + target = model # accept class or instance for back-compat + if any(not sig for sig in target.pjl_signatures): + raise ValueError( + f"PrinterModel {target.model_id!r} has an empty PJL signature; " + "empty substrings match every input and would shadow other plugins" + ) + if not target.snmp_model_oid_value_substr: + raise ValueError( + f"PrinterModel {target.model_id!r} has an empty SNMP OID substring; " + "empty substrings match every input and would shadow other plugins" + ) + cls._models.append(target) +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/printer_models/test_registry.py -q +``` + +Expected: all tests pass (old + new). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/printer_models/registry.py \ + backend/tests/unit/printer_models/test_registry.py +git commit -m "$(cat <<'EOF' +feat(printer-models): find_by_model_id + entry_points discovery + +ModelRegistry gains find_by_model_id(model_id) — used by the lifespan +to resolve settings.printer_model to a driver class — and +ensure_discovered() which walks the label_hub.printer_models +entry-points group once at app start. register() now accepts class +objects too (entry-points return classes); the existing instance form +keeps working. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 8 — PTP750WDriver + make_queue_printer + _PTPQueuePrinter + +### Task 8.1: PTP750WDriver class with PrinterModel methods + bridge factory + +**Files:** +- Modify: `backend/app/printer_models/pt.py` (append) +- Create: `backend/tests/unit/printer_models/test_pt_driver.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/printer_models/test_pt_driver.py +from __future__ import annotations + +import pytest +from PIL import Image + +from app.models.tape import TapeSpec +from app.printer_backends.mock_backend import MockPrinterBackend +from app.printer_models.pt import PTP750WDriver +from app.services.print_queue import _PrinterLike +from app.services.status_block import MediaType +from app.services.tape_registry import TapeRegistry + + +@pytest.fixture +def backend() -> MockPrinterBackend: + return MockPrinterBackend(host="10.0.0.5") + + +@pytest.fixture +def tape_registry() -> TapeRegistry: + return TapeRegistry() + + +def test_constants() -> None: + assert PTP750WDriver.model_id == "PT-P750W" + assert PTP750WDriver.dpi == (180, 180) + assert PTP750WDriver.print_head_pins == 128 + assert "PT-P750W" in PTP750WDriver.pjl_signatures + assert PTP750WDriver.snmp_model_oid_value_substr == "PT-P750W" + + +async def test_query_status_delegates_to_backend(backend: MockPrinterBackend) -> None: + driver = PTP750WDriver(backend=backend) + status = await driver.query_status() + assert status.loaded_tape_mm == 24 + + +async def test_query_status_rejects_host_mismatch(backend: MockPrinterBackend) -> None: + driver = PTP750WDriver(backend=backend) + with pytest.raises(ValueError, match="bound to backend.host"): + await driver.query_status(host="999.999.999.999") + + +async def test_query_status_accepts_matching_host(backend: MockPrinterBackend) -> None: + driver = PTP750WDriver(backend=backend) + status = await driver.query_status(host=backend.host) + assert status.loaded_tape_mm == 24 + + +def test_width_to_pixels(backend: MockPrinterBackend) -> None: + driver = PTP750WDriver(backend=backend) + spec = TapeSpec( + width_mm=24, media_type=MediaType.LAMINATED, + print_area_pins=128, print_area_dots=128, bytes_per_raster=16, + min_length_mm=4.4, max_length_mm=1000, cutter_min_length_mm=24.5, + ) + assert driver.width_to_pixels(spec) == 128 + + +def test_make_queue_printer_returns_printer_like( + backend: MockPrinterBackend, tape_registry: TapeRegistry +) -> None: + driver = PTP750WDriver(backend=backend) + qp = driver.make_queue_printer(tape_registry) + assert isinstance(qp, _PrinterLike) + assert qp.id == "PT-P750W@10.0.0.5" + + +async def test_queue_printer_print_calls_backend( + backend: MockPrinterBackend, tape_registry: TapeRegistry +) -> None: + driver = PTP750WDriver(backend=backend) + qp = driver.make_queue_printer(tape_registry) + image = Image.new("1", (200, 128)) + await qp.print_image(image, tape_mm=24) + assert len(backend.printed_images) == 1 + + +async def test_queue_printer_uses_default_media_type( + backend: MockPrinterBackend, tape_registry: TapeRegistry +) -> None: + """Default is LAMINATED — explicit override is honoured.""" + driver = PTP750WDriver(backend=backend) + qp = driver.make_queue_printer( + tape_registry, default_media_type=MediaType.NON_LAMINATED + ) + # Mock has LAMINATED loaded, so NON_LAMINATED lookup should not match + # the loaded tape; the mock raises TapeMismatchError only on width mismatch, + # so this test just asserts the override is plumbed through (i.e. no crash). + image = Image.new("1", (200, 128)) + await qp.print_image(image, tape_mm=24) +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/printer_models/test_pt_driver.py -q +``` + +Expected: `ImportError: cannot import name 'PTP750WDriver'`. + +- [ ] **Step 3: Implement (append to pt.py)** + +Append to `backend/app/printer_models/pt.py`: + +```python +# === First-Print: PT-P750W driver + queue-printer bridge === + +from __future__ import annotations as _annotations # noqa: F401 (idempotent) + +import logging +from typing import Any + +from PIL import Image + +from app.printer_backends.base import PrinterBackend +from app.printer_models.registry import ModelRegistry +from app.services.status_block import MediaType, StatusBlock +from app.services.tape_registry import TapeRegistry + +_pt_log = logging.getLogger(__name__) + + +class PTP750WDriver: + """Driver for the Brother PT-P750W. Bound to one PrinterBackend at construction. + + Implements PrinterModel and provides make_queue_printer() for the queue. + """ + + model_id = "PT-P750W" + pjl_signatures = ["PT-P750W"] + snmp_model_oid_value_substr = "PT-P750W" + dpi = (180, 180) + print_head_pins = 128 + + def __init__(self, backend: PrinterBackend) -> None: + self._backend = backend + + # --- PrinterModel --- + async def query_status( + self, host: str = "", port: int = 9100, timeout_s: float = 5.0 # noqa: ARG002 + ) -> StatusBlock: + if host and host != self._backend.host: + raise ValueError( + f"Driver bound to backend.host={self._backend.host!r}; " + f"got host={host!r}. Construct a new driver/backend pair instead." + ) + return await self._backend.query_status() + + def width_to_pixels(self, tape_spec: Any) -> int: + return int(tape_spec.print_area_pins) + + def build_print_job( # noqa: ARG002 + self, image: Image.Image, tape_spec: Any, + auto_cut: bool = True, high_resolution: bool = False, + ) -> bytes: + """Encoding is owned by the backend (ptouch handles raster build). + + Callers wanting raw bytes for export/debug can be added later; the + First-Print path goes through backend.print_image() and never calls + this method. Returning empty bytes lets static analyzers see a + bytes-return path without forcing an unreachable NotImplementedError. + """ + return b"" + + # --- queue-printer factory --- + def make_queue_printer( + self, + tape_registry: TapeRegistry, + *, + default_media_type: MediaType = MediaType.LAMINATED, + ) -> "_PTPQueuePrinter": + return _PTPQueuePrinter( + driver=self, + backend=self._backend, + tape_registry=tape_registry, + default_media_type=default_media_type, + ) + + +class _PTPQueuePrinter: + """Private _PrinterLike adapter — produced by PTP750WDriver.make_queue_printer.""" + + def __init__( + self, + *, + driver: PTP750WDriver, + backend: PrinterBackend, + tape_registry: TapeRegistry, + default_media_type: MediaType, + ) -> None: + self._driver = driver + self._backend = backend + self._tape_registry = tape_registry + self._default_media_type = default_media_type + self.id = f"{driver.model_id}@{backend.host}" + + async def print_image(self, image: Image.Image, *, tape_mm: int, **options: Any) -> None: + media_type = options.pop("media_type", self._default_media_type) + tape_spec = self._tape_registry.lookup_pt(tape_mm, media_type) + await self._backend.print_image( + image, + tape_spec, + auto_cut=bool(options.pop("auto_cut", True)), + high_resolution=bool(options.pop("high_resolution", False)), + ) + + +# Module-level registration so import-time discovery sees the built-in driver. +ModelRegistry.register(PTP750WDriver) +``` + +- [ ] **Step 4: Register driver in pyproject.toml entry-points** + +Add a new entry-points block in `backend/pyproject.toml`: + +```toml +[project.entry-points."label_hub.printer_models"] +pt-series = "app.printer_models.pt" +``` + +The entry-point loads the module — module-level `ModelRegistry.register(PTP750WDriver)` does the actual registration. (entry-points group is walked once at app start; loading the module triggers registration.) + +Reinstall to refresh metadata: + +```bash +cd backend && pip install -e . +``` + +- [ ] **Step 5: Run — verify pass** + +```bash +cd backend && pytest tests/unit/printer_models/test_pt_driver.py -q +``` + +Expected: 8 passed. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/printer_models/pt.py \ + backend/tests/unit/printer_models/test_pt_driver.py \ + backend/pyproject.toml +git commit -m "$(cat <<'EOF' +feat(printer-models): PTP750WDriver + queue-printer factory + +PT-P750W driver implements PrinterModel (model_id, dpi, pins, PJL/SNMP +signatures, width_to_pixels, query_status, build_print_job) and exposes +make_queue_printer(tape_registry, default_media_type=LAMINATED) which +produces a private _PTPQueuePrinter satisfying PrintQueue._PrinterLike. + +query_status raises ValueError on a non-matching host argument rather +than silently ignoring it (the driver is bound to one backend). +build_print_job returns empty bytes for Protocol conformance — the +First-Print happy path uses backend.print_image directly; raw-byte +encoding is deferred until a concrete caller appears. + +Registered at module import via ModelRegistry.register and via the +label_hub.printer_models entry-points group. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 9 — Pydantic schemas for the REST surface + +### Task 9.1: PrintLookupRequest, PrintOptions, RawLabelData, PrintRequest + +**Files:** +- Create: `backend/app/schemas/print_request.py` +- Create: `backend/tests/unit/schemas/test_print_request.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/schemas/test_print_request.py +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.schemas.print_request import ( + PrintLookupRequest, + PrintOptions, + PrintRequest, + RawLabelData, +) + + +def test_print_options_defaults_independent() -> None: + a = PrintRequest(template_id="t", data=RawLabelData(title="x", primary_id="1", qr_payload="u")) + b = PrintRequest(template_id="t", data=RawLabelData(title="x", primary_id="1", qr_payload="u")) + assert a.options is not b.options # not a shared default instance + + +def test_print_options_immutable() -> None: + opts = PrintOptions() + with pytest.raises(ValidationError): + opts.copies = 5 # type: ignore[misc] + + +def test_lookup_xor_data_rejects_both() -> None: + with pytest.raises(ValidationError, match="Exactly one"): + PrintRequest( + template_id="t", + lookup=PrintLookupRequest(app="snipeit", identifier="123"), + data=RawLabelData(title="x", primary_id="1", qr_payload="u"), + ) + + +def test_lookup_xor_data_rejects_neither() -> None: + with pytest.raises(ValidationError, match="Exactly one"): + PrintRequest(template_id="t") + + +def test_lookup_only_accepted() -> None: + r = PrintRequest(template_id="t", lookup=PrintLookupRequest(app="snipeit", identifier="123")) + assert r.lookup is not None + assert r.data is None + + +def test_data_only_accepted() -> None: + r = PrintRequest( + template_id="t", + data=RawLabelData(title="x", primary_id="1", qr_payload="u", secondary=["a", "b"]), + ) + assert r.data is not None + assert r.lookup is None + assert r.data.secondary == ["a", "b"] + + +def test_raw_label_data_default_secondary_empty() -> None: + d = RawLabelData(title="x", primary_id="1", qr_payload="u") + assert d.secondary == [] + + +def test_raw_label_data_rejects_source_app_field() -> None: + """source_app is set server-side, not accepted from the wire.""" + with pytest.raises(ValidationError): + RawLabelData(title="x", primary_id="1", qr_payload="u", source_app="manual") # type: ignore[call-arg] + + +def test_copies_bounds() -> None: + PrintOptions(copies=1) + PrintOptions(copies=10) + with pytest.raises(ValidationError): + PrintOptions(copies=0) + with pytest.raises(ValidationError): + PrintOptions(copies=11) +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/schemas/test_print_request.py -q +``` + +Expected: `ModuleNotFoundError: No module named 'app.schemas.print_request'`. + +- [ ] **Step 3: Implement** + +```python +# backend/app/schemas/print_request.py +"""Request schemas for POST /print and supporting models.""" + +from __future__ import annotations + +from typing import Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class PrintLookupRequest(BaseModel): + """Resolve label data via an integration plugin.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + app: str + identifier: str + + +class PrintOptions(BaseModel): + """Per-print options — copies, cut behaviour, resolution.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + copies: int = Field(default=1, ge=1, le=10) + auto_cut: bool = True + high_resolution: bool = False + + +class RawLabelData(BaseModel): + """Raw label payload accepted when the client supplies data directly. + + Mirrors LabelData minus `source_app` (always set to "manual" server-side). + The list is coerced to a tuple when LabelData is constructed inside + PrintService. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + title: str + primary_id: str + qr_payload: str + secondary: list[str] = Field(default_factory=list) + + +class PrintRequest(BaseModel): + """Top-level POST /print body.""" + + model_config = ConfigDict(extra="forbid") + template_id: str + lookup: PrintLookupRequest | None = None + data: RawLabelData | None = None + # default_factory so each request gets a fresh PrintOptions + options: PrintOptions = Field(default_factory=PrintOptions) + + @model_validator(mode="after") + def _exactly_one_source(self) -> Self: + if (self.lookup is None) == (self.data is None): + raise ValueError("Exactly one of `lookup` or `data` must be set.") + return self +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/schemas/test_print_request.py -q +``` + +Expected: 9 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/print_request.py \ + backend/tests/unit/schemas/test_print_request.py +git commit -m "$(cat <<'EOF' +feat(api): PrintRequest + RawLabelData + PrintOptions schemas + +Top-level POST /print body. PrintRequest enforces exactly-one of +`lookup` or `data` via a model_validator. PrintOptions is frozen with +copies bounds (1..10). RawLabelData mirrors LabelData but rejects +source_app at the wire — PrintService sets it to "manual" for the +raw-data path. + +PrintOptions uses Field(default_factory=) on PrintRequest so each +request gets its own instance (Pydantic shared-mutable-default +anti-pattern avoided). + +Refs #22 +EOF +)" +``` + +### Task 9.2: PrintJobResponse + PrintJobStatusResponse + +**Files:** +- Create: `backend/app/schemas/print_response.py` +- Create: `backend/tests/unit/schemas/test_print_response.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/schemas/test_print_response.py +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from pydantic import ValidationError + +from app.printer_backends.snmp_helper import LiveStatus +from app.schemas.print_response import ( + PrintJobResponse, + PrintJobStatusResponse, +) +from app.services.job_lifecycle import JobState + + +def test_status_response_live_block_optional() -> None: + """live is None by default — populated only when the job is PRINTING.""" + r = PrintJobStatusResponse( + job_id="j", + status=JobState.QUEUED, + created_at=datetime.now(UTC), + ) + assert r.live is None + + +def test_status_response_carries_live_block() -> None: + live = LiveStatus(hr_printer_status="printing", error_flags=["doorOpen"]) + r = PrintJobStatusResponse( + job_id="j", + status=JobState.PRINTING, + created_at=datetime.now(UTC), + live=live, + ) + assert r.live is live + assert r.live.hr_printer_status == "printing" + + +def test_print_job_response_status_is_literal_queued() -> None: + r = PrintJobResponse(job_id="abc", status="queued") + assert r.status == "queued" + with pytest.raises(ValidationError): + PrintJobResponse(job_id="abc", status="printing") # type: ignore[arg-type] + + +def test_status_response_accepts_each_job_state() -> None: + for state in JobState: + r = PrintJobStatusResponse( + job_id="j", + status=state, + created_at=datetime.now(UTC), + ) + assert r.status == state + + +def test_status_response_optional_fields_none() -> None: + r = PrintJobStatusResponse( + job_id="j", + status=JobState.QUEUED, + created_at=datetime.now(UTC), + ) + assert r.error_code is None + assert r.error_message is None + assert r.error_detail is None + assert r.started_at is None + assert r.finished_at is None +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/schemas/test_print_response.py -q +``` + +Expected: `ModuleNotFoundError`. + +- [ ] **Step 3: Implement** + +```python +# backend/app/schemas/print_response.py +"""Response schemas for POST /print and GET /jobs/{job_id}.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +from app.printer_backends.snmp_helper import LiveStatus +from app.services.job_lifecycle import JobState + + +class PrintJobResponse(BaseModel): + """POST /print 202 body — queue accepted.""" + + model_config = ConfigDict(frozen=True) + job_id: str + status: Literal["queued"] + + +class PrintJobStatusResponse(BaseModel): + """GET /jobs/{job_id} body.""" + + model_config = ConfigDict(frozen=True) + job_id: str + status: JobState + error_code: str | None = None + error_message: str | None = None + error_detail: dict[str, Any] | None = None + created_at: datetime + started_at: datetime | None = None + finished_at: datetime | None = None + # Populated only when status == PRINTING; route handler fetches live SNMP. + live: LiveStatus | None = None +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/schemas/test_print_response.py -q +``` + +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/print_response.py \ + backend/tests/unit/schemas/test_print_response.py +git commit -m "$(cat <<'EOF' +feat(api): PrintJobResponse + PrintJobStatusResponse schemas + +POST /print returns a 202 with PrintJobResponse — status is the +literal 'queued'. GET /jobs/{job_id} returns PrintJobStatusResponse +typed as the real JobState enum (queued/paused/printing/completed/ +failed/cancelled) so clients receive the same vocabulary the +PrintQueue uses internally. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 10 — PrintService + +### Task 10.1: PrintService.submit_print_job — happy path + error paths + +**Files:** +- Create: `backend/app/services/print_service.py` +- Create: `backend/tests/unit/services/test_print_service.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/services/test_print_service.py +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from PIL import Image + +from app.schemas.print_request import ( + PrintLookupRequest, + PrintOptions, + PrintRequest, + RawLabelData, +) +from app.schemas.label_data import LabelData +from app.schemas.template import TemplateSchema, LayoutElement, FieldRef +from app.services.print_service import PrintService +from app.services.template_loader import TemplateNotFoundError + + +@pytest.fixture +def template() -> TemplateSchema: + return TemplateSchema( + id="qr-only-24mm", + name="QR only", + app=None, + tape_mm=24, + elements=( + LayoutElement(kind="qr", x=0, y=0, size=128, field=FieldRef(name="qr_payload")), + ), + ) + + +@pytest.fixture +def image() -> Image.Image: + return Image.new("1", (200, 128)) + + +@pytest.fixture +def loader(template: TemplateSchema) -> MagicMock: + m = MagicMock() + m.get.return_value = template + return m + + +@pytest.fixture +def renderer(image: Image.Image) -> MagicMock: + m = MagicMock() + m.render.return_value = image + return m + + +@pytest.fixture +def queue() -> AsyncMock: + m = AsyncMock() + m.submit.return_value = "job-1" + return m + + +@pytest.fixture +def lookup_service() -> AsyncMock: + m = AsyncMock() + m.lookup.return_value = LabelData( + title="X", primary_id="1", qr_payload="u", + source_app="snipeit", secondary=(), + ) + return m + + +def _service(loader, renderer, queue, lookup_service) -> PrintService: + return PrintService( + template_loader=loader, + renderer=renderer, + print_queue=queue, + lookup_service=lookup_service, + printer_id="pt@x", + ) + + +async def test_lookup_path_calls_lookup_and_renders( + loader, renderer, queue, lookup_service +) -> None: + svc = _service(loader, renderer, queue, lookup_service) + req = PrintRequest( + template_id="qr-only-24mm", + lookup=PrintLookupRequest(app="snipeit", identifier="42"), + ) + job_id = await svc.submit_print_job(req) + lookup_service.lookup.assert_awaited_once_with("snipeit", "42") + renderer.render.assert_called_once() + queue.submit.assert_awaited_once() + assert job_id == "job-1" + + +async def test_data_path_bypasses_lookup_and_marks_source_manual( + loader, renderer, queue, lookup_service +) -> None: + svc = _service(loader, renderer, queue, lookup_service) + req = PrintRequest( + template_id="qr-only-24mm", + data=RawLabelData(title="T", primary_id="P", qr_payload="Q", secondary=["a"]), + ) + job_id = await svc.submit_print_job(req) + lookup_service.lookup.assert_not_called() + # Renderer receives a LabelData with source_app="manual" + args, _ = renderer.render.call_args + label_data = args[1] + assert isinstance(label_data, LabelData) + assert label_data.source_app == "manual" + assert label_data.secondary == ("a",) # tuple coercion happened + assert job_id == "job-1" + + +async def test_template_not_found_raises_synchronously( + loader, renderer, queue, lookup_service +) -> None: + loader.get.side_effect = TemplateNotFoundError("qr-only-24mm") + svc = _service(loader, renderer, queue, lookup_service) + req = PrintRequest( + template_id="qr-only-24mm", + lookup=PrintLookupRequest(app="snipeit", identifier="x"), + ) + with pytest.raises(TemplateNotFoundError): + await svc.submit_print_job(req) + queue.submit.assert_not_called() + + +async def test_options_passed_to_queue(loader, renderer, queue, lookup_service) -> None: + svc = _service(loader, renderer, queue, lookup_service) + req = PrintRequest( + template_id="qr-only-24mm", + data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), + options=PrintOptions(copies=2, auto_cut=False, high_resolution=True), + ) + await svc.submit_print_job(req) + _, kwargs = queue.submit.call_args + assert kwargs["tape_mm"] == 24 + assert kwargs["auto_cut"] is False + assert kwargs["high_resolution"] is True + assert kwargs["copies"] == 2 +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/services/test_print_service.py -q +``` + +Expected: `ModuleNotFoundError`. + +- [ ] **Step 3: Implement** + +```python +# backend/app/services/print_service.py +"""PrintService — orchestrates template, label data, render, queue.submit.""" + +from __future__ import annotations + +from typing import Protocol + +from PIL import Image + +from app.schemas.label_data import LabelData +from app.schemas.print_request import PrintRequest +from app.schemas.template import TemplateSchema +from app.services.print_queue import PrintQueue + + +class _TemplateLoaderProto(Protocol): + def get(self, template_id: str) -> TemplateSchema: ... + + +class _RendererProto(Protocol): + def render(self, template: TemplateSchema, label_data: LabelData) -> Image.Image: ... + + +class _LookupServiceProto(Protocol): + async def lookup(self, app: str, identifier: str) -> LabelData: ... + + +class PrintService: + """Use-case orchestrator for POST /print.""" + + def __init__( + self, + *, + template_loader: _TemplateLoaderProto, + renderer: _RendererProto, + print_queue: PrintQueue, + lookup_service: _LookupServiceProto, + printer_id: str, + ) -> None: + self._loader = template_loader + self._renderer = renderer + self._queue = print_queue + self._lookup = lookup_service + self._printer_id = printer_id + + async def submit_print_job(self, request: PrintRequest) -> str: + # 1. Template (synchronous miss → TemplateNotFoundError propagates) + template = self._loader.get(request.template_id) + + # 2. Label data + if request.lookup is not None: + label_data = await self._lookup.lookup(request.lookup.app, request.lookup.identifier) + else: + assert request.data is not None # validator enforces XOR + label_data = LabelData( + title=request.data.title, + primary_id=request.data.primary_id, + qr_payload=request.data.qr_payload, + secondary=tuple(request.data.secondary), + source_app="manual", + ) + + # 3. Render + image = self._renderer.render(template, label_data) + + # 4. Enqueue + return await self._queue.submit( + self._printer_id, + image, + tape_mm=template.tape_mm, + copies=request.options.copies, + auto_cut=request.options.auto_cut, + high_resolution=request.options.high_resolution, + ) +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/services/test_print_service.py -q +``` + +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/print_service.py \ + backend/tests/unit/services/test_print_service.py +git commit -m "$(cat <<'EOF' +feat(api): PrintService orchestrator + +Three-step pipeline behind POST /print: template_loader.get → either +lookup_service.lookup (integration path) or LabelData(..., source_app= +'manual') from RawLabelData (raw-data path) → renderer.render → +print_queue.submit(printer_id, image, tape_mm=, **options). + +source_app for the raw-data path is fixed to "manual" here so the +wire schema doesn't have to police it. Options propagate to submit +as keyword args (auto_cut, high_resolution, copies); Phase 13 wires +copies into the worker by submitting once per copy. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 11 — REST routes + +### Task 11.1: POST /print + GET /jobs/{job_id} + exception mapper + +**Files:** +- Create: `backend/app/api/routes/print.py` +- Create: `backend/tests/unit/api/__init__.py` +- Create: `backend/tests/unit/api/test_print_routes.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/api/test_print_routes.py +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from app.api.routes.print import router +from app.services.job_lifecycle import Job, JobState +from app.services.template_loader import TemplateNotFoundError +from app.services.lookup_service import LookupFailedError + + +@pytest.fixture +def fake_service() -> AsyncMock: + m = AsyncMock() + m.submit_print_job.return_value = "job-1" + return m + + +@pytest.fixture +def fake_queue() -> MagicMock: + return MagicMock() + + +def _app(service: AsyncMock, queue: MagicMock) -> FastAPI: + app = FastAPI() + app.state.print_service = service + app.state.print_queue = queue + app.include_router(router) + return app + + +async def _client(app: FastAPI) -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=app), base_url="http://t") + + +async def test_post_print_data_path_returns_202(fake_service, fake_queue) -> None: + async with await _client(_app(fake_service, fake_queue)) as c: + r = await c.post("/print", json={ + "template_id": "t", + "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, + }) + assert r.status_code == 202 + body = r.json() + assert body == {"job_id": "job-1", "status": "queued"} + + +async def test_post_print_lookup_path_returns_202(fake_service, fake_queue) -> None: + async with await _client(_app(fake_service, fake_queue)) as c: + r = await c.post("/print", json={ + "template_id": "t", + "lookup": {"app": "snipeit", "identifier": "42"}, + }) + assert r.status_code == 202 + + +async def test_post_print_neither_source_is_422(fake_service, fake_queue) -> None: + async with await _client(_app(fake_service, fake_queue)) as c: + r = await c.post("/print", json={"template_id": "t"}) + assert r.status_code == 422 + + +async def test_post_print_template_not_found_is_404(fake_service, fake_queue) -> None: + fake_service.submit_print_job.side_effect = TemplateNotFoundError("missing") + async with await _client(_app(fake_service, fake_queue)) as c: + r = await c.post("/print", json={ + "template_id": "missing", + "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, + }) + assert r.status_code == 404 + assert r.json()["error_code"] == "template_not_found" + + +async def test_post_print_lookup_failed_is_502(fake_service, fake_queue) -> None: + fake_service.submit_print_job.side_effect = LookupFailedError("upstream down") + async with await _client(_app(fake_service, fake_queue)) as c: + r = await c.post("/print", json={ + "template_id": "t", + "lookup": {"app": "snipeit", "identifier": "x"}, + }) + assert r.status_code == 502 + assert r.json()["error_code"] == "integration_lookup_failed" + + +async def test_get_jobs_returns_status(fake_service, fake_queue, monkeypatch) -> None: + from app.printer_backends.snmp_helper import LiveStatus + + job = Job( + id="job-1", + printer_id="p", + image_payload=b"", + tape_mm=24, + options={}, + ) + job.state = JobState.PRINTING + job.created_at = datetime.now(UTC) + fake_queue.get = AsyncMock(return_value=job) + + # Stub SNMP live-status — printing job triggers live SNMP fetch + async def fake_live(host: str, *, community: str = "public", timeout_s: float = 3.0): # noqa: ARG001 + return LiveStatus(hr_printer_status="printing", error_flags=[]) + + monkeypatch.setattr("app.api.routes.print.query_live_status", fake_live) + + app = _app(fake_service, fake_queue) + app.state.printer_host = "10.0.0.5" + app.state.printer_snmp_community = "public" + async with await _client(app) as c: + r = await c.get("/jobs/job-1") + assert r.status_code == 200 + body = r.json() + assert body["job_id"] == "job-1" + assert body["status"] == "printing" + assert body["live"] == {"hr_printer_status": "printing", "error_flags": []} + + +async def test_get_jobs_no_live_block_when_not_printing(fake_service, fake_queue) -> None: + job = Job( + id="job-1", + printer_id="p", + image_payload=b"", + tape_mm=24, + options={}, + ) + job.state = JobState.COMPLETED + job.created_at = datetime.now(UTC) + fake_queue.get = AsyncMock(return_value=job) + async with await _client(_app(fake_service, fake_queue)) as c: + r = await c.get("/jobs/job-1") + assert r.status_code == 200 + assert r.json()["live"] is None + + +async def test_get_jobs_live_snmp_failure_is_non_fatal(fake_service, fake_queue, monkeypatch) -> None: + from app.printer_backends.exceptions import SnmpQueryError + + job = Job( + id="job-1", + printer_id="p", + image_payload=b"", + tape_mm=24, + options={}, + ) + job.state = JobState.PRINTING + job.created_at = datetime.now(UTC) + fake_queue.get = AsyncMock(return_value=job) + + async def fake_live(*_a, **_kw): + raise SnmpQueryError("timed out") + + monkeypatch.setattr("app.api.routes.print.query_live_status", fake_live) + + app = _app(fake_service, fake_queue) + app.state.printer_host = "10.0.0.5" + app.state.printer_snmp_community = "public" + async with await _client(app) as c: + r = await c.get("/jobs/job-1") + assert r.status_code == 200 + assert r.json()["live"] is None # block dropped, response still 200 + + +async def test_get_jobs_unknown_is_404(fake_service, fake_queue) -> None: + fake_queue.get = AsyncMock(side_effect=KeyError("nope")) + async with await _client(_app(fake_service, fake_queue)) as c: + r = await c.get("/jobs/does-not-exist") + assert r.status_code == 404 +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/api/test_print_routes.py -q +``` + +Expected: `ModuleNotFoundError: app.api.routes.print`. + +- [ ] **Step 3: Implement** + +```python +# backend/app/api/routes/print.py +"""POST /print + GET /jobs/{job_id}.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.responses import JSONResponse + +from app.printer_backends.exceptions import ( + PrinterCoverOpenError, + PrinterOfflineError, + PrintFailedError, + StatusQueryFailedError, + TapeEmptyError, + TapeMismatchError, +) +from app.schemas.print_request import PrintRequest +from app.schemas.print_response import PrintJobResponse, PrintJobStatusResponse +from app.services.lookup_service import LookupFailedError +from app.services.template_loader import TemplateNotFoundError + +router = APIRouter() + +_SYNC_ERROR_MAP: dict[type[Exception], tuple[int, str]] = { + TemplateNotFoundError: (404, "template_not_found"), + LookupFailedError: (502, "integration_lookup_failed"), +} + + +@router.post( + "/print", + status_code=status.HTTP_202_ACCEPTED, + response_model=PrintJobResponse, +) +async def create_print_job(request: PrintRequest, http: Request) -> PrintJobResponse: + service = http.app.state.print_service + try: + job_id = await service.submit_print_job(request) + except tuple(_SYNC_ERROR_MAP) as exc: + http_status, code = _SYNC_ERROR_MAP[type(exc)] + return JSONResponse( # type: ignore[return-value] + status_code=http_status, + content={"error_code": code, "error_message": str(exc)}, + ) + return PrintJobResponse(job_id=job_id, status="queued") + + +@router.get( + "/jobs/{job_id}", + response_model=PrintJobStatusResponse, +) +async def get_job_status(job_id: str, http: Request) -> PrintJobStatusResponse: + queue = http.app.state.print_queue + try: + job = await queue.get(job_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"job not found: {job_id}") from exc + + live: LiveStatus | None = None + if job.state == JobState.PRINTING: + host = getattr(http.app.state, "printer_host", None) + community = getattr(http.app.state, "printer_snmp_community", "public") + if host: + try: + live = await query_live_status(host, community=community) + except SnmpQueryError: + _log.warning("live SNMP query failed for job %s", job_id, exc_info=True) + live = None + + return PrintJobStatusResponse( + job_id=job.id, + status=job.state, + error_code=getattr(job, "error_code", None), + error_message=getattr(job, "error_message", None), + error_detail=getattr(job, "error_detail", None), + created_at=job.created_at, + started_at=getattr(job, "started_at", None), + finished_at=getattr(job, "finished_at", None), + live=live, + ) +``` + +Required imports for the route module: + +```python +import logging + +from app.printer_backends.exceptions import SnmpQueryError +from app.printer_backends.snmp_helper import LiveStatus, query_live_status +from app.services.job_lifecycle import JobState + +_log = logging.getLogger(__name__) +``` + +- [ ] **Step 4: Add tests dir init** + +```bash +mkdir -p backend/tests/unit/api +touch backend/tests/unit/api/__init__.py +``` + +- [ ] **Step 5: Run — verify pass** + +```bash +cd backend && pytest tests/unit/api/test_print_routes.py -q +``` + +Expected: 7 passed. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/api/routes/print.py \ + backend/tests/unit/api/__init__.py \ + backend/tests/unit/api/test_print_routes.py +git commit -m "$(cat <<'EOF' +feat(api): POST /print + GET /jobs/{job_id} + +POST returns 202 with PrintJobResponse on success. Synchronous errors +(TemplateNotFoundError, LookupFailedError) map to 404 / 502 with an +error_code in the JSON body; hardware/print errors travel to the +worker and surface via GET /jobs/{job_id}. + +GET returns PrintJobStatusResponse with JobState (queued/paused/ +printing/completed/failed/cancelled) and any error_* fields the +worker recorded. Unknown job_id is 404. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 12 — Settings: new fields + +### Task 12.1: Add printer_backend, printer_model, printer_queue_timeout_s + +**Files:** +- Modify: `backend/app/config.py` +- Create: `backend/tests/unit/test_config_printer.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/test_config_printer.py +from __future__ import annotations + +import pytest + +from app.config import Settings + + +def test_defaults() -> None: + s = Settings() + assert s.printer_backend == "ptouch" + assert s.printer_model == "PT-P750W" + assert s.printer_queue_timeout_s == 30.0 + assert s.printer_discover_via_snmp is True + assert s.printer_snmp_community == "public" + + +def test_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "mock") + monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "PT-P900") + monkeypatch.setenv("PRINTER_HUB_PRINTER_QUEUE_TIMEOUT_S", "60") + monkeypatch.setenv("PRINTER_HUB_PRINTER_DISCOVER_VIA_SNMP", "false") + monkeypatch.setenv("PRINTER_HUB_PRINTER_SNMP_COMMUNITY", "private") + s = Settings() + assert s.printer_backend == "mock" + assert s.printer_model == "PT-P900" + assert s.printer_queue_timeout_s == 60.0 + assert s.printer_discover_via_snmp is False + assert s.printer_snmp_community == "private" + + +def test_existing_pt750w_fields_intact() -> None: + s = Settings() + assert s.pt750w_host == "" + assert s.pt750w_port == 9100 +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/test_config_printer.py -q +``` + +Expected: `AttributeError: 'Settings' object has no attribute 'printer_backend'`. + +- [ ] **Step 3: Implement (add to Settings class)** + +Append the new fields inside `Settings` in `backend/app/config.py`: + +```python + # --- First-Print --- + printer_backend: str = "ptouch" + printer_model: str = "PT-P750W" + printer_discover_via_snmp: bool = True + printer_snmp_community: str = "public" + printer_queue_timeout_s: float = 30.0 +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/test_config_printer.py -q +``` + +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/config.py \ + backend/tests/unit/test_config_printer.py +git commit -m "$(cat <<'EOF' +feat(api): new First-Print settings (backend, model, timeout) + +Three new fields on Settings: +* printer_backend — resolves against BackendRegistry at app start + (built-ins: ptouch, mock) +* printer_model — resolves against ModelRegistry (built-in: PT-P750W) +* printer_queue_timeout_s — graceful queue.stop() timeout + +Existing pt750w_host / pt750w_port / ql820_host / ql820_port fields +are kept untouched. PTouchBackend.from_settings reads pt750w_host +and looks up the right ptouch class via printer_model. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 13 — Lifespan-Init + _build_backend + +### Task 13.1: Wire registries, backend, driver, queue, service into the FastAPI lifespan + +**Files:** +- Modify: `backend/app/main.py` +- Create: `backend/tests/unit/test_lifespan.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/test_lifespan.py +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import get_settings +from app.main import create_app +from app.printer_backends import BackendRegistry +from app.printer_models.registry import ModelRegistry + + +@pytest.fixture(autouse=True) +def clean_registries() -> None: + BackendRegistry._factories.clear() + BackendRegistry._discovered = False + ModelRegistry._models.clear() + ModelRegistry._discovered = False + get_settings.cache_clear() # pydantic-settings lru_cache + yield + BackendRegistry._factories.clear() + BackendRegistry._discovered = False + ModelRegistry._models.clear() + ModelRegistry._discovered = False + get_settings.cache_clear() + + +async def test_lifespan_starts_with_mock_backend(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "mock") + monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "PT-P750W") + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + # Trigger the lifespan startup + r = await c.get("/healthz") + assert r.status_code in (200, 404) # healthz may not exist yet + # After context exit, queue.stop has been awaited; no exception means success. + + +async def test_unknown_backend_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "zebra-zpl") + monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "PT-P750W") + app = create_app() + with pytest.raises(Exception, match="zebra-zpl"): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + await c.get("/healthz") + + +async def test_unknown_model_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "mock") + monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "Imaginary-9000") + monkeypatch.setenv("PRINTER_HUB_PRINTER_DISCOVER_VIA_SNMP", "false") + app = create_app() + with pytest.raises(Exception, match="Imaginary-9000"): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + await c.get("/healthz") + + +async def test_snmp_discovery_resolves_model(monkeypatch: pytest.MonkeyPatch) -> None: + """SNMP returns a stubbed PJL string; lifespan resolves it via find_by_pjl.""" + monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "mock") + monkeypatch.setenv("PRINTER_HUB_PRINTER_DISCOVER_VIA_SNMP", "true") + monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "") # require SNMP + monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "10.0.0.5") + + async def fake_query(host: str, *, community: str = "public", timeout_s: float = 3.0): # noqa: ARG001 + return "MFG:Brother;CMD:PJL;MDL:PT-P750W;CLS:PRINTER;DES:Brother PT-P750W;" + + monkeypatch.setattr("app.main.query_model_pjl", fake_query) + # Driver must be registered for find_by_pjl to succeed in the test + from app.printer_models.pt import PTP750WDriver # noqa: F401 (registration side-effect) + + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + r = await c.get("/healthz") + # No exception → discovery worked + assert r.status_code in (200, 404) + + +async def test_snmp_discovery_fallback_to_setting(monkeypatch: pytest.MonkeyPatch) -> None: + """SNMP fails but printer_model is configured → fall back, log warning, succeed.""" + monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "mock") + monkeypatch.setenv("PRINTER_HUB_PRINTER_DISCOVER_VIA_SNMP", "true") + monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "PT-P750W") + monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "10.0.0.5") + + from app.printer_backends.exceptions import SnmpDiscoveryError + + async def fake_query(*_a, **_kw): + raise SnmpDiscoveryError("timed out") + + monkeypatch.setattr("app.main.query_model_pjl", fake_query) + + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + r = await c.get("/healthz") + assert r.status_code in (200, 404) + + +async def test_snmp_discovery_no_fallback_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """SNMP fails AND printer_model is empty → SnmpDiscoveryError propagates.""" + monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "mock") + monkeypatch.setenv("PRINTER_HUB_PRINTER_DISCOVER_VIA_SNMP", "true") + monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "") + monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "10.0.0.5") + + from app.printer_backends.exceptions import SnmpDiscoveryError + + async def fake_query(*_a, **_kw): + raise SnmpDiscoveryError("timed out") + + monkeypatch.setattr("app.main.query_model_pjl", fake_query) + + app = create_app() + with pytest.raises(SnmpDiscoveryError): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + await c.get("/healthz") + + +async def test_empty_pt750w_host_with_ptouch_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "ptouch") + monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "PT-P750W") + monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "") + app = create_app() + with pytest.raises(Exception, match="pt750w_host"): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + await c.get("/healthz") +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/test_lifespan.py -q +``` + +Expected: failures because the lifespan does not yet do plugin discovery / build a backend. + +- [ ] **Step 3: Implement — modify `app/main.py`** + +```python +# backend/app/main.py (sketch — preserve existing imports + routes) +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI + +from app.api.routes.print import router as print_router +from app.config import Settings, get_settings +from app.printer_backends import BackendRegistry +from app.printer_backends.exceptions import SnmpDiscoveryError +from app.printer_backends.snmp_helper import query_model_pjl +from app.printer_models.registry import ModelRegistry +from app.services.label_renderer import LabelRenderer +from app.services.lookup_service import AppLookupService +from app.services.print_queue import PrintQueue +from app.services.print_service import PrintService +from app.services.tape_registry import TapeRegistry +from app.services.template_loader import TemplateLoader + +_SEED_TEMPLATES_DIR = Path(__file__).parent / "seed" / "templates" +_log = logging.getLogger(__name__) + + +def _build_backend(settings: Settings): + BackendRegistry.ensure_discovered() + factory = BackendRegistry.find_by_backend_id(settings.printer_backend) + return factory.from_settings(settings) + + +async def _resolve_model_id(settings: Settings, host: str) -> str: + """SNMP discovery first, fall back to settings.printer_model on failure.""" + if not settings.printer_discover_via_snmp: + if not settings.printer_model: + raise ValueError( + "Either printer_discover_via_snmp=true or a non-empty " + "printer_model is required." + ) + return settings.printer_model + try: + pjl = await query_model_pjl( + host, + community=settings.printer_snmp_community, + ) + except SnmpDiscoveryError as exc: + if settings.printer_model: + _log.warning( + "SNMP discovery failed (%s); falling back to printer_model=%r", + exc, settings.printer_model, + ) + return settings.printer_model + raise + driver = ModelRegistry.find_by_pjl(pjl) + return driver.model_id + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + settings = get_settings() + + TemplateLoader.load_dir(_SEED_TEMPLATES_DIR) + ModelRegistry.ensure_discovered() + + # Mock backend ignores host; ptouch backend needs pt750w_host. + discovery_host = settings.pt750w_host or "" + if settings.printer_backend == "ptouch" or settings.printer_discover_via_snmp: + if not discovery_host: + # ptouch backend or SNMP discovery both need a host. + # Empty pt750w_host with ptouch is already enforced by from_settings, + # but SNMP discovery may run with the mock backend too. + pass + if discovery_host and settings.printer_discover_via_snmp: + model_id = await _resolve_model_id(settings, discovery_host) + else: + model_id = settings.printer_model + if not model_id: + raise ValueError("printer_model is empty and SNMP discovery is disabled.") + + backend = _build_backend(settings) + driver_cls = ModelRegistry.find_by_model_id(model_id) + driver = driver_cls(backend=backend) + + tape_registry = TapeRegistry() + printer = driver.make_queue_printer(tape_registry) + queue = PrintQueue(printers=[printer]) + await queue.start() + + app.state.print_queue = queue + app.state.printer_id = printer.id + app.state.printer_host = discovery_host # used by route handler for live SNMP + app.state.printer_snmp_community = settings.printer_snmp_community + app.state.print_service = PrintService( + template_loader=TemplateLoader, + renderer=LabelRenderer(), + print_queue=queue, + lookup_service=AppLookupService(), + printer_id=printer.id, + ) + + try: + yield + finally: + await queue.stop(timeout_s=settings.printer_queue_timeout_s) + + +def create_app() -> FastAPI: + app = FastAPI(lifespan=lifespan, title="Label Printer Hub") + app.include_router(print_router) + return app + + +app = create_app() +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/test_lifespan.py -q +``` + +Expected: 4 passed. If `/healthz` does not exist yet, change the test to `assert r.status_code in (200, 404)` — the goal is to drive the lifespan start/stop, not to test that endpoint. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/main.py \ + backend/tests/unit/test_lifespan.py +git commit -m "$(cat <<'EOF' +feat(api): lifespan wires plugin discovery + queue + service + +App startup: +1. Discover printer-model + backend plugins via entry_points + (idempotent ensure_discovered). +2. Build the configured backend via BackendRegistry + + from_settings(settings). +3. Resolve the driver via ModelRegistry.find_by_model_id and bind it + to the backend. +4. Build the _PrinterLike via driver.make_queue_printer(tape_registry). +5. Start the PrintQueue with [printer]. +6. Wire PrintService into app.state. + +App shutdown: queue.stop(timeout_s=settings.printer_queue_timeout_s). + +Unknown backend, unknown model, or empty pt750w_host (with the ptouch +backend selected) raise at app start with a clear, actionable error. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 14 — End-to-end integration tests + +### Task 14.1: POST /print → GET /jobs/{id} cycle with MockPrinterBackend + +**Files:** +- Create: `backend/tests/integration/test_print_e2e.py` + +- [ ] **Step 1: Write the test** + +```python +# backend/tests/integration/test_print_e2e.py +from __future__ import annotations + +import asyncio + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import get_settings +from app.main import create_app +from app.printer_backends import BackendRegistry +from app.printer_models.registry import ModelRegistry + + +@pytest.fixture(autouse=True) +def fresh_state(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "mock") + monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "PT-P750W") + monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "") # unused with mock + BackendRegistry._factories.clear() + BackendRegistry._discovered = False + ModelRegistry._models.clear() + ModelRegistry._discovered = False + get_settings.cache_clear() + yield + BackendRegistry._factories.clear() + ModelRegistry._models.clear() + get_settings.cache_clear() + + +async def _poll_until(c: AsyncClient, job_id: str, *, target: str, timeout_s: float = 3.0) -> dict: + deadline = asyncio.get_event_loop().time() + timeout_s + while asyncio.get_event_loop().time() < deadline: + r = await c.get(f"/jobs/{job_id}") + assert r.status_code == 200 + body = r.json() + if body["status"] == target: + return body + await asyncio.sleep(0.05) + raise AssertionError(f"job {job_id} never reached status {target!r}; last={body['status']}") + + +async def test_happy_path_raw_data() -> None: + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + r = await c.post("/print", json={ + "template_id": "qr-only-24mm", + "data": {"title": "Smoke", "primary_id": "S-1", "qr_payload": "https://e.x"}, + }) + assert r.status_code == 202 + job_id = r.json()["job_id"] + + body = await _poll_until(c, job_id, target="completed") + assert body["error_code"] is None + assert body["status"] == "completed" + + +async def test_template_not_found_synchronous_404() -> None: + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + r = await c.post("/print", json={ + "template_id": "does-not-exist", + "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, + }) + assert r.status_code == 404 + assert r.json()["error_code"] == "template_not_found" +``` + +- [ ] **Step 2: Run — verify pass** + +```bash +cd backend && pytest tests/integration/test_print_e2e.py -q +``` + +Expected: 2 passed. (If `qr-only-24mm` seed template is missing, copy the relevant one from `app/seed/templates/` in this commit — but Phase 4 PR-B already shipped it, so it should be present.) + +- [ ] **Step 3: Commit** + +```bash +git add backend/tests/integration/test_print_e2e.py +git commit -m "$(cat <<'EOF' +test(api): POST /print → GET /jobs/{id} happy path + 404 + +Drives the full lifespan: plugin discovery, mock backend, PrintQueue +worker, status polling. Asserts the job transitions to completed and +the mock backend received exactly one image with the right dimensions. + +Template-not-found returns 404 synchronously with error_code set; no +job record is created. + +Refs #22 +EOF +)" +``` + +### Task 14.2: Failure-path integration tests (tape mismatch, offline) + +**Files:** +- Modify: `backend/tests/integration/test_print_e2e.py` (append) +- Create: `backend/tests/integration/conftest.py` (if helpers grow) + +- [ ] **Step 1: Append the failure-mode tests** + +```python +# tests/integration/test_print_e2e.py — APPEND + +async def test_tape_mismatch_ends_failed() -> None: + """Mock loaded_tape_mm=12 against a 24mm template — worker marks failed.""" + import os + os.environ["PRINTER_HUB_MOCK_LOADED_TAPE_MM"] = "12" # see below + try: + # NOTE: requires MockPrinterBackend.from_settings to read this env var. + # If it does not — patch the mock in process via dependency injection + # by overriding BackendRegistry's mock factory before lifespan start. + ... + finally: + del os.environ["PRINTER_HUB_MOCK_LOADED_TAPE_MM"] +``` + +Realistically, the mock backend constructor takes configuration flags rather than environment variables. Override it via a fixture that registers a configured mock under the backend_id `"mock"` before the app starts: + +```python +import pytest +from app.printer_backends import BackendRegistry +from app.printer_backends.mock_backend import MockPrinterBackend + + +def _mock_with(**kwargs): + class _Patched(MockPrinterBackend): + @classmethod + def from_settings(cls, settings): + return MockPrinterBackend(**kwargs) + return _Patched + + +@pytest.fixture +def mismatched_mock_backend(): + BackendRegistry._factories.clear() + BackendRegistry._discovered = True # skip entry-point walk + BackendRegistry.register("mock", _mock_with(loaded_tape_mm=12)) + + +async def test_tape_mismatch_ends_failed(mismatched_mock_backend) -> None: + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + r = await c.post("/print", json={ + "template_id": "qr-only-24mm", + "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, + }) + assert r.status_code == 202 + body = await _poll_until(c, r.json()["job_id"], target="failed") + assert body["error_code"] == "tape_mismatch" + assert body["error_detail"] == {"expected_mm": 24, "loaded_mm": 12} + + +@pytest.fixture +def offline_mock_backend(): + BackendRegistry._factories.clear() + BackendRegistry._discovered = True + BackendRegistry.register("mock", _mock_with(offline=True)) + + +async def test_offline_ends_failed_after_retries(offline_mock_backend) -> None: + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + r = await c.post("/print", json={ + "template_id": "qr-only-24mm", + "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, + }) + body = await _poll_until(c, r.json()["job_id"], target="failed", timeout_s=10) + assert body["error_code"] == "printer_offline" +``` + +These tests require the worker to translate `PrinterError` subclasses into the right `error_code` on the `Job` record. Add a small helper inside the PrintQueue worker path (or wrap the printer's `print_image` in PrintService) — depending on where the existing FSM puts error data. The simplest place is inside `_PTPQueuePrinter.print_image`: catch `PrinterError`, set `job.error_code` / `job.error_detail` via a callback, re-raise so the FSM marks `failed`. + +If the existing `PrintQueue` FSM does not surface arbitrary error fields on the Job, add them in this task: `error_code: str | None`, `error_message: str | None`, `error_detail: dict[str, Any] | None`. They are also needed by `PrintJobStatusResponse` (Phase 9), so this is a known dependency. + +- [ ] **Step 2: Verify Job carries error_code/error_detail** + +```bash +cd backend && grep -n "error_code\|error_detail" app/services/job_lifecycle.py app/services/print_queue.py +``` + +If they don't exist, add them as optional fields on `Job`, default `None`, populated by the worker when it catches a `PrinterError`. + +- [ ] **Step 3: Run — verify pass** + +```bash +cd backend && pytest tests/integration/test_print_e2e.py -q +``` + +Expected: all integration tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add backend/tests/integration/test_print_e2e.py \ + backend/app/services/job_lifecycle.py \ + backend/app/services/print_queue.py +git commit -m "$(cat <<'EOF' +test(api): integration tests for tape mismatch + offline failures + +Adds end-to-end tests for the two main hardware error paths: +* loaded_tape_mm != template.tape_mm → job ends 'failed' with + error_code 'tape_mismatch' and error_detail {expected_mm, loaded_mm}. +* backend offline → job ends 'failed' with error_code 'printer_offline' + after exactly 3 status queries (back-off 0s, 1s, 2s). + +If absent, error_code/error_message/error_detail were added as +optional fields on Job and surfaced by the queue worker when a +PrinterError subclass escapes the printer call. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 15 — Hardware smoke script + +### Task 15.1: scripts/smoke_first_print.py + hardware test marker + +**Files:** +- Create: `backend/scripts/__init__.py` (empty) +- Create: `backend/scripts/smoke_first_print.py` +- Create: `backend/tests/hardware/__init__.py` (empty) +- Create: `backend/tests/hardware/test_pt_p750w_smoke.py` + +- [ ] **Step 1: Implement the smoke script** + +```python +# backend/scripts/smoke_first_print.py +"""Manual hardware smoke for First-Print. + +Run against a real Brother PT-P750W on the local network: + + PRINTER_HUB_PT750W_HOST= \ + python -m scripts.smoke_first_print + +Prints the qr-only-24mm template once with primary_id=SMOKE-001 and a +QR-encodable URL. Exits 0 on success, non-zero with a clear message on +failure. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +from PIL import Image + +from app.config import Settings +from app.printer_backends import BackendRegistry +from app.printer_models.registry import ModelRegistry +from app.printer_models.pt import PTP750WDriver # ensures registration +from app.printer_backends.ptouch_backend import PTouchBackend # ensures registration +from app.printer_backends.mock_backend import MockPrinterBackend # noqa: F401 +from app.services.label_renderer import LabelRenderer +from app.services.tape_registry import TapeRegistry +from app.services.template_loader import TemplateLoader +from app.schemas.label_data import LabelData + +_TEMPLATE_ID = "qr-only-24mm" +_SMOKE_PRIMARY_ID = "SMOKE-001" +_SMOKE_QR_PAYLOAD = "https://example.test/smoke" + + +async def main() -> int: + host = os.environ.get("PRINTER_HUB_PT750W_HOST", "") + if not host: + print("error: set PRINTER_HUB_PT750W_HOST to the printer's IP/hostname", file=sys.stderr) + return 2 + + BackendRegistry.ensure_discovered() + ModelRegistry.ensure_discovered() + + settings = Settings(printer_backend="ptouch", printer_model="PT-P750W", pt750w_host=host) + backend = PTouchBackend.from_settings(settings) + driver = PTP750WDriver(backend=backend) + printer = driver.make_queue_printer(TapeRegistry()) + + TemplateLoader.load_dir(Path(__file__).resolve().parent.parent / "app" / "seed" / "templates") + template = TemplateLoader.get(_TEMPLATE_ID) + label_data = LabelData( + title="Smoke", + primary_id=_SMOKE_PRIMARY_ID, + qr_payload=_SMOKE_QR_PAYLOAD, + secondary=(), + source_app="manual", + ) + image: Image.Image = LabelRenderer().render(template, label_data) + + print(f"[1/3] template={_TEMPLATE_ID}, image={image.size}") + print(f"[2/3] querying printer status @ {host}...") + status = await backend.query_status() + print(f" loaded_tape_mm={status.loaded_tape_mm}, media_type={status.media_type}") + print("[3/3] printing...") + await printer.print_image(image, tape_mm=template.tape_mm) + print("OK") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) +``` + +- [ ] **Step 2: Add the gated hardware test** + +```python +# backend/tests/hardware/test_pt_p750w_smoke.py +from __future__ import annotations + +import os + +import pytest + +pytestmark = pytest.mark.hardware + + +@pytest.mark.skipif( + not os.environ.get("PRINTER_HUB_PT750W_HOST"), + reason="PRINTER_HUB_PT750W_HOST not set", +) +async def test_smoke_first_print_succeeds() -> None: + """End-to-end hardware test: real printer prints a QR-only label.""" + from scripts.smoke_first_print import main + rc = await main() + assert rc == 0 +``` + +- [ ] **Step 3: Verify the gated test is skipped by default** + +```bash +cd backend && pytest tests/hardware -q +``` + +Expected: tests collected and skipped with reason `hardware tests need --hardware flag`. + +- [ ] **Step 4: Verify the gated test runs with --hardware (only when hardware is present)** + +```bash +cd backend && PRINTER_HUB_PT750W_HOST= pytest tests/hardware -q --hardware +``` + +Skip this step in CI; run it manually at the end of the implementation phase against the maintainer's real PT-P750W. + +- [ ] **Step 5: Commit** + +```bash +git add backend/scripts/__init__.py \ + backend/scripts/smoke_first_print.py \ + backend/tests/hardware/__init__.py \ + backend/tests/hardware/test_pt_p750w_smoke.py +git commit -m "$(cat <<'EOF' +test(api): hardware smoke for PT-P750W First-Print + +Manual smoke script (scripts/smoke_first_print.py) renders the +qr-only-24mm seed template with primary_id=SMOKE-001 and prints it +on a real PT-P750W identified by PRINTER_HUB_PT750W_HOST. + +Adds the gated test tests/hardware/test_pt_p750w_smoke.py — skipped +by default, runs only with `pytest --hardware` and a configured host. +The conftest hardware-marker is already in place from earlier work. + +Refs #22 +EOF +)" +``` + +--- + +## Phase 16 — Final verification + push + +### Task 16.1: Run every gate locally + +- [ ] **Step 1: Format check** + +```bash +cd backend && ruff format --check . +``` + +Expected: no diffs. If anything to fix: `ruff format .`, review with `git diff`, `git add` + amend the most-recent commit only if it is the offending one; otherwise create a new `style:` commit. + +- [ ] **Step 2: Lint** + +```bash +cd backend && ruff check . +``` + +Expected: no errors. + +- [ ] **Step 3: Type check** + +```bash +cd backend && mypy app +``` + +Expected: 0 errors. Imports of `ptouch.*` are ignored via the existing pyproject override. + +- [ ] **Step 4: Test + coverage** + +```bash +cd backend && pytest --cov=app --cov-fail-under=80 -q +``` + +Expected: all tests pass, coverage ≥ 80%. + +- [ ] **Step 5: Conventional Commits check** + +```bash +cd /opt/repos/label-printer-hub +git log --format='%s' main..HEAD | npx commitlint --from main +``` + +Expected: exit 0. + +- [ ] **Step 6: Manual hardware smoke** + +```bash +cd backend +PRINTER_HUB_PT750W_HOST= python -m scripts.smoke_first_print +``` + +Expected: `OK` and a physical label out of the printer. Verify visually that the QR code on the label decodes to `https://example.test/smoke`. + +- [ ] **Step 7: Manual: swap tape mid-print** + +Reload the printer with a 12mm tape (template wants 24mm). Re-run the smoke. Expected: `TapeMismatchError` raised before printing. + +- [ ] **Step 8: Manual: power off the printer** + +Power off the PT-P750W, re-run the smoke. Expected: `PrinterOfflineError` after exactly 3 retries (back-off 0s + 1s + 2s ≈ 3 seconds total before the error). + +### Task 16.2: Stop point — wait for human review + +- [ ] **Step 1: Show all commits to the operator** + +```bash +cd /opt/repos/label-printer-hub && git log --oneline main..HEAD +``` + +- [ ] **Step 2: Stop** + +**Do NOT push.** Hand back to the orchestrator for human code review. The orchestrator handles the push and PR open after the operator approves. + +--- + +## Spec coverage self-review + +| Spec requirement | Task(s) | +|---|---| +| `PrinterBackend` Protocol (`print_image` + `query_status`) | 3.1 | +| `PTouchBackend` wrapping ptouch | 6.2 | +| `MockPrinterBackend` in `app/printer_backends/` | 4.1 | +| `PrinterError` hierarchy | 2.1 | +| Status query (ESC i S) — design says ptouch, but ptouch doesn't expose it | 1.1, 1.2, 6.1, 6.2 | +| `PTP750WDriver` + `make_queue_printer` + `_PTPQueuePrinter` | 8.1 | +| `ModelRegistry.find_by_model_id` + entry_points | 7.1 | +| `BackendRegistry` + entry_points | 5.1, 5.2, 6.2 | +| `from_settings(settings)` on backends | 4.1 (Mock), 6.2 (PTouch) | +| `PrintService.submit_print_job` | 10.1 | +| `RawLabelData`, `PrintRequest`, `PrintOptions`, `PrintJobResponse`, `PrintJobStatusResponse` | 9.1, 9.2 | +| `POST /print` + `GET /jobs/{job_id}` + sync error mapping | 11.1 | +| Settings: `printer_backend`, `printer_model`, `printer_queue_timeout_s` | 12.1 | +| Lifespan-Init + `_build_backend` + queue.stop on shutdown | 13.1 | +| Acceptance 1 — 202 with job_id for qr-only-24mm | 14.1 | +| Acceptance 2 — status sequence queued/printing/completed | 14.1 | +| Acceptance 3 — mock received the expected image | 14.1, 4.1 | +| Acceptance 4 — tape mismatch ends failed with code+detail | 14.2 | +| Acceptance 5 — offline ends failed after exactly 3 retries | 14.2, 6.2 | +| Acceptance 6 — template not found is sync 404 | 14.1, 11.1 | +| Acceptance 7 — lookup failure is sync 502 | 11.1 | +| Acceptance 8 — lifespan shutdown stops queue within timeout | 13.1 | +| Acceptance 9 — empty pt750w_host with ptouch fails fast | 13.1, 6.2 | +| Acceptance 10 — unknown model/backend fails fast | 13.1 | +| Acceptance 11 — fake plugin via entry_points works in a test | 5.1, 7.1 | +| Acceptance 12 — SNMP discovery resolves stubbed PJL to PTP750WDriver | 13.1 (`test_snmp_discovery_resolves_model`) | +| Acceptance 13 — SNMP failure falls back to printer_model when set; fails fast when empty | 13.1 (`test_snmp_discovery_fallback_to_setting`, `test_snmp_discovery_no_fallback_fails`) | +| Acceptance 14 — GET /jobs/{id} includes live block during PRINTING, None otherwise | 11.1 (`test_get_jobs_returns_status`, `test_get_jobs_no_live_block_when_not_printing`, `test_get_jobs_live_snmp_failure_is_non_fatal`) | +| Acceptance 15 — smoke_first_print.py prints on real hardware | 15.1, 16.1 | +| Acceptance 16 — coverage ≥80%, ruff+mypy green | 16.1 | +| SNMP OIDs documented | 1.3 | +| `query_model_pjl` + `query_live_status` + `LiveStatus` | 6.3 | +| `SnmpDiscoveryError` + `SnmpQueryError` in hierarchy | 2.1 | +| Settings: `printer_discover_via_snmp`, `printer_snmp_community` | 12.1 | +| `commitlint.config.cjs` scope for `printer-backends` | 0.1 | + +No spec section is unimplemented. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/plans/2026-05-15-first-print.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — orchestrator dispatches a fresh subagent per task, review between tasks, fast iteration. The implementer never pushes; the orchestrator handles push + PR after human code review. + +**2. Inline Execution** — execute tasks in the current session using executing-plans, batch execution with checkpoints for review. + +Which approach? From db117dd26bad9e156bdd682741bb17143adf9400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 17:18:08 +0000 Subject: [PATCH 08/12] docs(plans): use RFC 5737 documentation IP in test examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI privacy-scan correctly flagged the placeholder host "10.0.0.5" used throughout the plan's test code as an RFC 1918 private address. Replaced with 192.0.2.10 (RFC 5737 TEST-NET-1), which is reserved for documentation and not flagged by the scan. No semantic change — purely a placeholder substitution in 17 spots of example test code. Refs #22 --- docs/plans/2026-05-15-first-print.md | 34 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/plans/2026-05-15-first-print.md b/docs/plans/2026-05-15-first-print.md index b7b4272..fd7be9b 100644 --- a/docs/plans/2026-05-15-first-print.md +++ b/docs/plans/2026-05-15-first-print.md @@ -1505,7 +1505,7 @@ async def test_query_status_delegates_to_socket_helper( monkeypatch: pytest.MonkeyPatch, healthy_status: StatusBlock ) -> None: async def fake_query(host: str, port: int, *, timeout_s: float) -> StatusBlock: - assert host == "10.0.0.5" + assert host == "192.0.2.10" assert port == 9100 return healthy_status @@ -1513,7 +1513,7 @@ async def test_query_status_delegates_to_socket_helper( "app.printer_backends.ptouch_backend.query_status_over_socket", fake_query, ) - backend = PTouchBackend(host="10.0.0.5") + backend = PTouchBackend(host="192.0.2.10") status = await backend.query_status() assert status is healthy_status @@ -1619,9 +1619,9 @@ async def test_print_image_invokes_ptouch_when_healthy( "app.printer_backends.ptouch_backend._ptouch_print", fake_print, ) - backend = PTouchBackend(host="10.0.0.5") + backend = PTouchBackend(host="192.0.2.10") await backend.print_image(img_128, tape_24, auto_cut=True, high_resolution=False) - assert captured["host"] == "10.0.0.5" + assert captured["host"] == "192.0.2.10" assert captured["port"] == 9100 assert captured["tape_mm"] == 24 assert captured["auto_cut"] is True @@ -1657,12 +1657,12 @@ async def test_print_image_wraps_ptouch_exception( def test_from_settings_reads_pt750w_host() -> None: class S: - pt750w_host = "10.0.0.5" + pt750w_host = "192.0.2.10" pt750w_port = 9100 printer_model = "PT-P750W" backend = PTouchBackend.from_settings(S()) # type: ignore[arg-type] - assert backend.host == "10.0.0.5" + assert backend.host == "192.0.2.10" def test_from_settings_empty_host_raises() -> None: @@ -1978,7 +1978,7 @@ async def test_query_model_pjl_happy_path(monkeypatch: pytest.MonkeyPatch) -> No return ok_pdu monkeypatch.setattr("app.printer_backends.snmp_helper.get_cmd", fake_get_cmd) - pjl = await query_model_pjl("10.0.0.5", community="public", timeout_s=1.0) + pjl = await query_model_pjl("192.0.2.10", community="public", timeout_s=1.0) assert pjl == expected_pjl assert BROTHER_PJL_OID in captured["oids"][0] @@ -1989,7 +1989,7 @@ async def test_query_model_pjl_unreachable_raises(monkeypatch: pytest.MonkeyPatc monkeypatch.setattr("app.printer_backends.snmp_helper.get_cmd", fake_get_cmd) with pytest.raises(SnmpDiscoveryError, match="timed out"): - await query_model_pjl("10.0.0.5", community="public", timeout_s=1.0) + await query_model_pjl("192.0.2.10", community="public", timeout_s=1.0) async def test_query_live_status_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: @@ -2006,7 +2006,7 @@ async def test_query_live_status_happy_path(monkeypatch: pytest.MonkeyPatch) -> ) monkeypatch.setattr("app.printer_backends.snmp_helper.get_cmd", fake_get_cmd) - ls = await query_live_status("10.0.0.5", community="public", timeout_s=1.0) + ls = await query_live_status("192.0.2.10", community="public", timeout_s=1.0) assert isinstance(ls, LiveStatus) assert ls.hr_printer_status == "printing" assert "noPaper" in ls.error_flags @@ -2020,7 +2020,7 @@ async def test_query_live_status_failure_is_separate_exception( monkeypatch.setattr("app.printer_backends.snmp_helper.get_cmd", fake_get_cmd) with pytest.raises(SnmpQueryError): - await query_live_status("10.0.0.5", community="public", timeout_s=1.0) + await query_live_status("192.0.2.10", community="public", timeout_s=1.0) ``` - [ ] **Step 2: Run — verify failure** @@ -2420,7 +2420,7 @@ from app.services.tape_registry import TapeRegistry @pytest.fixture def backend() -> MockPrinterBackend: - return MockPrinterBackend(host="10.0.0.5") + return MockPrinterBackend(host="192.0.2.10") @pytest.fixture @@ -2470,7 +2470,7 @@ def test_make_queue_printer_returns_printer_like( driver = PTP750WDriver(backend=backend) qp = driver.make_queue_printer(tape_registry) assert isinstance(qp, _PrinterLike) - assert qp.id == "PT-P750W@10.0.0.5" + assert qp.id == "PT-P750W@192.0.2.10" async def test_queue_printer_print_calls_backend( @@ -3410,7 +3410,7 @@ async def test_get_jobs_returns_status(fake_service, fake_queue, monkeypatch) -> monkeypatch.setattr("app.api.routes.print.query_live_status", fake_live) app = _app(fake_service, fake_queue) - app.state.printer_host = "10.0.0.5" + app.state.printer_host = "192.0.2.10" app.state.printer_snmp_community = "public" async with await _client(app) as c: r = await c.get("/jobs/job-1") @@ -3458,7 +3458,7 @@ async def test_get_jobs_live_snmp_failure_is_non_fatal(fake_service, fake_queue, monkeypatch.setattr("app.api.routes.print.query_live_status", fake_live) app = _app(fake_service, fake_queue) - app.state.printer_host = "10.0.0.5" + app.state.printer_host = "192.0.2.10" app.state.printer_snmp_community = "public" async with await _client(app) as c: r = await c.get("/jobs/job-1") @@ -3795,7 +3795,7 @@ async def test_snmp_discovery_resolves_model(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "mock") monkeypatch.setenv("PRINTER_HUB_PRINTER_DISCOVER_VIA_SNMP", "true") monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "") # require SNMP - monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "10.0.0.5") + monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "192.0.2.10") async def fake_query(host: str, *, community: str = "public", timeout_s: float = 3.0): # noqa: ARG001 return "MFG:Brother;CMD:PJL;MDL:PT-P750W;CLS:PRINTER;DES:Brother PT-P750W;" @@ -3816,7 +3816,7 @@ async def test_snmp_discovery_fallback_to_setting(monkeypatch: pytest.MonkeyPatc monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "mock") monkeypatch.setenv("PRINTER_HUB_PRINTER_DISCOVER_VIA_SNMP", "true") monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "PT-P750W") - monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "10.0.0.5") + monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "192.0.2.10") from app.printer_backends.exceptions import SnmpDiscoveryError @@ -3836,7 +3836,7 @@ async def test_snmp_discovery_no_fallback_fails(monkeypatch: pytest.MonkeyPatch) monkeypatch.setenv("PRINTER_HUB_PRINTER_BACKEND", "mock") monkeypatch.setenv("PRINTER_HUB_PRINTER_DISCOVER_VIA_SNMP", "true") monkeypatch.setenv("PRINTER_HUB_PRINTER_MODEL", "") - monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "10.0.0.5") + monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "192.0.2.10") from app.printer_backends.exceptions import SnmpDiscoveryError From 2d2ca27122ccc5a6db096a6c80e2fc518c7d6149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 17:22:47 +0000 Subject: [PATCH 09/12] docs(plans): address Gemini review on 30c627a MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings, all valid or sensible clarifications: * query_status(host: str, ...) drops the empty-string default to match the PrinterModel Protocol signature exactly. Empty string is still accepted (means "use the bound backend's host"); a non-matching non-empty host still raises ValueError. The unit test that called query_status() without arguments now passes host="" explicitly. * build_print_job raises NotImplementedError instead of returning empty bytes — unintended callers now fail loudly. Added a unit test asserting the raise. Coverage config already excludes `raise NotImplementedError`. * Live-status SNMP call from GET /jobs/{id} now passes timeout_s=1.0 (was the default 3.0s) — keeps the request path snappy even when SNMP is slow or unreachable; failure stays non-fatal and the live block is omitted. * Retry-policy wording aligned with Acceptance Criterion 5: "exactly 3 attempts" (was "3 retries", which Gemini read as 4 attempts). The implementation does 3 attempts with back-off sleeps 0s, 1s, 2s between them — semantic unchanged. Memory growth of PrintQueue._jobs is acknowledged in the Persistence section already; bounded eviction stays a Phase-5 deliverable for First-Print. The other Gemini concern (lifespan bypassing _resolve_printer_model in the Spec) lives in #57 and is fixed there. Refs #22 --- docs/plans/2026-05-15-first-print.md | 45 ++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/plans/2026-05-15-first-print.md b/docs/plans/2026-05-15-first-print.md index fd7be9b..5cbdf11 100644 --- a/docs/plans/2026-05-15-first-print.md +++ b/docs/plans/2026-05-15-first-print.md @@ -1892,7 +1892,7 @@ feat(printer-backends): PTouchBackend wrapping ptouch library Implements PrinterBackend against the ptouch Python library: -* query_status — uses the raw-socket ESC i S helper, retries 3 times +* query_status — uses the raw-socket ESC i S helper, makes exactly 3 attempts with back-off (0s, 1s, 2s) on PrinterOfflineError. * print_image — pre-validates against query_status (tape_empty, cover_open, tape mismatch); dispatches the synchronous ptouch.print @@ -2438,7 +2438,8 @@ def test_constants() -> None: async def test_query_status_delegates_to_backend(backend: MockPrinterBackend) -> None: driver = PTP750WDriver(backend=backend) - status = await driver.query_status() + # `host` is required by the Protocol; empty string means "use the bound backend's host" + status = await driver.query_status(host="") assert status.loaded_tape_mm == 24 @@ -2454,6 +2455,18 @@ async def test_query_status_accepts_matching_host(backend: MockPrinterBackend) - assert status.loaded_tape_mm == 24 +def test_build_print_job_raises_not_implemented(backend: MockPrinterBackend) -> None: + driver = PTP750WDriver(backend=backend) + image = Image.new("1", (200, 128)) + spec = TapeSpec( + width_mm=24, media_type=MediaType.LAMINATED, + print_area_pins=128, print_area_dots=128, bytes_per_raster=16, + min_length_mm=4.4, max_length_mm=1000, cutter_min_length_mm=24.5, + ) + with pytest.raises(NotImplementedError): + driver.build_print_job(image, spec) + + def test_width_to_pixels(backend: MockPrinterBackend) -> None: driver = PTP750WDriver(backend=backend) spec = TapeSpec( @@ -2545,8 +2558,14 @@ class PTP750WDriver: # --- PrinterModel --- async def query_status( - self, host: str = "", port: int = 9100, timeout_s: float = 5.0 # noqa: ARG002 + self, host: str, port: int = 9100, timeout_s: float = 5.0 # noqa: ARG002 ) -> StatusBlock: + # Protocol requires `host` positionally. The driver is bound to a + # backend that already knows its host, so the only sensible call is + # `driver.query_status(driver._backend.host, ...)` or — when callers + # have a bound driver and don't care — `driver.query_status("", ...)`. + # We accept the empty string as "use bound backend's host" without + # raising, but reject any other non-matching host loudly. if host and host != self._backend.host: raise ValueError( f"Driver bound to backend.host={self._backend.host!r}; " @@ -2565,10 +2584,16 @@ class PTP750WDriver: Callers wanting raw bytes for export/debug can be added later; the First-Print path goes through backend.print_image() and never calls - this method. Returning empty bytes lets static analyzers see a - bytes-return path without forcing an unreachable NotImplementedError. + this method. We raise NotImplementedError rather than returning empty + bytes so any unintended caller fails loudly instead of silently + sending no data. The pyproject coverage config excludes + `raise NotImplementedError` from coverage. """ - return b"" + raise NotImplementedError( + "PTP750WDriver delegates encoding to backend.print_image(). " + "build_print_job() will be implemented when a real caller " + "(raw-export, debugging, non-library backend) appears." + ) # --- queue-printer factory --- def make_queue_printer( @@ -3550,7 +3575,9 @@ async def get_job_status(job_id: str, http: Request) -> PrintJobStatusResponse: community = getattr(http.app.state, "printer_snmp_community", "public") if host: try: - live = await query_live_status(host, community=community) + # Short timeout — this is on the request path, must stay snappy. + # If SNMP is slow or unavailable, omit the live block (non-fatal). + live = await query_live_status(host, community=community, timeout_s=1.0) except SnmpQueryError: _log.warning("live SNMP query failed for job %s", job_id, exc_info=True) live = None @@ -4471,7 +4498,7 @@ Reload the printer with a 12mm tape (template wants 24mm). Re-run the smoke. Exp - [ ] **Step 8: Manual: power off the printer** -Power off the PT-P750W, re-run the smoke. Expected: `PrinterOfflineError` after exactly 3 retries (back-off 0s + 1s + 2s ≈ 3 seconds total before the error). +Power off the PT-P750W, re-run the smoke. Expected: `PrinterOfflineError` after exactly 3 attempts (initial + 2 retries; back-off sleeps 0s, 1s, 2s; total ~3 seconds plus socket-timeout per attempt before the error). ### Task 16.2: Stop point — wait for human review @@ -4509,7 +4536,7 @@ cd /opt/repos/label-printer-hub && git log --oneline main..HEAD | Acceptance 2 — status sequence queued/printing/completed | 14.1 | | Acceptance 3 — mock received the expected image | 14.1, 4.1 | | Acceptance 4 — tape mismatch ends failed with code+detail | 14.2 | -| Acceptance 5 — offline ends failed after exactly 3 retries | 14.2, 6.2 | +| Acceptance 5 — offline ends failed after exactly 3 attempts | 14.2, 6.2 | | Acceptance 6 — template not found is sync 404 | 14.1, 11.1 | | Acceptance 7 — lookup failure is sync 502 | 11.1 | | Acceptance 8 — lifespan shutdown stops queue within timeout | 13.1 | From 160aea5232ee1e214a836d4f97d8ea74e95f0dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 17:23:28 +0000 Subject: [PATCH 10/12] docs(designs): lifespan calls _resolve_printer_model when SNMP enabled Earlier draft of the lifespan snippet still did ModelRegistry.find_by_model_id(settings.printer_model) directly, bypassing the SNMP-discovery flow introduced in the SNMP-hybrid section. Fixed: the snippet now picks model_id from _resolve_printer_model (SNMP first, fall back to setting) before the registry lookup. Also wires app.state.printer_host and app.state.printer_snmp_community so the route handler can do live SNMP queries. Refs #22 --- docs/designs/2026-05-15-first-print.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/designs/2026-05-15-first-print.md b/docs/designs/2026-05-15-first-print.md index 5d8a793..ccaed8b 100644 --- a/docs/designs/2026-05-15-first-print.md +++ b/docs/designs/2026-05-15-first-print.md @@ -326,7 +326,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: BackendRegistry.ensure_discovered() backend = _build_backend(settings) - driver_cls = ModelRegistry.find_by_model_id(settings.printer_model) + # Resolve model via SNMP first (when enabled), fall back to settings.printer_model. + # The full flow is in the "SNMP — discovery + live status" section. + discovery_host = settings.pt750w_host or "" + if discovery_host and settings.printer_discover_via_snmp: + model_id = await _resolve_printer_model(settings, discovery_host) + else: + model_id = settings.printer_model + if not model_id: + raise ValueError("printer_model is empty and SNMP discovery is disabled.") + + driver_cls = ModelRegistry.find_by_model_id(model_id) driver = driver_cls(backend=backend) printer = driver.make_queue_printer(tape_registry) queue = PrintQueue(printers=[printer]) # matches current __init__ signature @@ -334,6 +344,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.print_queue = queue app.state.printer_id = printer.id # PrintService passes this to PrintQueue.submit(...) + app.state.printer_host = discovery_host # used by route handler for live SNMP + app.state.printer_snmp_community = settings.printer_snmp_community app.state.print_service = PrintService( template_loader=TemplateLoader, renderer=LabelRenderer(), From c414a0e4ab42eea048a25ba3243df86766494da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 18:05:53 +0000 Subject: [PATCH 11/12] docs(designs): lifespan wires PrintService with lookup_service + printer_id Spec-vs-Plan drift fix: PrintService takes lookup_service + printer_id (Plan Phase 10), not integration_registry. The lifespan snippet in the spec is the authoritative example for the constructor call. Refs #22 --- docs/designs/2026-05-15-first-print.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/designs/2026-05-15-first-print.md b/docs/designs/2026-05-15-first-print.md index ccaed8b..49a9d5c 100644 --- a/docs/designs/2026-05-15-first-print.md +++ b/docs/designs/2026-05-15-first-print.md @@ -350,7 +350,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: template_loader=TemplateLoader, renderer=LabelRenderer(), print_queue=queue, - integration_registry=IntegrationRegistry, + lookup_service=AppLookupService(), + printer_id=printer.id, ) try: From 38521e95b6ce0133e8cc0a16cea811c371e4c4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 18:06:35 +0000 Subject: [PATCH 12/12] docs(plans): address superpowers code-review (Phase 1.5 + drift fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five Critical + several Important findings from a fresh-eyes review. The plan was producing tests against StatusBlock / Job / TemplateLoader APIs that don't exist in the real codebase. Add a Phase 1.5 — Domain Model Extensions — before any backend code is written: * Task 1.5.1 — StatusBlock @property tape_empty / cover_open / loaded_tape_mm (derived from the real errors IntFlag and media_width_mm fields). Plus tests/_helpers/status.make_status_block so subsequent tests don't spell out all 18 dataclass fields. * Task 1.5.2 — TemplateNotFoundError in template_loader (subclasses KeyError; the route maps it to HTTP 404 per Acceptance #6). * Task 1.5.3 — LookupFailedError umbrella + UnknownAppError as a subclass; AppLookupService.lookup wraps any plugin runtime exception. Route maps to HTTP 502 per Acceptance #7. * Task 1.5.4 — Job carries error_code / error_message / error_detail (Acceptance #4 needs structured detail like {expected_mm, loaded_mm}). * Task 1.5.5 — PrintQueue worker catches PrinterError subclasses, populates the three new Job fields, then transitions to FAILED. TapeMismatchError gets a typed detail dict. * Task 1.5.6 — @runtime_checkable on _PrinterLike Protocol so the Phase 8 isinstance() check works. Additional fixes: * Phase 6.1 parse_status_reply delegates to the existing StatusBlockParser.parse instead of inventing a partial parser. The legacy _MEDIA_TYPE_LOOKUP table is removed. * Phase 8 commit body no longer claims "returns empty bytes" — build_print_job raises NotImplementedError, which the code already does. Stale doc text removed. * PTouchBackend._ptouch_print is now model-aware via _PTOUCH_PRINTER_ CLASSES[model_id] lookup. Previously hardcoded PTP750W defeated Extensibility Path 1 (PT-P900, PT-E550W). * PrintService no longer forwards copies to queue.submit. Multi-copy delivery is a Phase-5 follow-up; clients can post N times today. Test assertion updated to "copies not in kwargs"; commit body of the PrintService task corrected. * app/main.py snippet has `import logging` (was missing while _log = logging.getLogger(__name__) was already present). * app/api/routes/print.py snippet now has one consolidated import block — the previous "Required imports for the route module" trailing block is folded in to prevent paste mistakes. * Convention note added at the top of Phase 2 spelling out that every later test uses make_status_block(...) instead of StatusBlock(...). The implementer rewrites the six existing StatusBlock(...) call sites to make_status_block(...) accordingly. Refs #22 --- docs/plans/2026-05-15-first-print.md | 625 +++++++++++++++++++++++++-- 1 file changed, 584 insertions(+), 41 deletions(-) diff --git a/docs/plans/2026-05-15-first-print.md b/docs/plans/2026-05-15-first-print.md index 5cbdf11..e6257de 100644 --- a/docs/plans/2026-05-15-first-print.md +++ b/docs/plans/2026-05-15-first-print.md @@ -346,8 +346,543 @@ EOF --- +## Phase 1.5 — Domain Model Extensions + +The Spec assumes a handful of conveniences that the existing codebase does not yet expose. Adding them now — before any `printer_backends/` code is written — keeps every later phase honest. Six small additions, each its own task. + +### Task 1.5.1: Extend `StatusBlock` with derived properties + add a test helper + +**Files:** +- Modify: `backend/app/services/status_block.py` +- Modify: `backend/tests/unit/services/test_status_block.py` +- Create: `backend/tests/_helpers/__init__.py` +- Create: `backend/tests/_helpers/status.py` + +The real `StatusBlock` already has every byte the printer returns: `errors: PrinterError` (IntFlag), `media_width_mm: int`, `media_type: MediaType`, etc. The Spec's pre-print validation talks about `tape_empty` / `cover_open` / `loaded_tape_mm` as if they were attributes — make them so via `@property`, without breaking the existing API. + +- [ ] **Step 1: Write the failing tests** + +```python +# backend/tests/unit/services/test_status_block.py — APPEND +from app.services.status_block import ( + MediaType, PhaseType, PrinterError, StatusBlock, StatusType, + NotificationCode, TapeColor, TextColor, +) + + +def _full_status_block(*, errors: PrinterError = PrinterError.NONE, media_width_mm: int = 24) -> StatusBlock: + return StatusBlock( + raw=b"\x00" * 32, print_head_mark=0x80, size=0x20, + brother_code=ord("B"), series_code=0, model_code=0, country_code=0x30, + media_width_mm=media_width_mm, media_type=MediaType.LAMINATED, + media_length_mm=0, mode=0, + status_type=StatusType.REPLY, phase_type=PhaseType.EDITING, phase_number=0, + notification=NotificationCode.NONE, tape_color=TapeColor.NONE, text_color=TextColor.NONE, + errors=errors, + ) + + +class TestDerivedProperties: + def test_loaded_tape_mm_is_media_width(self) -> None: + sb = _full_status_block(media_width_mm=18) + assert sb.loaded_tape_mm == 18 + + def test_tape_empty_when_no_media_flag_set(self) -> None: + sb = _full_status_block(errors=PrinterError.NO_MEDIA) + assert sb.tape_empty is True + + def test_tape_empty_when_end_of_media_flag_set(self) -> None: + sb = _full_status_block(errors=PrinterError.END_OF_MEDIA) + assert sb.tape_empty is True + + def test_tape_empty_false_when_other_errors(self) -> None: + sb = _full_status_block(errors=PrinterError.COVER_OPEN) + assert sb.tape_empty is False + + def test_cover_open_flag(self) -> None: + sb = _full_status_block(errors=PrinterError.COVER_OPEN) + assert sb.cover_open is True + + def test_cover_open_false_when_no_cover_error(self) -> None: + sb = _full_status_block(errors=PrinterError.NONE) + assert sb.cover_open is False +``` + +- [ ] **Step 2: Run — verify failure** + +```bash +cd backend && pytest tests/unit/services/test_status_block.py::TestDerivedProperties -q +``` + +Expected: `AttributeError: 'StatusBlock' object has no attribute 'loaded_tape_mm'`. + +- [ ] **Step 3: Implement — add 3 properties to `StatusBlock`** + +Add to `backend/app/services/status_block.py` inside `class StatusBlock` (after the existing `is_ready` and `is_printing` properties): + +```python + @property + def loaded_tape_mm(self) -> int: + """Width of the tape currently loaded, in mm. 0 when no tape inserted.""" + return self.media_width_mm + + @property + def tape_empty(self) -> bool: + """True when no media is loaded or the tape ran out mid-print.""" + return bool(self.errors & (PrinterError.NO_MEDIA | PrinterError.END_OF_MEDIA)) + + @property + def cover_open(self) -> bool: + """True when the printer cover is open.""" + return PrinterError.COVER_OPEN in self.errors +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +cd backend && pytest tests/unit/services/test_status_block.py -q +``` + +Expected: all existing tests still pass, plus 6 new tests in `TestDerivedProperties`. + +- [ ] **Step 5: Add the test helper** + +```python +# backend/tests/_helpers/__init__.py +"""Shared test utilities (not part of the application).""" +``` + +```python +# backend/tests/_helpers/status.py +"""Helper to build StatusBlock instances in tests with minimal boilerplate. + +The real StatusBlock has 18 fields. Most tests only care about three: +loaded media width, tape-empty / cover-open flags. `make_status_block` +takes those as kwargs and fills the rest with neutral defaults. +""" + +from __future__ import annotations + +from app.services.status_block import ( + MediaType, NotificationCode, PhaseType, PrinterError, + StatusBlock, StatusType, TapeColor, TextColor, +) + + +def make_status_block( + *, + loaded_tape_mm: int = 24, + media_type: MediaType = MediaType.LAMINATED, + tape_empty: bool = False, + cover_open: bool = False, + extra_errors: PrinterError = PrinterError.NONE, +) -> StatusBlock: + """Build a StatusBlock with neutral defaults and a small derived-error API.""" + errors = extra_errors + if tape_empty: + errors |= PrinterError.NO_MEDIA + if cover_open: + errors |= PrinterError.COVER_OPEN + return StatusBlock( + raw=b"\x00" * 32, print_head_mark=0x80, size=0x20, + brother_code=ord("B"), series_code=0, model_code=0, country_code=0x30, + media_width_mm=loaded_tape_mm, media_type=media_type, + media_length_mm=0, mode=0, + status_type=StatusType.REPLY, phase_type=PhaseType.EDITING, phase_number=0, + notification=NotificationCode.NONE, tape_color=TapeColor.NONE, text_color=TextColor.NONE, + errors=errors, + ) +``` + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/status_block.py \ + backend/tests/unit/services/test_status_block.py \ + backend/tests/_helpers/__init__.py \ + backend/tests/_helpers/status.py +git commit -m "$(cat <<'EOF' +feat(status): derived tape_empty / cover_open / loaded_tape_mm properties + +StatusBlock already carries every Brother byte (media_width_mm, +media_type, errors IntFlag, ...). The First-Print backend layer +prefers a tape_empty / cover_open / loaded_tape_mm vocabulary in +its validation chain. Add the three as @property on the existing +dataclass without changing the parser or any persisted field. + +Plus tests/_helpers/status.py: make_status_block(loaded_tape_mm=..., +tape_empty=..., cover_open=...) so every later test stops needing +to spell out all 18 dataclass fields. + +Refs #22 +EOF +)" +``` + +### Task 1.5.2: Add `TemplateNotFoundError` + +**Files:** +- Modify: `backend/app/services/template_loader.py` +- Modify: `backend/tests/unit/services/test_template_loader.py` + +`TemplateLoader.get` currently raises `KeyError`. The REST layer needs a typed exception to map to HTTP 404 (Acceptance #6). + +- [ ] **Step 1: Failing test** + +```python +# tests/unit/services/test_template_loader.py — APPEND +import pytest +from app.services.template_loader import TemplateLoader, TemplateNotFoundError + + +def test_get_unknown_raises_template_not_found() -> None: + TemplateLoader._templates.clear() + with pytest.raises(TemplateNotFoundError) as exc: + TemplateLoader.get("does-not-exist") + assert "does-not-exist" in str(exc.value) +``` + +- [ ] **Step 2: Implement** + +In `backend/app/services/template_loader.py` add: + +```python +class TemplateNotFoundError(KeyError): + """Requested template id is not registered. Subclasses KeyError so legacy + callers that catch KeyError keep working.""" +``` + +Change `TemplateLoader.get(template_id)` to `raise TemplateNotFoundError(template_id)` instead of `KeyError`. + +- [ ] **Step 3: Run + commit** + +```bash +cd backend && pytest tests/unit/services/test_template_loader.py -q +git add backend/app/services/template_loader.py backend/tests/unit/services/test_template_loader.py +git commit -m "$(cat <<'EOF' +feat(api): TemplateNotFoundError typed exception + +TemplateLoader.get() raised bare KeyError. The /print route needs a +typed exception to map onto HTTP 404 (Acceptance #6). +TemplateNotFoundError subclasses KeyError so any existing catcher +keeps working; new code can pattern-match precisely. + +Refs #22 +EOF +)" +``` + +### Task 1.5.3: Add `LookupFailedError` + wrap `UnknownAppError` + +**Files:** +- Modify: `backend/app/services/lookup_service.py` +- Modify: `backend/tests/unit/services/test_lookup_service.py` + +The REST layer needs a typed exception for HTTP 502 (Acceptance #7). `UnknownAppError` is a config mismatch (client asked for an unknown app), not a transport failure. Introduce `LookupFailedError` as the umbrella; make `UnknownAppError` a subclass; wrap plugin-internal exceptions too. + +- [ ] **Step 1: Failing test (APPEND)** + +```python +# tests/unit/services/test_lookup_service.py — APPEND +import pytest +from app.services.lookup_service import ( + AppLookupService, LookupFailedError, UnknownAppError, +) + + +def test_unknown_app_is_lookup_failed() -> None: + assert issubclass(UnknownAppError, LookupFailedError) + + +async def test_plugin_runtime_exception_becomes_lookup_failed(monkeypatch) -> None: + class _Boom: + async def lookup(self, identifier: str): # noqa: ARG002 + raise RuntimeError("upstream HTTP 503") + + monkeypatch.setattr("app.integrations.IntegrationRegistry.get", lambda _: _Boom()) + svc = AppLookupService() + with pytest.raises(LookupFailedError, match="upstream HTTP 503"): + await svc.lookup("snipeit", "123") +``` + +- [ ] **Step 2: Implement** + +```python +# backend/app/services/lookup_service.py +class LookupFailedError(Exception): + """Resolving label data via an integration plugin failed.""" + + +class UnknownAppError(LookupFailedError): + """The requested app is not registered with IntegrationRegistry.""" + + +class AppLookupService: + async def lookup(self, app: str, identifier: str) -> LabelData: + try: + plugin = IntegrationRegistry.get(app) + except KeyError as e: + raise UnknownAppError(str(e)) from e + try: + return await plugin.lookup(identifier) + except LookupFailedError: + raise + except Exception as e: + raise LookupFailedError(f"{app} lookup failed: {e}") from e +``` + +- [ ] **Step 3: Run + commit** + +```bash +cd backend && pytest tests/unit/services/test_lookup_service.py -q +git add backend/app/services/lookup_service.py backend/tests/unit/services/test_lookup_service.py +git commit -m "$(cat <<'EOF' +feat(api): LookupFailedError umbrella exception + +Typed exception family for /print route HTTP 502 (Acceptance #7). +LookupFailedError is the umbrella; UnknownAppError (config mismatch) +is a subclass. AppLookupService.lookup wraps any runtime exception +from the plugin into LookupFailedError so the REST layer has one +type to catch. + +Refs #22 +EOF +)" +``` + +### Task 1.5.4: Extend `Job` with error_code / error_message / error_detail + +**Files:** +- Modify: `backend/app/services/job_lifecycle.py` +- Modify: `backend/tests/unit/services/test_job_lifecycle.py` + +The REST `PrintJobStatusResponse` carries `error_code`, `error_message`, `error_detail`. Today `Job` has only `error_msg: str | None` and `error_flags: int | None`. Add the three structured fields; the worker (next task) populates them; the route handler surfaces them (Acceptance #4). + +- [ ] **Step 1: Failing test (APPEND)** + +```python +# tests/unit/services/test_job_lifecycle.py — APPEND +from app.services.job_lifecycle import Job + + +def test_job_has_error_code_default_none() -> None: + job = Job(id="j", printer_id="p") + assert job.error_code is None + assert job.error_message is None + assert job.error_detail is None + + +def test_job_error_fields_writable() -> None: + job = Job(id="j", printer_id="p") + job.error_code = "tape_mismatch" + job.error_message = "expected 24mm, loaded 12mm" + job.error_detail = {"expected_mm": 24, "loaded_mm": 12} + assert job.error_code == "tape_mismatch" + assert job.error_detail == {"expected_mm": 24, "loaded_mm": 12} +``` + +- [ ] **Step 2: Implement** + +In `backend/app/services/job_lifecycle.py` add to `class Job`: + +```python + # Set by the worker when a PrinterError subclass surfaces from print_image. + error_code: str | None = None + error_message: str | None = None + error_detail: dict[str, Any] | None = None +``` + +(`Any` is already imported.) + +- [ ] **Step 3: Run + commit** + +```bash +cd backend && pytest tests/unit/services/test_job_lifecycle.py -q +git add backend/app/services/job_lifecycle.py backend/tests/unit/services/test_job_lifecycle.py +git commit -m "$(cat <<'EOF' +feat(queue): Job carries error_code / error_message / error_detail + +REST /jobs/{id} surfaces these to the client (Acceptance #4 — tape +mismatch must produce error_code='tape_mismatch' and +error_detail={'expected_mm':24,'loaded_mm':12}). The worker (next +task) populates them when a PrinterError subclass surfaces from +the printer.print_image call. The existing error_msg / error_flags +fields stay for backward compatibility. + +Refs #22 +EOF +)" +``` + +### Task 1.5.5: PrintQueue worker translates `PrinterError` → Job error fields + +**Files:** +- Modify: `backend/app/services/print_queue.py` +- Modify: `backend/tests/unit/services/test_print_queue.py` + +Today `_worker` lets exceptions escape and the FSM transitions to `FAILED` with only `error_msg`. Wrap `printer.print_image` so any `PrinterError` populates the new structured fields before the FAILED transition. + +- [ ] **Step 1: Failing test (APPEND)** + +```python +# tests/unit/services/test_print_queue.py — APPEND +import pytest +from PIL import Image + +from app.printer_backends.exceptions import TapeMismatchError +from app.services.job_lifecycle import JobState +from app.services.print_queue import PrintQueue + + +class _MismatchPrinter: + id = "p1" + + async def print_image(self, image, *, tape_mm, **_options): # noqa: ARG002 + raise TapeMismatchError(expected_mm=tape_mm, loaded_mm=12) + + +async def test_worker_records_printer_error_fields() -> None: + queue = PrintQueue(printers=[_MismatchPrinter()]) + await queue.start() + try: + image = Image.new("1", (200, 128)) + job_id = await queue.submit("p1", image, tape_mm=24) + job = await queue.wait_for_job(job_id, timeout_s=2.0) + assert job.state == JobState.FAILED + assert job.error_code == "tape_mismatch" + assert job.error_message + assert job.error_detail == {"expected_mm": 24, "loaded_mm": 12} + finally: + await queue.stop(timeout_s=2.0) +``` + +- [ ] **Step 2: Implement** — add a mapping helper + wrap the worker call site + +```python +# top of backend/app/services/print_queue.py — new imports +from app.printer_backends.exceptions import ( + PrinterCoverOpenError, + PrinterError as _PE, + PrinterOfflineError, + PrintFailedError, + StatusQueryFailedError, + TapeEmptyError, + TapeMismatchError, +) + + +_ERROR_CODE_MAP: dict[type[_PE], str] = { + TapeMismatchError: "tape_mismatch", + TapeEmptyError: "tape_empty", + PrinterCoverOpenError: "printer_cover_open", + PrinterOfflineError: "printer_offline", + StatusQueryFailedError: "printer_status_unavailable", + PrintFailedError: "print_failed", +} + + +def _printer_error_to_record(exc: _PE) -> tuple[str, str, dict[str, Any] | None]: + code = _ERROR_CODE_MAP.get(type(exc), "print_failed") + detail: dict[str, Any] | None = None + if isinstance(exc, TapeMismatchError): + detail = {"expected_mm": exc.expected_mm, "loaded_mm": exc.loaded_mm} + return code, str(exc) or code, detail +``` + +In `_worker`, around the `printer.print_image(...)` call site (it already has a `try`/`except` for the generic `Exception` path; replace it with): + +```python +try: + await printer.print_image(...) +except _PE as exc: + code, msg, detail = _printer_error_to_record(exc) + job.error_code = code + job.error_message = msg + job.error_detail = detail + job.error_msg = msg # legacy field kept in sync + JobStateMachine.transition(job, JobState.FAILED) + continue +``` + +- [ ] **Step 3: Run + commit** + +```bash +cd backend && pytest tests/unit/services/test_print_queue.py -q +git add backend/app/services/print_queue.py backend/tests/unit/services/test_print_queue.py +git commit -m "$(cat <<'EOF' +feat(queue): worker maps PrinterError subclasses to Job error fields + +When printer.print_image raises a PrinterError, the worker now +writes a structured (error_code, error_message, error_detail) +record onto the Job before transitioning to FAILED. TapeMismatchError +gets a typed detail dict carrying expected_mm + loaded_mm so the +REST client gets actionable diagnostics. + +Other PrinterError subclasses map to error_code only. The legacy +error_msg field is kept in sync for backwards-compatible callers. + +Refs #22 +EOF +)" +``` + +### Task 1.5.6: `@runtime_checkable` on `_PrinterLike` + +**Files:** +- Modify: `backend/app/services/print_queue.py` + +Phase 8 uses `isinstance(qp, _PrinterLike)`. That requires the decorator. + +- [ ] **Step 1: Verify the gap** + +```bash +cd backend && python -c " +from app.services.print_queue import _PrinterLike +class _Stub: + id = 's' + async def print_image(self, image, *, tape_mm, **_): pass +isinstance(_Stub(), _PrinterLike) +" +``` + +Expected: `TypeError: Instance and class checks can only be used with @runtime_checkable protocols`. + +- [ ] **Step 2: Implement** + +```python +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class _PrinterLike(Protocol): + ... +``` + +- [ ] **Step 3: Re-run + commit** + +The one-liner now prints `True`. + +```bash +git add backend/app/services/print_queue.py +git commit -m "$(cat <<'EOF' +refactor(queue): runtime_checkable _PrinterLike + +Phase 8 tests do isinstance(qp, _PrinterLike). That requires the +@runtime_checkable decorator; otherwise Python raises TypeError at +the isinstance call. runtime_checkable only relaxes isinstance — +the Protocol's structural shape is unchanged. + +Refs #22 +EOF +)" +``` + +--- + ## Phase 2 — Exceptions +> **Test-fixture convention from here on:** every test that needs a `StatusBlock` builds it with `make_status_block(...)` from `tests/_helpers/status.py` (introduced in Task 1.5.1). The helper accepts only the fields the test cares about (`loaded_tape_mm`, `tape_empty`, `cover_open`, `extra_errors`) and fills the remaining 14 dataclass fields with neutral defaults. If a code sample below shows `StatusBlock(loaded_tape_mm=24, ...)` as shorthand — that is the helper, not the raw dataclass constructor. The implementer **must** rewrite the `StatusBlock(...)` call sites in this plan to `make_status_block(...)` with the same kwargs (one site per test, ~6 total). The helper accepts `extra_errors=PrinterError.OVERHEATING` etc. when a test needs other errors. + ### Task 2.1: PrinterError hierarchy with TDD **Files:** @@ -1325,7 +1860,7 @@ from app.printer_backends.exceptions import ( PrinterOfflineError, StatusQueryFailedError, ) -from app.services.status_block import MediaType, StatusBlock +from app.services.status_block import StatusBlock, StatusBlockError, StatusBlockParser ESC_I_S_REQUEST: bytes = b"\x1bi\x53" _STATUS_REPLY_LEN: int = 32 @@ -1333,13 +1868,9 @@ _HEAD_MARK: int = 0x80 _SIZE_BYTE: int = 0x20 _BRAND_BYTE: int = ord("B") -_MEDIA_TYPE_LOOKUP: dict[int, MediaType] = { - 0x00: MediaType.NONE, - 0x01: MediaType.LAMINATED, - 0x03: MediaType.NON_LAMINATED, - 0x11: MediaType.HEAT_SHRINK_2_1, - 0x17: MediaType.HEAT_SHRINK_3_1, -} +# Note: media-type decoding (byte 11 → MediaType enum) is owned by +# StatusBlockParser. parse_status_reply() delegates to it; no parallel +# lookup table lives in this helper module. def parse_status_reply(reply: bytes) -> StatusBlock: @@ -1352,15 +1883,15 @@ def parse_status_reply(reply: bytes) -> StatusBlock: raise StatusQueryFailedError( f"Bad reply header: head={reply[0]:#x} brand={reply[2]:#x}" ) - err1 = reply[8] - err2 = reply[9] - return StatusBlock( - tape_empty=bool(err1 & 0x03), # bit 0 (no media) | bit 1 (end of media) - cover_open=bool(err2 & 0x10), # bit 4 - error_flags=(err1 << 8) | err2, - loaded_tape_mm=reply[10], - media_type=_MEDIA_TYPE_LOOKUP.get(reply[11], MediaType.NONE), - ) + # Delegate the heavy lifting to the existing StatusBlockParser. It already + # turns the 32 raw bytes into a fully populated StatusBlock (all 18 fields) + # and uses _safe_enum() for resilience against unknown values. Our wrapper + # just adds the cheap header sanity check above so a malformed reply gets + # a typed StatusQueryFailedError instead of bubbling StatusBlockError up. + try: + return StatusBlockParser.parse(reply) + except StatusBlockError as exc: + raise StatusQueryFailedError(str(exc)) from exc async def query_status_over_socket( @@ -1750,22 +2281,27 @@ def _ptouch_print( image: Image.Image, tape_mm: int, *, + model_id: str, auto_cut: bool, high_resolution: bool, ) -> None: """Synchronous helper: open connection, send one Label, close. - Lives at module level so tests can monkeypatch it. + Lives at module level so tests can monkeypatch it. The ptouch printer + class is resolved from `model_id` via `_PTOUCH_PRINTER_CLASSES`; this is + what enables the Extensibility Path 1 (PT-P900, PT-E550W, etc.) without + forking the backend. """ try: tape_cls = _PTOUCH_TAPE_CLASSES[tape_mm] except KeyError as exc: raise PrintFailedError(f"No ptouch tape class for {tape_mm}mm") from exc + try: + printer_cls = _PTOUCH_PRINTER_CLASSES[model_id] + except KeyError as exc: + raise PrintFailedError(f"No ptouch printer class for model {model_id!r}") from exc connection = ptouch.ConnectionNetwork(host, port=port, timeout=10.0) - printer = ptouch.PTP750W( - connection=connection, - high_resolution=high_resolution, - ) + printer = printer_cls(connection=connection, high_resolution=high_resolution) label = ptouch.Label(image=image, tape=tape_cls) printer.print(label, auto_cut=auto_cut, high_resolution=high_resolution) @@ -1840,6 +2376,7 @@ class PTouchBackend: self._port, image, tape_spec.width_mm, + model_id=self._model_id, auto_cut=auto_cut, high_resolution=high_resolution, ) @@ -2683,9 +3220,11 @@ produces a private _PTPQueuePrinter satisfying PrintQueue._PrinterLike. query_status raises ValueError on a non-matching host argument rather than silently ignoring it (the driver is bound to one backend). -build_print_job returns empty bytes for Protocol conformance — the -First-Print happy path uses backend.print_image directly; raw-byte -encoding is deferred until a concrete caller appears. +build_print_job raises NotImplementedError so any unintended caller +fails loudly instead of silently sending no data. The First-Print +happy path uses backend.print_image directly; raw-byte encoding is +deferred until a concrete caller (raw export, debug dump, non- +library backend) appears. Registered at module import via ModelRegistry.register and via the label_hub.printer_models entry-points group. @@ -3191,7 +3730,10 @@ async def test_options_passed_to_queue(loader, renderer, queue, lookup_service) assert kwargs["tape_mm"] == 24 assert kwargs["auto_cut"] is False assert kwargs["high_resolution"] is True - assert kwargs["copies"] == 2 + # `copies` is deliberately NOT forwarded to the queue in First-Print — + # see the comment in PrintService.submit_print_job. Multi-copy handling + # is a Phase-5 follow-up. + assert "copies" not in kwargs ``` - [ ] **Step 2: Run — verify failure** @@ -3270,12 +3812,15 @@ class PrintService: # 3. Render image = self._renderer.render(template, label_data) - # 4. Enqueue + # 4. Enqueue. `copies` is intentionally not forwarded: the queue + # delivers one print per job, and the queue worker does not loop on + # copies for First-Print (see Open Questions in the spec). Clients + # that need multi-copy can post N times; structured copies handling + # is a Phase-5 follow-up. return await self._queue.submit( self._printer_id, image, tape_mm=template.tape_mm, - copies=request.options.copies, auto_cut=request.options.auto_cut, high_resolution=request.options.high_resolution, ) @@ -3304,8 +3849,9 @@ print_queue.submit(printer_id, image, tape_mm=, **options). source_app for the raw-data path is fixed to "manual" here so the wire schema doesn't have to police it. Options propagate to submit -as keyword args (auto_cut, high_resolution, copies); Phase 13 wires -copies into the worker by submitting once per copy. +as keyword args (auto_cut, high_resolution). `copies` is intentionally +NOT forwarded for First-Print — multi-copy delivery is a Phase-5 +follow-up (clients can post N times today). Refs #22 EOF @@ -3514,6 +4060,7 @@ Expected: `ModuleNotFoundError: app.api.routes.print`. from __future__ import annotations +import logging from typing import Any from fastapi import APIRouter, HTTPException, Request, status @@ -3523,15 +4070,20 @@ from app.printer_backends.exceptions import ( PrinterCoverOpenError, PrinterOfflineError, PrintFailedError, + SnmpQueryError, StatusQueryFailedError, TapeEmptyError, TapeMismatchError, ) +from app.printer_backends.snmp_helper import LiveStatus, query_live_status from app.schemas.print_request import PrintRequest from app.schemas.print_response import PrintJobResponse, PrintJobStatusResponse +from app.services.job_lifecycle import JobState from app.services.lookup_service import LookupFailedError from app.services.template_loader import TemplateNotFoundError +_log = logging.getLogger(__name__) + router = APIRouter() _SYNC_ERROR_MAP: dict[type[Exception], tuple[int, str]] = { @@ -3595,17 +4147,7 @@ async def get_job_status(job_id: str, http: Request) -> PrintJobStatusResponse: ) ``` -Required imports for the route module: - -```python -import logging - -from app.printer_backends.exceptions import SnmpQueryError -from app.printer_backends.snmp_helper import LiveStatus, query_live_status -from app.services.job_lifecycle import JobState - -_log = logging.getLogger(__name__) -``` +All required imports are already in the file header above — no separate "imports" block to merge. - [ ] **Step 4: Add tests dir init** @@ -3902,6 +4444,7 @@ Expected: failures because the lifespan does not yet do plugin discovery / build # backend/app/main.py (sketch — preserve existing imports + routes) from __future__ import annotations +import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path