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
AO_Nfc and the reader

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
Life of one request

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:

RequestRefusal
Read, NDEF exchange, listen window, emulation, carrierKODE_DENIED
Production rowA failed verdict
Sweep, dumpAn 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:

RequestAnswer
Listen windowKODE_IO_ERROR
Read, NDEF exchangeKODE_DENIED
The restA 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

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:

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

SignalEventDirectionMeaning
NFC_READ_REQ_SIGNfcReadEvtin, postedEnergise and answer with the first tag's identity
NFC_NDEF_REQ_SIGNfcNdefEvtin, postedRead or write an NDEF message; answer NFC_NDEF_RSP_SIG
NFC_FIELD_REQ_SIGNfcFieldEvt: ms, out, rc, done, gen, ack, toin, postedThe listen window. Answer NFC_FIELD_RSP_SIG (NfcFieldRspEvt)
NFC_EMU_REQ_SIGNfcEmuReqEvtin, postedStart or stop an emulation. Answer NFC_EMU_RSP_SIG (NfcEmuRspEvt: rc, reads)
NFC_KEYS_REQ_SIG, NFC_DUMP_REQ_SIGwith a block, or nonein, postedThe sweep and the dump. Answers NFC_KEYS_RSP_SIG, NFC_DUMP_RSP_SIG
NFC_INFO_REQ_SIGnonein, postedThe console's nfc; answer NFC_INFO_RSP_SIG
NFC_TEST_REQ_SIGReaderTestReqEvt: modein, postedREADER_TEST_ROW, _FIELD or _CARD
NFC_CERT_REQ_SIGin, postedHeld FCC carrier; only with KODEOS_NFC_CERT
NFC_TICK_SIG, NFC_DEADLINE_SIGnoneself, one-shotThe pump and the request window
LOWPOWER_ROUND_SIGLowPowerRoundEvtin, subscribedWith CONFIG_KODE_LOWPOWER: the field down and the reader resting, or busy nfc busy
NFC_TAG_SIGNfcTagEvt: uid[10], uid_len, atqa, sak, typeout, publishedA tag activated, only while a request holds the field. An NDEF message never rides the bus
NFC_EMU_SIGNfcEmuEvt: kind, readsout, publishedAn edge of an emulation
TEST_RESULT_SIG, TEST_SKIP_SIGnamed nfcout, publishedThe verdict, or no tag to judge

Facade, numbers and verbs

ThingValue
IdentityIC_IDENTITY reads 0x31 on the ST25R3916B
Blocking callskode_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 AOnfc_ndef_request(), nfc_field_request() with to, nfc_emulate_request(), nfc_keys_request(), nfc_dump_request(), nfc_info_request()
Message limitKODE_NFC_TEXT_MAX 96 bytes through the facade
RowBudget 6.5 s (KODE_NFC_TEST_BUDGET_MS), keys budget 240 s (KODE_NFC_KEYS_BUDGET_MS)
Tag types read as NDEFNTAG (Type 2) and DESFire (Type 4). Mifare Classic is the type kode_nfc_ndef_read() denies
Consolenfc, 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 settingsNone
KconfigKODEOS_NFC, KODEOS_NFC_CERT

Listen window statuses and a measured reference

StatusMeaning
KODE_IO_ERRORNo reader is fitted. At once. Asking again changes nothing
KODE_DENIEDAnother request holds the reader. At once
KODE_ERRORA zero ms, or a window that failed to open, failed mid-window or could not restore the registers. Ask again
KODE_NO_RESOURCESThe event pool is dry; rc is written and ack and done are answered at once
KODE_OKOnly 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