Skip to content

feat: M5Stack NFC Support - ST25R3916 (Grove I2C) reader - #294

Merged
rednblkx merged 13 commits into
rednblkx:mainfrom
mullender:feat/st25r3916-reader
Aug 17, 2026
Merged

feat: M5Stack NFC Support - ST25R3916 (Grove I2C) reader#294
rednblkx merged 13 commits into
rednblkx:mainfrom
mullender:feat/st25r3916-reader

Conversation

@mullender

@mullender mullender commented Aug 1, 2026

Copy link
Copy Markdown

This PR was created with AI, and reviewed and verified by me before submission.

feat: ST25R3916 (I2C) reader backend + solder-free M5Stack build

Implements INfcReader for the ST25R3916, selectable as nfcReaderType = 2
alongside PN532 (0) and PN7160 (1).

Developed against an M5Stack AtomS3 Lite + M5Stack Unit NFC
(ST25R3916-AQWT, SKU U216) connected over Grove I2C. No soldering, no
breadboard, no jumper wires — which is the main reason this is worth having:
it makes a working Home Key reader available for people who don't want to
build a circuit.

What the driver has to do that the PN532 hid

The PN532 implements two layers in its own firmware that a bare transceiver
does not, so both are implemented here in software:

  • RATSInListPassiveTarget does this internally. pollForTag() runs it
    after anticollision when SAK indicates ISO-DEP, so a tag handed to the caller
    is genuinely ready for exchangeApdu().
  • ISO-DEP block framingInDataExchange does this in hardware.
    exchangeApdu() implements I-block framing, block-number toggling, S(WTX)
    waiting-time extension and R(NAK) error recovery.

Two silicon behaviours found the hard way

  • The received CRC is left in the FIFO. Taking the last two bytes of an APDU
    response as SW1 SW2 yields the CRC instead — a SELECT that had actually
    succeeded reported SW=0x5B62.
  • ANTICOLLISION carries no CRC in either direction. With RX CRC checking
    left on, a perfectly good UID+BCC response is flagged as a CRC error.
    no_crc_rx (AUX bit 0x80) is toggled per exchange.

No IRQ pin, and that turns out to be fine

The Grove connector doesn't break out IRQ, so every wait polls the interrupt
status registers over I2C. Measured on hardware:

step time
oscillator stable (I_osc) 1197 µs
ISO14443-A response (I_rxe) ~600 µs
SELECT AID 15 ms
Auth0 (112 B cmd, 1 WTX round) 76 ms
control flow 15 ms
full FAST-flow authentication 130–160 ms

ECP is transmitted with CMD_TRANSMIT_WITHOUT_CRC plus the software CRC-A that
NfcManager already appends — no transparent or stream mode required, and no
RFAL dependency.

Also in scope, because the reader is unusable without them

  • NfcManager no longer claims four SPI pins for an I2C reader. Only
    nfcGpioPins[0] (SDA) and [1] (SCL) are acquired, under new SDA/SCL pin
    functions. Claiming MISO/MOSI reserved two arbitrary GPIOs under misleading
    names — which matters on a board like the AtomS3 Lite that exposes only a
    handful.
  • The startup reader-type log was a two-way ternary and printed PN7160 for any
    value other than 0, so a correctly configured ST25R3916 reported the wrong
    hardware.
  • The web UI offers ST25R3916 in the reader dropdown, relabels the first two pin
    fields SDA/SCL and hides MISO/MOSI for it. Without the option the select
    rendered blank and saving the page would have written a bogus reader type.
  • Docs: supported-reader list, the M5Stack wiring and settings (reader type,
    SDA=2, SCL=1, optional NeoPixel on GPIO 35), and the component table updated
    to describe three reader backends rather than calling NfcManager
    PN532-specific.

Testing

Verified end to end against a real iPhone: ECP wakeup → anticollision → RATS →
SELECT A00000085801019000 → full FAST-flow authentication → lock state
propagated to HomeKit and MQTT.

The backend also has host-side unit tests (33 cases against a fake chip)
covering CRC trimming, block-number toggling, WTX handling and NAK recovery.
They are not included here as the repo has no host test harness — happy to add
them under whatever layout you'd prefer.

Clean build on ESP-IDF v5.5.4 (gcc 14.2.0), esp32s3 target.

ISO-DEP chaining

Commands and responses larger than the card's FSC are split and reassembled:

  • Transmit chaining splits the command into FSC-sized I-blocks, sets the M
    bit on all but the last, waits for R(ACK) between chunks and toggles the
    block number per chunk.
  • Receive chaining reassembles a chained response, requesting each
    continuation with R(ACK) carrying the received block number toggled, per
    ISO 14443-4. S(WTX) is handled inside the chain, not only around it.

The block number is taken from the PCB the card actually sent rather than
assumed, so a chained response leaves the session aligned. Responses are
bounded at 4 KB.

FSC bounds what the card is willing to receive; its own responses are bounded
by the FSD advertised in RATS, so the receive buffer is sized from the frame
buffer rather than from FSC.

Exercised via the attestation flow on hardware: a 723-byte attestation package
reassembled from 255 + 255 + 217 bytes across chained reads.

chained APDU ok: 90 ms, 5 in / 255 out (1 TX frame, 2 RX frames, FSC 256, 0 WTX)
chained APDU ok: 49 ms, 5 in / 255 out (1 TX frame, 2 RX frames, FSC 256, 0 WTX)
ATT PKG: 538202CD... (723 bytes)

20 successful attestation authentications over the test run.

Known limitations

  • ANTICOLLISION is sent with NVB=0x20 only, so two devices in the field at
    once are not resolved.
  • RFAL's per-technology analog presets are not applied, which may limit range.
image

Summary by CodeRabbit

  • New Features

    • Added support for ST25R3916 NFC readers over I2C.
    • Added reader selection and SDA/SCL pin configuration in the web interface.
    • Added NFC tag discovery, authentication, and APDU communication support.
    • Improved reader identification, connection status logging, and health handling.
    • Added validation for ST25R3916-specific pin configurations and wiring guidance.
  • Documentation

    • Updated prerequisites, hardware setup, supported NFC modules, project structure, and build requirements for ESP-IDF 5.4 or newer.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a7e3beef-9fac-4354-8bb9-2b086c4b9bce

📥 Commits

Reviewing files that changed from the base of the PR and between 2844ba1 and aa52296.

📒 Files selected for processing (2)
  • README.md
  • docs/content/prerequisites.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • README.md
  • docs/content/prerequisites.md

Included review availability: Your plan includes up to 4 reviews per rolling hour; 3 remain after this review.


Walkthrough

Changes

ST25R3916 I2C reader support

Layer / File(s) Summary
Reader contract and transport
main/include/St25r3916Reader.hpp, main/St25r3916Reader.cpp
Adds the St25r3916Reader interface and implements I2C setup, register and FIFO access, interrupt polling, RF control, and health checks.
Tag discovery and ISO-14443 handling
main/St25r3916Reader.cpp
Implements WUPA polling, anticollision, UID assembly, SAK processing, RATS parsing, and discovery lifecycle management.
ISO-DEP APDU exchange
main/St25r3916Reader.cpp
Adds presence checks and APDU exchange with chaining, block tracking, FWT/S(WTX) handling, bounded retries, and response validation.
Reader selection and runtime integration
main/include/NfcManager.hpp, main/NfcManager.cpp, main/main.cpp, main/CMakeLists.txt
Adds SDA/SCL pin handling, selects reader type 2, passes resolved NFC pins to NfcManager, logs ST25R3916 settings, and builds the new backend.
NFC pin ownership validation
main/WebServerManager.cpp
Applies NFC ownership rules to scalar and array GPIO validation and accepts ST25R3916 in captive-portal configuration.
Configuration UI and documentation
data/src/lib/components/HardwareConfig.svelte, README.md, docs/content/prerequisites.md
Adds the ST25R3916 option, displays SDA/SCL configuration, documents AtomS3 Lite wiring, and updates reader and ESP-IDF requirements.

Sequence Diagram(s)

sequenceDiagram
  participant ConfigurationUI
  participant WebServerManager
  participant NfcManager
  participant St25r3916Reader
  participant ST25R3916
  participant NFC_Tag

  ConfigurationUI->>WebServerManager: Submit ST25R3916 and SDA/SCL settings
  WebServerManager->>WebServerManager: Validate NFC pin ownership
  NfcManager->>St25r3916Reader: Initialize with SDA/SCL pins
  St25r3916Reader->>ST25R3916: Configure I2C, RF field, and interrupts
  NfcManager->>St25r3916Reader: Begin tag discovery
  St25r3916Reader->>NFC_Tag: Perform ISO14443 discovery and RATS
  NfcManager->>St25r3916Reader: Exchange APDU
  St25r3916Reader->>NFC_Tag: Exchange ISO-DEP frames
  NFC_Tag-->>St25r3916Reader: Return APDU response
Loading

Poem

A rabbit set SDA beside SCL,
And watched the reader start up well.
WUPA called; the tag replied,
ISO-DEP frames crossed side to side.
APDUs hopped through every trail—
ST25R3916 now joins the tale.

Merge Risk: 🔵 Low · up to aa522

The PR adds ST25R3916 support, but installation guidance still points users to PN532 wiring, which could lead to incorrect setup for the new reader. This is a bounded documentation issue that is mergeable with explicit owner follow-up.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding ST25R3916 Grove I2C NFC support for M5Stack.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mullender

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
main/St25r3916Reader.cpp (1)

104-109: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Clamp the derived frame waiting time.

fwiToMs(14) returns 81925 ms. exchangeApdu uses m_fwtMs as the transceive timeout, and waitInterrupt polls I2C in a tight loop for that whole period. A card that advertises a large FWI therefore blocks the NFC polling task for tens of seconds. Cap the value at a practical ceiling.

♻️ Proposed clamp
 uint32_t fwiToMs(uint8_t fwi) {
     if (fwi > 14) fwi = 14;
     uint32_t ms = 5;
     for (uint8_t i = 0; i < fwi; i++) ms *= 2;
-    return ms + 5;
+    ms += 5;
+    // Cap the wait: no Home Key transaction needs more than ~2 s, and a longer
+    // wait blocks the polling task on the I2C interrupt poll loop.
+    constexpr uint32_t kMaxFwtMs = 2000;
+    return ms > kMaxFwtMs ? kMaxFwtMs : ms;
 }
main/NfcManager.cpp (1)

295-298: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Validate the I2C pins before constructing the reader.

The type 1 branch rejects unset IRQ/VEN pins. The type 2 branch accepts unset pins, so an unconfigured device reaches i2c_new_master_bus with sda_io_num = 255 and reports only a driver argument error. Apply the same explicit check for consistent diagnostics.

♻️ Proposed fix
     } else if (m_nfcReaderType == 2) {
+        if (nfcGpioPins[0] == 255 || nfcGpioPins[1] == 255) {
+            ESP_LOGE(TAG, "ST25R3916 selected but SDA/SCL pins are unset");
+            return false;
+        }
         // I2C: nfcGpioPins[0] = SDA, [1] = SCL. Entries [2]/[3] are unused.
         m_reader = std::make_unique<St25r3916Reader>(nfcGpioPins, m_ecpData);
         ESP_LOGI(TAG, "Using ST25R3916 reader (I2C)");

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 81ec50ed-3f80-4de9-a925-d32aa7cf8dfc

📥 Commits

Reviewing files that changed from the base of the PR and between c330c70 and 256b695.

📒 Files selected for processing (8)
  • README.md
  • data/src/lib/components/HardwareConfig.svelte
  • main/CMakeLists.txt
  • main/NfcManager.cpp
  • main/St25r3916Reader.cpp
  • main/include/NfcManager.hpp
  • main/include/St25r3916Reader.hpp
  • main/main.cpp
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*

⚙️ CodeRabbit configuration file

assess the relevance of the changes to the purpose of the PR and do not mention anything if they are relevant.

Files:

  • main/CMakeLists.txt
  • main/main.cpp
  • main/include/NfcManager.hpp
  • main/NfcManager.cpp
  • README.md
  • main/include/St25r3916Reader.hpp
  • data/src/lib/components/HardwareConfig.svelte
  • main/St25r3916Reader.cpp
🪛 LanguageTool
README.md

[grammar] ~70-~70: Ensure spelling is correct
Context: ...kHz is required - M5Stack document that SoftwareI2C latency is too high > for the chip's RF...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🔇 Additional comments (11)
main/include/St25r3916Reader.hpp (1)

37-144: LGTM!

main/St25r3916Reader.cpp (5)

125-241: LGTM!


328-344: LGTM!


451-524: LGTM!


528-647: LGTM!


651-793: LGTM!

main/include/NfcManager.hpp (1)

104-107: LGTM!

main/NfcManager.cpp (1)

231-243: LGTM!

main/CMakeLists.txt (1)

3-3: LGTM!

data/src/lib/components/HardwareConfig.svelte (1)

48-58: LGTM!

Also applies to: 125-125, 148-151, 163-163, 173-205

README.md (1)

36-64: LGTM!

Also applies to: 276-278, 301-302, 315-318

Comment thread main/main.cpp Outdated
Comment thread main/St25r3916Reader.cpp
Comment thread main/St25r3916Reader.cpp
Comment thread main/St25r3916Reader.cpp Outdated
Comment thread main/St25r3916Reader.cpp
Comment thread README.md Outdated
@rednblkx

rednblkx commented Aug 1, 2026

Copy link
Copy Markdown
Owner

It looks good, i don't have the hardware to test it, but looking at it, i trust that the code is functional.

Before i merge this, please look into testing the Attestation flow and implement the right procedure for this nfc chip to handle >256 bytes payloads / extended frames. sometimes provision might not occur or for some reason fail and attestation can fill in the gap, i haven't experienced this but other people did and haven't found the root cause.

There's a debug command you can use to force the use of the Attestation flow to test, but first you need to press a key on reset to enable serial input (c2a8d01) and then enter @F2, let me know how it goes.

@mullender

Copy link
Copy Markdown
Author

I will work on addressing the comments, and update the pull request. I want to make sure I test everything again.

mullender added 4 commits August 2, 2026 18:09
Implements INfcReader for the ST25R3916, selectable as nfcReaderType = 2
alongside PN532 (0) and PN7160 (1). Developed against an M5Stack AtomS3 Lite
plus M5Stack Unit NFC (ST25R3916-AQWT, SKU U216) over Grove I2C -- a
solder-free combination.

The PN532 hides two layers in its own firmware that a bare transceiver does
not, so both are implemented here:

  - RATS. InListPassiveTarget performs it internally; pollForTag() runs it
    after anticollision when SAK indicates ISO-DEP, so a tag reported to the
    caller is genuinely ready for exchangeApdu().
  - ISO-DEP block framing. InDataExchange handles it in hardware; exchangeApdu()
    does I-block framing, block-number toggling, S(WTX) waiting-time extension
    and R(NAK) error recovery in software.

Two behaviours of this silicon are easy to get wrong and were found the hard
way:

  - The received CRC is left in the FIFO. Taking the last two bytes of an APDU
    response as SW1 SW2 yields the CRC instead; a SELECT that had actually
    succeeded reported SW=0x5B62.
  - ANTICOLLISION carries no CRC in either direction. With RX CRC checking
    left on, a perfectly good UID+BCC response is flagged as a CRC error.
    no_crc_rx (AUX bit 0x80) is toggled per exchange.

The Grove connector does not break out the IRQ pin, so every wait polls the
interrupt status registers over I2C. Measured on hardware, this is comfortable:

    oscillator stable (I_osc)        1197 us
    ISO14443-A response (I_rxe)       ~600 us
    SELECT AID                          15 ms
    Auth0 (112 B cmd, 1 WTX round)      76 ms
    control flow                        15 ms
    full FAST-flow authentication      130-160 ms

ECP is transmitted with CMD_TRANSMIT_WITHOUT_CRC and the software CRC-A that
NfcManager already appends; no transparent or stream mode is required.

Also in this commit, because they are part of making the reader usable:

  - NfcManager no longer claims four SPI pins for an I2C reader. Only
    nfcGpioPins[0] (SDA) and [1] (SCL) are acquired, under new SDA/SCL pin
    functions. Claiming MISO/MOSI reserved two arbitrary GPIOs under
    misleading names, which matters on a board like the AtomS3 Lite that
    exposes only a handful.
  - The startup reader-type log was a two-way ternary and printed "PN7160" for
    any value other than 0, so a correctly configured ST25R3916 reported the
    wrong hardware.
  - The web UI offers ST25R3916 in the reader dropdown, relabels the first two
    pin fields SDA/SCL and hides MISO/MOSI for it. Without the option the
    select rendered blank and saving the page would have written a bogus
    reader type.

Verified end to end against a real iPhone: ECP wakeup, anticollision, RATS,
SELECT A0000008580101 -> 9000, full FAST-flow authentication, lock state to
HomeKit and MQTT.

Known limitations: ISO-DEP chaining is not implemented (every Home Key command
observed so far fits the card's 256-byte FSC, but the attestation flow has not
been exercised); ANTICOLLISION is sent with NVB=0x20 only, so two devices in
the field at once are not resolved; RFAL's per-technology analog presets are
not applied, which may limit range.
Adds the ST25R3916 to the supported readers and documents an M5Stack pairing
that needs no soldering, breadboard or jumper wires -- the AtomS3 Lite and the
Unit NFC connect with the supplied Grove cable. That lowers the barrier for
anyone who wants a working Home Key reader without building a circuit.

Includes the exact settings the combination needs (reader type, SDA=2, SCL=1,
optional NeoPixel on GPIO 35), measured authentication latency of 130-160 ms,
and a note that the Grove connector omits the IRQ pin so hardware I2C at
400 kHz is required.

Also records the real ESP-IDF requirement in the build instructions. The
previous text linked to the generic install guide without naming a version,
while idf_component.yml claimed 5.3.0 and the tree does not compile below 5.4.

Project structure and component table updated to list the three reader
backends rather than describing NfcManager as PN532-specific.
Three items raised in review of rednblkx#294.

The startup log reported the wrong pins. NfcManager is handed
nfcGpioPinsPresets[...].gpioPins whenever a preset is selected, but the
ST25R3916 branch logged miscConfig.nfcGpioPins, so a device using a preset
printed SDA/SCL that the reader was not using. The pins are now resolved once
into activeNfcPins and that same value feeds both the log line and the
NfcManager constructor, so the two cannot disagree.

The reader type 2 branch accepted unset pins. Type 1 rejects an unset IRQ/VEN
pair with a clear message; type 2 passed 255 straight through to
i2c_new_master_bus(), which fails with a generic driver argument error that
says nothing about configuration. Same explicit check added.

README: "M5Stack document" -> "M5Stack documents".
Chaining
--------

exchangeApdu() assumed every command and response fitted the card's FSC.
Every Home Key exchange observed in the FAST flow does -- the largest is a
112 byte Auth0 command with an 87 byte response against FSC 256 -- but the
attestation flow does not, and that flow exists precisely to cover cases where
provisioning did not complete. An oversized APDU previously failed outright.

Both directions are now implemented:

  - Transmit chaining splits the command into FSC-sized I-blocks, sets the M
    bit on all but the last, waits for R(ACK) between chunks and toggles the
    block number per chunk.
  - Receive chaining reassembles a chained response, requesting each
    continuation with R(ACK) carrying the received block number toggled, per
    ISO 14443-4. S(WTX) is handled inside the chain, not just around it.

The block number is taken from the PCB the card actually sent rather than
assumed, so a chained response leaves the session aligned for the next
exchange. Responses are bounded at 4 KB so a misbehaving card cannot grow the
heap without limit.

Also removes an incorrect assumption: FSC bounds what the card is willing to
*receive*. Its own responses are bounded by the FSD advertised in RATS, so the
receive buffer is sized from the frame buffer rather than from FSC.

Review fixes
------------

Both RX waits could block for the full timeout after the response had already
arrived. readInterrupts() clears the status registers as it reads them, so a
poll that observed IRQ_TXE may have latched IRQ_RXE in the same read; the
following wait could then never see that bit and spun to its deadline. The
exchange still succeeded, which is why this showed up as latency rather than
failure. Both transceive() and shortFrame() now skip the RX wait when the bit
has already been seen.

waitInterrupt() used an absolute deadline. nowMs() wraps every ~49.7 days, and
a wrapped "nowMs() + timeoutMs" is already in the past, so every transceive
would fail for the length of that window. Now an unsigned elapsed delta.

shortFrame() inherited AUX_NO_CRC_RX from whatever ran before it. In
isTagStillPresent() that is an R(NAK) exchange with rxCrc = true, which clears
the bit, so the WUPA fallback had its reply checked against a CRC that ATQA
does not carry -- passing only because the error flags are ignored there. Now
set explicitly.

writeFifo() reserved 513 bytes of stack per call while exchangeApdu() already
held tx[264] and rx[264] on the same stack, on a path that also runs on the
auth precompute task. Bounded to the largest frame a caller can actually build.

fwiToMs() was unclamped: FWI 14 yields 81925 ms, and with no IRQ line the wait
is a tight I2C poll, so a card advertising a large FWI would occupy the polling
task for over a minute. Capped at 2000 ms; a card that legitimately needs
longer says so with S(WTX). Measured against a real Home Key card the
advertised FWT is 645 ms, well under the cap.

Verified on hardware after these changes: FAST flow authentication in 166 ms
(SELECT 17 ms, Auth0 112 B command with one WTX round 81 ms, control flow
14 ms), lock state propagated to HomeKit and MQTT, precompute task headroom
3984 of 8192 bytes.
@mullender
mullender force-pushed the feat/st25r3916-reader branch from 256b695 to 61a418a Compare August 3, 2026 02:10

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
main/St25r3916Reader.cpp (2)

331-343: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider yielding inside the interrupt poll loop.

waitInterrupt polls without any yield. Each iteration issues four I2C reads, so the loop is rate-limited but never blocks. With the FWT cap the loop can hold the CPU for up to 2000 ms. If several long waits run back to back, the idle task on that core can starve and the task watchdog can fire. A short vTaskDelay(1) after the first few iterations keeps the fast path fast and lets other tasks run during long waits.

♻️ Proposed refactor
     const uint32_t start = nowMs();
     uint32_t seen = 0;
+    uint32_t spins = 0;
     do {
         seen |= readInterrupts();
         if (seen & mask) break;
+        // Spin for the common sub-millisecond case, then yield so long waits
+        // do not starve the idle task.
+        if (++spins > 8) vTaskDelay(1);
     } while ((nowMs() - start) < timeoutMs);
     return seen;

883-886: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the dead comparison in the chaining PCB check.

PCB_TYPE_MASK is 0xC0 and PCB_I_BLOCK is 0x02. cpcb & PCB_TYPE_MASK can never equal 0x02, so the second comparison is always true. The check is still correct because I-blocks carry type bits 00, but the dead term suggests an intent that the code does not implement.

♻️ Proposed refactor
-                    if ((cpcb & PCB_TYPE_MASK) != 0x00 && (cpcb & PCB_TYPE_MASK) != PCB_I_BLOCK) {
+                    // I-blocks are the only PCB type with bits 7:6 == 00.
+                    if ((cpcb & PCB_TYPE_MASK) != 0x00) {
                         ESP_LOGW(TAG, "unexpected PCB 0x%02X during receive chaining", cpcb);
                         return false;
                     }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d048341-c02a-4e34-bdf9-75b114d5d61f

📥 Commits

Reviewing files that changed from the base of the PR and between 256b695 and 61a418a.

📒 Files selected for processing (8)
  • README.md
  • data/src/lib/components/HardwareConfig.svelte
  • main/CMakeLists.txt
  • main/NfcManager.cpp
  • main/St25r3916Reader.cpp
  • main/include/NfcManager.hpp
  • main/include/St25r3916Reader.hpp
  • main/main.cpp
🚧 Files skipped from review as they are similar to previous changes (5)
  • main/CMakeLists.txt
  • main/NfcManager.cpp
  • main/main.cpp
  • main/include/NfcManager.hpp
  • data/src/lib/components/HardwareConfig.svelte
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*

⚙️ CodeRabbit configuration file

assess the relevance of the changes to the purpose of the PR and do not mention anything if they are relevant.

Files:

  • main/include/St25r3916Reader.hpp
  • main/St25r3916Reader.cpp
  • README.md
🪛 LanguageTool
README.md

[grammar] ~70-~70: Ensure spelling is correct
Context: ...Hz is required - M5Stack documents that SoftwareI2C latency is too high > for the chip's RF...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🔇 Additional comments (15)
README.md (6)

36-42: LGTM!


44-58: LGTM!


62-71: LGTM!


276-278: LGTM!


301-302: LGTM!


315-318: LGTM!

main/include/St25r3916Reader.hpp (1)

37-144: LGTM!

main/St25r3916Reader.cpp (8)

11-121: LGTM!


135-236: LGTM!


255-314: LGTM!


347-487: LGTM!


491-564: LGTM!


568-687: LGTM!


691-724: LGTM!

Also applies to: 837-943


242-251: 🩺 Stability & Availability

No change needed for failed healthCheck.

NfcManager::pollingTask() enters the reconnection loop when m_reader->healthCheck() returns false and calls NfcManager::initializeReader() until reconnection succeeds.

Comment thread main/St25r3916Reader.cpp
Comment thread README.md Outdated
Two problems from a single counter, both raised in review.

nakRetries was declared once per exchangeApdu() call and never cleared, so the
budget was consumed across the whole transaction rather than per recovery. With
chaining that is materially worse than before: two recoverable errors while
sending the first chunk of a chained command left no recovery at all for any
later frame -- and later frames are exactly where errors accumulate.

The same counter also doubled as "the frame I am waiting for is a reply to an
R(NAK)". It stayed non-zero after a recovery succeeded, leaving the
resend-on-R-block branch armed for the rest of the exchange. A subsequent
unexpected R-block was then read as an answer to an R(NAK) that had already been
resolved, so the driver resent the I-block instead of failing, and could keep
doing so until the 8-attempt cap.

Those are two different questions, so they are now two variables.
awaitingNakReply tracks only what we are waiting for and is cleared as soon as
it is acted on; nakRetries is the budget and is reset whenever a frame arrives
intact -- on a mid-chain R(ACK) and on a good response I-block.

Also aligns the README reader-type value with the string the web UI actually
shows, so the documented option can be selected verbatim: ST25R3916 (I2C).
@mullender

Copy link
Copy Markdown
Author

Thanks — both requests are done, and I have test results plus one finding you may care about.

ISO-DEP chaining

exchangeApdu() previously assumed every command and response fitted the card's FSC and rejected anything larger outright. Both directions are now implemented:

  • Transmit chaining splits the command into FSC-sized I-blocks, sets the M bit on all but the last, waits for R(ACK) between chunks and toggles the block number per chunk.
  • Receive chaining reassembles a chained response, requesting each continuation with R(ACK) carrying the received block number toggled, per ISO 14443-4. S(WTX) is handled inside the chain, not only around it.

The block number is taken from the PCB the card actually sent rather than assumed, so a chained response leaves the session aligned for the next exchange. Responses are bounded at 4 KB.

I also removed an incorrect assumption: FSC bounds what the card is willing to receive. Its own responses are bounded by the FSD we advertise in RATS, so the receive buffer is sized from the frame buffer, not from FSC.

Attestation testing

Forced the attestation flow and tapped repeatedly. Over roughly 8 minutes:

  • 20 successful authentications
  • 21 attestation packages assembled
  • 40 chained APDUs
chained APDU ok: 90 ms, 5 in / 255 out (1 TX frame, 2 RX frames, FSC 256, 0 WTX)
chained APDU ok: 49 ms, 5 in / 255 out (1 TX frame, 2 RX frames, FSC 256, 0 WTX)
Data Length: 255 - pkg length: 255
Data Length: 255 - pkg length: 508
Data Length: 217 - pkg length: 723
ATT PKG: 538202CD... (723 bytes)

A 723-byte attestation package reassembled from 255 + 255 + 217 across chained reads. Before this change that exchange failed.

FAST flow is unchanged and re-verified: 166 ms end to end (SELECT 17 ms, Auth0 112 B with one WTX round 81 ms, control flow 14 ms), lock state propagating to HomeKit and MQTT.

A crash in the attestation flow — root-caused, filed separately

Testing attestation surfaced a real crash, and it turned out not to be in this
PR. Filed as rednblkx/DigitalDoorKey#2.

Short version: DDKAttestationAuth::envelope1Cmd() discards the bool returned
by params.nfc() and then indexes the response vector. When an NFC exchange
fails — routine if the phone moves mid-transaction — the vector is empty and
ctrlFlowRes[0] reads out of bounds, panicking the device.

*** UNEXPECTED RESET: PANIC (exception / assert) (reason 4) ***

The trigger is a clean, correctly-reported timeout on a 4-byte command:

ISO-DEP exchange FAILED after 656 ms: no RXE (blk=0, frame 1, 0 WTX rounds,
0 NAK retries, irqs=0x08000000, 4 byte cmd)

irqs=0x08000000 is IRQ_TXE alone — transmitted, nothing back, no CRC or
framing error. That 4-byte command is ctrlFlow = {0x80, 0x3c, 0x40, 0xa0}.

I audited all seven params.nfc() call sites rather than just the one that bit
us: three live crashes, one latent, one dead store, two already correct — all in
AttestationAuth.cpp. StandardAuth.cpp and FastAuth.cpp are clean, which
matches FAST and STANDARD never crashing across hundreds of taps. Details and
suggested patches are in the issue.

This is reader-independent: any INfcReader that correctly returns false on a
timeout will hit it, so a PN532 should panic identically. It may be the
attestation problem you mentioned others reporting — the reset is fast enough
that it presents as "the tap didn't work" rather than as a crash, and on a board
with no serial access there is nothing to see unless something records the boot
log.

Ruled out along the way: heap (min-ever 56188 bytes free), NFC task stack
(high-water min 1380 bytes free), precompute stack (3888 of 8192 free).

Review comments

All CodeRabbit items addressed:

  • RX wait could block for the full timeout (the major one). readInterrupts() clears the status registers as it reads them, so a poll that saw IRQ_TXE may have latched IRQ_RXE in the same read; the following wait could then never observe that bit and spun to its deadline. The exchange still succeeded, so it showed up as latency, not failure. Both transceive() and shortFrame() now skip the RX wait when the bit has already been seen.
  • waitInterrupt() absolute deadlinenowMs() wraps every ~49.7 days and a wrapped deadline is already in the past, failing every transceive for that window. Now an unsigned elapsed delta.
  • shortFrame() inherited AUX_NO_CRC_RX — in isTagStillPresent() the preceding R(NAK) clears it, so the WUPA fallback checked a CRC that ATQA does not carry, passing only because error flags are ignored there. Set explicitly now.
  • writeFifo() 513-byte stack buffer — bounded to the largest frame a caller can build. It runs while exchangeApdu() already holds tx[264] and rx[264], on a path that also runs on the auth precompute task.
  • fwiToMs() unclamped — FWI 14 gives 81925 ms and with no IRQ line that is a tight I2C poll. Capped at 2000 ms; a card needing longer says so with S(WTX). Measured against a real Home Key card, advertised FWT is 645 ms, comfortably under the cap.
  • Startup log reported the wrong pinsNfcManager receives the preset array when a preset is selected, but the log read miscConfig.nfcGpioPins. Resolved once now and used for both.
  • No I2C pin guard — type 1 rejects unset IRQ/VEN; type 2 passed 255 through to i2c_new_master_bus(). Same check added.
  • README grammar — fixed.

And the two from the round after my push, both correct:

  • nakRetries never reset (the major one). Two bugs from one counter. The
    budget was per transaction rather than per recovery episode, so with chaining
    two errors on the first chunk left no recovery for any later frame — which is
    where errors accumulate. The same counter also stood in for "the frame I am
    waiting on answers an R(NAK)", staying non-zero after a successful recovery
    and leaving the resend-on-R-block branch armed; a later unrelated R-block was
    then read as a NAK reply and resent the I-block, potentially looping to the
    attempt cap. Those are two different questions, so they are now two variables:
    awaitingNakReply is cleared as soon as it is acted on, and nakRetries is
    reset whenever a frame arrives intact.
  • README reader label — table said ST25R3916, the UI shows
    ST25R3916 (I2C). Aligned so the documented value can be selected verbatim.

@rednblkx

rednblkx commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Thanks for fixing the attestation flow, I’ll review this and will let you know if anything else is needed.

@mullender

Copy link
Copy Markdown
Author

And apologies for the flurry of PRs. I am trying to make this as robust as possible, and so far the results of the patched implementation are very stable. I can trigger an incomplete tap by quickly moving the phone over the sensor, but the esp32 does not crash anymore.

…or NFC-owned

Two narrow rules in WebServerManager::validateRequest fix the bug where
saving the misc config on an AtomS3 Lite installer (or any board where
NfcManager currently holds ST25R3916 or PN7160 pins) would reject the
whole form with "GPIO 2 for nfcGpioPins already owned by I2C_SDA" even
when the user changed only bootLogKb:

  1. If an incoming pin scalar (or array element) equals the currently-
     persisted value, skip the ownership check. Nothing new is being
     acquired.

  2. For an NFC-related pin (nfcIrqPin, nfcVenPin, nfcGpioPins), an
     owner that is itself one of NfcManager's own eight lease names
     (SPI2_SS/SCK/MISO/MOSI, I2C_SDA/SCL, NFC_IRQ/VEN) is accepted --
     the misc-config save always reboots on success, which releases the
     old NfcManager's leases before the new one is constructed on the
     next boot. Unrelated owners (Ethernet SPI, HardwareManager action
     pins, HomeSpan status/control, etc.) still reject.

The decision is factored into a tiny host-testable helper
(main/include/misc_config_nfc_pin_check.hpp, 26 effective code lines)
so the four required cases -- unchanged full form, atomic per-pin
replacement, unrelated-subsystem conflict, ETH_SPI not exempted -- are
covered by six focused tests. Existing type/GPIO validation is
retained; the ethSpiConfig substring-SPI exemption is unchanged (it
governs ETH-vs-ETH SPI swaps and is not reachable for NFC keys).

Host tests: 6 new + 19 existing = 25/25. Both firmware variants build.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d3b04439-df4d-4aa1-9a15-7af81afeb6b8

📥 Commits

Reviewing files that changed from the base of the PR and between ee77699 and e0c505e.

📒 Files selected for processing (2)
  • main/WebServerManager.cpp
  • main/include/misc_config_nfc_pin_check.hpp
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*

⚙️ CodeRabbit configuration file

assess the relevance of the changes to the purpose of the PR and do not mention anything if they are relevant.

Files:

  • main/include/misc_config_nfc_pin_check.hpp
  • main/WebServerManager.cpp
🔇 Additional comments (1)
main/WebServerManager.cpp (1)

1221-1224: 🎯 Functional Correctness

No change needed.

std::string::contains is supported on the ESP-IDF C++ toolchain used by this project.

Comment thread main/include/misc_config_nfc_pin_check.hpp Outdated
Comment thread main/include/misc_config_nfc_pin_check.hpp Outdated
@rednblkx

rednblkx commented Aug 5, 2026

Copy link
Copy Markdown
Owner

hey, don’t worry about the PRs, I want the same thing as you and I’m aware this isn’t nowhere near a bug-free codebase, so any help is appreciated. As long as a PR is adding something genuinely useful and is well written, it is more than welcomed, however many there may be.

mullender added 2 commits August 9, 2026 16:54
Conflict: main/main.cpp NfcManager construction

Origin/main moved core managers to static storage
(readerDataManager is now `ReaderDataManager readerDataManager;`
instead of `std::unique_ptr<ReaderDataManager>`) while the PR side
extracted the `activeNfcPins` reference so the log line and the
NfcManager argument use the same resolved pin array. Resolution: pass
readerDataManager by value (origin/main form) and keep activeNfcPins
(PR-side cleanup).
@mullender

Copy link
Copy Markdown
Author

If you are happy with this PR it should be good to merge.

rednblkx pushed a commit to mullender/HomeKey-ESP32 that referenced this pull request Aug 16, 2026
Three items raised in review of rednblkx#294.

The startup log reported the wrong pins. NfcManager is handed
nfcGpioPinsPresets[...].gpioPins whenever a preset is selected, but the
ST25R3916 branch logged miscConfig.nfcGpioPins, so a device using a preset
printed SDA/SCL that the reader was not using. The pins are now resolved once
into activeNfcPins and that same value feeds both the log line and the
NfcManager constructor, so the two cannot disagree.

The reader type 2 branch accepted unset pins. Type 1 rejects an unset IRQ/VEN
pair with a clear message; type 2 passed 255 straight through to
i2c_new_master_bus(), which fails with a generic driver argument error that
says nothing about configuration. Same explicit check added.

README: "M5Stack document" -> "M5Stack documents".
@rednblkx
rednblkx force-pushed the feat/st25r3916-reader branch 2 times, most recently from 7e24a5e to 90f284c Compare August 16, 2026 20:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0d3c70ed-1be9-4e39-a738-f2ac3bea4ace

📥 Commits

Reviewing files that changed from the base of the PR and between 90f284c and 7e24a5e.

📒 Files selected for processing (9)
  • README.md
  • components/DigitalDoorKey
  • main/HomeKitLock.cpp
  • main/NfcManager.cpp
  • main/ReaderDataManager.cpp
  • main/WebServerManager.cpp
  • main/include/NfcManager.hpp
  • main/include/ReaderDataManager.hpp
  • main/main.cpp
🚧 Files skipped from review as they are similar to previous changes (4)
  • main/include/NfcManager.hpp
  • main/NfcManager.cpp
  • main/main.cpp
  • main/WebServerManager.cpp

Included review availability: Your plan includes up to 4 reviews per rolling hour; 3 remain after this review.

📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*

⚙️ CodeRabbit configuration file

assess the relevance of the changes to the purpose of the PR and do not mention anything if they are relevant.

Files:

  • components/DigitalDoorKey
  • main/ReaderDataManager.cpp
  • main/include/ReaderDataManager.hpp
  • main/HomeKitLock.cpp
  • README.md
🪛 LanguageTool
README.md

[grammar] ~72-~72: Ensure spelling is correct
Context: ...Hz is required - M5Stack documents that SoftwareI2C latency is too high > for the chip's RF...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🔇 Additional comments (8)
components/DigitalDoorKey (1)

1-1: LGTM!

main/include/ReaderDataManager.hpp (1)

91-97: LGTM!

main/ReaderDataManager.cpp (2)

7-8: LGTM!

Also applies to: 270-273


307-333: LGTM!

main/HomeKitLock.cpp (3)

431-431: LGTM!


575-580: LGTM!


590-625: LGTM!

README.md (1)

32-37: LGTM!

Also applies to: 42-73, 131-134, 282-284, 307-308, 321-324

Comment thread README.md Outdated
@rednblkx rednblkx added the wip label Aug 16, 2026
@rednblkx
rednblkx force-pushed the feat/st25r3916-reader branch from e289b38 to 90f284c Compare August 16, 2026 20:36
- Delete the single-use header `misc_config_nfc_pin_check.hpp`.
- Implement `decideNfcPin()` directly in `WebServerManager.cpp` using
boolean returns instead of custom namespaces and enum states.
- Utilize `std::any_of` to verify if the incoming pin's owner matches
defined NFC leases.
- Adapt scalar and array-based pin validation checks in
`validateRequest()` to use the simplified helper interface.
- Relocate the M5Stack AtomS3 Lite and Unit NFC (ST25R3916)
documentation from `README.md` to `docs/content/prerequisites.md`.
- Update the lists of supported NFC reader modules to include the
ST25R3916 I2C interface details.
@rednblkx rednblkx removed the wip label Aug 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 50653b66-f707-459d-88df-fa4224d41756

📥 Commits

Reviewing files that changed from the base of the PR and between e289b38 and 2844ba1.

📒 Files selected for processing (3)
  • README.md
  • docs/content/prerequisites.md
  • main/WebServerManager.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • main/WebServerManager.cpp

Included review availability: Your plan includes up to 4 reviews per rolling hour; 1 remains after this review.

📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*

⚙️ CodeRabbit configuration file

assess the relevance of the changes to the purpose of the PR and do not mention anything if they are relevant.

Files:

  • docs/content/prerequisites.md
  • README.md
🧠 Learnings (1)
📚 Learning: 2026-08-04T03:08:04.811Z
Learnt from: mullender
Repo: rednblkx/HomeKey-ESP32 PR: 298
File: docs/coredump.md:67-69
Timestamp: 2026-08-04T03:08:04.811Z
Learning: When documenting authenticated HTTP endpoints, explain that the `curl -u webUsername:webPassword` option is required only when `webAuthEnabled` is true. When web authentication is disabled, users must omit the Basic Authentication credentials because `WebServerManager::basicAuth()` rejects missing credentials only when authentication is enabled.

Applied to files:

  • docs/content/prerequisites.md
🪛 LanguageTool
docs/content/prerequisites.md

[grammar] ~142-~142: Ensure spelling is correct
Context: ...Hz is required - M5Stack documents that SoftwareI2C latency is too high > for the chip's RF...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🔇 Additional comments (3)
README.md (2)

38-41: Keep the installation step reader-neutral.

The new prerequisite list names PN532, PN7160, and ST25R3916, but Line [78] still tells every user to wire a PN532. Update that step to reference the selected reader or provide reader-specific wiring instructions, including the ST25R3916 I2C SDA/SCL settings.


253-255: LGTM!

Also applies to: 278-279, 292-295

docs/content/prerequisites.md (1)

81-83: LGTM!

Also applies to: 114-143

Comment thread docs/content/prerequisites.md
@rednblkx

Copy link
Copy Markdown
Owner

@mullender did some tweaks, please take a look and let me know if you have any thoughts. i can't test the driver but based on the code and your comments, seems to be alright. the webserver changes are fine for now, i need to overhaul the validation flow for the API anyway so it's good enough to merge.

@mullender

Copy link
Copy Markdown
Author

These changes look like improvements to me. thank you for doing that!

…es in README

- Replace hardware-specific mentions of "PN532" with "NFC module" across
the introduction, wiring steps, and Mermaid diagram.
- Add notes to the prerequisites list specifying that the PN7160 (SPI)
and ST25R3916 (I2C) backends are available in the development release.
- Update the sub-heading under the NFC reader modules section to include
PN7161 and ST25R3916.
- Format the ST25R3916 list entry to match the bold title style used for
other controllers.
@rednblkx

Copy link
Copy Markdown
Owner

thanks, all good, will merge.

@rednblkx
rednblkx merged commit 500c999 into rednblkx:main Aug 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants