SDK
AO_Nfc
Owns the NFC reader of a Kode Dot: a field that exists only while you ask, the listen window, card emulation, key sweeps and the production row.
What it is
AO_Nfc owns the 13.56 MHz NFC reader of a Kode Dot, an ST25R3916B on the antenna board. It reads the identity of a tag, reads and writes NDEF messages, tries a dictionary of public keys on a Mifare Classic card, dumps one, listens to a field that another device makes, and presents the Kode Dot as a card.
The radio field is up only while a request asks for it. When nothing is asked, the antenna sends nothing and the chip sleeps with its oscillator off. So silence on NFC_TAG_SIG means that nobody asked for a read. It does not mean that no tag was near.
AO_Nfc is a poller: it makes the field and asks the tag, and the tag only answers. It works in small pieces, one discovery step per tick, so it never blocks. When the Kode Dot acts as a card the roles flip and a reader outside decides the timing, so a task of the driver answers and AO_Nfc only watches. The chip, RFAL and the tag layers are on sdk/kdrv/st25r3916.
How it is connected
flowchart LR subgraph board["On the Kode Dot"] ao["AO_Nfc<br/>priority 19"] -- "kdrv_st25r3916<br/>SPI2" --> chip["ST25R3916B"] end app["kode_nfc_*()<br/>or an AO"] -- "NFC_*_REQ_SIG" --> ao chip -- "13.56 MHz" --> tag["Tag or reader<br/>outside"] ao -- "NFC_TAG_SIG,<br/>NFC_EMU_SIG" --> subs["Subscribers"] tick["NFC_TICK_SIG,<br/>NFC_DEADLINE_SIG"] -.-> ao class ao ao class chip chip class app,subs app class tag ext
Resources and states
AO_Nfc claims KBSP_RES_NFC: the chip, its interrupt line and the SPI bus (SPI2) that the chip uses alone. It runs at priority 19 (KODE_PRIO_NFC), above AO_Ir, with a 16-event queue and a 6144-byte stack for the vendor code (KQP_AO_STACK_LARGE). kode_init() starts it after AO_Ir when the image links it, which an app does by calling a kode_nfc_*() function. An app that calls none leaves AO_Nfc out, and the core claims KBSP_RES_NFC as parked(AO_Nfc) and puts the chip in power-down, SPI2 left open. KODEOS_NFC=n gives the reader to the app, for a driver of its own: nothing starts or parks it, and AO_Nfc, its verbs and the kode_nfc_*() calls do not compile, so a call that is left fails to link. KODEOS_NFC_CERT adds a maximum-drive carrier for the FCC bench.
It has one state, Nfc_ready, and a request kind that says what holds the reader: REQ_TAG, REQ_NDEF, REQ_ROW, REQ_SWEEP, REQ_DUMP, REQ_FIELD, REQ_EMU and REQ_CERT.
stateDiagram-v2 [*] --> Idle Idle --> FieldUp: a request, chip woken, discovery armed FieldUp --> FieldUp: NFC_TICK_SIG, one discovery step FieldUp --> Serving: a tag activates, NFC_TAG_SIG FieldUp --> Idle: NFC_DEADLINE_SIG, no tag Serving --> Idle: answered, field dropped, chip rests
One request at a time
Refused and not queued. The answer travels through memory the requester keeps for that one request, so two requesters would read each other's result. A request that finds the reader held is refused as follows:
| Request | Refusal |
|---|---|
| Read, NDEF exchange, listen window, emulation, carrier | KODE_DENIED |
| Production row | A failed verdict |
| Sweep, dump | An empty answer |
A blocking facade call carries a generation number that the answer repeats, so a late answer to a caller that timed out is ignored by the next caller. An AO that asks gets an event instead, since it has no semaphore to sleep on.
A missing reader
A missing reader is a normal state. AO_Nfc looks for it only at boot. Every request is then answered at once and no field comes up:
| Request | Answer |
|---|---|
| Listen window | KODE_IO_ERROR |
| Read, NDEF exchange | KODE_DENIED |
| The rest | A refusal or a failed verdict |
The console's nfc reports it.
The wake
The chip needs its oscillator stable, which takes 700 µs typically and 10 ms at most, plus an RC calibration on the B variant. Only the first field-on of a request pays it.
Two timers, both one-shot
NFC_TICK_SIG(10 ms,NFC_TICK_MS) is re-armed after every pass. It pumps the discovery worker, takes a burst of a listen window, runs a batch of a sweep or a dump, or samples an emulation.NFC_DEADLINE_SIGbounds a request's window.
A periodic tick would deliver into an idle AO for nothing. Emulation, a sweep and a dump set no run-length deadline, and the handler ignores an expiry while they run.
The listen window
nfc_field_request() opens a window of up to 200 ms (KODE_NFC_FIELD_MS_MAX) with the transmitter held off. Each tick takes one burst of 16 amplitude measurements (KNFC_LISTEN_BURST), so a 50 ms window gives five bursts and n = 80, and a 200 ms window gives 320. The count is exact because the deadline is handled before the tick that expires with it, and a late burst costs one burst.
A step waits on the chip's conversions in a busy loop, bounded at 10 ms for each. The longest step measured about 5.1 ms, roughly half of every tick while a window runs, and the driver logs it at debug level under kdrv.nfc.
The answer is by value: st, min, mean, peak, mod (peak minus min), ext (1 when the chip's detector saw another device's field) and n. A blocked task names done, an AO names to, and both channels are served if both are named.
Emulation
A reader wants the answer to a Type-2 READ within about 87 µs, which no tick of an AO can promise. nfc_emulate_request() arms the driver's listen mode and a worker that serves READ every millisecond, and AO_Nfc holds REQ_EMU until nfc_emulate_stop_request() and the worker has released the reader.
The card is a knfc_emu_card_t: a UID, ATQA and SAK, and optionally an NDEF URI, text or raw message. A coherent identity (UID length matching the ATQA size bits) is presented as it is, for an access reader. Otherwise the driver builds a Type-2 tag with a 7-byte UID that carries the message, which a phone reads as a link or as text.
Once a tick AO_Nfc samples the driver's snapshot and publishes NFC_EMU_SIG with a kind: NFC_EMU_KIND_FIELD, _SELECTED, _READ or _STOPPED (with the total).
Only a served READ shows the card was read. A reader that only enumerates raises SELECTED.
Sweep and dump
nfc_keys tries the 2038-key dictionary against one block, eight keys a tick (NFC_SWEEP_BATCH); a full sweep of one block takes about 64 s. nfc_dump reads one sector a tick with the fast dictionary, streams each block to the log, and reports a sector that no known key opens as locked. It recovers no keys.
The production row
test component=nfc gives two separate verdicts.
Check 1 compares the IC_IDENTITY the driver read at start with 0x31 and turns on no field; it proves the part, the flex and connector J10, and tells an antenna PCB that is not fitted from one whose antenna does not work.
Check 2 runs only if check 1 passes. It turns the field on and requires a tag. A tag is reusable when it is blank or its NDEF text starts with KODE; the row then writes this board's serial and reads it back, which no stray card can fake.
A tag with anything else, or one that refuses the write (every real access card does), is left untouched and read twice for the same UID; that proves the antenna but not that the tag is genuine. A write that goes out and does not read back is a real fault and fails the row.
nfc_test_request_mode() adds two modes for the user profile of sdk/kaos/autotest, both under the verdict name nfc:
READER_TEST_FIELDruns check 1 and then holds the field forKODE_NFC_TEST_FIELD_MS(1.5 s) with nothing in front, passing if the part answered and the field stood.READER_TEST_CARDtries up toKODE_NFC_TEST_TRIES(3) windows, passes on any tag and leaves it alone, and publishesTEST_SKIP_SIGwhen none answers.
Resting with the board
With CONFIG_KODE_LOWPOWER, AO_Nfc joins the rounds of AO_LowPower as nfc (sdk/kaos/lowpower). With nothing asked the field is already down and the reader resting, so a round only makes sure: it closes any open window, drops the field and puts the oscillator to sleep again, which is harmless on a reader that is there already. A request in flight (a read, a window, an emulation, a sweep, a dump, the row or a held carrier) answers the round busy with nfc busy.
Nothing is restored when the board wakes. The next request wakes the reader itself, so AO_Nfc listens to the rounds and not to LOWPOWER_STATE_SIG.
Specifications
Signals
| Signal | Event | Direction | Meaning |
|---|---|---|---|
NFC_READ_REQ_SIG | NfcReadEvt | in, posted | Energise and answer with the first tag's identity |
NFC_NDEF_REQ_SIG | NfcNdefEvt | in, posted | Read or write an NDEF message; answer NFC_NDEF_RSP_SIG |
NFC_FIELD_REQ_SIG | NfcFieldEvt: ms, out, rc, done, gen, ack, to | in, posted | The listen window. Answer NFC_FIELD_RSP_SIG (NfcFieldRspEvt) |
NFC_EMU_REQ_SIG | NfcEmuReqEvt | in, posted | Start or stop an emulation. Answer NFC_EMU_RSP_SIG (NfcEmuRspEvt: rc, reads) |
NFC_KEYS_REQ_SIG, NFC_DUMP_REQ_SIG | with a block, or none | in, posted | The sweep and the dump. Answers NFC_KEYS_RSP_SIG, NFC_DUMP_RSP_SIG |
NFC_INFO_REQ_SIG | none | in, posted | The console's nfc; answer NFC_INFO_RSP_SIG |
NFC_TEST_REQ_SIG | ReaderTestReqEvt: mode | in, posted | READER_TEST_ROW, _FIELD or _CARD |
NFC_CERT_REQ_SIG | in, posted | Held FCC carrier; only with KODEOS_NFC_CERT | |
NFC_TICK_SIG, NFC_DEADLINE_SIG | none | self, one-shot | The pump and the request window |
LOWPOWER_ROUND_SIG | LowPowerRoundEvt | in, subscribed | With CONFIG_KODE_LOWPOWER: the field down and the reader resting, or busy nfc busy |
NFC_TAG_SIG | NfcTagEvt: uid[10], uid_len, atqa, sak, type | out, published | A tag activated, only while a request holds the field. An NDEF message never rides the bus |
NFC_EMU_SIG | NfcEmuEvt: kind, reads | out, published | An edge of an emulation |
TEST_RESULT_SIG, TEST_SKIP_SIG | named nfc | out, published | The verdict, or no tag to judge |
Facade, numbers and verbs
| Thing | Value |
|---|---|
| Identity | IC_IDENTITY reads 0x31 on the ST25R3916B |
| Blocking calls | kode_nfc_read(), kode_nfc_ndef_read(), kode_nfc_ndef_write_uri(), kode_nfc_ndef_write_text(), kode_nfc_field_read() wait out a whole window: never from an AO, not reentrant (a second caller gets KODE_DENIED). kode_nfc_emulate_start() and _stop() present and drop a card. kode_nfc_carrier() exists in a cert build only |
| For an AO | nfc_ndef_request(), nfc_field_request() with to, nfc_emulate_request(), nfc_keys_request(), nfc_dump_request(), nfc_info_request() |
| Message limit | KODE_NFC_TEXT_MAX 96 bytes through the facade |
| Row | Budget 6.5 s (KODE_NFC_TEST_BUDGET_MS), keys budget 240 s (KODE_NFC_KEYS_BUDGET_MS) |
| Tag types read as NDEF | NTAG (Type 2) and DESFire (Type 4). Mifare Classic is the type kode_nfc_ndef_read() denies |
| Console | nfc, nfc_help, nfc_read, nfc_write, nfc_keys, nfc_dump, nfc_field ms=, nfc_emulate, nfc_emulate_off, test component=nfc. While a window is open, nfc reads up=0 busy=1; while emulating, emulate on=1 reads=N |
| Stored settings | None |
| Kconfig | KODEOS_NFC, KODEOS_NFC_CERT |
Listen window statuses and a measured reference
| Status | Meaning |
|---|---|
KODE_IO_ERROR | No reader is fitted. At once. Asking again changes nothing |
KODE_DENIED | Another request holds the reader. At once |
KODE_ERROR | A zero ms, or a window that failed to open, failed mid-window or could not restore the registers. Ask again |
KODE_NO_RESOURCES | The event pool is dry; rc is written and ack and done are answered at once |
KODE_OK | Only a window that closed cleanly writes out and posts its statistics |
Measured on a Kode Dot with a 50 ms window: a quiet antenna reads n = 80 with min, mean and peak all 45, mod = 0 and ext = 0. A passive Mifare Classic 1K on the antenna reads 46. A phone that scans on the antenna reads 38 between its polling bursts, and a burst that falls inside the window reads ext = 1, peak 50 to 61, min 16 to 30. The phone's bursts fell inside 3 of 12 windows of 50 ms and 1 of 12 windows of 200 ms. So ext says a field is present and mod says it is active; no fixed level above 45 says either.
Gotchas
Example code
This waits up to 5 s for a tag on the antenna and logs the length of its UID with the first and last byte. Put a card or a phone with NFC on the back of the Kode Dot. The log level must be i: config key="log" value=i.
/*
* nfc_uid.c - 2026-10-28 - from Kode with love
*
* Asks AO_Nfc to raise the field of the Kode Dot for up to 5 s and logs the
* identity of the first tag it finds. Hold a card against the antenna and the
* console prints the UID length and its first and last byte; with no tag it
* prints the status code.
*/
#include "kode.h"
/* Bring the system up, then read one tag every few seconds. */
void app_main(void)
{
kode_init();
while (1) {
knfc_tag_t tag;
kode_status_t status = kode_nfc_read(&tag, 5000);
if (status == KODE_OK) {
KLOG_I("nfc_uid", "uid_len=%u first=%02X last=%02X",
(unsigned)tag.uid_len, (unsigned)tag.uid[0],
(unsigned)tag.uid[tag.uid_len - 1]);
} else {
KLOG_I("nfc_uid", "no tag, status %d", (int)status);
}
kode_delay_ms(2000);
}
}Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.
In the SDK
components/kaos/nfc:nfc_ao.c, the request machinery, the listen window, emulation and the row.kode.h: thekode_nfc_*calls.components/kdrv/nfc: the ST25R3916B driver and the tag layers; seesdk/kdrv/st25r3916.