diff --git a/src/opendisplay/__init__.py b/src/opendisplay/__init__.py index 018b4cc..257f437 100644 --- a/src/opendisplay/__init__.py +++ b/src/opendisplay/__init__.py @@ -18,6 +18,8 @@ ImageEncodingError, IntegrityCheckError, InvalidResponseError, + NfcNotSupportedError, + NfcWriteError, OpenDisplayError, OTAError, OTANotSupportedError, @@ -67,6 +69,7 @@ ICType, NfcFieldDetectMode, NfcIcType, + NfcRecordType, OpenDisplayBoardType, PartialUpdateSupport, PowerMode, @@ -108,6 +111,8 @@ "InvalidResponseError", "ImageEncodingError", "IntegrityCheckError", + "NfcNotSupportedError", + "NfcWriteError", "OTAError", "OTANotSupportedError", "find_nrf_dfu_device", @@ -164,6 +169,7 @@ "SolumBoardType", "TouchIcType", "NfcIcType", + "NfcRecordType", "FlashIcType", "NfcFieldDetectMode", "ActiveLevel", diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index 0918984..a7470b6 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -44,6 +44,7 @@ ImageEncodingError, IntegrityCheckError, InvalidResponseError, + NfcNotSupportedError, ProtocolError, RefreshTimeoutError, TruncatedConfigError, @@ -52,7 +53,7 @@ from .models.buzzer_activate import BuzzerActivateConfig from .models.capabilities import DeviceCapabilities from .models.config import GlobalConfig -from .models.enums import BoardManufacturer, FitMode, RefreshMode, Rotation +from .models.enums import BoardManufacturer, FitMode, NfcRecordType, RefreshMode, Rotation from .models.firmware import FirmwareVersion from .models.led_flash import LedFlashConfig from .partial import ( @@ -72,6 +73,9 @@ MAX_COMPRESSED_SIZE, MAX_PTO, MAX_START_PAYLOAD, + NFC_CHUNK_SIZE, + NFC_INLINE_MAX, + NFC_WRITE_MAX_TOTAL, PIPE_FLAG_PARTIAL, PIPE_FRAME_OVERHEAD, TIMEOUT_PIPE_START, @@ -90,6 +94,10 @@ build_direct_write_start_uncompressed, build_enter_dfu_command, build_led_activate_command, + build_nfc_write_data_command, + build_nfc_write_end_command, + build_nfc_write_inline_command, + build_nfc_write_start_command, build_pipe_write_data_command, build_pipe_write_end_command, build_pipe_write_start_command, @@ -108,6 +116,8 @@ validate_ack_response, ) from .protocol.responses import ( + NFC_STATUS_CHUNK_ACK, + NFC_STATUS_WRITE_OK, PIPE_FRAME_ACK, PIPE_FRAME_END_ACK, PIPE_FRAME_END_NACK, @@ -122,6 +132,7 @@ parse_authenticate_success, strip_command_echo, unpack_command_code, + validate_nfc_response, ) from .transport import BLEConnection @@ -422,6 +433,7 @@ class OpenDisplayDevice: # pylint: disable=too-many-instance-attributes TIMEOUT_FIRST_CHUNK = 10.0 # First chunk may take longer TIMEOUT_CONFIG_CHUNK = 2.0 # Subsequent config read chunks (interrogate) TIMEOUT_ACK = 5.0 # Command acknowledgments + TIMEOUT_NFC_WRITE = 15.0 # NFC EEPROM commit (inline write / chunk end): slow I2C work TIMEOUT_UNCOMPRESSED_DATA_ACK = 90.0 # Uncompressed DATA: bbepWriteData() blocks SPI on Spectra/ACeP (~60s max) TIMEOUT_UNCOMPRESSED_END_ACK = 90.0 # Uncompressed END: some firmware variants refresh before replying (~60s max) TIMEOUT_COMPRESSED_END_ACK = 90.0 # Compressed END: decompression + full SPI write to IC (~60s on Spectra/ACeP) @@ -1275,6 +1287,130 @@ async def activate_buzzer( validate_ack_response(response, CommandCode.BUZZER_ACTIVATE) return response + @_serialized + async def write_nfc( + self, + rec_type: NfcRecordType | int, + payload: bytes, + timeout: float | None = None, + ) -> None: + """Write an NDEF record to the device's NFC tag via NFC_ENDPOINT (0x0083). + + Payloads up to NFC_INLINE_MAX (120) bytes are sent as a single inline + write. Larger payloads (up to NFC_WRITE_MAX_TOTAL, 512 bytes) are sent + as a chunked write: a start frame, one or more NFC_CHUNK_SIZE (120) + byte data frames, and an end frame that commits the write. + + Args: + rec_type: NDEF record type (see NfcRecordType). + payload: Record payload bytes, 1..NFC_WRITE_MAX_TOTAL. + timeout: Optional override for the commit read (the inline write's + response, or the chunked write's end response). Defaults to + TIMEOUT_NFC_WRITE because the EEPROM commit is slow I2C work. + Intermediate chunk-stage ACKs during a chunked write always + use TIMEOUT_ACK. + + Raises: + RuntimeError: If device is not connected. + ValueError: If payload length is outside 1..NFC_WRITE_MAX_TOTAL. + NfcWriteError: If firmware rejects the write with an error frame. + NfcNotSupportedError: If the device does not respond to the first + frame of the write sequence (firmware older than the NFC + write feature stays silent on the unknown opcode). + InvalidResponseError: If a response frame is malformed or carries + an unexpected status. + """ + if self._connection is None: + raise RuntimeError("Device not connected") + if not 1 <= len(payload) <= NFC_WRITE_MAX_TOTAL: + raise ValueError(f"payload length must be 1..{NFC_WRITE_MAX_TOTAL}, got {len(payload)}") + + commit_timeout = self.TIMEOUT_NFC_WRITE if timeout is None else timeout + first_read = True + + async def _read_nfc(read_timeout: float) -> bytes: + nonlocal first_read + try: + response = await self._read(read_timeout) + except BLETimeoutError: + if first_read: + raise NfcNotSupportedError() from None + raise + finally: + first_read = False + return response + + if len(payload) <= NFC_INLINE_MAX: + await self._write(build_nfc_write_inline_command(int(rec_type), payload)) + response = await _read_nfc(commit_timeout) + validate_nfc_response(response, NFC_STATUS_WRITE_OK) + return + + await self._write(build_nfc_write_start_command(int(rec_type), len(payload))) + response = await _read_nfc(self.TIMEOUT_ACK) + validate_nfc_response(response, NFC_STATUS_CHUNK_ACK) + + for offset in range(0, len(payload), NFC_CHUNK_SIZE): + chunk = payload[offset : offset + NFC_CHUNK_SIZE] + await self._write(build_nfc_write_data_command(chunk)) + response = await _read_nfc(self.TIMEOUT_ACK) + validate_nfc_response(response, NFC_STATUS_CHUNK_ACK) + + await self._write(build_nfc_write_end_command()) + response = await _read_nfc(commit_timeout) + validate_nfc_response(response, NFC_STATUS_WRITE_OK) + + async def write_nfc_url(self, url: str, timeout: float | None = None) -> None: + """Write a URI NDEF record containing the given URL. + + The URL is sent verbatim as UTF-8; firmware builds the NDEF URI + record from the payload. + + Args: + url: URL to write. + timeout: Optional override; see write_nfc. + """ + await self.write_nfc(NfcRecordType.URI, url.encode("utf-8"), timeout) + + async def write_nfc_text(self, text: str, timeout: float | None = None) -> None: + """Write a TEXT NDEF record containing the given text. + + The text is sent verbatim as UTF-8. + + Args: + text: Text to write. + timeout: Optional override; see write_nfc. + """ + await self.write_nfc(NfcRecordType.TEXT, text.encode("utf-8"), timeout) + + async def write_nfc_mime( + self, + mime_type: str, + body: bytes | str, + timeout: float | None = None, + ) -> None: + """Write a MIME NDEF record with a length-prefixed MIME type header. + + The host payload format is ``bytes([len(mt)]) + mt + body`` where + ``mt`` is the MIME type encoded as UTF-8, confirmed against the web + BLE tester and firmware MIME parser. + + Args: + mime_type: MIME type string (e.g. "text/vcard"). Must encode to + 1..255 bytes as UTF-8. + body: Record body, as bytes or a UTF-8 string. + timeout: Optional override; see write_nfc. + + Raises: + ValueError: If the encoded MIME type is outside 1..255 bytes. + """ + mt = mime_type.encode("utf-8") + if not 1 <= len(mt) <= 255: + raise ValueError(f"mime_type must encode to 1..255 bytes, got {len(mt)}") + body_bytes = body.encode("utf-8") if isinstance(body, str) else body + payload = bytes([len(mt)]) + mt + body_bytes + await self.write_nfc(NfcRecordType.MIME, payload, timeout) + @_serialized async def write_config(self, config: GlobalConfig) -> None: """Write configuration to device. diff --git a/src/opendisplay/exceptions.py b/src/opendisplay/exceptions.py index 773d12a..4ea9d65 100644 --- a/src/opendisplay/exceptions.py +++ b/src/opendisplay/exceptions.py @@ -111,6 +111,32 @@ class IntegrityCheckError(ProtocolError): pass +class NfcWriteError(ProtocolError): + """Device rejected an NFC_ENDPOINT (0x0083) write with an error frame. + + Raised when the firmware returns ``{0xFF, 0x83, 0xFF, err}``. ``error_code`` + carries the raw firmware error byte (see ``NFC_ERROR_MESSAGES``) so callers + can distinguish e.g. a too-large record from NFC being disabled in config. + """ + + def __init__(self, message: str, error_code: int | None = None) -> None: + super().__init__(message) + self.error_code = error_code + + +class NfcNotSupportedError(ProtocolError): + """Device did not respond to an NFC_ENDPOINT (0x0083) command. + + Firmware older than the NFC write feature stays silent on unknown opcodes, + so a missing response is inconclusive rather than a confirmed rejection; + it could also be a dropped packet. Raised when a write times out waiting + for any 0x0083 response. + """ + + def __init__(self, message: str = "Device may not support NFC write (no response to NFC command)") -> None: + super().__init__(message) + + class ImageEncodingError(OpenDisplayError): """Failed to encode image.""" diff --git a/src/opendisplay/models/__init__.py b/src/opendisplay/models/__init__.py index 200c51f..9244e81 100644 --- a/src/opendisplay/models/__init__.py +++ b/src/opendisplay/models/__init__.py @@ -39,6 +39,7 @@ ICType, NfcFieldDetectMode, NfcIcType, + NfcRecordType, OpenDisplayBoardType, PowerMode, RefreshMode, @@ -90,6 +91,7 @@ "NfcConfig", "NfcFieldDetectMode", "NfcIcType", + "NfcRecordType", "OpenDisplayBoardType", "PassiveBuzzer", "PowerMode", diff --git a/src/opendisplay/models/enums.py b/src/opendisplay/models/enums.py index 8ec70a6..0e55a56 100644 --- a/src/opendisplay/models/enums.py +++ b/src/opendisplay/models/enums.py @@ -131,6 +131,16 @@ class NfcIcType(IntEnum): TNB132M = 1 +class NfcRecordType(IntEnum): + """NDEF record types for the NFC write endpoint (command 0x0083).""" + + TEXT = 0 + URI = 1 + WELL_KNOWN_RAW = 2 + MIME = 3 + RAW_NDEF = 4 + + class FlashIcType(IntEnum): """External flash IC types (config packet 0x2b).""" diff --git a/src/opendisplay/protocol/__init__.py b/src/opendisplay/protocol/__init__.py index fe9bcec..e544ed3 100644 --- a/src/opendisplay/protocol/__init__.py +++ b/src/opendisplay/protocol/__init__.py @@ -8,6 +8,14 @@ MAX_COMPRESSED_SIZE, MAX_PTO, MAX_START_PAYLOAD, + NFC_CHUNK_SIZE, + NFC_INLINE_MAX, + NFC_SUB_READ, + NFC_SUB_WRITE_DATA, + NFC_SUB_WRITE_END, + NFC_SUB_WRITE_INLINE, + NFC_SUB_WRITE_START, + NFC_WRITE_MAX_TOTAL, PIPE_FLAG_COMPRESSED, PIPE_FLAG_PARTIAL, PIPE_FRAME_OVERHEAD, @@ -28,6 +36,10 @@ build_direct_write_start_uncompressed, build_enter_dfu_command, build_led_activate_command, + build_nfc_write_data_command, + build_nfc_write_end_command, + build_nfc_write_inline_command, + build_nfc_write_start_command, build_pipe_write_data_command, build_pipe_write_end_command, build_pipe_write_start_command, @@ -87,6 +99,18 @@ "build_pipe_write_end_command", "build_buzzer_activate_command", "build_led_activate_command", + "NFC_SUB_READ", + "NFC_SUB_WRITE_INLINE", + "NFC_SUB_WRITE_START", + "NFC_SUB_WRITE_DATA", + "NFC_SUB_WRITE_END", + "NFC_INLINE_MAX", + "NFC_CHUNK_SIZE", + "NFC_WRITE_MAX_TOTAL", + "build_nfc_write_inline_command", + "build_nfc_write_start_command", + "build_nfc_write_data_command", + "build_nfc_write_end_command", "parse_config_response", "serialize_config", "calculate_config_crc", diff --git a/src/opendisplay/protocol/commands.py b/src/opendisplay/protocol/commands.py index e877463..a7d26f0 100644 --- a/src/opendisplay/protocol/commands.py +++ b/src/opendisplay/protocol/commands.py @@ -44,6 +44,9 @@ class CommandCode(IntEnum): PIPE_WRITE_DATA = 0x0081 # Windowed data frame (seq + chunk); also device→host ACK/NACK opcode PIPE_WRITE_END = 0x0082 # End sliding-window transfer and trigger display refresh + # NFC write endpoint (firmware NFC_ENDPOINT) + NFC_ENDPOINT = 0x0083 # Read/write the NFC tag NDEF record (sub-opcode selects the operation) + # Protocol constants SERVICE_UUID = "00002446-0000-1000-8000-00805F9B34FB" @@ -76,6 +79,16 @@ class CommandCode(IntEnum): TIMEOUT_PIPE_START = 30.0 MAX_PTO = 3 # Consecutive silent probe timeouts before aborting a pipe transfer +# NFC write endpoint (0x0083) sub-opcodes and size limits +NFC_SUB_READ = 0x00 # Read the current NDEF record (not built here) +NFC_SUB_WRITE_INLINE = 0x01 # Single-shot write: rec_type + len + payload in one packet +NFC_SUB_WRITE_START = 0x10 # Begin a chunked write: rec_type + total_len +NFC_SUB_WRITE_DATA = 0x11 # Chunked write data frame +NFC_SUB_WRITE_END = 0x12 # Commit a chunked write +NFC_INLINE_MAX = 120 # Firmware policy: payloads above this size must use chunked write +NFC_CHUNK_SIZE = 120 # Maximum bytes per NFC_SUB_WRITE_DATA chunk +NFC_WRITE_MAX_TOTAL = 512 # Firmware hard limit on total NDEF payload size + def build_read_config_command() -> bytes: """Build command to read device TLV configuration. @@ -441,6 +454,98 @@ def build_pipe_write_end_command(refresh_mode: int, new_etag: int | None = None) return cmd + refresh_mode.to_bytes(1, byteorder="big") + new_etag.to_bytes(4, byteorder="big") +def build_nfc_write_inline_command(rec_type: int, payload: bytes) -> bytes: + """Build an NFC_ENDPOINT inline write (sub-opcode 0x01). + + Wire: [0x00][0x83][0x01][rec_type:1][len:2 BE][payload] + + This builder only enforces the wire-level u16 length field. The firmware's + 120-byte inline-vs-chunked policy (NFC_INLINE_MAX) is the caller's concern + (see device method) and is not validated here. + + Args: + rec_type: NDEF record type (see NfcRecordType), 0..255. + payload: Record payload bytes. + + Returns: + Command bytes for 0x0083/0x01. + + Raises: + ValueError: If payload is empty, payload exceeds 0xFFFF bytes, or + rec_type is outside 0..255. + """ + if not 0 <= rec_type <= 0xFF: + raise ValueError(f"rec_type out of uint8 range: {rec_type}") + if len(payload) == 0: + raise ValueError("payload must not be empty") + if len(payload) > 0xFFFF: + raise ValueError(f"payload length {len(payload)} exceeds uint16 range") + + cmd = CommandCode.NFC_ENDPOINT.to_bytes(2, byteorder="big") + return cmd + bytes([NFC_SUB_WRITE_INLINE, rec_type]) + len(payload).to_bytes(2, byteorder="big") + payload + + +def build_nfc_write_start_command(rec_type: int, total_len: int) -> bytes: + """Build an NFC_ENDPOINT chunked-write start (sub-opcode 0x10). + + Wire: [0x00][0x83][0x10][rec_type:1][total_len:2 BE] + + Args: + rec_type: NDEF record type (see NfcRecordType), 0..255. + total_len: Total payload size the following DATA chunks will carry, + 1..NFC_WRITE_MAX_TOTAL (the firmware hard-rejects larger writes). + + Returns: + Command bytes for 0x0083/0x10. + + Raises: + ValueError: If total_len is outside 1..NFC_WRITE_MAX_TOTAL, or + rec_type is outside 0..255. + """ + if not 0 <= rec_type <= 0xFF: + raise ValueError(f"rec_type out of uint8 range: {rec_type}") + if not 1 <= total_len <= NFC_WRITE_MAX_TOTAL: + raise ValueError(f"total_len must be 1..{NFC_WRITE_MAX_TOTAL}, got {total_len}") + + cmd = CommandCode.NFC_ENDPOINT.to_bytes(2, byteorder="big") + return cmd + bytes([NFC_SUB_WRITE_START, rec_type]) + total_len.to_bytes(2, byteorder="big") + + +def build_nfc_write_data_command(chunk: bytes) -> bytes: + """Build an NFC_ENDPOINT chunked-write data frame (sub-opcode 0x11). + + Wire: [0x00][0x83][0x11][bytes] + + Args: + chunk: Chunk payload bytes, 1..NFC_CHUNK_SIZE. + + Returns: + Command bytes for 0x0083/0x11. + + Raises: + ValueError: If chunk is empty or exceeds NFC_CHUNK_SIZE. + """ + if len(chunk) == 0: + raise ValueError("chunk must not be empty") + if len(chunk) > NFC_CHUNK_SIZE: + raise ValueError(f"chunk size {len(chunk)} exceeds maximum {NFC_CHUNK_SIZE}") + + cmd = CommandCode.NFC_ENDPOINT.to_bytes(2, byteorder="big") + return cmd + bytes([NFC_SUB_WRITE_DATA]) + chunk + + +def build_nfc_write_end_command() -> bytes: + """Build an NFC_ENDPOINT chunked-write end / commit (sub-opcode 0x12). + + Wire: [0x00][0x83][0x12] + + Returns: + Command bytes for 0x0083/0x12. + """ + cmd = CommandCode.NFC_ENDPOINT.to_bytes(2, byteorder="big") + return cmd + bytes([NFC_SUB_WRITE_END]) + + def build_led_activate_command( led_instance: int, flash_config: LedFlashConfig, diff --git a/src/opendisplay/protocol/responses.py b/src/opendisplay/protocol/responses.py index 927ef42..760bdce 100644 --- a/src/opendisplay/protocol/responses.py +++ b/src/opendisplay/protocol/responses.py @@ -10,6 +10,7 @@ AuthenticationRequiredError, AuthenticationSessionExistsError, InvalidResponseError, + NfcWriteError, ) from ..models.firmware import FirmwareVersion from .commands import RESPONSE_HIGH_BIT_FLAG, CommandCode @@ -456,3 +457,58 @@ def classify_pipe_frame(data: bytes) -> str: if len(data) >= 2 and data[0] == 0xFF and data[1] == 0x82: return PIPE_FRAME_END_NACK return PIPE_FRAME_OTHER + + +# ─── NFC_ENDPOINT (0x0083) responses ─────────────────────────────────────── + +# Status byte in the {0x00, 0x83, status} OK frame. +NFC_STATUS_WRITE_OK = 0x81 # Inline write committed, or chunked write end committed +NFC_STATUS_CHUNK_ACK = 0x82 # Chunk-stage ACK (chunk start accepted, chunk data accepted) + +# Error codes carried in the {0xFF, 0x83, 0xFF, err} error frame. Error frames +# are always sent plaintext by firmware, even over an encrypted connection. +NFC_ERROR_MESSAGES: dict[int, str] = { + 1: "NFC write failed: malformed command", + 2: "NFC write failed: read failed", + 3: "NFC write failed: write failed (NFC disabled in config, IC error, or record too large for the NFC EEPROM)", + 4: "NFC write failed: unknown sub-command", + 5: "NFC write failed: invalid record type", + 6: "NFC write failed: invalid length", + 7: "NFC write failed: no active chunk session", + 8: "NFC write failed: chunk overflow", + 9: "NFC write failed: length mismatch at end", +} + + +def validate_nfc_response(data: bytes, expected_status: int) -> None: + """Validate an NFC_ENDPOINT (0x0083) response frame. + + Unlike ``validate_ack_response``, this distinguishes the two OK statuses + (0x81 write-committed vs. 0x82 chunk-stage ACK) and decodes the firmware's + dedicated NFC error frame. + + Args: + data: Decrypted response bytes (``cmd(2) + payload``). + expected_status: NFC_STATUS_WRITE_OK or NFC_STATUS_CHUNK_ACK, whichever + the caller's request should produce. + + Raises: + NfcWriteError: If the device returned ``{0xFF, 0x83, 0xFF, err}``. + InvalidResponseError: If the frame is too short, has an unexpected + command echo, or an OK frame with a status other than + ``expected_status``. + """ + if len(data) >= 4 and data[0] == 0xFF and data[1] == 0x83 and data[2] == 0xFF: + error_code = data[3] + message = NFC_ERROR_MESSAGES.get(error_code, f"NFC write failed: unknown error code 0x{error_code:02x}") + raise NfcWriteError(message, error_code=error_code) + + if len(data) >= 3 and data[0] == 0x00 and data[1] == 0x83: + status = data[2] + if status == expected_status: + return + raise InvalidResponseError( + f"NFC response status mismatch: expected 0x{expected_status:02x}, got 0x{status:02x}" + ) + + raise InvalidResponseError(f"Unexpected NFC_ENDPOINT response: {data[:4].hex()}") diff --git a/tests/unit/test_device_nfc_write.py b/tests/unit/test_device_nfc_write.py new file mode 100644 index 0000000..3deb1eb --- /dev/null +++ b/tests/unit/test_device_nfc_write.py @@ -0,0 +1,287 @@ +"""Test NFC write API on OpenDisplayDevice.""" + +from __future__ import annotations + +import pytest + +from opendisplay import OpenDisplayDevice +from opendisplay.crypto import encrypt_command +from opendisplay.exceptions import BLETimeoutError, InvalidResponseError, NfcNotSupportedError, NfcWriteError +from opendisplay.models.enums import NfcRecordType + + +class _FakeConnection: + def __init__(self, response: bytes | list[bytes], timeout_on: int | None = None): + if isinstance(response, list): + self._responses = response[:] + else: + self._responses = [response] + self.written: list[bytes] = [] + self.read_timeouts: list[float] = [] + self._timeout_on = timeout_on + self._read_count = 0 + + async def write_command(self, cmd: bytes, response: bool = True) -> None: + self.written.append(cmd) + + async def read_response(self, timeout: float) -> bytes: + self._read_count += 1 + self.read_timeouts.append(timeout) + if self._timeout_on is not None and self._read_count == self._timeout_on: + raise BLETimeoutError("Timed out waiting for response") + if not self._responses: + raise RuntimeError("No fake responses left") + return self._responses.pop(0) + + +@pytest.mark.asyncio +async def test_write_nfc_inline_happy_path() -> None: + """A short payload should be sent as a single inline write frame.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + fake = _FakeConnection(response=b"\x00\x83\x81") + device._connection = fake + + await device.write_nfc(NfcRecordType.TEXT, b"hello") + + expected_cmd = b"\x00\x83" + bytes([0x01, int(NfcRecordType.TEXT)]) + len(b"hello").to_bytes(2, "big") + b"hello" + assert fake.written == [expected_cmd] + assert fake.read_timeouts == [device.TIMEOUT_NFC_WRITE] + + +@pytest.mark.asyncio +async def test_write_nfc_chunked_121_bytes_exact_frame_sequence() -> None: + """A 121-byte payload should use start/data(120)/data(1)/end with the right ACKs.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + payload = bytes(range(121)) + fake = _FakeConnection(response=[b"\x00\x83\x82", b"\x00\x83\x82", b"\x00\x83\x82", b"\x00\x83\x81"]) + device._connection = fake + + await device.write_nfc(NfcRecordType.URI, payload) + + expected_start = b"\x00\x83" + bytes([0x10, int(NfcRecordType.URI)]) + len(payload).to_bytes(2, "big") + expected_data1 = b"\x00\x83" + bytes([0x11]) + payload[:120] + expected_data2 = b"\x00\x83" + bytes([0x11]) + payload[120:121] + expected_end = b"\x00\x83" + bytes([0x12]) + + assert fake.written == [expected_start, expected_data1, expected_data2, expected_end] + # First 3 reads (start ack, chunk1 ack, chunk2 ack) use TIMEOUT_ACK; final end read uses TIMEOUT_NFC_WRITE. + assert fake.read_timeouts == [ + device.TIMEOUT_ACK, + device.TIMEOUT_ACK, + device.TIMEOUT_ACK, + device.TIMEOUT_NFC_WRITE, + ] + + +@pytest.mark.asyncio +async def test_write_nfc_512_bytes_accepted() -> None: + """Exactly 512 bytes (NFC_WRITE_MAX_TOTAL) should be accepted.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + payload = bytes(512) + responses = [b"\x00\x83\x82"] * 6 + [b"\x00\x83\x81"] + fake = _FakeConnection(response=responses) + device._connection = fake + + await device.write_nfc(NfcRecordType.TEXT, payload) + + # start + 5 chunks (4x120 + 1x32) + end = 7 writes + assert len(fake.written) == 7 + + +@pytest.mark.asyncio +async def test_write_nfc_513_bytes_rejected() -> None: + """A payload larger than NFC_WRITE_MAX_TOTAL should raise ValueError.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + fake = _FakeConnection(response=b"\x00\x83\x81") + device._connection = fake + + with pytest.raises(ValueError): + await device.write_nfc(NfcRecordType.TEXT, bytes(513)) + + +@pytest.mark.asyncio +async def test_write_nfc_empty_payload_rejected() -> None: + """An empty payload should raise ValueError.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + fake = _FakeConnection(response=b"\x00\x83\x81") + device._connection = fake + + with pytest.raises(ValueError): + await device.write_nfc(NfcRecordType.TEXT, b"") + + +@pytest.mark.asyncio +async def test_write_nfc_error_frame_raises_nfc_write_error() -> None: + """A firmware error frame should raise NfcWriteError with the error code.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + fake = _FakeConnection(response=b"\xff\x83\xff\x03") + device._connection = fake + + with pytest.raises(NfcWriteError) as exc_info: + await device.write_nfc(NfcRecordType.TEXT, b"hello") + assert exc_info.value.error_code == 3 + + +@pytest.mark.asyncio +async def test_write_nfc_mid_stream_chunk_nack_aborts() -> None: + """A NACK mid-stream should abort immediately with no further writes.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + payload = bytes(range(121)) + fake = _FakeConnection(response=[b"\x00\x83\x82", b"\xff\x83\xff\x08"]) + device._connection = fake + + with pytest.raises(NfcWriteError) as exc_info: + await device.write_nfc(NfcRecordType.URI, payload) + assert exc_info.value.error_code == 8 + # start + first data chunk only; second data chunk and end never sent + assert len(fake.written) == 2 + + +@pytest.mark.asyncio +async def test_write_nfc_stage_ack_where_ok_expected_raises() -> None: + """A chunk-ack status where the final OK status is expected should raise InvalidResponseError.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + fake = _FakeConnection(response=b"\x00\x83\x82") + device._connection = fake + + with pytest.raises(InvalidResponseError): + await device.write_nfc(NfcRecordType.TEXT, b"hello") + + +@pytest.mark.asyncio +async def test_write_nfc_read_timeout_on_first_read_raises_not_supported() -> None: + """A BLE read timeout on the first read of a write sequence should map to NfcNotSupportedError.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + fake = _FakeConnection(response=b"\x00\x83\x81", timeout_on=1) + device._connection = fake + + with pytest.raises(NfcNotSupportedError): + await device.write_nfc(NfcRecordType.TEXT, b"hello") + + +@pytest.mark.asyncio +async def test_write_nfc_read_timeout_on_later_read_propagates() -> None: + """A BLE read timeout on a later read (not the first) should propagate as BLETimeoutError.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + payload = bytes(range(121)) + fake = _FakeConnection(response=[b"\x00\x83\x82", b"\x00\x83\x82"], timeout_on=3) + device._connection = fake + + with pytest.raises(BLETimeoutError): + await device.write_nfc(NfcRecordType.URI, payload) + + +@pytest.mark.asyncio +async def test_write_nfc_url_encodes_utf8_and_uses_uri_record_type() -> None: + """write_nfc_url should send the URL verbatim as UTF-8 with record type URI.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + fake = _FakeConnection(response=b"\x00\x83\x81") + device._connection = fake + + await device.write_nfc_url("https://example.com") + + payload = "https://example.com".encode("utf-8") + expected_cmd = b"\x00\x83" + bytes([0x01, int(NfcRecordType.URI)]) + len(payload).to_bytes(2, "big") + payload + assert fake.written == [expected_cmd] + + +@pytest.mark.asyncio +async def test_write_nfc_text_encodes_utf8_and_uses_text_record_type() -> None: + """write_nfc_text should send the text verbatim as UTF-8 with record type TEXT.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + fake = _FakeConnection(response=b"\x00\x83\x81") + device._connection = fake + + await device.write_nfc_text("hello world") + + payload = "hello world".encode("utf-8") + expected_cmd = b"\x00\x83" + bytes([0x01, int(NfcRecordType.TEXT)]) + len(payload).to_bytes(2, "big") + payload + assert fake.written == [expected_cmd] + + +@pytest.mark.asyncio +async def test_write_nfc_mime_builds_length_prefixed_payload() -> None: + """write_nfc_mime should prefix the payload with a one-byte MIME type length.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + fake = _FakeConnection(response=b"\x00\x83\x81") + device._connection = fake + + await device.write_nfc_mime("text/vcard", b"BEGIN:VCARD") + + body_prefix = b"\x0atext/vcard" + payload = body_prefix + b"BEGIN:VCARD" + expected_cmd = b"\x00\x83" + bytes([0x01, int(NfcRecordType.MIME)]) + len(payload).to_bytes(2, "big") + payload + assert fake.written == [expected_cmd] + + +@pytest.mark.asyncio +async def test_write_nfc_mime_type_too_long_rejected() -> None: + """A MIME type longer than 255 bytes should raise ValueError.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + fake = _FakeConnection(response=b"\x00\x83\x81") + device._connection = fake + + with pytest.raises(ValueError): + await device.write_nfc_mime("x" * 256, b"body") + + +@pytest.mark.asyncio +async def test_write_nfc_inline_happy_path_encrypted_session() -> None: + """An encrypted OK frame from the device must be decrypted and normalized correctly. + + _read() decrypts an encrypted response and returns cmd_code.to_bytes(2, "big") + payload, + i.e. exactly the same shape as the plaintext ack (b"\\x00\\x83\\x81"). This locks in that + normalization contract for the encrypted-session code path, which no other test exercises. + """ + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + session_key = bytes(range(16)) + session_id = bytes(range(8)) + device._session_key = session_key + device._session_id = session_id + device._nonce_counter = 0 + + # Build a genuinely encrypted device-to-host OK response using the library's own + # encrypt routine. The wire format (cmd(2) + nonce(16) + ciphertext + tag(12)) is + # identical in both directions, as proven by TestEncryptDecryptCommand.test_round_trip + # in test_crypto.py, so reusing encrypt_command here is not a fragile reimplementation. + encrypted_ok = encrypt_command(session_key, session_id, counter=1, cmd=b"\x00\x83", payload=b"\x81") + + fake = _FakeConnection(response=encrypted_ok) + device._connection = fake + + await device.write_nfc(NfcRecordType.TEXT, b"hello") + + # _write() also encrypts outgoing frames once a session key is set, so the frame + # recorded here is ciphertext too; what this test locks in is that the encrypted + # *response* was accepted and decrypted without error. + assert len(fake.written) == 1 + + +@pytest.mark.asyncio +async def test_write_nfc_error_frame_plaintext_during_encrypted_session() -> None: + """A plaintext firmware error frame must still raise NfcWriteError even with a session key set. + + Error frames {0xFF, 0x83, 0xFF, err} are only 4 bytes -- shorter than the 31-byte + encrypted-frame minimum -- so the firmware sends them unencrypted even mid-session, + and _read() must not attempt to decrypt them. + """ + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + device._session_key = bytes(range(16)) + device._session_id = bytes(range(8)) + device._nonce_counter = 0 + + fake = _FakeConnection(response=b"\xff\x83\xff\x03") + device._connection = fake + + with pytest.raises(NfcWriteError) as exc_info: + await device.write_nfc(NfcRecordType.TEXT, b"hello") + assert exc_info.value.error_code == 3 + + +@pytest.mark.asyncio +async def test_write_nfc_requires_connection() -> None: + """write_nfc should raise RuntimeError when not connected.""" + device = OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") + + with pytest.raises(RuntimeError, match="not connected"): + await device.write_nfc(NfcRecordType.TEXT, b"hello") diff --git a/tests/unit/test_protocol_commands.py b/tests/unit/test_protocol_commands.py index 92463b9..0461f92 100644 --- a/tests/unit/test_protocol_commands.py +++ b/tests/unit/test_protocol_commands.py @@ -1,10 +1,19 @@ import pytest from opendisplay.models.buzzer_activate import BuzzerActivateConfig +from opendisplay.models.enums import NfcRecordType from opendisplay.models.led_flash import LedFlashConfig, LedFlashStep from opendisplay.protocol.commands import ( CHUNK_SIZE, CONFIG_CHUNK_SIZE, + NFC_CHUNK_SIZE, + NFC_INLINE_MAX, + NFC_SUB_READ, + NFC_SUB_WRITE_DATA, + NFC_SUB_WRITE_END, + NFC_SUB_WRITE_INLINE, + NFC_SUB_WRITE_START, + NFC_WRITE_MAX_TOTAL, CommandCode, build_buzzer_activate_command, build_deep_sleep_command, @@ -13,6 +22,10 @@ build_direct_write_start_compressed, build_direct_write_start_uncompressed, build_led_activate_command, + build_nfc_write_data_command, + build_nfc_write_end_command, + build_nfc_write_inline_command, + build_nfc_write_start_command, build_read_config_command, build_read_fw_version_command, build_reboot_command, @@ -187,6 +200,7 @@ def test_command_code_values(self): assert CommandCode.DIRECT_WRITE_END == 0x0072 assert CommandCode.LED_ACTIVATE == 0x0073 assert CommandCode.BUZZER_ACTIVATE == 0x0077 + assert CommandCode.NFC_ENDPOINT == 0x0083 def test_command_code_to_bytes(self): """Test command codes convert to correct big-endian bytes.""" @@ -263,3 +277,123 @@ def test_chunk_count_matches_ceil_total_over_200(self): first, chunks = build_write_config_command(data) # first chunk (200) + continuations (200 each) == ceil(total/200) chunks assert 1 + len(chunks) == math.ceil(total / CONFIG_CHUNK_SIZE) + + +class TestNfcSubcommandConstants: + """Sub-opcode and size constants for the NFC endpoint (command 0x0083).""" + + def test_subcommand_values(self): + assert NFC_SUB_READ == 0x00 + assert NFC_SUB_WRITE_INLINE == 0x01 + assert NFC_SUB_WRITE_START == 0x10 + assert NFC_SUB_WRITE_DATA == 0x11 + assert NFC_SUB_WRITE_END == 0x12 + + def test_size_constants(self): + assert NFC_INLINE_MAX == 120 + assert NFC_CHUNK_SIZE == 120 + assert NFC_WRITE_MAX_TOTAL == 512 + + +class TestNfcRecordType: + """NfcRecordType enum values (used with the 0x0083 NFC endpoint).""" + + def test_values(self): + assert NfcRecordType.TEXT == 0 + assert NfcRecordType.URI == 1 + assert NfcRecordType.WELL_KNOWN_RAW == 2 + assert NfcRecordType.MIME == 3 + assert NfcRecordType.RAW_NDEF == 4 + + +class TestBuildNfcWriteInlineCommand: + """build_nfc_write_inline_command wire format: [0x00][0x83][0x01][rec_type:1][len:2 BE][payload].""" + + def test_wire_format(self): + cmd = build_nfc_write_inline_command(NfcRecordType.URI, b"https://x") + assert cmd == b"\x00\x83\x01\x01\x00\x09https://x" + + def test_accepts_plain_int_rec_type(self): + cmd = build_nfc_write_inline_command(0, b"hi") + assert cmd == b"\x00\x83\x01\x00\x00\x02hi" + + def test_empty_payload_raises(self): + with pytest.raises(ValueError): + build_nfc_write_inline_command(NfcRecordType.TEXT, b"") + + def test_payload_over_u16_limit_raises(self): + with pytest.raises(ValueError): + build_nfc_write_inline_command(NfcRecordType.TEXT, b"a" * 0x10000) + + def test_payload_at_u16_limit_is_accepted(self): + payload = b"a" * 0xFFFF + cmd = build_nfc_write_inline_command(NfcRecordType.TEXT, payload) + assert cmd[4:6] == (0xFFFF).to_bytes(2, "big") + + def test_rec_type_below_range_raises(self): + with pytest.raises(ValueError): + build_nfc_write_inline_command(-1, b"x") + + def test_rec_type_above_range_raises(self): + with pytest.raises(ValueError): + build_nfc_write_inline_command(256, b"x") + + +class TestBuildNfcWriteStartCommand: + """build_nfc_write_start_command wire format: [0x00][0x83][0x10][rec_type:1][total_len:2 BE].""" + + def test_wire_format(self): + cmd = build_nfc_write_start_command(3, 300) + assert cmd == b"\x00\x83\x10\x03\x01\x2c" + + def test_total_len_zero_raises(self): + with pytest.raises(ValueError): + build_nfc_write_start_command(0, 0) + + def test_total_len_over_max_raises(self): + with pytest.raises(ValueError): + build_nfc_write_start_command(0, NFC_WRITE_MAX_TOTAL + 1) + + def test_total_len_at_max_is_accepted(self): + cmd = build_nfc_write_start_command(0, NFC_WRITE_MAX_TOTAL) + assert cmd[4:6] == NFC_WRITE_MAX_TOTAL.to_bytes(2, "big") + + def test_total_len_at_one_is_accepted(self): + cmd = build_nfc_write_start_command(0, 1) + assert cmd[4:6] == (1).to_bytes(2, "big") + + def test_rec_type_below_range_raises(self): + with pytest.raises(ValueError): + build_nfc_write_start_command(-1, 10) + + def test_rec_type_above_range_raises(self): + with pytest.raises(ValueError): + build_nfc_write_start_command(256, 10) + + +class TestBuildNfcWriteDataCommand: + """build_nfc_write_data_command wire format: [0x00][0x83][0x11][bytes].""" + + def test_wire_format(self): + cmd = build_nfc_write_data_command(b"\x01\x02") + assert cmd == b"\x00\x83\x11\x01\x02" + + def test_empty_chunk_raises(self): + with pytest.raises(ValueError): + build_nfc_write_data_command(b"") + + def test_chunk_over_max_raises(self): + with pytest.raises(ValueError): + build_nfc_write_data_command(b"a" * (NFC_CHUNK_SIZE + 1)) + + def test_chunk_at_max_is_accepted(self): + chunk = b"a" * NFC_CHUNK_SIZE + cmd = build_nfc_write_data_command(chunk) + assert cmd == b"\x00\x83\x11" + chunk + + +class TestBuildNfcWriteEndCommand: + """build_nfc_write_end_command wire format: [0x00][0x83][0x12].""" + + def test_wire_format(self): + assert build_nfc_write_end_command() == b"\x00\x83\x12" diff --git a/tests/unit/test_protocol_responses.py b/tests/unit/test_protocol_responses.py index ab6a406..794efef 100644 --- a/tests/unit/test_protocol_responses.py +++ b/tests/unit/test_protocol_responses.py @@ -1,15 +1,21 @@ """Test protocol response parsing.""" +import re + import pytest -from opendisplay.exceptions import InvalidResponseError +from opendisplay.exceptions import InvalidResponseError, NfcNotSupportedError, NfcWriteError, ProtocolError from opendisplay.protocol.commands import CommandCode from opendisplay.protocol.responses import ( + NFC_ERROR_MESSAGES, + NFC_STATUS_CHUNK_ACK, + NFC_STATUS_WRITE_OK, check_response_type, parse_firmware_version, strip_command_echo, unpack_command_code, validate_ack_response, + validate_nfc_response, ) @@ -215,3 +221,97 @@ def test_parse_firmware_version_missing_sha(self): with pytest.raises(InvalidResponseError, match="missing SHA hash"): parse_firmware_version(data) + + +class TestValidateNfcResponse: + """Test validate_nfc_response() for the NFC_ENDPOINT (0x0083) command.""" + + def test_write_ok_status(self): + """Test {0x00, 0x83, 0x81} (inline write / chunk end) is accepted.""" + data = bytes([0x00, 0x83, NFC_STATUS_WRITE_OK]) + assert validate_nfc_response(data, NFC_STATUS_WRITE_OK) is None + + def test_chunk_ack_status(self): + """Test {0x00, 0x83, 0x82} (chunk start / chunk data ACK) is accepted.""" + data = bytes([0x00, 0x83, NFC_STATUS_CHUNK_ACK]) + assert validate_nfc_response(data, NFC_STATUS_CHUNK_ACK) is None + + def test_wrong_status_raises_invalid_response(self): + """Test a well-formed OK frame with a status the caller didn't expect.""" + data = bytes([0x00, 0x83, NFC_STATUS_CHUNK_ACK]) + with pytest.raises(InvalidResponseError): + validate_nfc_response(data, NFC_STATUS_WRITE_OK) + + @pytest.mark.parametrize("error_code", sorted(NFC_ERROR_MESSAGES)) + def test_error_frame_raises_nfc_write_error(self, error_code): + """Test each documented error code maps to its message and is carried on the exception.""" + data = bytes([0xFF, 0x83, 0xFF, error_code]) + with pytest.raises(NfcWriteError, match=re.escape(NFC_ERROR_MESSAGES[error_code])) as exc_info: + validate_nfc_response(data, NFC_STATUS_WRITE_OK) + assert exc_info.value.error_code == error_code + + def test_error_frame_unmapped_code(self): + """Test an error code outside the documented table still raises with the code attached.""" + data = bytes([0xFF, 0x83, 0xFF, 0x99]) + with pytest.raises(NfcWriteError) as exc_info: + validate_nfc_response(data, NFC_STATUS_WRITE_OK) + assert exc_info.value.error_code == 0x99 + + def test_short_frame_raises_invalid_response(self): + """Test a frame too short to be a valid response raises InvalidResponseError.""" + data = bytes([0x00, 0x83]) + with pytest.raises(InvalidResponseError): + validate_nfc_response(data, NFC_STATUS_WRITE_OK) + + def test_empty_frame_raises_invalid_response(self): + """Test an empty response raises InvalidResponseError.""" + with pytest.raises(InvalidResponseError): + validate_nfc_response(b"", NFC_STATUS_WRITE_OK) + + def test_garbage_frame_raises_invalid_response(self): + """Test a frame that is neither the expected OK shape nor the error shape.""" + data = bytes([0x12, 0x34, 0x56]) + with pytest.raises(InvalidResponseError): + validate_nfc_response(data, NFC_STATUS_WRITE_OK) + + def test_short_error_frame_raises_invalid_response(self): + """Test a truncated error frame (missing the error code byte) is not silently accepted.""" + data = bytes([0xFF, 0x83, 0xFF]) + with pytest.raises(InvalidResponseError): + validate_nfc_response(data, NFC_STATUS_WRITE_OK) + + def test_all_error_codes_documented(self): + """Test the error table covers the firmware's documented codes 1-9.""" + assert set(NFC_ERROR_MESSAGES) == set(range(1, 10)) + + +class TestNfcExceptions: + """Test the NFC-specific exception types added alongside validate_nfc_response().""" + + def test_nfc_write_error_carries_code(self): + """Test NfcWriteError exposes the firmware error_code attribute.""" + exc = NfcWriteError("NFC write failed", error_code=3) + assert exc.error_code == 3 + assert str(exc) == "NFC write failed" + + def test_nfc_write_error_defaults_code_to_none(self): + """Test error_code defaults to None when not supplied.""" + exc = NfcWriteError("NFC write failed") + assert exc.error_code is None + + def test_nfc_write_error_is_protocol_error(self): + """Test NfcWriteError fits the existing ProtocolError hierarchy.""" + assert issubclass(NfcWriteError, ProtocolError) + + def test_nfc_not_supported_error_is_protocol_error(self): + """Test NfcNotSupportedError fits the existing ProtocolError hierarchy.""" + assert issubclass(NfcNotSupportedError, ProtocolError) + + def test_nfc_not_supported_error_hedges_in_message(self): + """Test the default guidance hedges rather than asserting firmware definitely lacks NFC. + + Pre-0x83 firmware stays silent on unknown opcodes, so a missing response + is inconclusive (could be a dropped packet); the message must not overclaim. + """ + exc = NfcNotSupportedError() + assert "may not support" in str(exc).lower()