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/printer_models/pt.py b/backend/app/printer_models/pt.py new file mode 100644 index 0000000..ebf8806 --- /dev/null +++ b/backend/app/printer_models/pt.py @@ -0,0 +1,132 @@ +"""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. +""" + +from __future__ import annotations + +from app.models.tape import TapeSpec +from app.services.status_block import MediaType + +# 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: tuple[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: tuple[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/app/services/tape_registry.py b/backend/app/services/tape_registry.py new file mode 100644 index 0000000..5ecdbaa --- /dev/null +++ b/backend/app/services/tape_registry.py @@ -0,0 +1,47 @@ +"""Lookup of Brother tape specifications by physical width + media type. + +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 + + +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. 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 + 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: + if spec.width_mm == width_mm: + if spec.media_type != media_type: + return dataclasses.replace(spec, media_type=media_type) + return spec + + raise UnknownTapeError( + f"No PT-Series tape spec for width={width_mm}mm, media={media_type.name}" + ) 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..1cd55cd --- /dev/null +++ b/backend/tests/unit/services/test_tape_registry.py @@ -0,0 +1,37 @@ +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 + + +def test_lookup_pt_series_non_laminated_uses_tze_table() -> None: + """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 geometry as 12mm laminated TZe + assert spec.media_type == MediaType.NON_LAMINATED # spec reflects what was requested