Merge pull request #1797 from clonea1/contrib/thermal

feat(firmware): thermal monitoring, radio throttling and node health
This commit is contained in:
rUv authored and GitHub committed 2026-09-04 12:17:06 -04:00
commit dddc214a25
8 files changed
+555 -5

No files matched your search

+2 -1
View File
@@ -1,5 +1,6 @@
set(SRCS
"main.c" "csi_collector.c" "stream_sender.c" "nvs_config.c"
"main.c" "csi_collector.c"
"thermal.c" "stream_sender.c" "nvs_config.c"
"edge_processing.c" "ota_update.c" "power_mgmt.c"
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
"mmwave_sensor.c"
@@ -517,3 +517,75 @@ menu "Onboard LED (ADR-183)"
colormap, in milli-units (250 = 0.25). Lower = more sensitive
(reaches yellow with less motion).
endmenu
menu "Thermal monitoring"
config THERMAL_MONITOR
bool "Read the on-chip temperature sensor and throttle the radio"
depends on IDF_TARGET_ESP32C6
default y
help
The C6 has a temperature sensor this firmware otherwise never reads.
Harmless on a bare bench board; not once a node is sealed in a printed
enclosure on a wall with the radio transmitting continuously.
What is being protected is the ENCLOSURE, not the silicon. The part is
rated far past anything it sees here, but PLA softens at its glass
transition (~55-60 C) and sags slowly under its own weight, which never
announces itself.
config THERMAL_THROTTLE
bool "Act on the temperature by reducing transmit power"
depends on THERMAL_MONITOR
default n
help
Separate from monitoring on purpose. Reading the sensor is inert;
reducing transmit power moves every RSSI the node has, and doing that
mid-experiment gives any surprise two candidate causes.
Leave off until the fleet is stable and the logs show a node genuinely
running hot. If none ever does -- which is the likely outcome -- this
never needs enabling, and the thresholds below cost nothing.
config THERMAL_WARN_C
int "Warn above this die temperature (C)"
depends on THERMAL_MONITOR
range 40 100
default 65
config THERMAL_THROTTLE_C
int "Reduce transmit power above this die temperature (C)"
depends on THERMAL_MONITOR
range 45 105
default 72
help
Die temperature, not enclosure temperature -- the sensor is on the chip
and runs hotter than the plastic around it. ~72 C on the die in a small
vented box is roughly a 50-55 C enclosure, which is where PLA begins to
matter. Setting this at 55 would throttle a perfectly healthy node.
PETG (glass transition ~80 C) removes the concern; raise this if the
whole fleet is printed in it.
config THERMAL_CRITICAL_C
int "Minimum transmit power above this die temperature (C)"
depends on THERMAL_MONITOR
range 50 110
default 80
config THERMAL_HYSTERESIS_C
int "Degrees of cooling required before stepping down a thermal state"
depends on THERMAL_MONITOR
range 1 20
default 8
help
Recovering at the same threshold that tripped would oscillate, and every
oscillation is a visible RSSI step on every link the node has.
Applies to EVERY downward transition, each relative to the threshold that
was crossed on the way up: leaving CRITICAL needs CRITICAL_C minus this,
leaving THROTTLED needs THROTTLE_C minus this, leaving WARN needs WARN_C
minus this. Rising transitions are immediate at the plain thresholds --
heat is a reason to act now.
endmenu
@@ -13,6 +13,7 @@
*/
#include "adaptive_controller.h"
#include "thermal.h"
#include "rv_radio_ops.h"
#include "rv_feature_state.h"
#include "rv_mesh.h"
@@ -331,6 +332,10 @@ static void emit_feature_state(void)
static void slow_loop_cb(TimerHandle_t t)
{
(void)t;
/* Sample the die and apply the thermal policy. The slow loop (30 s) is the
* right cadence: the die does not change quickly, and reading it more
* often buys nothing but noise. No-op unless CONFIG_THERMAL_MONITOR. */
thermal_tick();
/* ADR-081 L3: publish a HEALTH mesh message every slow tick
* (default 30 s). The coordinator uses these to track liveness and
* detect sync-error drift. */
+31
View File
@@ -21,6 +21,7 @@
#include "led_strip.h"
#include "csi_collector.h"
#include "thermal.h"
#include "stream_sender.h"
#include "nvs_config.h"
#include "edge_processing.h"
@@ -318,6 +319,36 @@ void app_main(void)
ESP_LOGI(TAG, "Mock CSI active (scenario=%d)", CONFIG_CSI_MOCK_SCENARIO);
}
#else
/* Why did we just start? Nothing recorded this before, and it is the
* single most useful line in a fleet log: a node that reboots tells you
* almost nothing, but a node that reboots with ESP_RST_BROWNOUT tells you
* its supply sagged, which on shared outlets is a wiring problem rather
* than a firmware one. Three nodes dropping together on 2026-08-28 would
* have been answered by this line. */
{
esp_reset_reason_t rr = esp_reset_reason();
const char *why =
rr == ESP_RST_POWERON ? "power-on" :
rr == ESP_RST_EXT ? "external reset" :
rr == ESP_RST_SW ? "software restart" :
rr == ESP_RST_PANIC ? "PANIC (crash)" :
rr == ESP_RST_INT_WDT ? "interrupt watchdog" :
rr == ESP_RST_TASK_WDT ? "task watchdog" :
rr == ESP_RST_WDT ? "other watchdog" :
rr == ESP_RST_BROWNOUT ? "BROWNOUT (supply sagged)" :
rr == ESP_RST_DEEPSLEEP? "deep-sleep wake" : "unknown";
if (rr == ESP_RST_PANIC || rr == ESP_RST_BROWNOUT ||
rr == ESP_RST_INT_WDT || rr == ESP_RST_TASK_WDT) {
ESP_LOGW(TAG, "reset reason: %s (%d) <-- not a clean start", why, (int)rr);
} else {
ESP_LOGI(TAG, "reset reason: %s (%d)", why, (int)rr);
}
}
/* Bring thermal monitoring up before the radio is loaded, so the first
* reading is taken against a known-idle baseline rather than mid-burst. */
thermal_init();
csi_collector_init();
/* ADR-073: Start multi-frequency channel hopping if configured in NVS. */
+175
View File
@@ -0,0 +1,175 @@
/* On-chip thermal monitoring and radio throttling.
*
* The ESP32-C6 carries a temperature sensor (SOC_TEMP_SENSOR_SUPPORTED) that
* nothing in this firmware has ever read. That was tolerable on a bare board
* on a bench; it is not once nine nodes are sealed in printed enclosures and
* left on a wall for months with the radio transmitting continuously.
*
* What we are protecting is the enclosure, not the silicon. The part is rated
* well past anything it will see here, but PLA goes soft at its glass
* transition around 55-60 C -- and that is sagging under its own weight over
* weeks, not a dramatic failure, so it would never announce itself.
*
* The sensor reads DIE temperature, which runs hotter than the plastic around
* it. Thresholds are therefore set against the die with that offset in mind:
* a die at ~72 C in a small vented box is roughly a 50-55 C enclosure, which
* is where PLA starts to matter. They are deliberately not set at 55 C -- that
* would throttle a perfectly healthy node.
*
* Throttling reduces WiFi transmit power, which is the dominant heat source
* and degrades gracefully: links weaken but keep working, where cutting the
* capture rate would blind the node entirely.
*
* IMPORTANT for anyone reading link diagnostics: a throttled node's RSSI drops
* at both ends, which looks exactly like an obstruction. `thermal_state()` and
* `thermal_tx_dbm()` exist so that can be distinguished from a cage or a wall
* rather than misdiagnosed as one.
*/
#include "sdkconfig.h"
#include "thermal.h"
#if defined(CONFIG_THERMAL_MONITOR) && defined(CONFIG_IDF_TARGET_ESP32C6)
#include "driver/temperature_sensor.h"
#include "esp_wifi.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "esp_heap_caps.h"
static const char *TAG = "thermal";
static temperature_sensor_handle_t s_sensor;
static bool s_ready;
static float s_last_c = -273.0f;
static float s_peak_c = -273.0f;
static thermal_state_t s_state = THERMAL_OK;
/* esp_wifi_set_max_tx_power() takes 0.25 dBm units. 80 = 20 dBm, the default
* on this part; each step down is 3 dB, which halves radiated power. */
#define TXP_FULL 80 /* 20 dBm */
#define TXP_STEP1 68 /* 17 dBm */
#define TXP_STEP2 56 /* 14 dBm */
#define TXP_MIN 44 /* 11 dBm */
static int8_t s_txp = TXP_FULL;
static void apply_tx_power(int8_t q)
{
if (q == s_txp) {
return;
}
esp_err_t r = esp_wifi_set_max_tx_power(q);
if (r == ESP_OK) {
ESP_LOGW(TAG, "tx power %d.%02d dBm -> %d.%02d dBm (thermal)",
s_txp / 4, (s_txp % 4) * 25, q / 4, (q % 4) * 25);
s_txp = q;
} else {
ESP_LOGW(TAG, "esp_wifi_set_max_tx_power(%d) failed: %s",
q, esp_err_to_name(r));
}
}
esp_err_t thermal_init(void)
{
temperature_sensor_config_t cfg =
TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 110);
esp_err_t r = temperature_sensor_install(&cfg, &s_sensor);
if (r != ESP_OK) {
ESP_LOGW(TAG, "sensor install failed: %s (continuing unmonitored)",
esp_err_to_name(r));
return r;
}
r = temperature_sensor_enable(s_sensor);
if (r != ESP_OK) {
ESP_LOGW(TAG, "sensor enable failed: %s", esp_err_to_name(r));
return r;
}
s_ready = true;
ESP_LOGI(TAG, "monitoring on: warn %d C, throttle %d C, critical %d C "
"(die temperature; protecting the enclosure, not the chip)",
CONFIG_THERMAL_WARN_C, CONFIG_THERMAL_THROTTLE_C,
CONFIG_THERMAL_CRITICAL_C);
return ESP_OK;
}
void thermal_tick(void)
{
if (!s_ready) {
return;
}
float c;
if (temperature_sensor_get_celsius(s_sensor, &c) != ESP_OK) {
return;
}
s_last_c = c;
if (c > s_peak_c) {
s_peak_c = c;
}
/* Hysteresis on the way down: recovering at the same threshold that
* tripped would oscillate, and each oscillation is a visible RSSI step on
* every link this node has. The decision itself lives in thermal.h as a
* pure function so a host test can exercise the same code this runs. */
const thermal_state_t next = thermal_next_state(s_state, c,
CONFIG_THERMAL_WARN_C,
CONFIG_THERMAL_THROTTLE_C,
CONFIG_THERMAL_CRITICAL_C,
CONFIG_THERMAL_HYSTERESIS_C);
if (next != s_state) {
ESP_LOGW(TAG, "%.1f C: %s -> %s",
c, thermal_state_name(s_state), thermal_state_name(next));
s_state = next;
}
#ifdef CONFIG_THERMAL_THROTTLE
/* Acting on the reading is separate from taking it. Measuring is inert;
* changing transmit power mid-experiment moves every RSSI this node has,
* which is not something to introduce during a fleet bring-up when any
* surprise needs a single candidate cause. Turn this on once the fleet is
* stable AND the logs show a node actually gets hot -- if none ever does,
* it never needs turning on at all. */
switch (s_state) {
case THERMAL_CRITICAL: apply_tx_power(TXP_MIN); break;
case THERMAL_THROTTLED: apply_tx_power(TXP_STEP2); break;
case THERMAL_WARN: apply_tx_power(TXP_STEP1); break;
case THERMAL_OK: apply_tx_power(TXP_FULL); break;
}
#endif
static int64_t s_last_log_us;
int64_t now = esp_timer_get_time();
if (now - s_last_log_us > 60LL * 1000 * 1000) {
s_last_log_us = now;
/* Heap rides along because it is the other thing that fails slowly
* and silently over weeks. A node that reboots mysteriously after ten
* days looks identical to one that overheated, unless the minimum
* free heap has been visibly walking downwards the whole time. */
ESP_LOGI(TAG, "die %.1f C (peak %.1f C) state=%s tx=%d.%02d dBm "
"heap %u min %u",
s_last_c, s_peak_c, thermal_state_name(s_state),
s_txp / 4, (s_txp % 4) * 25,
(unsigned)esp_get_free_heap_size(),
(unsigned)esp_get_minimum_free_heap_size());
}
}
float thermal_celsius(void) { return s_last_c; }
float thermal_peak_celsius(void) { return s_peak_c; }
thermal_state_t thermal_state(void) { return s_state; }
int8_t thermal_tx_dbm(void) { return (int8_t)(s_txp / 4); }
const char *thermal_state_name(thermal_state_t s)
{
switch (s) {
case THERMAL_OK: return "ok";
case THERMAL_WARN: return "warn";
case THERMAL_THROTTLED: return "throttled";
case THERMAL_CRITICAL: return "critical";
}
return "?";
}
#endif /* CONFIG_THERMAL_MONITOR && ESP32C6 */
+106
View File
@@ -0,0 +1,106 @@
/* 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
+17 -4
View File
@@ -44,9 +44,11 @@ FUZZ_DURATION ?= 30
FUZZ_JOBS ?= 1
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
test_thermal run_thermal
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal
# --- ADR-110 encoding unit tests ---
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
@@ -80,8 +82,19 @@ test_mmwave_detect: test_mmwave_detect.c $(MAIN_DIR)/mmwave_detect.h
run_mmwave_detect: test_mmwave_detect
./test_mmwave_detect
host_tests: run_adr110 run_vitals run_mmwave_detect
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135)"
# --- Thermal state hysteresis ---
# Host-side. Exercises thermal_next_state() from ../main/thermal.h directly --
# the same function the device build runs -- so the test and the firmware cannot
# drift. Pins every edge in both directions, including the CRITICAL -> THROTTLED
# flap that had no hysteresis at all.
test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h
cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -o $@ $<
run_thermal: test_thermal
./test_thermal
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135 + thermal)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -0,0 +1,147 @@
/**
* @file test_thermal_hysteresis.c
* @brief Pins every thermal transition, in both directions.
*
* Exercises `thermal_next_state()` from ../main/thermal.h directly -- the same
* function the device build runs, not a copy -- so the test and the firmware
* cannot drift apart.
*
* The regression that motivated this: the CRITICAL -> THROTTLED edge was
* unconditional. Any reading below `critical_c` dropped the state at once, so a
* node sitting on the critical boundary flapped between minimum and step-2
* transmit power forever. Every oscillation moves the RSSI of every link that
* node has, at both ends, which a reader cannot tell apart from an obstruction
* -- exactly the failure the hysteresis on the other edges exists to prevent.
*
* Defaults under test: warn 65, throttle 72, critical 80, hysteresis 8.
*/
#include <stdio.h>
#include "thermal.h"
#define WARN_C 65
#define THROTTLE_C 72
#define CRITICAL_C 80
#define HYST_C 8
static int g_failures = 0;
static const char *name(thermal_state_t s)
{
switch (s) {
case THERMAL_OK: return "OK";
case THERMAL_WARN: return "WARN";
case THERMAL_THROTTLED: return "THROTTLED";
case THERMAL_CRITICAL: return "CRITICAL";
}
return "?";
}
static thermal_state_t step(thermal_state_t cur, float c)
{
return thermal_next_state(cur, c, WARN_C, THROTTLE_C, CRITICAL_C, HYST_C);
}
static void expect(thermal_state_t cur, float c, thermal_state_t want,
const char *why)
{
thermal_state_t got = step(cur, c);
if (got != want) {
printf(" FAIL: from %-9s at %5.1f C -> %s, expected %s\n %s\n",
name(cur), c, name(got), name(want), why);
g_failures++;
}
}
int main(void)
{
printf("thermal hysteresis (warn %d, throttle %d, critical %d, hyst %d)\n",
WARN_C, THROTTLE_C, CRITICAL_C, HYST_C);
/* ---- Rising edges are immediate. Heat is a reason to act now. ---- */
expect(THERMAL_OK, 64.9f, THERMAL_OK, "below warn");
expect(THERMAL_OK, 65.0f, THERMAL_WARN, "warn trips at the threshold");
expect(THERMAL_WARN, 72.0f, THERMAL_THROTTLED, "throttle trips at the threshold");
expect(THERMAL_THROTTLED, 80.0f, THERMAL_CRITICAL, "critical trips at the threshold");
/* A jump straight past intermediate bands must not be damped on the way up. */
expect(THERMAL_OK, 95.0f, THERMAL_CRITICAL, "a large rise skips straight to critical");
/* ---- THE REGRESSION: falling out of CRITICAL. ----
*
* Before the fix, 79.9 C dropped the state to THROTTLED immediately, so a
* node hovering at 80 alternated TXP_MIN and TXP_STEP2 indefinitely. */
expect(THERMAL_CRITICAL, 79.9f, THERMAL_CRITICAL,
"0.1 C below critical must NOT step down -- this is the flap");
expect(THERMAL_CRITICAL, 75.0f, THERMAL_CRITICAL,
"still inside the hysteresis band");
expect(THERMAL_CRITICAL, 72.1f, THERMAL_CRITICAL,
"just inside the band (critical 80 - hyst 8 = 72)");
/* Once it has cooled a full band it adopts the band it is ACTUALLY in,
* which under these defaults is WARN, not THROTTLED: the critical exit
* point (80 - 8 = 72) coincides exactly with the throttle threshold, so
* anything cool enough to leave CRITICAL is already below THROTTLED's
* entry. THROTTLED is therefore unreachable descending from CRITICAL at
* hysteresis 8 -- a consequence of the chosen constants, not of the logic.
* With a smaller hysteresis the intermediate step reappears, which the
* next assertion pins. */
expect(THERMAL_CRITICAL, 71.9f, THERMAL_WARN,
"a full band below critical, and 71.9 is in the warn band");
/* Same descent with hysteresis 4: the exit point is 76, which lands inside
* THROTTLED's band, so the intermediate power step is used. */
{
thermal_state_t got = thermal_next_state(THERMAL_CRITICAL, 75.9f,
WARN_C, THROTTLE_C, CRITICAL_C, 4);
if (got != THERMAL_THROTTLED) {
printf(" FAIL: from CRITICAL at 75.9 C with hyst 4 -> %s, "
"expected THROTTLED\n", name(got));
g_failures++;
}
thermal_state_t held = thermal_next_state(THERMAL_CRITICAL, 76.1f,
WARN_C, THROTTLE_C, CRITICAL_C, 4);
if (held != THERMAL_CRITICAL) {
printf(" FAIL: from CRITICAL at 76.1 C with hyst 4 -> %s, "
"expected CRITICAL\n", name(held));
g_failures++;
}
}
/* Prove the flap is gone by walking across the boundary repeatedly: a node
* dithering either side of 80 must not change state on the down-swings. */
{
thermal_state_t s = THERMAL_CRITICAL;
int changes = 0;
const float dither[] = {80.2f, 79.8f, 80.1f, 79.7f, 80.3f, 79.9f};
for (unsigned i = 0; i < sizeof(dither) / sizeof(dither[0]); i++) {
thermal_state_t n = step(s, dither[i]);
if (n != s) changes++;
s = n;
}
if (changes != 0) {
printf(" FAIL: dithering around the critical boundary produced %d "
"state change(s); each one steps transmit power\n", changes);
g_failures++;
}
}
/* ---- Falling out of THROTTLED. Behaviour preserved from before. ---- */
expect(THERMAL_THROTTLED, 71.9f, THERMAL_THROTTLED, "just below the throttle point holds");
expect(THERMAL_THROTTLED, 64.1f, THERMAL_THROTTLED, "still inside the band");
expect(THERMAL_THROTTLED, 63.9f, THERMAL_OK, "throttle 72 - hyst 8 = 64 releases");
/* ---- Falling out of WARN. ---- */
expect(THERMAL_WARN, 64.9f, THERMAL_WARN, "just below warn holds");
expect(THERMAL_WARN, 57.1f, THERMAL_WARN, "still inside the band");
expect(THERMAL_WARN, 56.9f, THERMAL_OK, "warn 65 - hyst 8 = 57 releases");
/* ---- OK is stable well below everything. ---- */
expect(THERMAL_OK, 30.0f, THERMAL_OK, "a cold node stays OK");
if (g_failures == 0) {
printf(" PASS\n");
return 0;
}
printf(" %d check(s) FAILED\n", g_failures);
return 1;
}