utils.nfc

NFC utilities for PN532 tag reading/writing over UART (HSU protocol).

 1"""NFC utilities for PN532 tag reading/writing over UART (HSU protocol)."""
 2
 3from utils.nfc.nfc_reader import NfcReader, NfcTagData  # noqa: F401
 4from utils.nfc.nfc_reader import parse_ndef_text_records, parse_ndef_uri_records  # noqa: F401,E501
 5from utils.nfc.nfc_serial_transport import NfcSerialTransport  # noqa: F401
 6from utils.nfc.nfc_writer import build_battery_ndef, encode_ndef_text_record  # noqa: F401,E501
 7from utils.nfc.nfc_writer import encode_ndef_uri_record, wrap_ndef_message  # noqa: F401,E501
 8
 9__all__ = [
10    "NfcReader", "NfcTagData", "NfcSerialTransport",
11    "parse_ndef_text_records", "parse_ndef_uri_records",
12    "build_battery_ndef", "encode_ndef_text_record",
13    "encode_ndef_uri_record", "wrap_ndef_message",
14]
class NfcReader:
207class NfcReader:
208    """PN532 NFC reader using a duck-typed transport.
209
210    The transport must implement:
211        - is_open: bool property
212        - write_command(data: bytes) -> bool
213        - read_response() -> Optional[bytes]
214    """
215
216    def __init__(self, transport):
217        self._transport = transport
218        self._initialized = False
219
220    @property
221    def is_ready(self) -> bool:
222        return self._transport.is_open and self._initialized
223
224    def begin(self) -> bool:
225        """Initialize the PN532 with SAMConfig (normal mode).
226
227        Returns True if the PN532 responded correctly.
228        """
229        if not self._transport.is_open:
230            return False
231
232        # SAMConfig: normal mode (0x01), timeout 0, no IRQ
233        if not self._transport.write_command(bytes([_CMD_SAM_CONFIG, 0x01, 0x00, 0x00])):
234            logger.warning("NFC SAMConfig: no ACK")
235            return False
236
237        resp = self._transport.read_response()
238        if resp is None or len(resp) < 1 or resp[0] != _RESP_SAM_CONFIG:
239            logger.warning("NFC SAMConfig: bad response")
240            return False
241
242        self._initialized = True
243        logger.info("NFC reader initialized")
244        return True
245
246    def read_passive_target(self) -> Optional[tuple]:
247        """Poll for an ISO14443A tag and return (target_num, uid), or None.
248
249        Returns a tuple of (target_number, uid_bytes) where target_number
250        is needed for subsequent InDataExchange commands.
251        """
252        if not self.is_ready:
253            return None
254
255        # InListPassiveTarget: 1 target, ISO14443A
256        cmd = bytes([_CMD_IN_LIST_PASSIVE_TARGET, 0x01, _MIFARE_ISO14443A])
257        if not self._transport.write_command(cmd):
258            return None
259
260        resp = self._transport.read_response()
261        if resp is None or len(resp) < 2:
262            return None
263
264        if resp[0] != _RESP_IN_LIST_PASSIVE_TARGET:
265            return None
266
267        num_targets = resp[1]
268        if num_targets == 0:
269            return None
270
271        # Parse target data: [tg, sens_res(2), sel_res, nfcid_len, nfcid...]
272        if len(resp) < 7:
273            return None
274
275        tg = resp[2]  # target number assigned by PN532
276        nfcid_len = resp[6]
277        if len(resp) < 7 + nfcid_len:
278            return None
279
280        uid = bytes(resp[7:7 + nfcid_len])
281        logger.debug("NFC target %d detected, UID: %s", tg, uid.hex().upper())
282        return (tg, uid)
283
284    def read_ntag_pages(self, tg: int, start_page: int,
285                        num_pages: int) -> Optional[bytes]:
286        """Read NTAG user data pages.
287
288        Each NTAG READ command returns 4 pages (16 bytes). This method
289        issues multiple reads to cover the requested page range.
290
291        Args:
292            tg: Target number from read_passive_target().
293            start_page: First page number to read (NTAG pages are 4 bytes each).
294            num_pages: Number of pages to read.
295
296        Returns:
297            Concatenated page data, or None on failure.
298        """
299        if not self.is_ready:
300            return None
301
302        result = bytearray()
303        page = start_page
304        remaining = num_pages
305
306        while remaining > 0:
307            data = self._read_4_pages(tg, page)
308            if data is None:
309                return None
310
311            # Each read returns 16 bytes (4 pages). Take only what we need.
312            pages_in_read = min(4, remaining)
313            result.extend(data[:pages_in_read * 4])
314            page += 4
315            remaining -= pages_in_read
316
317        return bytes(result)
318
319    def read_full_tag(self, user_data_start: int = 4,
320                      user_data_pages: int = 36) -> Optional[NfcTagData]:
321        """Read a tag's UID and user data in one operation.
322
323        The UID detection and page reads use the same target listing so
324        the tag must remain on the reader for the entire operation.
325
326        Args:
327            user_data_start: First user data page (default 4 for NTAG21x).
328            user_data_pages: Number of user data pages to read (default 36
329                for NTAG213; use 128 for NTAG215, 226 for NTAG216).
330
331        Returns:
332            NfcTagData with UID and user data, or None on failure.
333        """
334        result = self.read_passive_target()
335        if result is None:
336            return None
337
338        tg, uid = result
339        user_data = self.read_ntag_pages(tg, user_data_start, user_data_pages)
340        if user_data is None:
341            # Tag detected but read failed — caller may want to retry
342            logger.debug("NFC tag %s listed but page read failed", uid.hex().upper())
343            return NfcTagData(uid=uid, user_data=b"")
344
345        return NfcTagData(uid=uid, user_data=user_data)
346
347    def write_ntag_page(self, tg: int, page: int,
348                        data: bytes) -> bool:
349        """Write a single NTAG page (4 bytes).
350
351        Args:
352            tg: Target number from read_passive_target().
353            page: Page number to write.
354            data: Exactly 4 bytes of page data.
355
356        Returns:
357            True on success, False on failure.
358        """
359        if not self.is_ready:
360            return False
361        if len(data) != 4:
362            logger.error("NTAG write: data must be exactly 4 bytes, "
363                         "got %d", len(data))
364            return False
365
366        cmd = bytes([_CMD_IN_DATA_EXCHANGE, tg & 0xFF,
367                     _NTAG_CMD_WRITE, page & 0xFF]) + data
368        if not self._transport.write_command(cmd):
369            logger.debug("NFC page write: no ACK for page %d", page)
370            return False
371
372        resp = self._transport.read_response()
373        if resp is None or len(resp) < 2:
374            logger.debug("NFC page write: no/short response for page %d",
375                         page)
376            return False
377
378        if resp[0] != _RESP_IN_DATA_EXCHANGE:
379            logger.debug("NFC page write: unexpected response code 0x%02X",
380                         resp[0])
381            return False
382
383        if resp[1] != 0x00:
384            logger.debug("NFC page write error at page %d: status 0x%02X",
385                         page, resp[1])
386            return False
387
388        return True
389
390    def write_ntag_pages(self, tg: int, start_page: int,
391                         data: bytes) -> bool:
392        """Write data to consecutive NTAG pages.
393
394        Data is padded to a 4-byte boundary with zeros. Each page (4 bytes)
395        is written individually.
396
397        Args:
398            tg: Target number from read_passive_target().
399            start_page: First page to write.
400            data: Data to write (will be zero-padded to 4-byte boundary).
401
402        Returns:
403            True if all pages written successfully, False on first failure.
404        """
405        if not self.is_ready:
406            return False
407
408        # Pad to 4-byte boundary
409        padded = bytearray(data)
410        while len(padded) % 4 != 0:
411            padded.append(0x00)
412
413        for i in range(0, len(padded), 4):
414            page = start_page + (i // 4)
415            page_data = bytes(padded[i:i + 4])
416            if not self.write_ntag_page(tg, page, page_data):
417                logger.error("NFC write failed at page %d", page)
418                return False
419            logger.debug("NFC wrote page %d: %s", page,
420                         page_data.hex(' '))
421            # NTAG needs ~5ms per write; add brief delay for reliability
422            time.sleep(0.01)
423
424        return True
425
426    def _read_4_pages(self, tg: int, page: int) -> Optional[bytes]:
427        """Issue a single NTAG READ for 4 pages (16 bytes) starting at page."""
428        # InDataExchange: target number, NTAG/Ultralight READ cmd, page address
429        cmd = bytes([_CMD_IN_DATA_EXCHANGE, tg & 0xFF, _NTAG_CMD_READ, page & 0xFF])
430        if not self._transport.write_command(cmd):
431            logger.debug("NFC page read: no ACK for page %d", page)
432            return None
433
434        resp = self._transport.read_response()
435        if resp is None or len(resp) < 2:
436            logger.debug("NFC page read: no/short response for page %d (got %s)",
437                         page, resp.hex(' ') if resp else 'None')
438            return None
439
440        logger.debug("NFC page %d raw response (%d bytes): %s",
441                     page, len(resp), resp.hex(' '))
442
443        if resp[0] != _RESP_IN_DATA_EXCHANGE:
444            logger.debug("NFC page read: unexpected response code 0x%02X", resp[0])
445            return None
446
447        # First byte after command code is status (0x00 = success)
448        status = resp[1]
449        if status != 0x00:
450            logger.debug("NFC page read error at page %d: status 0x%02X", page, status)
451            return None
452
453        # Remaining bytes are the 16 bytes of page data
454        if len(resp) < 18:  # 1 cmd + 1 status + 16 data
455            logger.debug("NFC page read: short response (%d bytes) for page %d",
456                         len(resp), page)
457            return None
458
459        return bytes(resp[2:18])

PN532 NFC reader using a duck-typed transport.

The transport must implement: - is_open: bool property - write_command(data: bytes) -> bool - read_response() -> Optional[bytes]

NfcReader(transport)
216    def __init__(self, transport):
217        self._transport = transport
218        self._initialized = False
is_ready: bool
220    @property
221    def is_ready(self) -> bool:
222        return self._transport.is_open and self._initialized
def begin(self) -> bool:
224    def begin(self) -> bool:
225        """Initialize the PN532 with SAMConfig (normal mode).
226
227        Returns True if the PN532 responded correctly.
228        """
229        if not self._transport.is_open:
230            return False
231
232        # SAMConfig: normal mode (0x01), timeout 0, no IRQ
233        if not self._transport.write_command(bytes([_CMD_SAM_CONFIG, 0x01, 0x00, 0x00])):
234            logger.warning("NFC SAMConfig: no ACK")
235            return False
236
237        resp = self._transport.read_response()
238        if resp is None or len(resp) < 1 or resp[0] != _RESP_SAM_CONFIG:
239            logger.warning("NFC SAMConfig: bad response")
240            return False
241
242        self._initialized = True
243        logger.info("NFC reader initialized")
244        return True

Initialize the PN532 with SAMConfig (normal mode).

Returns True if the PN532 responded correctly.

def read_passive_target(self) -> tuple | None:
246    def read_passive_target(self) -> Optional[tuple]:
247        """Poll for an ISO14443A tag and return (target_num, uid), or None.
248
249        Returns a tuple of (target_number, uid_bytes) where target_number
250        is needed for subsequent InDataExchange commands.
251        """
252        if not self.is_ready:
253            return None
254
255        # InListPassiveTarget: 1 target, ISO14443A
256        cmd = bytes([_CMD_IN_LIST_PASSIVE_TARGET, 0x01, _MIFARE_ISO14443A])
257        if not self._transport.write_command(cmd):
258            return None
259
260        resp = self._transport.read_response()
261        if resp is None or len(resp) < 2:
262            return None
263
264        if resp[0] != _RESP_IN_LIST_PASSIVE_TARGET:
265            return None
266
267        num_targets = resp[1]
268        if num_targets == 0:
269            return None
270
271        # Parse target data: [tg, sens_res(2), sel_res, nfcid_len, nfcid...]
272        if len(resp) < 7:
273            return None
274
275        tg = resp[2]  # target number assigned by PN532
276        nfcid_len = resp[6]
277        if len(resp) < 7 + nfcid_len:
278            return None
279
280        uid = bytes(resp[7:7 + nfcid_len])
281        logger.debug("NFC target %d detected, UID: %s", tg, uid.hex().upper())
282        return (tg, uid)

Poll for an ISO14443A tag and return (target_num, uid), or None.

Returns a tuple of (target_number, uid_bytes) where target_number is needed for subsequent InDataExchange commands.

def read_ntag_pages(self, tg: int, start_page: int, num_pages: int) -> bytes | None:
284    def read_ntag_pages(self, tg: int, start_page: int,
285                        num_pages: int) -> Optional[bytes]:
286        """Read NTAG user data pages.
287
288        Each NTAG READ command returns 4 pages (16 bytes). This method
289        issues multiple reads to cover the requested page range.
290
291        Args:
292            tg: Target number from read_passive_target().
293            start_page: First page number to read (NTAG pages are 4 bytes each).
294            num_pages: Number of pages to read.
295
296        Returns:
297            Concatenated page data, or None on failure.
298        """
299        if not self.is_ready:
300            return None
301
302        result = bytearray()
303        page = start_page
304        remaining = num_pages
305
306        while remaining > 0:
307            data = self._read_4_pages(tg, page)
308            if data is None:
309                return None
310
311            # Each read returns 16 bytes (4 pages). Take only what we need.
312            pages_in_read = min(4, remaining)
313            result.extend(data[:pages_in_read * 4])
314            page += 4
315            remaining -= pages_in_read
316
317        return bytes(result)

Read NTAG user data pages.

Each NTAG READ command returns 4 pages (16 bytes). This method issues multiple reads to cover the requested page range.

Args: tg: Target number from read_passive_target(). start_page: First page number to read (NTAG pages are 4 bytes each). num_pages: Number of pages to read.

Returns: Concatenated page data, or None on failure.

def read_full_tag( self, user_data_start: int = 4, user_data_pages: int = 36) -> NfcTagData | None:
319    def read_full_tag(self, user_data_start: int = 4,
320                      user_data_pages: int = 36) -> Optional[NfcTagData]:
321        """Read a tag's UID and user data in one operation.
322
323        The UID detection and page reads use the same target listing so
324        the tag must remain on the reader for the entire operation.
325
326        Args:
327            user_data_start: First user data page (default 4 for NTAG21x).
328            user_data_pages: Number of user data pages to read (default 36
329                for NTAG213; use 128 for NTAG215, 226 for NTAG216).
330
331        Returns:
332            NfcTagData with UID and user data, or None on failure.
333        """
334        result = self.read_passive_target()
335        if result is None:
336            return None
337
338        tg, uid = result
339        user_data = self.read_ntag_pages(tg, user_data_start, user_data_pages)
340        if user_data is None:
341            # Tag detected but read failed — caller may want to retry
342            logger.debug("NFC tag %s listed but page read failed", uid.hex().upper())
343            return NfcTagData(uid=uid, user_data=b"")
344
345        return NfcTagData(uid=uid, user_data=user_data)

Read a tag's UID and user data in one operation.

The UID detection and page reads use the same target listing so the tag must remain on the reader for the entire operation.

Args: user_data_start: First user data page (default 4 for NTAG21x). user_data_pages: Number of user data pages to read (default 36 for NTAG213; use 128 for NTAG215, 226 for NTAG216).

Returns: NfcTagData with UID and user data, or None on failure.

def write_ntag_page(self, tg: int, page: int, data: bytes) -> bool:
347    def write_ntag_page(self, tg: int, page: int,
348                        data: bytes) -> bool:
349        """Write a single NTAG page (4 bytes).
350
351        Args:
352            tg: Target number from read_passive_target().
353            page: Page number to write.
354            data: Exactly 4 bytes of page data.
355
356        Returns:
357            True on success, False on failure.
358        """
359        if not self.is_ready:
360            return False
361        if len(data) != 4:
362            logger.error("NTAG write: data must be exactly 4 bytes, "
363                         "got %d", len(data))
364            return False
365
366        cmd = bytes([_CMD_IN_DATA_EXCHANGE, tg & 0xFF,
367                     _NTAG_CMD_WRITE, page & 0xFF]) + data
368        if not self._transport.write_command(cmd):
369            logger.debug("NFC page write: no ACK for page %d", page)
370            return False
371
372        resp = self._transport.read_response()
373        if resp is None or len(resp) < 2:
374            logger.debug("NFC page write: no/short response for page %d",
375                         page)
376            return False
377
378        if resp[0] != _RESP_IN_DATA_EXCHANGE:
379            logger.debug("NFC page write: unexpected response code 0x%02X",
380                         resp[0])
381            return False
382
383        if resp[1] != 0x00:
384            logger.debug("NFC page write error at page %d: status 0x%02X",
385                         page, resp[1])
386            return False
387
388        return True

Write a single NTAG page (4 bytes).

Args: tg: Target number from read_passive_target(). page: Page number to write. data: Exactly 4 bytes of page data.

Returns: True on success, False on failure.

def write_ntag_pages(self, tg: int, start_page: int, data: bytes) -> bool:
390    def write_ntag_pages(self, tg: int, start_page: int,
391                         data: bytes) -> bool:
392        """Write data to consecutive NTAG pages.
393
394        Data is padded to a 4-byte boundary with zeros. Each page (4 bytes)
395        is written individually.
396
397        Args:
398            tg: Target number from read_passive_target().
399            start_page: First page to write.
400            data: Data to write (will be zero-padded to 4-byte boundary).
401
402        Returns:
403            True if all pages written successfully, False on first failure.
404        """
405        if not self.is_ready:
406            return False
407
408        # Pad to 4-byte boundary
409        padded = bytearray(data)
410        while len(padded) % 4 != 0:
411            padded.append(0x00)
412
413        for i in range(0, len(padded), 4):
414            page = start_page + (i // 4)
415            page_data = bytes(padded[i:i + 4])
416            if not self.write_ntag_page(tg, page, page_data):
417                logger.error("NFC write failed at page %d", page)
418                return False
419            logger.debug("NFC wrote page %d: %s", page,
420                         page_data.hex(' '))
421            # NTAG needs ~5ms per write; add brief delay for reliability
422            time.sleep(0.01)
423
424        return True

Write data to consecutive NTAG pages.

Data is padded to a 4-byte boundary with zeros. Each page (4 bytes) is written individually.

Args: tg: Target number from read_passive_target(). start_page: First page to write. data: Data to write (will be zero-padded to 4-byte boundary).

Returns: True if all pages written successfully, False on first failure.

class NfcTagData:
161class NfcTagData:
162    """Data read from an NFC tag."""
163
164    def __init__(self, uid: bytes, user_data: bytes = b""):
165        self.uid = uid
166        self.user_data = user_data
167
168    @property
169    def uid_hex(self) -> str:
170        return self.uid.hex().upper()
171
172    def get_text_records(self) -> list:
173        """Parse NDEF text records from user data.
174
175        Returns a list of text strings found in NDEF Text records.
176        """
177        return parse_ndef_text_records(self.user_data)
178
179    def get_text(self) -> str:
180        """Get all NDEF text records joined, or raw decode as fallback."""
181        records = self.get_text_records()
182        if records:
183            return '\n'.join(records)
184        # Fallback: raw decode
185        try:
186            return self.user_data.rstrip(b'\x00').decode('utf-8',
187                                                         errors='replace')
188        except Exception:
189            return ""
190
191    def get_uri_records(self) -> list:
192        """Parse NDEF URI records from user data.
193
194        Returns a list of URI strings.
195        """
196        return parse_ndef_uri_records(self.user_data)
197
198    def get_uri(self) -> str:
199        """Get the first NDEF URI record, or empty string."""
200        uris = self.get_uri_records()
201        return uris[0] if uris else ""
202
203    def __repr__(self):
204        return f"NfcTagData(uid={self.uid_hex}, data_len={len(self.user_data)})"

Data read from an NFC tag.

NfcTagData(uid: bytes, user_data: bytes = b'')
164    def __init__(self, uid: bytes, user_data: bytes = b""):
165        self.uid = uid
166        self.user_data = user_data
uid
user_data
uid_hex: str
168    @property
169    def uid_hex(self) -> str:
170        return self.uid.hex().upper()
def get_text_records(self) -> list:
172    def get_text_records(self) -> list:
173        """Parse NDEF text records from user data.
174
175        Returns a list of text strings found in NDEF Text records.
176        """
177        return parse_ndef_text_records(self.user_data)

Parse NDEF text records from user data.

Returns a list of text strings found in NDEF Text records.

def get_text(self) -> str:
179    def get_text(self) -> str:
180        """Get all NDEF text records joined, or raw decode as fallback."""
181        records = self.get_text_records()
182        if records:
183            return '\n'.join(records)
184        # Fallback: raw decode
185        try:
186            return self.user_data.rstrip(b'\x00').decode('utf-8',
187                                                         errors='replace')
188        except Exception:
189            return ""

Get all NDEF text records joined, or raw decode as fallback.

def get_uri_records(self) -> list:
191    def get_uri_records(self) -> list:
192        """Parse NDEF URI records from user data.
193
194        Returns a list of URI strings.
195        """
196        return parse_ndef_uri_records(self.user_data)

Parse NDEF URI records from user data.

Returns a list of URI strings.

def get_uri(self) -> str:
198    def get_uri(self) -> str:
199        """Get the first NDEF URI record, or empty string."""
200        uris = self.get_uri_records()
201        return uris[0] if uris else ""

Get the first NDEF URI record, or empty string.

class NfcSerialTransport:
 24class NfcSerialTransport:
 25    """Low-level serial transport for PN532 using HSU (UART) protocol."""
 26
 27    def __init__(self, port: str, baudrate: int = 115200, timeout: float = 0.05):
 28        self._port_name = port
 29        self._baudrate = baudrate
 30        self._timeout = timeout
 31        self._serial: serial.Serial | None = None
 32
 33    def open(self) -> bool:
 34        """Open the serial port and wake the PN532.
 35
 36        Returns True on success, False on failure.
 37        """
 38        try:
 39            self._serial = serial.Serial(
 40                self._port_name,
 41                baudrate=self._baudrate,
 42                timeout=self._timeout,
 43            )
 44            self._wake()
 45            logger.info("NFC transport opened on %s", self._port_name)
 46            return True
 47        except serial.SerialException as e:
 48            logger.warning("Failed to open NFC serial port %s: %s", self._port_name, e)
 49            self._serial = None
 50            return False
 51
 52    def close(self):
 53        """Close the serial port."""
 54        if self._serial and self._serial.is_open:
 55            self._serial.close()
 56            self._serial = None
 57
 58    @property
 59    def is_open(self) -> bool:
 60        return self._serial is not None and self._serial.is_open
 61
 62    def write_command(self, command_data: bytes) -> bool:
 63        """Send a command frame to the PN532.
 64
 65        Args:
 66            command_data: Command bytes (TFI 0xD4 is prepended automatically).
 67
 68        Returns True if the PN532 ACKed the frame.
 69        """
 70        if not self.is_open:
 71            return False
 72
 73        frame = self._build_frame(command_data)
 74        try:
 75            self._serial.reset_input_buffer()
 76            self._serial.write(frame)
 77            return self._read_ack()
 78        except serial.SerialException as e:
 79            logger.warning("NFC write error: %s", e)
 80            return False
 81
 82    def read_response(self) -> bytes | None:
 83        """Read a response frame from the PN532.
 84
 85        Returns the response data (after TFI byte) or None on error/timeout.
 86        """
 87        if not self.is_open:
 88            return None
 89
 90        try:
 91            # Find preamble
 92            if not self._wait_for_preamble():
 93                return None
 94
 95            # Read length and length checksum
 96            header = self._serial.read(2)
 97            if len(header) < 2:
 98                return None
 99
100            length = header[0]
101            lcs = header[1]
102            if (length + lcs) & 0xFF != 0:
103                logger.debug("NFC bad length checksum")
104                return None
105
106            # Read TFI + data + DCS + postamble
107            payload = self._serial.read(length + 2)
108            if len(payload) < length + 2:
109                return None
110
111            tfi_and_data = payload[:length]
112            dcs = payload[length]
113
114            # Verify data checksum
115            if (sum(tfi_and_data) + dcs) & 0xFF != 0:
116                logger.debug("NFC bad data checksum")
117                return None
118
119            # Verify TFI is PN532-to-host
120            if tfi_and_data[0] != _TFI_PN532_TO_HOST:
121                logger.debug("NFC unexpected TFI: 0x%02X", tfi_and_data[0])
122                return None
123
124            # Return data after TFI
125            return bytes(tfi_and_data[1:])
126        except serial.SerialException as e:
127            logger.warning("NFC read error: %s", e)
128            return None
129
130    def _build_frame(self, command_data: bytes) -> bytes:
131        """Build a complete PN532 HSU frame with TFI=0xD4."""
132        data_with_tfi = bytes([_TFI_HOST_TO_PN532]) + command_data
133        length = len(data_with_tfi)
134        lcs = (~length + 1) & 0xFF
135        dcs = (~sum(data_with_tfi) + 1) & 0xFF
136        return _PREAMBLE + bytes([length, lcs]) + data_with_tfi + bytes([dcs]) + _POSTAMBLE
137
138    def _wake(self):
139        """Send wake-up sequence to PN532."""
140        if self._serial:
141            self._serial.write(_WAKEUP)
142            self._serial.flush()
143            # Discard any garbage after wake
144            self._serial.reset_input_buffer()
145
146    def _read_ack(self) -> bool:
147        """Read and verify ACK frame."""
148        ack = self._serial.read(len(_ACK))
149        return ack == _ACK
150
151    def _wait_for_preamble(self) -> bool:
152        """Scan incoming bytes until preamble (00 00 FF) is found."""
153        # Read up to 100 bytes looking for preamble
154        consecutive_zeros = 0
155        for _ in range(100):
156            b = self._serial.read(1)
157            if len(b) == 0:
158                return False
159            if b[0] == 0x00:
160                consecutive_zeros += 1
161            elif b[0] == 0xFF and consecutive_zeros >= 2:
162                return True
163            else:
164                consecutive_zeros = 0
165        return False

Low-level serial transport for PN532 using HSU (UART) protocol.

NfcSerialTransport(port: str, baudrate: int = 115200, timeout: float = 0.05)
27    def __init__(self, port: str, baudrate: int = 115200, timeout: float = 0.05):
28        self._port_name = port
29        self._baudrate = baudrate
30        self._timeout = timeout
31        self._serial: serial.Serial | None = None
def open(self) -> bool:
33    def open(self) -> bool:
34        """Open the serial port and wake the PN532.
35
36        Returns True on success, False on failure.
37        """
38        try:
39            self._serial = serial.Serial(
40                self._port_name,
41                baudrate=self._baudrate,
42                timeout=self._timeout,
43            )
44            self._wake()
45            logger.info("NFC transport opened on %s", self._port_name)
46            return True
47        except serial.SerialException as e:
48            logger.warning("Failed to open NFC serial port %s: %s", self._port_name, e)
49            self._serial = None
50            return False

Open the serial port and wake the PN532.

Returns True on success, False on failure.

def close(self):
52    def close(self):
53        """Close the serial port."""
54        if self._serial and self._serial.is_open:
55            self._serial.close()
56            self._serial = None

Close the serial port.

is_open: bool
58    @property
59    def is_open(self) -> bool:
60        return self._serial is not None and self._serial.is_open
def write_command(self, command_data: bytes) -> bool:
62    def write_command(self, command_data: bytes) -> bool:
63        """Send a command frame to the PN532.
64
65        Args:
66            command_data: Command bytes (TFI 0xD4 is prepended automatically).
67
68        Returns True if the PN532 ACKed the frame.
69        """
70        if not self.is_open:
71            return False
72
73        frame = self._build_frame(command_data)
74        try:
75            self._serial.reset_input_buffer()
76            self._serial.write(frame)
77            return self._read_ack()
78        except serial.SerialException as e:
79            logger.warning("NFC write error: %s", e)
80            return False

Send a command frame to the PN532.

Args: command_data: Command bytes (TFI 0xD4 is prepended automatically).

Returns True if the PN532 ACKed the frame.

def read_response(self) -> bytes | None:
 82    def read_response(self) -> bytes | None:
 83        """Read a response frame from the PN532.
 84
 85        Returns the response data (after TFI byte) or None on error/timeout.
 86        """
 87        if not self.is_open:
 88            return None
 89
 90        try:
 91            # Find preamble
 92            if not self._wait_for_preamble():
 93                return None
 94
 95            # Read length and length checksum
 96            header = self._serial.read(2)
 97            if len(header) < 2:
 98                return None
 99
100            length = header[0]
101            lcs = header[1]
102            if (length + lcs) & 0xFF != 0:
103                logger.debug("NFC bad length checksum")
104                return None
105
106            # Read TFI + data + DCS + postamble
107            payload = self._serial.read(length + 2)
108            if len(payload) < length + 2:
109                return None
110
111            tfi_and_data = payload[:length]
112            dcs = payload[length]
113
114            # Verify data checksum
115            if (sum(tfi_and_data) + dcs) & 0xFF != 0:
116                logger.debug("NFC bad data checksum")
117                return None
118
119            # Verify TFI is PN532-to-host
120            if tfi_and_data[0] != _TFI_PN532_TO_HOST:
121                logger.debug("NFC unexpected TFI: 0x%02X", tfi_and_data[0])
122                return None
123
124            # Return data after TFI
125            return bytes(tfi_and_data[1:])
126        except serial.SerialException as e:
127            logger.warning("NFC read error: %s", e)
128            return None

Read a response frame from the PN532.

Returns the response data (after TFI byte) or None on error/timeout.

def parse_ndef_text_records(data: bytes) -> list:
43def parse_ndef_text_records(data: bytes) -> list:
44    """Parse NDEF text records from NTAG user data pages.
45
46    Returns a list of decoded text strings.
47    """
48    texts, _ = _parse_ndef_message_tlv(data)
49    return texts

Parse NDEF text records from NTAG user data pages.

Returns a list of decoded text strings.

def parse_ndef_uri_records(data: bytes) -> list:
52def parse_ndef_uri_records(data: bytes) -> list:
53    """Parse NDEF URI records from NTAG user data pages.
54
55    Returns a list of decoded URI strings.
56    """
57    _, uris = _parse_ndef_message_tlv(data)
58    return uris

Parse NDEF URI records from NTAG user data pages.

Returns a list of decoded URI strings.

def build_battery_ndef(sn, year, note='', uri='http://www.raptacon.org', max_bytes=504):
118def build_battery_ndef(sn, year, note='',
119                       uri='http://www.raptacon.org',
120                       max_bytes=NTAG215_MAX_BYTES):
121    """Build a complete NDEF message for a battery tag.
122
123    Creates two records:
124      1. URI record (for phone scanning / lost item recovery)
125      2. Text record with bat: section and key=value data
126
127    Args:
128        sn: Battery serial number.
129        year: Battery year.
130        note: Optional note.
131        uri: URI for the first record (default team website).
132        max_bytes: Maximum tag capacity in bytes.
133
134    Returns:
135        bytes ready to write starting at NTAG page 4.
136
137    Raises:
138        ValueError: If the NDEF message exceeds tag capacity.
139    """
140    records = []
141
142    has_uri = bool(uri)
143
144    # Record 1: URI (MB=1, ME=0 if text follows)
145    if has_uri:
146        records.append(
147            encode_ndef_uri_record(uri, first=True, last=False)
148        )
149
150    # Record 2: Text with bat: section (MB depends on URI, ME=1)
151    text = f"bat:\nsn={sn}\nyear={year}"
152    if note:
153        text += f"\nnote={note}"
154    records.append(
155        encode_ndef_text_record(text, first=not has_uri, last=True)
156    )
157
158    data = wrap_ndef_message(records)
159
160    if len(data) > max_bytes:
161        raise ValueError(
162            f"NDEF message ({len(data)} bytes) exceeds tag capacity "
163            f"({max_bytes} bytes)")
164
165    return data

Build a complete NDEF message for a battery tag.

Creates two records:

  1. URI record (for phone scanning / lost item recovery)
  2. Text record with bat: section and key=value data

Args: sn: Battery serial number. year: Battery year. note: Optional note. uri: URI for the first record (default team website). max_bytes: Maximum tag capacity in bytes.

Returns: bytes ready to write starting at NTAG page 4.

Raises: ValueError: If the NDEF message exceeds tag capacity.

def encode_ndef_text_record(text, lang='en', first=False, last=False):
59def encode_ndef_text_record(text, lang='en', first=False, last=False):
60    """Encode a single NDEF Text record (Well-Known type 'T').
61
62    Args:
63        text: The text content.
64        lang: Language code (default 'en').
65        first: Set MB (Message Begin) flag.
66        last: Set ME (Message End) flag.
67
68    Returns:
69        bytes of the encoded NDEF record (without TLV wrapper).
70    """
71    lang_bytes = lang.encode('ascii')
72    text_bytes = text.encode('utf-8')
73    # Status byte: bit 7 = 0 (UTF-8), bits 5-0 = lang code length
74    status_byte = len(lang_bytes) & 0x3F
75    payload = bytes([status_byte]) + lang_bytes + text_bytes
76
77    # Header: TNF=0x01 (Well-Known), SR=1 (short record)
78    header = 0x11  # SR | TNF=0x01
79    if first:
80        header |= 0x80  # MB
81    if last:
82        header |= 0x40  # ME
83
84    if len(payload) > 255:
85        # Long record (SR=0, 4-byte payload length)
86        header &= ~0x10  # clear SR bit
87        hdr = bytes([header, 0x01]) + len(payload).to_bytes(4, 'big')
88        return hdr + b'T' + payload
89
90    # type = 'T', type_len = 1
91    return bytes([header, 0x01, len(payload), ord('T')]) + payload

Encode a single NDEF Text record (Well-Known type 'T').

Args: text: The text content. lang: Language code (default 'en'). first: Set MB (Message Begin) flag. last: Set ME (Message End) flag.

Returns: bytes of the encoded NDEF record (without TLV wrapper).

def encode_ndef_uri_record(uri, first=False, last=False):
26def encode_ndef_uri_record(uri, first=False, last=False):
27    """Encode a single NDEF URI record (Well-Known type 'U').
28
29    Args:
30        uri: Full URI string (e.g. 'http://www.raptacon.org').
31        first: Set MB (Message Begin) flag.
32        last: Set ME (Message End) flag.
33
34    Returns:
35        bytes of the encoded NDEF record (without TLV wrapper).
36    """
37    # Find longest matching prefix
38    prefix_code = 0x00
39    uri_body = uri
40    for prefix, code in _URI_PREFIX_MAP.items():
41        if uri.startswith(prefix):
42            prefix_code = code
43            uri_body = uri[len(prefix):]
44            break
45
46    payload = bytes([prefix_code]) + uri_body.encode('utf-8')
47
48    # Header: TNF=0x01 (Well-Known), SR=1 (short record)
49    header = 0x11  # SR | TNF=0x01
50    if first:
51        header |= 0x80  # MB
52    if last:
53        header |= 0x40  # ME
54
55    # type = 'U', type_len = 1
56    return bytes([header, 0x01, len(payload), ord('U')]) + payload

Encode a single NDEF URI record (Well-Known type 'U').

Args: uri: Full URI string (e.g. 'http://www.raptacon.org'). first: Set MB (Message Begin) flag. last: Set ME (Message End) flag.

Returns: bytes of the encoded NDEF record (without TLV wrapper).

def wrap_ndef_message(records):
 94def wrap_ndef_message(records):
 95    """Wrap concatenated NDEF records in an NDEF Message TLV.
 96
 97    Args:
 98        records: list of bytes, each an encoded NDEF record.
 99
100    Returns:
101        bytes with TLV header (0x03, length) + records + terminator (0xFE).
102    """
103    msg = b''.join(records)
104    msg_len = len(msg)
105
106    if msg_len < 0xFF:
107        tlv = bytes([0x03, msg_len]) + msg
108    else:
109        # 3-byte length format
110        tlv = bytes([0x03, 0xFF,
111                     (msg_len >> 8) & 0xFF,
112                     msg_len & 0xFF]) + msg
113
114    # Append terminator TLV
115    return tlv + bytes([0xFE])

Wrap concatenated NDEF records in an NDEF Message TLV.

Args: records: list of bytes, each an encoded NDEF record.

Returns: bytes with TLV header (0x03, length) + records + terminator (0xFE).