Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
96e53c9
docs(spec): Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#102)
strausmann Jun 4, 2026
8917d0e
docs(plan): Phase 1k.2 Multi-Label-Batch implementation plan (#102)
strausmann Jun 4, 2026
ad3aec5
docs(plan): adresse Gemini-Review G1+G2+G3 (PR #106)
strausmann Jun 4, 2026
4f56a2a
docs(spec+plan): adresse Copilot-Review C1-C9 + Privacy-Fixes (PR #106)
strausmann Jun 4, 2026
968b693
docs(plan): adresse Gemini Round-2 Review G-R2-1/2/3 (PR #106)
strausmann Jun 4, 2026
54e1123
feat(printer_backends): default_print_images_loop helper (Phase 1k.2 …
strausmann Jun 4, 2026
e93f945
feat(printer_backends): PrinterBackend.print_images Protocol method (…
strausmann Jun 4, 2026
5cbf955
fix(printer_backends): print_images stubs to fix Protocol isinstance …
strausmann Jun 4, 2026
c0d15f0
feat(ptouch): print_images via ptouch.print_multi (Phase 1k.2 Task 3)
strausmann Jun 4, 2026
f56e44c
feat(brother_ql): print_images via default_print_images_loop (Phase 1…
strausmann Jun 4, 2026
4ebdbee
feat(mock): MockBackend.print_images via default_print_images_loop (P…
strausmann Jun 4, 2026
15ef310
feat(pt): _PTPQueuePrinter.print_images + bug-fix half_cut/last_page …
strausmann Jun 4, 2026
cb1e317
feat(ql): _QLQueuePrinter.print_images adapter (Phase 1k.2 Task 7)
strausmann Jun 4, 2026
c08da56
feat(queue): BatchJob + enqueue_batch + worker isinstance dispatch (P…
strausmann Jun 4, 2026
c658e87
fix(queue): align image payloads with active_jobs + strengthen Task 8…
strausmann Jun 4, 2026
a2b6a98
feat(batch): dispatch_batch refactor to atomic BatchJob + submit_batc…
strausmann Jun 4, 2026
51539b5
fix(print_service): forward auto_cut/high_resolution from request opt…
strausmann Jun 4, 2026
bcc1d04
test(batch): end-to-end multi-label batch integration test (Phase 1k.…
strausmann Jun 4, 2026
d2ce28e
feat(scripts): smoke_first_print_batch.py — manual 4-item batch hardw…
strausmann Jun 4, 2026
ad47f7e
style: ruff format Task 11 integration test (Phase 1k.2 cleanup)
strausmann Jun 4, 2026
0b6250d
test(queue): coverage for _process_batch error paths + pragma on _pto…
strausmann Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions backend/app/api/routes/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@
PrinterCoverOpenError,
PrinterOfflineError,
SnmpQueryError,
TapeMismatchError,
)
from app.repositories import print_batches as batches_repo
from app.repositories import printers as printers_repo
from app.schemas.print_batch import BatchRequest, BatchResponse
from app.services.batch_dispatch import dispatch_batch
from app.services.batch_dispatch import MixedTapeSizesError, dispatch_batch

# SessionDep locally — Hub has no central app/api/deps.py module.
SessionDep = Annotated[AsyncSession, Depends(get_session)]
Expand All @@ -34,6 +35,7 @@
PrinterOfflineError: "printer_offline",
PrinterCoverOpenError: "printer_cover_open",
SnmpQueryError: "snmp_error",
TapeMismatchError: "tape_mismatch",
}


Expand Down Expand Up @@ -112,14 +114,23 @@ async def create_batch(
half_cut_override=body.half_cut_override,
backend=backend,
)
except (PrinterOfflineError, PrinterCoverOpenError, SnmpQueryError) as exc:
except (PrinterOfflineError, PrinterCoverOpenError, SnmpQueryError, TapeMismatchError) as exc:
raise HTTPException(
409,
detail={
"error_code": _SYNC_ERROR_MAP[type(exc)],
"error_message": str(exc),
},
) from exc
except MixedTapeSizesError as exc:
raise HTTPException(
400,
detail={
"error_code": "mixed_tape_sizes",
"error_message": str(exc),
"tape_mm_values": sorted(set(exc.tape_mm_values)),
},
) from exc

# 7. Persist tracking row
# auth.subject_id does NOT exist — use api_key_id or source
Expand Down
53 changes: 48 additions & 5 deletions backend/app/printer_backends/base.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
"""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.
Three-method surface (print_image, print_images, 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.

Phase 1k.2: print_images() added for batch printing via ptouch.print_multi
on PT-Series. Other backends delegate to default_print_images_loop helper.
"""

from __future__ import annotations
Expand Down Expand Up @@ -45,5 +48,45 @@ async def print_image(
mindestens ein weiteres Item (kein Cut zwischen).
"""

async def print_images(
self,
images: list[Image.Image],
tape_spec: TapeSpec,
*,
auto_cut: bool = True,
high_resolution: bool = False,
half_cut: bool = True,
) -> None:
"""Batch-print N images — atomic-or-best-effort je nach Backend-Impl.

Semantik haengt vom konkreten Backend ab:
- PTouchBackend via ptouch.print_multi: ATOMIC — auf Hardware-Ebene ein
einziger Print-Call. Bei Exception sind ggf. 0 oder ALLE Labels gedruckt,
niemals partial.
- Default-Loop (BrotherQLBackend, MockBackend) via
default_print_images_loop: BEST-EFFORT per item. Wenn item N
fehlschlaegt, koennen items 0..N-1 bereits physisch gedruckt sein.
Job-State-Handling muss damit umgehen (siehe Task 8 _process_batch).
(Copilot-Review C5 PR #106: vorher 'atomic semantics: success or all-fail'
war falsch fuer den default loop.)

Phase 1k.2: Default-Loop ueber print_image() lebt in
``app.printer_backends.batch_helper.default_print_images_loop``.
PTouchBackend ueberschreibt fuer ptouch.print_multi() (echtes
batch-fertig mit 5mm Half-Cut zwischen Labels statt 22.5mm Pre-Roll).
BrotherQLBackend und MockBackend delegieren explizit an den
default_print_images_loop helper.

Args:
images: PIL Images in print order. len(images) >= 1.
tape_spec: Shared TapeSpec — alle Items teilen das geladene Tape.
auto_cut: True = Drucker schneidet am Ende des Batches.
high_resolution: PT-Series HiRes-Mode.
half_cut: True = 5mm taktile Separation zwischen Items (PT-Series).
Letztes Item bekommt immer Voll-Cut (half_cut=False intern).
Default True differs from print_image (default False): in batch
context, inter-label half-cut separation is the expected mode.
"""

async def query_status(self) -> StatusBlock:
"""Send ESC i S, parse the 32-byte reply, return a StatusBlock."""
61 changes: 61 additions & 0 deletions backend/app/printer_backends/batch_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Default batch-print loop for Backends without native batch support.

Phase 1k.2: PTouchBackend overrides print_images() to use ptouch.print_multi()
for true atomic batch printing. BrotherQLBackend, MockBackend etc. delegate
their print_images() implementation to default_print_images_loop() here —
they loop over print_image() with correct half_cut + last_page semantics.

Semantics match the Brother iOS App: half_cut=True between intermediate
items (5mm taktile Trennung), half_cut=False + last_page=True on the final
item (voller Cut zur Trennung vom nächsten Batch).
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from PIL import Image

from app.models.tape import TapeSpec
from app.printer_backends.base import PrinterBackend


async def default_print_images_loop(
backend: PrinterBackend,
images: list[Image.Image],
tape_spec: TapeSpec,
*,
auto_cut: bool = True,
high_resolution: bool = False,
half_cut: bool = True,
) -> None:
"""Loop over print_image(); set half_cut + last_page per index.

Args:
backend: PrinterBackend instance whose print_image() is called per item.
images: Rendered PIL Images, one per batch item, in print order.
tape_spec: Shared TapeSpec — all items in a batch share the loaded tape.
auto_cut: Forwarded unchanged to each print_image call.
high_resolution: Forwarded unchanged to each print_image call.
half_cut: If True, intermediate items get half_cut=True (5mm taktile
separation). Last item always gets half_cut=False so the cutter
performs a full cut for batch separation.

Behaviour:
For each image at index i:
- is_last = (i == len(images) - 1)
- last_page = is_last (drives ptouch feed= → controls Pre-Roll)
- half_cut = half_cut and not is_last
"""
last_index = len(images) - 1
for i, image in enumerate(images):
is_last = i == last_index
await backend.print_image(
image,
tape_spec,
auto_cut=auto_cut,
high_resolution=high_resolution,
half_cut=half_cut and not is_last,
last_page=is_last,
)
27 changes: 27 additions & 0 deletions backend/app/printer_backends/brother_ql_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from PIL import Image

from app.models.tape import TapeSpec
from app.printer_backends.batch_helper import default_print_images_loop
from app.printer_backends.exceptions import (
PrinterCoverOpenError,
PrinterOfflineError,
Expand Down Expand Up @@ -121,6 +122,32 @@ async def print_image(
blocking=True,
)

async def print_images(
self,
images: list[Image.Image],
tape_spec: TapeSpec,
*,
auto_cut: bool = True,
high_resolution: bool = False,
half_cut: bool = True, # noqa: ARG002
) -> None:
"""Batch-print N images via per-item Loop.

brother_ql Lib hat kein print_multi-Equivalent — QL ist Endless-Tape,
kein Half-Cut-Konzept zwischen Labels (jedes Label wird vom Druckkopf
ausgegeben und manuell abgeschnitten). Delegiert an
default_print_images_loop mit half_cut=False (QL ignoriert das Argument).
"""
# QL-Series: half_cut existiert nicht. Backend-Capability-Flag erzwingt False.
await default_print_images_loop(
self,
images,
tape_spec,
auto_cut=auto_cut,
high_resolution=high_resolution,
half_cut=False,
)

async def preflight_check(
self,
*,
Expand Down
25 changes: 25 additions & 0 deletions backend/app/printer_backends/mock_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from PIL import Image

from app.models.tape import TapeSpec
from app.printer_backends.batch_helper import default_print_images_loop
from app.printer_backends.exceptions import (
PrinterCoverOpenError,
PrinterOfflineError,
Expand Down Expand Up @@ -150,3 +151,27 @@ async def print_image(
loaded_mm=status.loaded_tape_mm,
)
self.printed_images.append(image.copy())

async def print_images(
self,
images: list[Image.Image],
tape_spec: TapeSpec,
*,
auto_cut: bool = True,
high_resolution: bool = False,
half_cut: bool = True,
) -> None:
"""Test-friendly Mock-Implementation via default_print_images_loop.

Records each print_image call (because self.print_image is the existing
Mock recording method). Phase 1k.2 Task 5: replaces NotImplementedError
stub from Task 2 fix-up commit.
"""
await default_print_images_loop(
self,
images,
tape_spec,
auto_cut=auto_cut,
high_resolution=high_resolution,
half_cut=half_cut,
)
104 changes: 104 additions & 0 deletions backend/app/printer_backends/ptouch_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,61 @@ def _ptouch_print( # pragma: no cover - real-hardware-only, tests monkeypatch t
printer.print(label, auto_cut=auto_cut, high_resolution=high_resolution)


def _ptouch_print_multi( # pragma: no cover - real-hardware-only, tests monkeypatch this
host: str,
port: int,
images: list[Image.Image],
tape_mm: int,
*,
model_id: str,
auto_cut: bool,
high_resolution: bool,
half_cut: bool,
) -> None:
"""Synchronous helper for batch printing via ptouch.LabelPrinter.print_multi.

ptouch-py 1.1.0: LabelPrinter.print_multi(labels, margin_mm=None,
high_resolution=None, half_cut=True) — 1 Connection, 1 Init (=1 Pre-Roll),
5mm Half-Cut zwischen Labels, voller Cut am Ende.

Excluded from coverage: real-hardware-only. Tests monkeypatch this module-level
function. Hardware verification per scripts/smoke_first_print_batch.py (Task 11).
"""
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 = printer_cls(connection=connection, high_resolution=high_resolution)
labels = [ptouch.Label(image=img, tape=tape_cls) for img in images]
try:
printer.print_multi(
labels,
high_resolution=high_resolution,
half_cut=half_cut,
)
except TypeError:
# Älterer ptouch-Lib (<1.1) hat kein print_multi — Fallback: per-Item-Loop
# mit print(). Degraded zu Phase-1i-Verhalten (22.5mm Pre-Roll pro Item).
for i, label in enumerate(labels):
is_last = i == len(labels) - 1
try:
printer.print(
label,
auto_cut=auto_cut,
high_resolution=high_resolution,
half_cut=half_cut and not is_last,
feed=is_last,
)
except TypeError:
printer.print(label, auto_cut=auto_cut, high_resolution=high_resolution)


class PTouchBackend:
"""PrinterBackend backed by the ptouch library."""

Expand Down Expand Up @@ -246,3 +301,52 @@ async def print_image(
ptouch.PrinterConnectionError,
) as exc:
raise PrinterOfflineError(str(exc)) from exc

async def print_images(
self,
images: list[Image.Image],
tape_spec: TapeSpec,
*,
auto_cut: bool = True,
high_resolution: bool = False,
half_cut: bool = True,
) -> None:
"""Batch-print via ptouch.LabelPrinter.print_multi — atomic.

Pre-Print: SNMP preflight 1x am Batch-Anfang. Bei Tape-Mismatch wird
TapeMismatchError sofort geworfen, kein print_multi-Call.

Phase 1k.2: ersetzt N separate ptouch.print() Calls durch 1 print_multi.
Resultat: 5mm Half-Cut zwischen Labels statt 22.5mm Pre-Roll.
"""
if not images:
raise ValueError("print_images requires at least one image")

preflight = await self.preflight_check()
if preflight.loaded_tape_mm != tape_spec.width_mm:
raise TapeMismatchError(
expected_mm=tape_spec.width_mm,
loaded_mm=preflight.loaded_tape_mm,
)

try:
await asyncio.to_thread(
_ptouch_print_multi,
self.host,
self._port,
images,
tape_spec.width_mm,
model_id=self._model_id,
auto_cut=auto_cut,
high_resolution=high_resolution,
half_cut=half_cut,
)
except (ptouch.PrinterWriteError, ptouch.PrinterPermissionError) as exc:
raise PrintFailedError(str(exc)) from exc
except (
ptouch.PrinterNetworkError,
ptouch.PrinterTimeoutError,
ptouch.PrinterNotFoundError,
ptouch.PrinterConnectionError,
) as exc:
raise PrinterOfflineError(str(exc)) from exc
Loading
Loading