/* ESP32-S3 入口。只在 ESP-IDF 目标里编译,宿主机构建不包含本文件。 */ #include #include #include "board_pins.h" #include "bringup.h" #include "cmd.h" #include "cmd_queue.h" #include "esp_log.h" #include "esp_system.h" #include "evlog.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" #include "freertos/task.h" #include "hal_gpio.h" #include "hal_i2c.h" #include "hal_lock.h" #include "hal_time.h" #include "i2cbus.h" #include "ina226.h" #include "pca9555.h" #include "phonerail.h" #include "rail_cfg.h" #include "sched.h" #include "slot.h" #include "t_actuator.h" #include "t_safety.h" #include "t_thermal.h" #include "tasks.h" #include "thermal_esp.h" #include "mlink_esp.h" #include "nvs.h" #include "nvs_flash.h" #include "ota.h" #include "ota_esp.h" #include "warm_esp.h" #include "ulink_esp.h" static const char *TAG = "phonerail"; /* 启动自检(需求 §10.4):五路的地址 / 位号 / 引脚无重复、无越界, 而且那张五路表和四个驱动自己的表没有分家。 ⚠️ **不过就 abort,不降级。** 这里查的全是编译进固件的常量表, 查不过 = 固件本身写错了,不是硬件坏了 —— §10.10 的降级矩阵管的是后者。 带着一张错表跑起来的样子是「命令发给了另一路」,五台手机全在跑、 一个错误码都不报,谁也不会去怀疑它。 */ static void selftest_or_die(void) { /* 五个任务那张表(§10.3):周期与优先级的相对顺序。同样是编译进去的常量, 同样不过就 abort —— 优先级写反了板子照跑,只是巡检会在总线忙时被挤后。 */ if (!task_table_ok()) { ESP_LOGE(TAG, "任务表自检失败:周期或优先级与 §10.3 对不上"); abort(); } const uint32_t bad = rail_selftest(); if (bad == 0) { ESP_LOGI(TAG, "五路配置表自检通过"); return; } for (uint32_t bit = 1; bit != 0; bit <<= 1) { if ((bad & bit) != 0) { ESP_LOGE(TAG, "自检失败:%s (0x%02x)", rail_selftest_str(bit), (unsigned)bit); } } abort(); } /* ---- 五个任务(需求 §10.3) --------------------------------------------- 任务体只在这个文件里,而且都短得能一眼看完 —— 真正的活在 main/ 的其它 .c 里, 那些在宿主机上编得过、测得到。**任务体里不许出现判据与时序**:写进死循环里的 东西,宿主机一行也跑不到。 */ /* 巡检快照。**文件作用域**(A8 时它还在 task_safety 里):t_actuator 的硬重启 序列要等 PG,而 PG 挂在 PCA9555 上、已经被巡检每 20 ms 读一遍了(§10.3)。 执行器再去读一次等于凭空多一份总线占用和一份两边不一致的可能。 ⚠️ 这是跨任务读,但读的只有两个 `bool`(`io_ok` 与一个位), 字节级读写在 Xtensa 上是原子的,不值得为它加第三把锁(hal_lock.h:只有两把)。 */ static safety_snap_t s_snap; /* 喂给 t_actuator 的 PG 取值。返回 false = **这一刻读不到** (PCA9555 失联,或者巡检还没跑过一圈)—— 与「读到了,是低」是两回事。 */ static bool act_pg_from_safety(void *ctx, int rail, bool *pg) { (void)ctx; /* ⭐ R2:PCA9555 变成**每路一片**(五片同构、同地址 0x20,靠 mux 分开), 所以快照里的输入也是每路一份 —— R1 那时全板只有一片,只有一份。 ⚠️ 这里的 `rail - 1` 是**数组下标**,不是由路号算出来的器件编号 (rail_cfg.h 禁的是后者)。器件是哪一片由 `slot_*` 那一层查表决定。 */ if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) { return false; } if (!s_snap.io_ok[rail - 1]) { return false; } /* 极性只在驱动里出现一次。⚠️ R2 的判读函数**不带 rail** —— 五片逐格相同, 位号是板级常量(pca9555.h),传路号进去反而会让人以为五片位表不一样。 */ *pg = pca9555_efuse_pg(&s_snap.io[rail - 1]); 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。 ⚠️ 这个回调是在断电之后、不持任何锁的情况下被调的:**入队在前,打印在后**。 `ESP_LOGW` 会去抢串口,把它放在入队之前就是在事件最要紧的那一刻多等一会儿。 */ static void log_safety_event(void *ctx, const safety_event_t *ev) { evlog_put_safety(ctx, ev); ESP_LOGW(TAG, "安全事件 t=%u ms CH%u %s V=%d mV I=%d uA", (unsigned)ev->t_ms, (unsigned)ev->rail, t_safety_ev_str((safety_ev_t)ev->type), (int)ev->bus_mv, (int)ev->current_ua); } static void task_safety(void *arg) { (void)arg; safety_snap_t *const snapp = &s_snap; t_safety_init(snapp); sched_periodic_t tick; sched_periodic_init(&tick, task_desc(TASK_SAFETY)->period_ms); char line[128]; for (;;) { (void)t_safety_step(snapp); /* 器件读失败不在这里处理:§10.2 失联 ≠ 断电 */ /* 每 5 s 报一次总线占用。§10.3 手算是「约 1.5 ms / 约 8 %」, 首板上电时要拿逻辑分析仪复核这个数(§10.3 末尾那句)。 */ if ((snapp->cycles % 250u) == 0u) { t_safety_format(snapp, line, sizeof(line)); ESP_LOGI(TAG, "%s (预算 %u us, 过载 %u, 最迟 %u ms)", line, (unsigned)t_safety_budget_us(), (unsigned)tick.overruns, (unsigned)tick.worst_late_ms); } sched_periodic_wait(&tick); } } /* 下面四个是骨架:周期与优先级已经按 §10.3 定好,任务体等各自的包。 现在就把它们建起来,是为了让 A9~A16 只需要往里填,不用再动启动流程。 */ /* 热管理快照(A16)。写者只有本任务;`status` 命令跨任务只读(cmd.h:遥测无害)。 */ static thermal_snap_t s_therm; /* A16:两路 NTC → 风扇 PWM 闭环 + TACH 采样窗口(TR-6)。判据全在 `t_thermal_step()`, 温度 / TACH 的来源在 thermal_esp.c。`t_thermal_init()` 在 app_main 里做(风扇要先于上电转起来)。 */ static void task_thermal(void *arg) { (void)arg; sched_periodic_t tick; sched_periodic_init(&tick, task_desc(TASK_THERMAL)->period_ms); for (;;) { (void)t_thermal_step(&s_therm); /* 写 PWM 失败只计数(pwm_errors),不在这里处理 */ /* 每 10 s 一行:温度 / 占空比 / 两路转速。上板定阈值(t_thermal.h 那四个「待实测」)就看它。 */ if ((s_therm.cycles % 10u) == 1u) { ESP_LOGI(TAG, "t_thermal: %d/%d m°C (%d/%d mV) duty=%u%s rpm=%u/%u%s%s", s_therm.temp_ok[0] ? (int)s_therm.temp_mc[0] : -1, s_therm.temp_ok[1] ? (int)s_therm.temp_mc[1] : -1, (int)thermal_esp_last_mv(0), (int)thermal_esp_last_mv(1), (unsigned)s_therm.duty, s_therm.temp_valid ? "" : "(读不到温度→满速)", (unsigned)s_therm.fan[0].rpm, (unsigned)s_therm.fan[1].rpm, (s_therm.fan[0].stalled || s_therm.fan[1].stalled) ? " 停转!" : "", s_therm.derate ? " 过温降载!" : ""); } sched_periodic_wait(&tick); } } /* ---- 命令队列(A12)-------------------------------------------------------- 适配器(A13 USB / A14 WiFi)投递一行,执行器任务取出来调 `cmd_handle_line()`。 处理器放在执行器任务里而不是链路任务里,是因为 `t_actuator_*` 的状态机没有锁: 请求函数与 `t_actuator_step()` 必须同一个任务调(cmd.h「在哪个任务里跑」)。 深度 4:人点按钮 + 上位机轮询 status,四条排队已经是异常情形了。 */ typedef struct { cmd_link_t link; cmd_reply_fn reply; void *ctx; char line[CMD_LINE_MAX]; } cmd_msg_t; #define CMD_QUEUE_DEPTH 4 static QueueHandle_t s_cmd_q; bool cmd_queue_submit(cmd_link_t link, const char *line, cmd_reply_fn reply, void *ctx) { if (s_cmd_q == NULL || line == NULL) { return false; } cmd_msg_t m = { .link = link, .reply = reply, .ctx = ctx }; const size_t n = strlen(line); if (n >= sizeof(m.line)) { return false; /* 超长行整行拒收,不截断(截断的 JSON 可能碰巧仍然合法) */ } memcpy(m.line, line, n + 1); return xQueueSend(s_cmd_q, &m, 0) == pdTRUE; /* 不等:满了就是满了,让适配器去报 */ } /* A9 的三个状态机 + A12 的命令。任务体:推一步 → 带着「下一个截止时刻」去等命令 → 有命令就处理、回应,然后回到开头再推一步。判据与时序全在宿主机上跑得到的那两个函数里。 */ static void task_actuator(void *arg) { (void)arg; static char reply[CMD_OUT_MAX]; /* 静态:1.5 KB 放栈上会把 4 KB 的栈吃掉一大半 */ cmd_msg_t m; for (;;) { cmd_chgmode_tick(hal_time_now_ms()); /* 刷机模式到期清理 + 记事件(chgmode.h:只在本任务写) */ const TickType_t t = pdMS_TO_TICKS(t_actuator_step()); /* ⚠️ `CONFIG_FREERTOS_HZ=100` ⇒ 一个 tick 是 10 ms,`pdMS_TO_TICKS(5)` 是 **0**,而「等 0 tick」只是查一眼就回来 —— 那会让这个中优先级任务 在收尾重试时空转,把 t_link / t_log 饿死。至少等一个 tick。 早醒没关系:每个状态都是拿绝对时刻算的,早来就再返回一个剩余时间。 */ if (xQueueReceive(s_cmd_q, &m, t == 0 ? 1 : t) == pdTRUE) { const size_t n = cmd_handle_line(m.link, m.line, reply, sizeof(reply)); if (m.reply != NULL) { m.reply(m.ctx, reply, n); } } } } /* 两条传输链路(§10.8)。本任务只收字节、切行、投进命令队列 —— **不执行命令**(那在执行器任务里,cmd.h)。USB(A13)在这里收包;MQTT(A14)的收包在 esp-mqtt 自己的任务里,这里只管它的生命周期与遥测节拍。 */ static void task_link(void *arg) { (void)arg; for (;;) { ulink_esp_poll(100); /* 最多等 100 ms 一包;没有 USB 驱动时退化成睡 100 ms */ mlink_esp_poll(); ota_esp_poll(); /* C2:新固件的健康判据 + 到点重启 */ } } /* A11:唯一碰 flash 的任务。§10.7「`t_log` 空闲时批量写」—— 醒得比攒批周期密(100 ms),但 `evlog_due()` 说不用写就立刻回去睡: 代价是每秒 10 次几十条指令的问询,换来的是掉电丢失窗口的上界就是 `EVLOG_MAX_AGE_MS`,而不是「上一次醒到下一次醒」。 */ static void task_log(void *arg) { (void)arg; evlog_stats_t st; uint32_t reported = 0; for (;;) { if (evlog_due()) { (void)evlog_flush(EVLOG_FLUSH_MAX); } /* 丢过事件就报出来。§10.7 的日志是排查排线接触不良的唯一线索, 而「日志自己丢过东西」这件事必须比日志本身更显眼。 */ evlog_stats(&st); if (st.dropped != reported) { reported = st.dropped; ESP_LOGW(TAG, "事件缓冲丢过 %u 条(落盘 %u / 入队 %u,IO 错 %u)", (unsigned)st.dropped, (unsigned)st.stored, (unsigned)st.queued, (unsigned)st.io_errors); } vTaskDelay(pdMS_TO_TICKS(100)); } } /* ---- 每路「意图」的持久化(A15,§10.9 第 4 步「上次的每路使能状态」)-------- 一个字节,bit0 = CH1。只有 `power` 命令改它(cmd.h 的 `cmd_intent_fn`)。 与网络配置同一个 NVS 命名空间(mlink_esp.c),各用各的键。 新板 / 读不到 = 0 = 一路都不开:宁可让人手动开一次,也不在不知道的情况下给五台手机上电。 */ #define WANT_NS "phonerail" #define WANT_KEY "rail_want" static uint8_t s_want; static void nvs_up(void) { esp_err_t e = nvs_flash_init(); if (e == ESP_ERR_NVS_NO_FREE_PAGES || e == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); /* 同 mlink_esp.c:格式不认就清掉重来 */ e = nvs_flash_init(); } if (e != ESP_OK) { ESP_LOGE(TAG, "NVS 起不来:%s —— 本次按「一路都不开」启动", esp_err_to_name(e)); } } static uint8_t want_load(void) { nvs_handle_t h; uint8_t v = 0; if (nvs_open(WANT_NS, NVS_READONLY, &h) == ESP_OK) { if (nvs_get_u8(h, WANT_KEY, &v) != ESP_OK) { v = 0; } nvs_close(h); } return v; } static void want_save(void *ctx, int rail, bool on) { (void)ctx; const uint8_t v = bringup_want_update(s_want, rail, on); if (v == s_want) { return; /* 没变就不写 flash */ } nvs_handle_t h; esp_err_t e = nvs_open(WANT_NS, NVS_READWRITE, &h); if (e == ESP_OK) { e = nvs_set_u8(h, WANT_KEY, v); if (e == ESP_OK) { e = nvs_commit(h); } nvs_close(h); } if (e == ESP_OK) { s_want = v; } else { /* 存不下不影响这一次的开关(已经生效了),只影响下次开机恢复成什么样。 */ ESP_LOGW(TAG, "意图没存进 NVS:%s", esp_err_to_name(e)); } } static void log_report(const bringup_report_t *r) { ESP_LOGI(TAG, "启动:降级位 0x%02x,空槽 0x%02x,盲跑 0x%02x,PCA 失联 0x%02x,PD %u mA → 上限 %u mW", (unsigned)r->deg, (unsigned)r->empty, (unsigned)r->blind, (unsigned)r->io_lost, (unsigned)r->pd_ma, (unsigned)r->pd_limit_mw); if ((r->deg & BRINGUP_DEG_MUX) != 0) { ESP_LOGE(TAG, "TCA9548A 复位后仍选不中 —— 五路一路都不上电(§5.8)"); } if ((r->deg & BRINGUP_DEG_DAC_NVM) != 0) { ESP_LOGW(TAG, "DAC 上电值不是 3.90 V(NVM 被改过?):%u %u %u %u %u mV", r->dac_nvm_mv[0], r->dac_nvm_mv[1], r->dac_nvm_mv[2], r->dac_nvm_mv[3], r->dac_nvm_mv[4]); } } static void spawn(task_id_t id, TaskFunction_t fn) { const task_desc_t *d = task_desc(id); /* ⚠️ ESP-IDF 的 xTaskCreate 收的 stack_depth 是**字节**(原版 FreeRTOS 是字)。 */ if (xTaskCreate(fn, d->name, d->stack_bytes, NULL, d->prio, NULL) != pdPASS) { ESP_LOGE(TAG, "建任务失败:%s", d->name); abort(); } } void app_main(void) { ESP_LOGI(TAG, "PhoneRail %s boot, %d rails, t=%u ms", phonerail_version(), PHONERAIL_RAIL_COUNT, (unsigned)hal_time_now_ms()); /* ⛔ 关掉 IDF I²C 驱动自己的错误日志(2026-09-27 上板实测)。它每次 NAK 打三行 E 级日志, 打印是同步的、发生在**我们持着总线锁的时候**,一次 NAK 就吃掉十几 ms —— 20 ms 的巡检节拍直接被它的日志拖垮,而且刷屏把真正有用的启动日志全冲掉。 失败并没有丢:每一次都进 `t_safety` 的 i2c_err 计数,每 10 s 汇总打印一次。 */ esp_log_level_set("i2c.master", ESP_LOG_NONE); selftest_or_die(); const esp_reset_reason_t why = esp_reset_reason(); /* 「重启不断电」:是不是我们自己计划的重启、重启前哪几路开着(warm_esp.c)。 **必须在 bringup 之前**:那几根 EN 此刻还被上一条命锁在高,bringup 第一步决定留还是放。 */ const uint8_t adopt = warm_take(why); /* 事件日志**最先**起(A15 挪到这里):下面的启动序列会记事件(缺件 / mux / PD), 它们必须排在这次的 BOOT 之后,否则时间线上看着像是上一次运行留下的。 BOOT 的 arg = 复位原因 —— §10.10「看门狗复位 … 记一条复位事件」就是它。 只碰 flash,不碰总线,放在 EN 脚配置之前也安全(那几根线此刻靠下拉关着)。 ⚠️ 失败**不 abort**:日志是排查手段,不是安全链的一环(§10.2)。 */ hal_err_t err = evlog_init_ex((uint8_t)why); if (err != HAL_OK) { ESP_LOGE(TAG, "事件日志落盘不可用:%s —— 事件只留在 RAM 里", hal_err_str(err)); } else { evlog_stats_t st; evlog_stats(&st); ESP_LOGI(TAG, "事件日志:容量 %u 条,flash 里已有 %u~%u(复位原因 %d)", (unsigned)evlog_capacity(), (unsigned)st.oldest_seq, (unsigned)st.next_seq, (int)why); } /* C2:OTA。紧跟在事件日志后面:「这是 OTA 后的第一次启动」「有过回滚」要尽早知道, 兜底回滚的定时器也要尽早上弦(后面任何一步卡死都要能回滚)。失败不 abort。 */ err = ota_esp_init(); if (err != HAL_OK) { ESP_LOGE(TAG, "OTA 不可用:%s(分区表不是双槽?)", hal_err_str(err)); } nvs_up(); s_want = want_load(); /* §10.9 第 1~4 步:EN 脚安全态 → Hub 脉冲(仅上电复位)→ 总线 → 认件 / 初始化。 整段在 bringup.c 里、宿主机上测得到(A15 之前它在这里,一行也测不到)。 缺件一律降级不 abort;返回错误 = 引脚 / 配置常量写错了,那才 abort。 */ static bringup_report_t boot; const bringup_in_t in = { .power_on_reset = (why == ESP_RST_POWERON), .adopt = adopt }; err = bringup_devices(&in, &boot); if (err != HAL_OK) { ESP_LOGE(TAG, "启动序列失败(固件配置错误):%s", hal_err_str(err)); abort(); } log_report(&boot); if (adopt != 0) { evlog_put(0, LOG_EV_OTA, OTA_EV_WARM, (int32_t)boot.adopted, (int32_t)boot.adopt_dropped); ESP_LOGI(TAG, "接管:一直开着 0x%02x,关掉 0x%02x(刀片不在 / mux 失联)", (unsigned)boot.adopted, (unsigned)boot.adopt_dropped); } /* §10.9 第 7 步的前半:风扇闭环。**放在上电之前**:t_thermal_init 先满速、再拉 FAN_EN, 第一圈之前风扇就在转(t_thermal.h)。ADC / PCNT 起不来不 abort —— 温度读不到 t_thermal 会一直满速(§10.10),TACH 读不到就不判停转。 */ err = thermal_esp_init(); if (err != HAL_OK) { ESP_LOGE(TAG, "温度 / 转速采样不完整:%s(温度读不到 ⇒ 风扇满速)", hal_err_str(err)); } err = t_thermal_init(&s_therm, thermal_esp_temp, thermal_esp_tach, NULL); if (err != HAL_OK) { ESP_LOGE(TAG, "风扇起不来:%s —— 没有主动散热", hal_err_str(err)); } /* 执行器:PG 从巡检的快照里取,本任务自己不碰 I²C(t_actuator.h 开头那一段)。 ⚠️ 必须在 `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)。 队列要在执行器任务起来之前建好 —— 那个任务第一件事就是等它。 */ const cmd_env_t cmd_env = { .safety = &s_snap, .thermal = &s_therm, .intent = want_save }; cmd_init(&cmd_env); s_cmd_q = xQueueCreate(CMD_QUEUE_DEPTH, sizeof(cmd_msg_t)); if (s_cmd_q == NULL) { ESP_LOGE(TAG, "命令队列建不起来"); abort(); } /* 事件出口。要在巡检起来之前接上,否则第一批事件没人接。 */ t_safety_set_event_sink(log_safety_event, NULL); /* ⭐ 巡检**先于上电**起来(bringup.h「分两段」那一段): 五路一开始带电,SR-5 / SW-8 就已经在看了。 */ spawn(TASK_SAFETY, task_safety); /* §10.9 第 5~6 步:按上次的意图逐路上电、错开 100 ms、等 PG(阻塞,最坏约 0.9 s)。 */ bringup_power_restore(s_want, &boot); ESP_LOGI(TAG, "上电恢复:意图 0x%02x → 开着 0x%02x(等不到 PG 0x%02x,确认不了 0x%02x)", (unsigned)boot.want, (unsigned)boot.on, (unsigned)boot.pg_fail, (unsigned)boot.unverified); /* §10.9 第 7 步:链路与其余任务。 A13:USB 命令链路。装驱动 + 把日志复制一份到 USB(UART0 照旧)。 失败不 abort:USB 命令链路不可用,供电与别的链路照常(§10.2)。 */ err = ulink_esp_init(); if (err != HAL_OK) { ESP_LOGE(TAG, "USB 命令链路不可用:%s", hal_err_str(err)); } /* A14:WiFi / MQTT 命令链路(配置在 NVS,经 USB 下发)。失败同样不 abort。 */ err = mlink_esp_init(); if (err != HAL_OK) { ESP_LOGE(TAG, "WiFi 命令链路不可用:%s", hal_err_str(err)); } spawn(TASK_THERMAL, task_thermal); spawn(TASK_ACTUATOR, task_actuator); spawn(TASK_LINK, task_link); spawn(TASK_LOG, task_log); ota_esp_boot_done(); /* 健康判据的前一半:启动序列走完了 */ ESP_LOGI(TAG, "五个任务已启动(t_safety %u ms / t_thermal %u ms)," "I²C 预算 %u us 每周期", (unsigned)task_desc(TASK_SAFETY)->period_ms, (unsigned)task_desc(TASK_THERMAL)->period_ms, (unsigned)t_safety_budget_us()); }