文件
zhou yang和Claude Opus 5.5 ece65f7c19 固件 0.1.19~0.1.21-c5:配网热点(SoftAP + 网页)、风扇 40 ℃ 以下断电
- 0.1.19 配网热点:没配 WiFi / 连续 60 s 连不上时自己开 SoftAP,192.168.4.1 网页里加删 WiFi、改 MQTT;
  配置拼成 wifi / mqtt 命令走唯一的命令处理器(新链路号 3 CMD_LINK_AP),MQTT 仍不收配置命令
- 0.1.20 热点口令固定 88888888
- 0.1.21 风扇:板温 >=40 ℃ 上电、<38 ℃ 断电,40~65 ℃ 线性调速;fan 命令加可选 sec(下限到点自动交回)
- mqtt 命令加 keep_pass;net 回应加 ap;status 的 fan 加 left
- 新增 test_prov(13 例),test_t_thermal +3;29 ctest 全绿

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 14:35:44 +08:00

927 行
42 KiB
C

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/* A12:单一命令处理器(需求 §10.8,计划 §6.3)。验收原话三条:
① 同序号发两遍只执行一次;
② 越界命令被拒且记事件;
③ 空槽(`PRSNT#` 高)的命令一律拒。
和 A9 的测试同一个口径:断言一律打在**线上真实发生的事**上 ——
假 PCA9555 的锁存器与写事务计数、DAC 寄存器、`EFUSE_EN` 引脚电平、落盘的事件 ——
不看处理器自己回了什么。「回了 ok」和「真的按了一次」是两件事,
判重写错的现象恰恰是回应完全正常、手机被按了两遍。 */
#include <stdio.h>
#include <string.h>
#include "board_pins.h"
#include "ch224a.h"
#include "chgmode.h"
#include "cmd.h"
#include "dac63204.h"
#include "evlog.h"
#include "hal_i2c.h"
#include "hubctl.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "i2cbus.h"
#include "pca9555.h"
#include "pdmon.h"
#include "phonerail.h"
#include "rail.h"
#include "rail_cfg.h"
#include "sim_hal.h"
#include "slot.h"
#include "t_actuator.h"
#include "t_thermal.h"
#include "tca9548a.h"
#include "unity.h"
#define N PHONERAIL_RAIL_COUNT
static char s_out[CMD_OUT_MAX];
static const char *send_on(cmd_link_t link, const char *line)
{
const size_t n = cmd_handle_line(link, line, s_out, sizeof(s_out));
TEST_ASSERT_EQUAL_size_t(strlen(s_out), n);
return s_out;
}
static const char *send(const char *line) { return send_on(CMD_LINK_TEST, line); }
/* ⚠️ 写成函数不写成宏:宏会把 `send(...)` 展开两次(条件一次、失败消息一次),
断言失败时打印出来的是**又发了一遍**的那条回应 —— 正好是判重命中的那一条,
排查时会被它带偏。 */
static void assert_has_at(const char *resp, const char *needle, int line)
{
UNITY_TEST_ASSERT_NOT_NULL(strstr(resp, needle), line, resp);
}
static void assert_lacks_at(const char *resp, const char *needle, int line)
{
UNITY_TEST_ASSERT_NULL(strstr(resp, needle), line, resp);
}
#define ASSERT_HAS(resp, needle) assert_has_at((resp), (needle), __LINE__)
#define ASSERT_LACKS(resp, needle) assert_lacks_at((resp), (needle), __LINE__)
/* ---- 假板子:mux + 五片刀片 PCA9555 + 主干两片 DAC ------------------------ */
static sim_i2c_dev_t *blade(int rail)
{
sim_i2c_dev_t *d = sim_i2c_dev_ch(rail_cfg(rail)->mux_ch, PCA9555_ADDR);
TEST_ASSERT_NOT_NULL(d);
return d;
}
static uint8_t latch(int rail) { return sim_pca9555_output(rail - 1, 0); }
static uint32_t writes(int rail) { return blade(rail)->n_write; }
static int en_pin(int rail) { return sim_gpio_level(rail_cfg(rail)->efuse_en); }
static uint16_t dac_code(int rail)
{
const uint16_t reg = sim_i2c_get_reg(dac63204_rail_addr(rail),
DAC63204_REG_DATA(dac63204_rail_ch(rail)));
return DAC63204_REG_TO_CODE(reg);
}
static void set_present(int rail, bool present)
{
sim_gpio_drive(rail_cfg(rail)->prsnt_n, present ? 0 : 1);
slot_prsnt_poll();
}
void setUp(void)
{
sim_hal_reset();
const hal_i2c_cfg_t cfg = { .sda_gpio = BOARD_PIN_I2C_SDA,
.scl_gpio = BOARD_PIN_I2C_SCL,
.freq_hz = 400000 };
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init());
sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N);
for (int rail = 1; rail <= N; rail++) {
sim_pca9555_attach_ch(rail - 1, rail_cfg(rail)->mux_ch, PCA9555_ADDR);
}
for (int i = 0; i < DAC63204_COUNT; i++) {
sim_i2c_add(dac63204_all_addr[i], 2);
}
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init());
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init());
for (int rail = 1; rail <= N; rail++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail));
}
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_prsnt_init());
for (int rail = 1; rail <= N; rail++) {
set_present(rail, true);
}
rail_setpoint_reset();
t_thermal_set_floor(0);
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
t_actuator_init(NULL, NULL);
chgmode_reset();
cmd_init(NULL);
/* 启动期独占,之后任何一次 I²C 事务没持锁就记违规 —— `set_voltage` 那一笔
主干写就靠这一条抓(`rail_set_mv()` 自己不拿锁)。 */
sim_lock_require(HAL_LOCK_I2C, true);
}
void tearDown(void)
{
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, sim_lock_violations(HAL_LOCK_I2C), "有 I²C 事务没持锁");
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* 虚拟时钟往前推,期间按执行器自己要的节奏叫它(同 test_t_actuator.c)。 */
static void run_ms(uint32_t ms)
{
const uint32_t target = hal_time_now_ms() + ms;
for (;;) {
uint32_t w = t_actuator_step();
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);
}
}
/* 把 flash 里的事件全拉出来,找第 k 条类型为 type 的。 */
static bool find_event(log_ev_t type, int k, evlog_rec_t *out)
{
evlog_rec_t rec[64];
(void)evlog_flush(0);
const uint32_t n = evlog_read(0, rec, 64);
for (uint32_t i = 0; i < n; i++) {
if (rec[i].type == (uint8_t)type && k-- == 0) {
*out = rec[i];
return true;
}
}
return false;
}
static int count_events(log_ev_t type)
{
evlog_rec_t r;
int k = 0;
while (find_event(type, k, &r)) {
k++;
}
return k;
}
/* ---- 冻结的编号 ---------------------------------------------------------- */
/* 命令号与拒绝原因会写进 flash(CMD_REJECT 的 arg / current_ua),和 log_ev_t 一样冻结。 */
static void test_cmd_numbers_are_frozen(void)
{
TEST_ASSERT_EQUAL_INT(1, CMD_STATUS);
TEST_ASSERT_EQUAL_INT(2, CMD_SET_VOLTAGE);
TEST_ASSERT_EQUAL_INT(3, CMD_POWER_CYCLE);
TEST_ASSERT_EQUAL_INT(4, CMD_KEY);
TEST_ASSERT_EQUAL_INT(5, CMD_EDL);
TEST_ASSERT_EQUAL_INT(6, CMD_VBUS);
TEST_ASSERT_EQUAL_INT(7, CMD_FAN);
TEST_ASSERT_EQUAL_INT(8, CMD_EVENTS);
TEST_ASSERT_EQUAL_INT(9, CMD_POWER);
TEST_ASSERT_EQUAL_INT(10, CMD_WIFI);
TEST_ASSERT_EQUAL_INT(11, CMD_MQTT);
TEST_ASSERT_EQUAL_INT(12, CMD_NET);
TEST_ASSERT_EQUAL_INT(13, CMD_OTA); /* C2 */
TEST_ASSERT_EQUAL_INT(14, CMD_FLASH_MODE); /* 0.1.5 刷机模式 */
TEST_ASSERT_EQUAL_INT(15, CMD_HUB); /* 0.1.14 Hub 按需启停 */
TEST_ASSERT_EQUAL_INT(16, CMD_COUNT);
TEST_ASSERT_EQUAL_INT(0, CMD_OK);
TEST_ASSERT_EQUAL_INT(1, CMD_ERR_PARSE);
TEST_ASSERT_EQUAL_INT(2, CMD_ERR_UNKNOWN);
TEST_ASSERT_EQUAL_INT(3, CMD_ERR_NO_SEQ);
TEST_ASSERT_EQUAL_INT(4, CMD_ERR_ARG);
TEST_ASSERT_EQUAL_INT(5, CMD_ERR_RANGE);
TEST_ASSERT_EQUAL_INT(6, CMD_ERR_EMPTY);
TEST_ASSERT_EQUAL_INT(7, CMD_ERR_BUSY);
TEST_ASSERT_EQUAL_INT(8, CMD_ERR_STATE);
TEST_ASSERT_EQUAL_INT(9, CMD_ERR_IO);
TEST_ASSERT_EQUAL_INT(10, CMD_ERR_OVERFLOW);
TEST_ASSERT_EQUAL_INT(11, CMD_ERR_LINK);
TEST_ASSERT_EQUAL_INT(0x21, CMD_REBOOT_ARG(true, CMD_LINK_WIFI));
TEST_ASSERT_EQUAL_INT(0x10, CMD_REBOOT_ARG(false, CMD_LINK_USB));
for (int i = CMD_NONE + 1; i < CMD_COUNT; i++) {
TEST_ASSERT_TRUE(strlen(cmd_name((cmd_id_t)i)) > 0);
}
for (int i = 0; i < CMD_ERR_COUNT; i++) {
TEST_ASSERT_NOT_EQUAL(0, strcmp("?", cmd_err_str((cmd_err_t)i)));
}
}
/* ---- ① 判重 -------------------------------------------------------------- */
/* ⭐ 验收主项一。同一个 seq 从 USB 再到 WiFi:第二遍必须回第一次的结论、
**一个字节都不许再发到刀片上**。 */
static void test_same_seq_twice_executes_once_across_links(void)
{
const char *line = "{\"seq\":41,\"cmd\":\"key\",\"rail\":2,\"keys\":\"pwr\",\"ms\":100}";
const uint32_t w0 = writes(2);
ASSERT_HAS(send_on(CMD_LINK_WIFI, line), "\"ok\":true");
const uint32_t w1 = writes(2);
TEST_ASSERT_EQUAL_UINT32(w0 + 1, w1); /* 按下去正好一次写 */
TEST_ASSERT_TRUE((latch(2) & (1u << pca9555_key_bit(PCA9555_KEY_PWR))) != 0);
const char *r = send_on(CMD_LINK_USB, line);
ASSERT_HAS(r, "\"seq\":41");
ASSERT_HAS(r, "\"ok\":true");
ASSERT_HAS(r, "\"dup\":true");
TEST_ASSERT_EQUAL_UINT32(w1, writes(2));
cmd_stats_t st;
cmd_stats(&st);
TEST_ASSERT_EQUAL_UINT32(1, st.dups);
}
/* ⭐ §10.8 原话的那个场景:「按 10 秒电源键」执行两遍,第二遍正好按在刚开机的手机上。
第一遍**走完之后**重传到达,照样不许执行。 */
static void test_retransmit_after_the_sequence_finished_is_still_dropped(void)
{
const char *line = "{\"seq\":7,\"cmd\":\"key\",\"rail\":4,\"keys\":\"pwr\",\"ms\":10000}";
ASSERT_HAS(send(line), "\"ok\":true");
run_ms(10500);
TEST_ASSERT_FALSE(t_actuator_busy(4));
TEST_ASSERT_EQUAL_HEX8(0x00, latch(4));
const uint32_t w = writes(4);
ASSERT_HAS(send(line), "\"dup\":true");
TEST_ASSERT_FALSE(t_actuator_busy(4));
TEST_ASSERT_EQUAL_UINT32(w, writes(4));
TEST_ASSERT_EQUAL_INT(1, count_events(LOG_EV_REBOOT)); /* 软重启只记了一次 */
}
/* 被拒的命令也进窗口:重传拿到的是同一个拒绝,而不是「这次碰巧不忙了就执行」。 */
static void test_a_rejected_command_stays_rejected_on_retransmit(void)
{
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"key\",\"rail\":1,\"keys\":\"vup\",\"ms\":2000}"),
"\"ok\":true");
const char *busy = "{\"seq\":2,\"cmd\":\"edl\",\"rail\":1}";
ASSERT_HAS(send(busy), "\"err\":\"busy\"");
run_ms(2500);
TEST_ASSERT_FALSE(t_actuator_busy(1));
const char *r = send(busy);
ASSERT_HAS(r, "\"err\":\"busy\"");
ASSERT_HAS(r, "\"dup\":true");
TEST_ASSERT_EQUAL_UINT32(1, t_actuator_done(1)); /* 只走完过那一次按键,EDL 从没启动 */
TEST_ASSERT_FALSE(t_actuator_busy(1));
}
/* 上位机把同一个 seq 用在了两条命令上:不执行后一条,回 arg 让它看得见。 */
static void test_same_seq_different_command_is_not_executed(void)
{
ASSERT_HAS(send("{\"seq\":9,\"cmd\":\"fan\",\"duty\":500}"), "\"ok\":true");
const uint32_t w = writes(3);
const char *r = send("{\"seq\":9,\"cmd\":\"key\",\"rail\":3,\"keys\":\"pwr\",\"ms\":100}");
ASSERT_HAS(r, "\"err\":\"arg\"");
ASSERT_HAS(r, "\"dup\":true");
TEST_ASSERT_EQUAL_UINT32(w, writes(3));
}
/* 窗口按「值相等」判,不按「≤ 最大值」判:上位机重启后序号从头数,新命令照常执行。 */
static void test_window_is_by_value_so_a_restarted_host_is_not_blocked(void)
{
char line[96];
for (int s = 1000; s < 1000 + CMD_SEQ_WINDOW; s++) {
snprintf(line, sizeof(line), "{\"seq\":%d,\"cmd\":\"fan\",\"duty\":0}", s);
ASSERT_LACKS(send(line), "\"dup\":true");
}
ASSERT_LACKS(send("{\"seq\":3,\"cmd\":\"fan\",\"duty\":400}"), "\"dup\":true"); /* 比窗口里的都小 */
TEST_ASSERT_EQUAL_UINT16(400, t_thermal_floor());
/* 窗口满了之后最老的那条被挤出去:1000 这时已经不认识了。 */
ASSERT_LACKS(send("{\"seq\":1000,\"cmd\":\"fan\",\"duty\":0}"), "\"dup\":true");
ASSERT_HAS(send("{\"seq\":1031,\"cmd\":\"fan\",\"duty\":0}"), "\"dup\":true");
}
/* 会动东西的命令必须带 seq;status / events 幂等,带不带都行、也不判重。 */
static void test_seq_rules(void)
{
const uint32_t w = writes(1);
ASSERT_HAS(send("{\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr\",\"ms\":100}"), "\"err\":\"no_seq\"");
ASSERT_HAS(send("{\"seq\":0,\"cmd\":\"edl\",\"rail\":1}"), "\"err\":\"no_seq\"");
ASSERT_HAS(send("{\"seq\":4294967296,\"cmd\":\"edl\",\"rail\":1}"), "\"err\":\"no_seq\"");
ASSERT_HAS(send("{\"seq\":\"5\",\"cmd\":\"edl\",\"rail\":1}"), "\"err\":\"no_seq\"");
TEST_ASSERT_EQUAL_UINT32(w, writes(1));
TEST_ASSERT_FALSE(t_actuator_busy(1));
ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"ok\":true");
ASSERT_LACKS(send("{\"seq\":77,\"cmd\":\"status\"}"), "\"dup\":true");
const char *r = send("{\"seq\":77,\"cmd\":\"status\"}");
ASSERT_LACKS(r, "\"dup\":true");
ASSERT_HAS(r, "\"seq\":77");
}
/* ---- ② 越界即拒,而且记事件 ------------------------------------------------ */
/* ⭐ 验收主项二。SR-13:4011 / 4300 / 3523 都拒,DAC 一个字节都不动,每一次都记一条
CMD_REJECT(arg = 命令号、bus_mv = 那个越界值、current_ua = 原因)。 */
static void test_voltage_out_of_range_is_rejected_and_logged(void)
{
const uint16_t before = dac_code(2);
const uint32_t dac_w = sim_i2c_dev(dac63204_rail_addr(2))->n_write;
static const int bad[] = { 4011, 4300, 3523, -1 };
char line[96];
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
snprintf(line, sizeof(line), "{\"seq\":%d,\"cmd\":\"set_voltage\",\"rail\":2,\"mv\":%d}",
(int)i + 1, bad[i]);
ASSERT_HAS(send(line), "\"err\":\"range\"");
}
TEST_ASSERT_EQUAL_UINT16(before, dac_code(2));
TEST_ASSERT_EQUAL_UINT32(dac_w, sim_i2c_dev(dac63204_rail_addr(2))->n_write);
TEST_ASSERT_EQUAL_INT(RAIL_MV_SAFE, rail_setpoint_mv(2));
TEST_ASSERT_EQUAL_INT(4, count_events(LOG_EV_CMD_REJECT));
evlog_rec_t r;
TEST_ASSERT_TRUE(find_event(LOG_EV_CMD_REJECT, 1, &r));
TEST_ASSERT_EQUAL_UINT8(2, r.rail);
TEST_ASSERT_EQUAL_UINT8(CMD_SET_VOLTAGE, r.arg);
TEST_ASSERT_EQUAL_INT32(4300, r.bus_mv);
TEST_ASSERT_EQUAL_INT32(CMD_ERR_RANGE, r.current_ua);
}
/* 合法电压:主干上的 DAC 写**在锁里**(tearDown 查违规),码与 `rail_mv_to_code()` 一致。 */
static void test_voltage_in_range_is_written_under_the_bus_lock(void)
{
const char *r = send("{\"seq\":5,\"cmd\":\"set_voltage\",\"rail\":5,\"mv\":3800}");
ASSERT_HAS(r, "\"ok\":true");
ASSERT_HAS(r, "\"set\":3800");
TEST_ASSERT_EQUAL_UINT16(rail_mv_to_code(3800), dac_code(5));
TEST_ASSERT_EQUAL_INT(3800, rail_setpoint_mv(5));
ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"set_voltage\",\"rail\":5,\"mv\":4010}"), "\"ok\":true");
TEST_ASSERT_EQUAL_INT(0, count_events(LOG_EV_CMD_REJECT));
}
static void test_other_range_checks(void)
{
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"edl\",\"rail\":6}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"edl\",\"rail\":0}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr\",\"ms\":0}"),
"\"err\":\"range\"");
char line[96];
snprintf(line, sizeof(line), "{\"seq\":4,\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr\",\"ms\":%d}",
ACT_KEY_MAX_MS + 1);
ASSERT_HAS(send(line), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"fan\",\"duty\":1001}"), "\"err\":\"range\"");
TEST_ASSERT_EQUAL_INT(5, count_events(LOG_EV_CMD_REJECT));
evlog_rec_t r;
TEST_ASSERT_TRUE(find_event(LOG_EV_CMD_REJECT, 0, &r));
TEST_ASSERT_EQUAL_UINT8(0, r.rail); /* 路号本身越界:记整板级,不记一个不存在的路 */
TEST_ASSERT_EQUAL_INT32(6, r.bus_mv);
for (int rail = 1; rail <= N; rail++) {
TEST_ASSERT_FALSE(t_actuator_busy(rail));
}
}
/* 缺参数 / 类型不对 / 键名写错:arg,**不记事件**(那是上位机的 bug,不是操作)。 */
static void test_argument_errors_are_not_logged(void)
{
static const char *const bad[] = {
"{\"seq\":1,\"cmd\":\"set_voltage\",\"rail\":1}",
"{\"seq\":2,\"cmd\":\"set_voltage\",\"rail\":1,\"mv\":\"3900\"}",
"{\"seq\":3,\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr+xyz\",\"ms\":100}",
"{\"seq\":4,\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr+\",\"ms\":100}",
"{\"seq\":5,\"cmd\":\"key\",\"rail\":1,\"keys\":\"\",\"ms\":100}",
"{\"seq\":6,\"cmd\":\"vbus\",\"rail\":1,\"on\":1}",
"{\"seq\":7,\"cmd\":\"power\",\"rail\":1}",
"{\"seq\":8,\"cmd\":\"edl\"}",
};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
ASSERT_HAS(send(bad[i]), "\"err\":\"arg\"");
}
ASSERT_HAS(send("{\"seq\":9,\"cmd\":\"reboot_everything\"}"), "\"err\":\"unknown_cmd\"");
ASSERT_HAS(send("not json at all"), "\"err\":\"parse\"");
TEST_ASSERT_EQUAL_INT(0, count_events(LOG_EV_CMD_REJECT));
TEST_ASSERT_FALSE(t_actuator_busy(1));
TEST_ASSERT_EQUAL_HEX8(0x00, latch(1));
}
/* ---- ③ 空槽一律拒 -------------------------------------------------------- */
/* ⭐ 验收主项三。槽 3 空着:六条按路的命令全拒,刀片 3 一个字节不动、EN 不动,
每条都记一条事件;隔壁槽照常。 */
static void test_every_rail_command_is_refused_on_an_empty_slot(void)
{
set_present(3, false);
TEST_ASSERT_FALSE(slot_present(3));
const uint32_t w = writes(3);
const uint16_t code = dac_code(3);
static const char *const cmds[] = {
"{\"seq\":1,\"cmd\":\"set_voltage\",\"rail\":3,\"mv\":3800}",
"{\"seq\":2,\"cmd\":\"power_cycle\",\"rail\":3}",
"{\"seq\":3,\"cmd\":\"key\",\"rail\":3,\"keys\":\"pwr\",\"ms\":100}",
"{\"seq\":4,\"cmd\":\"edl\",\"rail\":3}",
"{\"seq\":5,\"cmd\":\"vbus\",\"rail\":3,\"on\":false}",
"{\"seq\":6,\"cmd\":\"power\",\"rail\":3,\"on\":true}",
};
for (size_t i = 0; i < sizeof(cmds) / sizeof(cmds[0]); i++) {
ASSERT_HAS(send(cmds[i]), "\"err\":\"empty\"");
}
run_ms(100);
TEST_ASSERT_EQUAL_UINT32(w, writes(3));
TEST_ASSERT_EQUAL_UINT16(code, dac_code(3));
TEST_ASSERT_EQUAL_INT(0, en_pin(3));
TEST_ASSERT_FALSE(t_actuator_busy(3));
TEST_ASSERT_EQUAL_INT(6, count_events(LOG_EV_CMD_REJECT));
evlog_rec_t r;
TEST_ASSERT_TRUE(find_event(LOG_EV_CMD_REJECT, 5, &r));
TEST_ASSERT_EQUAL_UINT8(3, r.rail);
TEST_ASSERT_EQUAL_UINT8(CMD_POWER, r.arg);
TEST_ASSERT_EQUAL_INT32(CMD_ERR_EMPTY, r.current_ua);
ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"power\",\"rail\":4,\"on\":true}"), "\"ok\":true");
TEST_ASSERT_EQUAL_INT(1, en_pin(4));
}
/* ---- 各条命令的语义 ------------------------------------------------------ */
/* 组合键:一次写两位(§10.6),走的是命令里给的那一路。 */
static void test_combo_keys_land_on_the_named_rail(void)
{
uint32_t w[N];
for (int r = 1; r <= N; r++) {
w[r - 1] = writes(r);
}
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"key\",\"rail\":5,\"keys\":\"vdn+pwr\",\"ms\":2000}"),
"\"ok\":true");
const uint8_t want = (uint8_t)((1u << pca9555_key_bit(PCA9555_KEY_PWR)) |
(1u << pca9555_key_bit(PCA9555_KEY_VDN)));
TEST_ASSERT_EQUAL_HEX8(want, latch(5));
TEST_ASSERT_EQUAL_UINT32(w[4] + 1, writes(5));
for (int r = 1; r < 5; r++) {
TEST_ASSERT_EQUAL_UINT32(w[r - 1], writes(r));
}
run_ms(2100);
TEST_ASSERT_EQUAL_HEX8(0x00, latch(5));
TEST_ASSERT_EQUAL_INT(0, count_events(LOG_EV_REBOOT)); /* 组合键不算重启 */
}
/* 同一路正在跑序列:busy,不记事件(忙不是越界)。 */
static void test_busy_rail_refuses_a_second_sequence(void)
{
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power_cycle\",\"rail\":1}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr\",\"ms\":100}"),
"\"err\":\"busy\"");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"vbus\",\"rail\":1,\"on\":false}"), "\"err\":\"busy\"");
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"power\",\"rail\":1,\"on\":false}"), "\"err\":\"busy\"");
TEST_ASSERT_EQUAL_INT(ACT_SEQ_HARD_RESET, t_actuator_seq(1));
TEST_ASSERT_EQUAL_INT(0, count_events(LOG_EV_CMD_REJECT));
}
/* 两级重启的两条事件:硬 = power_cycle,软 = 单按电源键 ≥10 s。arg 带链路。 */
static void test_reboots_are_logged_with_kind_and_link(void)
{
ASSERT_HAS(send_on(CMD_LINK_WIFI, "{\"seq\":1,\"cmd\":\"power_cycle\",\"rail\":2}"),
"\"ok\":true");
ASSERT_HAS(send_on(CMD_LINK_USB,
"{\"seq\":2,\"cmd\":\"key\",\"rail\":3,\"keys\":\"pwr\",\"ms\":10000}"),
"\"ok\":true");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"key\",\"rail\":4,\"keys\":\"pwr\",\"ms\":2000}"),
"\"ok\":true"); /* 2 s 是开机,不是重启 */
TEST_ASSERT_EQUAL_INT(2, count_events(LOG_EV_REBOOT));
evlog_rec_t r;
TEST_ASSERT_TRUE(find_event(LOG_EV_REBOOT, 0, &r));
TEST_ASSERT_EQUAL_UINT8(2, r.rail);
TEST_ASSERT_EQUAL_UINT8(CMD_REBOOT_ARG(true, CMD_LINK_WIFI), r.arg);
TEST_ASSERT_TRUE(find_event(LOG_EV_REBOOT, 1, &r));
TEST_ASSERT_EQUAL_UINT8(3, r.rail);
TEST_ASSERT_EQUAL_UINT8(CMD_REBOOT_ARG(false, CMD_LINK_USB), r.arg);
}
/* eFuse 关着时开 VBUS = 手机只靠 USB 供电:state,记事件。开了电之后才行。 */
static void test_vbus_needs_the_rail_powered(void)
{
const uint32_t vb = 1u << PCA9555_BIT_VBUS_EN;
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"vbus\",\"rail\":2,\"on\":true}"), "\"err\":\"state\"");
TEST_ASSERT_EQUAL_HEX8(0x00, latch(2) & vb);
TEST_ASSERT_EQUAL_INT(1, count_events(LOG_EV_CMD_REJECT));
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"power\",\"rail\":2,\"on\":true}"), "\"ok\":true");
TEST_ASSERT_EQUAL_INT(1, en_pin(2));
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"vbus\",\"rail\":2,\"on\":true}"), "\"ok\":true");
TEST_ASSERT_EQUAL_HEX8(vb, latch(2) & vb);
TEST_ASSERT_TRUE(slot_vbus_is_on(2));
}
/* 关电:先断 VBUS 再关 eFuse;VBUS 断不掉(刀片 NAK)也照样关 eFuse,回 io。 */
static void test_power_off_drops_vbus_and_always_drops_the_efuse(void)
{
const uint32_t vb = 1u << PCA9555_BIT_VBUS_EN;
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":1,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"vbus\",\"rail\":1,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"power\",\"rail\":1,\"on\":false}"), "\"ok\":true");
TEST_ASSERT_EQUAL_HEX8(0x00, latch(1) & vb);
TEST_ASSERT_EQUAL_INT(0, en_pin(1));
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"power\",\"rail\":2,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"vbus\",\"rail\":2,\"on\":true}"), "\"ok\":true");
sim_i2c_inject_nak_ch(rail_cfg(2)->mux_ch, PCA9555_ADDR, 100);
ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"power\",\"rail\":2,\"on\":false}"), "\"err\":\"io\"");
TEST_ASSERT_EQUAL_INT(0, en_pin(2)); /* 直供照样断了 */
TEST_ASSERT_TRUE(slot_vbus_is_on(2)); /* 影子如实留在 on(slot.h:倒向安全的一侧) */
/* ⭐ 2026-09-27 上板实测的 bug:器件不应答被报成 HAL_ERR_STATE(IDF 5.4 的 NACK 码),
命令层又把 STATE 当成「执行器忙」回了 busy —— 上位机会以为没执行。总线上的失败一律 io。 */
ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"power\",\"rail\":3,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":8,\"cmd\":\"vbus\",\"rail\":3,\"on\":true}"), "\"ok\":true");
sim_i2c_inject_err_ch(rail_cfg(3)->mux_ch, PCA9555_ADDR, 100, HAL_ERR_STATE);
const char *r = send("{\"seq\":9,\"cmd\":\"power\",\"rail\":3,\"on\":false}");
ASSERT_HAS(r, "\"err\":\"io\"");
ASSERT_LACKS(r, "busy");
TEST_ASSERT_EQUAL_INT(0, en_pin(3));
ASSERT_HAS(send("{\"seq\":10,\"cmd\":\"vbus\",\"rail\":3,\"on\":false}"), "\"err\":\"io\"");
}
/* A15:只有「真的开成 / 关成了」才改意图(下次开机按它恢复);被拒的、判重命中的都不改。 */
static int g_intent_n;
static int g_intent_rail;
static bool g_intent_on;
static void intent_spy(void *ctx, int rail, bool on)
{
(void)ctx;
g_intent_n++;
g_intent_rail = rail;
g_intent_on = on;
}
static void test_power_records_intent_only_when_it_took_effect(void)
{
g_intent_n = 0;
const cmd_env_t env = { .intent = intent_spy };
cmd_init(&env);
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":3,\"on\":true}"), "\"ok\":true");
TEST_ASSERT_EQUAL_INT(1, g_intent_n);
TEST_ASSERT_EQUAL_INT(3, g_intent_rail);
TEST_ASSERT_TRUE(g_intent_on);
send("{\"seq\":1,\"cmd\":\"power\",\"rail\":3,\"on\":true}"); /* 判重命中:不再执行 */
TEST_ASSERT_EQUAL_INT(1, g_intent_n);
set_present(4, false);
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"power\",\"rail\":4,\"on\":true}"), "\"err\":\"empty\"");
TEST_ASSERT_EQUAL_INT(1, g_intent_n);
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"power\",\"rail\":3,\"on\":false}"), "\"ok\":true");
TEST_ASSERT_EQUAL_INT(2, g_intent_n);
TEST_ASSERT_FALSE(g_intent_on);
/* 硬重启不是意图(那是事故处理),不许碰它。 */
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"power\",\"rail\":1,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"power_cycle\",\"rail\":1}"), "\"ok\":true");
TEST_ASSERT_EQUAL_INT(3, g_intent_n);
cmd_init(NULL);
}
/* 风扇:手动值只是下限,0 交回自动。 */
static void test_fan_floor(void)
{
const char *r = send("{\"seq\":1,\"cmd\":\"fan\",\"duty\":700}");
ASSERT_HAS(r, "\"floor\":700");
TEST_ASSERT_EQUAL_UINT16(700, t_thermal_floor());
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"fan\",\"duty\":0}"), "\"floor\":0");
TEST_ASSERT_EQUAL_UINT16(0, t_thermal_floor());
/* 0.1.21 `sec`:带时限的下限(到期回落在 test_t_thermal 里测)。没给 sec 的回应里不带它。 */
ASSERT_LACKS(r, "\"sec\"");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"fan\",\"duty\":600,\"sec\":90}"), "\"floor\":600,\"sec\":90");
TEST_ASSERT_EQUAL_UINT32(90000, t_thermal_floor_left_ms());
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"fan\",\"duty\":600}"), "\"floor\":600"); /* 不带 sec = 改成一直保持 */
TEST_ASSERT_EQUAL_UINT32(0, t_thermal_floor_left_ms());
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"fan\",\"duty\":600,\"sec\":86401}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"fan\",\"duty\":600,\"sec\":-1}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"fan\",\"duty\":600,\"sec\":\"90\"}"), "\"err\":\"arg\"");
TEST_ASSERT_EQUAL_UINT16(600, t_thermal_floor());
ASSERT_HAS(send("{\"seq\":8,\"cmd\":\"fan\",\"duty\":0,\"sec\":90}"), "\"floor\":0"); /* 0 就是交回自动,时限无意义 */
TEST_ASSERT_EQUAL_UINT32(0, t_thermal_floor_left_ms());
t_thermal_set_floor(0);
}
/* 0.1.14 hub:改模式 / 保持窗口,两条链路都收;越界记事件、缺参数不记;
USB 链路上的任何一行都算一次「有人在用 USB」。 */
static void test_hub_command(void)
{
hubctl_init(HUB_MODE_AUTO, NULL, NULL, hal_time_now_ms());
const char *r = send_on(CMD_LINK_WIFI, "{\"seq\":1,\"cmd\":\"hub\",\"mode\":\"on\"}");
ASSERT_HAS(r, "\"ok\":true");
ASSERT_HAS(r, "\"hub\":{\"on\":true,\"mode\":\"on\"");
TEST_ASSERT_EQUAL_INT(HUB_MODE_ON, hubctl_mode());
r = send("{\"seq\":2,\"cmd\":\"hub\",\"mode\":\"auto\",\"min\":30}");
ASSERT_HAS(r, "\"mode\":\"auto\"");
ASSERT_HAS(r, "\"hold\":1800");
TEST_ASSERT_EQUAL_UINT32(1800, hubctl_hold_left_s(hal_time_now_ms()));
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"hub\",\"min\":241}"), "\"err\":\"range\"");
TEST_ASSERT_EQUAL_INT(1, count_events(LOG_EV_CMD_REJECT));
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"hub\",\"mode\":\"off\"}"), "\"err\":\"arg\"");
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"hub\"}"), "\"err\":\"arg\"");
TEST_ASSERT_EQUAL_INT(1, count_events(LOG_EV_CMD_REJECT));
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"hub\"}"), "\"dup\":true"); /* 会动东西 ⇒ 进判重窗口 */
ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"hub\",\"min\":0}"), "\"hold\":0");
const hub_in_t idle = { .link_up = true };
TEST_ASSERT_EQUAL_INT(HUB_WHY_OFF, hub_decide(&idle, hal_time_now_ms() + 1));
/* USB 上来一行(哪怕是 status)⇒ 重新给一个 idle 窗口。 */
(void)send_on(CMD_LINK_USB, "{\"cmd\":\"status\"}");
TEST_ASSERT_EQUAL_INT(HUB_WHY_IDLE, hub_decide(&idle, hal_time_now_ms() + 1));
}
/* ---- 只读命令 ------------------------------------------------------------ */
/* events 分页:一次最多 CMD_EVENTS_MAX 条,逐条带序号,游标接得上;RAM 里的先落盘。 */
static void test_events_pages_with_contiguous_seqs(void)
{
for (int i = 0; i < 11; i++) {
evlog_put(1, LOG_EV_PG_CHANGE, (uint8_t)i, 3900 + i, 100 * i);
}
/* 这 11 条还在 RAM 里:events 自己会先落盘。 */
evlog_stats_t es;
evlog_stats(&es);
TEST_ASSERT_TRUE(es.pending > 0);
const char *r = send("{\"cmd\":\"events\"}");
ASSERT_HAS(r, "\"oldest\":0");
ASSERT_HAS(r, "\"next\":12"); /* BOOT + 11 */
ASSERT_HAS(r, "[0,"); /* 第一条是 BOOT,序号 0 */
ASSERT_HAS(r, "[7,");
ASSERT_LACKS(r, "[8,");
r = send("{\"cmd\":\"events\",\"from\":8}");
ASSERT_HAS(r, "[8,");
ASSERT_HAS(r, "[11,");
ASSERT_HAS(r, ",1,8,10,3910,1000]"); /* 第 10 条 PG_CHANGE:rail 1、type 8、arg 10 */
r = send("{\"cmd\":\"events\",\"from\":12}");
ASSERT_HAS(r, "\"ev\":[]");
ASSERT_HAS(send("{\"cmd\":\"events\",\"from\":-1}"), "\"err\":\"arg\"");
}
/* status:满配(两份快照都给)装得进 CMD_OUT_MAX;读不到的量是 null,不是 0。 */
static void test_status_fits_and_reports_unknowns_as_null(void)
{
static safety_snap_t s;
static thermal_snap_t t;
memset(&s, 0, sizeof(s));
memset(&t, 0, sizeof(t));
for (int i = 0; i < N; i++) {
s.rail_ok[i] = true;
s.io_ok[i] = true;
s.rail[i].bus_mv = -3999999; /* 故意取最长的数字,量上界 */
s.rail[i].current_ua = -3999999;
}
s.rail_ok[1] = false; /* 第 2 路这一圈没读到 */
s.io_ok[3] = false; /* 第 4 路 PCA9555 失联 */
s.input_ok = true;
s.input.bus_mv = 12030;
s.input.current_ua = 1234567;
s.cycles = 4000000000u;
t.temp_ok[0] = true;
t.temp_mc[0] = 41500;
t.fan[0].rpm_valid = true;
t.fan[0].rpm = 3300;
t.duty = 650;
t.fan_en = true;
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); /* 留着高电平:下面量满配长度 */
/* 0.1.17 CH224A:没读到过 ⇒ null;读到了报原值。 */
pdmon_init(0);
ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"pd\":null");
sim_i2c_add(CH224A_ADDR_DEFAULT, 1);
sim_i2c_set_reg(CH224A_ADDR_DEFAULT, CH224A_REG_STATUS, 0x08);
sim_i2c_set_reg(CH224A_ADDR_DEFAULT, CH224A_REG_CURRENT, 5000 / 50);
pdmon_poll();
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, "\"pd\":{\"st\":8,\"ma\":5000}");
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");
ASSERT_HAS(r, "\"r\":4,");
ASSERT_HAS(r, "\"pg\":null,\"flt\":null");
ASSERT_HAS(r, "\"temp\":[41500,null]");
ASSERT_HAS(r, "\"rpm\":[3300,null]");
ASSERT_HAS(r, "\"st\":\"idle\"");
TEST_ASSERT_EQUAL_CHAR('}', r[strlen(r) - 1]);
printf("status 满配 %u 字节(上限 %u)\n", (unsigned)strlen(r), (unsigned)CMD_OUT_MAX);
/* 原来留的是四分之一;0.1.17 加 `pd` 用掉了一点(1164 字节)。再加字段先看这个数,不够就该涨 CMD_OUT_MAX 了。 */
TEST_ASSERT_TRUE(strlen(r) < CMD_OUT_MAX - 256);
/* 不给快照:只报影子,照样是一行合法回应。 */
cmd_init(NULL);
r = send("{\"cmd\":\"status\"}");
ASSERT_LACKS(r, "\"in\"");
ASSERT_LACKS(r, "\"temp\"");
ASSERT_HAS(r, "\"r\":5,");
}
/* 回应装不下:只读命令如实报 overflow;已经执行了的命令回一条不带附加字段的 ok。 */
static void test_small_output_buffer(void)
{
char small[64];
size_t n = cmd_handle_line(CMD_LINK_TEST, "{\"seq\":1,\"cmd\":\"status\"}", small,
sizeof(small));
TEST_ASSERT_EQUAL_size_t(strlen(small), n);
TEST_ASSERT_EQUAL_STRING("{\"seq\":1,\"ok\":false,\"err\":\"overflow\"}", small);
char tiny[24];
n = cmd_handle_line(CMD_LINK_TEST, "{\"seq\":2,\"cmd\":\"fan\",\"duty\":300}", tiny,
sizeof(tiny));
TEST_ASSERT_EQUAL_STRING("{\"seq\":2,\"ok\":true}", tiny);
TEST_ASSERT_EQUAL_UINT16(300, t_thermal_floor());
}
/* ---- 0.1.5 刷机模式(flash_mode) ----------------------------------------- */
static void test_flash_mode_command(void)
{
/* 关着电的路不许开(与 vbus 同一条规矩),记拒绝事件。 */
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true}"), "\"err\":\"state\"");
TEST_ASSERT_EQUAL_INT(1, count_events(LOG_EV_CMD_REJECT));
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"power\",\"rail\":1,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true}"), "\"left\":3600"); /* 默认 60 分钟 */
TEST_ASSERT_TRUE(chgmode_active(1, hal_time_now_ms()));
evlog_rec_t r;
TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, 0, &r));
TEST_ASSERT_EQUAL_UINT8(1, r.rail);
TEST_ASSERT_EQUAL_UINT8(CHG_MODE_DEFAULT_MIN, r.arg);
/* status 每一路报剩余秒数 */
ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"chg\":3600");
/* 越界 / 参数错 */
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true,\"min\":0}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true,\"min\":121}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":false,\"min\":5}"), "\"err\":\"arg\"");
ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"flash_mode\",\"rail\":1}"), "\"err\":\"arg\"");
/* 续期:本路再开一次不占新名额 */
ASSERT_HAS(send("{\"seq\":8,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true,\"min\":120}"), "\"left\":7200");
/* 第三路被拒 */
ASSERT_HAS(send("{\"seq\":9,\"cmd\":\"power\",\"rail\":2,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":10,\"cmd\":\"power\",\"rail\":3,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":11,\"cmd\":\"flash_mode\",\"rail\":2,\"on\":true,\"min\":10}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":12,\"cmd\":\"flash_mode\",\"rail\":3,\"on\":true}"), "\"err\":\"state\"");
TEST_ASSERT_FALSE(chgmode_active(3, hal_time_now_ms()));
/* 关:回 left 0、记一条 arg=0;腾出的名额第三路就能用了 */
ASSERT_HAS(send("{\"seq\":13,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":false}"), "\"left\":0");
TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, 3, &r)); /* 开1、续期、开2、关1 */
TEST_ASSERT_EQUAL_UINT8(CHG_EV_OFF, r.arg);
ASSERT_HAS(send("{\"seq\":14,\"cmd\":\"flash_mode\",\"rail\":3,\"on\":true}"), "\"ok\":true");
}
/* 关这一路电 = 刷机模式一并退出(不白占名额),记一条 arg=0。 */
static void test_power_off_ends_flash_mode(void)
{
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":4,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"flash_mode\",\"rail\":4,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"power\",\"rail\":4,\"on\":false}"), "\"ok\":true");
TEST_ASSERT_FALSE(chgmode_active(4, hal_time_now_ms()));
TEST_ASSERT_EQUAL_INT(2, count_events(LOG_EV_CHG_MODE));
evlog_rec_t r;
TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, 1, &r));
TEST_ASSERT_EQUAL_UINT8(CHG_EV_OFF, r.arg);
}
/* 自然到期:执行器循环里的 cmd_chgmode_tick 记**一条** arg=255,之后不再重复记。 */
static void test_flash_mode_expiry_is_logged_once(void)
{
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":5,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"flash_mode\",\"rail\":5,\"on\":true,\"min\":1}"), "\"left\":60");
cmd_chgmode_tick(hal_time_now_ms());
TEST_ASSERT_EQUAL_INT(1, count_events(LOG_EV_CHG_MODE));
sim_time_advance_ms(60000 + CHG_MODE_GRACE_MS);
cmd_chgmode_tick(hal_time_now_ms());
cmd_chgmode_tick(hal_time_now_ms() + 100);
TEST_ASSERT_EQUAL_INT(2, count_events(LOG_EV_CHG_MODE));
evlog_rec_t r;
TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, 1, &r));
TEST_ASSERT_EQUAL_UINT8(5, r.rail);
TEST_ASSERT_EQUAL_UINT8(CHG_EV_EXPIRED, r.arg);
ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"chg\":0");
}
/* 0.1.13:安全判据 / 拔刀片把这一路关了(不经过 power 命令)⇒ 下一次 tick 退出刷机模式、记 arg=254、
让出名额;硬重启中间 eFuse 关着的那几秒不算。 */
static void test_flash_mode_ends_when_the_rail_loses_power(void)
{
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":1,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"power\",\"rail\":2,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"power\",\"rail\":3,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"flash_mode\",\"rail\":1,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"flash_mode\",\"rail\":2,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"flash_mode\",\"rail\":3,\"on\":true}"), "\"err\":\"state\"");
/* 硬重启:HR_OFF 期间 eFuse 关着,但这是序列的一部分 —— 不退。 */
ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"power_cycle\",\"rail\":1}"), "\"ok\":true");
TEST_ASSERT_FALSE(rail_efuse_is_on(1));
cmd_chgmode_tick(hal_time_now_ms());
TEST_ASSERT_TRUE(chgmode_active(1, hal_time_now_ms()));
/* 第 2 路被 t_safety 关了(这里直接拉低 EN 扮演):下一次 tick 退出。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_efuse_enable(2, false));
const int before = count_events(LOG_EV_CHG_MODE);
cmd_chgmode_tick(hal_time_now_ms());
TEST_ASSERT_FALSE(chgmode_active(2, hal_time_now_ms()));
TEST_ASSERT_EQUAL_INT(before + 1, count_events(LOG_EV_CHG_MODE));
evlog_rec_t r;
TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, before, &r));
TEST_ASSERT_EQUAL_UINT8(2, r.rail);
TEST_ASSERT_EQUAL_UINT8(CHG_EV_DROPPED, r.arg);
cmd_chgmode_tick(hal_time_now_ms() + 100);
TEST_ASSERT_EQUAL_INT(before + 1, count_events(LOG_EV_CHG_MODE)); /* 只记一次 */
/* 名额让出来了。 */
ASSERT_HAS(send("{\"seq\":8,\"cmd\":\"flash_mode\",\"rail\":3,\"on\":true}"), "\"ok\":true");
}
/* 0.1.6 加钟:只给开着的窗口加;剩余最多 120 分钟(超出截断);与 on / min 互斥;事件 arg = 0x80 | 分钟。 */
static void test_flash_mode_extend(void)
{
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":2,\"on\":true}"), "\"ok\":true");
/* 没开着 ⇒ state(要重新开,不是加钟) */
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":30}"), "\"err\":\"state\"");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"flash_mode\",\"rail\":2,\"on\":true,\"min\":20}"), "\"left\":1200");
sim_time_advance_ms(5 * 60000); /* 过了 5 分钟,还剩 15 */
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":30}"), "\"left\":2700"); /* 15 + 30 */
evlog_rec_t r;
TEST_ASSERT_TRUE(find_event(LOG_EV_CHG_MODE, 1, &r));
TEST_ASSERT_EQUAL_UINT8(CHG_EV_EXTEND | 30u, r.arg);
/* 截断到 120 分钟 */
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":120}"), "\"left\":7200");
/* 越界 / 混用 */
ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":0}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":121}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":8,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":10,\"on\":true}"), "\"err\":\"arg\"");
ASSERT_HAS(send("{\"seq\":9,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":10,\"min\":10}"), "\"err\":\"arg\"");
ASSERT_HAS(send("{\"seq\":10,\"cmd\":\"flash_mode\",\"rail\":2,\"add\":true}"), "\"err\":\"arg\"");
/* 加钟不占新名额:两路都开着时照样能加 */
ASSERT_HAS(send("{\"seq\":11,\"cmd\":\"power\",\"rail\":3,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":12,\"cmd\":\"flash_mode\",\"rail\":3,\"on\":true}"), "\"ok\":true");
ASSERT_HAS(send("{\"seq\":13,\"cmd\":\"flash_mode\",\"rail\":3,\"add\":10}"), "\"left\":4200");
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_cmd_numbers_are_frozen);
RUN_TEST(test_same_seq_twice_executes_once_across_links);
RUN_TEST(test_retransmit_after_the_sequence_finished_is_still_dropped);
RUN_TEST(test_a_rejected_command_stays_rejected_on_retransmit);
RUN_TEST(test_same_seq_different_command_is_not_executed);
RUN_TEST(test_window_is_by_value_so_a_restarted_host_is_not_blocked);
RUN_TEST(test_seq_rules);
RUN_TEST(test_voltage_out_of_range_is_rejected_and_logged);
RUN_TEST(test_voltage_in_range_is_written_under_the_bus_lock);
RUN_TEST(test_other_range_checks);
RUN_TEST(test_argument_errors_are_not_logged);
RUN_TEST(test_every_rail_command_is_refused_on_an_empty_slot);
RUN_TEST(test_combo_keys_land_on_the_named_rail);
RUN_TEST(test_busy_rail_refuses_a_second_sequence);
RUN_TEST(test_reboots_are_logged_with_kind_and_link);
RUN_TEST(test_vbus_needs_the_rail_powered);
RUN_TEST(test_flash_mode_command);
RUN_TEST(test_power_off_ends_flash_mode);
RUN_TEST(test_flash_mode_expiry_is_logged_once);
RUN_TEST(test_flash_mode_ends_when_the_rail_loses_power);
RUN_TEST(test_flash_mode_extend);
RUN_TEST(test_power_off_drops_vbus_and_always_drops_the_efuse);
RUN_TEST(test_power_records_intent_only_when_it_took_effect);
RUN_TEST(test_fan_floor);
RUN_TEST(test_hub_command);
RUN_TEST(test_events_pages_with_contiguous_seqs);
RUN_TEST(test_status_fits_and_reports_unknowns_as_null);
RUN_TEST(test_small_output_buffer);
return UNITY_END();
}