NFC and the Card Database
The NFC subsystem turns a card tap into a playback intent in under half a second. Its defining design choice: cards are identifiers, not storage.
UID-only, on purpose
Every ISO14443 tag carries a factory unique ID (UID) readable in the first protocol exchange, before authentication, sector reads, or NDEF parsing. Safi reads only that UID and looks it up in its own database. The alternative (writing the binding onto the card as NDEF) was rejected on every axis that matters here:
- Compatibility: UID-reading works with literally any tag family (NTAG, MIFARE Classic, Ultralight, payment-card-shaped fobs); writing requires per-family handling and excludes locked tags.
- Robustness: a card can never be "corrupted", and a stranger's NFC-writer prank cannot alter what your card does.
- Latency: one exchange versus several; taps feel instant.
- The cost: bindings live on one device. For a single-appliance product that is no cost at all.
The PN532 over I2C
The PN532 speaks I2C at 400 kHz on the shared I2C_NUM_1 bus (GPIO 8 SDA, GPIO 9 SCL), initialized in system_init.c alongside the DS3231 RTC. The driver attaches to the master bus via garag/esp-idf-pn532. Safi runs it in polling mode: a scan task asks "any target in field?" a few times per second rather than wiring the IRQ line. The tradeoff is deliberate: polling costs microwatts and one task, saves a GPIO plus an ISR path, and 200 ms worst-case detection latency is imperceptible against the human gesture of physically placing a card.
Init failure (module absent, loose breadboard jumper, or incorrect DIP switch settings) lands in the boot report and disables the subsystem cleanly without destroying the shared I2C bus for the RTC. The serial console provides a live i2c diagnostic command to probe electrical pin levels and verify peripheral presence.
Presence, not just detection
The scan loop tracks state, not events: a card entering the field publishes EVENT_NFC_CARD_DETECTED (with the UID); the same card remaining in field publishes nothing; the field going empty publishes EVENT_NFC_CARD_REMOVED. Debouncing lives here too, since RF coupling flickers at range margins: a card must be absent for a couple of consecutive polls before removal fires.
That removal event is what powers the user-visible card removal policy (continue, pause, stop): event_handlers.c applies the configured policy, including the pleasant "pause while lifted, resume on re-tap of the same card" behavior, which needs exactly the state this loop keeps.
The card database
card_database.c maps UID to action: a small fixed-capacity table (default 50, a Kconfig option) persisted as an NVS blob. An entry is UID, a display name, and a tagged union: an SD file (a reciter and surah, or a direct path), or a station reference.
Registration is a dashboard-driven handshake with the scan loop. The REST layer keeps the most-recently-tapped UID (s_last_uid, fed by an EVENT_NFC_CARD_DETECTED subscription) so GET /api/rfid/scan can hand it back to a browser that cannot hold the WebSocket open:
An unknown card shows a brief toast and does nothing else, by design: a device that reacts to arbitrary cards invites accidents.
Tap to sound, end to end
Detected UID, database hit, then event_handlers.c issues the player command (play_file for an SD binding, play_stream for a station). File bindings pass through resolve_sd_path() first, which anchors a bare path under the /sdcard mount so a card that stores Al-Husari/001.mp3 still resolves. Measured tap-to-audio is dominated by file open and first decode, not NFC. The chain touches four components and, per the architecture's rule, none of them knows the others exist.