Files
RuView/firmware/esp32-csi-node/main/thermal.h
Joeandclaude-flow 7f9df37380 fix(firmware): damp every thermal step down, not just the one into OK
Follow-up to the review on #1797. The CRITICAL -> THROTTLED edge was
unconditional: any reading below critical_c dropped the state immediately, so a
node sitting on the critical boundary flapped between TXP_MIN and TXP_STEP2
indefinitely. Every oscillation moves that node's RSSI at both ends, which a
reader cannot distinguish from an obstruction -- exactly the failure the
hysteresis on the other edges was added to prevent.

Now every falling edge requires cooling a full hysteresis band below the
threshold it crossed on the way up; rising edges stay immediate, because heat is
a reason to act now. Verified by dithering across the boundary: six readings
either side of 80 C previously produced five state changes, now zero.

The decision moved into thermal.h as a pure function, following the convention
edge_processing.h already uses for edge_evidence_person_count -- a host test
then exercises the exact function the device build runs rather than a copy free
to drift.

BEHAVIOUR CHANGE worth calling out, beyond what the review asked for. The old
`recover` point was a single global threshold (throttle_c - hyst) that every
state fell back through, so leaving WARN required cooling to 64 C -- a bound
derived from the throttle threshold, not WARN's own. Hysteresis is now relative
to each state's own entry point, so WARN releases at 57 C instead. Leaving
THROTTLED is unchanged at 64 C. Kconfig help updated, since it described the old
throttle-relative semantics.

One consequence the test pins explicitly: with the default constants the
critical exit point (80 - 8 = 72) coincides exactly with the throttle threshold,
so a node cool enough to leave CRITICAL is already below THROTTLED's entry and
lands in WARN. THROTTLED is unreachable descending from CRITICAL at hysteresis
8. That falls out of the chosen constants rather than the logic, and the test
covers hysteresis 4 as well to show the intermediate step reappears when the
bands do not coincide. My first version of the test asserted THROTTLED there and
was simply wrong -- running it caught that.

Verified in espressif/idf:v5.4: against the unfixed transition the test fails
five checks including the dither, passes with the fix, and the C6 firmware
builds. Still no hardware evidence for this PR -- a real boot log is coming
before merge, as promised earlier in the thread.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-04 11:29:27 -04:00

107 lines
4.0 KiB
C

/* On-chip thermal monitoring and radio throttling. See thermal.c for why the
* thresholds are set against die temperature rather than the plastic. */
#pragma once
#include "esp_err.h"
#include <stdint.h>
typedef enum {
THERMAL_OK = 0,
THERMAL_WARN,
THERMAL_THROTTLED,
THERMAL_CRITICAL,
} thermal_state_t;
/**
* Decide the next thermal state from a reading and the current state.
*
* Pure: no globals, no hardware. Kept in the public header so a host test can
* exercise the exact function the device build runs, rather than a copy of it
* that is free to drift.
*
* Rising edges act immediately -- heat is a reason to react now. EVERY falling
* edge requires cooling a full `hyst_c` below the threshold that was crossed on
* the way up, because recovering at the same point that tripped oscillates, and
* each oscillation steps this node's transmit power, which moves the RSSI of
* every link it has. A reader cannot distinguish that from a wall moving.
*
* The CRITICAL -> THROTTLED edge is the reason this exists as a function. It
* used to be unconditional: any reading below `critical_c` dropped the state
* immediately, so a node sitting on the critical boundary flapped between
* minimum and step-2 transmit power indefinitely -- precisely the oscillation
* the hysteresis elsewhere was added to prevent. With defaults (critical 80,
* hysteresis 8) leaving CRITICAL now needs 72 C, which is exactly the THROTTLED
* entry point.
*
* @param cur Current state.
* @param c Die temperature, degrees C.
* @param warn_c Warn threshold.
* @param throttle_c Throttle threshold.
* @param critical_c Critical threshold.
* @param hyst_c Degrees of cooling required before any step down.
* @return The state to adopt.
*/
static inline thermal_state_t thermal_next_state(thermal_state_t cur, float c,
int warn_c, int throttle_c,
int critical_c, int hyst_c)
{
const float h = (float)hyst_c;
/* Rising: immediate, at the plain thresholds. */
if (c >= (float)critical_c) return THERMAL_CRITICAL;
/* Falling out of CRITICAL needs a full hysteresis band, like every other
* downward step. Without this the state tracks `c` across critical_c with
* no damping at all. */
if (cur == THERMAL_CRITICAL && c >= (float)critical_c - h) {
return THERMAL_CRITICAL;
}
if (c >= (float)throttle_c) return THERMAL_THROTTLED;
if (cur == THERMAL_THROTTLED && c >= (float)throttle_c - h) {
return THERMAL_THROTTLED;
}
if (c >= (float)warn_c) return THERMAL_WARN;
if (cur == THERMAL_WARN && c >= (float)warn_c - h) {
return THERMAL_WARN;
}
return THERMAL_OK;
}
#if defined(CONFIG_THERMAL_MONITOR) && defined(CONFIG_IDF_TARGET_ESP32C6)
esp_err_t thermal_init(void);
/* Sample and apply policy. Call from a slow loop -- the die does not change
* fast, and reading it more often buys nothing. */
void thermal_tick(void);
float thermal_celsius(void);
float thermal_peak_celsius(void);
thermal_state_t thermal_state(void);
/* Current WiFi transmit ceiling in whole dBm.
*
* Exposed because a throttled node's RSSI falls at both ends, which is
* indistinguishable from an obstruction in the link diagnostics. Anything
* reading link strength should check this before concluding a wall moved. */
int8_t thermal_tx_dbm(void);
const char *thermal_state_name(thermal_state_t s);
#else /* stubs: S3 build and when monitoring is off */
static inline esp_err_t thermal_init(void) { return ESP_OK; }
static inline void thermal_tick(void) {}
static inline float thermal_celsius(void) { return -273.0f; }
static inline float thermal_peak_celsius(void) { return -273.0f; }
static inline thermal_state_t thermal_state(void) { return THERMAL_OK; }
static inline int8_t thermal_tx_dbm(void) { return 20; }
static inline const char *thermal_state_name(thermal_state_t s) { (void)s; return "n/a"; }
#endif