From ef91daf07d2c371ac08c44598175c3c38215cef2 Mon Sep 17 00:00:00 2001 From: zhou yang Date: Mon, 28 Sep 2026 08:59:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E4=BB=B6=200.1.3-c5=EF=BC=9A=E7=A1=AC?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E5=BC=80=E6=9C=BA=E6=A0=A1=E9=AA=8C=20+=20?= =?UTF-8?q?=E9=87=8D=E6=8C=89=EF=BC=880.1.2=EF=BC=89=E3=80=81status=20?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E4=B8=8A=E6=B8=B8=E7=94=B5=E8=84=91=E5=9C=A8?= =?UTF-8?q?=E4=B8=8D=E5=9C=A8=EF=BC=88host=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0.1.2:power_cycle 按完电源键后看电流,没开就重按,最多 3 次;新事件 19 BOOT_CHECK - 0.1.3:UP_VBUS_DET(IO6)启动时配成输入,status 加 "host":true|false|null - rack:单板页显示「上游:已连电脑 / 未连电脑」 - HANDOFF:损耗两档的来历(Hub 工作 ≈1.1 W)、板 B 第 2 槽 PRSNT2# 卡低 Co-Authored-By: Claude Opus 5.5 --- firmware/HANDOFF.md | 267 ++++++++-------- firmware/main/app_main.c | 25 ++ firmware/main/bringup.c | 6 + firmware/main/cmd_report.c | 10 + firmware/main/evlog.c | 1 + firmware/main/evlog.h | 7 + firmware/main/phonerail.h | 2 +- firmware/main/t_actuator.c | 128 +++++++- firmware/main/t_actuator.h | 488 ++++++++++++++++-------------- firmware/test/test_cmd.c | 8 + firmware/test/test_evlog.c | 3 +- firmware/test/test_t_actuator.c | 252 +++++++++++++++ rack/frontend/src/pages/Board.vue | 3 + 13 files changed, 834 insertions(+), 366 deletions(-) diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index cab6044..0509f1d 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -1,123 +1,144 @@ -# 交接 -更新:2026-09-28 固件 **0.1.1-c5**(硬重启末尾自动开机 + MQTT 发布死锁 + 欠压放宽到 −250 mV);板 B 经 USB 整片烧上,WiFi / MQTT 已通 - -## c4:两处修复(2026-09-28,第一片刀片 + 小米8 上板时发现) -- **硬重启后手机不开机**:电池端上电的小米8 停在关机态(只漏 ~2 mA)。现在 `power_cycle` 等到 PG 后 - **长按电源键 5 s**(新状态 `hr-boot`,`ACT_HR_BOOT_KEY_MS`);NO_PG 时不按。`ACT_KEY_MS_ON` 2000 → **5000**: - 实测按 2 s 时 PMIC 起来了(415 mA),一松手就掉回 1 mA。需求 SW-5 已同步。rack 的二级恢复(power_cycle)因此真的能拉起手机了。 - 上板:power_cycle → 4.1 s 上电 → hr-boot 5 s → res ok,手机进入开机电流曲线。 -- **热点一断整板失联(死锁)**:执行器「持 `s_mtx` → `esp_mqtt_client_enqueue` 等 `api_lock`」, - esp-mqtt 任务「持 `api_lock` 分发 DISCONNECTED → `on_mqtt` 的 `ST_EDIT` 等 `s_mtx`」。 - 现象:USB 命令无应答、WiFi 不重连、遥测停,只有 t_safety / t_thermal 的日志还在 —— **按到一半的键也会松不开**。 - 修:执行器只往 `s_outbox`(深度 4)拷一份,`t_link` 里 `outbox_drain()` 交给 esp-mqtt;持 `s_mtx` 时不调 esp-mqtt。 - ⚠️ 死锁状态下 OTA 进不去,只能 USB 烧(esptool `--before usb_reset`,第一次常报 ClearCommError,再跑一次就过)。 -- **c5:带载欠压误关**。小米8 插屏幕后开机峰值 ~2.1 A,轨 1 读回 3785 mV(设定 3900,−115 mV),SR-5 的 −100 mV 直接关路。 - 用户拍板:**欠压一侧放宽到 −250 mV**(`T_SAFETY_UNDER_MV`),过压仍 +100 mV;需求 SR-5 / SW-2 / 验收表同步。 - 压降 ≈ 55 mΩ × 2 A 的来源(Buck 负载调整 / eFuse / 采样电阻 / 走线)还没拆开查。 - -## 一句话现状 -OTA 换固件 / 超时回滚现在走 `warm_restart()`(`main/warm_esp.c`):目标固件认接管 ⇒ 手机不断电; -崩溃 / 看门狗 / 上电 / 兜底回滚仍全关(SR-6)。**26 个 ctest、448 个用例全绿**。板 B 跑 **0.1.1-c3**(经 OTA 推上去的)。 -定稿写在需求 §10.9「重启不断电」。C2 OTA 的说明在下面。 - -## 0.1.1:I²C 不应答被报成 STATE(真板独有,sim 测不出) -- 根因:IDF 5.4 的 `i2c_master_transmit/receive/transmit_receive` 在**从机 NACK / SCL 超时**时返回 `ESP_ERR_INVALID_STATE`, - HAL 照翻成 `HAL_ERR_STATE`(= 没 init)。于是:I2C_LOST 事件的错误码是 5;命令层 `from_hal` 又把 STATE 当成「执行器忙」⇒ - PCA9555 不应答时 `power off` 回 `busy`(eFuse 其实已经关了、意图也清了),上位机会以为没执行。 -- 修:`hal_i2c_esp.c` 传输函数用 `map_xfer_err`(INVALID_STATE → NAK);`cmd_exec.c` 新增 `from_bus`, - I²C / GPIO 路径(set_voltage / vbus / power)失败一律 `io`,`from_hal` 只留给执行器。 -- 测试:sim 加 `sim_i2c_inject_err_ch()`(指定注入的错误码);`test_cmd` 加一段 STATE 注入 ⇒ 必须是 io 不是 busy - (把关电那行改回 from_hal 时这段确实红,回应就是台架上看到的 `{"err":"busy"}`)。 -- 上板(台架镜像,CH1 强制在位):`power off` 回 `io`、CH1 关掉;I2C_LOST 的错误码 5 → **2**。 - -## 重启不断电(c3) -- 三道闸:RTC 标记(软件复位 + 魔数 / 反码,一次性)· 目标镜像能力标记(第 288 字节 `PRCAPS1`,老固件不认就不锁)· 起来后读 EN 真电平核对。 -- `rail_en_init_keep(adopt)` **五根一律放开 hold**:崩溃时上一条命的锁可能还在。 -- `pca9555_init_keep()`:方向寄存器是我们写的才留 VBUS;键 / EDL 清零。 -- 实测:台架镜像(空槽强制 CH1 在位,**没提交**)开 CH1 → OTA 带重启 → 事件 `18/7 mv=1`(新固件读到 IO9 仍为高、接管)。 - ⚠️ 没测到的:示波器上看 EN 在重启期间有没有毛刺(`warm_take` 读到高只能说明「起来时还是高」); - 「锁着 + 非计划复位」这条路径只有宿主机测试(真板上造不出来)。插上刀片 + 手机后要看一次手机有没有掉电重启。 -- **第一次从 c2 升到 c3 仍会断电一次**:锁 EN 的是**正在跑**的固件,c2 不会锁。 - -## 下一步(没人定,候选) -- 示波器验证:插一片刀片,CH 开着,OTA 带重启,抓 EN / 输出电压(`scope_*` 工具)。 -- 在手机上看一眼 rack 新界面(390 px 左右):无头 Chrome 最窄只到 ~500 px,窄屏只验到那里。 -- 下面「其它挂着的事」里 A15 / A16 留下的几条(I²C 总线挂死恢复、PD 降载、风扇阈值实测)照旧。 - -## rack 侧(服务器前后端,详见 `rack/README.md`) -- **固件页**(顶栏「固件」):上传 `build/phonerail.bin` → 选板推送;上传时核镜像头(非本工程 / 非 app 镜像 / >3 MB 直接拒), - 显示每个镜像「重启不断电:支持 / 不支持」,勾「立刻重启」时按它给不同提示。通用 `/cmd` 不放行 `ota`(URL / 哈希只能由固件库生成)。 -- **下载目录自动清理**(每小时):库里登记的不删;`ota_push.py` 直传的放满 7 天删;半截 `.tmp` 1 小时删。 -- **界面改版**:仪器前面板风格 + 暗色模式(跟随系统);总览每个槽带最近两分钟电流小曲线(平线 = 疑似卡死)。 -- **底板温度曲线**(单板页):两路 NTC + 风扇占空比,10 分钟 ~ 7 天;后端 `board_tele_min` 分钟聚合留 90 天, - 接口 `GET /api/boards/{id}/history`。NTC1 贴 Hub 稳压 U31,NTC2 贴输入降压 U37。 - -## 踩到的坑(c3) -- `.rodata_custom_desc` 没人引用会被 `--gc-sections` 丢掉 ⇒ `main/CMakeLists.txt` 加 `-u g_phonerail_caps`。 -- `PROJECT_VER` 在 CMake 配置时读 ⇒ 改 `phonerail.h` 不会重配,镜像头版本会是旧的 ⇒ 顶层加了 `CMAKE_CONFIGURE_DEPENDS`。 -- 撤掉台架补丁后要**重新编译**再推:`build/phonerail.bin` 还是台架版(这次推错了一次,已换回)。 -- Git Bash heredoc 里的 Python 写 `\0` 又变成真 NUL 了(这次在 rack/firmware.py)—— 含反斜杠的一律写 .py 文件。 - -## 怎么推固件 -``` -python tools/ota_push.py b81f3fc9c8bc # 推 build/phonerail.bin:只下载 + 校验 + 切分区,下次复位生效 -python tools/ota_push.py b81f3fc9c8bc --reboot # 校验过就立刻重启(板上 ≥c3 且目标镜像认接管 ⇒ 手机不断电,否则五路全断一次) -python tools/ota_push.py b81f3fc9c8bc --status -``` -它把镜像传到服务器 `/root/phonerail/fw/.bin`(nginx 7765,只放行这种文件名,不过门户; -**不进固件库,7 天后 rack 后端自动删** —— 要长期留着就从界面上传), -再在服务器上用后端账号经 1884 发命令、盯遥测。 -**rack 管理界面「固件」页也能推**(2026-09-27 加,上传 → 选板 → 可选立刻重启并二次确认;见 `rack/README.md`「推固件」)。 - - -## C2 改了什么 -- `partitions.csv` 重排(16 MB):**nvs 0x9000 没动**;ota_0 / ota_1 各 3 MB;otadata 0x610000;evlog 挪到 0x612000(历史事件清空了一次)。 -- `sdkconfig.defaults` + `sdkconfig`:flash 16 MB(之前误写 8 MB);`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`(bootloader 配置,只有 USB 整片烧才生效)。 -- `main/ota.{h,c}` **新**(宿主机可测):URL / 哈希校验、判定(busy / 待验证拒 / 同镜像不重下)、健康判据。 -- `main/ota_esp.{h,c}` **新**:`t_ota` 任务(esp_http_client + mbedtls sha256 + `OTA_WITH_SEQUENTIAL_WRITES`,每块让一个 tick)、 - 核 project_name、切分区;`t_link` 里判健康 / 到点重启(先等 2 s 让回应发出去、事件落盘);esp_timer 150 s 兜底回滚。 -- `cmd` 新命令 `ota`=13(`cmd_net.c` 的 `do_ota`);status 多 `ota` 一块;`evlog` 新事件 `LOG_EV_OTA`=18。 -- 顶层 `CMakeLists.txt`:`PROJECT_VER` 取 `PHONERAIL_FW_VERSION`(否则镜像头版本是 git describe,回滚后 `ota.bad` 报 `79c9f8b-dirty`)。 -- `test/test_ota.c` **新**(13 例);`test_cmd` / `test_evlog` 的冻结数字表各加一行。 -- `tools/ota_push.py` **新**;`rack/deploy/nginx-phonerail-fw.conf` **新**(已部署);`rack/backend/prrack/protocol.py` 加事件 18 / 命令 13(已部署)。 - -## 上板实测(板 B,五槽空) -``` -正常:c2 → c2-t1 经 MQTT --reboot:下载 ≈8 s,推送到自证健康 19 s;事件 18/1 → 18/2 → 18/6 → BOOT → 18/4 -哈希错:err "hash"、got 996064、固件不变;下载 20 s 里 t_safety skipped 0 -回滚:服务器删掉板子 broker 账号 → USB 发同镜像 reboot:true → 起在 c2-rb(pend true)→ 120 075 ms 记 18/5 → 回到 c2-t1、bad 报版本 - (账号已原样恢复,备份已删) -收尾:c2-t1 → 正式 0.1.0-c2 经 MQTT --reboot 成功 -``` - -## 踩到的坑(只写会再犯的) -- **路由器不是整机 DMZ**:新开的 7765 公网 TCP 都连不上,用户手动加了转发才通(本机测公网要 `curl --noproxy '*'`, - 否则 7890 代理回的 502 会被当成服务器的)。 -- 本机 PowerShell `Set-Content -Encoding ascii` 改 `phonerail.h` 把中文注释写成 `??`(已 `git checkout` 还原)—— 改它一律用 sed。 -- Git Bash heredoc 里的 Python 写 `'\0'` 会变成真 NUL 字节进源码;含反斜杠的替换写成 .py 文件再跑。 -- 宿主机 `bash host/test.sh` 用 `-j 4` 在 cygwin 上偶发「link: Subprocess aborted」,`cmake --build build -j 1` 就过。 - -## 其它挂着的事 -- **风扇还没下单购买**(2026-09-27):板 B 两路报 `rpm 0` + 停转是预期的。到货插上后看 `status.fan.rpm`;在转却仍是 0 再查 Q9/Q10 与 PCNT。 -- 风扇闭环四个阈值 + 占空比下限 + PPR 都是「待上板实测」(t_thermal.h),手机上架跑满载之后再定。 -- A15 留下的:I²C 总线挂死恢复(HAL 层);PD 上限的降载动作(§8.3);CH224A 读到 100 W 与早上记的 20V/3A 不一致,待核。 - -## 本次新增/修改的文件 -- `main/ntc.{h,c}` **新**(宿主机可测)— mV → Ω → 查 Murata NCP18XH103F03RB 的 R-T 表(−40~125 °C、5 °C 一格、格内线性插值); - 贴 +3V3 / 贴地 / 表外一律「读不到」(⇒ t_thermal 满速) -- `main/thermal_esp.{h,c}` **新**(只进 IDF)— ADC1 oneshot + 曲线拟合校准(8 次平均,引脚反查通道);两个 PCNT 单元数 TACH 上升沿(累计计数、1 µs 毛刺滤波) -- `main/app_main.c` — `task_thermal` 任务体(每 10 s 打一行温度 / mV / 占空比 / 转速);`thermal_esp_init()` + `t_thermal_init()` 放在**上电恢复之前**;`cmd_env.thermal = &s_therm` -- `main/CMakeLists.txt` REQUIRES 加 `esp_adc esp_driver_pcnt`;`host/CMakeLists.txt` 加 `ntc.c` -- `test/test_ntc.c` **新**(4 例:表点、⭐方向「越热电压越低」、⭐开路/短路不是温度、比例式);版本 `0.1.0-a16` - -## 上板实测(板 B,五槽空,风扇状态未知) -``` -status: "temp":[38249,34027] 分压端 1266 / 1384 mV ← 两路 NTC 读数合理(NTC1 在电感区更热) - "fan":{"duty":300,"en":true,"rpm":[0,0],"stall":[true,true]} -events: [31, 14318, 0, 11(FAN_STALL), 0] [32, …, 11, 1] ← 启动宽限 5 s + 一个窗口之后各报一次,不刷屏 -``` - -## 踩到的坑(只写会再犯的) -- 比例式 NTC 的电源误差估算:+3V3 偏 1 % 在 25 °C 附近是 **~0.6 °C**(中点处电阻误差是电源误差的 2 倍、NTC 每度 ~4 %),不是 0.3 °C。 -- PowerShell 5.1 的 `Set-Content -Encoding utf8` 会写 BOM —— 改 `phonerail.h` 这种文件用 sed,别用它。 -- `python` 在 Git Bash 里是 Windows Store 占位程序,会挂住;一律用 `/c/Python314/python`。 +# 交接 +更新:2026-09-28 固件 **0.1.3-c5**(status 加 `host`);板 B 经 OTA 推上(热重启,第 1 路没断电) + +## 0.1.3:上游电脑在不在进遥测(2026-09-28) +- `UP_VBUS_DET`(IO6,R721/R722 分压,R722 下拉)启动时配成输入;status 新增 `"host":true|false|null`(读不到 null)。 + 只进遥测、不参与判据。rack 单板页显示「上游:已连电脑 / 未连电脑」。 +- 起因:面板「损耗」在 ~0.5 W / ~1.6 W 两档间跳,与刀片、手机无关;高档时 NTC1(U31 旁)高 6~10 °C ⇒ USB2517I 工作 + (+ AMS1117 线性降压)约 1.1 W。⚠️ `host` 是 VBUS 在不在,不是 Hub 是否挂起:电脑休眠时多半仍是 true。 +- ⚠️ 热重启后 DAC 设定点回默认(实测 set 4010 → 3900 mV,输出 4.04 → 3.93 V)—— 供电保住了,设定没保住。 +- **板 B 第 2 槽 `PRSNT2#` 卡低**(09-28 02:09 第一次插刀片起,拔走后仍报在位,跨多次重启):硬件问题,待量 PRSNT2#(R607 / U20.9)对地、 + 看插座 B31 弹片。副作用:每圈 `i2c_err` +2;02:13~06:25 空槽 eFuse 开着约 4 h(见 0.1.4 的判据)。 + +## 0.1.2:硬重启按完电源键之后看电流,没开就重按(2026-09-28) +- **起因**:板 B 第 1 路 7 次 power_cycle 只开起来 1 次,失败时电流 1~2 mA、屏不亮;PCA9555 按键输出确实置位(命令都 ok), + **镊子短接 J603 能开** ⇒ R2 电源键是主板弹片压刀片铜盘(决策 14),接触时好时坏。这是机械问题,固件只做兜底。 +- **做法**(`t_actuator`,新状态 `hr-verify`):按满 5 s → 松键 → **松键后 3~5 s** 看电流,连续 5 个样本(0.5 s)≥ 100 mA 算开了; + 否则重按,**一共最多 3 次**;3 次都不行结果 `no-boot`,**电继续开着**。读不到电流不重按(对正在开机的手机再长按会进关机菜单)。 + 为什么 3 s 后才看:「按 2 s」那种 PMIC 醒了、SoC 没接住的情况,松手后 415 mA 撑约 3 s 才掉(宿主机测试把判定延迟改回 1 s 会被它骗)。 +- 电流与 PG 一样从巡检快照喂进来(`t_actuator_set_boot_check()`,app_main 接);**不接就是 0.1.1 的行为**,老测试一行没改。 +- 新事件 **19 `LOG_EV_BOOT_CHECK`**「开机重按」:arg 低 4 位 = 按了几次,bit7 = 最终没开起来;一次就开的不记。 + rack 已同步(名称「按了 N 次,开起来了 / 没开起来」,只有没开起来标红),已部署。 +- 手动 `key` 命令不校验、不重按(人的意图)。宿主机 26 ctest 全绿(`test_t_actuator` 30 → 37 例),rack 43 例全绿。 +- ⚠️ 实机只验到「第一下就开」(接触已恢复),重按路径靠宿主机测试。频繁出现事件 19 = 那一台弹片要处理。 + +## c4:两处修复(2026-09-28,第一片刀片 + 小米8 上板时发现) +- **硬重启后手机不开机**:电池端上电的小米8 停在关机态(只漏 ~2 mA)。现在 `power_cycle` 等到 PG 后 + **长按电源键 5 s**(新状态 `hr-boot`,`ACT_HR_BOOT_KEY_MS`);NO_PG 时不按。`ACT_KEY_MS_ON` 2000 → **5000**: + 实测按 2 s 时 PMIC 起来了(415 mA),一松手就掉回 1 mA。需求 SW-5 已同步。rack 的二级恢复(power_cycle)因此真的能拉起手机了。 + 上板:power_cycle → 4.1 s 上电 → hr-boot 5 s → res ok,手机进入开机电流曲线。 +- **热点一断整板失联(死锁)**:执行器「持 `s_mtx` → `esp_mqtt_client_enqueue` 等 `api_lock`」, + esp-mqtt 任务「持 `api_lock` 分发 DISCONNECTED → `on_mqtt` 的 `ST_EDIT` 等 `s_mtx`」。 + 现象:USB 命令无应答、WiFi 不重连、遥测停,只有 t_safety / t_thermal 的日志还在 —— **按到一半的键也会松不开**。 + 修:执行器只往 `s_outbox`(深度 4)拷一份,`t_link` 里 `outbox_drain()` 交给 esp-mqtt;持 `s_mtx` 时不调 esp-mqtt。 + ⚠️ 死锁状态下 OTA 进不去,只能 USB 烧(esptool `--before usb_reset`,第一次常报 ClearCommError,再跑一次就过)。 +- **c5:带载欠压误关**。小米8 插屏幕后开机峰值 ~2.1 A,轨 1 读回 3785 mV(设定 3900,−115 mV),SR-5 的 −100 mV 直接关路。 + 用户拍板:**欠压一侧放宽到 −250 mV**(`T_SAFETY_UNDER_MV`),过压仍 +100 mV;需求 SR-5 / SW-2 / 验收表同步。 + 压降 ≈ 55 mΩ × 2 A 的来源(Buck 负载调整 / eFuse / 采样电阻 / 走线)还没拆开查。 + +## 一句话现状 +OTA 换固件 / 超时回滚现在走 `warm_restart()`(`main/warm_esp.c`):目标固件认接管 ⇒ 手机不断电; +崩溃 / 看门狗 / 上电 / 兜底回滚仍全关(SR-6)。**26 个 ctest、448 个用例全绿**。板 B 跑 **0.1.2-c5**(经 OTA 推上去的)。 +定稿写在需求 §10.9「重启不断电」。C2 OTA 的说明在下面。 + +## 0.1.1:I²C 不应答被报成 STATE(真板独有,sim 测不出) +- 根因:IDF 5.4 的 `i2c_master_transmit/receive/transmit_receive` 在**从机 NACK / SCL 超时**时返回 `ESP_ERR_INVALID_STATE`, + HAL 照翻成 `HAL_ERR_STATE`(= 没 init)。于是:I2C_LOST 事件的错误码是 5;命令层 `from_hal` 又把 STATE 当成「执行器忙」⇒ + PCA9555 不应答时 `power off` 回 `busy`(eFuse 其实已经关了、意图也清了),上位机会以为没执行。 +- 修:`hal_i2c_esp.c` 传输函数用 `map_xfer_err`(INVALID_STATE → NAK);`cmd_exec.c` 新增 `from_bus`, + I²C / GPIO 路径(set_voltage / vbus / power)失败一律 `io`,`from_hal` 只留给执行器。 +- 测试:sim 加 `sim_i2c_inject_err_ch()`(指定注入的错误码);`test_cmd` 加一段 STATE 注入 ⇒ 必须是 io 不是 busy + (把关电那行改回 from_hal 时这段确实红,回应就是台架上看到的 `{"err":"busy"}`)。 +- 上板(台架镜像,CH1 强制在位):`power off` 回 `io`、CH1 关掉;I2C_LOST 的错误码 5 → **2**。 + +## 重启不断电(c3) +- 三道闸:RTC 标记(软件复位 + 魔数 / 反码,一次性)· 目标镜像能力标记(第 288 字节 `PRCAPS1`,老固件不认就不锁)· 起来后读 EN 真电平核对。 +- `rail_en_init_keep(adopt)` **五根一律放开 hold**:崩溃时上一条命的锁可能还在。 +- `pca9555_init_keep()`:方向寄存器是我们写的才留 VBUS;键 / EDL 清零。 +- 实测:台架镜像(空槽强制 CH1 在位,**没提交**)开 CH1 → OTA 带重启 → 事件 `18/7 mv=1`(新固件读到 IO9 仍为高、接管)。 + ⚠️ 没测到的:示波器上看 EN 在重启期间有没有毛刺(`warm_take` 读到高只能说明「起来时还是高」); + 「锁着 + 非计划复位」这条路径只有宿主机测试(真板上造不出来)。插上刀片 + 手机后要看一次手机有没有掉电重启。 +- **第一次从 c2 升到 c3 仍会断电一次**:锁 EN 的是**正在跑**的固件,c2 不会锁。 + +## 下一步(没人定,候选) +- 示波器验证:插一片刀片,CH 开着,OTA 带重启,抓 EN / 输出电压(`scope_*` 工具)。 +- 在手机上看一眼 rack 新界面(390 px 左右):无头 Chrome 最窄只到 ~500 px,窄屏只验到那里。 +- 下面「其它挂着的事」里 A15 / A16 留下的几条(I²C 总线挂死恢复、PD 降载、风扇阈值实测)照旧。 + +## rack 侧(服务器前后端,详见 `rack/README.md`) +- **固件页**(顶栏「固件」):上传 `build/phonerail.bin` → 选板推送;上传时核镜像头(非本工程 / 非 app 镜像 / >3 MB 直接拒), + 显示每个镜像「重启不断电:支持 / 不支持」,勾「立刻重启」时按它给不同提示。通用 `/cmd` 不放行 `ota`(URL / 哈希只能由固件库生成)。 +- **下载目录自动清理**(每小时):库里登记的不删;`ota_push.py` 直传的放满 7 天删;半截 `.tmp` 1 小时删。 +- **界面改版**:仪器前面板风格 + 暗色模式(跟随系统);总览每个槽带最近两分钟电流小曲线(平线 = 疑似卡死)。 +- **底板温度曲线**(单板页):两路 NTC + 风扇占空比,10 分钟 ~ 7 天;后端 `board_tele_min` 分钟聚合留 90 天, + 接口 `GET /api/boards/{id}/history`。NTC1 贴 Hub 稳压 U31,NTC2 贴输入降压 U37。 + +## 踩到的坑(c3) +- `.rodata_custom_desc` 没人引用会被 `--gc-sections` 丢掉 ⇒ `main/CMakeLists.txt` 加 `-u g_phonerail_caps`。 +- `PROJECT_VER` 在 CMake 配置时读 ⇒ 改 `phonerail.h` 不会重配,镜像头版本会是旧的 ⇒ 顶层加了 `CMAKE_CONFIGURE_DEPENDS`。 +- 撤掉台架补丁后要**重新编译**再推:`build/phonerail.bin` 还是台架版(这次推错了一次,已换回)。 +- Git Bash heredoc 里的 Python 写 `\0` 又变成真 NUL 了(这次在 rack/firmware.py)—— 含反斜杠的一律写 .py 文件。 + +## 怎么推固件 +``` +python tools/ota_push.py b81f3fc9c8bc # 推 build/phonerail.bin:只下载 + 校验 + 切分区,下次复位生效 +python tools/ota_push.py b81f3fc9c8bc --reboot # 校验过就立刻重启(板上 ≥c3 且目标镜像认接管 ⇒ 手机不断电,否则五路全断一次) +python tools/ota_push.py b81f3fc9c8bc --status +``` +它把镜像传到服务器 `/root/phonerail/fw/.bin`(nginx 7765,只放行这种文件名,不过门户; +**不进固件库,7 天后 rack 后端自动删** —— 要长期留着就从界面上传), +再在服务器上用后端账号经 1884 发命令、盯遥测。 +**rack 管理界面「固件」页也能推**(2026-09-27 加,上传 → 选板 → 可选立刻重启并二次确认;见 `rack/README.md`「推固件」)。 + + +## C2 改了什么 +- `partitions.csv` 重排(16 MB):**nvs 0x9000 没动**;ota_0 / ota_1 各 3 MB;otadata 0x610000;evlog 挪到 0x612000(历史事件清空了一次)。 +- `sdkconfig.defaults` + `sdkconfig`:flash 16 MB(之前误写 8 MB);`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`(bootloader 配置,只有 USB 整片烧才生效)。 +- `main/ota.{h,c}` **新**(宿主机可测):URL / 哈希校验、判定(busy / 待验证拒 / 同镜像不重下)、健康判据。 +- `main/ota_esp.{h,c}` **新**:`t_ota` 任务(esp_http_client + mbedtls sha256 + `OTA_WITH_SEQUENTIAL_WRITES`,每块让一个 tick)、 + 核 project_name、切分区;`t_link` 里判健康 / 到点重启(先等 2 s 让回应发出去、事件落盘);esp_timer 150 s 兜底回滚。 +- `cmd` 新命令 `ota`=13(`cmd_net.c` 的 `do_ota`);status 多 `ota` 一块;`evlog` 新事件 `LOG_EV_OTA`=18。 +- 顶层 `CMakeLists.txt`:`PROJECT_VER` 取 `PHONERAIL_FW_VERSION`(否则镜像头版本是 git describe,回滚后 `ota.bad` 报 `79c9f8b-dirty`)。 +- `test/test_ota.c` **新**(13 例);`test_cmd` / `test_evlog` 的冻结数字表各加一行。 +- `tools/ota_push.py` **新**;`rack/deploy/nginx-phonerail-fw.conf` **新**(已部署);`rack/backend/prrack/protocol.py` 加事件 18 / 命令 13(已部署)。 + +## 上板实测(板 B,五槽空) +``` +正常:c2 → c2-t1 经 MQTT --reboot:下载 ≈8 s,推送到自证健康 19 s;事件 18/1 → 18/2 → 18/6 → BOOT → 18/4 +哈希错:err "hash"、got 996064、固件不变;下载 20 s 里 t_safety skipped 0 +回滚:服务器删掉板子 broker 账号 → USB 发同镜像 reboot:true → 起在 c2-rb(pend true)→ 120 075 ms 记 18/5 → 回到 c2-t1、bad 报版本 + (账号已原样恢复,备份已删) +收尾:c2-t1 → 正式 0.1.0-c2 经 MQTT --reboot 成功 +``` + +## 踩到的坑(只写会再犯的) +- **路由器不是整机 DMZ**:新开的 7765 公网 TCP 都连不上,用户手动加了转发才通(本机测公网要 `curl --noproxy '*'`, + 否则 7890 代理回的 502 会被当成服务器的)。 +- 本机 PowerShell `Set-Content -Encoding ascii` 改 `phonerail.h` 把中文注释写成 `??`(已 `git checkout` 还原)—— 改它一律用 sed。 +- Git Bash heredoc 里的 Python 写 `'\0'` 会变成真 NUL 字节进源码;含反斜杠的替换写成 .py 文件再跑。 +- 宿主机 `bash host/test.sh` 用 `-j 4` 在 cygwin 上偶发「link: Subprocess aborted」,`cmake --build build -j 1` 就过。 + +## 其它挂着的事 +- **风扇还没下单购买**(2026-09-27):板 B 两路报 `rpm 0` + 停转是预期的。到货插上后看 `status.fan.rpm`;在转却仍是 0 再查 Q9/Q10 与 PCNT。 +- 风扇闭环四个阈值 + 占空比下限 + PPR 都是「待上板实测」(t_thermal.h),手机上架跑满载之后再定。 +- A15 留下的:I²C 总线挂死恢复(HAL 层);PD 上限的降载动作(§8.3);CH224A 读到 100 W 与早上记的 20V/3A 不一致,待核。 + +## 本次新增/修改的文件 +- `main/ntc.{h,c}` **新**(宿主机可测)— mV → Ω → 查 Murata NCP18XH103F03RB 的 R-T 表(−40~125 °C、5 °C 一格、格内线性插值); + 贴 +3V3 / 贴地 / 表外一律「读不到」(⇒ t_thermal 满速) +- `main/thermal_esp.{h,c}` **新**(只进 IDF)— ADC1 oneshot + 曲线拟合校准(8 次平均,引脚反查通道);两个 PCNT 单元数 TACH 上升沿(累计计数、1 µs 毛刺滤波) +- `main/app_main.c` — `task_thermal` 任务体(每 10 s 打一行温度 / mV / 占空比 / 转速);`thermal_esp_init()` + `t_thermal_init()` 放在**上电恢复之前**;`cmd_env.thermal = &s_therm` +- `main/CMakeLists.txt` REQUIRES 加 `esp_adc esp_driver_pcnt`;`host/CMakeLists.txt` 加 `ntc.c` +- `test/test_ntc.c` **新**(4 例:表点、⭐方向「越热电压越低」、⭐开路/短路不是温度、比例式);版本 `0.1.0-a16` + +## 上板实测(板 B,五槽空,风扇状态未知) +``` +status: "temp":[38249,34027] 分压端 1266 / 1384 mV ← 两路 NTC 读数合理(NTC1 在电感区更热) + "fan":{"duty":300,"en":true,"rpm":[0,0],"stall":[true,true]} +events: [31, 14318, 0, 11(FAN_STALL), 0] [32, …, 11, 1] ← 启动宽限 5 s + 一个窗口之后各报一次,不刷屏 +``` + +## 踩到的坑(只写会再犯的) +- 比例式 NTC 的电源误差估算:+3V3 偏 1 % 在 25 °C 附近是 **~0.6 °C**(中点处电阻误差是电源误差的 2 倍、NTC 每度 ~4 %),不是 0.3 °C。 +- PowerShell 5.1 的 `Set-Content -Encoding utf8` 会写 BOM —— 改 `phonerail.h` 这种文件用 sed,别用它。 +- `python` 在 Git Bash 里是 Windows Store 占位程序,会挂住;一律用 `/c/Python314/python`。 diff --git a/firmware/main/app_main.c b/firmware/main/app_main.c index 4955d29..c8b776a 100644 --- a/firmware/main/app_main.c +++ b/firmware/main/app_main.c @@ -100,6 +100,30 @@ static bool act_pg_from_safety(void *ctx, int rail, bool *pg) return true; } +/* 喂给 t_actuator 开机校验的电流(0.1.2)。同上,读不到就是读不到。 */ +static bool act_ua_from_safety(void *ctx, int rail, int32_t *ua) +{ + (void)ctx; + if (rail < 1 || rail > PHONERAIL_RAIL_COUNT || !s_snap.rail_ok[rail - 1]) { + return false; + } + *ua = s_snap.rail[rail - 1].current_ua; + return true; +} + +/* 开机校验的结论 → 事件(evlog.h `LOG_EV_BOOT_CHECK`)。一次就开的不记。 */ +static void act_boot_result(void *ctx, int rail, bool booted, uint8_t presses, int32_t ua) +{ + (void)ctx; + if (booted && presses <= 1) { + return; + } + const uint8_t arg = (uint8_t)((presses & 0x0Fu) | (booted ? 0u : 0x80u)); + evlog_put((uint8_t)rail, LOG_EV_BOOT_CHECK, arg, s_snap.rail[rail - 1].bus_mv, ua); + ESP_LOGW(TAG, "CH%d 开机校验:按了 %u 次,%s(%ld uA)", rail, (unsigned)presses, + booted ? "开起来了" : "没开起来", (long)ua); +} + /* A10 的事件出口,A11 把它接到了环形缓冲上(§10.7:`t_safety` 只入队、不落盘)。 `evlog_put_safety()` 自己就是 `safety_ev_fn` 的形状,这里包一层只为了顺带 打一行串口日志 —— 板子插着 USB 调试时,事件同时进日志流与 flash。 @@ -420,6 +444,7 @@ void app_main(void) ⚠️ 必须在 `spawn(TASK_SAFETY, ...)` 之前:判据要关路时会先 `t_actuator_abort()`, 而没 init 的执行器只会直接返回,那时序列还在跑,电关掉几秒又会被抬回来。 */ t_actuator_init(act_pg_from_safety, NULL); + t_actuator_set_boot_check(act_ua_from_safety, act_boot_result, NULL); /* 命令处理器(A12)。status 读巡检与热管理两份快照。 `power` 命令的意图经 `want_save` 存 NVS(A15)。 diff --git a/firmware/main/bringup.c b/firmware/main/bringup.c index fcb1aa7..21e5ac9 100644 --- a/firmware/main/bringup.c +++ b/firmware/main/bringup.c @@ -209,6 +209,12 @@ hal_err_t bringup_devices(const bringup_in_t *in, bringup_report_t *r) hub_pulse(); } + /* `UP_VBUS_DET`:上游 USB-C 的 VBUS 经 R721/R722 分压进来,高 = 电脑插着。 + 板上 R722 下拉到地,所以不开内部上下拉。只进遥测(status 的 `host`),不参与任何判据: + Hub 连着电脑时比挂起多吃约 1 W,没有它面板上的「损耗」会无缘无故跳一档。 + 失败不 abort —— 读不到时 status 报 null。每次启动都配:配成输入没有副作用。 */ + (void)hal_gpio_config(BOARD_PIN_UP_VBUS_DET, HAL_GPIO_INPUT); + /* 锁必须早于任何一个用总线的人(R2 之后全系统只剩这一把)。 */ (void)hal_lock_init(); const hal_i2c_cfg_t i2c = { diff --git a/firmware/main/cmd_report.c b/firmware/main/cmd_report.c index 2033f46..e12c021 100644 --- a/firmware/main/cmd_report.c +++ b/firmware/main/cmd_report.c @@ -6,8 +6,10 @@ 上位机的卡死判据(电流曲线)拿 0 当真值会直接误判。 */ #include +#include "board_pins.h" #include "cmd_priv.h" #include "evlog.h" +#include "hal_gpio.h" #include "hal_time.h" #include "ota.h" #include "pca9555.h" @@ -101,6 +103,14 @@ void cmd_report_status(cj_writer_t *w, const cmd_env_t *env) { const safety_snap_t *s = env->safety; cj_w(w, ",\"fw\":\"%s\",\"up\":%lu", phonerail_version(), (unsigned long)hal_time_now_ms()); + /* 上游 USB-C 插没插电脑(`UP_VBUS_DET`,bringup 里配的输入)。读不到写 null。 + ⚠️ 这是 VBUS 在不在,不是 Hub 有没有枚举 / 挂起:电脑休眠时 VBUS 多半还在。 */ + int host = 0; + if (hal_gpio_read(BOARD_PIN_UP_VBUS_DET, &host) == HAL_OK) { + cj_w(w, ",\"host\":%s", b(host != 0)); + } else { + cj_w(w, ",\"host\":null"); + } if (s != NULL) { if (s->input_ok) { cj_w(w, ",\"in\":{\"mv\":%ld,\"ua\":%ld}", (long)s->input.bus_mv, diff --git a/firmware/main/evlog.c b/firmware/main/evlog.c index 7b7bd5f..0ea6853 100644 --- a/firmware/main/evlog.c +++ b/firmware/main/evlog.c @@ -573,6 +573,7 @@ const char *evlog_ev_str(log_ev_t ev) case LOG_EV_PD_LIMIT: return "PD限功率"; case LOG_EV_PG_TIMEOUT: return "上电等不到PG"; case LOG_EV_OTA: return "固件升级"; + case LOG_EV_BOOT_CHECK: return "开机重按"; case LOG_EV_COUNT: break; } return "?"; diff --git a/firmware/main/evlog.h b/firmware/main/evlog.h index 113d3b8..d8e8800 100644 --- a/firmware/main/evlog.h +++ b/firmware/main/evlog.h @@ -81,6 +81,13 @@ typedef enum { current_ua = 镜像字节数。整板级(rail = 0)。 */ LOG_EV_OTA = 18, + /* 0.1.2 新增:硬重启按电源键之后的开机校验(t_actuator.h `ACT_HR_BOOT_TRIES`)。 + 只在「重按过」或「最终没开起来」时记,一次就开的不记(每次硬重启都记一条太吵)。 + arg:bit0~3 = 一共按了几次,bit7 = 1 最终没开起来(手机电流始终 < 100 mA)。 + bus_mv = 判定时这一路电压,current_ua = 判定时电流。 + 频繁出现 = 这一台的电源键弹片和刀片铜盘接触不良(2026-09-28 板 B 第 1 路)。 */ + LOG_EV_BOOT_CHECK = 19, + LOG_EV_COUNT, } log_ev_t; diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h index ecd2a94..51673dd 100644 --- a/firmware/main/phonerail.h +++ b/firmware/main/phonerail.h @@ -3,7 +3,7 @@ #define PHONERAIL_H /* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ -#define PHONERAIL_FW_VERSION "0.1.1-c5" +#define PHONERAIL_FW_VERSION "0.1.3-c5" /* 通道数。五路直供,见设计文档 §2。 */ #define PHONERAIL_RAIL_COUNT 5 diff --git a/firmware/main/t_actuator.c b/firmware/main/t_actuator.c index 92b92dd..3ae279b 100644 --- a/firmware/main/t_actuator.c +++ b/firmware/main/t_actuator.c @@ -20,12 +20,19 @@ typedef struct { act_result_t result; uint32_t done; uint32_t cleanup_retries; + uint8_t presses; /* HR:这一次硬重启已经按了几次电源键 */ + uint8_t good_n; /* HR_VERIFY:连续几个样本电流达标 */ + bool ua_seen; /* HR_VERIFY:这一轮窗口里读到过电流没有 */ + int32_t last_ua; /* HR_VERIFY:最近一个读到的样本 */ } act_rail_t; -static act_rail_t s_rail[PHONERAIL_RAIL_COUNT]; -static act_pg_fn s_pg; -static void *s_pg_ctx; -static bool s_inited; +static act_rail_t s_rail[PHONERAIL_RAIL_COUNT]; +static act_pg_fn s_pg; +static void *s_pg_ctx; +static act_ua_fn s_ua; +static act_boot_fn s_boot_done; +static void *s_boot_ctx; +static bool s_inited; /* ---- 小工具 -------------------------------------------------------------- */ @@ -67,12 +74,46 @@ static bool pg_now(int rail, bool *pg) return s_pg(s_pg_ctx, rail, pg); } +static bool ua_now(int rail, int32_t *ua) +{ + if (s_ua == NULL) { + return false; + } + return s_ua(s_boot_ctx, rail, ua); +} + static void enter(act_rail_t *r, act_state_t st, uint32_t now) { r->state = st; r->t0_ms = now; } +/* HR:按下电源键开机(HR_PG 第一次、HR_VERIFY 重按走同一个口)。 + 和 KEY 序列一样:先记 release_mask 再看写没写成 —— 写失败时器件里那个字节 + 是未知的,照样走 CLEANUP 把它显式清一遍。返回 false = 写失败,已 finish(IO)。 */ +static void finish(act_rail_t *r, act_result_t res, uint32_t now); + +static bool hr_press(act_rail_t *r, int rail, uint32_t now) +{ + const uint8_t mask = key_out_mask(ACT_KEYS_PWR); + r->release_mask = mask; + r->hold_ms = ACT_HR_BOOT_KEY_MS; + r->presses++; + if (out_write(rail, mask, mask) != HAL_OK) { + finish(r, ACT_RES_IO, now); /* 电是开着的,只是没按上键 */ + return false; + } + enter(r, ACT_ST_HR_BOOT, now); + return true; +} + +static void boot_report(act_rail_t *r, int rail, bool booted) +{ + if (s_boot_done != NULL) { + s_boot_done(s_boot_ctx, rail, booted, r->presses, r->last_ua); + } +} + /* 序列结束(成功、超时、出错、被打断走的是同一条路)。 按下去过东西就必须经 CLEANUP,那是「键一定会松开」的唯一保证。 */ static void finish(act_rail_t *r, act_result_t res, uint32_t now) @@ -122,18 +163,8 @@ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now) case ACT_ST_HR_PG: { bool pg = false; if (pg_now(rail, &pg) && pg) { - /* 电好了 ⇒ 长按电源键开机(ACT_HR_BOOT_KEY_MS 的注释)。 - 和 KEY 序列一样:先记 release_mask 再看写没写成 —— 写失败时器件里那个字节 - 是未知的,照样走 CLEANUP 把它显式清一遍。 */ - const uint8_t mask = key_out_mask(ACT_KEYS_PWR); - r->release_mask = mask; - r->hold_ms = ACT_HR_BOOT_KEY_MS; - if (out_write(rail, mask, mask) != HAL_OK) { - finish(r, ACT_RES_IO, now); /* 电是开着的,只是没按上键 */ - return ACT_CLEANUP_RETRY_MS; - } - enter(r, ACT_ST_HR_BOOT, now); - return ACT_HR_BOOT_KEY_MS; + /* 电好了 ⇒ 长按电源键开机(ACT_HR_BOOT_KEY_MS 的注释)。 */ + return hr_press(r, rail, now) ? ACT_HR_BOOT_KEY_MS : ACT_CLEANUP_RETRY_MS; } if (el >= ACT_HR_PG_TIMEOUT_MS) { /* ⚠️ 电**继续开着**。见 ACT_RES_NO_PG 的注释:把「看不见」当成 @@ -146,6 +177,57 @@ static uint32_t state_step(act_rail_t *r, int rail, uint32_t now) } case ACT_ST_HR_BOOT: + if (el < r->hold_ms) { + return r->hold_ms - el; + } + if (s_ua == NULL) { + finish(r, ACT_RES_OK, now); /* 不校验(0.1.1 行为):CLEANUP 松键 */ + return ACT_CLEANUP_RETRY_MS; + } + /* 要校验:先在这里松键(松不掉就走 CLEANUP 的「不放弃」,不再校验)。 */ + if (out_write(rail, r->release_mask, 0) != HAL_OK) { + finish(r, ACT_RES_IO, now); + return ACT_CLEANUP_RETRY_MS; + } + r->release_mask = 0; + r->good_n = 0; + r->ua_seen = false; + enter(r, ACT_ST_HR_VERIFY, now); + return ACT_HR_VERIFY_DELAY_MS; + + case ACT_ST_HR_VERIFY: { + if (el < ACT_HR_VERIFY_DELAY_MS) { + return ACT_HR_VERIFY_DELAY_MS - el; + } + int32_t ua = 0; + if (ua_now(rail, &ua)) { + r->ua_seen = true; + r->last_ua = ua; + r->good_n = ua >= ACT_HR_BOOT_UA_MIN ? (uint8_t)(r->good_n + 1) : 0; + if (r->good_n >= ACT_HR_VERIFY_GOOD_N) { + boot_report(r, rail, true); + finish(r, ACT_RES_OK, now); + return ACT_IDLE_WAIT_MS; + } + } + if (el < ACT_HR_VERIFY_DELAY_MS + ACT_HR_VERIFY_WINDOW_MS) { + const uint32_t left = ACT_HR_VERIFY_DELAY_MS + ACT_HR_VERIFY_WINDOW_MS - el; + return left < ACT_HR_VERIFY_POLL_MS ? left : ACT_HR_VERIFY_POLL_MS; + } + if (!r->ua_seen) { + /* 整个窗口都读不到电流:判不了,**不重按**(ACT_HR_BOOT_TRIES 那段注释)。 */ + finish(r, ACT_RES_OK, now); + return ACT_IDLE_WAIT_MS; + } + if (r->presses < ACT_HR_BOOT_TRIES) { + return hr_press(r, rail, now) ? ACT_HR_BOOT_KEY_MS : ACT_CLEANUP_RETRY_MS; + } + /* ⚠️ 电**继续开着**:关掉也不会让弹片压上,反而丢了人工到场前的现场。 */ + boot_report(r, rail, false); + finish(r, ACT_RES_NO_BOOT, now); + return ACT_IDLE_WAIT_MS; + } + case ACT_ST_KEY_HOLD: if (el < r->hold_ms) { return r->hold_ms - el; @@ -213,9 +295,19 @@ void t_actuator_init(act_pg_fn pg, void *ctx) memset(s_rail, 0, sizeof(s_rail)); s_pg = pg; s_pg_ctx = ctx; + s_ua = NULL; + s_boot_done = NULL; + s_boot_ctx = NULL; s_inited = true; } +void t_actuator_set_boot_check(act_ua_fn ua, act_boot_fn done, void *ctx) +{ + s_ua = ua; + s_boot_done = done; + s_boot_ctx = ctx; +} + hal_err_t t_actuator_hard_reset(int rail) { if (!s_inited) { @@ -244,6 +336,8 @@ hal_err_t t_actuator_hard_reset(int rail) } r->seq = ACT_SEQ_HARD_RESET; r->release_mask = 0; /* 断电这一段没按任何东西;开机那一下在 HR_PG 里才记 */ + r->presses = 0; + r->last_ua = 0; r->result = ACT_RES_NONE; enter(r, ACT_ST_HR_OFF, hal_time_now_ms()); return HAL_OK; @@ -368,6 +462,7 @@ const char *t_actuator_state_str(act_state_t st) case ACT_ST_HR_RAMP: return "hr-ramp"; case ACT_ST_HR_PG: return "hr-pg"; case ACT_ST_HR_BOOT: return "hr-boot"; + case ACT_ST_HR_VERIFY: return "hr-verify"; case ACT_ST_KEY_HOLD: return "key-hold"; case ACT_ST_EDL_SHORT: return "edl-short"; case ACT_ST_EDL_ENUM: return "edl-enum"; @@ -384,6 +479,7 @@ const char *t_actuator_result_str(act_result_t r) case ACT_RES_NO_PG: return "no-pg"; case ACT_RES_IO: return "io"; case ACT_RES_ABORTED: return "aborted"; + case ACT_RES_NO_BOOT: return "no-boot"; } return "?"; } diff --git a/firmware/main/t_actuator.h b/firmware/main/t_actuator.h index 1bb61ba..8268644 100644 --- a/firmware/main/t_actuator.h +++ b/firmware/main/t_actuator.h @@ -1,225 +1,263 @@ -/* `t_actuator` —— 每路三个序列 + 互斥(需求 §10.6,§10.3 的第三行任务)。 - - 三个序列,**同一路上同时只能跑一个**(§10.6 原话「彼此互斥、不可重入」): - - | 序列 | 时序 | 出处 | - |---|---|---| - | 硬重启 | `EFUSE_EN↓` → 等 3~5 s → `EFUSE_EN↑` → 等 **17 ms** → 等 PG 置位 → 长按电源键 5 s 开机 | 设计文档 §7.4;开机那一步见 `ACT_HR_BOOT_KEY_MS` | - | 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | SW-5 / §13.3 | - | EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | SW-6 / §22.4 | - - ---- 三条不能改的规矩 ---------------------------------------------------- - - 1. **一个函数一步,不许写 `while(1)`。** 死循环在宿主机上一行也跑不到, - 而这一包的全部价值就是那几个时间常量与它们的先后 —— 那正是要被测的东西。 - `t_actuator_step()` 只看时钟、推状态、返回「下次最晚多久再来」, - 任务体在 app_main.c 里,还是三行。 - - 2. **放电由硬件联锁自动完成(SR-7),固件一个字都不要写。** 放电 MOS 的栅极 - 经一级反相器接在 `EFUSE_EN` 上(设计文档 §7.3),物理上不可能与 eFuse 同时导通。 - 固件里再补一根「放电控制」= 10 Ω 上持续 1.76 W,电阻烧掉。 - 所以本文件里**没有任何放电接口**,硬重启只有一根 `EFUSE_EN`。 - - 3. **按下去的东西一定要松开。** 这一层最坏的失败不是「序列没走完」, - 而是**键留在按下状态** —— 电源键被一直按着,手机再也起不来,而且遥测上 - 五路电压电流全都正常。所以每个序列都以 `ACT_ST_CLEANUP` 收尾(超时、 - 出错、正常结束走的是同一条路),而且 **CLEANUP 不放弃**:写不进去就一直重试, - 这一路一直算 busy。放弃比重试危险得多。 - - ---- PG 为什么是别人喂进来的 ---------------------------------------------- - `PGOOD` 挂在刀片 PCA9555 的 P1.3 上,而 `t_safety` 已经每 20 ms 无条件读一圈 - (§10.3)。执行器再去读一遍等于凭空多一份总线占用和一份两边不一致的可能, - 所以 PG 从外面**喂**进来(`act_pg_fn`)。 - - ---- ⚠️ R2 改了这一层的锁前提 --------------------------------------------- - R1 里执行器只碰 GPIO 与 74HC595,与 I²C 无交集,所以两把锁永远不会嵌套。 - **R2 里按键 / EDL / VBUS 全在刀片的 PCA9555 上 ⇒ 本文件必须拿 I²C 锁** - (经 `slot_*`,它们自带 `i2cbus_xact`)。 - - 好消息是 595 那把锁没了,全系统**只剩一把锁**,嵌套在物理上不可能。 - 代价是与巡检争用同一把锁,于是: - · 每一次动作都是一次短事务,**不许在持锁期间等时间** - (按键保持 100 ms 是状态机的事,不是持锁的事); - · `ACT_ST_CLEANUP` 的「不放弃」比 R1 更要紧:总线忙时写不进去必须一直重试。 */ -#ifndef T_ACTUATOR_H -#define T_ACTUATOR_H - -#include -#include -#include - -#include "hal_err.h" -#include "phonerail.h" -#include "slot.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* ---- 时间常量:每一个都有出处,不许拍脑袋改 ----------------------------- */ - -/* 硬重启断电时长。§7.4:放到 <0.5 V 只要 ~110 ms(τ = 2200 µF × 10 Ω = 22 ms), - 其余是等手机彻底断电,窗口 3~5 s,取中值。 - ⚠️ 缩到 1 s 的现象**不是**「重启快了」,而是「有的机型压根没重启」 - (某些机型要 VBAT 掉到 2.5 V 以下才算真断电,见 §7.1)。 */ -#define ACT_HR_OFF_MS 4000 -#define ACT_HR_OFF_MIN_MS 3000 /* §7.4 的窗口,自检与测试拿它对账 */ -#define ACT_HR_OFF_MAX_MS 5000 - -/* `EFUSE_EN↑` 之后到输出稳定:tON 延迟 5.1 ms(C = 33 nF)+ 缓启动斜坡 11.5 ms - (0.36 V/ms)≈ **17 ms**(设计文档 §5「缓启动实际值」与 §7.4)。 - 这段时间之内 PG 本来就不该置位,去看它只会看到「还没好」。 */ -#define ACT_HR_RAMP_MS 17 - -/* 等 PG 的上限。TPS25940A 的 PGOOD 在输出过 2.97 V 时释放,也就是斜坡中段就该来了; - 500 ms 是 ~30 倍余量。等不到**不代表要再断一次电**,见 `ACT_RES_NO_PG`。 */ -#define ACT_HR_PG_TIMEOUT_MS 500 - -/* 等 PG 期间多久回来看一眼。PG 是 `t_safety` 每 20 ms 刷新的快照, - 比这个再密也没有新值。⚠️ 不能直接睡满 500 ms —— 那样「等到 PG 就结束」 - 会退化成「一律等满 500 ms」,而测试与遥测都看不出区别。 */ -#define ACT_HR_PG_POLL_MS 5 - -/* 硬重启的最后一步:等到 PG 之后长按电源键开机。电池端上电的手机**停在关机态** - (小米8 实测:重新上电后只漏 ~2 mA,不按键就一直关着),不按这一下的硬重启 - 等于「关机」。时长就是 SW-5 的开机档。 - ⚠️ 只在**等到 PG** 时按:NO_PG 多半是这一片 PCA9555 读不到,键也在同一片上, - 去按只会让这一路卡在 CLEANUP 里重试。 */ -#define ACT_HR_BOOT_KEY_MS ACT_KEY_MS_ON - -/* SW-5 的三档按键时长。 - ⚠️ 开机那一档 2026-09-28 小米8 实测:按 2 s 时 PMIC 起来了(415 mA),一松手 3 s 内 - 掉回 ~1 mA —— SoC 还没接住 PS_HOLD。按 5 s 松手后稳在 ~210 mA、约 45 s 进系统。 - 上限是 ACT_KEY_MS_FORCE:按到 10 s 就成了强制重启。 */ -#define ACT_KEY_MS_TAP 100 /* 短按:唤醒 / 息屏 */ -#define ACT_KEY_MS_ON 5000 /* 长按:开机 */ -#define ACT_KEY_MS_FORCE 10000 /* 长按:强制重启(两级重启的「一级(软)」) */ - -/* 按键时长上限。上层传进来一个 10 分钟,等于把电源键按死 —— - 这不是保护,是**拦调用方的手误**(SR-13 拦 4.2 V 是同一个道理)。 */ -#define ACT_KEY_MAX_MS 15000 - -/* EDL:先短接、再插 USB(SW-6 的顺序是硬要求)。 - 模拟开关 TS5A3157 的 Ron ~1 Ω、导通时间 µs 级,10 ms 是给它和 1.8 kΩ 上的建立留的。 */ -#define ACT_EDL_SHORT_MS 10 - -/* 「等枚举」。⚠️ **固件看不见枚举** —— 手机挂在板载 Hub 的下行口上,ESP32 这一侧 - 没有任何信号能告诉它 9008 有没有起来。所以这里只能定时等,3 s 是按 USB 枚举 - 典型 <1 s 给的余量。真要确认枚举成功,只能由上位机 `lsusb` 看 05c6:9008 - (那是 §10.8 上位机那一侧的事)。 */ -#define ACT_EDL_ENUM_MS 3000 - -/* 每次动作等 I²C 锁最多等多久(`slot.h` 的 `SLOT_LOCK_TIMEOUT_MS` 就是它)。 - 争用来自每 20 ms 一圈的巡检;等不到就下一拍再来(CLEANUP 会一直重试)。 */ -#define ACT_LOCK_TIMEOUT_MS SLOT_LOCK_TIMEOUT_MS - -/* CLEANUP 重试间隔,以及空闲时建议任务睡多久(A12 会换成「带超时地等队列」)。 */ -#define ACT_CLEANUP_RETRY_MS 5 -#define ACT_IDLE_WAIT_MS 100 - -/* ---- 按键:逻辑键位,不是器件上的位号 ----------------------------------- - 上层一律用这三个位(和它们的组合)表达「按哪几个键」,PCA9555 Port0 上的 - 位号由本文件经 `pca9555_key_bit()` 查表换算。 - ⚠️ R2 里位号**不再随路号变**(五片刀片同构),但仍然查表不算。 */ -typedef enum { - ACT_KEYS_PWR = 1u << PCA9555_KEY_PWR, - ACT_KEYS_VUP = 1u << PCA9555_KEY_VUP, - ACT_KEYS_VDN = 1u << PCA9555_KEY_VDN, - - /* SW-5 的两个组合键。**必须同一帧写进去**(§10.6)—— - 分两次写会先单按一个键,而「先单按 VOL−」在有的机型上就是音量调节。 */ - ACT_KEYS_FASTBOOT = ACT_KEYS_VDN | ACT_KEYS_PWR, - ACT_KEYS_RECOVERY = ACT_KEYS_VUP | ACT_KEYS_PWR, - - ACT_KEYS_ALL = ACT_KEYS_PWR | ACT_KEYS_VUP | ACT_KEYS_VDN, -} act_keys_t; - -/* ---- 状态与结果 ---------------------------------------------------------- */ - -typedef enum { - ACT_SEQ_NONE = 0, - ACT_SEQ_HARD_RESET, - ACT_SEQ_KEY, - ACT_SEQ_EDL, -} act_seq_t; - -typedef enum { - ACT_ST_IDLE = 0, - ACT_ST_HR_OFF, /* `EFUSE_EN` 已拉低,等 3~5 s(放电由硬件自己做) */ - ACT_ST_HR_RAMP, /* `EFUSE_EN` 已拉高,等 17 ms 走完 tON + 斜坡 */ - ACT_ST_HR_PG, /* 等 PG 置位 */ - ACT_ST_HR_BOOT, /* PG 到了,电源键按着,保持 ACT_HR_BOOT_KEY_MS 开机 */ - ACT_ST_KEY_HOLD, /* 键已按下,保持 T */ - ACT_ST_EDL_SHORT, /* `EDL_EN=1`,等短接建立 */ - ACT_ST_EDL_ENUM, /* `VBUS_EN=1`,等枚举 */ - ACT_ST_CLEANUP, /* 把本序列按下去的东西松开,落不下去就重试(不放弃) */ -} act_state_t; - -typedef enum { - ACT_RES_NONE = 0, - ACT_RES_OK, - /* 硬重启专用:电**已经开着**,只是 500 ms 内没等到 PG(也可能是 PCA9555 - 读不到)。⚠️ 这时**不许**再把这一路关掉:§10.10 里「PCA9555 不应答 = - 失去 FAULT/PG 可见性,**不关输出**」。把「看不见」当成「坏了」去断电, - 等于一片 I/O 扩展器失联就让五台手机一起停机。 */ - ACT_RES_NO_PG, - /* 写 PCA9555 或写 EN 引脚失败(含拿不到 I²C 锁)。序列中止,但收尾照做。 */ - ACT_RES_IO, - /* 被 `t_actuator_abort()` 打断。 */ - ACT_RES_ABORTED, -} act_result_t; - -/* PG 取值回调。返回 false = **这一刻读不到**(PCA9555 失联 / 还没读过一圈), - 与「读到了,是低」是两回事 —— 后者是「电源还没好」,前者是「不知道」。 - 传 NULL 给 `t_actuator_init()` 就是「永远读不到」,硬重启会走完并记 ACT_RES_NO_PG。 */ -typedef bool (*act_pg_fn)(void *ctx, int rail, bool *pg); - -/* ---- 接口 ---------------------------------------------------------------- */ - -/* 幂等,五路全部回到 IDLE。**不碰硬件**:SW-11 的「上电全关」是 - `rail_en_init()`(GPIO)+ `slot_init()`(PCA9555 的输出锁存器先清零) - 干的(A15),不是这里。 */ -void t_actuator_init(act_pg_fn pg, void *ctx); - -/* 三个请求。rail 是 **1~5**。 - HAL_ERR_ARG —— 路号 / 键位 / 时长越界; - HAL_ERR_STATE —— 没 init,或者**这一路正在跑另一个序列**(§10.6 不可重入)。 - 要抢占的话调用方得先 `t_actuator_abort()`,写成显式的。 - ⚠️ 返回非 HAL_OK 时序列**没有开始**,只有一个例外:按下 / 置位那一步真的 - 发到链上但失败了 —— 那时影子可能已经改了,所以那一路会进 CLEANUP 把它撤回来, - 函数仍然返回错误码,但 `t_actuator_busy()` 会短暂为 true。 */ -hal_err_t t_actuator_hard_reset(int rail); -hal_err_t t_actuator_key(int rail, uint32_t keys, uint32_t hold_ms); -hal_err_t t_actuator_edl(int rail); - -/* 打断这一路正在跑的序列:立刻收尾(键松开 / `EDL_EN` 清零),结果记 ACT_RES_ABORTED。 - **不动 `EFUSE_EN` / `VBUS_EN`** —— 硬重启走到一半被打断时,把电再关一次 - 或者再开一次都是自作主张;断电是 A10 的判据和上位机命令的事。 */ -void t_actuator_abort(int rail); - -/* 推一步。返回**下次最晚多久再来**(ms):五路里最近的那个截止时刻。 - 全空闲返回 ACT_IDLE_WAIT_MS。醒早了没关系 —— 每个状态都是拿绝对时刻算的, - 早来就再返回一个剩余时间。(ESP-IDF 默认 `CONFIG_FREERTOS_HZ=100`, - `vTaskDelay(pdMS_TO_TICKS(17))` 只有 1 个 tick,本来就会早醒。) */ -uint32_t t_actuator_step(void); - -/* ---- 观测 ---------------------------------------------------------------- */ - -bool t_actuator_busy(int rail); -act_state_t t_actuator_state(int rail); -act_seq_t t_actuator_seq(int rail); -act_result_t t_actuator_last_result(int rail); -uint32_t t_actuator_done(int rail); /* 走完几个序列(含失败的) */ -uint32_t t_actuator_cleanup_retries(int rail); /* 收尾重试次数,遥测里报出去 */ - -const char *t_actuator_state_str(act_state_t st); -const char *t_actuator_result_str(act_result_t r); - -/* 一行人话,给启动日志和遥测用。返回写进去的字符数(不含结尾 0)。 */ -int t_actuator_format(char *buf, size_t len); - -#ifdef __cplusplus -} -#endif - -#endif /* T_ACTUATOR_H */ +/* `t_actuator` —— 每路三个序列 + 互斥(需求 §10.6,§10.3 的第三行任务)。 + + 三个序列,**同一路上同时只能跑一个**(§10.6 原话「彼此互斥、不可重入」): + + | 序列 | 时序 | 出处 | + |---|---|---| + | 硬重启 | `EFUSE_EN↓` → 等 3~5 s → `EFUSE_EN↑` → 等 **17 ms** → 等 PG 置位 → 长按电源键 5 s 开机 →(接了电流回调时)看电流、没开就重按,最多 3 次 | 设计文档 §7.4;开机那一步见 `ACT_HR_BOOT_KEY_MS` / `ACT_HR_BOOT_TRIES` | + | 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | SW-5 / §13.3 | + | EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | SW-6 / §22.4 | + + ---- 三条不能改的规矩 ---------------------------------------------------- + + 1. **一个函数一步,不许写 `while(1)`。** 死循环在宿主机上一行也跑不到, + 而这一包的全部价值就是那几个时间常量与它们的先后 —— 那正是要被测的东西。 + `t_actuator_step()` 只看时钟、推状态、返回「下次最晚多久再来」, + 任务体在 app_main.c 里,还是三行。 + + 2. **放电由硬件联锁自动完成(SR-7),固件一个字都不要写。** 放电 MOS 的栅极 + 经一级反相器接在 `EFUSE_EN` 上(设计文档 §7.3),物理上不可能与 eFuse 同时导通。 + 固件里再补一根「放电控制」= 10 Ω 上持续 1.76 W,电阻烧掉。 + 所以本文件里**没有任何放电接口**,硬重启只有一根 `EFUSE_EN`。 + + 3. **按下去的东西一定要松开。** 这一层最坏的失败不是「序列没走完」, + 而是**键留在按下状态** —— 电源键被一直按着,手机再也起不来,而且遥测上 + 五路电压电流全都正常。所以每个序列都以 `ACT_ST_CLEANUP` 收尾(超时、 + 出错、正常结束走的是同一条路),而且 **CLEANUP 不放弃**:写不进去就一直重试, + 这一路一直算 busy。放弃比重试危险得多。 + + ---- PG 为什么是别人喂进来的 ---------------------------------------------- + `PGOOD` 挂在刀片 PCA9555 的 P1.3 上,而 `t_safety` 已经每 20 ms 无条件读一圈 + (§10.3)。执行器再去读一遍等于凭空多一份总线占用和一份两边不一致的可能, + 所以 PG 从外面**喂**进来(`act_pg_fn`)。 + + ---- ⚠️ R2 改了这一层的锁前提 --------------------------------------------- + R1 里执行器只碰 GPIO 与 74HC595,与 I²C 无交集,所以两把锁永远不会嵌套。 + **R2 里按键 / EDL / VBUS 全在刀片的 PCA9555 上 ⇒ 本文件必须拿 I²C 锁** + (经 `slot_*`,它们自带 `i2cbus_xact`)。 + + 好消息是 595 那把锁没了,全系统**只剩一把锁**,嵌套在物理上不可能。 + 代价是与巡检争用同一把锁,于是: + · 每一次动作都是一次短事务,**不许在持锁期间等时间** + (按键保持 100 ms 是状态机的事,不是持锁的事); + · `ACT_ST_CLEANUP` 的「不放弃」比 R1 更要紧:总线忙时写不进去必须一直重试。 */ +#ifndef T_ACTUATOR_H +#define T_ACTUATOR_H + +#include +#include +#include + +#include "hal_err.h" +#include "phonerail.h" +#include "slot.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- 时间常量:每一个都有出处,不许拍脑袋改 ----------------------------- */ + +/* 硬重启断电时长。§7.4:放到 <0.5 V 只要 ~110 ms(τ = 2200 µF × 10 Ω = 22 ms), + 其余是等手机彻底断电,窗口 3~5 s,取中值。 + ⚠️ 缩到 1 s 的现象**不是**「重启快了」,而是「有的机型压根没重启」 + (某些机型要 VBAT 掉到 2.5 V 以下才算真断电,见 §7.1)。 */ +#define ACT_HR_OFF_MS 4000 +#define ACT_HR_OFF_MIN_MS 3000 /* §7.4 的窗口,自检与测试拿它对账 */ +#define ACT_HR_OFF_MAX_MS 5000 + +/* `EFUSE_EN↑` 之后到输出稳定:tON 延迟 5.1 ms(C = 33 nF)+ 缓启动斜坡 11.5 ms + (0.36 V/ms)≈ **17 ms**(设计文档 §5「缓启动实际值」与 §7.4)。 + 这段时间之内 PG 本来就不该置位,去看它只会看到「还没好」。 */ +#define ACT_HR_RAMP_MS 17 + +/* 等 PG 的上限。TPS25940A 的 PGOOD 在输出过 2.97 V 时释放,也就是斜坡中段就该来了; + 500 ms 是 ~30 倍余量。等不到**不代表要再断一次电**,见 `ACT_RES_NO_PG`。 */ +#define ACT_HR_PG_TIMEOUT_MS 500 + +/* 等 PG 期间多久回来看一眼。PG 是 `t_safety` 每 20 ms 刷新的快照, + 比这个再密也没有新值。⚠️ 不能直接睡满 500 ms —— 那样「等到 PG 就结束」 + 会退化成「一律等满 500 ms」,而测试与遥测都看不出区别。 */ +#define ACT_HR_PG_POLL_MS 5 + +/* 硬重启的最后一步:等到 PG 之后长按电源键开机。电池端上电的手机**停在关机态** + (小米8 实测:重新上电后只漏 ~2 mA,不按键就一直关着),不按这一下的硬重启 + 等于「关机」。时长就是 SW-5 的开机档。 + ⚠️ 只在**等到 PG** 时按:NO_PG 多半是这一片 PCA9555 读不到,键也在同一片上, + 去按只会让这一路卡在 CLEANUP 里重试。 */ +#define ACT_HR_BOOT_KEY_MS ACT_KEY_MS_ON + +/* ---- 开机校验与重按(0.1.2,接了 `t_actuator_set_boot_check()` 才生效) ---- + 2026-09-28 板 B 第 1 路实测:R2 的电源键是**主板弹片压刀片铜盘**(决策 14), + 接触时好时坏 —— 7 次硬重启只开起来 1 次,失败那几次 PCA9555 输出确实置位了, + 手机侧电流却一直 1~2 mA(PMIC 根本没醒,屏幕不亮),镊子短接 J603 就能开机。 + 所以按完之后看电流:开起来了是 ~210 mA 平台(logo 段,之后 0.5~1.7 A),没开是 1~2 mA。 + + · 松键后先等 ACT_HR_VERIFY_DELAY_MS(3 s)再看:按 2 s 那种「PMIC 醒了、SoC 没接住 + PS_HOLD」的情况,电流会先冲到 ~415 mA、松手 3 s 内掉回去 —— 早看会误判成功; + 真开起来的那一段是 ~210 mA 平台、持续约 45 s,3~5 s 时稳稳在上面; + · 连续 ACT_HR_VERIFY_GOOD_N 个样本 ≥ ACT_HR_BOOT_UA_MIN 才算开机, + 窗口 ACT_HR_VERIFY_WINDOW_MS 内等不到就重按,一共最多按 ACT_HR_BOOT_TRIES 次; + · **读不到电流不重按**:看不见 ≠ 没开(ACT_RES_NO_PG 同一条理由), + 对一台正在开机的手机再长按 5 s 反而可能把它按进关机菜单。 */ +#define ACT_HR_BOOT_TRIES 3 +#define ACT_HR_BOOT_UA_MIN 100000 /* 100 mA:logo 段 ~210 mA,关机态 1~2 mA */ +#define ACT_HR_VERIFY_DELAY_MS 3000 +#define ACT_HR_VERIFY_WINDOW_MS 2000 /* 松键后 3~5 s 内判 */ +#define ACT_HR_VERIFY_POLL_MS 100 /* 巡检 20 ms 一圈,100 ms 取一个样本足够 */ +#define ACT_HR_VERIFY_GOOD_N 5 /* 连续 0.5 s 在线才算 */ + +/* SW-5 的三档按键时长。 + ⚠️ 开机那一档 2026-09-28 小米8 实测:按 2 s 时 PMIC 起来了(415 mA),一松手 3 s 内 + 掉回 ~1 mA —— SoC 还没接住 PS_HOLD。按 5 s 松手后稳在 ~210 mA、约 45 s 进系统。 + 上限是 ACT_KEY_MS_FORCE:按到 10 s 就成了强制重启。 */ +#define ACT_KEY_MS_TAP 100 /* 短按:唤醒 / 息屏 */ +#define ACT_KEY_MS_ON 5000 /* 长按:开机 */ +#define ACT_KEY_MS_FORCE 10000 /* 长按:强制重启(两级重启的「一级(软)」) */ + +/* 按键时长上限。上层传进来一个 10 分钟,等于把电源键按死 —— + 这不是保护,是**拦调用方的手误**(SR-13 拦 4.2 V 是同一个道理)。 */ +#define ACT_KEY_MAX_MS 15000 + +/* EDL:先短接、再插 USB(SW-6 的顺序是硬要求)。 + 模拟开关 TS5A3157 的 Ron ~1 Ω、导通时间 µs 级,10 ms 是给它和 1.8 kΩ 上的建立留的。 */ +#define ACT_EDL_SHORT_MS 10 + +/* 「等枚举」。⚠️ **固件看不见枚举** —— 手机挂在板载 Hub 的下行口上,ESP32 这一侧 + 没有任何信号能告诉它 9008 有没有起来。所以这里只能定时等,3 s 是按 USB 枚举 + 典型 <1 s 给的余量。真要确认枚举成功,只能由上位机 `lsusb` 看 05c6:9008 + (那是 §10.8 上位机那一侧的事)。 */ +#define ACT_EDL_ENUM_MS 3000 + +/* 每次动作等 I²C 锁最多等多久(`slot.h` 的 `SLOT_LOCK_TIMEOUT_MS` 就是它)。 + 争用来自每 20 ms 一圈的巡检;等不到就下一拍再来(CLEANUP 会一直重试)。 */ +#define ACT_LOCK_TIMEOUT_MS SLOT_LOCK_TIMEOUT_MS + +/* CLEANUP 重试间隔,以及空闲时建议任务睡多久(A12 会换成「带超时地等队列」)。 */ +#define ACT_CLEANUP_RETRY_MS 5 +#define ACT_IDLE_WAIT_MS 100 + +/* ---- 按键:逻辑键位,不是器件上的位号 ----------------------------------- + 上层一律用这三个位(和它们的组合)表达「按哪几个键」,PCA9555 Port0 上的 + 位号由本文件经 `pca9555_key_bit()` 查表换算。 + ⚠️ R2 里位号**不再随路号变**(五片刀片同构),但仍然查表不算。 */ +typedef enum { + ACT_KEYS_PWR = 1u << PCA9555_KEY_PWR, + ACT_KEYS_VUP = 1u << PCA9555_KEY_VUP, + ACT_KEYS_VDN = 1u << PCA9555_KEY_VDN, + + /* SW-5 的两个组合键。**必须同一帧写进去**(§10.6)—— + 分两次写会先单按一个键,而「先单按 VOL−」在有的机型上就是音量调节。 */ + ACT_KEYS_FASTBOOT = ACT_KEYS_VDN | ACT_KEYS_PWR, + ACT_KEYS_RECOVERY = ACT_KEYS_VUP | ACT_KEYS_PWR, + + ACT_KEYS_ALL = ACT_KEYS_PWR | ACT_KEYS_VUP | ACT_KEYS_VDN, +} act_keys_t; + +/* ---- 状态与结果 ---------------------------------------------------------- */ + +typedef enum { + ACT_SEQ_NONE = 0, + ACT_SEQ_HARD_RESET, + ACT_SEQ_KEY, + ACT_SEQ_EDL, +} act_seq_t; + +typedef enum { + ACT_ST_IDLE = 0, + ACT_ST_HR_OFF, /* `EFUSE_EN` 已拉低,等 3~5 s(放电由硬件自己做) */ + ACT_ST_HR_RAMP, /* `EFUSE_EN` 已拉高,等 17 ms 走完 tON + 斜坡 */ + ACT_ST_HR_PG, /* 等 PG 置位 */ + ACT_ST_HR_BOOT, /* PG 到了,电源键按着,保持 ACT_HR_BOOT_KEY_MS 开机 */ + ACT_ST_HR_VERIFY, /* 键已松开,看电流判断开没开起来;没开就回 HR_BOOT 重按 */ + ACT_ST_KEY_HOLD, /* 键已按下,保持 T */ + ACT_ST_EDL_SHORT, /* `EDL_EN=1`,等短接建立 */ + ACT_ST_EDL_ENUM, /* `VBUS_EN=1`,等枚举 */ + ACT_ST_CLEANUP, /* 把本序列按下去的东西松开,落不下去就重试(不放弃) */ +} act_state_t; + +typedef enum { + ACT_RES_NONE = 0, + ACT_RES_OK, + /* 硬重启专用:电**已经开着**,只是 500 ms 内没等到 PG(也可能是 PCA9555 + 读不到)。⚠️ 这时**不许**再把这一路关掉:§10.10 里「PCA9555 不应答 = + 失去 FAULT/PG 可见性,**不关输出**」。把「看不见」当成「坏了」去断电, + 等于一片 I/O 扩展器失联就让五台手机一起停机。 */ + ACT_RES_NO_PG, + /* 写 PCA9555 或写 EN 引脚失败(含拿不到 I²C 锁)。序列中止,但收尾照做。 */ + ACT_RES_IO, + /* 被 `t_actuator_abort()` 打断。 */ + ACT_RES_ABORTED, + /* 硬重启专用(0.1.2):电开着、PG 也到了,按了 ACT_HR_BOOT_TRIES 次电源键 + 手机电流始终起不来 —— 多半是电源键弹片没压上刀片铜盘。电**继续开着**。 */ + ACT_RES_NO_BOOT, +} act_result_t; + +/* PG 取值回调。返回 false = **这一刻读不到**(PCA9555 失联 / 还没读过一圈), + 与「读到了,是低」是两回事 —— 后者是「电源还没好」,前者是「不知道」。 + 传 NULL 给 `t_actuator_init()` 就是「永远读不到」,硬重启会走完并记 ACT_RES_NO_PG。 */ +typedef bool (*act_pg_fn)(void *ctx, int rail, bool *pg); + +/* 开机校验用的电流取值(µA,有符号)。返回 false = 这一刻读不到。和 PG 一样从巡检快照喂进来。 */ +typedef bool (*act_ua_fn)(void *ctx, int rail, int32_t *ua); + +/* 开机校验有了结论时回调一次(只在接了 `act_ua_fn` 时): + booted = 电流起来了;presses = 一共按了几次(1 = 第一次就开了);ua = 判定那一刻的电流。 + 执行器自己不写事件(宿主机测试不带 evlog),由 app_main 接到 LOG_EV_BOOT_CHECK。 + 「读不到电流、判不了」不回调。 */ +typedef void (*act_boot_fn)(void *ctx, int rail, bool booted, uint8_t presses, int32_t ua); + +/* ---- 接口 ---------------------------------------------------------------- */ + +/* 幂等,五路全部回到 IDLE。**不碰硬件**:SW-11 的「上电全关」是 + `rail_en_init()`(GPIO)+ `slot_init()`(PCA9555 的输出锁存器先清零) + 干的(A15),不是这里。 */ +void t_actuator_init(act_pg_fn pg, void *ctx); + +/* 打开硬重启的开机校验与重按(见 ACT_HR_BOOT_TRIES 那一段)。ua = NULL 关掉, + 关掉时硬重启按完一次电源键就记 OK(0.1.1 的行为)。done 可为 NULL。 + 在 `t_actuator_init()` 之后调;init 会把它清掉。 */ +void t_actuator_set_boot_check(act_ua_fn ua, act_boot_fn done, void *ctx); + +/* 三个请求。rail 是 **1~5**。 + HAL_ERR_ARG —— 路号 / 键位 / 时长越界; + HAL_ERR_STATE —— 没 init,或者**这一路正在跑另一个序列**(§10.6 不可重入)。 + 要抢占的话调用方得先 `t_actuator_abort()`,写成显式的。 + ⚠️ 返回非 HAL_OK 时序列**没有开始**,只有一个例外:按下 / 置位那一步真的 + 发到链上但失败了 —— 那时影子可能已经改了,所以那一路会进 CLEANUP 把它撤回来, + 函数仍然返回错误码,但 `t_actuator_busy()` 会短暂为 true。 */ +hal_err_t t_actuator_hard_reset(int rail); +hal_err_t t_actuator_key(int rail, uint32_t keys, uint32_t hold_ms); +hal_err_t t_actuator_edl(int rail); + +/* 打断这一路正在跑的序列:立刻收尾(键松开 / `EDL_EN` 清零),结果记 ACT_RES_ABORTED。 + **不动 `EFUSE_EN` / `VBUS_EN`** —— 硬重启走到一半被打断时,把电再关一次 + 或者再开一次都是自作主张;断电是 A10 的判据和上位机命令的事。 */ +void t_actuator_abort(int rail); + +/* 推一步。返回**下次最晚多久再来**(ms):五路里最近的那个截止时刻。 + 全空闲返回 ACT_IDLE_WAIT_MS。醒早了没关系 —— 每个状态都是拿绝对时刻算的, + 早来就再返回一个剩余时间。(ESP-IDF 默认 `CONFIG_FREERTOS_HZ=100`, + `vTaskDelay(pdMS_TO_TICKS(17))` 只有 1 个 tick,本来就会早醒。) */ +uint32_t t_actuator_step(void); + +/* ---- 观测 ---------------------------------------------------------------- */ + +bool t_actuator_busy(int rail); +act_state_t t_actuator_state(int rail); +act_seq_t t_actuator_seq(int rail); +act_result_t t_actuator_last_result(int rail); +uint32_t t_actuator_done(int rail); /* 走完几个序列(含失败的) */ +uint32_t t_actuator_cleanup_retries(int rail); /* 收尾重试次数,遥测里报出去 */ + +const char *t_actuator_state_str(act_state_t st); +const char *t_actuator_result_str(act_result_t r); + +/* 一行人话,给启动日志和遥测用。返回写进去的字符数(不含结尾 0)。 */ +int t_actuator_format(char *buf, size_t len); + +#ifdef __cplusplus +} +#endif + +#endif /* T_ACTUATOR_H */ diff --git a/firmware/test/test_cmd.c b/firmware/test/test_cmd.c index 79af22b..0d9518e 100644 --- a/firmware/test/test_cmd.c +++ b/firmware/test/test_cmd.c @@ -645,7 +645,15 @@ static void test_status_fits_and_reports_unknowns_as_null(void) const cmd_env_t env = { .safety = &s, .thermal = &t }; cmd_init(&env); + /* 上游 VBUS:没配过的脚读不到 ⇒ null;配成输入后如实报高低。 */ + ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"host\":null"); + TEST_ASSERT_EQUAL(HAL_OK, hal_gpio_config(BOARD_PIN_UP_VBUS_DET, HAL_GPIO_INPUT)); + sim_gpio_drive(BOARD_PIN_UP_VBUS_DET, 0); + ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"host\":false"); + sim_gpio_drive(BOARD_PIN_UP_VBUS_DET, 1); /* 留着高电平:下面量满配长度 */ + const char *r = send("{\"seq\":4000000000,\"cmd\":\"status\"}"); + ASSERT_HAS(r, "\"host\":true"); ASSERT_HAS(r, "{\"seq\":4000000000,\"ok\":true,\"fw\":\"" PHONERAIL_FW_VERSION "\""); ASSERT_HAS(r, "\"in\":{\"mv\":12030,\"ua\":1234567}"); ASSERT_HAS(r, "{\"r\":2,\"pr\":true,\"en\":false,\"vb\":false,\"set\":3900,\"mv\":null,\"ua\":null"); diff --git a/firmware/test/test_evlog.c b/firmware/test/test_evlog.c index dd7d874..003cf18 100644 --- a/firmware/test/test_evlog.c +++ b/firmware/test/test_evlog.c @@ -86,7 +86,8 @@ static void test_flash_format_is_frozen(void) TEST_ASSERT_EQUAL_INT(16, LOG_EV_PD_LIMIT); TEST_ASSERT_EQUAL_INT(17, LOG_EV_PG_TIMEOUT); /* A15 */ TEST_ASSERT_EQUAL_INT(18, LOG_EV_OTA); /* C2 */ - TEST_ASSERT_EQUAL_INT(19, LOG_EV_COUNT); + TEST_ASSERT_EQUAL_INT(19, LOG_EV_BOOT_CHECK); /* 0.1.2 开机校验 */ + TEST_ASSERT_EQUAL_INT(20, LOG_EV_COUNT); } /* §10.7 的「必记事件」有八类,A10 只产生其中两类。剩下的号先占住, diff --git a/firmware/test/test_t_actuator.c b/firmware/test/test_t_actuator.c index 328b77f..7216965 100644 --- a/firmware/test/test_t_actuator.c +++ b/firmware/test/test_t_actuator.c @@ -1096,6 +1096,250 @@ static void test_format_prints_every_rail(void) TEST_ASSERT_EQUAL_CHAR('\0', small[sizeof(small) - 1]); } +/* ==== 0.1.2:硬重启的开机校验与重按 ======================================= + 2026-09-28 板 B 第 1 路:电源键弹片压刀片铜盘接触不良,7 次硬重启只开起来 1 次, + 失败时电流一直 1~2 mA(屏都不亮)。假手机照实测建模: + · 第 `boots_on` 次按满 5 s 松手后开机 ⇒ 电流 ~210 mA(logo 段); + · 其余情况 1.5 mA(关机态); + · `half_on`:第一次按下去 PMIC 醒了、SoC 没接住 —— 松手后 415 mA 撑 2.5 s 再掉回去 + (照「按 2 s」那次的实测形态),校验不能被它骗过去。 */ +static bool s_ua_readable; +static int s_phone_boots_on; /* 第几次按能开;0 = 永远开不了(弹片没压上) */ +static bool s_phone_half_on; +static bool s_phone_on; +static uint32_t s_phone_half_until; +static int s_edges; /* 看到的「按下」次数 */ +static int s_last_pin; +static uint32_t s_press_t, s_release_t, s_min_gap; + +static int s_boot_calls; +static bool s_boot_booted; +static uint8_t s_boot_presses; + +static bool fake_ua(void *ctx, int rail, int32_t *ua) +{ + (void)ctx; + (void)rail; + if (!s_ua_readable) { + return false; + } + if (s_phone_on) { + *ua = 210000; + } else if ((int32_t)(s_phone_half_until - hal_time_now_ms()) > 0) { + *ua = 415000; + } else { + *ua = 1500; + } + return true; +} + +static void fake_boot_done(void *ctx, int rail, bool booted, uint8_t presses, int32_t ua) +{ + (void)ctx; + (void)rail; + (void)ua; + s_boot_calls++; + s_boot_booted = booted; + s_boot_presses = presses; +} + +static void phone_up(int boots_on, bool half_on) +{ + s_ua_readable = true; + s_phone_boots_on = boots_on; + s_phone_half_on = half_on; + s_phone_on = false; + s_phone_half_until = hal_time_now_ms(); + s_edges = 0; + s_last_pin = 0; + s_press_t = s_release_t = 0; + s_min_gap = UINT32_MAX; + s_boot_calls = 0; + t_actuator_set_boot_check(fake_ua, fake_boot_done, NULL); +} + +/* 看电源键那根栅极的边沿,推假手机的状态。 */ +static void phone_watch(int rail) +{ + const int p = key_pin(rail, PCA9555_KEY_PWR); + const uint32_t now = hal_time_now_ms(); + if (p && !s_last_pin) { + s_edges++; + if (s_release_t != 0 && now - s_release_t < s_min_gap) { + s_min_gap = now - s_release_t; + } + s_press_t = now; + } + if (!p && s_last_pin) { + s_release_t = now; + if (now - s_press_t >= ACT_KEY_MS_ON) { + if (s_edges == s_phone_boots_on) { + s_phone_on = true; + } else if (s_phone_half_on && s_edges == 1) { + s_phone_half_until = now + 2500; + } + } + } + s_last_pin = p; +} + +/* `run_ms()` 加上每一步之后看一眼按键。 */ +static void run_phone_ms(int rail, uint32_t ms) +{ + const uint32_t target = hal_time_now_ms() + ms; + for (;;) { + uint32_t w = t_actuator_step(); + phone_watch(rail); + const uint32_t now = hal_time_now_ms(); + if ((int32_t)(now - target) >= 0) { + return; + } + const uint32_t left = target - now; + if (w == 0 || w > left) { + w = left; + } + sim_time_advance_ms(w); + } +} + +/* 一轮按键 + 校验最长多久:按 5 s + 松键后判到窗口末尾。 */ +#define ONE_TRY_MS (ACT_HR_BOOT_KEY_MS + ACT_HR_VERIFY_DELAY_MS + ACT_HR_VERIFY_WINDOW_MS + 50) +#define HR_TO_PRESS_MS (ACT_HR_OFF_MS + ACT_HR_RAMP_MS + ACT_HR_PG_POLL_MS) + +static void test_boot_check_first_press_boots(void) +{ + const int rail = 1; + s_pg_val[rail - 1] = true; + phone_up(1, false); + + const uint32_t t0 = hal_time_now_ms(); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_phone_ms(rail, HR_TO_PRESS_MS + ACT_HR_BOOT_KEY_MS + 10); + TEST_ASSERT_EQUAL_INT(ACT_ST_HR_VERIFY, t_actuator_state(rail)); + TEST_ASSERT_EQUAL_INT(0, key_pin(rail, PCA9555_KEY_PWR)); /* 校验期间键是松开的 */ + + run_phone_ms(rail, ONE_TRY_MS); + TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail)); + TEST_ASSERT_EQUAL_INT(1, s_edges); + TEST_ASSERT_EQUAL_INT(1, s_boot_calls); + TEST_ASSERT_TRUE(s_boot_booted); + TEST_ASSERT_EQUAL_UINT8(1, s_boot_presses); + /* 开起来就收工:不把整个窗口等满。 */ + TEST_ASSERT_TRUE(hal_time_now_ms() - t0 >= HR_TO_PRESS_MS + ACT_HR_BOOT_KEY_MS + ACT_HR_VERIFY_DELAY_MS); + TEST_ASSERT_EQUAL_UINT32(1, t_actuator_done(rail)); + TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); +} + +/* ⭐ 这次的主症状:第一下没压上,第二下开了。 */ +static void test_boot_check_presses_again_when_the_phone_stays_off(void) +{ + const int rail = 3; + s_pg_val[rail - 1] = true; + phone_up(2, false); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_phone_ms(rail, HR_TO_PRESS_MS + 2 * ONE_TRY_MS + 1000); + + TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail)); + TEST_ASSERT_EQUAL_INT(2, s_edges); + TEST_ASSERT_TRUE(s_boot_booted); + TEST_ASSERT_EQUAL_UINT8(2, s_boot_presses); + TEST_ASSERT_EQUAL_INT(1, s_boot_calls); /* 只在有结论时报一次 */ + /* 两次按之间至少隔开整段判定延迟 —— 连着按等于一次 10 s 长按(强制重启档)。 */ + TEST_ASSERT_TRUE(s_min_gap >= ACT_HR_VERIFY_DELAY_MS); + TEST_ASSERT_EQUAL_INT(0, key_pin(rail, PCA9555_KEY_PWR)); +} + +/* 按满三次都不开:记 NO_BOOT,**电继续开着**,键松开,不再按第四下。 */ +static void test_boot_check_gives_up_after_three_presses_and_keeps_power(void) +{ + const int rail = 2; + s_pg_val[rail - 1] = true; + phone_up(0, false); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_phone_ms(rail, HR_TO_PRESS_MS + ACT_HR_BOOT_TRIES * ONE_TRY_MS + 1000); + + TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_INT(ACT_RES_NO_BOOT, t_actuator_last_result(rail)); + TEST_ASSERT_EQUAL_INT(ACT_HR_BOOT_TRIES, s_edges); + TEST_ASSERT_EQUAL_INT(1, s_boot_calls); + TEST_ASSERT_FALSE(s_boot_booted); + TEST_ASSERT_EQUAL_UINT8(ACT_HR_BOOT_TRIES, s_boot_presses); + TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(rail_cfg(rail)->efuse_en)); + TEST_ASSERT_EQUAL_INT(0, key_pin(rail, PCA9555_KEY_PWR)); + TEST_ASSERT_EQUAL_STRING("no-boot", t_actuator_result_str(ACT_RES_NO_BOOT)); + + run_phone_ms(rail, 30000); /* 收工之后再也不按 */ + TEST_ASSERT_EQUAL_INT(ACT_HR_BOOT_TRIES, s_edges); +} + +/* 读不到电流 ⇒ 判不了 ⇒ **不重按**,也不报结论。 */ +static void test_boot_check_does_not_press_again_when_current_is_unreadable(void) +{ + const int rail = 4; + s_pg_val[rail - 1] = true; + phone_up(0, false); + s_ua_readable = false; + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_phone_ms(rail, HR_TO_PRESS_MS + ACT_HR_BOOT_TRIES * ONE_TRY_MS + 1000); + + TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail)); + TEST_ASSERT_EQUAL_INT(1, s_edges); + TEST_ASSERT_EQUAL_INT(0, s_boot_calls); +} + +/* 「PMIC 醒了、SoC 没接住」:松手后先 415 mA 再掉回去。校验必须等它掉完再看, + 判成「开了」就不会有第二下。 */ +static void test_boot_check_is_not_fooled_by_a_pmic_that_wakes_and_drops(void) +{ + const int rail = 5; + s_pg_val[rail - 1] = true; + phone_up(2, true); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_phone_ms(rail, HR_TO_PRESS_MS + 2 * ONE_TRY_MS + 1000); + + TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail)); + TEST_ASSERT_EQUAL_INT(2, s_edges); + TEST_ASSERT_EQUAL_UINT8(2, s_boot_presses); +} + +/* 校验期间被打断(比如判据要关这一路):收工、不再按。 */ +static void test_abort_during_boot_check_stops_pressing(void) +{ + const int rail = 1; + s_pg_val[rail - 1] = true; + phone_up(0, false); + + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_hard_reset(rail)); + run_phone_ms(rail, HR_TO_PRESS_MS + ACT_HR_BOOT_KEY_MS + 500); + TEST_ASSERT_EQUAL_INT(ACT_ST_HR_VERIFY, t_actuator_state(rail)); + + t_actuator_abort(rail); + TEST_ASSERT_FALSE(t_actuator_busy(rail)); + TEST_ASSERT_EQUAL_INT(ACT_RES_ABORTED, t_actuator_last_result(rail)); + run_phone_ms(rail, 3 * ONE_TRY_MS); + TEST_ASSERT_EQUAL_INT(1, s_edges); + TEST_ASSERT_EQUAL_INT(0, s_boot_calls); +} + +/* 单独的按键命令不校验:那是人按的,按什么、按多久是人的意图。 */ +static void test_plain_key_command_is_never_repeated(void) +{ + const int rail = 2; + phone_up(0, false); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_ON)); + run_phone_ms(rail, ACT_KEY_MS_ON + 3 * ONE_TRY_MS); + TEST_ASSERT_EQUAL_INT(1, s_edges); + TEST_ASSERT_EQUAL_INT(ACT_RES_OK, t_actuator_last_result(rail)); + TEST_ASSERT_EQUAL_INT(0, s_boot_calls); +} + int main(void) { UNITY_BEGIN(); @@ -1130,6 +1374,14 @@ int main(void) RUN_TEST(test_actuator_and_safety_share_one_lock_without_starving); + RUN_TEST(test_boot_check_first_press_boots); + RUN_TEST(test_boot_check_presses_again_when_the_phone_stays_off); + RUN_TEST(test_boot_check_gives_up_after_three_presses_and_keeps_power); + RUN_TEST(test_boot_check_does_not_press_again_when_current_is_unreadable); + RUN_TEST(test_boot_check_is_not_fooled_by_a_pmic_that_wakes_and_drops); + RUN_TEST(test_abort_during_boot_check_stops_pressing); + RUN_TEST(test_plain_key_command_is_never_repeated); + RUN_TEST(test_bad_arguments_are_rejected); RUN_TEST(test_timings_match_the_documents); RUN_TEST(test_the_lock_discipline_check_actually_fires); diff --git a/rack/frontend/src/pages/Board.vue b/rack/frontend/src/pages/Board.vue index 57a8e0a..912d60c 100644 --- a/rack/frontend/src/pages/Board.vue +++ b/rack/frontend/src/pages/Board.vue @@ -13,6 +13,7 @@
ID
{{ b.id }}
固件
{{ b.status?.fw || b.fw }} 推固件
IP
{{ b.ip || '—' }}
+
上游
{{ HOST[b.status?.host] ?? '—' }}
@@ -92,6 +93,8 @@ const pw = computed(() => { const rails = railsMw(s?.rails) return { rails, loss: input == null || rails == null ? null : input - rails } }) +// 固件 0.1.3 起才有 host 字段;老固件没有 ⇒ 显示「—」 +const HOST = { true: '已连电脑', false: '未连电脑' } const fmtW = v => (v == null ? '—' : (v / 1000).toFixed(2)) const sel = ref(1) const name = ref('')