/* `t_thermal` —— 1 s 热管理任务的**一个周期**(需求 §10.3 第二行、计划 A16 / R-6)。 一个周期干四件事:读两路温度 → 算风扇占空比 → 结算 TACH 采样窗口 → 记事件。 和 `t_safety` 一样写成「一个周期是一个函数」而不是 `while(1)`:死循环在宿主机上 一行也跑不到,而这一包的全部价值就是下面那几条判据。 ---- R2 的风扇硬件(**已按板上 PCB 网络逐脚核实**,设计文档 §22.6 FAN-R2)---- +12V ──Q7(P-MOS)── FAN_12V_SW ── J26.2 / J27.2(两只风扇的 12V) Q7.G ←─ Q8(N-MOS) ←─ R748 ←─ FAN_EN(IO48) FAN_PWM(IO47) ─R733─ Q6.G,Q6.D = FAN_PWM_L → J26.4 / J27.4(**一路喂两只**) J26.3 / J27.3 ─→ Q9 / Q10 栅极,漏极上拉到 +3V3 → FAN_TACH_IN(IO21) / FAN_TACH_OUT(IO14) 由此来的**三件固件必须知道的事**,每一件写反了都不会报错: 1. **`FAN_EN` 复位态 = 风扇不转。** R749 把 Q8 栅极拉到地、R746 把 Q7 栅极拉回源极, 所以 MCU 高阻(复位、下载、跑飞)时 12V 是断的 —— 这是 SR-6「复位安全」要的, 代价是**固件必须主动把它拉高**。忘了拉高的现象是「一切正常,就是不散热」: 温度闭环照常算占空比、PWM 照常输出、TACH 读不到脉冲被启动宽限盖住, 功能测试全绿,只有在机器满载跑了半小时之后才以「手机降频」的形式暴露。 2. **PWM 是反相的。** Q6 是低边 N-MOS,栅极高 ⇒ 把风扇的 PWM 线**拉低** ⇒ `D_gpio = 1 − D_fan`。本模块对外一律用**风扇侧**占空比(`duty`), 只在写 `hal_pwm_set_permille()` 的那一行做反相(`gpio_duty`)。 写反了的现象是「越热转得越慢」—— 而遥测里占空比在跟着温度往上走,完全正常。 3. **TACH 是反相的。** 开集三极管 Q9/Q10 各接一路,风扇 TACH 拉低 ⇒ GPIO 读到高。 **数边沿没问题,任何「按电平判它转没转」的写法都是反的。** 所以本模块只吃 「累计边沿数」,一次电平也不读 —— 见 `therm_tach_fn`。 ---- 温度从哪来:**没有 ADC 的 HAL,也不打算加**(R-6 的一个决定)---------- R2 把载板整个删了,LM75 跟着没了,温度只剩两路 NTC(IO4/IO5,ADC1)。 本项目的 HAL 只有 gpio / i2c / pwm / time / store / lock 六样,**没有 ADC**。 这里**不加第七样**,理由两条: · ESP32-S3 的 ADC 校准(`esp_adc_cal` 曲线拟合 + eFuse 里的校准值)在没有板子 的时候一行也验不了,加进 HAL 只会得到一个「sim 里永远对、板上还没试过」的接口; · HAL 每多一个概念,后面每一次会话都要多读一遍。 所以温度**由外部喂进来**(`therm_temp_fn`),和 `t_actuator` 的 PG 回调同一个形状。 ADC 采样 + NTC 查表留到上板那一包(Track B),那时它是一个几十行的 `ntc.c`,接进这个回调即可,本模块一个字不用改。TACH 计数器同理(板上是 PCNT)。 ---- 采样窗口:这一包最容易写错的地方(需求 TR-6)------------------------ 低边调速的三线风扇在 PWM 关断期间**根本没有回流**,TACH 那一路是死的; 于是「这个 1 s 窗口里没数到脉冲 ⇒ 停转」在占空比低的时候会**稳定误报**。 (FAN-R2 换成四线风扇之后 12V 是常供的、TACH 连续,但判据不能建立在 「买到的一定是四线风扇」上 —— 而且四线风扇的脉冲只会**更多**,不会更少。) 本模块选的办法是 **③「窗口长度与占空比挂钩」**:判据不看墙钟走了多久, 而是看这个窗口里**攒够了多少导通时间**(`T_THERMAL_TACH_ON_MS`)。 导通时间是脉冲真正可能出现的时间,所以「窗口里该有多少个脉冲」与占空比无关, 门限就能是一个常数。占空比 100 % 时窗口 3 s,30 % 时自动拉长到 10 s。 没选另外两个的理由: · ①「窗口跨足够多个 PWM 周期」解决不了问题 —— 25 kHz 下 1 s 已经跨了 25000 个 周期,照样会误报。误报的根源不是周期数少,是**期望脉冲数随占空比变**。 · ②「只在导通期计数」要求固件跟着 PWM 相位走。25 kHz、一个周期 40 µs, 软件跟不上;真做只能上硬件(PCNT 的 control 脚接 PWM),那是板上那一包的事, 而且它解决的是同一个问题的硬件版本 —— 本模块的判据在那种硬件下照样成立。 ⚠️ 与之配套的是**占空比下限** `T_THERMAL_DUTY_MIN`:占空比真掉到 0, 导通时间永远攒不满,这条判据就变成了「永远不判」。所以闭环的下限不是 0; 真要停风扇只能撤 `FAN_EN`(§22.6:0 % PWM 时四线风扇仍转 3300 RPM)。 ---- 事件:入队,不落盘 -------------------------------------------------- `evlog_put()` 是几条指令的入队,任何任务都能调;`evlog_flush()` 是唯一碰 flash 的地方,只有 `t_log` 调(`evlog.h` 文件头)。本模块一次也不碰 flash。 */ #ifndef T_THERMAL_H #define T_THERMAL_H #include #include #include "hal_err.h" #ifdef __cplusplus extern "C" { #endif /* ---- 硬件常数(有出处的那些)-------------------------------------------- */ /* 风扇 PWM 走 `hal_pwm` 的 0 号通道 —— 全板只有这一路 PWM(`hal_pwm.h` 文件头)。 */ #define T_THERMAL_PWM_CH 0 /* 25 kHz:四线风扇 PWM 的通行规格(21~28 kHz),也是 `hal_pwm_esp.c` 与 `test_hal_sim.c` 里已经在用的数。落进音频带(<20 kHz)的现象是**啸叫**。 */ #define T_THERMAL_PWM_HZ 25000 /* ⚠️ Q6 反相(设计文档 §22.6「栅极高 = 风扇 PWM 线低」,已按板上网表核实: `R733: FAN_PWM → Q6.G`、`Q6.D = FAN_PWM_L → J26.4/J27.4`)。 */ #define T_THERMAL_PWM_INVERTED 1 /* 每转几个 TACH 脉冲。⚠️ **待上板实测**:§22.6 原话「2PPR 也来自系列波形示例」, 而 2026-09-13 换成 60×60×10 薄风扇之后连 MPN 都还没定。 它只影响报出去的 RPM 数值,**不影响停转判据**(那条只看「有没有脉冲」)。 */ #define T_THERMAL_TACH_PPR 2 /* ---- 闭环阈值 ------------------------------------------------------------ ⚠️ 下面四个**全部是「待上板实测后填」**,不是算出来的: R2 把测温点从「进风侧空气」挪到了「两排手机之间的排母走廊」,设计文档明说 「风扇闭环(§22.6)的阈值要按新位置重新定 —— 这是挪件的代价,不是 bug」, 而新位置的温升曲线只有上板才有。这里给的是**形状**(分段线性 + 迟滞), 数值先按常识填、单列在这里,上板实测之后只改这四行。 */ /* 低于它就按下限转(安静优先)。 */ #define T_THERMAL_TEMP_LO_MC 40000 /* 40.000 °C —— 待实测 */ /* 高于它满速。 */ #define T_THERMAL_TEMP_HI_MC 65000 /* 65.000 °C —— 待实测 */ /* 过温降载门限(FR-13「超阈值先降流再关路」)。本模块只**报**,不动电源 —— 真降载要按整板功率优先级关路,那是限功率那一包的事(计划 §5.9)。 */ #define T_THERMAL_DERATE_MC 80000 /* 80.000 °C —— 待实测 */ /* 降载解除门限。⚠️ 必须**低于**上面那个:没有迟滞的现象是温度在门限上抖, 每秒记一条降载事件,把 256 条的 RAM 环形缓冲在 4 分钟内刷光(`evlog.h`), 真正有用的过压/反灌事件被挤掉 —— 而那时日志里看起来「记录得非常完整」。 */ #define T_THERMAL_DERATE_CLEAR_MC 70000 /* 70.000 °C —— 待实测 */ /* 占空比下限(千分比)。风扇侧口径。见文件头「与之配套的是占空比下限」。 ⚠️ 待上板实测:低了 TACH 窗口拉得太长(判停转变慢),高了吵。 */ #define T_THERMAL_DUTY_MIN 300 /* ---- TACH 采样窗口 ------------------------------------------------------ */ /* 一个判据窗口要攒够这么多**导通时间**(不是墙钟时间)。见文件头。 3 s:占空比 100 % 时 3 s 判一次,30 % 下限时 10 s 判一次 —— 风扇停了之后 最坏 10 s 才报出来,而机箱的热时间常数是分钟级,够用。 */ #define T_THERMAL_TACH_ON_MS 3000 /* 窗口里少于这么多个边沿 ⇒ 判它停了。 余量巨大是有意的:§22.6 给的最慢转速是 0 % PWM 下的 3300 RPM, 3300 RPM × 2 PPR × 3 s 导通 = **330 个脉冲**,门限 4 是它的 1/80。 这条判据要答的是「有没有在转」,不是「转得够不够快」—— 转速不足是另一回事 (风扇老化/积灰),那要等 §22.6 把额定转速定下来才判得了。 */ #define T_THERMAL_TACH_MIN_EDGES 4 /* 窗口墙钟长度的**上界**。窗口按导通时间结算,所以它的实际长度是 `T_THERMAL_TACH_ON_MS × 1000 / duty`,最长出现在占空比压到下限的时候 (3 s ÷ 30 % = 10 s)。这个 30 s 是允许它拉到多长的红线: 拉过头 = 风扇停了很久才报出来。 ⚠️ 这条不是运行期判断,是 `t_thermal.c` 里的**编译期断言**。 把占空比下限改小(比如将来限功率想让风扇让电)会同时拉长这个窗口, 改到 0 的时候停转判据就**静默失效**了 —— 不报错、不告警、再也不报停转。 写成运行期兜底反而更糟:那段代码在下限 >0 时永远走不到,测不了也就不可信。 */ #define T_THERMAL_TACH_MAX_MS 30000 /* `FAN_EN` 拉高之后的启动宽限:这段时间内不判停转,窗口一直重置。 设计文档 §22.6「使能后给启动宽限,关电后屏蔽停转告警」。 没有这个窗口的现象是**每次上电必报一条风扇停转**,然后大家就开始无视这个事件。 ⚠️ 数值**待实测**(薄风扇的起转时间厂家没给),但它有一个下界,不能随便往小写: **必须长于「播种一圈 + 一个完整判据窗口」**,也就是 `period_ms + T_THERMAL_TACH_ON_MS`(1 s 节拍下 = 4 s),否则第一个窗口在宽限 到期之前根本结算不了,这条宽限就是一段**永远走不到的死代码** —— 写成 3 s 时把整个宽限判断删掉,测试一条都不会红(本包实测过)。取 5 s。 */ #define T_THERMAL_SPINUP_MS 5000 /* ---- 外部喂进来的两样东西 ------------------------------------------------ */ #define T_THERMAL_NTC_COUNT 2 /* IO4 = RT601、IO5 = RT602 */ #define T_THERMAL_FAN_COUNT 2 /* 0 = 进风 J26(TACH → IO21)、1 = 排风 J27(IO14) */ typedef enum { THERM_FAN_IN = 0, /* J26 进风,TACH 走 `BOARD_PIN_FAN_TACH_IN` */ THERM_FAN_OUT = 1, /* J27 排风,TACH 走 `BOARD_PIN_FAN_TACH_OUT` */ } therm_fan_t; /* 温度取值回调,单位**毫摄氏度**(整数,不引浮点)。 返回 false = 这一刻读不到(ADC 还没起来 / NTC 开路)。 ⚠️ 两路**都**读不到时本模块把风扇开到 100 %,不是维持原样 —— 需求 §10.10 对「载板温度读不到」给的处置就是「风扇转到 100 % 并告警, 不要按『读不到温度 = 不热』处理」。 */ typedef bool (*therm_temp_fn)(void *ctx, int idx, int32_t *milli_c); /* TACH 取值回调:返回**累计**边沿数(自己回绕,uint32 相减是回绕安全的)。 板上这个数来自 PCNT / GPIO 中断,宿主机上来自假风扇。 返回 false = 计数器这一刻读不到(那就跳过这一路本圈的累加,不判停转)。 ⚠️ 这里收的是**计数**不是**电平**:接收电路反相(Q9/Q10), 「电平高 = 在转」和「电平低 = 在转」都是错的,而且两种写法都有一半的时间蒙对。 */ typedef bool (*therm_tach_fn)(void *ctx, int idx, uint32_t *edges); /* ---- 快照 ---------------------------------------------------------------- */ typedef struct { /* 本窗口的三个累加量。窗口在「导通时间攒满」或「墙钟超时」时结算并清零。 */ uint32_t win_edges; /* 数到的边沿 */ uint32_t win_on_ms; /* **导通**时间:判据用的就是它 */ uint32_t win_ms; /* 墙钟时长:只用来算 RPM 和兜底超时 */ uint32_t last_edges; /* 上一圈读回来的累计值 */ bool have_last; /* 第一圈只播种,不作差(不然第一圈会白捡一大堆边沿) */ uint32_t rpm; /* 上一个**结算完**的窗口算出来的转速 */ bool rpm_valid; /* 还没结算过任何窗口时为 false —— 0 RPM 与「还不知道」不是一回事 */ bool stalled; /* 当前判定 */ uint32_t stalls; /* 判出过几次(只数上升沿,和事件条数一致) */ uint32_t windows; /* 结算过几个窗口 */ } therm_tach_state_t; typedef struct { int32_t temp_mc[T_THERMAL_NTC_COUNT]; bool temp_ok[T_THERMAL_NTC_COUNT]; int32_t temp_max_mc; /* 两路里**高的那一个**:一点凉不代表另一点不烫 */ bool temp_valid; /* 至少有一路读到了 */ uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径 */ uint16_t gpio_duty; /* 真写进 hal_pwm 的值(Q6 反相 ⇒ 1000 − duty) */ bool fan_en; /* `FAN_EN` 的命令值 */ uint32_t en_ms; /* 拉高的时刻,启动宽限从这里算 */ bool derate; /* 正在过温降载 */ uint32_t derates; /* 进入过几次(只数上升沿) */ therm_tach_state_t fan[T_THERMAL_FAN_COUNT]; uint32_t cycles; /* 跑过几圈 */ uint32_t last_ms; /* 上一圈的时刻,dt 从这里算 —— 不假设「一圈正好 1 s」 */ uint32_t pwm_errors; /* 写 PWM 失败次数(板上不该发生,发生了要报出去) */ uint32_t temp_errors; /* 两路温度**全部**读不到的圈数 */ } thermal_snap_t; /* 把快照清干净 + 起硬件: ① `FAN_EN` 配成输出并**先拉低**、② 配 PWM 并写满速、③ 再把 `FAN_EN` 拉高。 顺序来自设计文档 §22.6「先 FAN_EN=0、配好 PWM 与两个计数器,再使能供电」。 `temp` / `tach` 传 NULL 就是「永远读不到」(温度读不到 ⇒ 满速,见上)。 */ hal_err_t t_thermal_init(thermal_snap_t *s, therm_temp_fn temp, therm_tach_fn tach, void *ctx); /* 一个周期。周期取 `task_desc(TASK_THERMAL)->period_ms`(1 s,`tasks.c` 里定死), 但本函数**不假设**自己是按时被调的:dt 一律用 `hal_time_elapsed_ms()` 实测, 所以任务被挤晚了只会让窗口跟着变长,不会把导通时间算错。 */ hal_err_t t_thermal_step(thermal_snap_t *s); /* 温度 → 风扇侧占空比。分段线性,纯函数(不碰硬件、不记事件),单独可测。 `valid == false` ⇒ 满速。单调**不降**是这条曲线唯一不能破的性质。 */ uint16_t t_thermal_duty_for(int32_t milli_c, bool valid); /* 上位机手动给的占空比**下限**(风扇侧千分比,A12 的 `fan` 命令)。0 = 全自动。 ⚠️ 只抬不压:实际占空比 = max(温度曲线, 下限)。「手动把风扇调小」这件事 不提供 —— 那等于允许远程关掉散热,而温度读不到时满速(§10.10)的兜底也会被它盖掉。 下一圈 `t_thermal_step()` 生效;不随 `t_thermal_init()` 清零(重启自然清零)。 */ void t_thermal_set_floor(uint16_t permille); uint16_t t_thermal_floor(void); #ifdef __cplusplus } #endif #endif /* T_THERMAL_H */