From 1260851961ceec3238c11a6eb5ff3a473b76071e Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 22 Feb 2026 13:51:13 +0100 Subject: [PATCH] Set up PN5180 NFC reader --- requirements.txt | 4 + spoolbuddy/README.md | 161 +++++++++-- spoolbuddy/pn5180_diag.py | 346 +++++++++++++++++++++++ spoolbuddy/read_tag.py | 570 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 1063 insertions(+), 18 deletions(-) create mode 100644 spoolbuddy/pn5180_diag.py create mode 100644 spoolbuddy/read_tag.py diff --git a/requirements.txt b/requirements.txt index 759efc1df..d67cb2849 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,3 +58,7 @@ httpx>=0.26.0 ruff>=0.2.0 pillow>=12.1.1 + +# SpoolBuddy Hardware (PN5180 NFC reader, scale) +gpiod>=2.2.0 +spidev>=3.6 diff --git a/spoolbuddy/README.md b/spoolbuddy/README.md index c5ce06e8e..a65a8f774 100644 --- a/spoolbuddy/README.md +++ b/spoolbuddy/README.md @@ -1,21 +1,146 @@ -### PN5180 NFC Reader (SPI) +# SpoolBuddy Hardware Setup -| PN5180 Pin | Raspberry Pi GPIO | Wire Color | -|------------|-------------------|------------| -| VCC | Pin17 or 1 | Red | -| GND | Pin 20 | Black | -| SCK | Pin23 (GPIO11) | Yellow | -| MISO | Pin21 (GPIO9) | Blue | -| MOSI | Pin19 (GPIO10) | Green | -| NSS (CS) | Pin24 (GPIO8) | Orange | -| BUSY | Pin22 (GPIO25) | White | -| RST | Pin18 (GPIO24) | Brown | +## PN5180 NFC Reader (SPI) -### NAU7802 Scale (I2C) +### Wiring -| NAU7802 Pin | Raspberry Pi GPIO | Wire Color | -|-------------|-------------------|------------| -| VCC | Pin17 or 1 | Red | -| SDA | Pin 27 (GPIO 0) | Yellow | -| SCL | Pin 28 (GPIO 1) | White | -| GND | Pin 30 or Pin 25 | Black | +| PN5180 Pin | Raspberry Pi Pin | GPIO | Wire Color | +|------------|------------------|------|------------| +| 3V3 | Pin 1 | — | Red | +| 5V | Pin 2 | — | Red | +| GND | Pin 20 | — | Black | +| SCK | Pin 23 | GPIO11 | Yellow | +| MISO | Pin 21 | GPIO9 | Blue | +| MOSI | Pin 19 | GPIO10 | Green | +| NSS (CS) | Pin 16 | GPIO23 | Orange | +| BUSY | Pin 22 | GPIO25 | White | +| RST | Pin 18 | GPIO24 | Brown | + +> **Power:** The PN5180 board has two power pins. 3V3 powers the IC itself, +> 5V powers the antenna booster and extends read range. Both should be connected. +> Do NOT connect 5V to the 3V3 pin — it will destroy the reader. + +> **NSS:** We use GPIO23 for manual chip-select instead of the default SPI CE0 +> (GPIO8) because the kernel SPI driver's automatic CS timing does not meet the +> PN5180's requirements (5µs setup, 100µs hold). Manual CS via GPIO23 with +> `spidev.no_cs = True` resolves this. + +### Setup Steps + +#### 1. Enable SPI + +After a fresh Raspberry Pi OS install, SPI is disabled by default. + +```bash +sudo raspi-config +# Navigate to: Interface Options -> SPI -> Enable +sudo reboot +``` + +Verify after reboot: + +```bash +ls /dev/spidev0.* +# Should show: /dev/spidev0.0 /dev/spidev0.1 +``` + +#### 2. Disable automatic CS (kernel SPI chip-select) + +Since we use manual CS on GPIO23, we need to tell the SPI driver not to +drive any hardware CS pins. Add this to `/boot/firmware/config.txt`: + +``` +dtoverlay=spi0-0cs +``` + +Then reboot: + +```bash +sudo reboot +``` + +#### 3. Install system packages + +```bash +sudo apt install python3-spidev python3-libgpiod gpiod libgpiod3 +``` + +- `python3-spidev` / `libgpiod3` — system libraries for SPI and GPIO access +- `gpiod` — command-line GPIO tools (useful for debugging) + +#### 4. Install Python dependencies (in venv) + +```bash +pip install spidev gpiod +``` + +- `spidev` — Python SPI bindings +- `gpiod` — Python GPIO bindings via libgpiod (works on both RPi 4 and RPi 5) + +#### 5. Solder all connections + +Wago connectors or breadboard jumpers are unreliable for SPI — the PN5180 +is very sensitive to signal integrity issues (loose connections cause RF +field flickering, phantom errors, and intermittent communication failures). +**Solder all wires directly** for reliable operation. + +#### 6. Verify hardware communication + +Run the diagnostic script to confirm the PN5180 is responding: + +```bash +sudo python3 spoolbuddy/pn5180_diag.py +``` + +Expected output includes product version (e.g. `v4.0`), firmware version, +register dump, and "Diagnostics complete" at the end. + +#### 7. Test tag reading + +```bash +sudo python3 spoolbuddy/read_tag.py +``` + +Place a tag on the reader. Supported tag types: + +| Tag Type | SAK | Use Case | +|---------------------|--------|------------------------------| +| MIFARE Classic 1K | `0x08` | Bambu Lab filament tags | +| MIFARE Classic 4K | `0x18` | Bambu Lab filament tags | +| NTAG (213/215/216) | `0x00` | SpoolEase / OpenPrintTag | + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| All zeros from SPI reads | SPI not enabled | Run `raspi-config` and enable SPI, then reboot | +| `GENERAL_ERROR` on SEND_DATA | Automatic CS timing too fast | Use manual CS on GPIO23 with `spi0-0cs` overlay | +| `BUSY timeout` | Wiring issue or RST not connected | Check RST and BUSY pin connections | +| RF field flickering on/off | Loose power wires | Solder all connections | +| `No tag found` but tag is present | Wrong protocol or missing `setTransceiveMode()` | Ensure ISO 14443A config (`0x00, 0x80`) and `setTransceiveMode()` before every `SEND_DATA` | +| Auth failed for block N | Wrong key derivation | Verify HKDF uses context `"RFID-A\0"` (7 bytes including null terminator) | +| `EBUSY` when requesting GPIO8 | Kernel SPI driver owns CE0 | Use GPIO23 for NSS instead | + +### Technical Notes + +- SPI speed: **500 kHz** (higher speeds cause communication errors) +- SPI mode: **0** (CPOL=0, CPHA=0) +- CS timing: **5µs** setup after CS LOW, **100µs** hold after CS HIGH +- BUSY handshake: wait for BUSY **HIGH** (processing started) then **LOW** (done) — waiting only for LOW is incorrect +- `setTransceiveMode()`: must write `0x03` to SYSTEM_CONFIG bits 0-2 before every `SEND_DATA`, or the PN5180 buffers data but never transmits on RF +- Bambu tags use **MIFARE Classic** with per-sector keys derived via **HKDF-SHA256** from a master key + tag UID +- NTAG reads require **CRC disabled** (unlike MIFARE Classic which needs CRC enabled) +- The PN5180 handles Crypto1 encryption/decryption internally via the `MFC_AUTHENTICATE` (0x0C) host command + +--- + +## NAU7802 Scale (I2C) + +### Wiring + +| NAU7802 Pin | Raspberry Pi Pin | GPIO | Wire Color | +|-------------|------------------|--------|------------| +| VCC | Pin 1 | — | Red | +| SDA | Pin 27 | GPIO 0 | Yellow | +| SCL | Pin 28 | GPIO 1 | White | +| GND | Pin 30 | — | Black | diff --git a/spoolbuddy/pn5180_diag.py b/spoolbuddy/pn5180_diag.py new file mode 100644 index 000000000..2c4f37d8b --- /dev/null +++ b/spoolbuddy/pn5180_diag.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +"""PN5180 NFC reader diagnostic script. + +Connects to a PN5180 over SPI on a Raspberry Pi and reads +hardware status, version info, and register state. + +Wiring (from spoolbuddy/README.md): + PN5180 VCC -> Pi Pin 1 (3.3V) + PN5180 GND -> Pi Pin 20 (GND) + PN5180 SCK -> Pi Pin 23 (GPIO11) + PN5180 MISO -> Pi Pin 21 (GPIO9) + PN5180 MOSI -> Pi Pin 19 (GPIO10) + PN5180 NSS -> Pi Pin 24 (GPIO8 / CE0) + PN5180 BUSY -> Pi Pin 22 (GPIO25) + PN5180 RST -> Pi Pin 18 (GPIO24) +""" + +import sys +import time + +import gpiod +import spidev + +# --------------------------------------------------------------------------- +# Pin assignments (BCM numbering) +# --------------------------------------------------------------------------- +BUSY_PIN = 25 # Pin 22 +RST_PIN = 24 # Pin 18 + +# --------------------------------------------------------------------------- +# SPI command instruction codes (NXP PN5180 datasheet Table 5) +# --------------------------------------------------------------------------- +CMD_WRITE_REGISTER = 0x00 +CMD_WRITE_REGISTER_OR_MASK = 0x01 +CMD_WRITE_REGISTER_AND_MASK = 0x02 +CMD_READ_REGISTER = 0x04 +CMD_READ_REGISTER_MULTIPLE = 0x05 +CMD_WRITE_EEPROM = 0x06 +CMD_READ_EEPROM = 0x07 +CMD_SEND_DATA = 0x09 +CMD_READ_DATA = 0x0A +CMD_LOAD_RF_CONFIG = 0x11 +CMD_RF_ON = 0x16 +CMD_RF_OFF = 0x17 + +# --------------------------------------------------------------------------- +# Register addresses (32-bit each) +# --------------------------------------------------------------------------- +REG_SYSTEM_CONFIG = 0x00 +REG_IRQ_ENABLE = 0x01 +REG_IRQ_STATUS = 0x02 +REG_IRQ_CLEAR = 0x03 +REG_TRANSCEIVE_CONTROL = 0x04 +REG_TIMER1_RELOAD = 0x0C +REG_TIMER1_CONFIG = 0x0F +REG_RX_WAIT_CONFIG = 0x11 +REG_CRC_RX_CONFIG = 0x12 +REG_RX_STATUS = 0x13 +REG_CRC_TX_CONFIG = 0x19 +REG_RF_STATUS = 0x1D +REG_SYSTEM_STATUS = 0x24 +REG_TEMP_CONTROL = 0x25 + +REGISTER_NAMES = { + REG_SYSTEM_CONFIG: "SYSTEM_CONFIG", + REG_IRQ_ENABLE: "IRQ_ENABLE", + REG_IRQ_STATUS: "IRQ_STATUS", + REG_IRQ_CLEAR: "IRQ_CLEAR", + REG_TRANSCEIVE_CONTROL: "TRANSCEIVE_CONTROL", + REG_TIMER1_RELOAD: "TIMER1_RELOAD", + REG_TIMER1_CONFIG: "TIMER1_CONFIG", + REG_RX_WAIT_CONFIG: "RX_WAIT_CONFIG", + REG_CRC_RX_CONFIG: "CRC_RX_CONFIG", + REG_RX_STATUS: "RX_STATUS", + REG_CRC_TX_CONFIG: "CRC_TX_CONFIG", + REG_RF_STATUS: "RF_STATUS", + REG_SYSTEM_STATUS: "SYSTEM_STATUS", + REG_TEMP_CONTROL: "TEMP_CONTROL", +} + +# --------------------------------------------------------------------------- +# EEPROM addresses +# --------------------------------------------------------------------------- +EEPROM_DIE_IDENTIFIER = 0x00 # 16 bytes +EEPROM_PRODUCT_VERSION = 0x10 # 2 bytes +EEPROM_FIRMWARE_VERSION = 0x12 # 2 bytes +EEPROM_EEPROM_VERSION = 0x14 # 2 bytes +EEPROM_IRQ_PIN_CONFIG = 0x1A # 1 byte + + +def _find_gpio_chip(): + """Find the right gpiochip for Raspberry Pi GPIO pins. + + RPi 5 uses gpiochip4, RPi 4 uses gpiochip0. + """ + for path in ["/dev/gpiochip4", "/dev/gpiochip0"]: + try: + chip = gpiod.Chip(path) + info = chip.get_info() + # RPi 4: pinctrl-bcm2711, RPi 5: pinctrl-rp1 + if "pinctrl" in info.label: + return chip + chip.close() + except (FileNotFoundError, PermissionError, OSError): + continue + raise RuntimeError("Could not find Raspberry Pi GPIO chip") + + +class PN5180: + """Low-level driver for the PN5180 NFC frontend over SPI.""" + + def __init__(self, spi_bus=0, spi_device=0, spi_speed_hz=1_000_000, busy_pin=BUSY_PIN, rst_pin=RST_PIN): + # GPIO setup via libgpiod + self._chip = _find_gpio_chip() + + self._busy_line = self._chip.request_lines( + consumer="pn5180-diag", + config={busy_pin: gpiod.LineSettings(direction=gpiod.line.Direction.INPUT)}, + ) + self._rst_line = self._chip.request_lines( + consumer="pn5180-diag", + config={ + rst_pin: gpiod.LineSettings( + direction=gpiod.line.Direction.OUTPUT, + output_value=gpiod.line.Value.ACTIVE, + ) + }, + ) + self._busy_pin = busy_pin + self._rst_pin = rst_pin + + # SPI setup – mode 0 (CPOL=0, CPHA=0), MSB first + self._spi = spidev.SpiDev() + self._spi.open(spi_bus, spi_device) + self._spi.max_speed_hz = spi_speed_hz + self._spi.mode = 0b00 + self._spi.bits_per_word = 8 + + def close(self): + self._spi.close() + self._busy_line.release() + self._rst_line.release() + self._chip.close() + + # -- low-level helpers -------------------------------------------------- + + def _busy_is_high(self): + return self._busy_line.get_value(self._busy_pin) == gpiod.line.Value.ACTIVE + + def _wait_busy(self, timeout_s=1.0): + """Block until BUSY goes LOW (PN5180 ready).""" + deadline = time.monotonic() + timeout_s + while self._busy_is_high(): + if time.monotonic() > deadline: + raise TimeoutError("PN5180 BUSY line did not go low") + time.sleep(0.001) + + def _send_command(self, tx_data, rx_len=0): + """Send an SPI command frame and optionally read a response frame. + + The PN5180 SPI protocol is half-duplex: + 1. Send command frame (NSS held low for entire frame). + 2. Wait for BUSY high then low (command processed). + 3. If a response is expected, clock out rx_len bytes in a second frame. + """ + self._wait_busy() + + # Transmit command + self._spi.xfer2(list(tx_data)) + + if rx_len == 0: + # Write-only command – wait for processing + time.sleep(0.001) + self._wait_busy() + return None + + # Wait for PN5180 to process command (BUSY goes high then low) + time.sleep(0.001) + self._wait_busy() + + # Read response + rx = self._spi.xfer2([0xFF] * rx_len) + time.sleep(0.001) + self._wait_busy() + return bytes(rx) + + # -- register operations ------------------------------------------------ + + def read_register(self, addr): + """Read a 32-bit register. Returns int.""" + resp = self._send_command([CMD_READ_REGISTER, addr], rx_len=4) + return int.from_bytes(resp, "little") + + def write_register(self, addr, value): + """Write a 32-bit value to a register.""" + self._send_command( + [ + CMD_WRITE_REGISTER, + addr, + value & 0xFF, + (value >> 8) & 0xFF, + (value >> 16) & 0xFF, + (value >> 24) & 0xFF, + ] + ) + + def write_register_or_mask(self, addr, mask): + self._send_command( + [ + CMD_WRITE_REGISTER_OR_MASK, + addr, + mask & 0xFF, + (mask >> 8) & 0xFF, + (mask >> 16) & 0xFF, + (mask >> 24) & 0xFF, + ] + ) + + def write_register_and_mask(self, addr, mask): + self._send_command( + [ + CMD_WRITE_REGISTER_AND_MASK, + addr, + mask & 0xFF, + (mask >> 8) & 0xFF, + (mask >> 16) & 0xFF, + (mask >> 24) & 0xFF, + ] + ) + + # -- EEPROM operations -------------------------------------------------- + + def read_eeprom(self, addr, length): + """Read `length` bytes from EEPROM starting at `addr`.""" + return self._send_command([CMD_READ_EEPROM, addr, length], rx_len=length) + + # -- reset -------------------------------------------------------------- + + def reset(self): + """Hardware-reset the PN5180 via the RST pin.""" + self._rst_line.set_value(self._rst_pin, gpiod.line.Value.INACTIVE) + time.sleep(0.01) + self._rst_line.set_value(self._rst_pin, gpiod.line.Value.ACTIVE) + time.sleep(0.05) + self._wait_busy(timeout_s=2.0) + # Clear all IRQ flags + self.write_register(REG_IRQ_CLEAR, 0xFFFFFFFF) + + # -- version / identity ------------------------------------------------- + + def get_product_version(self): + data = self.read_eeprom(EEPROM_PRODUCT_VERSION, 2) + return f"{data[1]}.{data[0]}" + + def get_firmware_version(self): + data = self.read_eeprom(EEPROM_FIRMWARE_VERSION, 2) + return f"{data[1]}.{data[0]}" + + def get_eeprom_version(self): + data = self.read_eeprom(EEPROM_EEPROM_VERSION, 2) + return f"{data[1]}.{data[0]}" + + def get_die_identifier(self): + data = self.read_eeprom(EEPROM_DIE_IDENTIFIER, 16) + return data.hex() + + +def run_diagnostics(): + print("=" * 60) + print("PN5180 NFC Reader Diagnostics") + print("=" * 60) + + nfc = PN5180() + try: + # Reset + print("\n[1] Hardware reset...") + nfc.reset() + print(" Reset OK") + + # Version info + print("\n[2] Version info (EEPROM)") + print(f" Product version : {nfc.get_product_version()}") + print(f" Firmware version : {nfc.get_firmware_version()}") + print(f" EEPROM version : {nfc.get_eeprom_version()}") + print(f" Die identifier : {nfc.get_die_identifier()}") + + # Register dump + print("\n[3] Register dump") + for addr, name in sorted(REGISTER_NAMES.items()): + val = nfc.read_register(addr) + print(f" 0x{addr:02X} {name:<24s} = 0x{val:08X}") + + # IRQ status breakdown + irq = nfc.read_register(REG_IRQ_STATUS) + print(f"\n[4] IRQ status flags (0x{irq:08X})") + irq_flags = [ + (0, "RX_IRQ"), + (1, "TX_IRQ"), + (2, "IDLE_IRQ"), + (3, "MODE_DETECTED_IRQ"), + (4, "CARD_ACTIVATED_IRQ"), + (5, "STATE_CHANGE_IRQ"), + (6, "RFOFF_DET_IRQ"), + (7, "RFON_DET_IRQ"), + (8, "TX_RFOFF_IRQ"), + (9, "TX_RFON_IRQ"), + (10, "RF_ACTIVE_ERROR_IRQ"), + (14, "LPCD_IRQ"), + ] + for bit, name in irq_flags: + state = "SET" if irq & (1 << bit) else "---" + print(f" bit {bit:2d}: {name:<28s} [{state}]") + + # RF status + rf = nfc.read_register(REG_RF_STATUS) + print(f"\n[5] RF status (0x{rf:08X})") + tx_rf_on = bool(rf & (1 << 0)) + rx_en = bool(rf & (1 << 1)) + print(f" TX RF active : {tx_rf_on}") + print(f" RX enabled : {rx_en}") + + # System status + sys_stat = nfc.read_register(REG_SYSTEM_STATUS) + print(f"\n[6] System status (0x{sys_stat:08X})") + + # Temperature + temp_ctrl = nfc.read_register(REG_TEMP_CONTROL) + print(f"\n[7] Temp control register (0x{temp_ctrl:08X})") + + print("\n" + "=" * 60) + print("Diagnostics complete - PN5180 is responding over SPI.") + print("=" * 60) + + except TimeoutError as e: + print(f"\nERROR: {e}") + print("Check wiring and ensure SPI is enabled (dtparam=spi=on in /boot/firmware/config.txt)") + sys.exit(1) + except Exception as e: + print(f"\nERROR: {e}") + sys.exit(1) + finally: + nfc.close() + + +if __name__ == "__main__": + run_diagnostics() diff --git a/spoolbuddy/read_tag.py b/spoolbuddy/read_tag.py new file mode 100644 index 000000000..659079d98 --- /dev/null +++ b/spoolbuddy/read_tag.py @@ -0,0 +1,570 @@ +#!/usr/bin/env python3 +"""PN5180 NFC tag reader — ported from working Pico firmware (pico-nfc-bridge.ino). + +Key learnings from pico-nfc-bridge.ino: +- Must call setTransceiveMode() before every SEND_DATA +- waitBusy() must wait for HIGH then LOW (not just LOW) +- Bambu tags are MIFARE Classic 1K (ISO 14443A), not ISO 15693 +- SPI at 500kHz, 5µs CS setup, 100µs post-CS delay +- MFC_AUTHENTICATE (0x0C) is a PN5180 host command — Crypto1 handled in hardware +- HKDF-SHA256 derives per-sector keys from master key + UID +""" + +import hashlib +import hmac +import sys +import time + +import gpiod +import spidev + +BUSY_PIN = 25 +RST_PIN = 24 +NSS_PIN = 23 # Manual CS (moved from GPIO8) + +# Bambu Lab MIFARE Classic key derivation constants (from pico-nfc-bridge.ino) +BAMBU_MASTER_KEY = bytes( + [ + 0x9A, + 0x75, + 0x9C, + 0xF2, + 0xC4, + 0xF7, + 0xCA, + 0xFF, + 0x22, + 0x2C, + 0xB9, + 0x76, + 0x9B, + 0x41, + 0xBC, + 0x96, + ] +) +BAMBU_CONTEXT = b"RFID-A\x00" # 7 bytes including null terminator + +# Blocks to read for Bambu tag data +BAMBU_BLOCKS = [1, 2, 4, 5] + + +def hkdf_derive_keys(uid: bytes) -> bytes: + """Derive 96 bytes of MIFARE key material (16 sectors * 6 bytes each). + + Uses HKDF-SHA256 with the Bambu master key as salt and the tag UID as IKM. + """ + # HKDF-Extract: PRK = HMAC-SHA256(salt=master_key, IKM=uid) + prk = hmac.new(BAMBU_MASTER_KEY, uid, hashlib.sha256).digest() + + # HKDF-Expand: generate 96 bytes using context "RFID-A\0" + okm = b"" + t = b"" + counter = 1 + while len(okm) < 96: + t = hmac.new(prk, t + BAMBU_CONTEXT + bytes([counter]), hashlib.sha256).digest() + okm += t + counter += 1 + return okm[:96] + + +def get_sector_key(keys: bytes, block: int) -> bytes: + """Get the 6-byte key for the sector containing the given block.""" + sector = block // 4 + return keys[sector * 6 : sector * 6 + 6] + + +def _find_gpio_chip(): + for path in ["/dev/gpiochip4", "/dev/gpiochip0"]: + try: + chip = gpiod.Chip(path) + if "pinctrl" in chip.get_info().label: + return chip + chip.close() + except (FileNotFoundError, PermissionError, OSError): + continue + raise RuntimeError("No GPIO chip") + + +class PN5180: + def __init__(self): + self._chip = _find_gpio_chip() + self._lines = self._chip.request_lines( + consumer="pn5180", + config={ + BUSY_PIN: gpiod.LineSettings(direction=gpiod.line.Direction.INPUT), + RST_PIN: gpiod.LineSettings( + direction=gpiod.line.Direction.OUTPUT, output_value=gpiod.line.Value.ACTIVE + ), + NSS_PIN: gpiod.LineSettings( + direction=gpiod.line.Direction.OUTPUT, output_value=gpiod.line.Value.ACTIVE + ), + }, + ) + self._spi = spidev.SpiDev() + self._spi.open(0, 0) + self._spi.max_speed_hz = 500_000 # 500kHz like Pico firmware + self._spi.mode = 0b00 + self._spi.no_cs = True + + def close(self): + self._spi.close() + self._lines.release() + self._chip.close() + + def _cs_low(self): + self._lines.set_value(NSS_PIN, gpiod.line.Value.INACTIVE) + time.sleep(0.000005) # 5µs setup + + def _cs_high(self): + self._lines.set_value(NSS_PIN, gpiod.line.Value.ACTIVE) + time.sleep(0.000100) # 100µs post-CS delay + + def _wait_busy(self, timeout_s=1.0): + """Wait for BUSY to go HIGH (processing) then LOW (done) — matches Pico firmware.""" + deadline = time.monotonic() + min(timeout_s, 0.010) + # Wait for BUSY HIGH (PN5180 started processing) + while self._lines.get_value(BUSY_PIN) != gpiod.line.Value.ACTIVE: + if time.monotonic() > deadline: + break # Timeout waiting for HIGH — command may have processed already + time.sleep(0.00001) + # Wait for BUSY LOW (PN5180 done) + deadline = time.monotonic() + timeout_s + while self._lines.get_value(BUSY_PIN) == gpiod.line.Value.ACTIVE: + if time.monotonic() > deadline: + raise TimeoutError("BUSY timeout") + time.sleep(0.0001) + + def _cmd(self, data): + self._cs_low() + self._spi.xfer2(list(data)) + self._cs_high() + self._wait_busy() + + def _read_response(self, n): + self._cs_low() + result = self._spi.xfer2([0xFF] * n) + self._cs_high() + return result + + # -- Register ops -- + + def write_reg(self, reg, val): + self._cmd([0x00, reg, val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF]) + + def write_reg_or(self, reg, mask): + self._cmd([0x01, reg, mask & 0xFF, (mask >> 8) & 0xFF, (mask >> 16) & 0xFF, (mask >> 24) & 0xFF]) + + def write_reg_and(self, reg, mask): + self._cmd([0x02, reg, mask & 0xFF, (mask >> 8) & 0xFF, (mask >> 16) & 0xFF, (mask >> 24) & 0xFF]) + + def read_reg(self, reg): + self._cmd([0x04, reg]) + time.sleep(0.000100) # Extra 100µs before read + return int.from_bytes(self._read_response(4), "little") + + def read_eeprom(self, addr, length): + self._cmd([0x07, addr, length]) + time.sleep(0.000100) + return bytes(self._read_response(length)) + + # -- Commands -- + + def reset(self): + self._lines.set_value(RST_PIN, gpiod.line.Value.INACTIVE) + time.sleep(0.050) + self._lines.set_value(RST_PIN, gpiod.line.Value.ACTIVE) + time.sleep(0.100) + self._wait_busy(2.0) + time.sleep(0.050) + + def load_rf_config(self, tx, rx): + self.write_reg(0x03, 0xFFFFFFFF) # Clear IRQs first + time.sleep(0.000100) + self._cmd([0x11, tx, rx]) + time.sleep(0.010) + + def rf_on(self): + self._cmd([0x16, 0x00]) + time.sleep(0.010) + + def rf_off(self): + self._cmd([0x17, 0x00]) + time.sleep(0.005) + + def set_transceive_mode(self): + """Set SYSTEM_CONFIG command bits to TRANSCEIVE (0x03) — CRITICAL!""" + sys_cfg = self.read_reg(0x00) + sys_cfg = (sys_cfg & 0xFFFFFFF8) | 0x03 + self.write_reg(0x00, sys_cfg) + + def send_data(self, data, valid_bits=0x00): + self._cs_low() + self._spi.xfer2([0x09, valid_bits] + list(data)) + self._cs_high() + time.sleep(0.000100) + self._wait_busy() + + def read_data(self, length): + self._cmd([0x0A, 0x00]) + return bytes(self._read_response(length)) + + # -- ISO 14443A -- + + def activate_type_a(self): + """Full Type A activation: WUPA -> Anticollision -> SELECT. Returns (uid, sak) or None.""" + # Crypto off, CRC off + self.write_reg_and(0x00, 0xFFFFFFBF) + self.write_reg_and(0x12, 0xFFFFFFFE) + self.write_reg_and(0x19, 0xFFFFFFFE) + self.write_reg(0x03, 0xFFFFFFFF) + + # Reset to IDLE then TRANSCEIVE + sys_cfg = self.read_reg(0x00) + self.write_reg(0x00, sys_cfg & 0xFFFFFFF8) # IDLE + time.sleep(0.001) + self.write_reg(0x00, (sys_cfg & 0xFFFFFFF8) | 0x03) # TRANSCEIVE + time.sleep(0.002) + + # WUPA (7-bit) + self.send_data([0x52], valid_bits=0x07) + time.sleep(0.005) + + rx_status = self.read_reg(0x13) + rx_len = rx_status & 0x1FF + if rx_len < 2 or rx_len == 511: + # Try REQA + self.write_reg(0x03, 0xFFFFFFFF) + time.sleep(0.002) + self.set_transceive_mode() + time.sleep(0.002) + self.send_data([0x26], valid_bits=0x07) + time.sleep(0.005) + rx_status = self.read_reg(0x13) + rx_len = rx_status & 0x1FF + if rx_len < 2 or rx_len == 511: + return None + + atqa = self.read_data(2) + if atqa[0] == 0xFF or atqa[0] == 0x00: + return None + + # Anti-collision Level 1 + self.write_reg(0x03, 0xFFFFFFFF) + self.set_transceive_mode() + time.sleep(0.002) + + self.send_data([0x93, 0x20]) + time.sleep(0.010) + + rx_status = self.read_reg(0x13) + rx_len = rx_status & 0x1FF + if rx_len < 5 or rx_len > 64: + return None + + uid_buf = self.read_data(5) + uid = uid_buf[:4] + bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3] + if bcc != uid_buf[4]: + return None + + # SELECT + self.write_reg(0x03, 0xFFFFFFFF) + self.set_transceive_mode() + time.sleep(0.002) + + # Enable CRC for SELECT + self.write_reg_or(0x19, 0x01) + self.write_reg_or(0x12, 0x01) + + self.send_data([0x93, 0x70, uid[0], uid[1], uid[2], uid[3], bcc]) + time.sleep(0.010) + + rx_status = self.read_reg(0x13) + rx_len = rx_status & 0x1FF + if rx_len < 1: + return None + + sak_buf = self.read_data(min(rx_len, 3)) + sak = sak_buf[0] + + return bytes(uid), sak + + # -- MIFARE Classic -- + + def mfc_authenticate(self, block: int, key: bytes, uid: bytes) -> bool: + """MIFARE Classic authentication via PN5180 MFC_AUTHENTICATE (0x0C). + + The PN5180 handles Crypto1 internally. After success, bit 6 of + SYSTEM_CONFIG is set (MFC_CRYPTO1_ON) and all subsequent RF + communication is encrypted/decrypted by the hardware. + + Args: + block: Block number to authenticate + key: 6-byte MIFARE Key A + uid: 4-byte tag UID + Returns: + True if authentication succeeded + """ + # Wait for BUSY LOW before starting + deadline = time.monotonic() + 0.100 + while self._lines.get_value(BUSY_PIN) == gpiod.line.Value.ACTIVE: + if time.monotonic() > deadline: + return False + time.sleep(0.001) + + # MFC_AUTHENTICATE: [0x0C][key 6B][keyType][blockNo][uid 4B] = 13 bytes + cmd = [0x0C] + list(key) + [0x60, block] + list(uid[:4]) + self._cs_low() + self._spi.xfer2(cmd) + self._cs_high() + + # Wait for BUSY HIGH then LOW (auth can take up to 1s) + self._wait_busy(timeout_s=1.0) + + # Read 1-byte response: 0x00 = success + self._cs_low() + response = self._spi.xfer2([0xFF]) + self._cs_high() + + return response[0] == 0x00 + + def mfc_read_block(self, block: int) -> bytes | None: + """Read a 16-byte MIFARE Classic block (must be authenticated first). + + Returns 16 bytes of block data, or None on failure. + """ + # Clear IRQs + self.write_reg(0x03, 0xFFFFFFFF) + + # Set transceive mode (Crypto1 stays active from MFC_AUTHENTICATE) + self.set_transceive_mode() + time.sleep(0.001) + + # Enable TX and RX CRC for encrypted read + self.write_reg_or(0x19, 0x01) + self.write_reg_or(0x12, 0x01) + + # Send MIFARE READ command: 0x30 + block number + self.send_data([0x30, block]) + time.sleep(0.010) + + # Check RX status + rx_status = self.read_reg(0x13) + rx_len = rx_status & 0x1FF + if rx_len != 16: + return None + + return self.read_data(16) + + def ntag_read_pages(self, start_page: int, num_pages: int) -> bytes | None: + """Read NTAG pages (4 bytes each). No authentication required. + + Uses NTAG READ command (0x30) which returns 4 pages (16 bytes) at a time. + CRC must be disabled for NTAG reads. + """ + # Disable CRC for NTAG + self.write_reg_and(0x19, 0xFFFFFFFE) # TX CRC off + self.write_reg_and(0x12, 0xFFFFFFFE) # RX CRC off + + result = bytearray() + pages_read = 0 + while pages_read < num_pages: + self.write_reg(0x03, 0xFFFFFFFF) # Clear IRQs + self.set_transceive_mode() + time.sleep(0.001) + + # READ command: 0x30 + page number → returns 16 bytes (4 pages) + self.send_data([0x30, start_page + pages_read]) + time.sleep(0.005) + + rx_status = self.read_reg(0x13) + rx_len = rx_status & 0x1FF + if rx_len < 16: + return None + + data = self.read_data(16) + # Copy only the pages we need + pages_to_copy = min(4, num_pages - pages_read) + result.extend(data[: pages_to_copy * 4]) + pages_read += 4 # Always advances by 4 (READ returns 4 pages) + + return bytes(result) + + def reactivate_card(self) -> tuple[bytes, int] | None: + """RF cycle and full re-select of the card. Returns (uid, sak) or None.""" + self.rf_off() + time.sleep(0.010) + + self.write_reg(0x03, 0xFFFFFFFF) # Clear IRQs + self.load_rf_config(0x00, 0x80) # ISO 14443A + time.sleep(0.005) + + self.rf_on() + time.sleep(0.020) + + return self.activate_type_a() + + def read_bambu_tag(self, uid: bytes) -> dict[int, bytes] | None: + """Read Bambu tag data blocks using HKDF-derived keys. + + Args: + uid: 4-byte tag UID (from activate_type_a) + Returns: + Dict mapping block number -> 16 bytes of data, or None on failure + """ + # Derive per-sector keys from UID + keys = hkdf_derive_keys(uid) + + # Clear Crypto1 state and IRQs + self.write_reg_and(0x00, 0xFFFFFFBF) # Clear MFC_CRYPTO1_ON (bit 6) + self.write_reg(0x03, 0xFFFFFFFF) + + # Reactivate card (may have timed out) + result = self.reactivate_card() + if result is None: + print(" Failed to reactivate card") + return None + + uid_check, _ = result + if uid_check != uid: + print(f" UID mismatch after reactivation: {uid_check.hex()} != {uid.hex()}") + return None + + # Read blocks with per-sector authentication + blocks = {} + current_sector = -1 + + for block in BAMBU_BLOCKS: + sector = block // 4 + + # Authenticate when entering a new sector + if sector != current_sector: + key = get_sector_key(keys, block) + if not self.mfc_authenticate(block, key, uid): + print(f" Auth failed for block {block} (sector {sector})") + return None + current_sector = sector + + # Read the block + data = self.mfc_read_block(block) + if data is None: + print(f" Read failed for block {block}") + return None + blocks[block] = data + + return blocks + + def read_ntag(self, uid: bytes) -> bytes | None: + """Read NTAG pages 4-20 (NDEF data area, 68 bytes). No auth needed. + + Used for SpoolEase / OpenPrintTag community tags. + """ + # Reactivate card + result = self.reactivate_card() + if result is None: + print(" Failed to reactivate card") + return None + + return self.ntag_read_pages(start_page=4, num_pages=17) + + +def _print_hex_dump(data: bytes, label: str, bytes_per_line: int = 16): + """Print a hex dump with ASCII sidebar.""" + for i in range(0, len(data), bytes_per_line): + chunk = data[i : i + bytes_per_line] + hex_str = " ".join(f"{b:02X}" for b in chunk) + ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + print(f" {label}{i:3d}: {hex_str:<{bytes_per_line * 3}}|{ascii_str}|") + + +def main(): + print("=" * 60) + print("PN5180 NFC Tag Reader") + print(" Supports: Bambu (MIFARE Classic) + NTAG (SpoolEase/OpenPrintTag)") + print("=" * 60) + + nfc = PN5180() + try: + nfc.reset() + ver = nfc.read_eeprom(0x10, 2) + print(f"[1] Reset OK — product v{ver[1]}.{ver[0]}") + + nfc.load_rf_config(0x00, 0x80) # ISO 14443A + time.sleep(0.010) + nfc.rf_on() + time.sleep(0.030) + nfc.set_transceive_mode() + + rf = nfc.read_reg(0x1D) + print(f"[2] RF ON (RF_STATUS=0x{rf:08X}, TX_RF={'ON' if rf & 1 else 'OFF'})") + + print("[3] Scanning for tag...") + result = nfc.activate_type_a() + + if result is None: + print(" No tag found.") + sys.exit(1) + + uid, sak = result + tag_types = {0x00: "NTAG", 0x08: "MIFARE Classic 1K", 0x18: "MIFARE Classic 4K"} + print(f" UID : {uid.hex().upper()}") + print(f" SAK : 0x{sak:02X} ({tag_types.get(sak, 'Unknown')})") + + if sak in (0x08, 0x18): + # MIFARE Classic 1K or 4K — Bambu Lab tag + print("[4] Reading Bambu tag data (MIFARE Classic)...") + blocks = nfc.read_bambu_tag(uid) + + if blocks is None: + print(" Failed to read tag data.") + nfc.rf_off() + sys.exit(1) + + print("[5] Tag data:") + for block_num in BAMBU_BLOCKS: + data = blocks[block_num] + hex_str = " ".join(f"{b:02X}" for b in data) + ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in data) + print(f" Block {block_num:2d}: {hex_str} |{ascii_str}|") + + raw = b"" + for block_num in BAMBU_BLOCKS: + raw += blocks[block_num] + print(f"\n Raw payload ({len(raw)} bytes): {raw.hex().upper()}") + + elif sak == 0x00: + # NTAG — SpoolEase / OpenPrintTag + print("[4] Reading NTAG data (pages 4-20)...") + ntag_data = nfc.read_ntag(uid) + + if ntag_data is None: + print(" Failed to read NTAG data.") + nfc.rf_off() + sys.exit(1) + + print(f"[5] NTAG data ({len(ntag_data)} bytes):") + _print_hex_dump(ntag_data, "page ") + + else: + print(f" Unsupported tag type (SAK=0x{sak:02X})") + nfc.rf_off() + sys.exit(1) + + nfc.rf_off() + print("\n" + "=" * 60) + print("Tag read complete!") + print("=" * 60) + + except Exception as e: + print(f"\nERROR: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) + finally: + nfc.close() + + +if __name__ == "__main__": + main()