From e6d1df2a1fac48032f346faa40be1b61a5b31360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 07:35:07 +0000 Subject: [PATCH 1/3] feat(printer-models): Brother PT-Series TapeRegistry with TZe and heat-shrink specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pure-data lookup module (tape_specs_pt.py) with manufacturer-published print-area tables for TZe laminated/non-laminated tapes (4–24 mm) and heat-shrink 2:1 tapes (6–24 mm), and a static TapeRegistry.lookup_pt() that maps (width_mm, MediaType) → TapeSpec or raises UnknownTapeError. HS 3:1 and QL-Series die-cut labels are explicitly out of scope pending Phase 2 hardware tests. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/tape_registry.py | 43 ++++++ backend/app/services/tape_specs_pt.py | 146 ++++++++++++++++++ .../tests/unit/services/test_tape_registry.py | 30 ++++ 3 files changed, 219 insertions(+) create mode 100644 backend/app/services/tape_registry.py create mode 100644 backend/app/services/tape_specs_pt.py create mode 100644 backend/tests/unit/services/test_tape_registry.py diff --git a/backend/app/services/tape_registry.py b/backend/app/services/tape_registry.py new file mode 100644 index 0000000..d32227b --- /dev/null +++ b/backend/app/services/tape_registry.py @@ -0,0 +1,43 @@ +"""Lookup of Brother tape specifications by physical width + media type. + +QL-Series die-cut labels will be added once Phase 2 hardware tests confirm the +status-block byte sequence — see `docs/superpowers/plans/2026-05-11-label-printer-hub.md`. +""" + +from __future__ import annotations + +from app.services.status_block import MediaType +from app.services.tape_specs_pt import ( + PT_HS_2_1_TAPES, + PT_TZE_TAPES, + TapeSpec, +) + + +class UnknownTapeError(Exception): + """Raised when a (width_mm, media_type) combination has no registered spec.""" + + +class TapeRegistry: + @staticmethod + def lookup_pt(width_mm: int, media_type: MediaType) -> TapeSpec: + """Return the PT-Series tape spec for the given width + media type. + + Non-laminated TZe-N tapes share TZe laminated dimensions, so both + MediaType.LAMINATED and MediaType.NON_LAMINATED resolve to the same + PT_TZE_TAPES table. + """ + if media_type in (MediaType.LAMINATED, MediaType.NON_LAMINATED): + table = PT_TZE_TAPES + elif media_type == MediaType.HEAT_SHRINK_2_1: + table = PT_HS_2_1_TAPES + else: + raise UnknownTapeError(f"No PT-Series tape table for media_type={media_type.name}") + + for spec in table: + if spec.width_mm == width_mm: + return spec + + raise UnknownTapeError( + f"No PT-Series tape spec for width={width_mm}mm, media={media_type.name}" + ) diff --git a/backend/app/services/tape_specs_pt.py b/backend/app/services/tape_specs_pt.py new file mode 100644 index 0000000..820a1db --- /dev/null +++ b/backend/app/services/tape_specs_pt.py @@ -0,0 +1,146 @@ +"""Tape specifications for Brother PT-Series printers (180 DPI, 128-pin head). + +Source: Brother Raster Command Reference (PT-E550W / PT-P710BT / PT-P750W) v1.02. +The numbers here come straight from the manufacturer's printable-area tables. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from app.services.status_block import MediaType + + +@dataclass(frozen=True) +class TapeSpec: + width_mm: int + media_type: MediaType + print_area_pins: int + print_area_dots: int + bytes_per_raster: int + min_length_mm: float + max_length_mm: int + cutter_min_length_mm: float + + +# TZe laminated tapes. The non-laminated TZe-N* variants share the same +# print-area geometry — only the material differs — so we reuse this list +# for MediaType.NON_LAMINATED in the registry. +PT_TZE_TAPES: list[TapeSpec] = [ + TapeSpec( + width_mm=4, + media_type=MediaType.LAMINATED, + print_area_pins=24, + print_area_dots=24, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=1000, + cutter_min_length_mm=24.5, + ), + TapeSpec( + width_mm=6, + media_type=MediaType.LAMINATED, + print_area_pins=32, + print_area_dots=32, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=1000, + cutter_min_length_mm=24.5, + ), + TapeSpec( + width_mm=9, + media_type=MediaType.LAMINATED, + print_area_pins=50, + print_area_dots=50, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=1000, + cutter_min_length_mm=24.5, + ), + TapeSpec( + width_mm=12, + media_type=MediaType.LAMINATED, + print_area_pins=70, + print_area_dots=70, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=1000, + cutter_min_length_mm=24.5, + ), + TapeSpec( + width_mm=18, + media_type=MediaType.LAMINATED, + print_area_pins=112, + print_area_dots=112, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=1000, + cutter_min_length_mm=24.5, + ), + 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, + ), +] + +# Heat-shrink tubing 2:1 (status-block media-type byte = 0x11). +# Widths are nominal — actual tape is slightly narrower (e.g. 12mm HS = ~11.7mm). +# Pin counts are the Brother-published values. +PT_HS_2_1_TAPES: list[TapeSpec] = [ + TapeSpec( + width_mm=6, + media_type=MediaType.HEAT_SHRINK_2_1, + print_area_pins=28, + print_area_dots=28, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=500, + cutter_min_length_mm=24.5, + ), + TapeSpec( + width_mm=9, + media_type=MediaType.HEAT_SHRINK_2_1, + print_area_pins=48, + print_area_dots=48, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=500, + cutter_min_length_mm=24.5, + ), + TapeSpec( + width_mm=12, + media_type=MediaType.HEAT_SHRINK_2_1, + print_area_pins=66, + print_area_dots=66, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=500, + cutter_min_length_mm=24.5, + ), + TapeSpec( + width_mm=18, + media_type=MediaType.HEAT_SHRINK_2_1, + print_area_pins=106, + print_area_dots=106, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=500, + cutter_min_length_mm=24.5, + ), + TapeSpec( + width_mm=24, + media_type=MediaType.HEAT_SHRINK_2_1, + print_area_pins=128, + print_area_dots=128, + bytes_per_raster=16, + min_length_mm=4.4, + max_length_mm=500, + cutter_min_length_mm=24.5, + ), +] diff --git a/backend/tests/unit/services/test_tape_registry.py b/backend/tests/unit/services/test_tape_registry.py new file mode 100644 index 0000000..c66f5fa --- /dev/null +++ b/backend/tests/unit/services/test_tape_registry.py @@ -0,0 +1,30 @@ +import pytest +from app.services.status_block import MediaType +from app.services.tape_registry import TapeRegistry, UnknownTapeError + + +def test_lookup_pt_series_12mm_laminated() -> None: + spec = TapeRegistry.lookup_pt(width_mm=12, media_type=MediaType.LAMINATED) + assert spec.width_mm == 12 + assert spec.print_area_pins == 70 + assert spec.print_area_dots == 70 + assert spec.bytes_per_raster == 16 + assert spec.min_length_mm == pytest.approx(4.4) + assert spec.max_length_mm == 1000 + assert spec.cutter_min_length_mm == pytest.approx(24.5) + + +def test_lookup_pt_series_24mm() -> None: + spec = TapeRegistry.lookup_pt(width_mm=24, media_type=MediaType.LAMINATED) + assert spec.print_area_pins == 128 + + +def test_lookup_pt_unknown_width_raises() -> None: + with pytest.raises(UnknownTapeError): + TapeRegistry.lookup_pt(width_mm=15, media_type=MediaType.LAMINATED) + + +def test_lookup_pt_heat_shrink_2_1() -> None: + spec = TapeRegistry.lookup_pt(width_mm=12, media_type=MediaType.HEAT_SHRINK_2_1) + # 12mm HS 2:1 (~11.7mm tape) — 66 print pins per Brother spec + assert spec.print_area_pins == 66 From 8cfc0e15cb72f6f4773d5416a51067591464bc22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 07:39:44 +0000 Subject: [PATCH 2/3] refactor(printer-models): immutable tape tables (tuple + slots) and NON_LAMINATED test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change PT_TZE_TAPES and PT_HS_2_1_TAPES from list[TapeSpec] to tuple[TapeSpec, ...] — module-level manufacturer constants must not be mutable at runtime; tuple makes that intent explicit and enforced. - Add slots=True to @dataclass(frozen=True) on TapeSpec to match the project convention established in status_block.py (line 167). - Document the unregistered catch-all branch in tape_registry.py so a future contributor adding HEAT_SHRINK_3_1 or QL-Series support knows exactly where to wire it in. - Add test_lookup_pt_series_non_laminated_uses_tze_table to cover the NON_LAMINATED dispatch path and pin the intentional aliasing behaviour: a NON_LAMINATED query returns a LAMINATED-typed spec because both share the same TZe geometry table. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/tape_registry.py | 2 ++ backend/app/services/tape_specs_pt.py | 10 +++++----- backend/tests/unit/services/test_tape_registry.py | 7 +++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/backend/app/services/tape_registry.py b/backend/app/services/tape_registry.py index d32227b..d0498a8 100644 --- a/backend/app/services/tape_registry.py +++ b/backend/app/services/tape_registry.py @@ -32,6 +32,8 @@ def lookup_pt(width_mm: int, media_type: MediaType) -> TapeSpec: elif media_type == MediaType.HEAT_SHRINK_2_1: table = PT_HS_2_1_TAPES else: + # MediaType.HEAT_SHRINK_3_1 and QL-Series types are intentionally + # unregistered — add a new table and branch here when supported. raise UnknownTapeError(f"No PT-Series tape table for media_type={media_type.name}") for spec in table: diff --git a/backend/app/services/tape_specs_pt.py b/backend/app/services/tape_specs_pt.py index 820a1db..e38bfaa 100644 --- a/backend/app/services/tape_specs_pt.py +++ b/backend/app/services/tape_specs_pt.py @@ -11,7 +11,7 @@ from app.services.status_block import MediaType -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class TapeSpec: width_mm: int media_type: MediaType @@ -26,7 +26,7 @@ class TapeSpec: # TZe laminated tapes. The non-laminated TZe-N* variants share the same # print-area geometry — only the material differs — so we reuse this list # for MediaType.NON_LAMINATED in the registry. -PT_TZE_TAPES: list[TapeSpec] = [ +PT_TZE_TAPES: tuple[TapeSpec, ...] = ( TapeSpec( width_mm=4, media_type=MediaType.LAMINATED, @@ -87,12 +87,12 @@ class TapeSpec: max_length_mm=1000, cutter_min_length_mm=24.5, ), -] +) # Heat-shrink tubing 2:1 (status-block media-type byte = 0x11). # Widths are nominal — actual tape is slightly narrower (e.g. 12mm HS = ~11.7mm). # Pin counts are the Brother-published values. -PT_HS_2_1_TAPES: list[TapeSpec] = [ +PT_HS_2_1_TAPES: tuple[TapeSpec, ...] = ( TapeSpec( width_mm=6, media_type=MediaType.HEAT_SHRINK_2_1, @@ -143,4 +143,4 @@ class TapeSpec: max_length_mm=500, cutter_min_length_mm=24.5, ), -] +) diff --git a/backend/tests/unit/services/test_tape_registry.py b/backend/tests/unit/services/test_tape_registry.py index c66f5fa..5247cac 100644 --- a/backend/tests/unit/services/test_tape_registry.py +++ b/backend/tests/unit/services/test_tape_registry.py @@ -28,3 +28,10 @@ def test_lookup_pt_heat_shrink_2_1() -> None: spec = TapeRegistry.lookup_pt(width_mm=12, media_type=MediaType.HEAT_SHRINK_2_1) # 12mm HS 2:1 (~11.7mm tape) — 66 print pins per Brother spec assert spec.print_area_pins == 66 + + +def test_lookup_pt_series_non_laminated_uses_tze_table() -> None: + """MediaType.NON_LAMINATED resolves via the TZe table (same geometry).""" + spec = TapeRegistry.lookup_pt(width_mm=12, media_type=MediaType.NON_LAMINATED) + assert spec.print_area_pins == 70 # same as 12mm laminated TZe + assert spec.media_type == MediaType.LAMINATED # returned spec is the laminated record From 7b1611d30d6d670a67ff0b0f4e69e315c48d92b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 07:48:32 +0000 Subject: [PATCH 3/3] refactor(printer-models): relocate PT tape data to printer_models/, fix media_type identity, drop private doc link Co-Authored-By: Claude Sonnet 4.6 --- backend/app/models/tape.py | 22 +++++++++++++++++++ .../tape_specs_pt.py => printer_models/pt.py} | 18 ++------------- backend/app/services/tape_registry.py | 18 ++++++++------- .../tests/unit/services/test_tape_registry.py | 6 ++--- 4 files changed, 37 insertions(+), 27 deletions(-) create mode 100644 backend/app/models/tape.py rename backend/app/{services/tape_specs_pt.py => printer_models/pt.py} (90%) diff --git a/backend/app/models/tape.py b/backend/app/models/tape.py new file mode 100644 index 0000000..29a5370 --- /dev/null +++ b/backend/app/models/tape.py @@ -0,0 +1,22 @@ +"""Series-neutral tape specification record. + +Used by all printer model modules and the tape registry dispatch layer. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from app.services.status_block import MediaType + + +@dataclass(frozen=True, slots=True) +class TapeSpec: + width_mm: int + media_type: MediaType + print_area_pins: int + print_area_dots: int + bytes_per_raster: int + min_length_mm: float + max_length_mm: int + cutter_min_length_mm: float diff --git a/backend/app/services/tape_specs_pt.py b/backend/app/printer_models/pt.py similarity index 90% rename from backend/app/services/tape_specs_pt.py rename to backend/app/printer_models/pt.py index e38bfaa..ebf8806 100644 --- a/backend/app/services/tape_specs_pt.py +++ b/backend/app/printer_models/pt.py @@ -1,4 +1,4 @@ -"""Tape specifications for Brother PT-Series printers (180 DPI, 128-pin head). +"""Tape data for Brother PT-Series printers (180 DPI, 128-pin head). Source: Brother Raster Command Reference (PT-E550W / PT-P710BT / PT-P750W) v1.02. The numbers here come straight from the manufacturer's printable-area tables. @@ -6,23 +6,9 @@ from __future__ import annotations -from dataclasses import dataclass - +from app.models.tape import TapeSpec from app.services.status_block import MediaType - -@dataclass(frozen=True, slots=True) -class TapeSpec: - width_mm: int - media_type: MediaType - print_area_pins: int - print_area_dots: int - bytes_per_raster: int - min_length_mm: float - max_length_mm: int - cutter_min_length_mm: float - - # TZe laminated tapes. The non-laminated TZe-N* variants share the same # print-area geometry — only the material differs — so we reuse this list # for MediaType.NON_LAMINATED in the registry. diff --git a/backend/app/services/tape_registry.py b/backend/app/services/tape_registry.py index d0498a8..5ecdbaa 100644 --- a/backend/app/services/tape_registry.py +++ b/backend/app/services/tape_registry.py @@ -1,17 +1,16 @@ """Lookup of Brother tape specifications by physical width + media type. -QL-Series die-cut labels will be added once Phase 2 hardware tests confirm the -status-block byte sequence — see `docs/superpowers/plans/2026-05-11-label-printer-hub.md`. +QL-Series die-cut labels will be added once their byte-sequence reproducible +fixtures are in place — see docs/decisions/0004-plugin-architecture-for-printer-models.md. """ from __future__ import annotations +import dataclasses + +from app.models.tape import TapeSpec +from app.printer_models.pt import PT_HS_2_1_TAPES, PT_TZE_TAPES from app.services.status_block import MediaType -from app.services.tape_specs_pt import ( - PT_HS_2_1_TAPES, - PT_TZE_TAPES, - TapeSpec, -) class UnknownTapeError(Exception): @@ -25,7 +24,8 @@ def lookup_pt(width_mm: int, media_type: MediaType) -> TapeSpec: Non-laminated TZe-N tapes share TZe laminated dimensions, so both MediaType.LAMINATED and MediaType.NON_LAMINATED resolve to the same - PT_TZE_TAPES table. + PT_TZE_TAPES table. The returned spec always carries the queried + media_type so callers see the media_type they asked for. """ if media_type in (MediaType.LAMINATED, MediaType.NON_LAMINATED): table = PT_TZE_TAPES @@ -38,6 +38,8 @@ def lookup_pt(width_mm: int, media_type: MediaType) -> TapeSpec: for spec in table: if spec.width_mm == width_mm: + if spec.media_type != media_type: + return dataclasses.replace(spec, media_type=media_type) return spec raise UnknownTapeError( diff --git a/backend/tests/unit/services/test_tape_registry.py b/backend/tests/unit/services/test_tape_registry.py index 5247cac..1cd55cd 100644 --- a/backend/tests/unit/services/test_tape_registry.py +++ b/backend/tests/unit/services/test_tape_registry.py @@ -31,7 +31,7 @@ def test_lookup_pt_heat_shrink_2_1() -> None: def test_lookup_pt_series_non_laminated_uses_tze_table() -> None: - """MediaType.NON_LAMINATED resolves via the TZe table (same geometry).""" + """NON_LAMINATED resolves via the TZe table; returned spec carries the queried media_type.""" spec = TapeRegistry.lookup_pt(width_mm=12, media_type=MediaType.NON_LAMINATED) - assert spec.print_area_pins == 70 # same as 12mm laminated TZe - assert spec.media_type == MediaType.LAMINATED # returned spec is the laminated record + assert spec.print_area_pins == 70 # same geometry as 12mm laminated TZe + assert spec.media_type == MediaType.NON_LAMINATED # spec reflects what was requested