会给自己写工具的 Dongle:一个由大模型操控的 ESP32-S3 BLE 实验室

Author: zcode · 2026-11-03 · 中文翻译(CSDN 发布稿)
原文:article/index.html(Medium 长文 “The Dongle That Writes Its Own
Tools”)。本文件按仓库语言规则属于例外:应所有者要求(2026-11-03)
生成的外部发布用中文译稿,不作为仓库文档维护。发布前请删除本说明块。

发布说明(粘贴到 CSDN 前删除):

  • CSDN 用 Markdown 编辑器,直接粘贴正文即可;
  • 文中 7 张图片在 article/img/ 下,粘贴后请在编辑器里逐一上传对应
    PNG 并替换本地路径(每张图下方已给出中文图注);
  • 代码块、命令、错误码保持英文原样,符合 CSDN 阅读习惯。

摘要: 一个五美元的 ESP32-S3 dongle,通过你的 PC 把 BLE 设备和云端大模型桥接起来。dongle 负责采集广播数据和 GATT 连接数据,以严格的 JSON 行格式通过 USB 流式输出;宿主机对数据流做后处理、赋予语义,大模型读取这些语义、回答你的问题、编写 Lua 代码、定义自己的工具,并把它们部署回 dongle——闭环一路回到产生数据的设备本身。一切都在沙箱里、经过确认门控,并在真实硬件上验证过。本文讲清这套系统的架构、数据流,以及从使用者视角出发——你究竟能拿它做什么。

调试蓝牙低功耗(BLE)设备这件事,出奇地手工:要么买个专用的抓包设备,要么跟 Wireshark 的抓包文件搏斗,要么为每碰到一个新设备就现写一个一次性解析脚本。而当你想让固件本身换一种方式过滤或解读流量时,你又得回到工作台:重新编译、重新烧录、重新测试。

这个项目押了另一个注:把 Lua 沙箱放进一个廉价的无线芯片里,把大模型放在宿主机一侧——让它有能力扩展自己的测量仪器。dongle 负责采集;PC 负责给数据赋予含义;模型读取这些含义,写出决定后续行为的代码——代码几秒钟内经 USB 送抵设备,而不是一轮烧录周期。整条链路——采集、分析、生成、部署、观察——今天就在真实硬件上运行,本文的每一个论断都有 300+ 测试体系中的某一项检查作为依据。

TL;DR: ESP32-S3 固件(C 语言,约 5.7k 行 + 内置 Lua 5.4)把 BLE 广播变成 ≤512 字节的 JSON 行,经 USB CDC 输出。三个自包含的 Python 工具把一个 OpenAI 兼容的大模型接入环路:你可以就实时流量和它对话;它在你确认之后编写并部署 Lua 过滤器;它还能在 dongle 上定义并运行自己的 Lua 工具;一个物理演示证明了这个闭环——大模型对一个模拟传感器的 time constant(时间常数)的估算,与真值偏差在 5% 以内。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

整个系统一张图:你的 BLE 设备经由这条链路与 LLM 对话——它的数据沿 dongle 的射频上行,LLM 的 Lua 沿同一条路下行。

一场会话,三个参与者

先说清楚:一场会话运行时,房间里到底有谁。在你这一侧是三个参与者——外加射频链路另一端的一台设备:

  • 设备——你的传感器或控制器——是数据的出生地。它在空中广播,或者维持着一条 dongle 可以加入的 GATT 连接。
  • USB dongle 以两种方式采集数据——广播,或一条活的 BLE 连接——并把它作为严格的 JSON 行经 USB 流式输出。
  • PC 宿主机对数据流做后处理:改善语义——去重、命名设备、标注每行数据来自哪个数据面、维护一个有界的滚动快照——让原始的射频字节变成有意义的、模型可直接使用的上下文。LLM 就住在这里,距一次 API 调用只有一步。

而你是第四个参与者:提问的人,批准代码的人。于是整场会话跑成一个循环。设备的数据上行——先到 dongle,再到 PC,经后处理进入 LLM 的上下文。LLM 的回复不只是话:Lua 脚本、工具程序、直答、追问。代码沿同一条链路下行,在离设备几米远的 dongle 上运行——过滤它的数据、变换它、对它实时响应、驱动 dongle 自己的引脚、维持或断开连接。这才是真正的产品:LLM 通过 dongle 控制设备,你在它身后看着,手里握着部署键。

从你的角度看:只碰三个地方

先讲内部结构之前:作为用户,你恰好在这个系统的三个位置触碰它,其余一切都是为了让这三个位置好用。

1. 你对着 dongle 控制台敲命令(任意串口终端——PuTTY、screen、自带的 monitor)。输入:纯文本命令,如 SCAN START、FILTER ADD RSSI -70。输出:每条命令一行严格的 JSON 响应,外加每条 BLE 广播一行 JSON 事件流——你、脚本、LLM 都能读。

2. 你运行宿主机工具(普通的 Python 命令行)。输入:串口号、一个自然语言目标、case 参数。输出:抓包文件、生成的 Lua 脚本、带评分的估算结果、会话记录。

3. 你和 LLM 对话。 这是主界面,也是这个项目存在的理由。你输入一个平常的问题——“我周围哪些设备是苹果的?”、“只保留我的两个传感器”、“植物传感器的平均 RSSI 是多少?”——模型作答,在你表述含糊时向你追问,提出 Lua 代码,或者在 dongle 上执行工具调用。每一次代码部署都要你批准;有界预算内的工具调用则自主运行。

所以与你的契约很简单:文本进,JSON 和答案出。 下文解释是什么让这份契约值得信任。

dongle 内部:一个数据包的旅程

固件是一个 ESP-IDF v5.1 工程,一个关注点一个组件,组件之间只通过 interfaces/*_if.h 里的公共头文件通信——正是这层契约,让多个 AI agent 能并行扩展这份代码几个月而互不踩脚。

  • usb — USB-Serial/JTAG 控制台:行 I/O、Ctrl+C 中断路径
  • ble — NimBLE 观察者(被动扫描、去重)+ 可选的 GATT central
  • proto / json_enc — AD 结构解析与 JSON 行编码器
  • filter — 热路径上求值的最多 16 条 C 过滤规则
  • lua — 128 KB 静态内存池上的 Lua 5.4 沙箱;钩子、工具包、hw.* 绑定
  • cli — 命令分发,背后是 IDLE / SCANNING / SCRIPT_RUNNING 状态守卫
  • bridge / storage / power — 脚本/工具包上传扫描、LittleFS、light-sleep 电源管理

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

数据流。每条广播从左到右走完这条流水线;第二行是可选的 GATT 连接面——它让 dongle 从观察者变成客户端。

每条广播走同一段旅程:NimBLE 观察者(被动、连续)→ 去重(FNV-1a,约 1 秒窗口)→ AD 解析 → C 过滤引擎 → [Lua on_adv 钩子:保留/丢弃] → JSON 编码 → [Lua transform 钩子] → USB 控制台发送。第二条可选的数据面把 dongle 连到一个 GATT 对端——订阅它的 notify,或退回轮询读取——并把数据以 "src":"conn" 行的形式汇入同一条 USB 流。

真正让其余一切成为可能的,是那些不光鲜的不变量:热路径零 malloc、一把 TX 互斥锁保证行永不交错、按模块划分的数字错误码、以及一个用 -911 拒绝时序错误命令的状态机。Ctrl+C(0x03)即使在连接工作线程内部也会被轮询——没有任何命令能锁死控制台。

一份大模型可以信任的线上契约

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

全部协议:文本行进,JSON 行出。每条响应都写明它回答的是哪条命令,因此迟到的旧响应永远不会被张冠李戴。

宿主机链路是 USB CDC,协议简单得近乎不像话:

> SCAN START
< {"cmd":"scan","status":"ok","state":"SCANNING"}

> SCRIPT LOAD
> print("hello from the model")
> SCRIPT END
< {"cmd":"script","status":"loaded"}

有三个细节,比看上去重要。

每条响应都带 cmd 字段——当 agent 打断一个运行中的脚本、紧接着发出下一条命令时,旧的响应行可能还在往外排;cmd 字段保证旧答案永远不会被当成新答案。错误是数字,不是散文——每个模块拥有一个 100 宽的负数号段(Lua 沙箱 -6xx、CLI -9xx……),程序——或模型——可以精确地按失败类型分支。上传的代码逐行扫描、fail-closed(默认拒绝)——os.、io.、require 一类会在到达解释器之前被 -612 拒收。对"什么能运行"拥有最终裁决权的,是设备,不是宿主机。

宿主机上的大模型:真正的产品

这里是必须强调的部分,因为它是上述一切存在的理由:LLM 不是贴在旁边的噱头——它是主界面。 三个 Python 宿主工具的存在,就是为了给模型干净的上下文和安全的执行器。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

宿主机-LLM 环路:两路输入(设备 + 你)、一次提问、一份组装好的上下文、四类类型化回复——路由给你、经人工门控、或进入下一轮上下文。

模型看到什么。 两个数据面的滚动快照——最新去重后的广播行、最新的连接面样本——每一轮对话刷新一次,外加工具注册表(见下)和你们的对话历史。采样有上限(20 条 adv + 30 条 conn 行),以控制 token 成本。

你能问它什么。 关于实时流量的任何事:“我周围有什么设备?”、“我传感器的信号是不是在变弱?”、“总结一下我能看到的厂商”。它用类型化信封回答:

  • answer——直答,依据当前快照;
  • clarify——你的表述有歧义(“只保留我的设备”——哪几台?),所以它先问再做;
  • lua——它提出代码:一个过滤器、一个 transform、或一个工具包,只有在 deploy? [y/N] 之后才部署;
  • error——出了故障(服务商、设备);会话继续存活,故障既展示给你,也作为提示回喂给模型。

模型能自主做什么。 装载了工具注册表之后,它能超越"回答":面对一个关于实时数据的问题,它可能写一个小型 Lua 工具程序,组合已注册的工具,在 dongle 上执行,读回结果,然后继续——每轮最多连续执行三次,之后必须以纯文本作答。这个"生成-执行"(generate-and-execute)循环,正是把 dongle 变成一台自我扩展的仪器的关键。

你如何保持控制。 四道门,按顺序:严格的类型化信封解析(一次聚焦转义的重试);每次脚本/钩子部署前的人工 deploy? [y/N] 门——以及一个可选的 --mutating-gate,把确认扩展到会改动硬件状态的工具程序;上传时的逐行沙箱扫描;设备端白名单沙箱作为最终裁决。工具调用是有界自主;一切改变流水线行为的动作都需要明确同意。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

工具注册表。上半部:一个 pack 如何注册(manifest 分块取回,冲突在任何设备字节发出之前拒绝)。下半部:LLM 运行的 cap-3 执行循环。

定义工具:Lua 工具注册表。 一个 tool pack(工具包)就是一个普通的 Lua 文件:每一行都是一条完整语句、不超过 240 字节(因此能搭现有单行传输的便车,固件零改动);每个工具只收一个 table 参数、返回一个字符串;一个 manifest() 函数宣告这套 API——名字、文档、带类型的参数、示例,以及一个 mutating 标志:

-- every tool: ONE table argument, returns a string
function mean(a)
  local s = 0
  for _, v in ipairs(a.numbers) do s = s + v end
  return string.format("%.3f", s / #a.numbers)
end

function manifest()
  return '{"version":1,"name":"demo","tools":'
      .. '[{"name":"mean","doc":"mean of numbers",'
      .. '"args":[{"name":"numbers","type":"table"}]}]}'
end

在 assistant 会话里 /tools load demo.lua——完成,这个工具你和大模型都能调了。工具包可以持久化到 Flash 并标记 autorun,于是每次上电你的工具都活着,不需要 PC 在场。设备侧的 hw.* 绑定(GPIO 读写、12 位 ADC、一个小型持久化键值存储)让工具包能触碰物理世界;那个 kv 存储甚至能挺过重启。

你能调用的全部接口

面向用户的完整接口面,浓缩版。控制台上(输入 → 你得到什么):

  • 发送 SCAN START/STOP、SCAN INTERVAL ms —— JSON 广播行开始流式输出,外加扫描状态
  • 发送 FILTER ADD NAME/UUID/RSSI/MAC … —— 只有匹配的设备输出(≤16 条规则,AND/OR 逻辑)
  • 发送 SCRIPT LOAD → 行 → SCRIPT END,然后 SCRIPT RUN/STOP —— on_adv/transform 钩子加入流水线
  • 发送 LUA EXEC code 或 LUA BEGIN … LUA END —— 单行或 4 KB 块的结果,立即返回
  • 发送 PACK BEGIN name [autorun] → 行 → PACK END —— 一个工具包存入 Flash,可选开机自启
  • 发送 CONN TARGET/START/STOP/INTERVAL … —— 一个 GATT 对端的数据以 "src":"conn" 行到达
  • 发送 POWER SLEEP ON/OFF、STATUS、VERSION —— 或 Ctrl+C —— 电源策略;一份完整的健康快照;一个万能中断

assistant 会话里(与 LLM 的聊天):

  • 自由文本——关于实时流量的任何问题 → 模型回 answer / clarify / lua 信封
  • 在 deploy? [y/N] 提示处输入 y → 提议的 Lua 被上传并运行(经沙箱扫描)
  • /tools load file.lua · /tools persist name autorun → 注册一个工具包 · 持久化到 Flash
  • /scan on|off · /conn on|off|status · /samples · /history → 不离开聊天就能驱动两个数据面

Shell 里(批处理与脚本化使用):

python llm_loop.py COM12 loop --secs 8 --goal "keep only my Sensor_* devices"
python host_app/assistant.py COM12            # the interactive chat
python host_app/run_case.py COM12 --case first_order --estimate
python host_app/assistant.py COM12 --no-llm   # mechanics without any API key

任何 OpenAI 兼容端点都能用——OpenAI、通义千问/DashScope、DeepSeek、OpenRouter、本地 Ollama。凭据放在 gitignore 的 .llm_env 里(模板:.llm_env.example);被拒的 key 或阿里云方言的 URL 会自我修复并大声重试。

让一切落地的演示:估算一个时间常数

对"这套系统究竟能干什么"最清晰的回答,是一阶被控对象(first-order plant)这个 case。一条命令:

python host_app/run_case.py COM12 --case first_order --tau 10 --step-at 5 --secs 60 --estimate

一步一步发生了什么:

  1. 你的 PC 变成一台 BLE 传感器。 一个 GATT server(Windows WinRT)广播一个服务,对外提供一个模拟一阶系统的测量值:y' = (K·u − y)/τ,输入 u 在 t = 5 s 时从 0 阶跃到 1。(在 GATT 里,server 是持有数据的一侧——在这里,是生成数据的一侧;dongle 作为 client 连上去。)这个宿主机侧的 BLE 仿真本身就是个功能——可复现的射频流量、已知的真值,不需要第二台真实设备。
  2. dongle 按广播的服务 UUID 自动连接,开始接收测量值,并以 "src":"conn" JSON 行的形式转发给宿主机,写入抓包文件。
  3. LLM 拿到原始抓包——只有 JSONL 样本,别的什么都没有——并且必须以 JSON 作答:这个传感器的时间常数 τ 是多少?
  4. 测试框架按物理规律给答案打分,不看观点:一阶阶跃响应在 t = step_at + τ 时刻应达到 K 的 63.2%,估算值必须落在配置真值的 ±30% 以内。

实录的现场运行:抓到 60/60 行,LLM 的估值为 τ̂ = 9.5,配置真值 τ = 10.0——误差 −5%,由模型仅凭原始样本推出。一个更难的变体 --case first_order_poll 把特征值设为只读、订阅不可用;dongle 检测到后回退到轮询读取——case 依然端到端通过。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

被评分的闭环:PC 模拟的被控对象 → dongle 连接面 → LLM 估算 → 物理校验的裁决。

这个演示为什么重要:它在一个可测量的循环里同时检验了仿真(宿主机)、射频(连接/订阅/轮询)、传输(JSON 行)和智能(估算)。当你改动技术栈中的任何一环,这就是告诉你"产品是否还能用"的那个实验。

信任,是一套测试

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

检查清单与一条命令的硬件门禁:固件版本不对就全停,任何一个套件之后出现意外重启即判失败。

  • 160 项 Unity C 测试在宿主机构建(单元 + 响应契约 + fuzz)——每一条有文档的 CLI 响应都做严格 JSON 校验;fuzzer 首轮运行就找到三个真实的编码器缺陷。
  • 198 项 python 检查分布在三个宿主工具上(仅 assistant 就占 153 项),包括在设备模拟器上跑完整的 REPL 会话。
  • 167 项硬件检查由真实 COM 口驱动的套件完成,藏在一条命令后面(python tests/hw/run_all_hw.py):它先断言固件版本,把每份记录留档,并在每个套件之后检查芯片的 reset_reason——一次意外重启就判门禁失败。
  • 长跑(soak):盯着 Lua 内存池的两小时广播运行,以及每 300 秒重连一次的连接长跑。

这个项目同时也是 AI 原生开发的一个案例研究:面向机器的工程任务书、提案里带编号的设计决策、合并前由第二个模型评审代码、以及一个由硬件门禁决定合并的分支仪式——从不靠"信心"。

路线图,以及如何上手

  • 工具注册表下一步: 更多 hw.* 绑定、更丰富的 mutating 工具工作流、在支持的模型上启用原生 function-calling。
  • 可移植性: 固件是标准 ESP-IDF——它已经能在其他 ESP32-S3 开发板上运行(包括语音/OLED 开发板)。

仓库用标准 ESP-IDF v5.1 构建,C 测试在宿主机上用 gcc 或 MinGW 运行,硬件套件只需要 COM 口上插着 dongle:

git clone https://github.com/jinboagent/esp32_lua_llm.git
cd esp32_lua_llm
scripts\build.bat && scripts\flash.bat        :: firmware
cmake -S tests/host -B build/host -G Ninja && cmake --build build/host
build\host\test_runner.exe                      :: 160 C tests
python tests/hw/run_all_hw.py                   :: hardware gate
python host_app\assistant.py COM12              :: start chatting

如果你曾经想要一个不只是谈论你的设备、而是真正坐在设备环路里的大模型——数据经 dongle 上行,可运行的代码原路下行——并且护栏值得信任,那么这就是一个关于如何构建它的小而完整的示例。

仓库地址:github.com/jinboagent/esp32_lua_llm —— 固件、宿主工具、测试、用户指南(docs/features.zcode.md、docs/user-guide.zcode.md、docs/demo-first-order.zcode.md),以及一份 17 页的架构 deck。感谢阅读。

Logo

更多推荐