桌上有一块 ESP32-P4 的小板,用来显示各个 AI 的额度。它平时直连 Mac 上的采集程序(bridge);直连不通时,退回去走一个 relay(经过 Cloudflare Tunnel)。

这周我把 relay 从 192.168.1.244 搬到了 Mac 上,搬完板子右上角就一直是 × no link。我一开始以为是搬迁搞坏的。

这篇记它怎么被证伪,以及最后落到的根因:板子从 DHCP 拿到的网关是 192.168.1.1,经它访问 Cloudflare 有相当比例的连接超时;而我的 Mac 走的是旁路网关 192.168.1.254,一直正常。

文中域名、token、Wi-Fi 信息均已抹去;内网地址是真实的。


一、TL;DR

  • 现象:搬完 relay 后,板子 header 一直显示 × no link,但下面的额度数据还在(停在最后一次收到的那份)。
  • 先排除了"搬迁引起":板子最后一次请求 relay 的时间,比我切 DNS 早了 16 分钟;之后它对新旧两个 relay 都没有任何请求。
  • 根因(间接证据):板子 DHCP 拿到的默认网关是 192.168.1.1。我在同样走 .1 的 NAS 上用 curl 打 Cloudflare,约四成会超时;Mac 走 192.168.1.254,一直正常。板子直连 Mac 一断就退到 relay,而 relay 这条路经 .1 基本连不上,于是一直 no link。
  • 修复:固件里加一个"同一个 /24 里就把网关覆盖成 192.168.1.254“的选项。烧录后 relay 约 2 秒就连上了,之后稳定。
  • 没查清的:为什么 .1 去 Cloudflare 会丢这么多连接(我没有往下追),以及板子最初掉线的那一次究竟是怎么回事。

二、背景:这套东西怎么连的

  • bridge(Mac 上的 launchd 服务):采集各 agent 的额度/token 用量,推送给板子,也推给 relay。
  • relay:纯内存的 store-and-forward,一个很小的 HTTP 服务,没有持久数据。原来跑在 192.168.1.244 的 podman 里,前面挂一条 Cloudflare Tunnel。
  • 板子:优先直连 Mac 上的 bridge;连不上就去 relay 拉。

搬迁的做法:在 Mac 上起一个 podman pod,里面放 relay、cloudflared 和 pod 自带的 infra 容器。域名和 token 不变,所以板子固件不用改;DNS 切到 Mac 上新建的那条隧道上。Mac 重启后 podman machine 不一定起来,所以加了一个 launchd 任务,每 60 秒检查一次,没起就 podman machine start,pod 里有容器挂了就 podman pod start。

infra 容器的名字顺手改成了 hud-infra(podman 不能给已有 infra 改名,只能删 pod 重建,创建时加 --infra-name hud-infra,中间断了大约十秒)。我问过能不能干脆去掉这个容器、让 cloudflared 直接借 relay 的网络——不建议:pod 里 infra 的作用是持有网络命名空间,relay 一旦重启,借它网络的 cloudflared 也得跟着重建。这条是按命名空间的机制推出来的,我没有真的搭一套验证。


三、看一眼板子:先要能"看见”

我想知道板子屏幕上到底显示什么。桌上有一个 2K USB 摄像头对着它,让 AI 用 ffmpeg -f avfoundation 拍一张——拿到的是我 iPhone 的连续互通相机,黑的。换序号、换名字都会选错设备。

最后写了一个几十行的 Swift 小工具,按设备的 uniqueID 直接从那个摄像头取帧,现在放在仓库的 tools/hudcam.swift 里:

swiftc -O tools/hudcam.swift -o ~/.local/bin/hudcam

拍到的画面:右上角 × no link,下面的额度数据还在。


四、证伪"是搬迁搞坏的"

三条证据:

  1. 板子最后一次请求 relay 是 01:35(北京时间),比我切 DNS 的时间早 16 分钟。
  2. 切完之后,板子对新旧两个 relay 都没有任何请求。
  3. Mac 上的 bridge 在局域网里自己是通的,板子也没来连;局域网 ARP 表里找不到板子。

所以不是 relay 搬坏了,而是板子压根没联网,或者联上了但出不去。


五、先怀疑 Wi-Fi 协处理器,这个方向没证实

我的第一个猜测是板子上 C6 Wi-Fi 协处理器卡死了:原来的"自愈"逻辑只是每 3 秒调一次 esp_wifi_connect(),C6 卡死时拉不回来。这是猜测,没验证。

基于这个猜测,我让 AI 改了固件,同时把 no link 拆成几种具体原因,免得以后再靠猜:

  • no Wi-Fi:没拿到 IP;
  • no internet:有 IP 但连不上 Cloudflare;
  • Mac offline:网是通的,但 relay 没应答(比如 Mac 关机或睡眠);
  • 兜底仍是 no link。

还加了两条自愈重启:连上过 Wi-Fi 之后 3 分钟拿不到 IP,或有 IP 但连续 10 分钟连不上外网——板子会自己重启。Mac 关机不会触发重启;从没连上过 Wi-Fi(比如密码错)也不会。

同一轮里顺带做了:“最后一份数据存进板子上空着的 7 MB storage 分区(最多 5 分钟写一次),开机先显示它”,这样 Mac 关机后板子也能留住最后的数据,header 上标出数据有多旧,比如 × Mac offline • data 3h old;以及一个 Wi-Fi 图标,绿色带 dBm,信号弱于 -75 变黄,断开变红带 ×。

一个我自己引入的 bug

第一次烧录后板子不停重启(boot loop)。串口日志里看到断言失败:我新加的 header 每秒刷新的定时器在 net_start() 之前就开始跑了,它调用 net_is_connected() 时,里面用到的事件组还没创建。修法是一行判空:

bool net_is_connected(void) { return s_ev && (xEventGroupGetBits(s_ev) & BIT_GOT_IP); }

六、真正的根因:网关

固件烧好、boot loop 修掉之后,no link 依然在。这时我转向"板子联上网了,但到 relay 这条路出不去"这个方向,开始看路由:

  • 板子 DHCP 拿到的网关是 192.168.1.1(192.168.1.1)。
  • 我的 Mac 的默认路由走 192.168.1.254(192.168.1.254 旁路网关)。

怎么测板子走 .1 的效果?板子本身不好做抓包,我用了一个替身:wenkangnas.local (192.168.1.4) 的默认路由恰好也是 .1。在 NAS 上把 Cloudflare 的两个边缘地址轮流打几轮:

for i in 1 2 3 4 5 6; do
  for ip in <CF边缘IP-1> <CF边缘IP-2>; do
    curl -s -o /dev/null -m 10 \
      -w "$ip %{http_code} connect=%{time_connect} total=%{time_total}\n" \
      --resolve <ddns域名>:443:$ip https://<ddns域名>/health
  done
done

AI 汇总的结论是约四成超时;Mac 同样的请求一直正常。我手上只有它转述的结论,没有留存当时的完整输出,所以这里只能当作"趋势性证据"。

把这和板子的行为拼起来:它平时直连 Mac 上的 bridge;直连一断就退到 relay;而 relay 这条路经 .1,大概率超时——所以 no link 一直亮着。


七、修复

固件里加了一个 Kconfig 选项:

config HUD_GATEWAY_OVERRIDE
    string "Default gateway to use instead of DHCP's, when in the same /24 (empty = keep DHCP)"
    default "192.168.1.254"

拿到 IP 后,如果板子在同一个 /24 里,就把网关换成这个值;在别的网络(比如公司)里则仍用 DHCP 给的网关。覆盖网关后不提前 return,照常置位"已联网"标志,避免万一 esp_netif 没有再发一次 GOT_IP 事件时卡在没网的状态。

烧录后的结果:relay 约 2 秒多就连上了,之后一直稳定。这也是因果上最直接的验证——覆盖网关前 no link,覆盖后消失。但要说清楚:我只验证了"换网关之后好了",没有在板子上直接抓包证明 .1 丢包,也没有去追 .1 为什么会丢 Cloudflare 的连接(可能是光猫/主路由对某类连接的处理,没查)。


八、顺带的几处小事

  • ~/ai/hud 和实际在用的 ~/code/agents-hud 是两份目录,后者更新两个提交。我一开始改在了旧的上,后来把补丁迁到新的里并撤掉了旧的改动,迁移时在 ui.c 上解了一个冲突。改之前先确认哪份是活的。
  • 我让 AI 把 agent 在板子上的顺序改过一次(OpenCode → Grok → Claude → Codex),看了之后觉得应该和电脑端保持一致,整个 revert 了。以后调顺序在电脑端拖动就行,板子自动跟着变。
  • 提交前专门查了 git 历史里有没有 relay token 和 Wi-Fi 密码:没有,相关文件都在 .gitignore 里。仓库推到了 Gitea 和一个 GitHub 私有仓库;它里面的子模块指向的是公开 fork,克隆私有仓库时会拉到那份。
  • 代价:relay 现在在 Mac 上,Mac 关机或睡眠时板子拿不到新数据(有 flash 缓存的最后一份数据可看)。介意的话可以把 relay 放回虚拟机或 NAS。

九、可复用的清单

“设备能连 Wi-Fi,但连某个外部服务时好时坏”:

  1. 先分清是设备自己没联网,还是联网了但某条路径出不去。给设备加一个能区分的状态显示,比靠猜省事。
  2. 看 DHCP 给它的网关,和你的"正常机器"用的网关一样吗?用一台走同一网关的其他机器做替身去复现,比在设备上抓包容易得多。
  3. 用 curl --resolve 把同一个域名固定到不同的边缘 IP,各打几轮,看是不是整体性的超时。
  4. 固件里覆盖网关时,只在已知的网段里生效,别的网络保持 DHCP 的结果。
  5. 开机路径上的定时器、回调,不要假定初始化顺序——这次的 boot loop 就是这么来的。