From 3c0a9b404d8af1f4ca41f5a7f5f5a5562728bf25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Sun, 10 May 2026 18:07:32 +0000 Subject: [PATCH 1/2] feat(status): add Brother 32-byte status block parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the StatusBlockParser per ADR 0006 — decodes the binary status block that Brother PT-Series and QL-Series printers return from ESC i S and emit during print jobs. Key design points: - One parser handles both PT and QL families (the layout is shared with family-specific quirks in media-type codes, reserved bytes, and tape/text-colour bytes 24-25 which only PT populates) - Frozen dataclass output (StatusBlock) — consumers never mutate - Unknown enum values map to UNKNOWN rather than raising, so unfamiliar hardware doesn't crash the parser - PrinterError IntFlag covers all error bits documented in both Brother spec versions (PT v1.02 and QL v1.01) - Phase number decoded as big-endian (Brother convention: bytes 20+21) - Convenience properties: is_ready, is_printing Backend project structure: - pyproject.toml with FastAPI/Pydantic/SQLModel/pytest/ruff/mypy stack - pytest.ini config with strict markers, coverage 80% floor - conftest.py with --hardware skip-by-default flag - 24 unit tests cover: size validation, PT decoding, QL decoding, error flag combinations, phase transitions, unknown value fallback, raw bytes preservation, frozen dataclass References: - Brother Raster Command Reference v1.02 (PT-E550W/P750W/P710BT) - Brother Raster Command Reference v1.01 (QL-800/810W/820NWB) - ADR 0004 — Plugin architecture (consumers of this parser) - ADR 0006 — Status sources by phase Refs #11, #19 --- .gitignore | 1 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/models/__init__.py | 0 backend/app/printer_models/__init__.py | 0 backend/app/services/__init__.py | 0 backend/app/services/status_block.py | 293 ++++++++++++++++++ backend/pyproject.toml | 115 +++++++ backend/tests/__init__.py | 0 backend/tests/conftest.py | 26 ++ backend/tests/unit/__init__.py | 0 backend/tests/unit/printer_models/__init__.py | 0 backend/tests/unit/services/__init__.py | 0 .../tests/unit/services/test_status_block.py | 276 +++++++++++++++++ 14 files changed, 711 insertions(+) create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/printer_models/__init__.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/status_block.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/printer_models/__init__.py create mode 100644 backend/tests/unit/services/__init__.py create mode 100644 backend/tests/unit/services/test_status_block.py diff --git a/.gitignore b/.gitignore index ad40fdb..345550e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ data/ *.key *.crt secrets/ +backend/.venv/ diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/printer_models/__init__.py b/backend/app/printer_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/status_block.py b/backend/app/services/status_block.py new file mode 100644 index 0000000..89c3c87 --- /dev/null +++ b/backend/app/services/status_block.py @@ -0,0 +1,293 @@ +"""Brother PT/QL Series 32-byte status block parser. + +This module decodes the binary status block that Brother label printers return +in response to ``ESC i S`` (TCP/9100) and that they emit unprompted during +print jobs (phase changes, completion, errors). + +The block layout is shared between PT-Series and QL-Series with a few +family-specific quirks. PT-Series populates tape and text colour bytes +(24, 25); QL-Series leaves those reserved. PT-Series uses media-type codes +0x01/0x03/0x11/0x17; QL-Series uses 0x4A/0x4B. Both families parse cleanly +through this single parser; downstream consumers (printer-model plugins) can +ignore fields that don't apply to their hardware. + +References: + - Brother PT-E550W/P750W/P710BT Raster Command Reference v1.02, section 4 + - Brother QL-800/810W/820NWB Raster Command Reference v1.01, section 4 + +See also: + docs/decisions/0006-status-sources-by-phase.md — when this is consulted + docs/decisions/0004-plugin-architecture-for-printer-models.md — who uses it +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import IntEnum, IntFlag + +STATUS_BLOCK_SIZE: int = 32 + + +class StatusBlockError(Exception): + """Raised when a status block cannot be parsed.""" + + +class MediaType(IntEnum): + """Media type at status-block offset 11. + + PT-Series and QL-Series use disjoint code sets here. Unknown values map to + :attr:`UNKNOWN` so unfamiliar hardware doesn't crash the parser. + """ + + NONE = 0x00 + LAMINATED = 0x01 # PT-Series TZe + NON_LAMINATED = 0x03 # PT-Series TZe + HEAT_SHRINK_2_1 = 0x11 # PT-Series HS 2:1 + HEAT_SHRINK_3_1 = 0x17 # PT-Series HS 3:1 + CONTINUOUS_LENGTH_TAPE = 0x4A # QL-Series + DIE_CUT_LABEL = 0x4B # QL-Series + INCOMPATIBLE = 0xFF + UNKNOWN = -1 # placeholder when value isn't in the spec + + +class StatusType(IntEnum): + """Status type at offset 18 — what kind of message the printer is sending.""" + + REPLY = 0x00 # response to a status-information-request + PRINTING_COMPLETED = 0x01 # printer finished a page + ERROR = 0x02 # error occurred (consult error flags) + TURNED_OFF = 0x04 + NOTIFICATION = 0x05 + PHASE_CHANGE = 0x06 # transitioning between phases (printing/receiving) + UNKNOWN = -1 + + +class PhaseType(IntEnum): + """Phase type at offset 19.""" + + EDITING = 0x00 # also "receiving" on QL-Series + PRINTING = 0x01 + UNKNOWN = -1 + + +class NotificationCode(IntEnum): + """Notification at offset 22.""" + + NOT_AVAILABLE = 0x00 + COVER_OPEN = 0x01 # PT-Series + COVER_CLOSED = 0x02 # PT-Series + COOLING_STARTED = 0x03 # QL-Series + COOLING_FINISHED = 0x04 # QL-Series + UNKNOWN = -1 + + +class TapeColor(IntEnum): + """Tape colour at offset 24 — PT-Series only. + + QL-Series does not populate this byte; the value lands on :attr:`UNKNOWN`. + """ + + UNKNOWN = 0x00 + WHITE = 0x01 + OTHER = 0x02 + CLEAR = 0x03 + RED = 0x04 + BLUE = 0x05 + YELLOW = 0x06 + GREEN = 0x07 + BLACK = 0x08 + CLEAR_WHITE_TEXT = 0x09 + MATTE_WHITE = 0x20 + MATTE_CLEAR = 0x21 + MATTE_SILVER = 0x22 + SATIN_GOLD = 0x23 + SATIN_SILVER = 0x24 + BLUE_D = 0x30 + RED_D = 0x31 + FLUORESCENT_ORANGE = 0x40 + FLUORESCENT_YELLOW = 0x41 + BERRY_PINK_S = 0x50 + LIGHT_GRAY_S = 0x51 + LIME_GREEN_S = 0x52 + YELLOW_F = 0x60 + PINK_F = 0x61 + BLUE_F = 0x62 + HEAT_SHRINK_WHITE = 0x70 + FLEX_WHITE = 0x90 + FLEX_YELLOW = 0x91 + CLEANING = 0xF0 + STENCIL = 0xF1 + INCOMPATIBLE = 0xFF + + +class TextColor(IntEnum): + """Text colour at offset 25 — PT-Series only.""" + + UNKNOWN = 0x00 + WHITE = 0x01 + OTHER = 0x02 + RED = 0x04 + BLUE = 0x05 + BLACK = 0x08 + GOLD = 0x0A + BLUE_F = 0x62 + CLEANING = 0xF0 + STENCIL = 0xF1 + INCOMPATIBLE = 0xFF + + +class PrinterError(IntFlag): + """Aggregated error flags from offsets 8 (info1) and 9 (info2). + + Some flags are PT-only or QL-only; both sets are exposed here. Consumers + should treat unfamiliar flags gracefully — a future Brother firmware may + add or repurpose bits. + """ + + NONE = 0 + # Error info 1 (offset 8) + NO_MEDIA = 1 << 0 + END_OF_MEDIA = 1 << 1 # QL die-cut only + CUTTER_JAM = 1 << 2 + WEAK_BATTERIES = 1 << 3 # PT only + PRINTER_IN_USE = 1 << 4 # QL + PRINTER_TURNED_OFF = 1 << 5 # QL + HIGH_VOLTAGE_ADAPTER = 1 << 6 # PT + FAN_MOTOR_ERROR = 1 << 7 # QL + # Error info 2 (offset 9), shifted into upper byte + REPLACE_MEDIA = 1 << 8 + EXPANSION_BUFFER_FULL = 1 << 9 # QL + COMMUNICATION_ERROR = 1 << 10 # QL + COVER_OPEN = 1 << 12 # bit 4 of error info 2 + OVERHEATING = 1 << 13 # bit 5 (PT) — QL has cooling notifications instead + MEDIA_CANNOT_BE_FED = 1 << 14 # QL + SYSTEM_ERROR = 1 << 15 # QL + + +@dataclass(frozen=True, slots=True) +class StatusBlock: + """Parsed Brother status block. + + Field names mirror the Brother spec column names where reasonable. + Unknown enum values land on the ``UNKNOWN`` member so consumers don't + have to defend against ValueError on every field access. + """ + + raw: bytes + print_head_mark: int + size: int + brother_code: int + series_code: int + model_code: int + country_code: int + error_flags_1: int + error_flags_2: int + media_width_mm: int + media_type: MediaType + media_length_mm: int + mode: int + status_type: StatusType + phase_type: PhaseType + phase_number: int + notification: NotificationCode + tape_color: TapeColor + text_color: TextColor + + errors: list[PrinterError] = field(default_factory=list) + + @property + def is_ready(self) -> bool: + """True if the printer is idle, no errors, and waiting to receive data.""" + return ( + self.status_type == StatusType.REPLY + and self.phase_type == PhaseType.EDITING + and not self.errors + ) + + @property + def is_printing(self) -> bool: + """True if the printer is mid-print.""" + return self.phase_type == PhaseType.PRINTING + + +def _safe_enum[E: IntEnum](enum_cls: type[E], value: int, default: E) -> E: + """Map a raw byte to an enum member, falling back to ``default`` on misses.""" + try: + return enum_cls(value) + except ValueError: + return default + + +def _decode_errors(error_flags_1: int, error_flags_2: int) -> list[PrinterError]: + """Translate Brother error info 1+2 into a list of distinct flags.""" + flags: list[PrinterError] = [] + bit_map_1: list[tuple[int, PrinterError]] = [ + (0x01, PrinterError.NO_MEDIA), + (0x02, PrinterError.END_OF_MEDIA), + (0x04, PrinterError.CUTTER_JAM), + (0x08, PrinterError.WEAK_BATTERIES), + (0x10, PrinterError.PRINTER_IN_USE), + (0x20, PrinterError.PRINTER_TURNED_OFF), + (0x40, PrinterError.HIGH_VOLTAGE_ADAPTER), + (0x80, PrinterError.FAN_MOTOR_ERROR), + ] + bit_map_2: list[tuple[int, PrinterError]] = [ + (0x01, PrinterError.REPLACE_MEDIA), + (0x02, PrinterError.EXPANSION_BUFFER_FULL), + (0x04, PrinterError.COMMUNICATION_ERROR), + (0x10, PrinterError.COVER_OPEN), + (0x20, PrinterError.OVERHEATING), + (0x40, PrinterError.MEDIA_CANNOT_BE_FED), + (0x80, PrinterError.SYSTEM_ERROR), + ] + for mask, flag in bit_map_1: + if error_flags_1 & mask: + flags.append(flag) + for mask, flag in bit_map_2: + if error_flags_2 & mask: + flags.append(flag) + return flags + + +class StatusBlockParser: + """Decodes the Brother 32-byte status block. + + Use :meth:`parse` from outside; the class itself holds no state. + """ + + @staticmethod + def parse(raw: bytes) -> StatusBlock: + """Decode a 32-byte block. + + Raises: + StatusBlockError: if the input is not exactly 32 bytes. + """ + if len(raw) != STATUS_BLOCK_SIZE: + raise StatusBlockError( + f"Status block must be exactly {STATUS_BLOCK_SIZE} bytes, got {len(raw)}" + ) + + error_flags_1 = raw[8] + error_flags_2 = raw[9] + return StatusBlock( + raw=raw, + print_head_mark=raw[0], + size=raw[1], + brother_code=raw[2], + series_code=raw[3], + model_code=raw[4], + country_code=raw[5], + error_flags_1=error_flags_1, + error_flags_2=error_flags_2, + media_width_mm=raw[10], + media_type=_safe_enum(MediaType, raw[11], MediaType.UNKNOWN), + media_length_mm=raw[17], + mode=raw[15], + status_type=_safe_enum(StatusType, raw[18], StatusType.UNKNOWN), + phase_type=_safe_enum(PhaseType, raw[19], PhaseType.UNKNOWN), + phase_number=(raw[20] << 8) | raw[21], + notification=_safe_enum(NotificationCode, raw[22], NotificationCode.UNKNOWN), + tape_color=_safe_enum(TapeColor, raw[24], TapeColor.UNKNOWN), + text_color=_safe_enum(TextColor, raw[25], TextColor.UNKNOWN), + errors=_decode_errors(error_flags_1, error_flags_2), + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..28786d2 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,115 @@ +[project] +name = "label-printer-hub-backend" +version = "0.0.0-dev" +description = "Self-hosted label printer hub for Brother PT/QL series — backend" +readme = "../README.md" +license = "MIT" +requires-python = ">=3.12" +authors = [ + {name = "Björn Strausmann", email = "strausmannservices@googlemail.com"}, +] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.32", + "pydantic>=2.9", + "pydantic-settings>=2.6", + "sqlmodel>=0.0.22", + "aiosqlite>=0.20", + "jinja2>=3.1", + "python-multipart>=0.0.12", + "httpx>=0.28", + "pillow>=11.0", + "qrcode[pil]>=8.0", + "brother-ql>=0.9.4", + "ptouch>=1.1.0", + "pysnmp>=6.2", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3", + "pytest-asyncio>=0.24", + "pytest-cov>=6.0", + "respx>=0.21", + "ruff>=0.8", + "mypy>=1.13", + "types-requests", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +line-length = 100 +target-version = "py312" +src = ["app", "tests"] + +[tool.ruff.lint] +select = [ + "E", "F", "W", # pycodestyle + pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # bugbear + "A", # builtins shadowing + "C4", # comprehensions + "T20", # flake8-print + "SIM", # simplify + "ARG", # unused args + "PTH", # use pathlib + "RUF", # ruff-specific +] +ignore = [] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["ARG"] # pytest fixtures + +[tool.mypy] +python_version = "3.12" +strict = true +warn_unreachable = true +warn_unused_ignores = true +show_error_codes = true +disallow_untyped_decorators = false # FastAPI/pytest decorators + +[[tool.mypy.overrides]] +module = ["brother_ql.*", "ptouch.*", "pysnmp.*", "qrcode.*"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = [ + "--strict-markers", + "--strict-config", + "-ra", +] +markers = [ + "hardware: requires real Brother printer hardware (skipped by default; opt in with --hardware)", +] + +[tool.coverage.run] +source = ["app"] +branch = true + +[tool.coverage.report] +fail_under = 80 +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "\\.\\.\\.", +] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..e4d143f --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,26 @@ +"""Pytest configuration shared across all tests. + +Hardware tests are skipped by default — pass `--hardware` to opt in. +""" + +from __future__ import annotations + +import pytest + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption( + "--hardware", + action="store_true", + default=False, + help="run hardware-in-the-loop tests against real Brother printers", + ) + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + if config.getoption("--hardware"): + return + skip_hardware = pytest.mark.skip(reason="hardware tests need --hardware flag") + for item in items: + if "hardware" in item.keywords: + item.add_marker(skip_hardware) diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/printer_models/__init__.py b/backend/tests/unit/printer_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/services/__init__.py b/backend/tests/unit/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/services/test_status_block.py b/backend/tests/unit/services/test_status_block.py new file mode 100644 index 0000000..3748991 --- /dev/null +++ b/backend/tests/unit/services/test_status_block.py @@ -0,0 +1,276 @@ +"""Tests for the Brother 32-byte status block parser. + +Reference: Brother Raster Command Reference v1.02 (PT-E550W/P750W/P710BT) and +v1.01 (QL-800/810W/820NWB), section 4 — Status information request. + +The PT-Series and QL-Series share the same overall layout but differ in: +- byte [3] series code: PT 0x30, QL 0x34 +- byte [4] model code: PT-P750W 0x68, QL-820NWB 0x41, etc. +- byte [11] media type values: PT 0x01/0x03/0x11/0x17 vs QL 0x4A/0x4B +- byte [14] reserved value: PT 0x00 vs QL 0x3F +- bytes [24-25] tape/text colour: PT only (QL reserves these) +""" + +from __future__ import annotations + +import pytest +from app.services.status_block import ( + MediaType, + NotificationCode, + PhaseType, + PrinterError, + StatusBlock, + StatusBlockError, + StatusBlockParser, + StatusType, + TapeColor, + TextColor, +) + +# Synthetic 32-byte sample matching what the PT-P750W replies in READY state +# with a 12mm laminated white tape and black text. Values follow the v1.02 spec. +PT_READY_12MM_WHITE_BLACK = bytes( + [ + 0x80, + 0x20, + 0x42, + 0x30, + 0x68, + 0x30, + 0x00, + 0x00, # 0-7: header (PT-P750W) + 0x00, + 0x00, # 8-9: no errors + 0x0C, + 0x01, # 10-11: 12mm laminated TZe + 0x00, + 0x00, + 0x00, + 0x00, # 12-15: defaults + 0x00, + 0x00, # 16-17: density / continuous + 0x00, # 18: status reply + 0x00, + 0x00, + 0x00, # 19-21: editing phase, phase 0 + 0x00, # 22: no notification + 0x00, # 23: expansion area + 0x01, + 0x08, # 24-25: white tape, black text + 0x00, + 0x00, + 0x00, + 0x00, # 26-29: hardware + 0x00, + 0x00, # 30-31: reserved + ] +) + +# Synthetic QL-820NWB status block — same READY-on-12mm-continuous shape. +QL_READY_12MM_CONTINUOUS = bytes( + [ + 0x80, + 0x20, + 0x42, + 0x34, + 0x41, + 0x30, + 0x00, + 0x00, # 0-7: header (QL-820NWB) + 0x00, + 0x00, # 8-9: no errors + 0x0C, + 0x4A, # 10-11: 12mm continuous + 0x00, + 0x00, + 0x3F, + 0x00, # 12-15: QL fixes [14] = 0x3F + 0x00, + 0x00, # 16-17: density / continuous + 0x00, # 18: status reply + 0x00, + 0x00, + 0x00, # 19-21: receiving phase + 0x00, # 22: no notification + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, # 23-30: reserved (8 bytes) + 0x00, # 31: reserved + ] +) + + +class TestSize: + def test_rejects_too_short(self) -> None: + with pytest.raises(StatusBlockError, match="32 bytes"): + StatusBlockParser.parse(b"\x00" * 31) + + def test_rejects_too_long(self) -> None: + with pytest.raises(StatusBlockError, match="32 bytes"): + StatusBlockParser.parse(b"\x00" * 33) + + def test_accepts_exactly_32(self) -> None: + # Bare zero block parses without raising — fields land at default enum values + result = StatusBlockParser.parse(b"\x00" * 32) + assert isinstance(result, StatusBlock) + + +class TestPtSeriesParsing: + def test_decodes_header_fields(self) -> None: + sb = StatusBlockParser.parse(PT_READY_12MM_WHITE_BLACK) + assert sb.print_head_mark == 0x80 + assert sb.size == 0x20 + assert sb.brother_code == 0x42 + assert sb.series_code == 0x30 + assert sb.model_code == 0x68 + + def test_decodes_media(self) -> None: + sb = StatusBlockParser.parse(PT_READY_12MM_WHITE_BLACK) + assert sb.media_width_mm == 12 + assert sb.media_type == MediaType.LAMINATED + assert sb.media_length_mm == 0 # continuous + + def test_decodes_status_phase(self) -> None: + sb = StatusBlockParser.parse(PT_READY_12MM_WHITE_BLACK) + assert sb.status_type == StatusType.REPLY + assert sb.phase_type == PhaseType.EDITING + assert sb.phase_number == 0 + assert sb.notification == NotificationCode.NOT_AVAILABLE + + def test_decodes_colours(self) -> None: + sb = StatusBlockParser.parse(PT_READY_12MM_WHITE_BLACK) + assert sb.tape_color == TapeColor.WHITE + assert sb.text_color == TextColor.BLACK + + def test_no_errors(self) -> None: + sb = StatusBlockParser.parse(PT_READY_12MM_WHITE_BLACK) + assert sb.errors == [] + assert sb.is_ready is True + assert sb.is_printing is False + + +class TestQlSeriesParsing: + def test_decodes_qlseries_codes(self) -> None: + sb = StatusBlockParser.parse(QL_READY_12MM_CONTINUOUS) + assert sb.series_code == 0x34 + assert sb.model_code == 0x41 # QL-820NWB + + def test_decodes_continuous_media(self) -> None: + sb = StatusBlockParser.parse(QL_READY_12MM_CONTINUOUS) + assert sb.media_width_mm == 12 + assert sb.media_type == MediaType.CONTINUOUS_LENGTH_TAPE + + def test_qlseries_has_no_tape_colour(self) -> None: + sb = StatusBlockParser.parse(QL_READY_12MM_CONTINUOUS) + # QL doesn't populate these — bytes should land on zero/unknown + assert sb.tape_color in (TapeColor.UNKNOWN, TapeColor.WHITE) or sb.tape_color is None + + +class TestErrorFlags: + def test_no_media_error(self) -> None: + raw = bytearray(PT_READY_12MM_WHITE_BLACK) + raw[8] = 0x01 + raw[10] = 0 # no media → width 0 + raw[18] = 0x02 # status type = error + sb = StatusBlockParser.parse(bytes(raw)) + assert PrinterError.NO_MEDIA in sb.errors + assert sb.media_width_mm == 0 + assert sb.status_type == StatusType.ERROR + assert sb.is_ready is False + + def test_cover_open_error(self) -> None: + raw = bytearray(PT_READY_12MM_WHITE_BLACK) + raw[9] = 0x10 + raw[18] = 0x02 + sb = StatusBlockParser.parse(bytes(raw)) + assert PrinterError.COVER_OPEN in sb.errors + + def test_cutter_jam_error(self) -> None: + raw = bytearray(PT_READY_12MM_WHITE_BLACK) + raw[8] = 0x04 + raw[18] = 0x02 + sb = StatusBlockParser.parse(bytes(raw)) + assert PrinterError.CUTTER_JAM in sb.errors + + def test_overheating_error(self) -> None: + raw = bytearray(PT_READY_12MM_WHITE_BLACK) + raw[9] = 0x20 + raw[18] = 0x02 + sb = StatusBlockParser.parse(bytes(raw)) + assert PrinterError.OVERHEATING in sb.errors + + def test_multiple_errors(self) -> None: + raw = bytearray(PT_READY_12MM_WHITE_BLACK) + raw[8] = 0x09 # NO_MEDIA + WEAK_BATTERIES (0x01 | 0x08) + raw[9] = 0x10 # COVER_OPEN + raw[18] = 0x02 + sb = StatusBlockParser.parse(bytes(raw)) + assert PrinterError.NO_MEDIA in sb.errors + assert PrinterError.WEAK_BATTERIES in sb.errors + assert PrinterError.COVER_OPEN in sb.errors + + +class TestPhaseTransitions: + def test_printing_phase(self) -> None: + raw = bytearray(PT_READY_12MM_WHITE_BLACK) + raw[18] = 0x06 # phase change + raw[19] = 0x01 # printing phase + sb = StatusBlockParser.parse(bytes(raw)) + assert sb.status_type == StatusType.PHASE_CHANGE + assert sb.phase_type == PhaseType.PRINTING + assert sb.is_printing is True + assert sb.is_ready is False + + def test_print_complete(self) -> None: + raw = bytearray(PT_READY_12MM_WHITE_BLACK) + raw[18] = 0x01 + sb = StatusBlockParser.parse(bytes(raw)) + assert sb.status_type == StatusType.PRINTING_COMPLETED + + def test_phase_number_decoded_big_endian(self) -> None: + # PT spec: cover-open-while-receiving = phase 20 → 0x00 0x14 + raw = bytearray(PT_READY_12MM_WHITE_BLACK) + raw[18] = 0x06 + raw[19] = 0x01 + raw[20] = 0x00 + raw[21] = 0x14 + sb = StatusBlockParser.parse(bytes(raw)) + assert sb.phase_number == 20 + + +class TestUnknownEnums: + """Unknown values fall back to safe defaults rather than raising.""" + + def test_unknown_media_type(self) -> None: + raw = bytearray(PT_READY_12MM_WHITE_BLACK) + raw[11] = 0x99 # not in spec + sb = StatusBlockParser.parse(bytes(raw)) + assert sb.media_type == MediaType.UNKNOWN + + def test_unknown_status_type(self) -> None: + raw = bytearray(PT_READY_12MM_WHITE_BLACK) + raw[18] = 0x99 + sb = StatusBlockParser.parse(bytes(raw)) + assert sb.status_type == StatusType.UNKNOWN + + def test_incompatible_media_explicitly_supported(self) -> None: + raw = bytearray(PT_READY_12MM_WHITE_BLACK) + raw[11] = 0xFF + sb = StatusBlockParser.parse(bytes(raw)) + assert sb.media_type == MediaType.INCOMPATIBLE + + +class TestRoundtrip: + def test_raw_bytes_preserved(self) -> None: + sb = StatusBlockParser.parse(PT_READY_12MM_WHITE_BLACK) + assert sb.raw == PT_READY_12MM_WHITE_BLACK + + def test_dataclass_is_frozen(self) -> None: + sb = StatusBlockParser.parse(PT_READY_12MM_WHITE_BLACK) + with pytest.raises((AttributeError, Exception)): # FrozenInstanceError or similar + sb.media_width_mm = 99 # type: ignore[misc] From f01befa910a9025c5ef426e95f47fe1934d66e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Sun, 10 May 2026 18:22:08 +0000 Subject: [PATCH 2/2] refactor(status): address Gemini + Copilot review feedback on PR #29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reviewers caught the same set of issues — addressing them in one follow-up commit on the feature branch: Gemini findings (all addressed): - StatusBlock.errors changed from list[PrinterError] (mutable, breaks frozen=True guarantee) to a single PrinterError IntFlag value. Use bitwise membership: 'PrinterError.NO_MEDIA in sb.errors' still works because Python IntFlag supports it natively. - error_flags_1 / error_flags_2 fields removed — redundant when raw bytes are preserved in .raw[8] / .raw[9]. - _decode_errors() collapsed to a one-liner using IntFlag's CONFORM boundary: combined = info1 | (info2 << 8); PrinterError(combined). No more bit_map allocations on each parse. - types-requests removed from dev dependencies — the project uses httpx exclusively (per CLAUDE.md and .gemini/styleguide.md). Copilot findings (all addressed): - frozen=True now actually frozen (no list field). FrozenInstanceError is raised on attribute mutation. - test_qlseries_has_no_tape_colour assertion tightened to '== TapeColor.UNKNOWN' (was permissive 'in (UNKNOWN, WHITE) or None'). - test_dataclass_is_frozen catches the specific dataclasses.FrozenInstanceError instead of bare Exception. - bit_map_1 / bit_map_2 module-level constants no longer needed — the IntFlag bitwise operation replaces them entirely. Test count: 24 → 25 (added test_errors_field_is_immutable_intflag to guard against regression to mutable list). --- backend/app/services/status_block.py | 64 +++++++------------ backend/pyproject.toml | 1 - .../tests/unit/services/test_status_block.py | 17 +++-- 3 files changed, 36 insertions(+), 46 deletions(-) diff --git a/backend/app/services/status_block.py b/backend/app/services/status_block.py index 89c3c87..320a0b4 100644 --- a/backend/app/services/status_block.py +++ b/backend/app/services/status_block.py @@ -22,7 +22,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import IntEnum, IntFlag STATUS_BLOCK_SIZE: int = 32 @@ -166,11 +166,17 @@ class PrinterError(IntFlag): @dataclass(frozen=True, slots=True) class StatusBlock: - """Parsed Brother status block. + """Parsed Brother status block — fully immutable. Field names mirror the Brother spec column names where reasonable. Unknown enum values land on the ``UNKNOWN`` member so consumers don't have to defend against ValueError on every field access. + + The ``errors`` field is a single :class:`PrinterError` ``IntFlag`` value — + use Python's bitwise membership test to check for individual flags:: + + if PrinterError.NO_MEDIA in status.errors: + ... """ raw: bytes @@ -180,8 +186,6 @@ class StatusBlock: series_code: int model_code: int country_code: int - error_flags_1: int - error_flags_2: int media_width_mm: int media_type: MediaType media_length_mm: int @@ -192,8 +196,7 @@ class StatusBlock: notification: NotificationCode tape_color: TapeColor text_color: TextColor - - errors: list[PrinterError] = field(default_factory=list) + errors: PrinterError @property def is_ready(self) -> bool: @@ -201,7 +204,7 @@ def is_ready(self) -> bool: return ( self.status_type == StatusType.REPLY and self.phase_type == PhaseType.EDITING - and not self.errors + and self.errors == PrinterError.NONE ) @property @@ -218,35 +221,18 @@ def _safe_enum[E: IntEnum](enum_cls: type[E], value: int, default: E) -> E: return default -def _decode_errors(error_flags_1: int, error_flags_2: int) -> list[PrinterError]: - """Translate Brother error info 1+2 into a list of distinct flags.""" - flags: list[PrinterError] = [] - bit_map_1: list[tuple[int, PrinterError]] = [ - (0x01, PrinterError.NO_MEDIA), - (0x02, PrinterError.END_OF_MEDIA), - (0x04, PrinterError.CUTTER_JAM), - (0x08, PrinterError.WEAK_BATTERIES), - (0x10, PrinterError.PRINTER_IN_USE), - (0x20, PrinterError.PRINTER_TURNED_OFF), - (0x40, PrinterError.HIGH_VOLTAGE_ADAPTER), - (0x80, PrinterError.FAN_MOTOR_ERROR), - ] - bit_map_2: list[tuple[int, PrinterError]] = [ - (0x01, PrinterError.REPLACE_MEDIA), - (0x02, PrinterError.EXPANSION_BUFFER_FULL), - (0x04, PrinterError.COMMUNICATION_ERROR), - (0x10, PrinterError.COVER_OPEN), - (0x20, PrinterError.OVERHEATING), - (0x40, PrinterError.MEDIA_CANNOT_BE_FED), - (0x80, PrinterError.SYSTEM_ERROR), - ] - for mask, flag in bit_map_1: - if error_flags_1 & mask: - flags.append(flag) - for mask, flag in bit_map_2: - if error_flags_2 & mask: - flags.append(flag) - return flags +def _decode_errors(error_flags_1: int, error_flags_2: int) -> PrinterError: + """Translate Brother error info 1 + 2 into a combined :class:`PrinterError`. + + The :class:`PrinterError` ``IntFlag`` is laid out so that error info 1 maps + to the low byte (bits 0-7) and error info 2 maps to the high byte + (bits 8-15). Combining them is therefore a single bitwise operation. + + Unknown bits are silently dropped — Python ``IntFlag`` (default + ``CONFORM`` boundary) masks them off automatically. + """ + combined = error_flags_1 | (error_flags_2 << 8) + return PrinterError(combined) class StatusBlockParser: @@ -267,8 +253,6 @@ def parse(raw: bytes) -> StatusBlock: f"Status block must be exactly {STATUS_BLOCK_SIZE} bytes, got {len(raw)}" ) - error_flags_1 = raw[8] - error_flags_2 = raw[9] return StatusBlock( raw=raw, print_head_mark=raw[0], @@ -277,8 +261,7 @@ def parse(raw: bytes) -> StatusBlock: series_code=raw[3], model_code=raw[4], country_code=raw[5], - error_flags_1=error_flags_1, - error_flags_2=error_flags_2, + errors=_decode_errors(raw[8], raw[9]), media_width_mm=raw[10], media_type=_safe_enum(MediaType, raw[11], MediaType.UNKNOWN), media_length_mm=raw[17], @@ -289,5 +272,4 @@ def parse(raw: bytes) -> StatusBlock: notification=_safe_enum(NotificationCode, raw[22], NotificationCode.UNKNOWN), tape_color=_safe_enum(TapeColor, raw[24], TapeColor.UNKNOWN), text_color=_safe_enum(TextColor, raw[25], TextColor.UNKNOWN), - errors=_decode_errors(error_flags_1, error_flags_2), ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 28786d2..334da69 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -42,7 +42,6 @@ dev = [ "respx>=0.21", "ruff>=0.8", "mypy>=1.13", - "types-requests", ] [build-system] diff --git a/backend/tests/unit/services/test_status_block.py b/backend/tests/unit/services/test_status_block.py index 3748991..11ac4cc 100644 --- a/backend/tests/unit/services/test_status_block.py +++ b/backend/tests/unit/services/test_status_block.py @@ -149,7 +149,7 @@ def test_decodes_colours(self) -> None: def test_no_errors(self) -> None: sb = StatusBlockParser.parse(PT_READY_12MM_WHITE_BLACK) - assert sb.errors == [] + assert sb.errors == PrinterError.NONE assert sb.is_ready is True assert sb.is_printing is False @@ -167,8 +167,8 @@ def test_decodes_continuous_media(self) -> None: def test_qlseries_has_no_tape_colour(self) -> None: sb = StatusBlockParser.parse(QL_READY_12MM_CONTINUOUS) - # QL doesn't populate these — bytes should land on zero/unknown - assert sb.tape_color in (TapeColor.UNKNOWN, TapeColor.WHITE) or sb.tape_color is None + # QL leaves byte 24 reserved (0x00) — must map to UNKNOWN, not WHITE + assert sb.tape_color == TapeColor.UNKNOWN class TestErrorFlags: @@ -271,6 +271,15 @@ def test_raw_bytes_preserved(self) -> None: assert sb.raw == PT_READY_12MM_WHITE_BLACK def test_dataclass_is_frozen(self) -> None: + import dataclasses + sb = StatusBlockParser.parse(PT_READY_12MM_WHITE_BLACK) - with pytest.raises((AttributeError, Exception)): # FrozenInstanceError or similar + with pytest.raises(dataclasses.FrozenInstanceError): sb.media_width_mm = 99 # type: ignore[misc] + + def test_errors_field_is_immutable_intflag(self) -> None: + """The errors field is a single IntFlag value (not a mutable list).""" + sb = StatusBlockParser.parse(PT_READY_12MM_WHITE_BLACK) + assert isinstance(sb.errors, PrinterError) + # IntFlag in/membership is bitwise; NONE has no bits set + assert sb.errors == PrinterError.NONE