This file documents how to work in this repository, intended for both human contributors and AI coding agents.
This is an external ESPHome component for the ST25R3916 NFC reader chip. It provides ISO14443A (NFC-A) tag detection, UID reading, multi-tag anticollision, Mifare Classic authentication and block read, and tag presence/removal triggers for Home Assistant integration.
Component locations:
components/st25r/— abstract base (C++ protocol logic, ISO14443A state machine, triggers, sensors)components/st25r_spi/— SPI transport variantcomponents/st25r_i2c/— I2C transport variant
components/
st25r/ Base component (st25r.h/cpp, crypto1.h/cpp, __init__.py, binary_sensor.py)
st25r_spi/ SPI transport (st25r_spi.h/cpp, __init__.py)
st25r_i2c/ I2C transport (st25r_i2c.h/cpp, __init__.py)
docs/
st25r3916.pdf Datasheet DS12484 Rev 8 (primary reference)
memory/
multitag_anticol.md Multi-tag anticollision algorithm — bugs found, fixes applied
datasheet_notes.md Register map and SPI/I2C protocol details
.github/workflows/
compile.yml Compiles tests/ci-test-spi.yaml and tests/ci-test-i2c.yaml via ESPHome
tests/
ci-test-spi.yaml CI firmware config for SPI (Arduino, esp32dev, compile-only)
ci-test-i2c.yaml CI firmware config for I2C (Arduino, esp32dev, compile-only)
local-test.yaml Local dev firmware config (ESP32-C6 + Elechouse ST25R3916 module, SPI)
test-vicino-i2c-c6.yaml Local dev I2C config (ESP32-C6, SDA=GPIO10, SCL=GPIO19)
test-*.yaml Other local hardware test configs
The memory/ folder contains hard-won technical knowledge that is not derivable from reading the code alone. Always read relevant files here before making changes to the protocol or register configuration.
| File | Contents |
|---|---|
memory/multitag_anticol.md |
Complete multi-tag anticollision algorithm; ST25R3916-specific FIFO behaviour; Mifare Classic quirks; cascade (7-byte UID) state save/restore; NVB encoding |
memory/datasheet_notes.md |
Verified register map, SPI protocol, IRQ bit definitions, direct command table |
| YAML key | Default | Description |
|---|---|---|
update_interval |
1s |
Tag polling rate |
rf_power |
15 |
TX driver power 0–15 (15 = max) |
rf_field_enabled |
true |
Enable RF field on startup |
supply_3v3 |
true |
Set for 3.3 V supply (IO_CONF2 sup3V bit) |
status |
— | Binary sensor: chip health |
field_strength |
— | Sensor: raw amplitude ADC value |
irq_pin |
— | Optional; if omitted, polls IRQ_MAIN register |
reset_pin |
— | Optional hardware reset |
- Follow the ESPHome C++ style guide.
- Use
esphome::namespace conventions; component classes live innamespace esphome { namespace st25r { ... } }. - Do not use
delay()in the main scan loop — usemillis()-based non-blocking state machines inloop(). Shortdelay()calls are acceptable insidereset_()and one-shot operations (HALT frame transmission, field-on stabilisation). - Use
ESP_LOGD/ESP_LOGI/ESP_LOGW/ESP_LOGEfor logging. Do not leave noisy per-frameLOGDtraces in merged code. - Pure virtual transport methods (
read_register,write_register,write_command,write_fifo,read_fifo) must be implemented by each transport subclass — never call them from the base class constructor.
- Schema files are
__init__.pyunder each component folder. - Follow PEP 8 style.
- New config options must have sensible defaults so existing YAML configs continue to work.
esphome compile tests/ci-test-spi.yaml
esphome compile tests/ci-test-i2c.yamlBoth must compile without errors before merging.
esphome run tests/local-test.yaml
esphome logs tests/local-test.yamlblack --check components/
pylint components/There is no automated test runner; CI is compile-only validation. See TESTING.md for hardware validation procedures.
- SPI pins: CLK=GPIO19, MISO=GPIO10, MOSI=GPIO18, CS=GPIO6, IRQ=GPIO7
- Avoid GPIO9 for CS on ESP32-C6 (strapping pin)
- I2C slave address:
0x50 - I2C_EN pin: GND = SPI mode, VDD_D = I2C mode
- Read
memory/datasheet_notes.mdandmemory/multitag_anticol.mdbefore touching protocol code. - Edit C++ sources under
components/st25r/(and/orst25r_spi/,st25r_i2c/). - Update the Python schema (
__init__.py) if new YAML config options are added. - Update
README.mdfor any new config variables. - If behaviour changes, update
TESTING.mdaccordingly. - Compile both CI YAML configs and confirm zero errors.
Read memory/multitag_anticol.md first. Key invariants that must be preserved:
RX_CONF3 = 0xE2must be written inupdate()before WUPA (not inreset_())- Use WUPA (not REQA) after HALTing a tag — Mifare Classic returns to HALT, not IDLE
- Send WUPA before each new anticol prefix branch (20 ms timeout per branch)
- OR saved prefix bits back into FIFO data after
read_fifo()for UID reconstruction - Save CL1 collision state before entering CL2 for cascade (7-byte UID) tags
- Minimal firmware configs for compile validation only.
- Do not add secrets or real pin assignments that break in headless CI.
- Real pin assignments live in
local-test.yaml.
| Protocol | Status | Notes |
|---|---|---|
| ISO14443A (NFC-A) | Working | UID detection, multi-tag, anticollision |
| Mifare Classic auth | Working | Crypto1 3-pass auth; see below for clone card caveat |
| Mifare Classic block read | Working | 16-byte block read with parity verification |
| Mifare Classic NDEF | Not started | Would need sector/block traversal on top of auth |
| ISO14443B (NFC-B) | Not implemented | ST25R3916 supports it; MODE register value differs |
| ISO15693 (NFC-V) | Not implemented | ST25R3916 supports it; requires different MODE + protocol |
| FeliCa (NFC-F) | Not implemented | ST25R3916 supports it |
3-pass mutual authentication flow:
- Send AUTH1 (
0x60/0x61+ block) — tag responds with NT (4-byte nonce) - Compute NR+AR with Crypto1 (
crypto1_init, prime withNT ^ UID, thencrypto1_byte/crypto1_bitfor 8 bytes) - Send NR+AR via
transceive_mifare_()— tag responds with AT - Verify AT =
prng_successor(AR_plain, 32) ^ crypto1_word(cs, 0, 0)
Critical: parity bits advance Crypto1 LFSR state. Use crypto1_bit(cs, 0, 0) (1-bit advance) for each parity byte — NOT crypto1_filter(cs->odd) (which reads output without advancing). This applies in both TX (NR/AR encoding) and RX (block data decryption) paths.
AR computation: prng_successor(NT, 64) — advance tag PRNG by 64 steps, then encode MSB-first over 4 bytes.
Cards where NT never changes (e.g. DEA30D00 always returns NT=0x009080A2) are clone/magic cards with broken PRNG. These respond to AUTH1 with the static NT, but silently HALT on receiving NR+AR. The implementation is cryptographically correct — clone cards simply do not complete authentication. Genuine NXP Mifare Classic 1K generates a random NT each time.
Set ISO14443A_CONF = 0xC0 (no_tx_par | no_rx_par) before each Mifare transceive so the chip passes raw bits without inserting/checking hardware parity. Host software packs/unpacks 9-bit frames (8 data + 1 parity per byte) into the FIFO manually.
Send ST25R_CMD_RESET_RX_GAIN (0xD5) before each transceive (both transceive_ex_ and transceive_mifare_) to reset AGC/squelch and ensure clean reception.
- SPI Mode wrong in
st25r_spi.h: should beCLOCK_PHASE_TRAILING(Mode 1), currentlyCLOCK_PHASE_LEADING— works in practice due to signal timing margins. - IC identity check:
(ic_identity >> 3) != 0x05should be(ic_identity & 0xF8) != 0x28. - Space B register access broken:
write_register()masksaddr & 0x3F, so registers 0x40–0x7F cannot be written via normal path. CORR_CONF1/2 left at factory defaults.
- ESPHome external components guide
- ESPHome contributing guide
- ST25R3916 datasheet:
docs/st25r3916.pdf(DS12484 Rev 8) - Architecture pattern reference: esphome_pn532