StackChan firmware GOB fork における NFC タグの使い方と、独自 NDEF プロトコルの仕様をまとめる。
| 項目 | 内容 |
|---|---|
| ハードウェア | M5Unit-NFC (ST25R3916、I2C 接続。BMI270 / RTC / FT6336 と I2C bus 共有) |
| ライブラリ | m5stack/M5Unit-NFC (feature/esp-idf-native-support ブランチ、ESP-IDF Component Manager 経由) |
| 対応スコープ | NFC-A (ISO 14443-A、Type-B/F は非対応) + NDEF (NFC Data Exchange Format) フォーマット + MIME type レコード |
| ペイロード形式 | UTF-8 JSON (application/vnd.stackchan.cmd+json、RFC 6838 vendor tree) |
| 用途 | タグをかざすと stackchan:cmd (move / rgb / home / dance / stop_dance) を実行 |
| MCP との関係 | xiaozhi の self.robot.* MCP tool と同一の actions/ 層を共有 → AI 音声と NFC で同セマンティクス |
| ファイル | 役割 |
|---|---|
firmware/main/hal/hal_nfc.cpp |
UnitNFC bring-up + poll task + NDEF read + retry |
firmware/main/stackchan/nfc/nfc_cmd_dispatcher.{h,cpp} |
JSON parse + cmd 振り分け |
firmware/main/stackchan/actions/robot_actions.{h,cpp} |
MCP/NFC 共通の動作実装 |
firmware/main/stackchan/gob_fork_nvs.{h,cpp} |
nfc_enabled (opt-in) |
デフォルトは OFF。理由: UnitNFC poll は I2C bus を共有しているため、有効化前に xiaozhi の audio codec / DSP と相性問題がないか確認するため明示的に opt-in としている。
有効化方法:
- GOB FORK アプリ → NFC 設定 (提供されている場合)
- または NVS API:
stackchan::gob_fork::set_nfc_enabled(true)を起動コードから 1 度呼ぶ
切替後 再起動 で反映 (poll task は起動時に作る)。
| タグ種別 | NDEF 容量 | コメント |
|---|---|---|
| NTAG213 | 137 B | 1 cmd は十分書ける (move 等で 50-80 B) |
| NTAG215 | 480 B | 複数 cmd / 余裕あるサイズ |
| NTAG216 | 872 B | 大きめのペイロード可 |
| MIFARE Ultralight | 46 B | 単純な cmd のみ (例: {"v":1,"cmd":"home"}) |
NFC-A 互換であれば上記以外でも動作する。
NFC Tools (Wakdev) を推奨。Android / iOS いずれにも提供されており、MIME type レコードを直接書き込めるため本 fork のプロトコルとの相性がよい。
手順:
- アプリで「書き込む」 → 「レコードを追加」 → カスタム (Custom)
- MIME type を選択
- MIME type 欄:
application/vnd.stackchan.cmd+json - Content 欄: 後述の JSON (例:
{"v":1,"cmd":"home","s":500}) - 「書き込む」 → タグをスマホに近づけて完了
前提: AI AGENT が起動済みであること (起動時の初期化が一段落してから NFC poll task が立ち上がる)。Boot 直後 (起動アニメーション中・WiFi 接続中など) はまだ NFC が有効化されていないので反応しない。
タグを CoreS3 にかざすと:
- chime 再生 (xiaozhi 標準の通知音、TTS 発話中はサイレント)
- Toast 表示:
% NFC.<cmd>(...)(画面上端 2 秒) - シリアルログ:
I HAL-NFC: PICC detected uid=04A1B2... type=NTAG213 I HAL-NFC: NDEF read start uid=... I HAL-NFC: stackchan:cmd received uid=... bytes=... I NFC-CMD: <cmd> ...
| 症状 | 原因候補 | 対処 |
|---|---|---|
| 何も反応しない | NFC opt-in OFF / UnitNFC 未接続 | nfc_enabled 確認 / 配線確認 |
PICC detected は出るが cmd 実行されない |
MIME type が違う | NFC Tools の MIME type 欄を再確認 (application/vnd.stackchan.cmd+json) |
record skipped: TNF=... (warn) |
TNF が MIME (2) でない | NDEF レコードを「カスタム MIME」で書き直す |
JSON parse failed (warn) |
JSON 構文エラー | jq . your.json で検証 |
unsupported version N (warn) |
v フィールド ≠ 1 |
"v": 1 を指定 |
unknown cmd '...' (warn) |
cmd 名タイプミス | §4 の一覧と照合 |
% NFC.move(reject: type) Toast |
引数の 型 が違う (例: 数値ではなく文字列) | JSON の型を確認。範囲外は clamp されるので reject にはならない |
NFC Forum NDEF 1.0 準拠。本 fork が読むのは:
| フィールド | 値 |
|---|---|
| TNF | 2 (MIME Media) |
| Type | application/vnd.stackchan.cmd+json (固定文字列) |
| Payload | UTF-8 で encode された JSON 文字列 |
| ID | 任意 (使用しない) |
その他の TNF (Well-known, External 等) や、対象外 MIME type のレコードは warn ログを出して無視。
application/vnd.stackchan.cmd+json
RFC 6838 vendor tree の vnd.stackchan 名前空間を予約使用。将来も変更しない。
すべて UTF-8 JSON。改行/空白の有無は任意 (NDEF 上のバイト数を抑えたければ空白なしが望ましい)。
{"v":1,"cmd":"home"}{"v":1,"cmd":"home","s":800}{"v":1,"cmd":"move","x":30,"y":45,"s":300}{"v":1,"cmd":"rgb","r":160,"g":40,"b":40}{"v":1,"cmd":"dance","style":"happy"}{"v":1,"cmd":"stop_dance"}1 つの NDEF メッセージ (Message TLV) 内に 複数 MIME レコード を含めることが可能。すべての該当レコードが順次 dispatch される。
非対象 record (URI / Text 等) が混ざっていても問題なし (skip + warn)。
NDEF メッセージのオーバーヘッド (header + TLV + record) で約 30-40 B 上乗せされる。
| ペイロード | おおよその NDEF サイズ |
|---|---|
{"v":1,"cmd":"home"} |
~50 B |
{"v":1,"cmd":"home","s":800} |
~60 B |
{"v":1,"cmd":"move","x":30,"y":45,"s":300} |
~75 B |
{"v":1,"cmd":"rgb","r":160,"g":40,"b":40} |
~70 B |
{"v":1,"cmd":"dance","style":"happy"} |
~70 B |
NTAG213 (137 B) なら単一 cmd は確実。複数 cmd を 1 タグにまとめるなら NTAG215 以上。
{ "v": 1, "cmd": "<command name>", ...args }| key | 必須 | 値 |
|---|---|---|
v |
必須 | プロトコルバージョン。現状 1 のみ。v != 1 のレコードは drop |
cmd |
必須 | コマンド名。下表参照 |
| その他 | 任意 | コマンド固有の引数 |
{"v":1,"cmd":"move","x":30,"y":45,"s":300}| arg | 型 | 範囲 | デフォルト | 単位 |
|---|---|---|---|---|
x |
int | -128 〜 128 | -9999 (=変更しない) |
yaw 度 (内部で ×10) |
y |
int | 0 〜 90 | -9999 (=変更しない) |
pitch 度 |
s |
int | 100 〜 1000 | 150 | servo 速度 |
意味は MCP の self.robot.set_head_angles と同じ。-9999 を指定すると、その軸は現在位置を保持。
{"v":1,"cmd":"rgb","r":160,"g":40,"b":40}| arg | 型 | 範囲 | デフォルト |
|---|---|---|---|
r / g / b |
int | 0 〜 168 | 0 |
MCP の self.robot.set_led_color の "safe range" (168 上限) と一致。両側 NeonLight 同色。
{"v":1,"cmd":"home","s":500}| arg | 型 | 範囲 | デフォルト |
|---|---|---|---|
s |
int | 100 〜 1000 | 500 |
MCP の self.robot.head_home と同じ実装。
{"v":1,"cmd":"dance","style":"happy"}| arg | 型 | デフォルト |
|---|---|---|
style |
string | "happy" |
style が未知の場合 Toast % NFC.dance(unknown style=...) を出して何もしない (warn ログ)。MCP の self.robot.dance と同等。
{"v":1,"cmd":"stop_dance"}引数なし。MCP の self.robot.stop_dance と同等。
| cmd | 引数 | 範囲 | デフォルト | 対応 MCP tool |
|---|---|---|---|---|
move |
x, y, s |
x: -128..128 / y: 0..90 / s: 100..1000 | -9999 / -9999 / 150 | self.robot.set_head_angles |
rgb |
r, g, b |
0..168 | 0 | self.robot.set_led_color |
home |
s |
100..1000 | 500 | self.robot.head_home |
dance |
style |
(string) | "happy" |
self.robot.dance |
stop_dance |
(なし) | — | — | self.robot.stop_dance |
範囲外の数値は clamp + warn ログ (reject にはならない)。型不一致 (例: int 期待箇所が string) は reject。
| ファイル | 役割 |
|---|---|
firmware/main/hal/hal_nfc.cpp |
UnitNFC bring-up、poll task、reactivate / NDEF read retry、NFC Cmd 解析準備 |
firmware/main/stackchan/nfc/nfc_cmd_dispatcher.{h,cpp} |
JSON parse、cmd dispatch、Toast / chime feedback |
firmware/main/stackchan/actions/robot_actions.{h,cpp} |
MCP / NFC 共通アクション (head_home / dance / stop_dance 等) |
firmware/main/hal/hal.h |
NfcTagEvent_t / NfcCmdEvent_t 型 + Hal::onNfcTagDetected / onNfcCmdReceived シグナル |
_nfc_task (core1, prio1, vTaskDelay 10ms)
└─ poll_once
├─ _units->update()
├─ _nfc_a->detect(piccs, timeout) timeout: speaking?10:50 ms
├─ for each PICC:
│ └─ process_picc
│ ├─ identify(u) [失敗→skip]
│ ├─ tag_events.push (uid + type)
│ ├─ reactivate_with_retry(u) retry: speaking?0:3
│ ├─ supportsNDEF()? [no→skip]
│ ├─ read_ndef_with_retry(u, tlvs) retry: speaking?1:3
│ └─ parse_tlvs_and_collect → cmd_events
├─ deactivate()
└─ emit シグナル (busy 解除後にまとめて)
PNG 等の重い処理 (Toast / OGG) は busy 解除後に走るよう、_nfc_busy.store(false) の後で signal emit する。
struct NfcTagEvent_t { std::string uid; std::string type; };
struct NfcCmdEvent_t { std::string uid; std::vector<uint8_t> payload; };
uitk::Signal<NfcTagEvent_t> Hal::onNfcTagDetected;
uitk::Signal<NfcCmdEvent_t> Hal::onNfcCmdReceived;CmdDispatcher::initOnce() が onNfcCmdReceived に接続して JSON dispatch を担う。アプリ側で UID 集計などを行いたい場合は onNfcTagDetected を別途 connect。
NFC poll は core1 で I2C 操作中、FT6336 の touch poll (core 0 esp_timer) と bus を取り合うとフリーズが起きうる。回避策:
std::atomic<bool> _nfc_busyを poll 中 true に立てる- FT6336 esp_timer は
Hal::isNfcBusy()を確認して poll skip - NDEF read 等の重い区間も busy true で守られる (部分解放禁止 —
tmp/case_w_failure_analysis.md参照: 部分解放は bus stuck → 全 I2C 死)
xiaozhi の TTS 発話中 (Application::GetDeviceState() == kDeviceStateSpeaking) は audio_input task が CPU 0 を占有し、NFC poll task が回らない瞬間がある (5/5s に低下するケースを実機ログで確認)。この CPU starve の上で同じ retry 数を維持すると cycle 数がさらに低下する悪循環になるため、speaking 中はコストを切り詰める方針:
| 項目 | non-speaking | speaking | 根拠 |
|---|---|---|---|
detect timeout |
50 ms | 10 ms | speaking 中は cycle 機会優先 (REQA polling 数より cycle 回転を稼ぐ) |
reactivate retry |
最大 3 回 | 0 回 | speaking 中は recover 実績ゼロ (実機ログで確認済み)、retry 浪費が cycle starve を悪化 |
ndefRead retry |
最大 3 回 | 1 回 | speaking 中も 1 retry での recover 実例あり、2 回以降は不発のため打ち切り |
実用上 speaking 中も「セリフ間の隙間」では検知が成功する。完全な解決には audio task の優先度調整 / Wake Up Mode 採用などの根本対策が必要 (詳細は tmp/nfc_detect_lightening.md X1-X5、tmp/nfc_speaking_rf_failure_analysis.md を参照)。
firmware/main/stackchan/actions/robot_actions.{h,cpp}に共通実装を追加 (MCP / NFC 共有のため)- xiaozhi 側 MCP tool (
self.robot.<name>) にも同じ呼び出しをラップ (任意だが推奨: AI 音声と NFC で挙動を揃える) nfc_cmd_dispatcher.cppのinitOnce()内registerHandler("<name>", ...)を追加- 引数の range は
constexpr定数で MCP と揃える (例:_RGB_MIN/_RGB_MAX) - Toast 文言は既存形式に合わせる: 受理
% NFC.<cmd>(...)/ reject!! NFC.<cmd>(reject: ...)
下位互換: 既存タグは新 cmd を含まない限り影響なし。逆に古いファームに新 cmd を含むタグをかざすと unknown cmd ログが出るのみで安全に無視される。
- 受理時 (
dispatchが cmd handler を呼んだ後):OGG_NEW_NOTIFICATIONを再生 - xiaozhi が speaking 中はサイレント: AI 発話と被らせない (Toast は表示する)
- 拒否時 / 不明 cmd / parse error: chime なし
| 状況 | 文言例 |
|---|---|
| 受理 | % NFC.move(x=30, y=45, s=300) |
| 受理 (引数なし) | % NFC.stop_dance(stopped) |
| 引数の型違反 | !! NFC.move(reject: type) |
| 範囲外 | (clamp + warn ログ。Toast は受理側で表示) |
| 不明 cmd / parse error / version 不一致 | (Toast/chime なし、warn ログのみ) |
複数 record は Toast が重なるため ToastDecorator::stack で順次表示される (ToastManager と同じ挙動)。
- NFC-A 限定 — Type-B / Type-F は未対応 (M5Unit-NFC のレイヤ A クラスのみ使用)
- MIME record のみ — URI / Text / External など他の TNF はすべて skip
v固定 1 — 将来の互換性確保のため将来 v=2 を追加する余地はある- speaking 中の poll cycle 数低下 — CPU starve が根因。Wake Up Mode (低電力 inductive sensing) 採用や audio task の優先度調整は未実装
- 常時 polling — Wake Up Mode (IRQ 駆動) は ST25R3916 のハード機能としてあるが、ライブラリ未対応
- core 1 pin — xiaozhi audio AEC が core 0 占有するため core 1 固定。FT6336 abort 保護は xiaozhi-esp32.patch (i2c_device.cc retry-on-timeout) で代替
- ライブラリ: m5stack/M5Unit-NFC (
feature/esp-idf-native-supportブランチ、ESP-IDF Component Manager) - NDEF 仕様: NFC Forum — NDEF 1.0
- 関連 fork メモ:
tmp/nfc_detect_lightening.md— 軽量化案 (X1-X5)tmp/nfc_speaking_rf_failure_analysis.md— speaking 中 RF 劣化分析tmp/case_w_failure_analysis.md— NDEF read 中 I2C 部分解放 NG の根拠tmp/external_control_paths.md— 外部制御経路総覧 (NFC は B 軸)
This document describes how to use NFC tags with the StackChan firmware GOB fork, and the proprietary NDEF protocol it implements.
| Item | Value |
|---|---|
| Hardware | M5Unit-NFC (ST25R3916, I2C — shared with BMI270 / RTC / FT6336) |
| Library | m5stack/M5Unit-NFC (feature/esp-idf-native-support branch, ESP-IDF Component Manager) |
| Scope | NFC-A only (ISO 14443-A; Type-B/F not supported) + NDEF + MIME records |
| Payload format | UTF-8 JSON (application/vnd.stackchan.cmd+json, RFC 6838 vendor tree) |
| Use case | Tap a tag → execute a stackchan:cmd (move / rgb / home / dance / stop_dance) |
| MCP relationship | Shares the actions/ layer with xiaozhi's self.robot.* MCP tools — AI voice and NFC produce identical behavior |
| File | Role |
|---|---|
firmware/main/hal/hal_nfc.cpp |
UnitNFC bring-up + poll task + NDEF read + retry |
firmware/main/stackchan/nfc/nfc_cmd_dispatcher.{h,cpp} |
JSON parse + cmd dispatch |
firmware/main/stackchan/actions/robot_actions.{h,cpp} |
Shared MCP / NFC actions |
firmware/main/stackchan/gob_fork_nvs.{h,cpp} |
nfc_enabled (opt-in) |
Default is OFF. Reason: UnitNFC poll shares the I2C bus with audio codec / DSP, so NFC is opt-in to make sure users explicitly accept any contention before turning it on.
How to enable:
- GOB FORK app → NFC settings (if exposed)
- Or NVS API: call
stackchan::gob_fork::set_nfc_enabled(true)once
A reboot is required (poll task is created at boot).
| Tag | NDEF capacity | Note |
|---|---|---|
| NTAG213 | 137 B | One cmd fits comfortably (50–80 B per cmd) |
| NTAG215 | 480 B | Multiple cmds / generous size |
| NTAG216 | 872 B | Larger payloads |
| MIFARE Ultralight | 46 B | Simple cmds only (e.g. {"v":1,"cmd":"home"}) |
Any NFC-A compatible tag works.
Use NFC Tools (Wakdev). It is available on both Android and iOS, and lets you write a MIME-type record directly, which matches this fork's protocol.
Steps:
- "Write" → "Add a record" → Custom
- Select MIME type
- MIME type:
application/vnd.stackchan.cmd+json - Content: the JSON described below (e.g.
{"v":1,"cmd":"home","s":500}) - "Write" → tap the tag
Prerequisite: the AI AGENT must be up and running. The NFC poll task starts only after the boot-time initialization settles, so taps during early boot (splash animation, WiFi connection) produce no reaction.
When you tap a tag near the CoreS3:
- Chime (xiaozhi notification sound — silent during TTS)
- Toast at the top:
% NFC.<cmd>(...)(2 s) - Serial log:
I HAL-NFC: PICC detected uid=04A1B2... type=NTAG213 I HAL-NFC: NDEF read start uid=... I HAL-NFC: stackchan:cmd received uid=... bytes=... I NFC-CMD: <cmd> ...
| Symptom | Likely cause | Fix |
|---|---|---|
| No reaction at all | NFC opt-in OFF / UnitNFC not connected | Check nfc_enabled / wiring |
PICC detected but no cmd |
MIME type mismatch | Re-check the MIME field in NFC Tools |
record skipped: TNF=... (warn) |
TNF is not MIME (2) | Re-author as a custom MIME record |
JSON parse failed (warn) |
JSON syntax error | Validate with jq . |
unsupported version N (warn) |
v ≠ 1 |
Set "v": 1 |
unknown cmd '...' (warn) |
Typo in cmd name | Compare with §4 |
Toast % NFC.move(reject: type) |
Argument type wrong (e.g. string instead of int) | Check JSON types. Out-of-range numbers are clamped, not rejected |
Per NFC Forum NDEF 1.0. The fork only consumes:
| Field | Value |
|---|---|
| TNF | 2 (MIME Media) |
| Type | application/vnd.stackchan.cmd+json (fixed string) |
| Payload | UTF-8 JSON string |
| ID | unused |
Other TNFs (Well-known, External, ...) and other MIME types are logged at warn level and skipped.
application/vnd.stackchan.cmd+json
Reserved under the RFC 6838 vendor tree (vnd.stackchan). Will not change.
All UTF-8 JSON. Whitespace is optional (drop it to save bytes on small tags).
{"v":1,"cmd":"home"}{"v":1,"cmd":"home","s":800}{"v":1,"cmd":"move","x":30,"y":45,"s":300}{"v":1,"cmd":"rgb","r":160,"g":40,"b":40}{"v":1,"cmd":"dance","style":"happy"}{"v":1,"cmd":"stop_dance"}A single NDEF message (Message TLV) may contain multiple MIME records. Every matching record is dispatched in order.
Non-matching records (URI / Text / etc.) coexist fine — they are simply skipped with a warn log.
NDEF overhead (header + TLV + record framing) adds about 30–40 B.
| Payload | Approx. NDEF size |
|---|---|
{"v":1,"cmd":"home"} |
~50 B |
{"v":1,"cmd":"home","s":800} |
~60 B |
{"v":1,"cmd":"move","x":30,"y":45,"s":300} |
~75 B |
{"v":1,"cmd":"rgb","r":160,"g":40,"b":40} |
~70 B |
{"v":1,"cmd":"dance","style":"happy"} |
~70 B |
NTAG213 (137 B) handles a single cmd reliably. For multiple cmds on one tag, use NTAG215 or larger.
{ "v": 1, "cmd": "<command name>", ...args }| Key | Required | Value |
|---|---|---|
v |
yes | Protocol version. Currently only 1. Records with v ≠ 1 are dropped |
cmd |
yes | Command name (see table) |
| others | optional | Per-command arguments |
{"v":1,"cmd":"move","x":30,"y":45,"s":300}| Arg | Type | Range | Default | Unit |
|---|---|---|---|---|
x |
int | -128 to 128 | -9999 (no change) |
yaw degrees (×10 internally) |
y |
int | 0 to 90 | -9999 (no change) |
pitch degrees |
s |
int | 100 to 1000 | 150 | servo speed |
Mirrors MCP self.robot.set_head_angles. -9999 keeps the current angle on that axis.
{"v":1,"cmd":"rgb","r":160,"g":40,"b":40}| Arg | Type | Range | Default |
|---|---|---|---|
r / g / b |
int | 0 to 168 | 0 |
Matches the "safe range" of MCP self.robot.set_led_color (168 max). Both NeonLights take the same color.
{"v":1,"cmd":"home","s":500}| Arg | Type | Range | Default |
|---|---|---|---|
s |
int | 100 to 1000 | 500 |
Same implementation as MCP self.robot.head_home.
{"v":1,"cmd":"dance","style":"happy"}| Arg | Type | Default |
|---|---|---|
style |
string | "happy" |
Unknown styles produce a Toast % NFC.dance(unknown style=...) and do nothing (warn log). Matches MCP self.robot.dance.
{"v":1,"cmd":"stop_dance"}No arguments. Matches MCP self.robot.stop_dance.
| cmd | Args | Range | Default | MCP equivalent |
|---|---|---|---|---|
move |
x, y, s |
x: -128..128 / y: 0..90 / s: 100..1000 | -9999 / -9999 / 150 | self.robot.set_head_angles |
rgb |
r, g, b |
0..168 | 0 | self.robot.set_led_color |
home |
s |
100..1000 | 500 | self.robot.head_home |
dance |
style |
(string) | "happy" |
self.robot.dance |
stop_dance |
(none) | — | — | self.robot.stop_dance |
Out-of-range numerics are clamped + warn-logged (not rejected). Wrong-type values (e.g. string where int is required) are rejected.
| File | Role |
|---|---|
firmware/main/hal/hal_nfc.cpp |
UnitNFC bring-up, poll task, reactivate / NDEF read retries, NFC cmd parse |
firmware/main/stackchan/nfc/nfc_cmd_dispatcher.{h,cpp} |
JSON parse, cmd dispatch, Toast / chime feedback |
firmware/main/stackchan/actions/robot_actions.{h,cpp} |
Shared MCP / NFC actions (head_home / dance / stop_dance etc.) |
firmware/main/hal/hal.h |
NfcTagEvent_t / NfcCmdEvent_t + Hal::onNfcTagDetected / onNfcCmdReceived signals |
_nfc_task (core1, prio1, vTaskDelay 10 ms)
└─ poll_once
├─ _units->update()
├─ _nfc_a->detect(piccs, timeout) timeout: speaking?10:50 ms
├─ for each PICC:
│ └─ process_picc
│ ├─ identify(u) [fail → skip]
│ ├─ tag_events.push (uid + type)
│ ├─ reactivate_with_retry(u) retry: speaking?0:3
│ ├─ supportsNDEF()? [no → skip]
│ ├─ read_ndef_with_retry(u, tlvs) retry: speaking?1:3
│ └─ parse_tlvs_and_collect → cmd_events
├─ deactivate()
└─ emit signals (after busy is released)
Heavy work (Toast, OGG playback) runs after _nfc_busy.store(false) so the busy window stays short.
struct NfcTagEvent_t { std::string uid; std::string type; };
struct NfcCmdEvent_t { std::string uid; std::vector<uint8_t> payload; };
uitk::Signal<NfcTagEvent_t> Hal::onNfcTagDetected;
uitk::Signal<NfcCmdEvent_t> Hal::onNfcCmdReceived;CmdDispatcher::initOnce() connects to onNfcCmdReceived for JSON dispatch. App-side UID aggregation can connect to onNfcTagDetected separately.
NFC poll runs on core 1 and contends with FT6336 touch poll (core 0 esp_timer) for the I2C bus. Mitigation:
std::atomic<bool> _nfc_busyis set during a poll- The FT6336 esp_timer checks
Hal::isNfcBusy()and skips its poll - The whole NDEF read window is covered by busy (partial release is forbidden — see
tmp/case_w_failure_analysis.md: a partial release stuck the bus and killed all I2C)
While xiaozhi is speaking (Application::GetDeviceState() == kDeviceStateSpeaking), the audio_input task occupies CPU 0 and the NFC poll task can drop to ~5 cycles in 5 s (observed in real-device logs). Holding the same retry counts on top of CPU starve only worsens the cycle starvation, so speaking-mode trims cost:
| Item | Non-speaking | Speaking | Rationale |
|---|---|---|---|
detect timeout |
50 ms | 10 ms | Prefer cycle throughput over REQA polling depth |
reactivate retry |
up to 3 | 0 | Zero recover examples during speaking in real-device logs — retries only worsen starve |
ndefRead retry |
up to 3 | 1 | One retry has documented recovers; further retries unproductive |
In practice, gaps between sentences let detection succeed during speech. Full fix requires audio task priority tuning or Wake Up Mode (see tmp/nfc_detect_lightening.md X1-X5, tmp/nfc_speaking_rf_failure_analysis.md).
- Add the shared implementation under
firmware/main/stackchan/actions/robot_actions.{h,cpp}(so MCP and NFC share the code path) - (Recommended) Wire the same call from the xiaozhi MCP tool
self.robot.<name>so AI voice and NFC behave identically - In
nfc_cmd_dispatcher.cppinitOnce(), addregisterHandler("<name>", ...) - Define ranges as
constexprto match MCP (e.g._RGB_MIN/_RGB_MAX) - Use the existing Toast format: accept
% NFC.<cmd>(...), reject!! NFC.<cmd>(reject: ...)
Backward compatibility: existing tags are unaffected unless they include the new cmd. An old firmware seeing a new-cmd tag just logs unknown cmd and ignores it safely.
- On accept (after a cmd handler runs): plays
OGG_NEW_NOTIFICATION - Suppressed during xiaozhi speaking (Toast still shows)
- No chime on reject / unknown cmd / parse error
| Situation | Example text |
|---|---|
| Accept | % NFC.move(x=30, y=45, s=300) |
| Accept (no args) | % NFC.stop_dance(stopped) |
| Type violation | !! NFC.move(reject: type) |
| Out of range | (clamped + warn log; Toast shows the accepted value) |
| Unknown cmd / parse error / version mismatch | (no Toast / chime; warn log only) |
Multiple records stack via ToastDecorator::stack (same as ToastManager).
- NFC-A only — Type-B / Type-F unsupported (Layer A class only)
- MIME records only — URI / Text / External etc. are skipped
vfixed at 1 — room for v=2 in the future- Cycle-rate dip during speaking — root cause is CPU starve. Wake Up Mode (low-power inductive sensing) and audio task priority tuning are not implemented
- Always polling — Wake Up Mode (IRQ-driven) exists in ST25R3916 hardware but is unsupported by the library
- Pinned to core 1 — xiaozhi audio AEC owns core 0. FT6336 abort protection that previously required core 0 colocation is now provided by the xiaozhi-esp32.patch i2c_device.cc retry-on-timeout patch
- Library: m5stack/M5Unit-NFC (
feature/esp-idf-native-support, ESP-IDF Component Manager) - NDEF spec: NFC Forum — NDEF 1.0
- Fork notes:
tmp/nfc_detect_lightening.md— lightening options (X1-X5)tmp/nfc_speaking_rf_failure_analysis.md— RF degradation during speakingtmp/case_w_failure_analysis.md— why partial I2C release during NDEF read is forbiddentmp/external_control_paths.md— external control map (NFC = axis B)