# 工程审查记录：构建/烧录分工与 artifact provenance

日期：2026-10-08
审查对象：`main`，审查期间 HEAD 由 `bc946b8` 前进到 `6bca975`
审查方式：本机 + lighthouse(`claw.pangtiao.xyz`) + 真机三方交叉核实
状态：**发现已实测确认，待 Codex 复核**

> **HEAD 变动说明**：审查过程中新增提交
> `6bca975 tools: re-apply Wi-Fi credentials after a flash`，
> 仅改动 `tools/mac/`（`device.sh` +9、`provision-wifi.sh` +216、`wifi_ctl.py` +14）
> 与 `docs/wifi-provisioning-plan.md`。
> 经核实**不触及 `server/` 或 `firmware/`**，故本文档全部结论不变：
> - `git diff --stat bc946b8..6bca975 -- firmware/` 为空 → `dist/` artifact 仍有效
> - `78a8f0c` 仍是 HEAD 的祖先 → 部署漂移结论不变
>
> 文中凡引用 `bc946b8` 之处均为审查时点的实测值，保留原样以存证。

---

## 0. 背景与本次审查的起因

本次审查起因是一次**判断错误**：初次静态审查本机仓库时，看到 `.cache/` 为空、
`dist/` 为空、无 `.elf/.bin`，据此得出"工程从未构建过"的结论。

该结论是**错误的**。真实原因是工程刚从 lighthouse 迁移到本机，本机是**新加入的
烧录/调试节点**，不是构建节点。经用户指出后重新核实，得到本文档的记录。

**这暴露的教训**（也是本文档存在的主要价值）：
本仓库存在两个物理位置不同的运行环境，**只看其中任何一个都会得出错误结论**。
静态阅读本机仓库不足以判断构建状态、部署状态与 provenance。

本机与 lighthouse 的分工（经用户确认，且是工程既定设计）：

| | 本机（Mac） | lighthouse |
|---|---|---|
| 角色 | 烧录 + 真机调试 | 构建 + 生产部署 |
| ESP-IDF | 不需要 | 5.5.4 |
| `tools/mac/.venv` | esptool 4.8.1 + pyserial | — |
| 网关 | 可选（调试用，需独立端口/凭据） | 生产 `gw.pangtiao.men` |

---

## 1. 环境实况（已核实）

### 1.1 两机 git 状态一致

```
本机       HEAD = bc946b8  branch=main  clean
lighthouse HEAD = bc946b8  branch=main  dirty=0
```

`bc946b8` = `watch: show Codex reset as MM/DD-HH:MM instead of weekday`

### 1.2 本机 artifact 完整且与 lighthouse 字节一致

`dist/m5stack-stopwatch/` 在本机**是完整的**（初次判断为"空"是错的）：

```
VERSION  bootloader.bin  candidate.json  firmware.bin
flash.json  merged-flash.bin  partition-table.bin
```

```
sha256(merged-flash.bin)
  本机      7232f521be3501abbdff2d7cb910b669faebe4f876f59f763edd142552e1d17b
  lighthouse 7232f521be3501abbdff2d7cb910b669faebe4f876f59f763edd142552e1d17b
  flash.json 7232f521be3501abbdff2d7cb910b669faebe4f876f59f763edd142552e1d17b
```

三方一致。本机 `./tools/mac/device.sh verify dist/m5stack-stopwatch` 通过。

### 1.3 本机工具链就绪（无需 ESP-IDF）

```
tools/mac/.venv/bin/python      Python 3.9.6
  -m esptool                    v4.8.1
  - import serial.tools.miniterm  OK
```

`device.sh` 依赖的 `esptool` / `pyserial` / `miniterm` 全部可用。

### 1.4 真机在线并已实测（本机直连）

```
Port   : /dev/cu.usbmodem14501
Chip   : ESP32-S3 (QFN56) revision v0.2
Feature: WiFi, BLE, Embedded PSRAM 8MB (AP_3v3)
Crystal: 40MHz
MAC    : 28:84:85:43:a2:00
Flash  : 16MB (Manufacturer 20, Device 4018)
```

设备 app descriptor（`read_flash 0x10020`）解析：

```
magic        : 0xabcd5432   ✅ 有效
version      : 0.8.0-dev
project_name : device_lab
build date   : Oct  8 2026
build time   : 10:57:00
idf_ver      : v5.5.4
secure_version: 0
```

即**设备当前固件 = 今日 10:57 构建 = 与 `dist/` 产物同源**。
`flash_id` 报告 16MB，与 `flash.json` 期望一致（`device.sh:157` 的尺寸门禁通过）。

### 1.5 生产网关健康

```
device-gateway.service  active (running) since 2026-10-07 13:42, ~21h
  Memory: 198.6M (peak 386.3M swap 56.5M)
  ├─ uvicorn device_gateway.app:app --host 127.0.0.1 --port 18081
  └─ node .../releases/0.8.0-78a8f0c/integrations/pi/sdk-host.mjs

$ curl https://gw.pangtiao.men/health
{"status":"ok","control_version":"0.5.0","online_devices":1}
```

`online_devices: 1` —— 端到端链路是活的。

### 1.6 Cloudflare Access 边界实测生效

| 路径 | 实测 | 说明 |
|---|---|---|
| `/health` | **200** | 匿名可探测，符合文档 |
| `/api/status` | **302** | Access 登录跳转 |
| `/admin` | **302** | Access 登录跳转 |

此前静态审查曾把 `/api/*` 无本地 auth 列为风险。**实测证明该边界生效**，
应降级为"文档化的信任边界，验证通过"，不是缺陷。
（仍然成立的低成本加固：为 `GATEWAY_HOST` 增加启动断言，防误配成 `0.0.0.0`。）

### 1.7 Wi-Fi 凭据：本机有，lighthouse 无（分工所致）

本节为写入时点新落地的 `6bca975` 补充。

```
本机       .env  存在，权限 0600，166 bytes
            keys: DEVICE_WIFI_SSID / DEVICE_WIFI_PASSWORD
            git-ignored (.gitignore:9)
lighthouse .env  不存在
```

这是**正确且预期**的不对称：`.env` 含 Wi-Fi 明文凭据，
而 `.gitignore` 已将其排除，仓库从不分发它。
构建机（lighthouse）不需要 Wi-Fi 凭据（它不联调设备），
烧录机（本机）才需要。

`tools/mac/provision-wifi.sh`（`6bca975` 新增）解决的是一真实陷阱：
**刷固件不会携带 Wi-Fi 凭据**——凭据存在设备自己的 NVS 里，
构建期迁移只在真正的首次启动（NVS store 不存在）时触发。
所以重刷后若不重新下发，设备就联不上网，而人会误以为是固件问题。

设计上值得肯定：密码只从文件读取、**从不走 argv**，
因此不会泄入进程列表或 shell history。

---：`dist/` 内的 `candidate.json` 是矛盾且陈旧的孤儿

**严重度：中（误导性文档，非哑弹）**
**影响面：直接命中"本机只烧录、不做构建"这条工作流**

### 2.1 现象

同一目录、同一时间戳（10:58）的两个 JSON，字段结构几乎相同，内容互相矛盾：

```jsonc
// dist/m5stack-stopwatch/candidate.json
{ "version": "0.6.1",
  "firmware   sha256": "dd161d69…",
  "merged_image_sha256": "38c530b8…",
  "source_commit": "b9d25039ad0349abe0a64273f3d2c0c9ffc69f59",
  "source_tree":   "5beb91493caef1a91e39ec4352c12d80af3fc2cf" }

// dist/m5stack-stopwatch/flash.json      ← 反映真实产物
{ "version": "0.8.0-dev",
  "firmware   sha256": "ae2faa38…",
  "merged_image_sha256": "7232f521…" }
```

`candidate.json` 声称 `0.6.1` / `b9d2503`；
实际产物与真机固件都是 `0.8.0-dev` / `bc946b8`。

### 2.2 根因（已定位）

1. `scripts/package-firmware.py:92` **只写 `flash.json`**，从不写 `candidate.json`：

   ```python
   (output_dir / "flash.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
   (output_dir / "VERSION").write_text(version + "\n", encoding="utf-8")
   ```

   且其 `metadata` dict（`package-firmware.py:79-91`）**不含 `source_commit` / `source_tree`
   字段** —— 即使是权威的 `flash.json` 也无法反查构建源 commit。

2. 全仓库对 `candidate.json` 的引用**只有 1 处**：
   `docs/physical-ui-cleanup-0.6a.md:21`
   （"新 artifact 的源 commit、SHA-256 与软件状态见同目录 `candidate.json`"）

3. `git ls-files` 无记录，`dist/` 在 `.gitignore` 内
   （`.gitignore`: "Firmware artifacts are reproducible build output / dist/"）

→ 结论：`candidate.json` 是 **0.6a 时期遗留的、无任何代码生成或消费的死文件**，
被 `update-flash.sh` 的 `scp -r` 每次原样搬运，随构建目录一起活到今天。

### 2.3 危害评估（精确边界）

**烧写行为是正确的，不会烧错固件。** `device.sh` 只信任 `flash.json`，
证据 `tools/mac/device.sh:176-192`：

```
:179  [[ -f "$artifact_dir/flash.json" ]]          # 门禁读 flash.json
:183  expected_chip = ... flash.json["chip"]
:186  expected_sha  = ... flash.json["merged_image_sha256"]
:191  actual_sha 与 expected_sha 比对，不符则 exit 1
```

`candidate.json` 不在任何代码路径上。

**真实危害是溯源错误**：任何按文档去查 provenance 的人
（未来的维护者、按 `docs/physical-ui-cleanup-0.6a.md` 操作的人）会读到
`0.6.1` / `b9d2503`，而当时设备上跑的是 `0.8.0-dev` / `bc946b8`。
在"本机不构建、无法自查"的分工下，这个错配尤其容易骗到人。

### 2.4 建议修复（二选一，不要两个都保留）

- **方案 A（推荐）**：删 `dist/` 里的 `candidate.json`，
  改 `docs/physical-ui-cleanup-0.6a.md:21` 指向 `flash.json`。
- **方案 B**：让 `package-firmware.py` 生成 `candidate.json` 并纳入版本管理，
  同时**两个文件都由脚本生成**，消除手工残留的可能。

两种方案都应附带：**给 `flash.json` 的 `metadata` 补上 `source_commit` /
`source_tree`**（`git rev-parse HEAD` / `git write-tree`），
否则发布物永远无法反查构建源。这是本条建议中价值最高的部分。

---

## 3. 发现 D2：六个层叠 drop-in 决定生产代码路径

**严重度：中（可工作，但脆弱且难推理）**

lighthouse 上该服务有 6 个 drop-in 层层覆盖 `WorkingDirectory` / `ExecStart`：

```
demo-text-voice-baseline.conf  → .cache/demo-text-voice-baseline-0.5.1
zz-wearable-shell-0.6a.conf    → .cache/wearable-shell-0.6a
zzz-agent-navigator-0.6b.conf  → /home/lighthouse/esp32-dev
zzzzz-stopwatch080.conf        → releases/0.8.0-451b4c5  (+PYTHONPATH)
zzzzzz-live-sessions.conf      → releases/0.8.0-78a8f0c  (+PYTHONPATH)  ← 生效
```

实测生效值：

```
PYTHONPATH       = .../releases/0.8.0-78a8f0c/server
WorkingDirectory = .../releases/0.8.0-78a8f0c/server
```

**注意**：运行的解释器来自 `.cache/double-click-shortcuts-0.6.3/server/.venv`，
但**代码来自 `releases/0.8.0-78a8f0c`**（快照副本，无 `.git`）。
两者不矛盾，但静态看进程命令行极易误判为"跑的是 0.6.3 的旧代码"。

**风险**：生效者由字母序决定。再添加一个 `zzzzzzz-*.conf` 就会**静默切换生产代码路径**。

**建议**：合并为单一 drop-in，显式声明唯一 `WorkingDirectory` + `ExecStart` + `PYTHONPATH`。

### 3.1 附带澄清：生产与 `main` 的漂移**无功能影响**

```
git merge-base --is-ancestor 78a8f0c main   → YES（生产落后，未分叉）
git log --oneline 78a8f0c..main
  bc946b8 watch: show Codex reset as MM/DD-HH:MM instead of weekday
  d395fa1 Merge branch 'main' of https://github.com/lilinux/esp32-dev
  13a8bc5 add todo follows
git diff --stat 78a8f0c..main -- server/    → （空）
```

落后的 3 个 commit **完全没有改动 `server/` 任何文件**（全是固件显示格式与文档）。
故网关侧**零功能漂移**。静态审查曾暗示"生产跑旧代码"，此处予以澄清。

---

## 4. 发现 D3：`run_history_binding_test.py` 硬编码 IDF 路径

**严重度：中（但不影响本机分工，属 CI/构建机问题）**

`firmware/tests/run_history_binding_test.py:55` 依赖
`.cache/esp-idf-v5.5.4/components/json/cJSON/cJSON.c`。

- 该依赖**未文档化**。
- 在 lighthouse 的 `build.sh` 流程内可满足（IDF 已安装）。
- 在**任何其他环境**（含本机）都会失败，
  报 `clang: no such file or directory` + `CalledProcessError`，
  表现为"测试失败"而非"环境缺失"，**会掩盖真实回归**。

**说明**：本机按分工**不装 ESP-IDF**，因此本机**不应也无法**运行此测试。它属构建机流程。

精确引用（`firmware/tests/run_history_binding_test.py:55`）：

```python
subprocess.run(['cc','-c',str(cjson/'cJSON.c'),'-I',str(cjson),'-o',str(temp/'cjson.o')],check=True)
```

注意两点：
- 用 `check=True` 且 `cjson` 路径由 `.cache/esp-idf-v5.5.4` 硬拼而来，
  因此**路径不存在时表现为硬失败**（`CalledProcessError`），而非 skip。
- 编译器写死为 `cc`。本机 macOS 无 `cc` 时另有一层失败
  （`clang: no such file or directory`），与路径问题叠加，更难定位。

**建议**：改为从 `IDF_PATH` 派生路径；组件或编译器缺失时 **skip 并明确提示**，
而不是硬失败；编译器改用 `CC` 环境变量并回退。

---

## 5. 发现 D4：弱网语音缓冲余量不足（唯一需真机复现项）

**严重度：高（功能可用性问题）**
**状态：静态量化已完成，真机复现待做**

### 5.1 已量化的根因

`firmware/.../voice_capture_limits.h`：

```
VOICE_CAPTURE_FRAME_MS     = 40
VOICE_CAPTURE_QUEUE_FRAMES = 64
VOICE_SEND_TIMEOUT_MS      = 2000
VOICE_MIN_RECORDING_MS     = 1000
```

推导：

```
队列容量        = 64 × 40ms            = 2560 ms
单次 send_text 失败阻塞 =               2000 ms（占 2560ms 的 78.1%）
剩余可吸收抖动  = 2560 - 2000          =  560 ms ≈ 14 帧
```

即：**两次连续 stall（2×2000=4000ms），或任何 >2.56s 的中断，
即触发 `capture-queue-full`**（`audio_backend.cpp:316`）。

现有 `static_assert` 只证明"缓冲 > 单次超时"，**未覆盖超时期间仍在到达的帧**，
因此该缺陷不会被编译期发现。

唯一上行发送路径：`cloud_client.cpp:131 esp_websocket_client_send_text`
（阻塞式）。

### 5.2 为何日志无法证实

`capture-queue-full` 是**设备侧串口日志**，不出现在网关日志中。

已核查 lighthouse journal（留存自 Oct 2，共 210512 行），
`capture-queue-full` / `PONG_TIMEOUT` / `voice_error` / `reconnect` / `1012`
**均 0 命中** —— 但这**不构成反证**，因为网关根本不产生这些设备侧事件。
（同理，此前"网关日志 0 命中"曾被误读为"问题不存在"，已在本文档更正。）

### 5.3 验证方法（建议下次真机调试做）

```
# 终端 A：抓设备串口
export ESPPORT=/dev/cu.usbmodem14501
./tools/mac/device.sh monitor

# 终端 B：制造弱网（需另配，勿动生产网关）
#   或直接拔网线/关 AP，然后连续两次 ≥2s 的断网
# 终端 A 观察是否出现 capture-queue-full
```

### 5.4 建议修复

- **最小改动**：`VOICE_CAPTURE_QUEUE_FRAMES` 64 → 256
  （10.24 s 余量，代价约 10 KB PSRAM）。
- **根治**：采集与网络发送解耦——录音先落 PSRAM，再带 ack 分块续传。
  已量化成本：20 s 录音仅需 **1.682 MiB PSRAM**（占 8 MiB 的 21%），完全可行。

---

## 6. 发现 D5：本机调试时必须避免与生产网关争抢设备

**严重度：中（本机新增角色引入的新风险）**

本机现在是烧录/调试节点，而**同一台真机同时连着 lighthouse 生产网关**
（`online_devices: 1`，MAC `28:84:85:43:a2:00`）。

若本机也起一个 gateway 指向同一 `device_id`：

- 两个 gateway 会争抢同一 WS incarnation
- 表现为互相 `close(1012)`、设备反复重连
- 极易被误判为固件 bug

**建议**：本机调试使用独立 `GATEWAY_PORT` 与独立 `device_token`；
或在调试期间临时停用设备侧指向生产网关的配置。

这是一条**运维纪律**，不是代码缺陷，但值得写进文档。

---

## 7. 本次审查中被证伪 / 需撤回的结论

为便于 Codex 复核，明确列出**我此前提出但已自我否定**的项，
避免它们继续在讨论中流转：

| 曾提出的结论 | 核实结果 | 证据 |
|---|---|---|
| "工程从未构建过" | ❌ **错误** | lighthouse 有 `.cache/esp-idf-v5.5.4`、`firmware/build/*.elf(20.9MB)/*.bin(3.18MiB)` |
| "本机 `dist/` 为空" | ❌ **错误** | 本机 `dist/m5stack-stopwatch/` 完整，7 个文件 |
| "`dist/` 产物陈旧" | ❌ **错误** | `merged-flash.bin` sha256 两机一致，且与真机固件同源 |
| "所有固件结论未经二进制验证" | ⚠️ **撤回** | 真机已实测：`0.8.0-dev` @ Oct 8 10:57，IDF v5.5.4 |
| "生产网关可能跑旧代码（功能漂移）" | ⚠️ **降级** | 确落后 3 commit，但 `git diff 78a8f0c..main -- server/` **为空** |
| "`/api/*` 无本地 auth 是风险" | ⚠️ **降级** | 实测 `/api/status`、`/admin` 均 **302**，Access 边界生效 |
| 固件侧 P0 "SSID data-race" | ❌ **误报** | `wifi_manager.cpp` 全部 13 处 `s_current_ssid` 访问点均在 `TransitionGuard` 内 |

**方法论备注**：前 4 项的根因是同一个——**静态阅读本机仓库不足以判断
构建/部署/provenance 状态**，因为真实运行环境在另一台机器上。
后续审查应先确定"在哪个环境看什么"，再下结论。

---

## 8. 当前状态下的可用操作（均已实测）

```bash
export ESPPORT=/dev/cu.usbmodem14501
cd /Users/lili/workspace/codex/esp32-dev

./tools/mac/device.sh detect          # ✅ 已实测通过
./tools/mac/device.sh monitor         # ✅ miniterm 就绪

# 首次务必先备份出厂固件（16MB 全片读，约 1-2 分钟）
./tools/mac/device.sh backup m5stack-stopwatch

# 从 lighthouse 拉取最新产物并烧写（含 preflight + sha256 校验）
./tools/mac/update-flash.sh

# ⚠️ 刷完后重新下发 Wi-Fi 凭据（否则设备联不上网，易误判为固件 bug）
./tools/mac/provision-wifi.sh --yes

# 交互式管理已知网络（增/删/清/查）
./tools/mac/device.sh wifi

# 只校验不烧写
./tools/mac/device.sh verify dist/m5stack-stopwatch   # ✅ 已通过
```

`update-flash.sh` 的 preflight 已核查通过：本机 `branch=main`、tracked changes 为空。

---

## 9. 建议的修复优先级

| 优先级 | 项 | 成本 |
|---|---|---|
| P1 | D1 补 `flash.json` 的 `source_commit`/`source_tree` | 低（~5 行） |
| P1 | D1 删除或改用脚本生成 `candidate.json` | 低 |
| P1 | D3 `run_history_binding_test.py` 改 `IDF_PATH` 派生 + 缺失 skip | 低 |
| P1 | D4 弱网语音：`VOICE_CAPTURE_QUEUE_FRAMES` 64→256 | 低（~10KB PSRAM） |
| P2 | D2 合并 6 个 drop-in 为单一配置 | 低（运维） |
| P2 | D5 本机调试端口/凭据隔离（写进文档） | 文档 |
| P2 | 重刷后必须重新下发 Wi-Fi 凭据（见 §1.7） | 低（`provision-wifi.sh` 已提供） |
| P3 | D4 根治：PSRAM 录音 + 有 ack 分块续传 | 高 |

原报告中另有若干 P1/P2 项（`app_main` 的 `ESP_ERROR_CHECK(cloud_client_start())`
abort 路径、`board_vibrate` 未检查 `xTaskCreate` 返回值、
`wifi_manager.cpp` 8 处 `ESP_ERROR_CHECK(esp_wifi_*)`、
`s_display_lock` 临界区与 `board_voice_active()` 每 40ms 争用、
无 MCU 级低功耗）**未受本次发现影响，仍然成立**，
详见前次审查报告，此处不重复。

---

## 10. 请 Codex 重点复核的问题

1. **D1 的修复方向**：`source_commit`/`source_tree` 应由
   `package-firmware.py` 写入，还是由 `build.sh` 传入？注意 `package-firmware.py`
   可能被用于构建目录之外，`git` 状态未必可用。
2. **D2**：合并 drop-in 时是否需要保留"回退到某个实验 checkout"的能力？
   若需要，更稳妥的做法是单一 drop-in + 环境变量，还是保留分层但加显式断言？
3. **D4**：`VOICE_CAPTURE_QUEUE_FRAMES` 提到 256 后，
   是否与 `CLOUD_RX_BUFFER_BYTES = 16*1024` 或其他缓冲存在新的耦合/上限？
4. **D5**：本机起调试网关时，`device_token` 与生产是否必须不同？
   若相同，是否存在除 WS incarnation 争抢外的其他相互干扰
   （如 `approvals.sock`、`GATEWAY_CODEX_QUOTA_REFRESH_SECONDS` 等）。
5. 是否还有其他"只在某一台机器上存在"的状态被遗漏
   （NVS 内容、`$CODEX_HOME`、`integrations/pi` 的 Node 依赖、cloudflared 配置）？
6. **§1.7 的凭据不对称**：既然 `.env` 只在本机存在，
   那么任何人复制本仓库到第三台机器做真机测试都需要重建 `.env`。
   是否需要在 `docs/` 里明确写一份"烧录机环境搭建清单"
   （`tools/mac/install.sh` + `.env` + ESPPORT），
   而不是让下一个人靠读脚本自己推断？