判据全在 t_safety_step() 里、**放锁之后**、直接写引脚(§10.3 原话:不经任何 队列或抽象层): SR-5 |实测 − 该路设定值| > 100 mV **连着两圈** → 先断 VBUS 再关 eFuse SW-8 I < −50 mA,或 VBUS 期间电压被顶高 > +100 mV → **一圈就断 VBUS** 七条实现约定(详见需求文档 §10.2): · 「持续 20 ms」数的是圈,不是墙钟毫秒; · 反灌不设确认窗口,与过压那 20 ms 的不对称是有意的 —— 误断 VBUS 只丢一次 USB 数据,漏判会把能量泵回 12V 母线连累另外四台(设计文档 §15.2); · 判据只在「该路该有电 + 这一圈的值新鲜 + 过了 60 ms 缓启动窗口」时武装 (对应 §10.10 的「盲跑 → 保持供电,禁用该路判据」); · 比的是该路 DAC 设定值的影子,不是 3900 这个常数(SW-4 要单路微调); · SR-5 关路关干净(不留「只靠 USB 供着电」的窗口),SW-8 只断 VBUS 不碰 eFuse; · 断电之前先 t_actuator_abort(),断 VBUS 时只打断 EDL(只有它写 VBUS_EN); · 动作在前、记录在后,事件走回调出去,A11 在那儿接环形缓冲。 判据一条 I²C 事务都不发(设定值、两根 EN、正在跑的序列全部走影子), §10.3 那 7.8 % 的预算不变。顺带新增两处影子:rail_efuse_is_on()/rail_vbus_is_on() 与 rail_setpoint_mv(),都**只在写成功时更新**,两个方向都倒向安全的那一侧。 29 个用例;25 条注错逐个跑过,24 条变红。第一版把「持续 20 ms」按墙钟算, 测试一半红一半绿 —— 巡检的采样时刻带亚毫秒残差(一圈占 1.56 ms 总线, 而睡到下一拍按整毫秒算),相邻两次判据的时刻是 21/19 ms 交替,19 ms 那一半 要拖到第三圈 = 40 ms,超验收且间歇性。改成数圈之后一次不飘, 并留了一条跑 20 轮、两种相位都踩得到的回归拦网。 Co-Authored-By: Claude Opus 5 <[email protected]>
280 行
11 KiB
C
280 行
11 KiB
C
#include "t_safety.h"
|
||
|
||
#include <assert.h>
|
||
#include <stdio.h>
|
||
#include <string.h>
|
||
|
||
#include "hal_i2c_acct.h"
|
||
#include "hal_lock.h"
|
||
#include "hal_time.h"
|
||
#include "rail.h"
|
||
#include "rail_cfg.h"
|
||
#include "t_actuator.h"
|
||
#include "tasks.h"
|
||
|
||
/* ---- A10:两条判据 --------------------------------------------------------
|
||
|
||
§10.3 原话:「越界**直接**操作 `EFUSE_EN` / `VBUS_EN`(GPIO 直连,不经任何
|
||
队列或抽象层)」。所以下面这几个函数直接调 `rail_*_enable()`,不发消息给
|
||
t_actuator —— 发消息就等于把「关电」排在了一个中优先级任务的队列后面。
|
||
|
||
四条不能改的规矩:
|
||
|
||
1. **判据只在放锁之后跑。** 两根 EN 线是 GPIO,和 I²C 没有关系;拿着总线锁
|
||
去写引脚只会把别的任务多挡一会儿。而且 `t_actuator_abort()` 会去拿 595
|
||
那把锁,拿着 I²C 锁进去就是嵌套(`hal_lock_take()` 会直接判 HAL_ERR_STATE)。
|
||
|
||
2. **先 `t_actuator_abort()`,再断电。** 反过来有个真实的洞:硬重启的
|
||
`HR_OFF` 状态会在 4 s 后自己把 `EFUSE_EN` 抬回去,于是「关掉的路」几秒钟
|
||
之后又带电了。abort 之后那一路只会走收尾(只碰 595),再也够不到 EN 线。
|
||
代价是 abort 最坏会等 595 那把锁 20 ms(`ACT_LOCK_TIMEOUT_MS`),也就是
|
||
巡检可能丢一拍 —— 认了:SR-5 原文写着它「仅承担记录与告警,不承担实时保护」,
|
||
真正的过压保护是 eFuse 里的硬件 OVP,不靠这条路径的延迟。
|
||
|
||
3. **判据只在「这一路该有电」而且「这一圈的值是新鲜的」时武装。**
|
||
关着的路电压本来就是 0,不设这一条的话每一圈都会得出「偏低 3900 mV」;
|
||
而器件失联那一路的值是上一圈的,§10.10 明写着「保持供电,禁用该路的
|
||
SR-5 / SW-8 判据并持续告警」。
|
||
|
||
4. **一条判据也不许发 I²C 事务。** 判据要的三个量(设定值、两根 EN 的状态、
|
||
正在跑的序列)全部有影子可查。多读一次就把 §10.3 那 7.8 % 的预算顶穿,
|
||
`test_one_cycle_is_exactly_thirteen_transactions()` 会当场变红。 */
|
||
|
||
static safety_ev_fn s_sink;
|
||
static void *s_sink_ctx;
|
||
|
||
void t_safety_set_event_sink(safety_ev_fn fn, void *ctx)
|
||
{
|
||
s_sink = fn;
|
||
s_sink_ctx = ctx;
|
||
}
|
||
|
||
const char *t_safety_ev_str(safety_ev_t ev)
|
||
{
|
||
switch (ev) {
|
||
case SAFETY_EV_NONE:
|
||
return "无";
|
||
case SAFETY_EV_OVER_VOLT:
|
||
return "过压关路(SR-5)";
|
||
case SAFETY_EV_UNDER_VOLT:
|
||
return "欠压关路(SR-5)";
|
||
case SAFETY_EV_REVERSE:
|
||
return "反灌断VBUS(SW-8)";
|
||
case SAFETY_EV_PUSHED_UP:
|
||
return "被顶高断VBUS(SW-8)";
|
||
default:
|
||
return "?";
|
||
}
|
||
}
|
||
|
||
/* 记一条。**永远在动作之后调** —— 记录慢一步没关系,断电慢一步不行。 */
|
||
static void emit(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
|
||
{
|
||
const ina226_vi_t *vi = &s->rail[rail - 1];
|
||
|
||
s->last.t_ms = now;
|
||
s->last.rail = (uint8_t)rail;
|
||
s->last.type = (uint8_t)ev;
|
||
s->last.bus_mv = vi->bus_mv;
|
||
s->last.current_ua = vi->current_ua;
|
||
s->events++;
|
||
|
||
if (s_sink != NULL) {
|
||
s_sink(s_sink_ctx, &s->last); /* TODO(A11):这里接环形缓冲 */
|
||
}
|
||
}
|
||
|
||
/* SR-5:关这一路。**先断 VBUS 再关 eFuse** —— 只关 eFuse 的话,插着 USB 的那几路
|
||
会停在「手机只靠 VBUS 供着电」,那既不是开也不是关(`rail_all_off()` 里同一条
|
||
理由)。两个都要试,不因为第一个失败就不关第二个。 */
|
||
static void trip_rail(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
|
||
{
|
||
t_actuator_abort(rail); /* 规矩 2:必须在断电之前 */
|
||
(void)rail_vbus_enable(rail, false);
|
||
(void)rail_efuse_enable(rail, false);
|
||
emit(s, rail, ev, now);
|
||
}
|
||
|
||
/* SW-8:只断这一路的 VBUS,**不碰 eFuse** —— 反灌的能量是从 USB 那一侧来的,
|
||
把直供也一起关掉等于「因为充电器有问题就把手机关机」,而 §10.2 的整节都在讲
|
||
「范围可控的时候只动出事的那一处」。 */
|
||
static void cut_vbus(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
|
||
{
|
||
/* 三个序列里只有 EDL 会去写 `VBUS_EN`(`EDL_EN=1 → VBUS_EN=1 → 等枚举`)。
|
||
它正跑着的话必须打断,否则下一拍它就把 VBUS 又抬回来了。
|
||
硬重启 / 按键不碰 VBUS,没有理由为一次反灌把它们打断。 */
|
||
if (t_actuator_seq(rail) == ACT_SEQ_EDL) {
|
||
t_actuator_abort(rail);
|
||
}
|
||
(void)rail_vbus_enable(rail, false);
|
||
emit(s, rail, ev, now);
|
||
}
|
||
|
||
static void check_rail(safety_snap_t *s, int rail, uint32_t now)
|
||
{
|
||
safety_rail_t *st = &s->st[rail - 1];
|
||
const bool en = rail_efuse_is_on(rail);
|
||
|
||
/* `EFUSE_EN` 的上升沿:缓启动窗口从这一刻算起。 */
|
||
if (en && !st->en_on) {
|
||
st->en_ms = now;
|
||
}
|
||
st->en_on = en;
|
||
|
||
/* 规矩 3 的三道闸。任何一道不过就把圈数清零 —— 「关着的那几秒」
|
||
不能算进「持续 20 ms」里,不然一开机就跳闸。
|
||
(缓启动窗口是真按毫秒算的:它对应的是 tON + 斜坡那段物理时间,
|
||
不像「持续 20 ms」那样是在数样本。) */
|
||
if (!en || !s->rail_ok[rail - 1] || (now - st->en_ms) < T_SAFETY_SETTLE_MS) {
|
||
st->dev_n = 0;
|
||
return;
|
||
}
|
||
|
||
const int32_t mv = s->rail[rail - 1].bus_mv;
|
||
const int32_t ua = s->rail[rail - 1].current_ua;
|
||
const int32_t dev = mv - (int32_t)rail_setpoint_mv(rail);
|
||
|
||
/* ---- SW-8(先跑):只在 VBUS 通着的时候武装 -------------------------
|
||
反灌的能量只可能从 VBUS 那一侧来,断开之后判据自然就解除武装了 ——
|
||
这也是「关掉之后不会每 20 ms 再刷一条事件」的机制。
|
||
先跑它是因为过压这一种情况两条判据都成立:该先撤掉能量来源(VBUS),
|
||
20 ms 后 SR-5 再决定要不要连这一路一起关掉。 */
|
||
if (rail_vbus_is_on(rail)) {
|
||
if (ua < T_SAFETY_REV_UA) {
|
||
cut_vbus(s, rail, SAFETY_EV_REVERSE, now);
|
||
} else if (dev > T_SAFETY_DEV_MV) {
|
||
cut_vbus(s, rail, SAFETY_EV_PUSHED_UP, now);
|
||
}
|
||
}
|
||
|
||
/* ---- SR-5:±100 mV 连着 T_SAFETY_DEV_HOLD_CYCLES 圈(= 持续 20 ms)---- */
|
||
if (dev > T_SAFETY_DEV_MV || dev < -T_SAFETY_DEV_MV) {
|
||
if (st->dev_n < 0xFF) {
|
||
st->dev_n++;
|
||
}
|
||
if (st->dev_n >= T_SAFETY_DEV_HOLD_CYCLES) {
|
||
trip_rail(s, rail, dev > 0 ? SAFETY_EV_OVER_VOLT : SAFETY_EV_UNDER_VOLT, now);
|
||
/* ⚠️ 这里**故意不**把 `dev_n` 清零。关掉之后下一圈 `en` 就是 false,
|
||
上面那道闸自己会清;而万一那次写引脚失败了(影子留在 on),
|
||
下一圈 `dev_n` 已经够数,会**立刻再关一次** —— 清零的话得再等两圈。
|
||
关电路径上「重试快一点」永远是对的那一边。 */
|
||
}
|
||
} else {
|
||
st->dev_n = 0; /* 「持续」的意思是连续,中间回到范围内就从头数 */
|
||
}
|
||
}
|
||
|
||
void t_safety_init(safety_snap_t *s)
|
||
{
|
||
assert(s != NULL);
|
||
memset(s, 0, sizeof(*s));
|
||
/* 游标对齐到「现在」,否则第一圈会把启动期间(器件初始化)的事务算进来。 */
|
||
s->bus_cursor = hal_i2c_acct_bits();
|
||
}
|
||
|
||
hal_err_t t_safety_step(safety_snap_t *s)
|
||
{
|
||
assert(s != NULL);
|
||
s->cycles++;
|
||
|
||
hal_err_t err = hal_lock_take(HAL_LOCK_I2C, T_SAFETY_LOCK_TIMEOUT_MS);
|
||
if (err != HAL_OK) {
|
||
/* 规矩 1:不死等。这一圈作废,20 ms 后重来。 */
|
||
s->skipped++;
|
||
s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor);
|
||
return err;
|
||
}
|
||
|
||
hal_err_t first = HAL_OK;
|
||
|
||
/* 五路 —— 地址查表,**不许算**(rail_cfg.h 那条禁令)。 */
|
||
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
|
||
ina226_vi_t vi;
|
||
const hal_err_t e = ina226_read_vi(rail_cfg(rail)->ina_addr, &vi);
|
||
s->rail_ok[rail - 1] = (e == HAL_OK);
|
||
if (e == HAL_OK) {
|
||
s->rail[rail - 1] = vi; /* 规矩 2:失败就留着上一圈的值,别清零 */
|
||
} else {
|
||
s->i2c_errors++;
|
||
if (first == HAL_OK) {
|
||
first = e;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 第六片:输入侧。 */
|
||
{
|
||
ina226_vi_t vi;
|
||
const hal_err_t e = ina226_read_vi(INA226_ADDR_INPUT, &vi);
|
||
s->input_ok = (e == HAL_OK);
|
||
if (e == HAL_OK) {
|
||
s->input = vi;
|
||
} else {
|
||
s->i2c_errors++;
|
||
if (first == HAL_OK) {
|
||
first = e;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* PCA9555 的两个输入端口一次事务读完(自增指针)。 */
|
||
{
|
||
pca9555_in_t in;
|
||
const hal_err_t e = pca9555_read_inputs(&in);
|
||
s->io_ok = (e == HAL_OK);
|
||
if (e == HAL_OK) {
|
||
s->io = in;
|
||
} else {
|
||
s->i2c_errors++;
|
||
if (first == HAL_OK) {
|
||
first = e;
|
||
}
|
||
}
|
||
}
|
||
|
||
hal_lock_give(HAL_LOCK_I2C);
|
||
|
||
/* 判据:放锁之后跑,直接写 EFUSE_EN / VBUS_EN(上面那四条规矩)。
|
||
`now` 只取一次 —— 五路要拿同一个时刻去算「持续了多久」。 */
|
||
{
|
||
const uint32_t now = hal_time_now_ms();
|
||
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
|
||
check_rail(s, rail, now);
|
||
}
|
||
}
|
||
|
||
s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor);
|
||
return first;
|
||
}
|
||
|
||
uint32_t t_safety_budget_us(void)
|
||
{
|
||
/* 上面那一圈真正发出去的事务,逐条数:
|
||
6 片 INA226 × 2 次寄存器读(母线电压 + 电流)
|
||
1 次 PCA9555 读(一次事务读回两个输入端口)
|
||
每次都是「写 1 字节寄存器号 + 读 2 字节」的形状。 */
|
||
const uint32_t txns = INA226_COUNT * 2u + 1u;
|
||
return (uint32_t)hal_i2c_bits_to_us((uint64_t)txns * hal_i2c_txn_bits(1, 2));
|
||
}
|
||
|
||
uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms)
|
||
{
|
||
if (period_ms == 0) {
|
||
return 0;
|
||
}
|
||
return (uint32_t)(((uint64_t)bus_us * 1000ull) / ((uint64_t)period_ms * 1000ull));
|
||
}
|
||
|
||
int t_safety_format(const safety_snap_t *s, char *buf, size_t len)
|
||
{
|
||
assert(s != NULL && buf != NULL && len > 0);
|
||
const uint32_t period = task_desc(TASK_SAFETY)->period_ms;
|
||
const uint32_t pm = t_safety_load_permille(s->bus_us, period);
|
||
return snprintf(buf, len,
|
||
"t_safety: cycles=%u skipped=%u i2c_err=%u bus=%u us/%u ms load=%u.%u%% "
|
||
"events=%u",
|
||
(unsigned)s->cycles, (unsigned)s->skipped, (unsigned)s->i2c_errors,
|
||
(unsigned)s->bus_us, (unsigned)period, (unsigned)(pm / 10u),
|
||
(unsigned)(pm % 10u), (unsigned)s->events);
|
||
}
|