固件:A12 命令处理器 + A13 USB 分帧 + A14 WiFi/MQTT(含 C1 配网)

- A12 单一命令处理器(cmd*.c):一行 JSON 进出、seq 全局判重(32 条按值)、
  限幅 / 空槽一律拒 / 拒绝记事件;命令号与错误码冻结(进 flash)。新增 power 命令,
  fan 改为只抬不压的下限(t_thermal_set_floor)。rail_set_mv 注明不拿锁,调用方包 xact。
- A13 USB-Serial-JTAG 分帧(ulink*.c):@C / @R 前缀 + CRC-16,日志行首 @ 垫空格,
  任意切块可切;关掉 USB 副控制台(它绕开驱动写 FIFO)。PC 端客户端 tools/prlink.py。
- A14 + C1(netcfg / mlink / cmd_net):topic phonerail/<mac>/{cmd,rsp,tele,state},
  mqtts(evse-ca 编进固件)、1 s 遥测、遗嘱离线;wifi / mqtt 配置只收 USB、存 NVS,
  net 永不回显密码。
- 协议定稿写进需求文档 §10.8(A12 / A13 / A14 三节);N7 撤销改 D7(机架管理前后端)。

宿主机 23 个 ctest / 400 例全绿;板 B 已烧 0.1.0-a14,经 WiFi → 公网 → 8883 TLS 真连网验过。
固件 0xE2220 / 1 MB 分区只剩 12%,C2 前要重排分区表。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-27 12:33:18 +08:00
共同撰写人 Claude Opus 5.5
父节点 cae567dfd8
当前提交 a32fa06c43
共修改 38 个文件,包含 4490 行新增和 85 行删除
+132
查看文件
@@ -0,0 +1,132 @@
/* `status` 与 `events` 两条只读命令的正文(A12,需求 §10.8)。
两条都是**只读、幂等**的,所以不进判重窗口(cmd.h)。
输出一律是整数(mV / µA / m°C / 千分比 / ms),不引浮点,和固件内部口径一致;
读不到的量写 `null`,**不写 0** —— 0 mA 与「这一圈没读到」是两回事,
上位机的卡死判据(电流曲线)拿 0 当真值会直接误判。 */
#include <stdio.h>
#include "cmd_priv.h"
#include "evlog.h"
#include "hal_time.h"
#include "pca9555.h"
#include "phonerail.h"
#include "rail.h"
#include "rail_cfg.h"
#include "slot.h"
#include "t_actuator.h"
static const char *b(bool v) { return v ? "true" : "false"; }
static void rail_obj(cj_writer_t *w, int rail, const safety_snap_t *s)
{
const int i = rail - 1;
cj_w(w, "{\"r\":%d,\"pr\":%s,\"en\":%s,\"vb\":%s,\"set\":%d", rail, b(slot_present(rail)),
b(rail_efuse_is_on(rail)), b(slot_vbus_is_on(rail)), rail_setpoint_mv(rail));
if (s != NULL) {
if (s->rail_ok[i]) {
cj_w(w, ",\"mv\":%ld,\"ua\":%ld", (long)s->rail[i].bus_mv,
(long)s->rail[i].current_ua);
} else {
cj_w(w, ",\"mv\":null,\"ua\":null");
}
if (s->io_ok[i]) {
cj_w(w, ",\"pg\":%s,\"flt\":%s", b(pca9555_efuse_pg(&s->io[i])),
b(pca9555_efuse_fault(&s->io[i])));
} else {
cj_w(w, ",\"pg\":null,\"flt\":null");
}
}
cj_w(w, ",\"st\":\"%s\",\"res\":\"%s\"}", t_actuator_state_str(t_actuator_state(rail)),
t_actuator_result_str(t_actuator_last_result(rail)));
}
static void thermal_obj(cj_writer_t *w, const thermal_snap_t *t)
{
cj_w(w, ",\"temp\":[");
for (int i = 0; i < T_THERMAL_NTC_COUNT; i++) {
if (t->temp_ok[i]) {
cj_w(w, "%s%ld", i ? "," : "", (long)t->temp_mc[i]);
} else {
cj_w(w, "%snull", i ? "," : "");
}
}
cj_w(w, "],\"fan\":{\"duty\":%u,\"floor\":%u,\"en\":%s,\"rpm\":[", (unsigned)t->duty,
(unsigned)t_thermal_floor(), b(t->fan_en));
for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) {
if (t->fan[i].rpm_valid) {
cj_w(w, "%s%lu", i ? "," : "", (unsigned long)t->fan[i].rpm);
} else {
cj_w(w, "%snull", i ? "," : "");
}
}
cj_w(w, "],\"stall\":[");
for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) {
cj_w(w, "%s%s", i ? "," : "", b(t->fan[i].stalled));
}
cj_w(w, "]},\"derate\":%s", b(t->derate));
}
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());
if (s != NULL) {
if (s->input_ok) {
cj_w(w, ",\"in\":{\"mv\":%ld,\"ua\":%ld}", (long)s->input.bus_mv,
(long)s->input.current_ua);
} else {
cj_w(w, ",\"in\":null");
}
}
cj_w(w, ",\"rails\":[");
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
if (rail > 1) {
cj_w(w, ",");
}
rail_obj(w, rail, s);
}
cj_w(w, "]");
if (env->thermal != NULL) {
thermal_obj(w, env->thermal);
}
if (s != NULL) {
cj_w(w, ",\"safety\":{\"cycles\":%lu,\"skipped\":%lu,\"i2c_err\":%lu,\"events\":%lu}",
(unsigned long)s->cycles, (unsigned long)s->skipped,
(unsigned long)s->i2c_errors, (unsigned long)s->events);
}
evlog_stats_t es;
evlog_stats(&es);
cmd_stats_t cs;
cmd_stats(&cs);
/* `ev_next` 是**已落盘**的下一个序号;还在 RAM 里的 `ev_pending` 条要等 events 触发落盘。
上位机拿 `ev_next` 与自己的游标比,就知道要不要拉日志,不用每次都拉。 */
cj_w(w, ",\"ev_next\":%lu,\"ev_pending\":%lu,\"cmd\":{\"lines\":%lu,\"dups\":%lu,\"rej\":%lu}",
(unsigned long)es.next_seq, (unsigned long)es.pending, (unsigned long)cs.lines,
(unsigned long)cs.dups, (unsigned long)cs.rejects);
}
void cmd_report_events(cj_writer_t *w, uint32_t from_seq)
{
(void)evlog_flush(0);
evlog_stats_t es;
evlog_stats(&es);
evlog_rec_t rec[CMD_EVENTS_MAX];
const uint32_t n = evlog_read(from_seq, rec, CMD_EVENTS_MAX);
/* `evlog_read()` 从 max(from, oldest) 开始读、序号连续(洞也占位),
所以每条的序号可以这样算回来 —— 回应里逐条带上,上位机不用自己推。 */
const uint32_t start = (from_seq < es.oldest_seq) ? es.oldest_seq : from_seq;
/* `oldest` 大于上位机的游标 ⇒ 中间那一段已经被环形缓冲盖掉了(evlog.h)。 */
cj_w(w, ",\"oldest\":%lu,\"next\":%lu,\"ev\":[", (unsigned long)es.oldest_seq,
(unsigned long)es.next_seq);
for (uint32_t i = 0; i < n; i++) {
/* [序号, 上电毫秒, 路, 类型, arg, mV, µA]。类型是 `log_ev_t` 的冻结数字,
名字表在上位机(中文名进不了 ASCII 行,也没必要每条都带)。 */
cj_w(w, "%s[%lu,%lu,%u,%u,%u,%ld,%ld]", i ? "," : "", (unsigned long)(start + i),
(unsigned long)rec[i].t_ms, (unsigned)rec[i].rail, (unsigned)rec[i].type,
(unsigned)rec[i].arg, (long)rec[i].bus_mv, (long)rec[i].current_ua);
}
cj_w(w, "]");
}