固件 0.1.3-c5:硬重启开机校验 + 重按(0.1.2)、status 上报上游电脑在不在(host)

- 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 <[email protected]>
这个提交包含在:
zhou yang
2026-09-28 08:59:12 +08:00
共同撰写人 Claude Opus 5.5
父节点 830b4cdff8
当前提交 ef91daf07d
共修改 13 个文件,包含 834 行新增和 366 行删除
+25
查看文件
@@ -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)。
+6
查看文件
@@ -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 = {
+10
查看文件
@@ -6,8 +6,10 @@
上位机的卡死判据(电流曲线)拿 0 当真值会直接误判。 */
#include <stdio.h>
#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,
+1
查看文件
@@ -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 "?";
+7
查看文件
@@ -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;
+1 -1
查看文件
@@ -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
+112 -16
查看文件
@@ -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 "?";
}
+263 -225
查看文件
@@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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 */