
给 StackChan 这类开源桌面机器人做换装通常要先改代码、再重烧固件或者手工切换一份配置文件整个过程既慢又不直观。如果希望通过实体配饰直接改变机器人的表情、动作、语音风格和 AI 人设NFC 是目前成本和交互成本都很低的一条路线把每套配饰做成一张 NFC 标签让机器人靠近标签后自动识别再由 AI 根据当前语境推荐和匹配最合适的装扮。这篇文章会以 StackChan 为例从硬件接线、读卡器选型、NFC 标签映射、角色配置库到 AI 自动匹配搭建一套最小可运行的“NFC 智能换装”系统。整套方案的核心链路是NFC 读卡器读取实体标签主程序根据标签内容切换角色配置AI 在角色配置之上做一次语义匹配最后通过串口指令控制 StackChan 的表情、舵机动作和语音输出。读者不需要有完整的嵌入式背景只要会基础 Python、能操作 Raspberry Pi 或类似 Linux 开发板就可以按顺序复现。阅读结束后你会得到一套可以直接扩展的工程框架而不是只能运行一次的临时脚本。1. 先搞清楚StackChan 换装为什么需要 NFC 和 AI1.1 手动换装的痛点StackChan 的“换装”不是贴一张贴纸那么简单。一套完整的装扮通常由几个维度组成屏幕表情例如普通、开心、思考、闭眼。配色和纹理例如头壳颜色、饰条颜色、背景装饰。舵机动作例如头部角度、身体姿态、空闲时的小动作。语音风格例如语速、音调、启动时说的话。AI 人设例如户外模式、职场模式、聚会模式各自对应的 prompt 和回复风格。如果每次更换装扮都手动编辑配置文件再重启程序整个交互就失去了“换装”的乐趣。更关键的是非技术的使用者无法理解配置文件里的每个字段也无法在实体世界和数字状态之间建立直观联系。NFC 的做法是把“角色 ID”物化成一张卡片每一张卡片代表一套完整配置。使用者不需要接触代码只需要把卡片靠近读卡器机器人就能完成切换。1.2 NFC 解决的是“实体标签到数字配置”的识别问题NFC 是一种近距离无线通信技术典型工作距离在几厘米以内。它在嵌入式场景中的优势不是传输大文件而是快速、稳定地传递一个短 ID 或一段短文本。常见的读卡器模块有 PN532、RC522、MFRC522 等对应的标签有 NTAG213、NTAG215、NTAG216 等。这里使用 NTAG215 的原因是容量为 504 字节比 NTAG213 的 144 字节大可以存下一条带关键信息的短文本记录。读卡器读 UID 的流程不依赖标签内是否有 NDEF 消息即使标签从未被格式化也能立刻识别。市场上容易买到卡片、贴纸、钥匙扣三种形态适合做实体配饰。在文章后续例子中每张 NTAG215 标签都代表“一个配饰 ID”。程序读取标签后先在本地映射表里找到对应的角色配置。这一层是一个快速、确定性的动作不依赖网络也不会因为 AI 调用超时而导致识别失败。1.3 AI 解决的是“配饰和人设的自动匹配”问题只靠 NFC 只能做到“指定标签切换指定配置”这相当于一个遥控器。真正让人感觉“智能”的是同一个标签在用户不同语境下可以触发不同状态。例如用户把“户外探索”卡片贴在机器人底座上然后说“我今天要见客户能不能帮我换个更正式的介绍方式”。如果程序只做固定映射它只会切换成户外模式不会理解见客户这个上下文。AI 的作用就是接收用户输入、当前 NFC 标签对应的候选角色以及本地配置库里的角色集合最终返回一个最适合当前场景的role_id再由系统自动加载对应装扮和人设。因此AI 不是用来做 NFC 识别的而是在 NFC 识别完成之后做一层语义匹配。这样设计的好处是即使 AI 接口超时或断网系统仍然可以保留“贴卡切换默认配置”的最低可用能力。2. 环境准备与硬件接线2.1 硬件清单和模块选型搭建这套最小系统需要的硬件如下部件型号建议数量作用主控开发板Raspberry Pi 4B 或兼容 Linux 板1运行 Python 主程序调用 AI 接口NFC 读卡器PN532 模块1读取 NTAG215 标签NFC 标签NTAG215 贴纸或卡片若干每个标签对应一套配饰配置StackChan 控制板Arduino 或 ESP32 兼容板1解析串口指令控制舵机和屏幕USB 转 TTL 串口线CP2102 / CH3401连接主控与 StackChan 控制板电源5V 3A 直流电源1为主控和电机供电避免电压跌落NFC 读卡器的选型对开发体验影响很大。下面是几种常见模块的对比模块工作协议读取距离供电与电平推荐场景PN532I2C / SPI / UART2-6 cm5V 供电I2C 引脚为 3.3V 逻辑树莓派 Python 开发生态成熟RC522SPI2-5 cm3.3V 逻辑部分 5V 模块需注意电平低成本学习读写 MIFARE 卡MFRC522SPI2-5 cm3.3V 逻辑常见于 Arduino 教程手机 NFC 模拟模式无取决于手机无测试和调试不适合独立产品如果主要用树莓派做上位机推荐 PN532。它在 I2C 模式下只需要 4 根线不用再处理 SPI 片选和中断竞争。RC522 虽然便宜但 SPI 模式在树莓派上要额外占用片选引脚如果后续还要接多个 SPI 设备排查起来会比较麻烦。2.2 PN532 与树莓派的 I2C 接线PN532 模块通常有下面这些引脚VCC接 5VGND接 GNDSDA接树莓派 I2C 数据线BCM 编号为 GPIO2物理引脚 3SCL接树莓派 I2C 时钟线BCM 编号为 GPIO3物理引脚 5接线前要确认模块版本是否带 3.3V 逻辑电平转换。有些廉价模块没有电平转换直接接树莓派 3.3V GPIO 可能不稳定。安全的接法是把模块的 VCC 接 5VSDA/SCL 接树莓派 3.3V 引脚PND 和 IRQ 不接或按模块文档配置。接线完成后可以先通过命令确认 I2C 设备是否被系统识别默认情况下 PN532 的 I2C 地址是0x24。这一步很重要如果地址不对后面所有 Python 库都无法初始化成功。2.3 StackChan 原有舵机与屏幕的控制链路StackChan 的硬件结构并不完全统一。常见做法是一块 Arduino 或 ESP32 兼容板负责读取舵机角度和刷新屏幕上位机通过串口发送一行指令控制板解析后执行动作。在这套 NCF 换装系统中树莓派是上位机。它不直接操作舵机和屏幕而是通过 USB 转 TTL 串口线向 StackChan 控制板发送文本指令。这样可以让主程序更专注于 NFC 和 AI不用处理舵机 PWM 和屏幕刷新的底层逻辑。假设控制板固件支持类似下面的指令格式#FACE happy #MOVE head 20 body -10 #TEXT 你好我是户外探索者编程时只需要封装一个发送串口指令的函数即可。不同项目的指令格式可能有差异落地方案时要先确认自己 StackChan 固件支持的协议。3. 先让树莓派读到 NFC 标签3.1 启用 I2C 并验证设备使用树莓派时第一步是启用 I2C 接口。执行sudo raspi-config在 Interface Options 中选择 I2C然后启用。启用后重启再安装工具和依赖sudo apt update sudo apt install -y python3-pip i2c-tools接着检查 I2C 总线上是否有设备i2cdetect -y 1如果接线正确输出结果中会在0x24位置出现一个编号。这个数字就是 PN532 的 I2C 地址。如果什么都没有先检查接线和模块是否供电再确认 I2C 是否启用。3.2 用 pn532pi 读 NTAG215 UIDPython 生态中有多个 NFC 库pn532pi是封装比较整齐的一个。安装依赖pip3 install pn532pi下面是最小的读 UID 脚本。它会初始化 PN532每隔一秒探测一次周围是否有 ISO14443A 类型卡片发现后打印 UID。import time from pn532pi import Pn532, Pn532I2c, Pn532Mifare I2C_BUS_ID 1 i2c Pn532I2c(I2C_BUS_ID) nfc Pn532(i2c) def setup_nfc(): nfc.begin() version_data nfc.get_firmware_version() if not version_data: print(PN532 not found, please check wiring) return False print(Found PN532, firmware version:, version_data) nfc.SAMConfig() return True def read_uid(): if nfc.readPassiveTargetID(Pn532Mifare.MIFARE_ISO14443A, timeout1000): uid nfc.get_last_tag_uid() uid_str :.join([{:02X}.format(b) for b in uid]) return uid_str return None if not setup_nfc(): exit(1) print(NFC reader ready, waiting for tags...) while True: uid read_uid() if uid: print(tag detected, UID:, uid) time.sleep(2) time.sleep(0.1)这段代码的要点SAMConfig()是安全访问模块配置读卡前必须调用。readPassiveTargetID的timeout单位是毫秒1000 代表最多等待一秒。get_last_tag_uid()返回的是字节数组转换成十六进制字符串后方便做映射。如果运行成功贴上一张 NTAG215 标签终端会出现类似下面的输出Found PN532, firmware version: (1, 6) NFC reader ready, waiting for tags... tag detected, UID: 04:12:34:56:78:903.3 读懂 UID 与 NDEF 文本的区别读 UID 是最简单的标签识别方式但理解它和 NDEF 文本的区别对后续设计很重要。NTAG215 属于 NTAG 系列存储区域按页划分每页 4 字节。前几页固定存放厂商信息、UID 和校验字节用户数据区从后半部分开始。UID 在全厂范围内唯一但只适合做“快速索引”不适合做安全凭据。因为很多卡片的 UID 可以被直接写入或模拟如果系统把 UID 当作唯一身份凭证实际项目很容易被绕过。NDEF 文本是另一种存储方式。开发者可以用手机 App 或桌面写入工具在标签内写入一段文本例如role:outdoor或outdoor。程序读取标签后既能看到 UID又能读到这段文本。文本内容比 UID 更容易理解也方便直接作为角色 ID。这里推荐的做法是UID 用于快速定位角色 ID 存到 NDEF 文本中。主程序先用 UID 查本地映射表如果映射不存在再尝试读取 NDEF 文本作为降级方案。这样既保持快速切换也避免了只依赖 UID 的隐患。4. 为配饰建立配置库与 NFC 映射表4.1 定义一个角色和装扮配置为了让“换装”不只是改个名字角色配置需要覆盖表情、配色、动作和 AI 人设。可以把这些信息统一放在一个 JSON 文件中。下面是一个最小角色配置示例文件名为roles.json{ roles: { outdoor: { name: 户外探索者, face: happy, head_color: #00AA88, body_texture: forest, tail_action: swing, ai: { system_prompt: 你现在是StackChan的户外探索模式。你说话简短、热情、喜欢森林和天空。, temperature: 0.8 } }, interview: { name: 职场面试官, face: serious, head_color: #336699, body_texture: suit, tail_action: sit, ai: { system_prompt: 你现在是StackChan的职场面试模式。你回答正式、克制用词专业。, temperature: 0.4 } }, party: { name: 派对精灵, face: joy, head_color: #FF6699, body_texture: neon, tail_action: dance, ai: { system_prompt: 你现在是StackChan的派对模式。你语气轻快喜欢使用短句和感叹。, temperature: 0.9 } } }, nfc_map: { 04:12:34:56:78:90: outdoor, 04:12:34:56:78:91: interview, 04:12:34:56:78:92: party } }face对应屏幕表情head_color和body_texture可以控制屏幕配色或舵机外壳装饰tail_action是 StackChan 空闲时的动作模式ai里存放的是送给大模型的人设描述。把配置放在 JSON 文件而不是硬编码在 Python 中是为了后续扩展。非技术人员可以通过编辑 JSON 增加新角色不需要了解程序逻辑。4.2 维护 UID 到角色 ID 的映射nfc_map中保存了 UID 字符串和角色 ID 的对应关系。需要注意UID 字符串格式必须保持全大写且用冒号分隔否则容易查不到。不同读卡器返回的字节序可能不同以i2cdetect扫描和实际脚本输出为准。如果买家提供的 NTAG215 标签 UID 是 7 字节脚本输出会比 4 字节更长不要直接硬编码先用测试脚本读取真实 UID。一旦映射写错表现就是“贴卡后有反应但角色没有变化”。排查这类问题最快的方法是先打印 UID 和查到的角色 ID。4.3 程序启动时加载配置并做校验加载 JSON 配置时建议写一个函数做完整性校验而不是直接使用原始结果。import json def load_config(pathroles.json): with open(path, r, encodingutf-8) as f: data json.load(f) roles data.get(roles, {}) nfc_map data.get(nfc_map, {}) if not roles: raise ValueError(roles is empty) required_fields {name, face, ai} for role_id, role in roles.items(): missing required_fields - set(role.keys()) if missing: raise ValueError(frole {role_id} missing fields: {missing}) return roles, nfc_map校验函数可以在启动早期暴露 JSON 拼写错误、字段缺失等问题避免运行到一半才发现某个角色无法加载。实际项目中还可以增加字段类型检查比如temperature必须是数字face必须在允许的表情集合内。5. 接入 AI 自动匹配配饰5.1 设计 AI 的 system promptAI 的角色不只是生成一句回复它要能根据当前 NFC 标签和用户输入从配置库中选出最合适的角色。系统提示词需要把可选项、当前状态和用户需求说清楚。一种可用的 prompt 结构如下当前 StackChan 已经佩戴了配饰 ID{current_role}。 可用角色{role_ids}。 用户说{user_input}。 请从可用角色中选择一个最适合的 role_id并生成一句简短开场白。 只输出 JSON格式为 {role_id:xxx,message:...}。把角色 ID 列表交给模型再由模型输出 JSON能够在“贴卡切换”和“用户语义切换”之间取得平衡。程序只需要解析模型返回的 JSON再校验role_id是否存在于配置库中。5.2 封装 OpenAI 兼容接口大模型接口虽然厂商不同但很多都兼容/chat/completions的请求格式。下面用一个通用函数封装import os import requests def call_llm(system_prompt, user_content): api_base os.environ.get(LLM_API_BASE, https://api.example.com/v1) api_key os.environ.get(LLM_API_KEY, ) model os.environ.get(LLM_MODEL, qwen-plus) resp requests.post( f{api_base}/chat/completions, headers{Authorization: fBearer {api_key}}, json{ model: model, messages: [ {role: system, content: system_prompt}, {role: user, content: user_content} ], temperature: 0.8, max_tokens: 200, response_format: {type: json_object} }, timeout10 ) resp.raise_for_status() data resp.json() return data[choices][0][message][content]这里有两个生产经验API Key 不要写在代码仓库中使用环境变量注入。超时时间控制在 10 秒以内避免用户等太久。AI 调用失败时应该降级使用 NFC 标签默认配置而不是让整个程序卡住。5.3 让 AI 根据用户输入自动推荐合适装扮AI 自动匹配函数的输入有三个当前 NFC 标签对应的角色 ID、用户输入的自然语言、本地配置库中的所有角色。输出是一个role_id和一段开场白。def ai_select_role(current_role, user_input, roles): role_ids ,.join(roles.keys()) prompt ( f当前 StackChan 已经佩戴了配饰 ID{current_role}。\n f可用角色{role_ids}。\n f用户说{user_input}。\n 请从可用角色中选择一个最适合的 role_id并生成一句简短开场白。\n 只输出 JSON格式为 {\role_id\:\xxx\,\message\:\...\}。 ) content call_llm(prompt, user_input) try: result json.loads(content) except json.JSONDecodeError: return current_role, 抱歉我暂时无法理解你的需求。 if result.get(role_id) not in roles: return current_role, 这个角色不存在我保持当前模式。 return result[role_id], result.get(message, )需要注意模型输出 JSON 时偶尔会附带额外文字或格式不完整。实际项目中要加上容错解析最好使用正则删除多余的反引号再执行json.loads。这里的关键不变量是任何情况下role_id都必须经过配置库校验不能直接传入串口或屏幕指令。6. 把 NFC、AI、StackChan 串成主程序6.1 主循环结构主程序可以分成初始化、读卡循环、配置切换、AI 推荐、串口控制五个阶段。下面是一个可以运行的完整框架import json import os import time import serial from pn532pi import Pn532, Pn532I2c, Pn532Mifare # ---------- 初始化 ---------- roles, nfc_map load_config() i2c Pn532I2c(1) nfc Pn532(i2c) nfc.begin() nfc.SAMConfig() ser serial.Serial(/dev/ttyUSB0, 115200, timeout1) current_role outdoor def send_face(face_name): ser.write(f#FACE {face_name}\n.encode()) def send_move(head, body0): ser.write(f#MOVE head {head} body {body}\n.encode()) def send_speak(text): ser.write(f#TEXT {text}\n.encode()) # ---------- 读卡循环 ---------- print(StackChan NFC dressing system started) while True: uid read_uid() if uid: if uid not in nfc_map: print([NFC] unknown tag:, uid) time.sleep(2) continue new_role nfc_map[uid] print([NFC] tag detected:, uid, role:, new_role) current_role new_role role roles[current_role] send_face(role[face]) send_move(30, 20) send_speak(f已切换到{role[name]}模式) # AI 自动匹配等待用户输入 user_input input(请输入一句话让 AI 帮你匹配更合适的配饰) if user_input.strip(): selected_role, reply ai_select_role(current_role, user_input, roles) if selected_role ! current_role: current_role selected_role role roles[current_role] send_face(role[face]) send_move(20, -10) send_speak(reply) time.sleep(2) time.sleep(0.1)这段代码把不同的功能拆成了函数但input会阻塞主循环。实际项目中更优雅的方式是使用队列把 NFC 读卡、AI 请求、串口发送分别放在不同线程里。下面是一个简化版的队列方案from queue import Queue from threading import Thread task_queue Queue() def console_input_thread(): while True: text input() task_queue.put((ai, text)) thread Thread(targetconsole_input_thread, daemonTrue) thread.start() while True: uid read_uid() if uid and uid in nfc_map: task_queue.put((nfc, nfc_map[uid])) if not task_queue.empty(): task_type, payload task_queue.get() if task_type nfc: apply_role(payload) elif task_type ai: handle_ai_input(payload)队列方式能避免 AI 请求耗时导致的读卡丢失尤其在用户频繁贴卡时会稳定很多。6.2 串口指令控制 StackChan串口协议需要根据自己固件调整。上面代码中使用的格式是#FACE happy切换屏幕表情。#MOVE head 20 body -10控制头部和身体舵机。#TEXT 你好显示文本或触发语音模块。在真正开始调试前先用串口工具手动发送一条指令确认控制板能解析。如果发送#FACE happy后屏幕没有变化可以检查串口波特率是否与控制板固件一致。每条命令末尾是否有控制板要求的\n或\r\n。控制板是否支持你发送的表情名。不要在还没有确认串口协议的情况下就开始写主程序否则最后所有 NFC 和 AI 代码都要跟着串口协议返工。6.3 运行日志和预期输出运行主程序后的正常日志应该类似StackChan NFC dressing system started [NFC] tag detected: 04:12:34:56:78:90 role: outdoor 已切换到户外探索者模式 请输入一句话让 AI 帮你匹配更合适的配饰我今天要见客户 [AI] selected role: interview [AI] reply: 我会为你切换成职场模式语气更正式。 #FACE serious #MOVE head 10 body -5如果出现unknown tag说明 UID 不在映射表里需要把真实 UID 补充到nfc_map。如果 AI 返回的role_id不在配置库中程序会保留当前角色避免出现“AI 推荐了不存在的装扮”这类问题。7. 常见问题排查与最佳实践7.1 按现象倒推原因下面整理一套常见问题排查表覆盖硬件、配置和 AI 多个层面问题现象可能原因检查方式处理建议i2cdetect -y 1没有 0x24接线错误或模块未供电检查 VCC、GND、SDA、SCL重启系统后重试优先换一个 I2C 通道路数测试PN532 能读到 UID但读不到角色标签没有写 NDEF 文本或映射表未配置用手机 NFC Tools 查看标签内容先写 NDEF 文本再同步更新 nfc_map贴卡后没有反应读卡循环被 input 阻塞观察日志是否停在 input改用队列或后台线程处理用户输入舵机抖动或乱动串口指令发送过快电压不足用逻辑分析仪看串口时序测量电源电压每条指令后加延时舵机接独立电源AI 接口超时网络不稳定或模型响应慢单独运行 call_llm 测试函数设置超时时间失败时回退到默认角色AI 返回的 JSON 解析失败模型没有按指定格式输出打印原始 content增加容错解析使用正则提取花括号内容换了角色但表情没变表情名不在控制板固件支持列表手动串口发送表情指令验证调整 roles.json 中的 face 字段排查顺序建议是先确认 NFC 读到 UID再确认角色 ID 映射是否正确最后才看 AI 和串口部分。不要一上来就改模型 prompt底层链路没通之前上层问题很难定位。7.2 NFC 标签和读取器使用建议NFC 标签虽然使用简单但有几点注意事项NTAG215 的 UID 不是保密数据不要把 UID 当作安全凭据。需要做防复制时应使用支持加密的标签并增加服务端校验。标签不要贴在金属物体表面金属会吸收高频磁场导致识别距离大幅缩短。如果必须放金属上要使用抗金属标签。写入数据前先确认标签类型。NTAG213、NTAG215、NTAG216 容量不同写入工具显示的可用空间也不同。在程序里不要频繁轮询 NFC每 100 毫秒一次即可。过于频繁会增加 CPU 占用也可能干扰其他 I2C 设备。7.3 生产环境部署清单从学习 demo 转向长期运行前至少检查下面几项配置文件是否外置是否能支持热更新。API Key 是否通过环境变量注入是否没有提交到 Git。NFC 标签是否有重复发放和补卡的流程。串口指令是否有错误重试机制。是否有结构化日志至少记录 UID、role_id、AI 调用耗时和异常堆栈。是否做了 AI 结果校验防止任意 role_id 被传入配置库。断网或 AI API 不可用时是否还能保持基础换装功能。这套 NCF AI 的换装框架不只适用于 StackChan。同样的逻辑可以扩展为实体音乐墙、卡片互动屏、盲盒识别、展会打卡等场景读卡、映射、配置、AI 推荐四个步骤可以复用。对于第一次尝试的开发者建议先把“贴卡切配置”跑通观察日志再逐步加入 AI 推荐。这样每一步都能得到明确反馈排查成本也会小很多。