# ESP32-S3 Device Lab

M5Stack StopWatch **0.7.0 — Battery / Quota / Wi-Fi Provisioning**（基于 0.6.3）：随身查看 Agent 状态、切换已有 Pi/Codex Session、说话、处理原生交互和阅读最终结果。已有 Session 至少恢复最近一次 Agent 回复，状态标签保持 DORMANT/IDLE 等真实状态。产品默认启动到 Watch；Agent 是核心工作页，完整服务器指标作为次级 Monitor，Help 独立可见。

0.7.0 是日用体验收尾版本，包含四项内容：**电池满电/充电显示**、**Codex quota 自动刷新**、**Wi-Fi Profile 配网（Settings SoftAP + QR + Web Portal + USB）**、**Agent 完成/Approval 通知回归**。Speech Output 仍然冻结。

### 0.7.0

- Double-click shortcuts
- Touch navigation
- Bounded Agent history
- Agent completion / approval notifications
- Battery full / charging UX
- Fresh Codex quota
- Wi-Fi profile provisioning
- SoftAP + QR + local Web Portal
- USB Wi-Fi provisioning
- Speech Output deferred

Battery：原始电压估算（`percent`）与显示百分比（`display_percent`）分离。只有 PMIC 报告 charge complete 且滤波电压进入满电区间时，显示才抬升到 `100%`；带 latch 与 release 门限，插拔不会在 `98 ↔ 100` 之间抖动。Watch 电池行在接入外部电源时绘制本地 primitive 闪电（不使用可能缺失的 Unicode glyph），区分 CHARGING / FULL / EXTERNAL；System 的 Battery 详情同步给出 charging/full 文案。

Codex quota：新增 server 端有界周期刷新（默认 60 s），即使 native runtime 不再发送 `account/rateLimits/updated` 事件，缓存也会在约一分钟级追上真实值。刷新由 `quota_refresh_lock` 串行化（并发合并），不在 socket reader 内 await，失败保留上一次有效值，disconnect/close 时 clean cancel。设备端仍然只读缓存，绝不按 2 s snapshot 触发 native RPC。

Wi-Fi Provisioning：按 [Wi-Fi Provisioning 规划](docs/wifi-provisioning-plan.md) 实现。**设备绝不会因为连接失败而自动进入 SoftAP**：找不到 Wi-Fi、密码错误、已知 AP 消失、Gateway 不可达、重启无网，都只进入 `OFFLINE / RECONNECTING`，SoftAP 只能由 `Settings → Network → Wi-Fi Setup` 明确确认后启动。Known Network 存于 NVS（最多 5 条），启动时按 last-successful 优先尝试，当前网络正常时不主动 roam。Provisioning 屏幕显示本地生成的 Wi-Fi 标准 QR、稳定 SSID `AgentPager-XXXX`、固定 provisioning password 与 `192.168.4.1`；Portal 只暴露扫描结果与 SSID，candidate 必须先连接成功才写入 profile，成功后关闭 AP 并不重启地回到 station。USB recovery path 复用既有 Serial：`./tools/mac/device.sh wifi`，密码 hidden input。

Agent 完成 / Approval 通知：Pi 与 Codex 从 RUNNING 进入 DONE/FAILED 时震动 + audio tone，旧 session 不会通知当前新 session，周期 snapshot 与 reconnect 不重复历史通知；Approval 首次出现只提醒一次，resolve 后新的 approval 可再次提醒。Vibration 偏好继续生效。

0.6.3 加入 CST820B 触屏、可 host 测试的触摸手势 reducer、以及受限的原生 Agent History。Touch 通过现有 `handle_gesture` 生成虚拟 A/B，不复制任何 Approval / Voice / Settings / Navigator 逻辑；一次 contact 在第一次有效接触时锁定当时的 epoch，contact 期间若发生 Approval / Native UserInput / Voice / Settings takeover，未决手势即失效、绝不作用于新 owner（合法触发的 Touch Long 其 LONG_RELEASE 仍能结束该 Voice）。顶层左右滑动等同 A/B 翻页，Agent 页上下滑动进入手动浏览，越过最旧本地页进入 History，历史仍可用实体键从 `Settings → Agent → History` 进入。History 为只读、有界（每页 ≤4 条、整帧 ≤4096 UTF-8 字节、设备缓存 ≤3 页、opaque cursor），只暴露 user / assistant，永不建立第二份聊天数据库；单条长消息按文本屏线性分页，B/Older 走完该消息每一屏后才进入更旧的一条，A/Newer 完全可逆，footer 形如 `2 / 4 · 2 / 3`。Touch 可在 `Settings → Device → Touch` 关闭（NVS v4→v5，默认 On），关闭后实体按键功能完整。详见 [Touch/History 记录](docs/touch-history-0.6.3.md)。

Touch 驱动协议经过验证而非猜测：CST820B（I2C 0x15）使用 CST816S 寄存器集，来源是 M5GFX 的 StopWatch autodetect 与 `Touch_CST816S`（`0x02` 读 6 字节，chip id `0xA7`，INT GPIO13，TP_RST 经 M5IOE1 PYG4，raw 0..233）。固件复用已有 `s_i2c_bus`，不重复初始化 I2C；GPIO13 ISR 只置标志，Idle 时从不轮询 I2C；初始化失败为 nonfatal。

0.6.3 在正常顶层 Shell（Watch / Agent / Monitor / Help）增加两个快捷键：**Double A** 直接进入 Speech Mode 选择（`OFF / MANUAL / AUTO_REPLY / AUTO_SUMMARY`，A NEXT / B APPLY），**Double B** 直接进入已有 Agent Session Picker（与 `Settings → Agent → Session` 完全同一入口、同一状态机）。在 Approval、Voice、Settings、Navigator、Native UserInput、Error 和任何临时 overlay 下禁用 Double，这些页面仍保持原有即时单击响应。短 A/B 翻页、长 A Voice、长 B Settings 等既有行为不变；顶层单击因为双击窗口最多延迟 300 ms。详见 [Double-click 记录](docs/double-click-shortcuts-0.6.3.md)。

双击状态机沿用现有 `ButtonGesture`，不引入新的按键依赖：`DEBOUNCE_MS=30`、`LONG_MS=600`、`DOUBLE_GAP_MS=300`。第一次短按释放后先等待 300 ms，窗口内第二次同键短按产出唯一 `DOUBLE_CLICK`，绝不先产出第一次 `CLICK`；`LONG` 优先于 pending 的 single/double；pending click 绑定 epoch，UI 归属变化即失效。

0.6.2 把 Watch 变成极轻量的 Agent/Host 状态终端，同时保持"首先是一块表"：电池图标 + 数字百分比在最上方，日期在时间上方，时间仍是页面最大视觉元素，时间下方固定三行 `Host` / `Task` / `Codex`。正常 `Wi-Fi` 与 `Agent online` 文本已从 Watch 移除（诊断仍在 Settings → System），异常通过 `Host`/`Task` 行表达。Host 直接使用已有本地 server snapshot 派生状态；Codex quota 来自原生 `account/rateLimits/read`，搭载在已有 server_snapshot 帧上，不新增设备轮询、不改变 WSS 周期，Watch 仅在派生值真正变化时重画。详见 [0.6.2 收口记录](docs/watch-glance-0.6.2.md)。

0.6.2 真机验收及修复已通过：三行左右对齐、日期/时间上移、状态字号增大，长 UTF-8 会话不再触发 Watch 周期重画，断线 Host 状态正确失效为 Offline。0.6.3 承接 Watch Glance，加入 Touch 与受限 Agent History；Speech Output 仍保持冻结。

短 A 前进，短 B 后退：`Watch ↔ Agent ↔ Monitor ↔ Help`。0.6.3 起顶层单击经 300 ms 双击窗口确认后生效；双击窗口内不产生第一次单击。没有 launcher。长 A 保留 Voice 输入，长 B 打开 Settings；Settings 内长 A 返回一级、长 B 关闭，Edit 返回会撤销草稿和亮度预览；Approval 的 A APPROVE / B REJECT 和 Voice 的 A SEND / B CANCEL 优先于 Shell，且不受双击窗口影响。

Settings → Device：Brightness 10..100% / Vibration / **Touch On-Off**；Display：Home Screen / Compact、Normal、Large / Dark、Dim / Agent Clock；**Network：Wi-Fi Setup / Known Networks（列出、Forget、Forget All 需二次确认）/ 当前网络状态**；System：只读电压、Wi-Fi、Gateway、固件。只有 B Apply 才保存双槽 NVS，失败显示 APPLIED / NOT SAVED，本地设置离线可用。Gateway `hello_ack` 下发 UTC；RX8130 和内部走时保持 UTC，当前 Demo 显示固定 UTC+8，即使 Gateway/NVS offset=0 也不改变。断网后继续走时，RTC 无效且网络未同步时显示 `--:--`。

Settings → Agent 提供已有 Project/Session Picker、原生 Model/Effort/Permissions、Voice Confirm/Direct 和首次 Voice 创建新 Session。短录音保护、持久 target generation 和切换后的 authoritative snapshot 已实现；不提供完整 History Browser。0.6.1 完整软件 gate 和针对性实体回归已通过，作为 DAILY-USABLE BASELINE。详见 [0.6.1 收口记录](docs/agent-navigator-0.6b.md)、[0.6a 验收记录](docs/wearable-shell-0.6a.md) 和 [Hold A 收口记录](docs/hold-a-back-closeout-0.6a.md)。

**Speech Output = DEFERRED / FROZEN。** Volume 80 仅保留在偏好模型中，无运行时音量修改。固件只声明 `speech-settings`，不声明 `speech_playback`，Gateway 只有在 `speech_playback` 下才会下发 `speech_start/chunk/end`，因此保存 mode 不会重新启用冻结的播放。Speech Output 在本地/LAN 验证通过（硬件、I2S/ES8311/AW8737A、deterministic PCM、queue、Cloudflare MeloTTS、TTS PCM direct playback 全部 PASS），但生产跨境 WSS/TLS 出现未解决的数秒级 read stall，因此本轮明确暂停：不改 WSS/TLS、不改 chunk/jitter buffer/speech queue/idle timeout、不做 AUTO_REPLY/AUTO_SUMMARY、不改 TTS provider、不动 ffmpeg pipeline。已有 playback 实现与诊断成果保留，未来换网络环境后再评估。Voice Input / STT 是独立能力，继续支持。Control Layer / CONTROL_VERSION 保持 **0.5.0**。

0.4.1 更新 Gateway STT，现有 0.4.0 firmware 无需刷写。真实中文、英文、自然中英混合 STT 已通过。精确机器 identifier 为 best effort，发送前确认；model/session 等精确选择使用 Device Settings。该限制已被接受，不阻塞 STT production readiness。`GATEWAY_STT_INITIAL_PROMPT` 保持未启用。详见 [0.4.1 验收记录](docs/stt-0.4.1-validation.md)。

## Architecture

```text
Lighthouse (Gateway + system collector + optional Pi Extension)
  -> existing Cloudflare Tunnel / wss://gw.pangtiao.men
  -> M5Stack StopWatch (CPU / MEM / DISK / NET / Pi state)

Machine authentication:
  /device/ws              -> WSS + Gateway device token
  /integrations/pi/events -> Gateway Pi token
  /health                 -> unauthenticated health check

Human administration:
  /admin + management APIs/docs -> Cloudflare Access
  -> same allowed identity and IdP model as pi.pangtiao.men

Cloud build (ESP-IDF 5.5.4)
  -> dist/m5stack-stopwatch/merged-flash.bin
  -> scp to Intel Mac
  -> esptool + USB
```

当前完整实现 `m5stack-stopwatch`。`boards/waveshare_1_75c/` 只是明确的后续接入边界，不能构建，也不声称已经支持。

## 云端首次设置

要求：Git、Python 3、可访问 GitHub。脚本会把 ESP-IDF 和工具链安装到仓库内的 `.cache/`，不会要求全局安装，也不会改动服务器现有 Docker 数据。

```bash
git clone <your-repo-url> esp32-dev
cd esp32-dev
./scripts/setup-cloud.sh
```

工程固定 ESP-IDF `v5.5.4`，与 M5Stack 官方 StopWatch ESP-IDF 示例保持一致；M5GFX、M5PM1、M5IOE1 由 ESP-IDF Component Manager 以固定版本/提交获取。

## 配置 Wi-Fi 和网关

真实密码只放在 Git 忽略的本地文件中：

```bash
cp config/sdkconfig.defaults.example config/sdkconfig.defaults.local
```

编辑 `config/sdkconfig.defaults.local`：

```text
CONFIG_DEVICE_LAB_WIFI_SSID="your-2.4GHz-wifi"
CONFIG_DEVICE_LAB_WIFI_PASSWORD="replace-me"
CONFIG_DEVICE_LAB_CLOUD_WS_URL="wss://gw.pangtiao.men/device/ws"
CONFIG_DEVICE_LAB_DEVICE_TOKEN="provisioned-secret"
```

不要手工复制 token。首次配置或轮换时在 Lighthouse 运行：

```bash
./scripts/provision-device.py --device-id esp32s3-28848543a200
```

脚本生成高熵 token，同步写入 Git 忽略的 `config/sdkconfig.defaults.local` 和权限为 `0600` 的 `~/.config/esp32-device-gateway/device.json`，且不会打印 token。省略 `--device-id` 时，首个持有 token 的设备会完成一次性绑定。

ESP32 只能连接可用的 2.4 GHz 网络。设备必须能访问 Gateway；不要把 `127.0.0.1` 写进固件。公网连接使用公共 CA 的 `wss://`。token 只在 TLS WebSocket 的 `hello` 中发送，日志不会记录它。

## 构建固件

```bash
./scripts/build.sh m5stack-stopwatch
```

成功后生成：

```text
dist/m5stack-stopwatch/
  bootloader.bin
  firmware.bin
  partition-table.bin
  merged-flash.bin
  flash.json
  VERSION
```

`merged-flash.bin` 已包含正确偏移，可以直接从 `0x0` 写入。`flash.json` 同时保留芯片、Flash 大小、各镜像偏移和版本信息。

## 启动云端网关

### 本地临时开发

首次安装：

```bash
./scripts/setup-server.sh
```

启动（默认仅监听 loopback）：

```bash
./scripts/run-server.sh
```

另一个终端检查：

```bash
curl http://127.0.0.1:8000/health
curl http://127.0.0.1:8000/devices
```

### Lighthouse production deployment

生产环境使用仓库中的 `deploy/systemd/device-gateway.service` 作为用户级
systemd 服务，通过 Cloudflare Tunnel 暴露；不要直接监听公网地址。Lighthouse
上的 `127.0.0.1:8000` 已由 FDE Workbench API 使用，`127.0.0.1:18080`
也已有服务，因此 production Gateway 固定使用：

```text
device-gateway.service
http://127.0.0.1:18081
https://gw.pangtiao.men
wss://gw.pangtiao.men/device/ws
```

端口、采样周期和状态文件由仓库外的 `~/.config/esp32-device-gateway/gateway.env` 配置。安装或更新服务：

```bash
install -m 600 deploy/systemd/gateway.env.example ~/.config/esp32-device-gateway/gateway.env
install -m 644 deploy/systemd/device-gateway.service ~/.config/systemd/user/device-gateway.service
systemctl --user daemon-reload
systemctl --user enable --now device-gateway.service
```

production 检查与日志：

```bash
systemctl --user status device-gateway.service
journalctl --user -u device-gateway.service -f
curl http://127.0.0.1:18081/health
curl https://gw.pangtiao.men/health
```

管理页为 <https://gw.pangtiao.men/admin>。它显示设备在线状态、固件版本、最新系统 snapshot、Pi sessions，并允许为设备选择 `Server Overview`、`PI · <session>` 或 `CODEX · <session>`。`/admin`、`/api/*`、`/server*`、`/devices*` 及 API 文档由 Cloudflare Access 保护，复用 `pi.pangtiao.men` 的身份提供方式和允许身份；Gateway 不再实现另一套管理用户名、密码或 session。在线状态仍在内存中；历史 session 从各 Agent 正式 SDK/API 发现，Gateway 重启后可重新查询。Project Registry 与设备 target 持久化在仓库外的小型 JSON 文件。

现有 `cloudflared-dsh.service` 的 `gw.pangtiao.men -> 127.0.0.1:18081` ingress 被复用，不需要第二套 Tunnel。Access 只覆盖人类管理路径：`/device/ws` 继续由 Gateway `device_token` 认证，`/integrations/pi/events` 继续由独立 `pi_token` 认证，`/health` 保持无需浏览器登录的基础探针。这三个机器路径不得依赖 Access Cookie 或交互式登录。

Access 边界回归应从未登录的客户端执行。`/admin`、`/api/status`、`/server`、`/devices`、`/docs`、`/redoc` 和 `/openapi.json` 应返回 Access 登录跳转，`/health` 应直接返回 `200`。向 `/integrations/pi/events` 发送结构合法但不带 token 的事件应由 Gateway 返回 `401`，而不是 Access 登录页；真实设备 WSS smoke 应继续依次收到 `hello_ack`、`server_snapshot` 和 `target_state`。

运行全部网关测试：

```bash
./scripts/test-server.sh
```

这会先运行 Pi Extension 批处理/脱敏测试和 Gateway 的 collector、协议、认证、Pi lifecycle/activity 单元测试，再在随机空闲端口启动真实 Uvicorn，验证 Admin HTTP、设备认证、双向 WebSocket、server snapshot 和模拟 Pi target 状态。

## Unified Approval Control 0.4.0

Pi 的 mutation `tool_call` 和 Codex 的 command/file-change approval 共用内存 Registry。
设备不论当前页面/target 都弹出临时审批窗口：松开 A 批准、松开 B 拒绝，完成后恢复原页面。
无审批时 A/B 继续翻页。Admin 的 **Pending Approvals** 提供同一 Registry 的备用决定入口。
所有已认证在线设备按 FIFO 展示最早的一条，第一个合法决定生效；没有人工决定就一直等待：**NO DECISION = KEEP WAITING**。没有任何默认 timer 自动 approve、reject 或 expire；运行时/会话/连接失效只会 CANCELLED。

Pi Extension 的新审批通道使用同用户 `0700` runtime 目录中的 `0600 approvals.sock`，
不轮询 HTTP；Gateway 不可用时 mutation 被阻止。已有交互式 Pi 需要 `/reload` 加载更新后的 extension。
这只是人工工作流 gate，Pi sandbox 和 OS 权限仍独立。只读工具不弹窗，未知工具及复杂 shell 默认要求审批。

```bash
./scripts/test-server.sh
./scripts/build.sh m5stack-stopwatch
# 显式真实模型 smoke：独立临时项目、session 和 Gateway，不动生产服务
server/.venv/bin/python scripts/smoke-approvals.py
```

协议、连接边界与部署说明见 [Approval control](docs/approval-design.md)；真实 smoke 与构建证据见 [0.3.0 验收记录](docs/approval-0.3.0-validation.md)。

## Agent Control Layer 0.2.2

Admin 统一展示 Project → Pi / Codex → 历史与 live sessions。选中 dormant session 后输入文本，点击 `Resume + Send`；HTTP 202 只确认恢复及提交，后续状态/文本继续刷新。聚焦或提交中的设备 target 下拉框不会被轮询重建。

在 Admin 显式添加项目；也可通过受 Access 保护的 `POST /api/projects` 添加 `{id, name, path}`。路径必须是存在的绝对目录，ID 保持稳定，不自动扫描 home。默认配置文件为 `~/.config/esp32-device-gateway/projects.json`，仓库只提供 [example](config/projects.example.json)。删除项目仅取消注册。

Pi SDK host 要求本机 Pi **0.85.1** 与 Node 22。user systemd 的 PATH 可能不包含 Node 安装目录；在仓库外 `gateway.env` 配置 `GATEWAY_NODE_BIN` 和 `PI_SDK_MODULE` 的真实绝对路径。正常 shell 下可根据已安装的 `node` / `pi` 自动定位。Gateway 管理一个 Node SDK host，直接调用正式 SDK，没有启动 Pi TUI。

Codex **0.154.0** spike 已通过。本机复用现有 managed App Server 的 Unix control socket；在 `gateway.env` 设置 `GATEWAY_CODEX_ENABLED=1`，仅在非默认位置时设置 `GATEWAY_CODEX_SOCKET`。Gateway 不新建或重启共享 daemon，不将其暴露到公网。见 [spike 证据与限制](docs/codex-app-server-spike.md)。

统一 API、Pi SDK 调查及生命周期细节见 [Pi session control](docs/pi-session-control.md)。`/api/projects*`、`/api/agents*` 和 Admin 脚本仍在现有 Cloudflare Access `/api*` 边界内。运行中的 Codex turn 暂不接受第二条输入（409）；审批请求进入统一 Registry，并由设备或 Admin 决定。

显式运行真实 Pi 验收（调用模型，只创建专用临时 project/session）：

```bash
server/.venv/bin/python scripts/smoke-pi-control.py
```

流程包括 `SESSION_READY`、关闭 runtime、静态 DORMANT、同 ID 恢复并观察 `SESSION_RESUMED`、live input，以及 Gateway 重启后重新发现。结果见 [0.2.2 验收记录](docs/control-0.2.2-validation.md)。当前审批语义见 [Approval control](docs/approval-design.md)；Voice/Settings 已实现，见 [说明](docs/voice-input-design.md)。

## Voice Input + Session Settings 0.4.0

选择 Pi 或 Codex target 后，长按 A（600 ms）开始说话，松开结束，最多 20 秒。
短按 A/B 仍翻页。录音为 44.1 kHz / 16-bit / mono PCM，每 40 ms 一块，
通过已有认证 WSS 发送，Gateway 在内存中封装 WAV。转录后必须 A SEND / B CANCEL，
只有 SEND 才继续原 session。Approval 会取消录音或未发送的 transcript，并优先显示。

长按 B 打开设置：A NEXT、B SELECT/APPLY，长按 B 退出。Model 来自正式 Adapter；
Codex effort 来自所选模型 metadata，Pi 隐藏 effort。Device 最多六个模型，Admin
显示完整列表。RUNNING/WAITING_APPROVAL 时不允许修改，返回 409。

STT 通过 repo 外 `0600 ~/.config/esp32-device-gateway/gateway.env` 配置。
首选 `GATEWAY_STT_PROVIDER=cloudflare-workers-ai`，搭配 `GATEWAY_CF_ACCOUNT_ID`、
`GATEWAY_CF_API_TOKEN`、`GATEWAY_STT_MODEL=@cf/openai/whisper-large-v3-turbo`。
缺少 Cloudflare 授权时可显式选择 `openai-compatible`，使用
`GATEWAY_STT_BASE_URL=https://api.groq.com/openai/v1`、`GROQ_API_KEY` 和
`GATEWAY_STT_MODEL=whisper-large-v3-turbo`；已有通用 `GATEWAY_STT_API_KEY` 配置仍兼容。
**音频会发送给配置的 STT provider。** Gateway 默认不保存录音或 transcript，
也不记录正文日志。每次仅请求一个 provider，无自动 fallback、无重试。
直接发送现有 44.1 kHz WAV，不指定 language，默认不带 prompt。
授权失败、限流、超时和无有效 transcript 均显示 Transcription failed，不会发送 Agent input。

```bash
server/.venv/bin/python scripts/smoke-settings.py
server/.venv/bin/python scripts/smoke-stt.py  # 未配置时 SKIPPED，exit 2
```

真实 smoke 的公开音频、关键词和 prompt A/B 用法见 [0.4.1 验收记录](docs/stt-0.4.1-validation.md)。

## 可选 Pi Extension

本机 Pi `0.85.1` 使用正式 Extension API。扩展位于 `integrations/pi/device-lab-pager.ts`，除 lifecycle 外还订阅 `message_update` 的 `text_delta` 和 `tool_execution_start/end`。文本以 350 ms 或 120 字符为阈值批量发送；工具只发送脱敏后的短 action/path/command。所有请求均为 750 ms timeout 的 best effort，不读取 TUI/ANSI/session JSONL，也不阻塞 Pi 运行。

安装为全局扩展：

```bash
ln -s "$(pwd)/integrations/pi/device-lab-pager.ts" \
  ~/.pi/agent/extensions/device-lab-pager.ts
```

新 Pi 会话会自动加载；已打开的会话执行 `/reload`。状态统一为 `IDLE / RUNNING / DONE / FAILED / OFFLINE`。Gateway 为每个 session 在内存保留 12 KiB recent tail（不写磁盘），设备切换 target 时立即取得最近 4 KiB；StopWatch 在 PSRAM 继续保留 6 KiB 自动 tail。审批等待时覆盖显示 `WAITING_APPROVAL`；官方 `tool_call` hook 会等待设备或 Admin 的明确决定。

## 把固件下载到 Mac

在 Intel Mac 的仓库任意目录中，一键拉取最新代码、归档本地固件、下载 Lighthouse 构建、校验、检测设备、刷写并进入串口监视器：

```bash
./tools/mac/update-flash.sh
```

脚本只编排现有的 `device.sh` 能力。刷写前会显示本地旧版本和下载的新版本并要求确认；任一关键步骤失败都会立即停止。默认从 `lighthouse@claw.pangtiao.xyz:/home/lighthouse/esp32-dev/dist/m5stack-stopwatch` 下载，可通过 `REMOTE_HOST`、`REMOTE_REPO` 和 `BOARD` 环境变量覆盖。当前本地 artifact 会归档到 `dist/archive/`。

首次使用时脚本会在 Mac 工具缺失或损坏时自动运行 `tools/mac/install.sh`；工具正常时不会重复安装。

### 手动下载

默认使用 `scp`，不需要把二进制提交 Git。在 Mac 的仓库根目录执行：

```bash
mkdir -p dist
scp -r <vps-user>@<vps-host>:/absolute/path/esp32-dev/dist/m5stack-stopwatch dist/
```

如果 Mac 上还没有源码，先 `git clone <your-repo-url> esp32-dev && cd esp32-dev`，再执行上面的命令。

## Mac 首次安装

Mac 只需要 Python 3.8+，不需要完整 ESP-IDF，也不依赖 Homebrew：

```bash
./tools/mac/install.sh
```

脚本在 `tools/mac/.venv/` 安装固定的 `esptool 4.8.1` 和 `pyserial 3.5`。同时支持 Intel Mac；若旧系统没有 Python 3，请先安装 python.org 提供的 Intel 安装包。

## 第一次刷写前备份原厂固件

用支持数据传输的 USB 线连接设备，然后先执行：

```bash
./tools/mac/device.sh detect
./tools/mac/device.sh backup m5stack-stopwatch
```

只有一个 `/dev/cu.usbmodem*` 或 `/dev/cu.usbserial*` 时会自动选择；多个端口时会要求选择。也可显式指定：

```bash
ESPPORT=/dev/cu.usbmodem1101 ./tools/mac/device.sh backup m5stack-stopwatch
```

备份会读取设备报告的完整 Flash 大小，并在 Git 忽略的 `backups/` 中保存 `.bin` 和包含 MAC、Flash 大小、串口及 UTC 时间的 `.json`。

如果 esptool 无法自动连接 M5Stack StopWatch，保持 USB 连接，长按电源键约 2 秒，看到绿色指示灯后松开，使其进入下载模式，再重试。

## 刷写与串口日志

```bash
./tools/mac/device.sh flash dist/m5stack-stopwatch
```

脚本会检测 ESP32-S3、再次提醒备份、要求确认、将合并镜像写到 `0x0` 并复位，然后询问是否打开串口监视器。之后也可单独执行：

```bash
./tools/mac/device.sh monitor
```

串口监视器波特率为 `115200`，按 `Ctrl-]` 退出。脚本不会自动擦除整片 Flash；只有明确执行 `./tools/mac/device.sh erase` 才会进入带警告和确认的擦除流程。

## 恢复原厂镜像

```bash
./tools/mac/device.sh restore backups/m5stack-stopwatch-<UTC-date>.bin
```

恢复会再次检测芯片并要求确认，然后从 `0x0` 写回完整备份。

## 端到端验证

设备成功连接到 production Gateway 后，在已通过 Cloudflare Access 登录的浏览器中打开管理页查看 `device_id`：

<https://gw.pangtiao.men/admin>

管理 API 同样受 Access 保护。需要从命令行调用时，应先使用现有 Cloudflare Access 登录态，再向对应设备发消息：

```bash
curl -X POST https://gw.pangtiao.men/devices/<device-id>/message \
  -H 'content-type: application/json' \
  -d '{"title":"Hello","body":"Message from cloud"}'
```

通知实体自测可直接在 Lighthouse 本机执行，不需要真正启动 Pi run：

```bash
curl -X POST http://127.0.0.1:18081/devices/<device-id>/test-notification \
  -H 'content-type: application/json' -d '{"kind":"done"}'
curl -X POST http://127.0.0.1:18081/devices/<device-id>/test-notification \
  -H 'content-type: application/json' -d '{"kind":"failed"}'
```

公网同一路径属于现有 Cloudflare Access 的 `/devices*` 管理保护范围，匿名请求不能触发设备。设备必须在线，否则接口返回 `409`。

默认启动 Watch；A/B 以相反方向即时遍历 Watch、Agent、Monitor（仅 Overview）和 Help。Gateway 断线显示 `RECONNECTING`，保留 Agent target/state/final_text 并自动恢复。

从管理页选择 Pi session 后，设备更新当前 Agent，保留正在查看的页面；Agent 页以正文为主，终态优先 `final_text`，运行态保留最新 activity、工具活动与 recent tail。连续 activity 最多约 4 FPS 重绘。真实 `RUNNING -> DONE` 转换触发 90% PWM 的 250/180 ms 长短双震及 880/1200 Hz 上扬提示音；`RUNNING -> FAILED` 触发三个 240 ms 强震及 600/400 Hz 下行提示音。同一 run 只提醒一次；Vibration OFF 关闭所有震动但不改变视觉状态或审批。声音由 I2S0 + ES8311 + AW8737A 非阻塞播放，音频初始化失败不影响显示、Wi-Fi、WSS 或 Pager。

检查清单：

- [ ] `./scripts/build.sh m5stack-stopwatch` 成功
- [ ] Mac 检测到 ESP32-S3 和 16 MB Flash
- [ ] 原厂完整 Flash 已备份
- [ ] `merged-flash.bin` 刷写成功
- [ ] 115200 串口启动日志可见
- [ ] 屏幕显示 LIGHTHOUSE、CPU/MEM/DISK/NET 和 ONLINE
- [ ] 日志出现 `[WIFI] connected <ip>`
- [ ] 日志出现 `[CLOUD] connected` 和 `hello acknowledged`
- [ ] `/devices` 中设备为 `online: true`
- [ ] 云端消息显示在设备上并触发轻震
- [ ] 按 A/B 后网关收到 `button_event`
- [ ] 管理页可选择 Pi session，设备显示 RUNNING/DONE/FAILED
- [ ] Pi 页面实时显示 assistant text 和脱敏后的 tool activity
- [ ] DONE/FAILED 自测产生对应声音和强震，且同一 run 不重复提醒
- [ ] 串口出现 `[AUDIO] init OK`（或明确的 nonfatal 失败日志）

首次启动的关键串口输出应类似：

```text
[BOOT] firmware 0.5.0
[BOARD] M5Stack StopWatch
[WIFI] connecting...
[WIFI] connected 192.168.x.x
[CLOUD] connecting to wss://gw.pangtiao.men/device/ws
[CLOUD] connected
[CLOUD] sent hello device_id=esp32s3-...
[CLOUD] hello acknowledged
[CLOUD] target server
```

设备 token 轮换：

```bash
./scripts/provision-device.py --rotate --device-id esp32s3-28848543a200
./scripts/build.sh m5stack-stopwatch
systemctl --user restart device-gateway.service
```

轮换后必须刷入新构建的固件。协议详情见 [`docs/protocol.md`](docs/protocol.md)。

## 工程结构

```text
boards/                 板级显示、按键和振动边界
config/                 不含秘密的配置样例
docs/                   官方硬件/BSP 调研记录
firmware/               ESP-IDF 应用
integrations/pi/         可选 Pi lifecycle Extension
scripts/                云端安装、构建、打包、网关运行和测试
server/                 FastAPI 内存网关及测试
tools/mac/              备份、刷写、恢复和串口监视器
dist/                   可复现构建产物（Git 忽略）
backups/                设备完整 Flash 备份（Git 忽略）
```

板卡与官方资源的具体选择见 [`docs/boards.md`](docs/boards.md)。
