diff --git a/PROGRESS.md b/PROGRESS.md index b049d90..d1e1ddb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,5 +1,11 @@ # 进度与交接 +## ⭐ 2026-09-27 夜:固件 A16(风扇闭环 + NTC/TACH 真实采样)完成 ⇒ A1~A16 齐了 + +- 新增 `firmware/main/ntc.{h,c}`(R-T 表换算,宿主机 4 例)与 `thermal_esp.{h,c}`(ADC1 + PCNT);任务体接上,status 带温度 / 风扇。全工程 429 例全绿。 +- 板 B 已烧 **0.1.0-a16**:两路 NTC 38.2 / 34.0 °C(合理);两路风扇 `rpm 0` 报停转 —— 拉满占空比输入电流不涨,**多半是风扇没插**,待确认。 +- 分区只剩 9%(C2 前必须重排)。详见 `firmware/HANDOFF.md`。 + ## ⭐ 2026-09-27 晚:固件 A15(启动顺序 + 降级矩阵)完成,已上板 - 启动序列从 `app_main.c` 拆到 `firmware/main/bringup.{h,c}`,宿主机 `test_bringup.c` 24 例(逐个拔器件对 §10.10 逐行),全工程 425 例全绿。 diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index 204f9ff..21a16bb 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -1,49 +1,33 @@ # 交接 -更新:2026-09-27 完成 **A15**(启动顺序 + 降级矩阵,需求 §10.9 / §10.10) +更新:2026-09-27 完成 **A16**(风扇闭环接进任务体 + NTC / TACH 的真实采样)。**A1~A16 全部完成 ⇒ M1 的固件部分齐了** ## 一句话现状 -启动序列从 `app_main.c`(只进 IDF、一行测不到)拆进 `main/bringup.{h,c}`,宿主机 `test_bringup.c` **24 例**逐个「拔掉」器件对 §10.10 逐行。 -**24 个 ctest、425 个用例全绿**,`idf.py build` 通过(**0xE2C60,分区剩 11%**)。板 B(COM5)已烧 **0.1.0-a15**。 +`t_thermal` 的判据在 R-6 就写好测过了;A16 补的是它两个回调的真实来源(NTC 走 ADC1、TACH 走 PCNT)和任务体。 +**25 个 ctest、429 个用例全绿**,`idf.py build` 通过(**0xE9920,分区只剩 9%**)。板 B(COM5)已烧 **0.1.0-a16**。 ## 下一步 -- **A16**:风扇闭环接进 `t_thermal` 任务体(app_main.c 的 `task_thermal` 还是空壳;`cmd_env.thermal` 仍是 NULL)。 -- 服务器:`rack/backend/prrack/protocol.py` 已加事件 17,已部署(HOST=10.5.5.13,26 例过)。 -- 待核:CH224A 读到 5000 mA(100 W),而 PROGRESS 早上记的是 20V/3A —— 换了充电器,还是 0x50 的理解有误? +- ⚠️ **风扇待你确认**:板 B 上两路都报 `rpm 0` + 停转;拉到 100 % 时输入电流**没有上升**(130 → 110 mA,属波动) + ⇒ 多半是**风扇没插 / 没转**,而不是 TACH 没数到。插上风扇后看 `status.fan.rpm`;若在转却仍是 0,查 Q9/Q10 与 PCNT。 +- 风扇闭环四个阈值 + 占空比下限 + PPR 都是「待上板实测」(t_thermal.h),手机上架跑满载之后再定。 +- **分区只剩 9%**:C2(OTA 双分区)前必须重排分区表(模组 16 MB,sdkconfig 只按 8 MB 用)。 +- 仍欠(A15 留下的):I²C 总线挂死恢复(HAL 层);PD 上限的降载动作(§8.3);CH224A 读到 100 W 与早上记的 20V/3A 不一致,待核。 ## 本次新增/修改的文件 -- `main/bringup.{h,c}` **新** — 两段:`bringup_devices()`(§10.9 第 1~4 步 + 降级)、`bringup_power_restore(want)`(第 5~6 步) -- `main/app_main.c` — 启动段换成两段调用;**`t_safety` 在两段之间 spawn**(上电时巡检已在看); - `evlog_init_ex(esp_reset_reason())` 挪到最前;NVS 键 `phonerail/rail_want` 存「意图」;`hub_reset_pulse_once()` 搬进 bringup.c -- `main/cmd.h` / `cmd_exec.c` — `cmd_env_t` 加 `intent` 回调,只有 `power` 命令真的生效时调 -- `main/evlog.{h,c}` — `evlog_init_ex(boot_arg)`;新事件 **`LOG_EV_PG_TIMEOUT = 17`** -- `main/rail.{h,c}` — `rail_volt_init_one(addr)`;`rail_volt_init()` 一片失败**不再放弃另一片** -- sim:`sim_gpio_rise_us()`、`sim_gpio_set_hook()`;`sim_gpio_drive()` 可在配置前调、跨 config 保持(刀片把 PRSNT# 短到地与固件何时配脚无关) -- `test/test_bringup.c` **新**(24 例);`test_cmd.c` +1(意图);`test_evlog.c` 冻结号 +17;版本 `0.1.0-a15` +- `main/ntc.{h,c}` **新**(宿主机可测)— mV → Ω → 查 Murata NCP18XH103F03RB 的 R-T 表(−40~125 °C、5 °C 一格、格内线性插值); + 贴 +3V3 / 贴地 / 表外一律「读不到」(⇒ t_thermal 满速) +- `main/thermal_esp.{h,c}` **新**(只进 IDF)— ADC1 oneshot + 曲线拟合校准(8 次平均,引脚反查通道);两个 PCNT 单元数 TACH 上升沿(累计计数、1 µs 毛刺滤波) +- `main/app_main.c` — `task_thermal` 任务体(每 10 s 打一行温度 / mV / 占空比 / 转速);`thermal_esp_init()` + `t_thermal_init()` 放在**上电恢复之前**;`cmd_env.thermal = &s_therm` +- `main/CMakeLists.txt` REQUIRES 加 `esp_adc esp_driver_pcnt`;`host/CMakeLists.txt` 加 `ntc.c` +- `test/test_ntc.c` **新**(4 例:表点、⭐方向「越热电压越低」、⭐开路/短路不是温度、比例式);版本 `0.1.0-a16` -## §10.10 各行落在哪 -| 行 | 行为 | 用例 | -|---|---|---| -| 刀片 INA226 无应答 | 照常上电、`blind`、I2C_LOST(rail, 0x40) | `test_row_rail_ina_missing_keeps_power` | -| DAC 无应答 | 不关输出、另一片照样初始化、影子回 3.90 | `test_row_dac_missing_…` | -| PCA9555 无应答 | 照常上电,PG 改按 INA226 电压;两样都看不见 = 开着报 unverified | `test_row_pca_*`(3 例) | -| mux 选不中 | 脉冲一次 MUX_RST#,仍不通 ⇒ 一路不开、MUX_LOST,不碰主干 | `test_row_mux_*`(2 例) | -| PRSNT# 高 | 不发一个总线字节、不上电;想开的才记 SLOT_EMPTY | `test_row_empty_slot_*`(2 例) | -| CH224A 读不到 / 偏低 | 按 60 W / 按读到的,PD_LIMIT 事件 | `test_row_pd_*` + 通道 5 兜底 | -| 看门狗复位 | BOOT.arg = 复位原因 | `test_row_watchdog_reset_is_recorded_in_boot` | -| **I²C 总线挂死** | ⛔ **没做**(9 个时钟恢复在 HAL 层) | — | - -注错验证过:删掉 `ina226_set_reversed(0x43)` ⇒ 第一条红;错开改成 1 ms ⇒ 两条红。 - -## 上板实测(板 B,五槽空) +## 上板实测(板 B,五槽空,风扇状态未知) ``` -status: "fw":"0.1.0-a15","in":{"mv":12034,"ua":98500} ← 输入电流第一次是真值(以前 Calibration 没写,恒 0) -events: [27, 51, 0, 1(BOOT), 11(ESP_RST_USB), …] [28, 155, 0, 16(PD_LIMIT), 0, 1000(=100 W), 5000 mA] +status: "temp":[38249,34027] 分压端 1266 / 1384 mV ← 两路 NTC 读数合理(NTC1 在电感区更热) + "fan":{"duty":300,"en":true,"rpm":[0,0],"stall":[true,true]} +events: [31, 14318, 0, 11(FAN_STALL), 0] [32, …, 11, 1] ← 启动宽限 5 s + 一个窗口之后各报一次,不刷屏 ``` -没有 I2C_LOST ⇒ 两片 DAC / 输入侧 INA226 / CH224A 都在主干应答。复位原因是 USB ⇒ 没碰 HUB_RST#,COM5 没掉。 -**上电复位路径(Hub 脉冲、DAC NVM 核对)和有刀片时的逐路上电还没在实物上走过**——要等刀片 + 手机上架。 ## 踩到的坑(只写会再犯的) -- ⛔ **mux 死了主干也够不着**:`i2cbus_xact(TRUNK)` 先写 mux「全关」,失败就不跑回调。别以为「DAC 在主干上就不受 mux 影响」。 -- sim 里同一地址在主干和某通道各挂一个,主干那颗 `present=false` 时仍会先被匹配 ⇒ NAK。测「只在通道 5」要主干上干脆不挂。 -- 设定电压**不持久化**:热重启后五路回到 3.90 V(bringup 把 DAC 重写成安全值)。上位机要的话重下。 -- `python` 在这台机器的 Git Bash 里是 Windows Store 占位程序,会挂住;一律用 `/c/Python314/python`。 +- 比例式 NTC 的电源误差估算:+3V3 偏 1 % 在 25 °C 附近是 **~0.6 °C**(中点处电阻误差是电源误差的 2 倍、NTC 每度 ~4 %),不是 0.3 °C。 +- PowerShell 5.1 的 `Set-Content -Encoding utf8` 会写 BOM —— 改 `phonerail.h` 这种文件用 sed,别用它。 +- `python` 在 Git Bash 里是 Windows Store 占位程序,会挂住;一律用 `/c/Python314/python`。 diff --git a/firmware/host/CMakeLists.txt b/firmware/host/CMakeLists.txt index 8a9db57..153e211 100644 --- a/firmware/host/CMakeLists.txt +++ b/firmware/host/CMakeLists.txt @@ -41,6 +41,7 @@ set(PHONERAIL_CORE_SRCS ${FW_ROOT}/main/netcfg.c ${FW_ROOT}/main/mlink.c ${FW_ROOT}/main/bringup.c + ${FW_ROOT}/main/ntc.c ) # HAL:接口在 include/,宿主机链 sim/ 的实现(esp/ 那份只有 IDF 目标编)。 diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index b2a4dc4..a9376e5 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,5 +1,5 @@ # ESP-IDF 侧的 main 组件。 -# app_main.c / ulink_esp.c / mlink_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过 +# app_main.c / ulink_esp.c / mlink_esp.c / thermal_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过 # (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。 idf_component_register( SRCS "app_main.c" @@ -25,7 +25,10 @@ idf_component_register( "netcfg.c" "mlink.c" "bringup.c" + "ntc.c" + "thermal_esp.c" INCLUDE_DIRS "." EMBED_TXTFILES "certs/evse_ca.pem" REQUIRES phonerail_hal drv esp_driver_usb_serial_jtag - esp_wifi esp_netif esp_event esp_timer nvs_flash mqtt) + esp_wifi esp_netif esp_event esp_timer nvs_flash mqtt + esp_adc esp_driver_pcnt) diff --git a/firmware/main/app_main.c b/firmware/main/app_main.c index 6235d53..9f530c6 100644 --- a/firmware/main/app_main.c +++ b/firmware/main/app_main.c @@ -25,7 +25,9 @@ #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" @@ -136,16 +138,30 @@ static void task_safety(void *arg) /* 下面四个是骨架:周期与优先级已经按 §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 (;;) { - /* A16:两路 NTC(IO4/IO5)→ 风扇 PWM 闭环(TR-6 的 TACH 采样窗口)。 - ⚠️ R2 没有 LM75 了(载板整个不存在,计划 §5.10)。 - 真正的任务体在 `main/t_thermal.c` 里,由 A16 那一包填; - **这里不 include 它**,免得两个人同时改到同一处。 */ + (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); } } @@ -368,15 +384,27 @@ void app_main(void) } log_report(&boot); + /* §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); - /* 命令处理器(A12)。status 读巡检快照;热管理快照等 A16 把任务体接上再给。 + /* 命令处理器(A12)。status 读巡检与热管理两份快照。 `power` 命令的意图经 `want_save` 存 NVS(A15)。 队列要在执行器任务起来之前建好 —— 那个任务第一件事就是等它。 */ - const cmd_env_t cmd_env = { .safety = &s_snap, .thermal = NULL, .intent = want_save }; + 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) { diff --git a/firmware/main/ntc.c b/firmware/main/ntc.c new file mode 100644 index 0000000..62ca820 --- /dev/null +++ b/firmware/main/ntc.c @@ -0,0 +1,53 @@ +/* NTC 换算。电路与出处见 ntc.h。 */ +#include "ntc.h" + +#include + +/* NCP18XH103F03RB 的 R-T 表(Murata 规格书,−40 ~ 125 °C,5 °C 一格),单位 Ω。 + ⚠️ 单调**递减**是这张表唯一不能破的性质 —— `test_ntc.c` 逐格查。 + 抽查:25 °C = 10000(标称);50 °C = 4161 与 B3380 公式算出的 4160 吻合。 */ +#define NTC_T0_MC (-40000) +#define NTC_STEP_MC 5000 +static const uint32_t k_ohm[] = { + 195652, 148171, 113347, 87559, 68237, 53650, 42506, 33892, /* −40 … −5 */ + 27219, 22021, 17926, 14674, 12081, 10000, 8315, 6948, /* 0 … 35 */ + 5834, 4917, 4161, 3535, 3014, 2586, 2228, 1925, /* 40 … 75 */ + 1669, 1452, 1268, 1110, 974, 858, 758, 672, /* 80 … 115 */ + 596, 531, /* 120, 125 */ +}; +#define NTC_N ((int)(sizeof(k_ohm) / sizeof(k_ohm[0]))) +_Static_assert(NTC_N == 34, "表应当覆盖 −40 ~ 125 °C,5 °C 一格"); + +bool ntc_mc_from_ohm(uint32_t ohm, int32_t *milli_c) +{ + if (milli_c == NULL || ohm > k_ohm[0] || ohm < k_ohm[NTC_N - 1]) { + return false; + } + for (int i = 0; i < NTC_N - 1; i++) { + const uint32_t hi = k_ohm[i]; /* 较冷的一端,电阻大 */ + const uint32_t lo = k_ohm[i + 1]; /* 较热的一端 */ + if (ohm <= hi && ohm >= lo) { + /* 格内按电阻线性插值。分子最大 190000 × 5000 ≈ 9.5e8,int64 稳妥。 */ + const int64_t frac = (int64_t)(hi - ohm) * NTC_STEP_MC / (int64_t)(hi - lo); + *milli_c = NTC_T0_MC + i * NTC_STEP_MC + (int32_t)frac; + return true; + } + } + return false; /* 走不到:上面的范围判断已经把表外挡掉了 */ +} + +bool ntc_mc_from_mv(int32_t mv, int32_t vref_mv, int32_t *milli_c) +{ + if (milli_c == NULL || vref_mv <= 2 * NTC_RAIL_MARGIN_MV) { + return false; + } + if (mv < NTC_RAIL_MARGIN_MV || mv > vref_mv - NTC_RAIL_MARGIN_MV) { + return false; /* 贴地 = 短路,贴 +3V3 = 开路:都不是温度 */ + } + /* 下臂分压:mv / vref = R / (Rpu + R) ⇒ R = Rpu × mv / (vref − mv)。 */ + const uint64_t ohm = (uint64_t)NTC_PULLUP_OHM * (uint64_t)mv / (uint64_t)(vref_mv - mv); + if (ohm > 0xFFFFFFFFull) { + return false; + } + return ntc_mc_from_ohm((uint32_t)ohm, milli_c); +} diff --git a/firmware/main/ntc.h b/firmware/main/ntc.h new file mode 100644 index 0000000..b3d1fd3 --- /dev/null +++ b/firmware/main/ntc.h @@ -0,0 +1,49 @@ +/* 两路板载 NTC 的换算:ADC 读回的毫伏 → 毫摄氏度(A16,需求 §10.3 `t_thermal`)。 + + ---- 电路(已按 `.claude/r2/_base_pcb_nets.json` 逐脚核实)---------------- + + +3V3 ── R660 10k ──┬── NTC1(IO4, ADC1_CH3) ── C736 ── GND + └── RT601 ── GND + (NTC2 同形:R661 / RT602 / C739 → IO5, ADC1_CH4) + + **NTC 在下臂**:越热电阻越小、电压越低。写反成上臂的现象是「越热读数越低」—— + 风扇闭环照样算、照样有数,只是方向反了,要等机器满载才暴露。 + + RT601/RT602 = Murata **NCP18XH103F03RB**(10 kΩ @25 °C,B25/50 = 3380,立创 C13564; + 设计文档 BOM 那一行)。换算走厂家 R-T 表(5 °C 一格、格内线性插值), + 不用 B 值公式:B 公式在 25/50 °C 以外偏差 1~2 °C,而且要浮点和 log。 + + ---- 这一层不做的事 ------------------------------------------------------ + · 不读 ADC(那是 `thermal_esp.c`,只进 IDF)。本文件是纯整数换算,宿主机可测; + · 不做滤波:C736/C739 是硬件 RC,t_thermal 1 s 才读一次,再滤就是加延迟。 */ +#ifndef NTC_H +#define NTC_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define NTC_PULLUP_OHM 10000 /* R660 / R661 */ + +/* 分压端离两条轨多近就判「读不到」(mV)。 + 开路(NTC 没焊 / 断线)⇒ 电压贴 +3V3;短路 ⇒ 贴地。两者都不是温度。 + ⚠️ 这不是精度门限,是「这还是不是一颗 NTC」的门限: + 表的两端(−40 °C / +125 °C)分别对应约 3.13 V / 0.16 V,离两条轨都还远。 */ +#define NTC_RAIL_MARGIN_MV 30 + +/* ADC 读回的分压端电压 + 当时的上臂电源电压 → 毫摄氏度。 + `vref_mv` 传 +3V3 的实际值(不知道就传 3300);比例式测量,电源偏 1 % 在 25 °C 附近带来 ~0.6 °C(分压中点处电阻误差是电源误差的 2 倍,NTC 每度约 4 %)。 + 返回 false = 开路 / 短路 / 超出表的范围(−40~125 °C),`*milli_c` 不动。 */ +bool ntc_mc_from_mv(int32_t mv, int32_t vref_mv, int32_t *milli_c); + +/* 电阻 → 毫摄氏度(查表 + 插值)。超出表的范围返回 false。给测试用,也给标定用。 */ +bool ntc_mc_from_ohm(uint32_t ohm, int32_t *milli_c); + +#ifdef __cplusplus +} +#endif + +#endif /* NTC_H */ diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h index cb7bd4a..bf75dd9 100644 --- a/firmware/main/phonerail.h +++ b/firmware/main/phonerail.h @@ -3,7 +3,7 @@ #define PHONERAIL_H /* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ -#define PHONERAIL_FW_VERSION "0.1.0-a15" +#define PHONERAIL_FW_VERSION "0.1.0-a16" /* 通道数。五路直供,见设计文档 §2。 */ #define PHONERAIL_RAIL_COUNT 5 diff --git a/firmware/main/thermal_esp.c b/firmware/main/thermal_esp.c new file mode 100644 index 0000000..09b3eb0 --- /dev/null +++ b/firmware/main/thermal_esp.c @@ -0,0 +1,170 @@ +/* `t_thermal` 回调的 ESP 实现。契约见 thermal_esp.h;判据在 t_thermal.c(宿主机测过), + 换算在 ntc.c(同上)。**这里只放驱动胶水,不许出现判据。** */ +#include "thermal_esp.h" + +#include "board_pins.h" +#include "driver/pulse_cnt.h" +#include "esp_adc/adc_cali.h" +#include "esp_adc/adc_cali_scheme.h" +#include "esp_adc/adc_oneshot.h" +#include "esp_log.h" +#include "ntc.h" +#include "t_thermal.h" + +static const char *TAG = "thermal"; + +/* 上臂电源的名义值。比例式测量,偏 1 % 只带来 ~0.6 °C(ntc.h),对风扇闭环无所谓,不值得多占一路 ADC 去量它。 */ +#define THERM_VREF_MV 3300 + +/* 一次读几次取平均。ADC 单次噪声 ±10 mV 量级 ≈ ±0.3 °C;8 次之后可以忽略。1 s 一回,开销无所谓。 */ +#define THERM_AVG 8 + +/* PCNT 计数器是 16 位有符号的;开 `accum_count` + 在上限放观察点之后,驱动会把溢出 + 累进 `pcnt_unit_get_count()` 的返回值里(int,回绕后按 uint32 用即可)。 */ +#define TACH_LIMIT 30000 + +/* 毛刺滤波:TACH 最快 ~6000 RPM × 2 PPR = 200 Hz,周期 5 ms;1 µs 的滤波远在它下面, + 但能把 Q6 开关 25 kHz PWM 串进来的尖刺挡掉。 */ +#define TACH_GLITCH_NS 1000 + +static const int k_ntc_pin[T_THERMAL_NTC_COUNT] = { BOARD_PIN_NTC1, BOARD_PIN_NTC2 }; +static const int k_tach_pin[T_THERMAL_FAN_COUNT] = { + [THERM_FAN_IN] = BOARD_PIN_FAN_TACH_IN, + [THERM_FAN_OUT] = BOARD_PIN_FAN_TACH_OUT, +}; + +static adc_oneshot_unit_handle_t s_adc; +static adc_channel_t s_ch[T_THERMAL_NTC_COUNT]; +static adc_cali_handle_t s_cali[T_THERMAL_NTC_COUNT]; +static bool s_adc_ok[T_THERMAL_NTC_COUNT]; +static int32_t s_last_mv[T_THERMAL_NTC_COUNT] = { -1, -1 }; + +static pcnt_unit_handle_t s_pcnt[T_THERMAL_FAN_COUNT]; +static bool s_pcnt_ok[T_THERMAL_FAN_COUNT]; + +static hal_err_t adc_up(void) +{ + const adc_oneshot_unit_init_cfg_t ucfg = { .unit_id = ADC_UNIT_1 }; + if (adc_oneshot_new_unit(&ucfg, &s_adc) != ESP_OK) { + return HAL_ERR_STATE; + } + hal_err_t first = HAL_OK; + for (int i = 0; i < T_THERMAL_NTC_COUNT; i++) { + adc_unit_t unit; + /* 由引脚反查通道,不写死「CH3/CH4」:board_pins.h 是引脚的唯一出处, + 而且这一步顺带证明了它确实落在 ADC1 上(ADC2 在 WiFi 开着时读不了)。 */ + if (adc_oneshot_io_to_channel(k_ntc_pin[i], &unit, &s_ch[i]) != ESP_OK || + unit != ADC_UNIT_1) { + ESP_LOGE(TAG, "IO%d 不在 ADC1 上", k_ntc_pin[i]); + first = (first == HAL_OK) ? HAL_ERR_ARG : first; + continue; + } + /* 12 dB 衰减:量程到 ~3.1 V,覆盖整张 R-T 表(−40 °C 时 3.14 V 会略微饱和, + 那一端本来就不关心 —— 低温不需要风扇)。 */ + const adc_oneshot_chan_cfg_t ccfg = { .atten = ADC_ATTEN_DB_12, + .bitwidth = ADC_BITWIDTH_DEFAULT }; + if (adc_oneshot_config_channel(s_adc, s_ch[i], &ccfg) != ESP_OK) { + first = (first == HAL_OK) ? HAL_ERR_STATE : first; + continue; + } + const adc_cali_curve_fitting_config_t kcfg = { + .unit_id = ADC_UNIT_1, + .chan = s_ch[i], + .atten = ADC_ATTEN_DB_12, + .bitwidth = ADC_BITWIDTH_DEFAULT, + }; + if (adc_cali_create_scheme_curve_fitting(&kcfg, &s_cali[i]) != ESP_OK) { + /* eFuse 里没有校准值(不该发生在 WROOM-1 上):没校准的读数误差可达 + ±100 mV ≈ ±3 °C,宁可报「读不到」让风扇满速,也不给一个看着正常的错数。 */ + ESP_LOGE(TAG, "NTC%d 的 ADC 校准不可用", i + 1); + first = (first == HAL_OK) ? HAL_ERR_STATE : first; + continue; + } + s_adc_ok[i] = true; + } + return first; +} + +static hal_err_t pcnt_up(int idx) +{ + const pcnt_unit_config_t ucfg = { + .low_limit = -TACH_LIMIT, + .high_limit = TACH_LIMIT, + .flags.accum_count = 1, + }; + pcnt_unit_handle_t u = NULL; + if (pcnt_new_unit(&ucfg, &u) != ESP_OK) { + return HAL_ERR_STATE; + } + const pcnt_glitch_filter_config_t fcfg = { .max_glitch_ns = TACH_GLITCH_NS }; + (void)pcnt_unit_set_glitch_filter(u, &fcfg); + + /* ⚠️ 只数边沿、不看电平:Q9/Q10 反相(t_thermal.h 硬件第 3 条), + 数上升沿还是下降沿都一样 —— 每个 TACH 脉冲正好一个。板上 R753/R757 上拉,不开内部上拉。 */ + const pcnt_chan_config_t ccfg = { .edge_gpio_num = k_tach_pin[idx], .level_gpio_num = -1 }; + pcnt_channel_handle_t c = NULL; + if (pcnt_new_channel(u, &ccfg, &c) != ESP_OK || + pcnt_channel_set_edge_action(c, PCNT_CHANNEL_EDGE_ACTION_INCREASE, + PCNT_CHANNEL_EDGE_ACTION_HOLD) != ESP_OK || + pcnt_unit_add_watch_point(u, TACH_LIMIT) != ESP_OK || + pcnt_unit_enable(u) != ESP_OK || pcnt_unit_clear_count(u) != ESP_OK || + pcnt_unit_start(u) != ESP_OK) { + return HAL_ERR_STATE; + } + s_pcnt[idx] = u; + s_pcnt_ok[idx] = true; + return HAL_OK; +} + +hal_err_t thermal_esp_init(void) +{ + hal_err_t first = adc_up(); + for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) { + const hal_err_t e = pcnt_up(i); + if (e != HAL_OK) { + ESP_LOGE(TAG, "TACH%d(IO%d)的 PCNT 起不来", i, k_tach_pin[i]); + if (first == HAL_OK) { + first = e; + } + } + } + return first; +} + +bool thermal_esp_temp(void *ctx, int idx, int32_t *milli_c) +{ + (void)ctx; + if (idx < 0 || idx >= T_THERMAL_NTC_COUNT || !s_adc_ok[idx]) { + return false; + } + int32_t sum = 0; + for (int n = 0; n < THERM_AVG; n++) { + int mv = 0; + if (adc_oneshot_get_calibrated_result(s_adc, s_cali[idx], s_ch[idx], &mv) != ESP_OK) { + s_last_mv[idx] = -1; + return false; + } + sum += mv; + } + s_last_mv[idx] = sum / THERM_AVG; + return ntc_mc_from_mv(s_last_mv[idx], THERM_VREF_MV, milli_c); +} + +bool thermal_esp_tach(void *ctx, int idx, uint32_t *edges) +{ + (void)ctx; + if (idx < 0 || idx >= T_THERMAL_FAN_COUNT || !s_pcnt_ok[idx]) { + return false; + } + int v = 0; + if (pcnt_unit_get_count(s_pcnt[idx], &v) != ESP_OK) { + return false; + } + *edges = (uint32_t)v; /* 累计值;t_thermal 用 uint32 相减,回绕安全 */ + return true; +} + +int32_t thermal_esp_last_mv(int idx) +{ + return (idx >= 0 && idx < T_THERMAL_NTC_COUNT) ? s_last_mv[idx] : -1; +} diff --git a/firmware/main/thermal_esp.h b/firmware/main/thermal_esp.h new file mode 100644 index 0000000..9905fc8 --- /dev/null +++ b/firmware/main/thermal_esp.h @@ -0,0 +1,23 @@ +/* `t_thermal` 的两个回调在 ESP32-S3 上的实现(A16)。**只进 IDF 目标。** + 温度:ADC1 oneshot + 曲线拟合校准 → mV → `ntc_mc_from_mv()`; + TACH:两个 PCNT 单元数上升沿(累计计数,uint32 回绕安全)。 */ +#ifndef THERMAL_ESP_H +#define THERMAL_ESP_H + +#include +#include + +#include "hal_err.h" + +/* 起 ADC 与 PCNT。失败不 abort:温度读不到 t_thermal 会满速(§10.10), + TACH 读不到就不判停转 —— 两者都是降级,不是固件写错了。 + 返回第一个错误码;已经起来的那一半照常可用。 */ +hal_err_t thermal_esp_init(void); + +bool thermal_esp_temp(void *ctx, int idx, int32_t *milli_c); /* therm_temp_fn */ +bool thermal_esp_tach(void *ctx, int idx, uint32_t *edges); /* therm_tach_fn */ + +/* 最近一次读到的两路原始毫伏(调试 / 标定用;读失败是 −1)。 */ +int32_t thermal_esp_last_mv(int idx); + +#endif /* THERMAL_ESP_H */ diff --git a/firmware/test/test_ntc.c b/firmware/test/test_ntc.c new file mode 100644 index 0000000..fcf3077 --- /dev/null +++ b/firmware/test/test_ntc.c @@ -0,0 +1,86 @@ +/* A16:NTC 换算(ntc.h)。两条最要紧的: + ⭐ 方向 —— NTC 在下臂,**越热电压越低**;写反了闭环照样有数,只是反着转; + ⭐ 开路 / 短路判「读不到」—— 读不到时 t_thermal 会满速(§10.10), + 而把开路算成 −40 °C 的后果是「风扇按最低速转、机器在烤」。 */ +#include "ntc.h" +#include "unity.h" + +void setUp(void) {} +void tearDown(void) {} + +/* 分压端电压(与 ntc.c 的反算互为逆运算,单独写一遍免得自己证明自己)。 */ +static int32_t mv_at(uint32_t ohm) { return (int32_t)(3300ull * ohm / (NTC_PULLUP_OHM + ohm)); } + +static void test_table_points(void) +{ + int32_t mc = 0; + TEST_ASSERT_TRUE(ntc_mc_from_ohm(10000, &mc)); + TEST_ASSERT_EQUAL_INT32(25000, mc); + TEST_ASSERT_TRUE(ntc_mc_from_ohm(4161, &mc)); + TEST_ASSERT_EQUAL_INT32(50000, mc); + TEST_ASSERT_TRUE(ntc_mc_from_ohm(195652, &mc)); + TEST_ASSERT_EQUAL_INT32(-40000, mc); + TEST_ASSERT_TRUE(ntc_mc_from_ohm(531, &mc)); + TEST_ASSERT_EQUAL_INT32(125000, mc); + /* 格内插值:10000 与 8315 的正中间 ≈ 27.5 °C。 */ + TEST_ASSERT_TRUE(ntc_mc_from_ohm((10000 + 8315) / 2, &mc)); + TEST_ASSERT_INT32_WITHIN(20, 27500, mc); +} + +/* ⭐ 方向:电压从高扫到低,温度必须一路**不降**;电阻从大到小同理。 */ +static void test_hotter_means_lower_voltage(void) +{ + int32_t prev = -1000000, mc = 0; + int n = 0; + for (int32_t mv = 3300 - NTC_RAIL_MARGIN_MV - 1; mv > NTC_RAIL_MARGIN_MV; mv -= 7) { + if (ntc_mc_from_mv(mv, 3300, &mc)) { + TEST_ASSERT_TRUE_MESSAGE(mc >= prev, "电压降低时温度必须升高(NTC 在下臂)"); + prev = mc; + n++; + } + } + TEST_ASSERT_TRUE(n > 300); + /* 两个现实点:25 °C 正好一半(1650 mV);60 °C ≈ 0.76 V。 */ + TEST_ASSERT_TRUE(ntc_mc_from_mv(1650, 3300, &mc)); + TEST_ASSERT_INT32_WITHIN(100, 25000, mc); + TEST_ASSERT_TRUE(ntc_mc_from_mv(mv_at(3014), 3300, &mc)); + TEST_ASSERT_INT32_WITHIN(200, 60000, mc); +} + +/* ⭐ 开路(贴 +3V3)/ 短路(贴地)/ 表外:一律「读不到」,而且不动输出。 */ +static void test_open_and_short_are_not_temperatures(void) +{ + int32_t mc = 12345; + TEST_ASSERT_FALSE(ntc_mc_from_mv(3300, 3300, &mc)); + TEST_ASSERT_FALSE(ntc_mc_from_mv(3290, 3300, &mc)); + TEST_ASSERT_FALSE(ntc_mc_from_mv(0, 3300, &mc)); + TEST_ASSERT_FALSE(ntc_mc_from_mv(10, 3300, &mc)); + TEST_ASSERT_FALSE(ntc_mc_from_mv(-5, 3300, &mc)); + TEST_ASSERT_FALSE(ntc_mc_from_mv(1650, 0, &mc)); + TEST_ASSERT_FALSE(ntc_mc_from_ohm(200000, &mc)); /* < −40 °C */ + TEST_ASSERT_FALSE(ntc_mc_from_ohm(500, &mc)); /* > 125 °C */ + TEST_ASSERT_EQUAL_INT32(12345, mc); + TEST_ASSERT_FALSE(ntc_mc_from_mv(1650, 3300, NULL)); +} + +/* 比例式:+3V3 偏 1 %(3.267 V),只要把实际值传进来,读数不变; + 传错成 3300 时误差约 0.6 °C(25 °C 附近)—— 对风扇闭环无所谓。 */ +static void test_ratiometric(void) +{ + int32_t a = 0, b = 0; + TEST_ASSERT_TRUE(ntc_mc_from_mv(1650, 3300, &a)); + TEST_ASSERT_TRUE(ntc_mc_from_mv(1634, 3267, &b)); + TEST_ASSERT_INT32_WITHIN(50, a, b); + TEST_ASSERT_TRUE(ntc_mc_from_mv(1634, 3300, &b)); + TEST_ASSERT_INT32_WITHIN(700, a, b); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_table_points); + RUN_TEST(test_hotter_means_lower_voltage); + RUN_TEST(test_open_and_short_are_not_temperatures); + RUN_TEST(test_ratiometric); + return UNITY_END(); +} diff --git a/固件开发计划.md b/固件开发计划.md index ce6a8c9..5add5d1 100644 --- a/固件开发计划.md +++ b/固件开发计划.md @@ -528,7 +528,7 @@ R-3 依赖 R-1+R-2;R-4 依赖 R-3;R-5 / R-6 随时可插。 | 里程碑 | 判据 | 依赖硬件 | |---|---|---| | **M1 逻辑跑通** | A1~A16 + R-0~R-6 全绿;模拟器上能完成一次完整的「崩溃 → 软重启 → 硬重启」 | ❌ 不需要 | -|  └ 进度 | **R-0~R-6 ✅ 完成**(2026-09-16,351 例);A1~A11 ✅;**A12 ✅**(2026-09-27,380 例);**A13 ✅**(同日,390 例,已上板);**A14 + C1 ✅**(同日,400 例,已上板真连网);**A15 ✅**(同日,425 例,已上板:输入电流 +98.5 mA、BOOT 带复位原因);**还欠 A16** | | +|  └ 进度 | **R-0~R-6 ✅ 完成**(2026-09-16,351 例);A1~A11 ✅;**A12 ✅**(2026-09-27,380 例);**A13 ✅**(同日,390 例,已上板);**A14 + C1 ✅**(同日,400 例,已上板真连网);**A15 ✅**(同日,425 例,已上板:输入电流 +98.5 mA、BOOT 带复位原因);**A16 ✅**(同日,429 例,已上板:两路 NTC 读数合理;风扇待插上验 TACH)⇒ **A1~A16 全部完成** | | | **M2 板子活了** | §12.1 的 17 步 + B1b / B1c / B8b 全过,尤其 B3 / B4 | ✅ | | **M3 交付** | 五台 24 h 无重启无告警;双链路验收通过 | ✅ |