Skip to content

feat: Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#102)#106

Merged
strausmann merged 21 commits into
mainfrom
spec/phase-1k.2-multi-label-batch
Jun 5, 2026
Merged

feat: Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#102)#106
strausmann merged 21 commits into
mainfrom
spec/phase-1k.2-multi-label-batch

Conversation

@strausmann

Copy link
Copy Markdown
Owner

REVIEW-ONLY PR — keine Code-Änderungen

Diese PR enthaelt nur Spec + Plan fuer Phase 1k.2. Ziel: AI-Reviews (Gemini, Copilot) bekommen BEVOR Implementation startet.

Per Memory feedback_ai_reviews_pflicht_vor_merge: AI-Reviews vor Merge lesen + adressieren. Hier nutzen wir das aktiv um Design-Probleme zu finden BEVOR sie zu Bugs werden.

Closes nichts. Wird nach Adressierung der Reviews und User-Approval gemerged oder geclosed (je nachdem ob die Spec/Plan-Approach passt).

Was hier drin ist

1. Spec (committed 96e53c9)

docs/superpowers/specs/2026-06-04-multi-label-batch-design.md (282 Zeilen)

Aus superpowers:brainstorming Session 2026-06-04. 4 Sections:

  • Section 1: Ziel + Scope (PT-Series via print_multi, QL out-of-scope)
  • Section 2: Architektur (Render-Phase + Print-Phase Flow-Diagramme)
  • Section 3: Komponenten + Datenmodell (BatchJob dataclass, Protocol-Erweiterung)
  • Section 4: Failure Modes + Test-Strategie

User-Entscheidungen die im Spec festgehalten sind:

  • API-Vertrag bleibt unveraendert (job_ids[] in Response)
  • Atomic failure semantics (Option 1) — bei Fehler alle Jobs gemeinsam failed
  • QL out-of-scope (kein print_multi-Equivalent in brother_ql)

2. Plan (committed 8917d0e)

docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md (2252 Zeilen, 12 Tasks)

Aus superpowers:writing-plans skill. TDD-strukturiert: failing test → impl → verify → commit pro Step. 12 Tasks von helper-Function bis Hardware-Smoke-Script.

Bonus-Bug-Fund waehrend Plan-Writing:
_PTPQueuePrinter.print_image Adapter (backend/app/printer_models/pt.py:256-264) droppt aktuell half_cut und last_page aus options dict statt sie ans Backend weiterzureichen. PR #100 last_page→feed Fix erreichte die ptouch-Lib nie. Wird in Task 6 mit-gefixt.

Was zur Pruefung steht

Bitte AI-Reviewer (Gemini + Copilot), prueft:

  1. Architektur-Konsistenz — passt das Design zur bestehenden Codebase? Habe ich vorhandene Patterns korrekt erkannt?
  2. Protocol-Default-Method Approachprint_images in PrinterBackend Protocol mit Helper-delegate ist tragfaehig oder lieber ABC?
  3. BatchJob vs Job Worker-Dispatch via isinstance — sauber genug oder lieber Union-Type Discriminator?
  4. Atomic failure semantics implementation — alle job_ids gemeinsam markieren ist semantisch korrekt?
  5. Tape-Mismatch Check Position — Preflight 1x am Anfang reicht? Oder pro item?
  6. Task-Decomposition — sind 12 Tasks bite-sized genug oder zu fein granular?
  7. Test-Strategie — TDD-Reihenfolge konsistent? Test-Coverage ausreichend?
  8. Plan-Code-Qualitaet — sind die in den Tasks gezeigten Code-Snippets korrekt + idiomatisch?

Background

Refs Phase 1k.2 #102 + #101

User-approved design aus superpowers:brainstorming Session 2026-06-04.
4 Sections: Scope, Architektur, Komponenten+Datenmodell, Fehlerbehandlung+Tests.

Atomic Failure Semantik (Option 1 — User-Entscheidung), API-Vertrag bleibt
unverändert, QL out-of-scope (kein print_multi-Equivalent in brother_ql Lib).

Referenziert Issues #101 (Umbrella), #102 (1k.2), #103 (1k.1 Auto-Scale Insight).

Refs #102
12 Tasks via TDD:
  1. default_print_images_loop helper
  2. PrinterBackend.print_images Protocol method
  3. PTouchBackend.print_images + _ptouch_print_multi
  4. BrotherQLBackend.print_images (default loop)
  5. MockBackend.print_images (default loop)
  6. _PTPQueuePrinter adapter + bug-fix half_cut/last_page forwarding (PR #100 regression)
  7. _QLQueuePrinter adapter
  8. PrintQueue.BatchJob dataclass + enqueue_batch + worker isinstance dispatch
  9. dispatch_batch refactor + MixedTapeSizesError
 10. API route: MixedTapeSizesError -> 400
 11. End-to-End integration test
 12. Hardware-smoke script (manual PT-P750W verification)

Bug-Fund waehrend Plan-Writing: _PTPQueuePrinter droppt half_cut + last_page
beim Forward zum Backend — PR #100 fix erreichte ptouch lib nie. Task 6 fixt
das mit.

Refs #102
Copilot AI review requested due to automatic review settings June 4, 2026 16:45
@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request implements the Phase 1k.2 design for multi-label batch printing. By leveraging the ptouch.print_multi capability, the system now achieves efficient batch printing with proper tactile separation between labels. The changes include a new BatchJob queue architecture, a robust validation layer for tape consistency, and critical bug fixes in the printer adapter layer to ensure correct option propagation.

Highlights

  • Multi-Label-Batch Implementation: Implements the Phase 1k.2 design to enable atomic batch printing for PT-Series printers using ptouch.print_multi, ensuring 5mm half-cuts between labels instead of 22.5mm pre-rolls.
  • Atomic Failure Semantics: Introduces a BatchJob queue type that ensures atomic success or failure for all job IDs in a batch, with shared error reporting on failure.
  • Bug Fix: Adapter Forwarding: Fixed a regression in the _PTPQueuePrinter adapter where half_cut and last_page options were dropped, ensuring they are now correctly forwarded to the backend.
  • Mixed Tape Validation: Added a pre-queue validation check to prevent batches with mixed tape sizes, returning a 400 error if detected.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@strausmann

Copy link
Copy Markdown
Owner Author

Title updated from spec+plan: to docs: per Conventional Commits convention. Re-running Commitlint check.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a design spec and a detailed implementation plan for Phase 1k.2, which aims to implement multi-label batch printing via ptouch.print_multi() to achieve 5mm half-cuts between labels on PT-series printers. The code review identified three critical architectural and performance gaps in the proposed plan: first, a lack of in-memory job registration in enqueue_batch that would cause KeyErrors during status queries; second, missing in-memory state transitions and SSE notifications in _process_batch that would result in hanging waiters and a non-responsive UI; and third, synchronous CPU-intensive rendering in submit_batch_job that blocks the async event loop. The reviewer provided actionable code suggestions to resolve these issues by properly managing in-memory states and offloading rendering to threads concurrently.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1439 to +1489
async def enqueue_batch(
self,
*,
printer_id: UUID,
images: list[Image.Image],
job_ids: list[UUID],
tape_mm: int,
options: dict[str, Any],
) -> UUID:
"""Phase 1k.2: Submit N images as ONE BatchJob (atomic print_multi call).

Args:
printer_id: Target printer (must be registered in self._queues).
images: PIL Images in print order, len(images) >= 1.
job_ids: Pre-allocated job UUIDs, one per image. Must be len(images) long.
tape_mm: Shared tape width (12/18/24/62).
options: Collective options (auto_cut, high_resolution, half_cut).

Returns:
batch_id: New UUID identifying this batch.

Raises:
KeyError: unknown printer_id.
ValueError: len(images) != len(job_ids), or len(images) == 0.
"""
if printer_id not in self._queues:
raise KeyError(f"Unknown printer: {printer_id}")
if not images:
raise ValueError("enqueue_batch requires at least one image")
if len(images) != len(job_ids):
raise ValueError(
f"images and job_ids length mismatch: {len(images)} vs {len(job_ids)}"
)

# Serialize images to PNG bytes (consistent with single-job storage)
payloads = [
await asyncio.to_thread(_serialize_image_to_png, img) for img in images
]
batch_id = uuid4()
batch = BatchJob(
batch_id=batch_id,
printer_id=printer_id,
image_payloads=payloads,
job_ids=list(job_ids),
tape_mm=tape_mm,
options=dict(options),
)
await self._queues[printer_id].put(batch)
logger.info("Batch %s queued on %s with %d items", batch_id, printer_id, len(images))
return batch_id
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Architektur-Lücke: Fehlende In-Memory Job-Registrierung

In der vorgeschlagenen Implementierung von enqueue_batch werden die einzelnen Jobs des Batches niemals als In-Memory Job-Objekte instanziiert oder in self._jobs registriert.

Dies führt zu folgenden kritischen Problemen:

  1. KeyError bei Abfragen: Jeder spätere Aufruf von get(job_id) oder wait_for_job(job_id) wird einen KeyError werfen, da die Jobs in self._jobs nicht existieren.
  2. Queue-Verwaltung: Methoden wie list_queue() und clear_queue() ignorieren diese Batch-Jobs vollständig.

Um die Konsistenz mit der bestehenden Single-Job-Architektur zu wahren, sollten wir für jede job_id im Batch ein Job-Objekt instanziieren und in self._jobs registrieren. Zudem können wir die PNG-Serialisierung der Bilder mittels asyncio.gather parallelisieren, um die Performance zu verbessern.

    async def enqueue_batch(
        self,
        *,
        printer_id: UUID,
        images: list[Image.Image],
        job_ids: list[UUID],
        tape_mm: int,
        options: dict[str, Any],
    ) -> UUID:
        """Phase 1k.2: Submit N images as ONE BatchJob (atomic print_multi call).

        Args:
            printer_id: Target printer (must be registered in self._queues).
            images: PIL Images in print order, len(images) >= 1.
            job_ids: Pre-allocated job UUIDs, one per image. Must be len(images) long.
            tape_mm: Shared tape width (12/18/24/62).
            options: Collective options (auto_cut, high_resolution, half_cut).

        Returns:
            batch_id: New UUID identifying this batch.

        Raises:
            KeyError: unknown printer_id.
            ValueError: len(images) != len(job_ids), or len(images) == 0.
        """
        if printer_id not in self._queues:
            raise KeyError(f"Unknown printer: {printer_id}")
        if not images:
            raise ValueError("enqueue_batch requires at least one image")
        if len(images) != len(job_ids):
            raise ValueError(
                f"images and job_ids length mismatch: {len(images)} vs {len(job_ids)}"
            )

        # Serialize images to PNG bytes concurrently
        payloads = await asyncio.gather(*(
            asyncio.to_thread(_serialize_image_to_png, img) for img in images
        ))
        batch_id = uuid4()
        batch = BatchJob(
            batch_id=batch_id,
            printer_id=printer_id,
            image_payloads=payloads,
            job_ids=list(job_ids),
            tape_mm=tape_mm,
            options=dict(options),
        )

        # Register individual jobs in-memory to support status queries and wait_for_job
        for jid, payload in zip(job_ids, payloads, strict=True):
            job = Job(
                id=str(jid),
                printer_id=printer_id,
                image_payload=payload,
                tape_mm=tape_mm,
                options=dict(options),
            )
            self._jobs[str(jid)] = job

        await self._queues[printer_id].put(batch)
        logger.info("Batch %s queued on %s with %d items", batch_id, printer_id, len(images))
        return batch_id

Comment on lines +1520 to +1566
async def _process_batch(
self,
printer: _PrinterLike,
printer_id: UUID,
batch: BatchJob,
) -> None:
"""Phase 1k.2: Handle BatchJob — atomic success/failure for all job_ids.

Decodes payloads, calls printer.print_images() once. On exception,
marks all job_ids as failed with a shared error_message.
"""
# Wait while paused (mirror _worker semantics)
while self._worker_states[printer_id] == PrinterWorkerState.PAUSED:
if self._stopping:
return
await self._worker_resume_events[printer_id].wait()

# Persist transitions: all → PRINTING
for jid in batch.job_ids:
await self._store.mark_printing(jid)

# Decode PNG payloads back to PIL Images
images = [
await asyncio.to_thread(Image.open, BytesIO(p))
for p in batch.image_payloads
]

try:
await printer.print_images(
images,
tape_mm=batch.tape_mm,
**batch.options,
)
for jid in batch.job_ids:
await self._store.mark_done(jid)
logger.info("Batch %s completed on %s", batch.batch_id, printer_id)
except asyncio.CancelledError:
raise
except Exception as exc:
error_msg = f"batch print failed: {exc}"
for jid in batch.job_ids:
await self._store.mark_failed(jid, error_msg)
logger.exception(
"Batch %s failed on %s — all %d items marked failed",
batch.batch_id, printer_id, len(batch.job_ids),
)
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Architektur-Lücke: Fehlende In-Memory Zustandsübergänge und SSE-Benachrichtigungen

In der vorgeschlagenen Implementierung von _process_batch werden zwar die Datenbank-Zustände über self._store aktualisiert, aber die In-Memory Job-Objekte werden niemals übergeleitet (z. B. via JobStateMachine.transition) und _notify_state_change wird nicht aufgerufen.

Dies führt zu folgenden Problemen:

  1. Fehlende SSE-Benachrichtigungen: Das Frontend (Hangar) verlässt sich auf _notify_state_change, um Echtzeit-Updates über den SSE-Event-Bus zu erhalten. Ohne diese Aufrufe wird die UI niemals anzeigen, dass Batch-Jobs gedruckt werden, abgeschlossen sind oder fehlgeschlagen sind.
  2. Hängende Waiter: Jeder Aufruf von wait_for_job wird unendlich lange hängen, da das _done_event des Jobs niemals gesetzt wird (was intern von JobStateMachine.transition bei terminalen Zuständen erledigt wird).

Wir sollten _process_batch so anpassen, dass die entsprechenden In-Memory-Zustandsübergänge durchgeführt und Benachrichtigungen für jeden Job im Batch ausgelöst werden. Zudem können wir das Laden der Bilder mittels asyncio.gather parallelisieren.

    async def _process_batch(
        self,
        printer: _PrinterLike,
        printer_id: UUID,
        batch: BatchJob,
    ) -> None:
        """Phase 1k.2: Handle BatchJob — atomic success/failure for all job_ids.

        Decodes payloads, calls printer.print_images() once. On exception,
        marks all job_ids as failed with a shared error_message.
        """
        # Wait while paused (mirror _worker semantics)
        while self._worker_states[printer_id] == PrinterWorkerState.PAUSED:
            if self._stopping:
                return
            await self._worker_resume_events[printer_id].wait()

        # Persist transitions: all → PRINTING
        for jid in batch.job_ids:
            await self._store.mark_printing(jid)
            job = self._jobs.get(str(jid))
            if job:
                _from = job.state
                JobStateMachine.transition(job, JobState.PRINTING)
                self._notify_state_change(
                    job,
                    _from,
                    JobState.PRINTING,
                    queue_depth=self._queue_depth(printer_id),
                )

        # Decode PNG payloads back to PIL Images concurrently
        images = await asyncio.gather(*(
            asyncio.to_thread(Image.open, BytesIO(p))
            for p in batch.image_payloads
        ))

        try:
            await printer.print_images(
                images,
                tape_mm=batch.tape_mm,
                **batch.options,
            )
            for jid in batch.job_ids:
                await self._store.mark_done(jid)
                job = self._jobs.get(str(jid))
                if job:
                    _from = job.state
                    JobStateMachine.transition(job, JobState.COMPLETED)
                    self._notify_state_change(
                        job,
                        _from,
                        JobState.COMPLETED,
                        queue_depth=self._queue_depth(printer_id),
                    )
            logger.info("Batch %s completed on %s", batch.batch_id, printer_id)
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            error_msg = f"batch print failed: {exc}"
            for jid in batch.job_ids:
                await self._store.mark_failed(jid, error_msg)
                job = self._jobs.get(str(jid))
                if job:
                    job.error_code = "print_failed"
                    job.error_message = error_msg
                    job.error_msg = error_msg
                    _from = job.state
                    try:
                        JobStateMachine.transition(job, JobState.FAILED)
                        self._notify_state_change(
                            job,
                            _from,
                            JobState.FAILED,
                            queue_depth=self._queue_depth(printer_id),
                        )
                    except InvalidStateTransitionError:
                        pass
            logger.exception(
                "Batch %s failed on %s — all %d items marked failed",
                batch.batch_id, printer_id, len(batch.job_ids),
            )

Comment on lines +1799 to +1862
async def submit_batch_job(
self,
requests: list[PrintRequest],
*,
half_cut: bool,
) -> list[UUID]:
"""Phase 1k.2: Render N items, submit ONE BatchJob to PrintQueue.

Atomic: alle job_ids werden gemeinsam als completed/failed markiert.
Preflight + tape-mismatch werden 1x am Anfang fuer alle items geprueft.
"""
if not requests:
raise ValueError("submit_batch_job requires at least one request")

# 1. Load templates (alle muessen existieren — TemplateNotFoundError vorher abgefangen)
templates = [self._loader.get(r.template_id) for r in requests]
tape_mm = templates[0].tape_mm # alle gleich (mixed-tape-check vorher)

# 2. Preflight + tape-mismatch (1x fuer alle)
preflight = await self._backend.preflight_check()
if preflight.loaded_tape_mm != tape_mm:
raise TapeMismatchError(
expected_mm=tape_mm,
loaded_mm=preflight.loaded_tape_mm,
)

# 3. Resolve LabelData + Render
images: list[Image.Image] = []
for request, template in zip(requests, templates, strict=True):
label_data = await self._resolve_label_data(request)
image = self._renderer.render(template, label_data)
images.append(image)

# 4. Pre-allocate job UUIDs + persist in JobStore (analog submit_print_job)
job_ids: list[UUID] = []
for i, (request, template) in enumerate(zip(requests, templates, strict=True)):
job_id = uuid4()
await self._store.save_queued(
job_id=job_id,
printer_id=self._printer_id,
template_key=request.template_id,
payload={
"tape_mm": tape_mm,
"options": request.options.model_dump(),
"label_data": (await self._resolve_label_data(request)).model_dump(),
},
)
job_ids.append(job_id)

# 5. Enqueue as BatchJob
await self._queue.enqueue_batch(
printer_id=self._printer_id,
images=images,
job_ids=job_ids,
tape_mm=tape_mm,
options={
"auto_cut": True, # Sammelt
"high_resolution": False,
"half_cut": half_cut,
},
)

return job_ids
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performance & Event-Loop-Responsiveness: Nebenläufiges und nicht-blockierendes Rendering

In der vorgeschlagenen Implementierung von submit_batch_job wird self._renderer.render (eine CPU-intensive Pillow-Operation) synchron innerhalb der async-Schleife aufgerufen:

        # 3. Resolve LabelData + Render
        images: list[Image.Image] = []
        for request, template in zip(requests, templates, strict=True):
            label_data = await self._resolve_label_data(request)
            image = self._renderer.render(template, label_data)
            images.append(image)

Dies hat zwei Nachteile:

  1. Blockieren des Event-Loops: Der synchrone Aufruf blockiert den Haupt-Thread des Event-Loops, was die Reaktionsfähigkeit der gesamten FastAPI-Anwendung beeinträchtigt.
  2. Sequenzielle Ausführung: Die Batch-Elemente werden nacheinander gerendert, was die Gesamtantwortzeit bei großen Batches unnötig verlängert.

Wir sollten das Rendering in asyncio.to_thread auslagern und mittels asyncio.gather parallelisieren.

    async def submit_batch_job(
        self,
        requests: list[PrintRequest],
        *,
        half_cut: bool,
    ) -> list[UUID]:
        """Phase 1k.2: Render N items, submit ONE BatchJob to PrintQueue.

        Atomic: alle job_ids werden gemeinsam als completed/failed markiert.
        Preflight + tape-mismatch werden 1x am Anfang fuer alle items geprueft.
        """
        if not requests:
            raise ValueError("submit_batch_job requires at least one request")

        # 1. Load templates (alle muessen existieren — TemplateNotFoundError vorher abgefangen)
        templates = [self._loader.get(r.template_id) for r in requests]
        tape_mm = templates[0].tape_mm  # alle gleich (mixed-tape-check vorher)

        # 2. Preflight + tape-mismatch (1x fuer alle)
        preflight = await self._backend.preflight_check()
        if preflight.loaded_tape_mm != tape_mm:
            raise TapeMismatchError(
                expected_mm=tape_mm,
                loaded_mm=preflight.loaded_tape_mm,
            )

        # 3. Resolve LabelData + Render concurrently in threads
        async def _resolve_and_render(req, temp):
            ld = await self._resolve_label_data(req)
            return await asyncio.to_thread(self._renderer.render, temp, ld)

        images = await asyncio.gather(*(
            _resolve_and_render(r, t)
            for r, t in zip(requests, templates, strict=True)
        ))

        # 4. Pre-allocate job UUIDs + persist in JobStore (analog submit_print_job)
        job_ids: list[UUID] = []
        for i, (request, template) in enumerate(zip(requests, templates, strict=True)):
            job_id = uuid4()
            await self._store.save_queued(
                job_id=job_id,
                printer_id=self._printer_id,
                template_key=request.template_id,
                payload={ 
                    "tape_mm": tape_mm,
                    "options": request.options.model_dump(),
                    "label_data": (await self._resolve_label_data(request)).model_dump(),
                },
            )
            job_ids.append(job_id)

        # 5. Enqueue as BatchJob
        await self._queue.enqueue_batch(
            printer_id=self._printer_id,
            images=images,
            job_ids=job_ids,
            tape_mm=tape_mm,
            options={
                "auto_cut": True,  # Sammelt
                "high_resolution": False,
                "half_cut": half_cut,
            },
        )

        return job_ids

@strausmann strausmann changed the title spec+plan: Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#102) — REVIEW ONLY docs: Phase 1k.2 Multi-Label-Batch spec + plan (#102) — REVIEW ONLY Jun 4, 2026
@codecov

codecov Bot commented Jun 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.18519% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.48%. Comparing base (3998575) to head (0b6250d).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
backend/app/services/print_queue.py 85.56% 10 Missing and 4 partials ⚠️
backend/app/printer_backends/ptouch_backend.py 50.00% 5 Missing and 1 partial ⚠️
backend/app/services/print_service.py 86.66% 2 Missing and 2 partials ⚠️
backend/app/api/routes/batch.py 25.00% 3 Missing ⚠️
backend/app/services/batch_dispatch.py 95.83% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #106      +/-   ##
==========================================
- Coverage   89.57%   89.48%   -0.10%     
==========================================
  Files          95       96       +1     
  Lines        4316     4478     +162     
  Branches      368      388      +20     
==========================================
+ Hits         3866     4007     +141     
- Misses        358      369      +11     
- Partials       92      102      +10     
Components Coverage Δ
Printer Backends (transport) 86.41% <76.92%> (-0.78%) ⬇️
Printer Models (drivers) 88.20% <100.00%> (+4.78%) ⬆️
Services 90.65% <87.41%> (-0.60%) ⬇️
REST API 84.83% <25.00%> (-0.20%) ⬇️
Pydantic Schemas 100.00% <ø> (ø)
Integration Plugins 100.00% <ø> (ø)
Files with missing lines Coverage Δ
backend/app/printer_backends/base.py 100.00% <100.00%> (ø)
backend/app/printer_backends/batch_helper.py 100.00% <100.00%> (ø)
backend/app/printer_backends/brother_ql_backend.py 87.23% <100.00%> (+0.87%) ⬆️
backend/app/printer_backends/mock_backend.py 84.74% <100.00%> (+0.81%) ⬆️
backend/app/printer_models/pt.py 96.29% <100.00%> (+0.29%) ⬆️
backend/app/printer_models/ql.py 78.43% <100.00%> (+18.85%) ⬆️
backend/app/services/batch_dispatch.py 90.69% <95.83%> (-1.81%) ⬇️
backend/app/api/routes/batch.py 54.34% <25.00%> (-2.48%) ⬇️
backend/app/services/print_service.py 95.23% <86.66%> (-4.77%) ⬇️
backend/app/printer_backends/ptouch_backend.py 89.33% <50.00%> (-7.50%) ⬇️
... and 1 more

... and 2 files with indirect coverage changes

Flag Coverage Δ
backend 89.48% <85.18%> (-0.10%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 3998575...0b6250d. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

3 medium-priority architektur-Findings aus Gemini-Code-Assist:

G1 — Task 8 enqueue_batch: In-Memory Job-Registrierung pro item ergaenzt.
  Ohne registrierte Job-Objekte in self._jobs wuerde get(job_id) und
  wait_for_job(job_id) KeyError werfen, list_queue/clear_queue ignorieren
  Batch-Items. Plus: PNG-Serialisierung parallelisiert via asyncio.gather.

G2 — Task 8 _process_batch: JobStateMachine.transition + _notify_state_change
  pro Job ergaenzt. Ohne transitions:
  - _done_event wird nie gesetzt → wait_for_job haengt unendlich
  - SSE-Frontend (Hangar) bekommt keine Events → UI zeigt Jobs als ewig 'queued'
  - started_at/finished_at Timestamps bleiben None

G3 — Task 9 submit_batch_job: asyncio.to_thread + asyncio.gather fuer
  Renders. Synchroner Pillow-Call in async-Loop blockierte Event-Loop +
  serialisierte unnoetig. _resolve_label_data Duplikat-Call entfernt.

Plus: Plan-Header bekommt 'Plan Revisions' Tabelle als Audit-Trail.

Refs #102, #106 Gemini-Review
@strausmann

Copy link
Copy Markdown
Owner Author

@gemini-code-assist Alle 3 medium-priority Findings adressiert in Commit ad3aec5:

G1 (enqueue_batch In-Memory Job-Registrierung):
Pro job_id wird jetzt ein Job Objekt instanziiert und in self._jobs registriert. PNG-Serialisierung wurde via asyncio.gather parallelisiert. Damit funktioniert get(job_id), wait_for_job(job_id), list_queue() und clear_queue() korrekt mit Batch-Jobs.

G2 (_process_batch State-Transitions + SSE-Events):
Pro Job im Batch:

  • QUEUED → PRINTING via JobStateMachine.transition + _notify_state_change
  • Auf Success: PRINTING → COMPLETED via Transition + Notify
  • Auf Failure: PRINTING → FAILED via Transition + Notify (mit error_code='batch_failed' + gemeinsamer error_message)

Damit: _done_event.set() läuft (wait_for_job hängt nicht mehr), SSE-EventBus bekommt Updates (Hangar UI zeigt korrekte Status), Timestamps started_at/finished_at werden gesetzt. Plus parallele Image-Decode via asyncio.gather.

G3 (submit_batch_job CPU-Block):
Rendering jetzt in asyncio.to_thread + parallel via asyncio.gather. _resolve_label_data Duplikat-Call (vorher 2x pro Item: 1x für Render, 1x für label_data im JobStore-Payload) entfernt.

Plan-Header hat jetzt Plan Revisions Tabelle als Audit-Trail.

Wartet noch auf Copilot-Review. Bei Bedarf weiter iterieren — der Branch ist nicht gemerged.

@gemini-code-assist

Copy link
Copy Markdown

Hallo strausmann, danke für das Update und die detaillierte Aufschlüsselung der Änderungen in Commit ad3aec5. Das sieht sehr solide aus und adressiert die kritischen Punkte der In-Memory-Registrierung, der State-Transitions und der CPU-Blockierung durch parallele Verarbeitung.

Da du bereits die Tests für die neuen Komponenten und die Integration implementiert hast, scheint der Plan gut auf Kurs zu sein. Ich habe keine weiteren Anmerkungen zum Design oder zur Implementierung. Viel Erfolg bei der weiteren Umsetzung und dem Hardware-Smoke-Test!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This review-only PR adds the Phase 1k.2 design spec and a detailed TDD-oriented implementation plan for introducing PT-Series multi-label batching via ptouch.print_multi(), while keeping the public batch API contract unchanged.

Changes:

  • Adds a design spec describing the target behavior (single connection per batch, ~5mm half-cut between items, full cut at end) and the proposed queue/backend architecture.
  • Adds a step-by-step implementation plan (tasks, test strategy, and code snippets) covering backend protocol changes, queue batching, and adapter updates.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.

File Description
docs/superpowers/specs/2026-06-04-multi-label-batch-design.md Design spec for Phase 1k.2 batching architecture, failure semantics, and test strategy.
docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md Detailed task plan with TDD steps, proposed APIs, and implementation snippets for Phase 1k.2.

Comment on lines +275 to +278
## Referenzen

- Phase 1i Smoke-Test Empirie: [docs/site/operations/protokolle/2026-06-04-phase1i-smoke-test-empirie.md](https://docs.strausmann.cloud/operations/protokolle/2026-06-04-phase1i-smoke-test-empirie/)
- Hub PR #100 (last_page → feed groundwork): [https://github.com/strausmann/Label-Printer-Hub/pull/100](https://github.com/strausmann/Label-Printer-Hub/pull/100)
Comment on lines +178 to +188
# NEU — Default-Impl in Base, Backend überschreibt für native batching
async def print_images(
self,
images: list[Image],
tape_spec: TapeSpec,
*,
auto_cut: bool = True,
high_resolution: bool = False,
half_cut: bool = True, # Standard: Half-Cut zwischen Batch-Items
) -> None:
"""Print N images as a batch. Default loops over print_image."""
Comment on lines +234 to +235
**Hangar-Sicht:** Wenn ein Item-Job-Status `failed` zeigt, weiss Hangar nicht ob NUR dieses Item gescheitert ist oder der ganze Batch. Lösung: Job-Record bekommt zusätzliches Feld `batch_failure_mode: atomic | individual` damit Hangar die UI entsprechend gestalten kann ("Batch failed — 4 items affected").

Comment on lines +283 to +291
- [ ] **Step 5: Lint + mypy + commit**

```bash
backend/.venv/bin/ruff check backend/app/printer_backends/batch_helper.py backend/tests/unit/printer_backends/test_batch_helper.py
backend/.venv/bin/ruff format --check backend/app/printer_backends/batch_helper.py backend/tests/unit/printer_backends/test_batch_helper.py
backend/.venv/bin/mypy backend/app/printer_backends/batch_helper.py 2>&1 | tail -3

git add backend/app/printer_backends/batch_helper.py backend/tests/unit/printer_backends/test_batch_helper.py
git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(printer_backends): default_print_images_loop helper (Phase 1k.2 Task 1)
Comment on lines +316 to +318
self,
images: list[Image.Image],
tape_spec: TapeSpec,
Comment on lines +1782 to +1790

_PER_ITEM_ERRORS: dict[type[Exception], str] = {
TemplateNotFoundError: "template_not_found",
LookupFailedError: "integration_lookup_failed",
TapeEmptyError: "tape_empty",
}

_BATCH_FATAL_ERRORS: tuple[type[Exception], ...] = (
PrinterCoverOpenError,
Comment on lines +1825 to +1847
valid_items.append((index, item, tape_mm))
except _BATCH_FATAL_ERRORS:
raise
except tuple(_PER_ITEM_ERRORS) as exc:
code = _PER_ITEM_ERRORS[type(exc)]
errors.append(
BatchError(index=index, error_code=code, error_message=str(exc))
)
except Exception as exc: # unknown sync failure
_log.exception("unexpected error validating batch item %d", index)
errors.append(
BatchError(index=index, error_code="internal_error", error_message=str(exc))
)

if not valid_items:
return [], errors

# 2. Mixed tape_mm check
tape_mm_set = {tm for _, _, tm in valid_items}
if len(tape_mm_set) > 1:
raise MixedTapeSizesError([tm for _, _, tm in valid_items])

# 3. Backend half_cut capability
Comment on lines +2086 to +2089

**Rationale:** Verifiziert full flow: HTTP POST → batch_dispatch → submit_batch_job → enqueue_batch → worker → printer.print_images → atomic completion.

- [ ] **Step 1: Write end-to-end test**
Comment on lines +1556 to +1566
- _done_event wird nie gesetzt → wait_for_job(job_id) haengt unendlich
- _notify_state_change wird nie gerufen → SSE-Frontend (Hangar) bekommt
keine Updates und zeigt Jobs als ewig 'queued' an
- started_at/finished_at Timestamps bleiben None (UI-Probleme)
"""
# Wait while paused (mirror _worker semantics)
while self._worker_states[printer_id] == PrinterWorkerState.PAUSED:
if self._stopping:
return
await self._worker_resume_events[printer_id].wait()

9 Copilot-Findings adressiert:

C1 Spec: PrinterBackend Protocol-default-method-Approach klargestellt — Python
   typing.Protocol vererbt keine Methoden-Bodies. Alle Backends MUESSEN
   print_images explicit haben; helper macht den Loop.

C2 Spec: batch_failure_mode UI-Anpassung in Hangar als Out-of-1k.2-Scope
   verschoben. 1k.2 bleibt Backward-Compat-only.

C3 Spec: Privacy-Konformitaet — personalisierte Doku-Links durch
   Repo-Hinweise + Kurzfassung ersetzt.

C4 Plan: Real name+email in 'git -c user.*' commit examples durch
   plain 'git commit' ersetzt (12 Stellen).

C5 Plan: print_images() docstring praeziser — 'atomic' nur fuer PT-Series
   via print_multi, default-loop kann partial-succeed.

C6 Plan: _process_batch nutzt _printer_error_to_record + pause_printer
   bei recoverable PrinterErrors. Vorher generic Exception-Handling. Damit
   Konsistenz mit existing _worker loop.

C7 Plan: _validate_item_get_tape_mm nutzt public PrintService.get_template_tape_mm
   statt service._loader (private). Neuer Step 4a fuer den public helper.

C8 Plan: label_data wird in submit_batch_job nur einmal pro Item resolved
   (via _prepare_one helper). Vorher wurde es 2x gerufen.

C9 Plan: Smoke-Script ohne hardcoded API-Key-Default 'lh_pat_demo'. Exit
   mit error wenn weder CLI-Arg noch Env-Var gesetzt.

Plus Privacy-Fix: Final-Verification grep umformuliert (literal-Strings
entfernt die den CI Privacy-Scan getriggert haben).

Refs #102, #106 Gemini+Copilot Reviews
@strausmann

Copy link
Copy Markdown
Owner Author

@copilot Alle 9 Findings adressiert in 4f56a2a plus Privacy-Scan-Fix:

# Finding Fix
C1 Spec: Protocol-default-method funktioniert nicht (Python Protocols vererben keine Method-Bodies) Spec klargestellt: alle Backends MÜSSEN print_images explizit haben, der freie Helper default_print_images_loop macht den Loop
C2 batch_failure_mode widerspricht "Hangar unverändert" Scope Als Out-of-1k.2-Scope verschoben (Followup oder Phase 1l)
C3 Personalisierte Doku-Links violieren Privacy-Policy Durch Repo-Hinweis + inhaltliche Kurzfassung ersetzt
C4 Real name+email in git -c user.* Commit-Beispielen (12 Stellen) Durch plain git commit ersetzt
C5 print_images() docstring sagt "atomic" — falsch für default-loop Präzisiert: atomic nur via ptouch.print_multi, default-loop best-effort
C6 _process_batch ignoriert existing _printer_error_to_record + pause_printer Logik Neuer except PrinterError Path mit _printer_error_to_record(exc) + pause_printer(printer_id, reason=code) für recoverable errors. Plus separater Fallback für non-PrinterError exceptions
C7 _validate_item_get_tape_mm greift auf service._loader (private) Neuer Step 4a fügt PrintService.get_template_tape_mm() public helper hinzu
C8 label_data 2x pro Item resolved _prepare_one helper resolved+rendert+dumped in einem Schritt, parallel via gather
C9 Smoke-Script hardcoded API-Key default lh_pat_demo Exit(2) wenn weder CLI-Arg noch env-var gesetzt — klare Fehlermeldung

Privacy-Scan-Fix: Final-Verification grep-Beispiel enthielt Literal-Strings die der CI-Privacy-Scan blockt — umformuliert zu Repo-Verweis.

@gemini-code-assist — G1+G2+G3 wurden in ad3aec5 adressiert. Bitte erneut prüfen falls sinnvoll.

Wartet auf CI-Re-Run. Bei No-Findings: ready for implementation start.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a design spec and implementation plan for Phase 1k.2, enabling Multi-Label-Batch printing via ptouch.print_multi() to produce 5mm half-cuts between labels on PT-series printers. The review feedback identifies three critical API and logic issues within the plan's code snippets: a missing media_type argument in the lookup_ql call, a state inconsistency vulnerability when handling cancelled jobs during batch transitions, and an incorrect signature usage for JobStore.save_queued.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.


QL-Series unterstuetzt kein half_cut — Backend forciert intern False.
"""
tape_spec = self._tape_registry.lookup_ql(tape_mm)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The lookup_ql method on TapeRegistry requires both tape_mm and media_type arguments (as seen in _QLQueuePrinter.print_image). Calling it with only tape_mm will raise a TypeError at runtime.

We should pop the media_type from options (defaulting to self._default_media_type) and pass it to lookup_ql.

Suggested change
tape_spec = self._tape_registry.lookup_ql(tape_mm)
media_type = options.pop("media_type", self._default_media_type)
tape_spec = self._tape_registry.lookup_ql(tape_mm, media_type)

Comment on lines +1604 to +1618
# Gemini-Review G2: in-memory transitions + SSE-Events + DB persist.
# QUEUED -> PRINTING fuer jeden Job.
for job in jobs:
try:
JobStateMachine.transition(job, JobState.PRINTING)
self._notify_state_change(
job, JobState.QUEUED, JobState.PRINTING,
queue_depth=self._queue_depth(printer_id),
)
except InvalidStateTransitionError:
logger.warning(
"Batch %s: job %s skipped — state already %s",
batch.batch_id, job.id, job.state,
)
await self._store.mark_printing(UUID(job.id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a job in the batch was cancelled mid-flight (its state is CANCELLED), JobStateMachine.transition(job, JobState.PRINTING) will raise an InvalidStateTransitionError. The current code catches this exception, logs a warning, but then still calls await self._store.mark_printing(UUID(job.id)) and later await self._store.mark_done(UUID(job.id)) or await self._store.mark_failed(UUID(job.id)). This causes a severe state inconsistency where the job is CANCELLED in-memory but marked as COMPLETED or FAILED in the database.

To fix this, we should only perform database updates and subsequent transitions for jobs that successfully transitioned to PRINTING. We can collect these in an active_jobs list and use it throughout the rest of the method.

Suggested change
# Gemini-Review G2: in-memory transitions + SSE-Events + DB persist.
# QUEUED -> PRINTING fuer jeden Job.
for job in jobs:
try:
JobStateMachine.transition(job, JobState.PRINTING)
self._notify_state_change(
job, JobState.QUEUED, JobState.PRINTING,
queue_depth=self._queue_depth(printer_id),
)
except InvalidStateTransitionError:
logger.warning(
"Batch %s: job %s skipped — state already %s",
batch.batch_id, job.id, job.state,
)
await self._store.mark_printing(UUID(job.id))
# Gemini-Review G2: in-memory transitions + SSE-Events + DB persist.
# QUEUED -> PRINTING fuer jeden Job.
active_jobs: list[Job] = []
for job in jobs:
try:
JobStateMachine.transition(job, JobState.PRINTING)
self._notify_state_change(
job, JobState.QUEUED, JobState.PRINTING,
queue_depth=self._queue_depth(printer_id),
)
await self._store.mark_printing(UUID(job.id))
active_jobs.append(job)
except InvalidStateTransitionError:
logger.warning(
"Batch %s: job %s skipped — state already %s",
batch.batch_id, job.id, job.state,
)

Comment on lines +2008 to +2017
await self._store.save_queued(
job_id=job_id,
printer_id=self._printer_id,
template_key=request.template_id,
payload={
"tape_mm": tape_mm,
"options": request.options.model_dump(),
"label_data": ld_dump,
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The JobStore.save_queued method expects a single Job model instance (as seen in submit_print_job), but the proposed code calls it with individual keyword arguments (job_id, printer_id, etc.). This will raise a TypeError at runtime.

We should instantiate the Job model first and pass it to save_queued.

Suggested change
await self._store.save_queued(
job_id=job_id,
printer_id=self._printer_id,
template_key=request.template_id,
payload={
"tape_mm": tape_mm,
"options": request.options.model_dump(),
"label_data": ld_dump,
},
)
db_job = Job(
id=job_id,
printer_id=self._printer_id,
template_key=request.template_id,
payload={
"tape_mm": tape_mm,
"options": request.options.model_dump(),
"label_data": ld_dump,
},
api_key_id=None,
source_ip=None,
)
await self._store.save_queued(db_job)

3 Runtime-Crashes im Plan-Code gefixt:

G-R2-1 (_QLQueuePrinter.print_images): lookup_ql benoetigt tape_mm UND
  media_type. Mein Plan rief lookup_ql(tape_mm) ohne media_type -> TypeError.
  Fix: pop media_type aus options + pass through (analog _PTPQueuePrinter).

G-R2-2 (_process_batch state inconsistency): Wenn job mid-flight cancelled
  ist, wirft JobStateMachine.transition InvalidStateTransitionError. Mein
  Plan loggte Warning aber rief trotzdem _store.mark_printing + mark_done
  -> DB sagt COMPLETED, in-memory CANCELLED.
  Fix: active_jobs[] sammelt nur erfolgreich transitioned jobs. Alle
  folgenden DB-Calls + post-print transitions nutzen active_jobs[].

G-R2-3 (submit_batch_job save_queued signature): Mein Plan rief save_queued
  mit kwargs (job_id=..., printer_id=..., template_key=..., payload=...)
  aber JobStore.save_queued erwartet eine DbJob-Instanz.
  Fix: DbJob model instanziieren + an save_queued uebergeben (konsistent
  mit submit_print_job). Import 'from app.models.job import Job as DbJob'
  ergaenzt.

Refs #102, #106 Gemini R2 Review
@strausmann

Copy link
Copy Markdown
Owner Author

@gemini-code-assist Alle 3 Round-2 Findings adressiert in 968b693 — alle waren echte Runtime-TypeErrors die im Plan-Code lauerten:

# Bug Fix
G-R2-1 _QLQueuePrinter.print_images rief lookup_ql(tape_mm) ohne media_type → TypeError pop media_type aus options + pass through (analog _PTPQueuePrinter.print_image)
G-R2-2 _process_batch state inconsistency: bei InvalidStateTransitionError (cancelled job) lief Code weiter und schrieb mark_done/mark_failed in DB → DB COMPLETED + in-memory CANCELLED active_jobs[] Liste nur mit erfolgreich transitioned Jobs. Alle DB-Calls + post-print transitions nutzen active_jobs, nicht jobs. Plus early-return wenn len(active_jobs)==0
G-R2-3 submit_batch_job rief save_queued(job_id=..., printer_id=..., ...) mit kwargs aber API erwartet DbJob-Instanz → TypeError DbJob instanziieren + an save_queued übergeben (konsistent mit submit_print_job). Import from app.models.job import Job as DbJob ergänzt

Vielen Dank für die R2 — alle 3 hätten erst im Subagent-Implementation-Step gecrasht.

Wartet auf weitere Reviews. Bei No-Findings: ready for implementation start.

@gemini-code-assist

Copy link
Copy Markdown

Hallo strausmann, danke für die detaillierte Aufschlüsselung der Round-2-Fixes. Die Korrekturen (insbesondere die Typ-Sicherheit bei lookup_ql und die Konsistenz bei DbJob-Instanziierung) sind essenziell, um Laufzeitfehler im Batch-Pfad zu vermeiden. Da alle bisherigen Findings adressiert wurden und die Testabdeckung solide aussieht, habe ich keine weiteren Anmerkungen. Viel Erfolg bei der Implementierung!

…Task 1)

Backends ohne native batch-support (BrotherQLBackend, MockBackend) loopen
per-item — Helper zentralisiert die half_cut + last_page Semantik analog
Brother iOS App.

Refs #102
…Phase 1k.2 Task 2)

Backend-Contract erweitert um batch-print Method. PTouchBackend ueberschreibt
fuer ptouch.print_multi (Task 3), andere Backends delegieren an
default_print_images_loop helper (Tasks 4, 5).

Refs #102
…regressions (Phase 1k.2 Task 2 fixup)

Code-quality review on commit e93f945 found 3 test failures: adding
print_images to the Protocol broke isinstance(obj, PrinterBackend) for
backends that did not yet implement it. Fixes:

- _Compliant test fixture (test_base.py): minimal pass stub
- MockBackend + PTouchBackend: print_images that raises NotImplementedError
  (real impl follows in Task 5 and Task 3 respectively)
- base.py: docstring notes half_cut=True default vs print_image's False
- base.py: module docstring 'two-method' -> 'three-method' surface

Refs #102
PTouchBackend.print_images() ueberschreibt Protocol-Default mit echtem
batch-printing via ptouch.LabelPrinter.print_multi: 1 Connection, 1 Pre-Roll,
5mm Half-Cut zwischen Labels statt 22.5mm zwischen jedem.

Replaces Task 2 stub (NotImplementedError) with real implementation.

TypeError-Fallback: Aelterer ptouch-Lib ohne print_multi degradet zu
per-Item-Loop (Pre-Phase-1k.2 Verhalten).

Refs #102
…k.2 Task 4)

QL-Series ist Endless-Tape ohne Half-Cut-Konzept. BrotherQLBackend.print_images
delegiert an default_print_images_loop mit half_cut=False (capability-flag erzwingt).
Phase 1k.2 Architektur-Konsistenz: alle Backends bieten print_images.

Refs #102
…hase 1k.2 Task 5)

Replaces Task 2 fix-up stub (NotImplementedError) mit echter Delegation
an default_print_images_loop helper. Test-friendly impl: print_image()
Recording-Verhalten bleibt erhalten.

Refs #102
…forwarding (Phase 1k.2 Task 6)

Bug discovered during Phase 1k.2 plan-writing: _PTPQueuePrinter.print_image
forwarded only auto_cut + high_resolution. half_cut und last_page wurden aus
options dict geholt aber NIE an backend.print_image() weitergeleitet. PR #100
fix landete nie bei ptouch lib.

Fix: explicit options.pop für half_cut + last_page. Plus neue print_images()
Adapter-Methode für Queue-BatchJob-Pfad.

Refs #102, regression-fixes PR #100 (silent drop in adapter layer)
QL erzwingt half_cut=False intern (capability-flag). Adapter-Konsistenz mit
PT fuer Queue-BatchJob-Pfad.

Gemini-Review G-R2-1: lookup_ql benoetigt tape_mm UND media_type (gleiche
Konvention wie lookup_pt).

Refs #102, #106
…hase 1k.2 Task 8)

Neuer BatchJob dataclass mit batch_id, printer_id, image_payloads (PNG bytes),
job_ids[], tape_mm, options. PrintQueue.enqueue_batch validiert + serialisiert
plus In-Memory Job-Registrierung (G1).
Worker dispatched BatchJob → _process_batch → printer.print_images. Atomic:
auf success markiert alle active_jobs als done, auf failure alle als failed mit
gemeinsamer Error-Message.

Adresses Gemini-Reviews:
- G1: In-Memory Job-Registrierung in enqueue_batch (sonst KeyError bei get/wait_for_job)
- G2: JobStateMachine.transition + _notify_state_change (sonst SSE-Stille + hängende Waiter)
- G-R2-2: active_jobs[] für state consistency (cancel-mid-flight)
- C6: _printer_error_to_record + pause_printer für recoverable errors

_PrinterLike Protocol erweitert um print_images.
mypy fix: list[ImageFile] → list[Image.Image] cast für invariante List.

Refs #102, PR #106 reviews
… tests (Phase 1k.2 Task 8 follow-up)

Code-quality review on c08da56 identified:

- MEDIUM: _process_batch decoded ALL payloads even if some jobs were
  cancelled mid-flight, causing phantom prints. Fix: track active_indices[]
  alongside active_jobs[] and filter payloads via index. Collapsed the
  previous two-stage lookup+transition into a single enumerate loop.

- LOW: 2 success/failure tests only asserted call counts — strengthened
  to verify JobState.COMPLETED / JobState.FAILED + error_message.

- LOW: Missing test for empty images list rejection — added.

- Optional: test_batchjob_cancelled_job_skipped_in_active verifies the
  phantom-print fix end-to-end (cancel mid-job, assert 2 images not 3).

Refs #102, PR #106
…h_job (Phase 1k.2 Task 9)

dispatch_batch sammelt valide items (per-item template_not_found etc. weiter
in errors[]), prueft mixed tape_mm vor Queue, ruft service.submit_batch_job
fuer den happy path. PrintService.submit_batch_job rendert N images parallel,
persistiert N JobRecords (DbJob instances), queued ONE BatchJob via enqueue_batch.

MixedTapeSizesError als neue Fatal-Error (Batch → 400 im Route-Layer).
TapeMismatchError jetzt ebenfalls fatal (nicht mehr per-item best-effort).
Route-Layer faengt TapeMismatchError → 409, MixedTapeSizesError → 400.

Adresses Reviews:
- C7: get_template_tape_mm public helper (statt service._loader private)
- C8: label_data ONCE per item (via _prepare_one)
- G3: asyncio.to_thread + gather fuer parallele Renders
- G-R2-3: save_queued mit Job instance (statt kwargs)

Integration-Tests angepasst: printer_offline + tape_mismatch patchen jetzt
submit_batch_job statt submit_print_job. test_batch_tape_mismatch_per_item
zeigt neues atomares Verhalten (409 statt per-item 202+errors).

Refs #102, PR #106 reviews
…ions (Phase 1k.2 Task 9 follow-up)

Code-quality review on a2b6a98 found submit_batch_job hardcoded
auto_cut=True + high_resolution=False, silently dropping caller-provided
values from request.options.

Fix: read from requests[0].options (all batch items share collective
options since ptouch.print_multi takes batch-wide settings).

Refs #102
…2 Task 11)

Verifies the full flow: HTTP POST → batch_dispatch → submit_batch_job →
enqueue_batch → worker → backend.print_images → atomic job state updates.

CRITICAL assertions:
- 4-item batch calls print_images() ONCE (not 4x print_image)
- All 4 job_ids reach state='done' on success
- On failure: all 4 job_ids reach state='failed' with shared 'batch print failed' msg

Fixtures: ml_batch_client (mirrors batch_client from test_batch_endpoint_happy.py).
Backend mock: patch.object(MockPrinterBackend, 'print_images', AsyncMock) via
inner_app.state.backend_router.get(slug) — no conftest change needed.

Refs #102
…are-smoke (Phase 1k.2 Task 12)

Verify 5mm Half-Cut between batch items vs Brother iOS App output.

Copilot-Review C9: no hardcoded API-Key default — exit(2) wenn weder CLI-Arg
noch Env-Var gesetzt ist.

Refs #102
@strausmann strausmann changed the title docs: Phase 1k.2 Multi-Label-Batch spec + plan (#102) — REVIEW ONLY feat: Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#102) Jun 4, 2026
…uch_print_multi (Phase 1k.2 Task 8 coverage)

codecov/patch on PR #106 reported 79.89% (target 80%) — 0.11pp gap.

Coverage fixes:
- _ptouch_print_multi: # pragma: no cover already present (no change needed)
- Test PrinterOfflineError → pause_printer + failed jobs (C6 path)
- Test generic Exception fallback path (batch_failed error_code)

Refs #102
@strausmann
strausmann merged commit a9726a9 into main Jun 5, 2026
19 checks passed
@strausmann
strausmann deleted the spec/phase-1k.2-multi-label-batch branch June 5, 2026 05:34
github-actions Bot pushed a commit that referenced this pull request Jun 5, 2026
## 0.9.0 (2026-06-05)

* feat: Phase 1i — Label-Quality + Multi-Printer + Samla + Preview (#98) ([a33dda9](a33dda9)), closes [#98](#98)
* feat: Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#106) ([a9726a9](a9726a9)), closes [#106](#106) [#101](#101) [#103](#103)
* feat(api): GET /api/templates/{key}/preview-svg + Dashboard-Modal (Phase 1i D) ([c2cbf3c](c2cbf3c))
* feat(api): GET /api/templates/{key}/preview.png (Diagnose-Tool, Phase 1i A) ([27dd325](27dd325)), closes [#5](#5)
* feat(batch): dispatch_batch refactor to atomic BatchJob + submit_batch_job (Phase 1k.2 Task 9) ([a2b6a98](a2b6a98)), closes [#102](#102) [#106](#106)
* feat(batch): printer_slug + half_cut_override + BackendRouter-Lookup (Phase 1i C) ([0561504](0561504)), closes [#6](#6)
* feat(brother_ql): print_images via default_print_images_loop (Phase 1k.2 Task 4) ([f56e44c](f56e44c)), closes [#102](#102)
* feat(config): Clean-Break Settings + upsert_runtime_printers (Phase 1i H CA-1 7a) ([64c38b3](64c38b3))
* feat(lifespan): Multi-Printer-Wiring + service_for pro Drucker (Phase 1i H CA-1 7b) ([3661a4e](3661a4e))
* feat(mock): MockBackend.print_images via default_print_images_loop (Phase 1k.2 Task 5) ([4ebdbee](4ebdbee)), closes [#102](#102)
* feat(printer_backends): BrotherQLBackend + QLSeriesModel (Phase 1i G) ([ad52009](ad52009))
* feat(printer_backends): default_print_images_loop helper (Phase 1k.2 Task 1) ([54e1123](54e1123)), closes [#102](#102)
* feat(printer_backends): half_cut Parameter + half_cut_supported Capability (Phase 1i C) ([d41c246](d41c246))
* feat(printer_backends): PrinterBackend.print_images Protocol method (Phase 1k.2 Task 2) ([e93f945](e93f945)), closes [#102](#102)
* feat(pt): _PTPQueuePrinter.print_images + bug-fix half_cut/last_page forwarding (Phase 1k.2 Task 6) ([15ef310](15ef310)), closes [#100](#100) [#100](#100)
* feat(ptouch): print_images via ptouch.print_multi (Phase 1k.2 Task 3) ([c0d15f0](c0d15f0)), closes [#102](#102)
* feat(ql): _QLQueuePrinter.print_images adapter (Phase 1k.2 Task 7) ([cb1e317](cb1e317)), closes [#102](#102) [#106](#106)
* feat(queue): BatchJob + enqueue_batch + worker isinstance dispatch (Phase 1k.2 Task 8) ([c08da56](c08da56)), closes [#102](#102) [#106](#106)
* feat(schemas): PrinterYAMLConfig + PrintersFile (Phase 1i H) ([7779a94](7779a94))
* feat(scripts): smoke_first_print_batch.py — manual 4-item batch hardware-smoke (Phase 1k.2 Task 12) ([d2ce28e](d2ce28e)), closes [#102](#102)
* feat(services): BackendRouter mit slug->Backend-Mapping (Phase 1i H CA-2) ([a735a34](a735a34))
* feat(services): PrinterConfigLoader + printers.yaml.example (Phase 1i H) ([f065575](f065575))
* feat(templates): DPI-Fix + Samla-Templates (Phase 1i B) ([af7bce8](af7bce8))
* test(batch): end-to-end multi-label batch integration test (Phase 1k.2 Task 11) ([bcc1d04](bcc1d04)), closes [#102](#102)
* test(queue): coverage for _process_batch error paths + pragma on _ptouch_print_multi (Phase 1k.2 Tas ([0b6250d](0b6250d)), closes [#106](#106) [#102](#102)
* style: ruff format + lint cleanup (Phase 1i) ([6cffe4c](6cffe4c))
* style: ruff format Task 11 integration test (Phase 1k.2 cleanup) ([ad47f7e](ad47f7e)), closes [#102](#102)
* fix: TTF font + last_page→feed (Phase 1i smoke-test bugs) (#100) ([3998575](3998575)), closes [#100](#100)
* fix(api): preview-png statt preview.png (OpenAPI kebab-Konvention) ([b420722](b420722))
* fix(ci): mypy types + privacy scan (Phase 1i) ([541502e](541502e))
* fix(ci): SnmpQueryError __all__ export + privacy test IP ([99c1946](99c1946)), closes [#98](#98) [#98](#98)
* fix(ci): UUID-Continuity-Runbook entfernen (privacy) ([b027cff](b027cff))
* fix(lifespan): col() for mypy-safe SQLModel where-clause in slug collision check ([6f9d840](6f9d840)), closes [#98](#98)
* fix(lifespan): session.flush() + slug-collision-detection in upsert_runtime_printers ([e7d81e6](e7d81e6)), closes [#98](#98)
* fix(print_service): forward auto_cut/high_resolution from request options (Phase 1k.2 Task 9 follow- ([51539b5](51539b5)), closes [#102](#102)
* fix(printer_backends): BrotherQLBackend.preflight_check (Phase 1i Task 8b) ([dba9b6c](dba9b6c))
* fix(printer_backends): print_images stubs to fix Protocol isinstance regressions (Phase 1k.2 Task 2  ([5cbf955](5cbf955)), closes [#102](#102)
* fix(ptouch): propagate last_page→feed to ptouch lib (short half-cut between batch items) ([05f780d](05f780d))
* fix(queue): align image payloads with active_jobs + strengthen Task 8 tests (Phase 1k.2 Task 8 follo ([c658e87](c658e87)), closes [#102](#102) [#106](#106)
* fix(renderer): install fonts-dejavu-core in Dockerfile (font_size honored) ([9b0cd84](9b0cd84))
* fix(reviews): adresse Gemini + Copilot PR #100 findings ([9507432](9507432)), closes [#100](#100) [#100](#100)
* fix(schemas): half_cut_override docstring matches actual force-to-false behavior ([a7b84b9](a7b84b9)), closes [#98](#98)
* docs: pydantic-settings honesty + DPI-decouple + openapi range + preview-png drift ([3d8857e](3d8857e)), closes [PR#98-Copilot](https://github.com/PR/issues/98-Copilot) [PR#98-Copilot](https://github.com/PR/issues/98-Copilot) [PR#98-Copilot](https://github.com/PR/issues/98-Copilot) [PR#98-Copilot](https://github.com/PR/issues/98-Copilot) [#98](#98)
* docs(plan): adresse Gemini Round-2 Review G-R2-1/2/3 (PR #106) ([968b693](968b693)), closes [#106](#106) [#102](#102) [#106](#106)
* docs(plan): adresse Gemini-Review G1+G2+G3 (PR #106) ([ad3aec5](ad3aec5)), closes [#106](#106) [#102](#102) [#106](#106)
* docs(plan): Phase 1k.2 Multi-Label-Batch implementation plan (#102) ([8917d0e](8917d0e)), closes [#102](#102) [#100](#100) [#102](#102)
* docs(research): PT-P750W Layout-Diagnose Befund (Phase 1i A) ([06e8fa9](06e8fa9))
* docs(runbook): UUID-Continuity-Check Phase 1i (MA-2-Fix) ([0ef3861](0ef3861))
* docs(spec): Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#102) ([96e53c9](96e53c9)), closes [#102](#102) [#101](#101) [#102](#102) [#103](#103) [#102](#102)
* docs(spec+plan): adresse Copilot-Review C1-C9 + Privacy-Fixes (PR #106) ([4f56a2a](4f56a2a)), closes [#106](#106) [#102](#102) [#106](#106)
* Merge pull request #99 from strausmann/fix/phase1i-ql-preflight-check ([8df1be3](8df1be3)), closes [#99](#99)

[skip ci]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants