教程七:把真实设备接进来
ANetLink:14 个协议适配器,而 daemon 仍然不知道"设备"是什么
场景与前置条件
你有一台机器连着真实设备:一台 Modbus PLC、一根 Zigbee 协调器棒、一个已经跑着的 Home Assistant、几台 IP 摄像头。你想让网络另一端的 agent 能调用它们,并且拿回的 不是"我发了命令",而是可核对的效果记录。
这一页做三件事:先在一台机器上把 ANetLink 单独跑通(不需要 daemon,不需要 hub), 再接一台真实设备,最后把它接进 anet daemon,让另一台机器经 hub 委派它。
开始之前需要:
- 一台装了 Go 1.26.6+ 的机器(
go.mod声明go 1.26.6)。ANetLink 只能从源码构建 —— 仓库里既没有发布脚本也没有 release workflow,.github/workflows下只有ci.yml。CGO_ENABLED=0,无需 C 编译器。 - 一个 anet daemon(第三步才用到)。版本 0.1.7,一行安装见 安装。
- 第三步还需要另一台装了 anet 的机器作为调用方,两边都能出站访问同一个 hub。
本页用官方 hub
https://hub.agentnetwork.org.cn。
第一步和第二步与 anet 完全无关。ANetLink 是一个能单独使用的运行时:装上它加一个 适配器,你本机的 Claude Code 就能经 MCP 直接控设备,一个联系人都不需要。
ANetLink 是什么
ANetLink 是 ANet 面向物理世界的窄腰。它南向说各种设备协议,北向只暴露一个能力面。
三个模型撑起这件事:
DeviceProfile —— 一台设备"能做什么"的机器可读描述,是内容寻址的 CoreDet-CBOR 对象,有稳定 CID。它由三部分组成:
| 组成 | 含义 |
|---|---|
| 能力列表 | 点分类法 id,如 light.onoff、sensor.temperature、ptz.move |
| 访问掩码 | 三比特:STATE=1(主动状态流)、SET=2(执行)、GET=4(按需读)。可读可写的开关 = 6 |
| 信任阶梯 | 两条正交的轴:MaxTrust(V0–V4,这次执行是否真的发生了)与 AuthTrust(A0–A4,这台设备是谁) |
Effect —— 每次调用返回一个效果,状态与度量严格分离。这是 ANetLink 的诚实核心:
sdk.EffectBuilder 在 Verified() 没有配套的观测(Metric() 的度量,或 Snapshot() /
StreamURI() 的制品)时,Build() 自动降级为 UNVERIFIED。适配器在类型层面无法假报
OK。runtime 再钳一道:MaxTrust < V2 的设备,即使适配器声称 OK 也强制降为
UNVERIFIED。信任只能钳、不能抬,适配器和 quirk 都无权抬高。
Adapter / Backend —— 适配器说一个协议族,实现 C4 合同;一个适配器可以有多个后端, 启动时按优先序探测。
北向两个消费口,同一批设备与效果:MCP(给 Claude / IDE)与 C1(给 anet daemon)。
双后端制:探测优先,永远兜底
设计纪律写在 sdk/probe.go 的 Select 里:
- 有
backend_override时用指定的后端,但探测仍然必须通过 —— 强制指定却探测失败 是配置错误,启动即报,不静默降级; - 没有 override 时按声明顺序探测,取第一个可用的;
- 全部不可用时,错误信息里带每个后端各自的失败原因,而不是一句"不可用"。
意图是:宿主设施(已装的 BlueZ、已跑的 OTBR、已跑的 zigbee2mqtt 或 Home Assistant) 活着就骑上去,不活着就落到自研兜底路径,永远都要兜底。
按当前代码,这条纪律只有 thread 适配器真正落成了两个后端(host-otbr 与
managed-otbr)。其余 13 个适配器的 Backends() 各自只返回一个后端。
兜底在适配器一层存在而不在后端一层:Zigbee 生态有两条路(骑 zigbee2mqtt 的 mqtt
桥,和直驱协调器棒的原生 zigbee),但它们是两个适配器,不是同一个适配器的两个后端。
这一点值得如实说明,不要按"每个适配器都有双后端"去理解现状。
14 个适配器与它们的 build tag
适配器全部是编译期插件,注册在 cmd/anetlinkd/plugin_<name>.go 里。有两套 tag 约定,
用哪一套本身说明了适配器的性质。
减法 tag(默认编入,-tags no_<name> 去掉) —— 对本地网络讲开放标准、不依赖发行版
之外的东西:
| 适配器目录 | 配置键 | build tag | 说明 |
|---|---|---|---|
sim | sim | !no_sim | 内建模拟灯。默认注册,裸 anetlinkd 也有东西可看 |
modbus | modbus | !no_modbus | Modbus/TCP,纯 Go 客户端,写后读回 |
mqttbridge | mqtt | !no_mqtt | zigbee2mqtt / Tasmota 桥,骑在已跑的 broker 上 |
habridge | homeassistant | !no_ha | Home Assistant WebSocket 桥 |
opcua | opcua | !no_opcua | OPC UA,工业自动化 |
bacnet | bacnet | !no_bacnet | BACnet/IP,楼宇自动化,纯 Go ASHRAE 135 编解码 |
can | can | !no_can | CAN 总线,over SocketCAN |
zigbee | zigbee | !no_zigbee | 原生 Z-Stack 协调器,自研 UNPI + ZCL |
ble | ble | !no_ble | 纯 Go 直连内核 HCI socket,不需要 BlueZ / D-Bus / cgo |
btmesh | btmesh | !no_btmesh | 蓝牙 Mesh,自实现 AES-CMAC / AES-CCM 与密钥调度 |
thread | thread | !no_thread | Thread 承载,host OTBR 或打包 otbr-agent 受管子进程 |
加法 tag(默认不编入,-tags <name> 才有) —— 经厂商 HTTP 接口触达设备、要持有凭据:
| 适配器目录 | 配置键 | build tag | 说明 |
|---|---|---|---|
onvif | onvif | onvif | ONVIF IP 摄像头,标准路径,带 WS-Discovery |
hikvision | hikvision | hikvision | 海康 ISAPI |
dahua | dahua | dahua | 大华 CGI |
方向反过来的理由与 anet 的 shell tag 一致:两种 tag 写错的代价不对称。减法 tag 漏了,
是某人想去掉的模块没去掉;加法 tag 漏了,是一个没人启用过的攻击面进了所有构建。
另有一个加法 tag adap,是编译期之外的逃生口:经 UDS 托管一个不在本仓构建的
C4 适配器(可以是别的语言写的)。一个 socket 一个适配器,不做复用。
配置里写了没编入的适配器,启动响亮报错,并且指向正确的修法 —— 加法的说
"用 -tags onvif 重新构建",减法的说"是不是带了 no_x"。裁掉的能力绝不静默消失。
第一步:单机跑通
构建并空跑
实际输出:
四行分别是:加载的厂商修正数量、这个二进制里编进并且配置好的适配器、设备清单
(键 = <适配器>/<设备id>,带信任级与 Profile CID)、一次真实调用的效果。
第二行只有 sim,不代表这个构建里只有 sim。默认构建把 11 个减法 tag 的适配器全都链接
进来了 —— go tool nm ./anetlinkd | grep -c adapters/modbus 是正数,adapters/onvif
是 0(加法 tag,没带 -tags onvif)。这一行列的是注册进 runtime 的适配器,而只有
sim 是 defaultOn:其余的没写配置段就不注册。
sim 的 status=OK verifiable=true 是真的:它是进程内闭环,MaxTrust=V4、
AuthTrust=A4,并且带回了度量 power_state=1。换成一台云端设备,同样一句
Verified() 会被 runtime 钳成 UNVERIFIED。
挂到 Claude / IDE(MCP)
在 stdio 上起一个 MCP server,四个工具:
| 工具 | 作用 |
|---|---|
devices_list | 列出全部设备:键、协议、在线态、V/A 双轴信任、可调能力、Profile CID |
device_invoke | 调用一个能力,返回诚实效果(status / verifiable / metrics / message / evidence) |
devices_discover | 扫描已连接的网络,返回发现结果与可直接粘贴的配置片段 |
devices_pair | 开放入网窗口(默认 60 秒,上限 254 秒 —— Zigbee 的限制),报告谁加入了 |
--mcp 模式下 stdout 归 MCP 会话,日志走 stderr。
在 Claude Code 里注册这个 MCP server 之后,就可以直接说"把客厅的灯关掉"或
"我刚买了个传感器",分别落到 device_invoke 与 devices_pair。
第二步:接一台真实设备
先让它自己找
没有可扫的东西时输出是这样(这是裸构建的真实输出):
14 个适配器里有 5 个实现了自动发现(sdk.Discoverer):zigbee(ZDO 邻居表)、
bacnet(Who-Is 广播)、mqttbridge(读 zigbee2mqtt 的 retained 设备表)、
habridge(枚举 HA 实体)、onvif(WS-Discovery)。其余九个是静态配置 ——
Modbus、OPC UA、CAN 这类协议本身没有发现机制,只能写地址。
扫出来的结果可以直接落成配置文件:
已存在的文件不会被覆盖,会报错让你先挪开。
写配置
config.json,所有段可选;不写某适配器段即不注册它。
要点:
access是三比特整数按位或:STATE=1、SET=2、GET=4。可写可读 = 6,只读 = 4。modbus段的键名来自 Go 结构体字段名,该结构体没有 json tag (adapters/modbus/modbus.go的DeviceConfig)。encoding/json的兜底匹配不区分大小写 但不认下划线,所以 unit id 那一项是unitid,写成unit_id会被静默忽略、落成 0。 (ANetLink/docs/MANUAL-zh.md§5 的样例写的是unit_id,那一处是错的。)backend_override强制某适配器用指定后端。强制但探测失败 = 配置错误,启动即报。quirk_dir放站点自己的厂商修正,叠在随构建发行的那批之上(裸构建默认加载 7 条)。 修正只改数值,永远不能抬信任,并且被应用的 quirk id 会写进效果的 evidence —— 一个被修正过的读数不是设备原样报出的读数,消费方必须能看出来。
启动
启动日志会打印:加载的厂商修正数量、编进并配置好的适配器、设备清单(每台带在线态、 信任级与 Profile CID)。
选中的后端不在这份日志里。runtime 记着它(Runtime.Backend(<适配器名>) 能问出来),
但 cmd/anetlinkd/main.go 没有把它打出来 —— 眼下要确认某个适配器骑上了哪个后端,只能靠
探测失败时的报错,或者读 backend_override。
第三步:接进 ANet
到这一步为止,设备只对本机可见。接上 daemon 之后,它们变成这个节点在 hub 上可被 委派的能力。
让 anetlinkd 服务 C1 wire
这条 wire 是 HTTP over Unix socket,三个端点(默认 JSON,带
Accept: application/cbor 时返回 CoreDet-CBOR):
不接 daemon 也可以直接核一遍:
实际返回:
确认 daemon 里有 anetlink 模块
modules: 这一行列的是这个二进制里实际链接进来的模块。v0.1.7 的发布件每个平台只有
两个 —— anet-<plat> 与 anet-shell-<plat>(deploy/release/build-release.sh 的
build_one "$plat" "" "anet" 与 build_one "$plat" "shell" "anet-shell"),一行安装拿到的
是前者:不带任何减法 tag,所以 anetlink 在内。
min/standard/paid/agent/p2p 那几档确实带了 no_anetlink,但它们是
docs/DISTRIBUTIONS-zh.md 里的构建配方,没有对应的发布件 —— 要那几档得自己构建。
详见发行版总览。
判据是符号数,不是运行时开关:
配置 daemon 指向那个 socket
编辑 ~/.anet/config.json:
socket 必填,timeout_s 限定启动时那次注册握手(缺省 5 秒)。
先起 anetlinkd,再起 daemon。 模块编译进来了、配置块也写了、但 socket 连不上,
daemon 会启动失败并报 register provider on /run/anetlink.sock: ...,而不是带着
一份缺了能力的注册表照常起来。这是故意的:后者属于"沉默的失败",会在几天后由一次
"莫名其妙找不到 provider"的委派发现。
也存在一个旧的等价写法 providers.anetlink.socket(只有 socket 一个字段),
daemon 会把它投影成同一个模块配置块。新配置用 modules.anetlink。
把能力发布到 hub
daemon 在注册时会把 provider 注册表里实际服务的能力 id 折进要发布的 caps 列表,
和运营者手写的标签并存。也就是说 light.onoff@sim/lamp-1 这样的 id 是 daemon 自己
放进去的,不需要你手抄一遍。
daemon 每次启动也做同一次合并并重新注册一遍(internal/daemon/daemon.go 的
refreshRegistration,0.1.7 起)。所以设备清单变了(新配了一台 PLC、换了适配器 tag)
之后,anet stop && anet up 就够,不必再显式 hub-register;显式注册仍然可用,
改展示名或手写 caps 时用它。两条路径共用一把锁,后到的不会被过期快照覆盖。
这次刷新在后台跑、失败不致命:hub 不可达时 daemon 照常起来,只在 daemon.log 里留一行
anet: could not refresh this node's registration with <hub>: …。真的改了清单时会留一行
anet: refreshed capabilities at <hub>: [...]。看不到设备能力上目录,先查这两行。
能力的"加"跟着重启走,"减"不一定。 显式 hub-register 会把合并后的 caps 写回
config.json,所以一个曾经显式注册过的设备 id 会留在本机 caps 里;拔掉那台设备再重启,
启动时的刷新报的仍然是它。要真的撤下来,手工删掉 config.json 里 caps 数组的那几条再重启。
另一台机器委派它
调用方装默认版即可,不需要 ANetLink,也不需要知道设备的存在:
--cap 走 hub 的能力索引做精确匹配,* 结尾按前缀匹配一个族,逗号分隔是或。
它和不带 --cap 的 anet find 是两个问题:后者搜的是散文,会返回一个只是"提到了"
这个词的 agent。
拿到对方 AID 之后:
--capability 的委派不经过任何模型:provider 端的 daemon 用注册表解析这个 id,
确定性执行,把效果作为交付物返回,附一份签名收据,并写进本地证据链。
交付物的形状(capabilityResult):
evidence 一路从适配器穿过 C1 wire 到达委派方,包括 quirk 字段 —— 一个被厂商修正
改过的数值,消费方能看出它被改过。这份 provenance 也在 provider 自己的证据链里,
两侧对同一次执行的记述不能不一致。
哪些 evidence 字段有值取决于适配器。sim 只填度量、不填 evidence,所以拿它做的那次
/v0/invoke 返回是 {"status":"OK","metrics":{"power_state":1}},没有 evidence 段 ——
这是诚实的:一个进程内模拟器没有"协议"也没有"设备 ack"可报。
C1 红线:能力 id 里带着设备,daemon 不知道设备是什么
light.onoff@sim/lamp-1 这个 id 的形状是 <能力>@<适配器>/<设备>。它只在 ANetLink
一侧铸造和解析 —— northbound/c1serv 的 CapabilityID 与 ParseCapabilityID 是全
系统唯一认识这个形状的两个函数。
过了 socket 之后,它在 daemon 眼里是一个不透明字符串:
provider/anetlink只负责把它放进 HTTP body;provider.Registry把它当 map 的键;internal/daemon/capability.go把它写进 TaskDoc 的Requires,签名、投递、记账。
这几层里没有一行代码去看 @ 右边是什么。daemon 因此不需要有"设备"这个概念,
也不需要有"设备表""在线状态""配对窗口"。它只知道"某个 id 可以被调用,调用会返回一个
效果"。
这条红线的实用后果是:ANetLink 可以长出第 15、第 20 个适配器,daemon 一行都不用改;
而 daemon 也不会慢慢长成第二个设备管理系统。同一道门里还进来别的东西 ——
cas、shell、service、taskboard 的能力,走的是同一个
provider.CapabilityProvider 接口。
诚实效果:UNVERIFIED 不是失败
设备这一侧最容易出错的地方,是把"我发了命令"当成"我确认了效果"。ANetLink 用两条机制 挡这件事:
结构上挡 —— EffectBuilder.Verified() 没有配套的度量或制品时,Build() 降级为
UNVERIFIED。适配器作者写不出一个没有观测支撑的 OK。
运行时钳 —— runtime 对 MaxTrust < V2 的设备把 OK 强制降为 UNVERIFIED。
云 API 封顶在 V1(只有传输层 ack),所以任何走厂商云的路径都不可能报出可验证的成功。
各适配器如实标注自己能验证到哪一级:
| 适配器 | VerifyTrust | AuthTrust | 验证手段 |
|---|---|---|---|
sim | V4 | A4 | 进程内闭环 |
modbus | V2 | A1 | 写后读回,读回值即 metric;相悖报 FAILED |
opcua | V2 | A2 | 写后读回 |
homeassistant | V2 | A1 | 等 state_changed 事件;幂等执行回退到活读 |
mqtt | V2 | A1 | 等设备状态回声;静默 = UNVERIFIED |
bacnet | V2 | A1 | 写后读回;BACnet/IP 无设备身份,认证轴止于 A1 |
zigbee | V2 | A2 | 加密网络 + 每设备链路密钥,读回属性 |
btmesh | V2 | A2 | AppKey 认证每条消息;未收到 status 的 set 报 UNVERIFIED |
thread | V2 | A2 | CoAP PUT 后 GET 读回 |
can | 读 V2 / 写 V1 | A0 | 传统 CAN 无应用层 ack、无设备身份,写入不得假装验证成功 |
ble | V2 | A0 | 广播即观测值,但无身份、无 ack;写入被拒绝而不是假装成功 |
摄像头这一族有自己的红线,因为它们是第一类"结果不是数字"的设备:
ptz.move的速度契约统一为[-1,1],厂商标度在适配器内部换算(海康[-100,100]、 大华离散方向码加 1–8 速度)。调用方不需要知道这是谁家的相机。- 每次 move 都是有界的,发完必停。ONVIF 的 continuous move 不停就一直转。
- 位置回读是 PTZ 的验证手段。相机到机械限位时会照常 ack 却纹丝不动;只信 ack 就会
报告一次没发生的转动。这种情况报
UNVERIFIED并说明"位置未变"。 verify_stream打开后,取流会真做一次 RTSP DESCRIBE 拿到 SDP 才算验证通过。GetStreamUri只是返回一个字符串,相机会为一条编码器没起来的流照样发 URI。
五种效果状态的完整语义见效果状态参考。
已知缺口
这些是当前代码的实际状态,不是路线图上的措辞。
Matter 栈没有接成适配器。 matter/ 下九个包齐备(TLV、mDNS 发现、SPAKE2+、PASE、
帧、exchange/MRP、加密会话、交互模型、入网序列),SPAKE2+ 对 RFC 9383 与
connectedhomeip 官方向量双钉,PASE 与一只 matter.js 虚拟灯完成了完整握手、双方导出
同一会话密钥(那是真实的 Matter 协议实现,但不是硬件设备;硬件握手没有记录)。但
cmd/anetlinkd/ 下没有 plugin_matter.go —— 这套栈今天还不能作为设备出现在 runtime 的设备表里,
也不会出现在 --scan 的结果中。
厂商云适配器为零。 adapters/ 下 14 个适配器全部走本地网络或局域网内的厂商
HTTP 接口。没有任何一个适配器接厂商云(小米、涂鸦、Aqara 之类)。这意味着一批只提供
云 API 的设备目前接不进来 —— 并且即使接进来,它们的效果也封顶在 V1,永远报不出可验证
的 OK。
摄像头的 PTZ / 事件 / 抓拍没有真机验证记录。 L0 假件覆盖了全部路径(真 SOAP 与
ISAPI/CGI 线格式、真 multipart 事件流),L1 用 onvif-server 覆盖发现、能力投影与 RTSP
探测 —— 但 onvif-server 只实现 Profile S,PTZ、事件、JPEG 抓拍这三样必须真机。海康 ISAPI 与
大华 CGI 是厂商私有协议,不存在模拟器,整条路径都只能在 L2 跑。相应的
-tags realworld 测试写好了(TestRealPTZ、TestRealSnapshotIsRealBytes、
TestRealStreamProbe),等的是把 ANETLINK_HIKVISION_ADDR 之类指向真机。
TestRealPTZ 会真的转动云台(跑完会尽量转回去)。不要对着此刻有人在看的画面跑。
已经验证过的那部分要一并说清楚:ANetLink 的 ONVIF 适配器驱动 ANetMock 里的模拟海康球机
跑通过 —— 绝对定位经位置回读确认(pan 0 → 0.6)、抓拍拿到 2391 字节真 JPEG 并内容寻址、
RTSP DESCRIBE 得到 H265。这三个数字取自 ANetMock/README.md 的自述,本页没有重跑这条链路。
ANetMock 说真实线协议且状态闭环,但它不是真机;两者不能互相替代。
自动发现覆盖 5/14。 见上文第二步的清单。其余适配器要手写地址与寄存器/节点映射。
排查
| 症状 | 原因与处置 |
|---|---|
adapter "x" configured but not compiled into this build (built with no_x?) | config 引用了被减法 tag 裁掉的适配器。重构建不带那个 no_x,或删掉 config 段 |
adapter "x" is configured but not compiled in — rebuild with -tags x | 引用了加法 tag 的适配器(onvif / hikvision / dahua / adap)。按提示加 tag 重构建 |
sdk: forced backend "y" unavailable: ... | backend_override 指定的后端探测失败。检查宿主设施是否真在跑 |
sdk: no backend available — ... | 该适配器所有后端都探测失败,错误里逐个列了原因 |
daemon 启动失败 register provider on ... | anetlinkd 没起、socket 路径不对、或权限不足。先起 anetlinkd 再起 daemon |
| hub 上看不到设备能力 | 接完 anetlink 之后 daemon 没重启(模块只在启动时构建),或者重启时 hub 不可达 —— 启动时那次注册刷新失败只写一行日志。anet logs 里找 could not refresh this node's registration,修好后重启,或跑一次 anet hub-register |
anet find --cap 找不到,但 anet find 能搜到 | 前者是精确/前缀匹配。确认 id 一字不差,包括 @ 后面的适配器名与设备 id |
效果总是 UNVERIFIED | 设备 MaxTrust < V2,或读回/事件没到。这是诚实行为不是缺陷 —— 命令发了,但这一级验证不了 |
--demo 输出 no devices | sim 被 no_sim 裁掉或 config 里关了。这是预期的优雅退化 |
| MQTT 适配器发现 0 设备 | broker 通但没有 zigbee2mqtt 的 retained bridge/devices。确认 z2m 在同一个 broker 上 |
更多见排查手册。