fan-control: thin actuator — HA computes the setpoint, host only applies it
The R730 fan-control logic now lives entirely in Home Assistant: the curve thresholds, duty %, bias and asymmetric deadband, plus manual/lock, are set on the dashboard and published as sensor.r730_fan_command_pct. The host daemon is reduced to a thin actuator — it reads that one number each loop, validates it (numeric + not older than STALE_SECS) and applies it over IPMI. Removed the presence-aware two-curve logic and the garage-door coupling. Safety stays independent on the host: CPU>=CEILING, repeated IPMI failures, or HA unreachable/stale all hand the fans back to Dell auto. RPM telemetry now averages all 6 chassis fans. Deployed and verified live on pve (applies the HA command; fans follow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3c3e6bfc95
commit
082bdfcc77
2 changed files with 94 additions and 165 deletions
|
|
@ -1,6 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Unit tests for the pure functions in fan-control.sh.
|
||||
# Sources the script (main is guarded), exercises curve/decide/resolve/presence/parse.
|
||||
# Unit tests for the pure functions in fan-control.sh (the thin actuator).
|
||||
# The control math lives in Home Assistant now; the daemon only validates and
|
||||
# applies the HA-computed command, so these cover the I/O-adjacent pure helpers.
|
||||
# Run: bash infra/scripts/test-fan-control.sh
|
||||
|
||||
set -uo pipefail
|
||||
|
|
@ -14,43 +15,31 @@ eq() { # <description> <expected> <actual>
|
|||
fail=$((fail + 1)); printf 'FAIL: %s — expected [%s] got [%s]\n' "$1" "$2" "$3"
|
||||
fi
|
||||
}
|
||||
ok() { # <description> <cmd...> (passes if cmd exits 0)
|
||||
if "${@:2}"; then pass=$((pass + 1)); else fail=$((fail + 1)); printf 'FAIL: %s — expected exit 0\n' "$1"; fi
|
||||
}
|
||||
no() { # <description> <cmd...> (passes if cmd exits non-zero)
|
||||
if "${@:2}"; then fail=$((fail + 1)); printf 'FAIL: %s — expected non-zero exit\n' "$1"; else pass=$((pass + 1)); fi
|
||||
}
|
||||
|
||||
# --- COOL curve (continuous linear: 30% @50C .. 100% @83C) ---
|
||||
eq "cool <=T_LO clamps" 30 "$(fc_curve cool 40)"
|
||||
eq "cool 50 -> 30" 30 "$(fc_curve cool 50)"
|
||||
eq "cool 55 -> 41" 41 "$(fc_curve cool 55)"
|
||||
eq "cool 60 -> 51" 51 "$(fc_curve cool 60)"
|
||||
eq "cool 64 -> 60" 60 "$(fc_curve cool 64)"
|
||||
eq "cool 70 -> 72" 72 "$(fc_curve cool 70)"
|
||||
eq "cool 75 -> 83" 83 "$(fc_curve cool 75)"
|
||||
eq "cool 83 -> 100" 100 "$(fc_curve cool 83)"
|
||||
eq "cool >=T_HI clamps" 100 "$(fc_curve cool 90)"
|
||||
# --- fc_num: sanitise the HA command read (truncate floats, fallback, clamp) ---
|
||||
eq "num valid" 55 "$(fc_num 55 0 0 100)"
|
||||
eq "num float trunc" 55 "$(fc_num 55.7 0 0 100)"
|
||||
eq "num empty->fb" 0 "$(fc_num '' 0 0 100)"
|
||||
eq "num garbage->fb" 0 "$(fc_num abc 0 0 100)"
|
||||
eq "num clamp low" 0 "$(fc_num -5 0 0 100)"
|
||||
eq "num clamp high" 100 "$(fc_num 150 0 0 100)"
|
||||
|
||||
# --- QUIET curve (continuous linear: 20% @68C .. 100% @83C) ---
|
||||
eq "quiet <=T_LO clamps" 20 "$(fc_curve quiet 60)"
|
||||
eq "quiet 68 -> 20" 20 "$(fc_curve quiet 68)"
|
||||
eq "quiet 70 -> 31" 31 "$(fc_curve quiet 70)"
|
||||
eq "quiet 75 -> 57" 57 "$(fc_curve quiet 75)"
|
||||
eq "quiet 80 -> 84" 84 "$(fc_curve quiet 80)"
|
||||
eq "quiet 83 -> 100" 100 "$(fc_curve quiet 83)"
|
||||
# --- fc_fresh: staleness gate on the command's last_updated age ---
|
||||
ok "fresh well within" fc_fresh 30 120
|
||||
ok "fresh at boundary" fc_fresh 120 120
|
||||
no "stale just past" fc_fresh 121 120
|
||||
no "stale way past" fc_fresh 600 120
|
||||
|
||||
# --- decide: asymmetric hysteresis (ramp up now, ease down only past the deadband) ---
|
||||
eq "decide uninit -> target" 68 "$(fc_decide cool 68 -1 3)"
|
||||
eq "decide ramp up now" 68 "$(fc_decide cool 68 25 3)"
|
||||
eq "decide equal holds" 62 "$(fc_decide cool 65 62 3)"
|
||||
eq "decide down held" 72 "$(fc_decide cool 68 72 3)" # curve(68)=68<72 but curve(71)=75 !<72 -> hold
|
||||
eq "decide down past" 60 "$(fc_decide cool 64 72 3)" # curve(64)=60, curve(67)=66<72 -> drop
|
||||
|
||||
# --- fc_clamp / fc_resolve: HA mode resolution ---
|
||||
# --- fc_clamp ---
|
||||
eq "clamp over 100" 100 "$(fc_clamp 150)"
|
||||
eq "clamp under 0" 0 "$(fc_clamp -5)"
|
||||
eq "clamp passthrough" 45 "$(fc_clamp 45)"
|
||||
eq "resolve manual=slider" 42 "$(fc_resolve manual 64 42 cool -1 3)"
|
||||
eq "resolve manual clamped" 100 "$(fc_resolve manual 64 150 cool -1 3)"
|
||||
eq "resolve cool=cool curve" 51 "$(fc_resolve cool 60 0 cool -1 3)"
|
||||
eq "resolve quiet=quiet curve" 73 "$(fc_resolve quiet 78 0 cool -1 3)"
|
||||
eq "resolve auto+empty=cool" 51 "$(fc_resolve auto 60 0 cool -1 3)"
|
||||
eq "resolve auto+present=quiet" 31 "$(fc_resolve auto 70 0 quiet -1 3)"
|
||||
|
||||
# --- fc_fan_watts: estimated fan power from RPM (cube-law, calibrated to the sweep) ---
|
||||
eq "fan_watts 0" 0 "$(fc_fan_watts 0)"
|
||||
|
|
@ -59,21 +48,14 @@ eq "fan_watts 9360" 16 "$(fc_fan_watts 9360)"
|
|||
eq "fan_watts 12720" 42 "$(fc_fan_watts 12720)"
|
||||
eq "fan_watts 16920" 99 "$(fc_fan_watts 16920)"
|
||||
|
||||
# --- presence ---
|
||||
now=1000000
|
||||
eq "presence open -> quiet" quiet "$(fc_presence_mode Отворена 0 $now 900 Отворена)"
|
||||
eq "presence closed recent -> quiet" quiet "$(fc_presence_mode Затворена $((now - 100)) $now 900 Отворена)"
|
||||
eq "presence closed stale -> cool" cool "$(fc_presence_mode Затворена $((now - 1000)) $now 900 Отворена)"
|
||||
eq "presence closed edge -> cool" cool "$(fc_presence_mode Затворена $((now - 900)) $now 900 Отворена)"
|
||||
|
||||
# --- temp parsing ---
|
||||
eq "parse temp line" 74 "$(fc_parse_temp 'Temp | 0Eh | ok | 3.1 | 74 degrees C')"
|
||||
eq "parse temp 7C" 72 "$(fc_parse_temp 'Temp | 0Eh | ok | 3.1 | 72 degrees C')"
|
||||
|
||||
# --- json field (jq-free) ---
|
||||
J='{"entity_id":"sensor.garage_door_state_bg","state":"Отворена","attributes":{"friendly_name":"Garage Door State BG"},"last_changed":"2026-06-04T16:55:20.517745+00:00","last_updated":"2026-06-04T16:55:20.517745+00:00"}'
|
||||
eq "json state" "Отворена" "$(fc_json_str_field "$J" state)"
|
||||
eq "json last_changed" "2026-06-04T16:55:20.517745+00:00" "$(fc_json_str_field "$J" last_changed)"
|
||||
# --- json field (jq-free): state + last_updated parsing for the command read ---
|
||||
J='{"entity_id":"sensor.r730_fan_command_pct","state":"57","attributes":{"unit_of_measurement":"%"},"last_changed":"2026-06-08T16:55:20.517745+00:00","last_updated":"2026-06-08T16:55:25.000000+00:00"}'
|
||||
eq "json state" "57" "$(fc_json_str_field "$J" state)"
|
||||
eq "json last_updated" "2026-06-08T16:55:25.000000+00:00" "$(fc_json_str_field "$J" last_updated)"
|
||||
|
||||
# --- hex conversion ---
|
||||
eq "hex 20" 0x14 "$(fc_pct_to_hex 20)"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue