fan-control: read HA mode/manual-% setpoint (HA fan control)

The host daemon now polls input_select.r730_fan_mode (auto/cool/quiet/
manual) + input_number.r730_fan_manual_pct from ha-sofia each loop and
routes through fc_resolve: manual holds a fixed %, cool/quiet force that
curve, auto keeps the garage-presence behaviour. CEILING still overrides.
Ships HA control now on the running host daemon (no Vault); the cluster
CronJob migration stays the eventual Terraform home (same logic).

HA side (on ha-sofia, auto-git-tracked there): two helpers, an auto-
revert-to-auto automation (60min), mode + %-slider control tiles on the
dashboard-it Server view. Verified end-to-end: HA manual 70% -> fans
12720rpm; revert to auto -> presence curve 50%.

10 new pure-function tests (fc_resolve/fc_clamp); 46 total green.

[ci skip]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-05 09:26:22 +00:00
parent b958935ee0
commit 8beca1dfc7
2 changed files with 56 additions and 5 deletions

View file

@ -37,6 +37,11 @@ set -uo pipefail
: "${HA_TOKEN:=}" # long-lived ha-sofia token; empty => presence disabled (COOL only)
: "${GARAGE_ENTITY:=sensor.garage_door_state_bg}"
: "${GARAGE_OPEN_STATE:=Отворена}" # ha state string meaning "open"
# HA control: a mode select + manual % the user drives from Home Assistant.
# auto => garage-presence curve (default); cool/quiet => force that curve;
# manual => hold MANUAL_ENTITY %. Empty HA_TOKEN or unreachable HA => auto.
: "${MODE_ENTITY:=input_select.r730_fan_mode}"
: "${MANUAL_ENTITY:=input_number.r730_fan_manual_pct}"
: "${PUSHGATEWAY_URL:=}" # optional Prometheus Pushgateway base URL
: "${MAX_IPMI_FAILS:=3}"
: "${DRY_RUN:=0}" # 1 => log IPMI actions instead of executing
@ -98,6 +103,21 @@ fc_json_str_field() {
# fc_pct_to_hex <pct> -> 0xNN
fc_pct_to_hex() { printf '0x%02x' "$1"; }
# fc_clamp <pct> -> 0..100
fc_clamp() { local p="$1"; (( p < 0 )) && p=0; (( p > 100 )) && p=100; echo "$p"; }
# fc_resolve <ha_mode> <temp> <manual_pct> <presence> <current> <deadband> -> pct
# HA mode resolution (the hard ceiling is handled by the caller):
# manual -> clamp(manual_pct), no hysteresis
# cool|quiet -> that curve (with hysteresis)
# auto (else) -> presence-driven curve (garage door)
fc_resolve() {
local ha_mode="$1" temp="$2" manual_pct="$3" presence="$4" current="$5" deadband="$6"
if [[ "$ha_mode" == "manual" ]]; then fc_clamp "$manual_pct"; return 0; fi
local eff; [[ "$ha_mode" == "auto" ]] && eff="$presence" || eff="$ha_mode"
fc_decide "$eff" "$temp" "$current" "$deadband"
}
# ---- side-effecting wrappers ----
ipmi_manual_on=0
@ -139,9 +159,18 @@ get_presence() {
echo "$presence_cache"
}
# ha_entity_state <entity> -> state string (empty if HA disabled/unreachable)
ha_entity_state() {
[[ -z "$HA_TOKEN" ]] && return 0
local resp
resp="$(curl -fsS --max-time 5 -H "Authorization: Bearer $HA_TOKEN" \
"$HA_URL/api/states/$1" 2>/dev/null)" || return 0
fc_json_str_field "$resp" state
}
push_metrics() { # <temp> <pct> <mode> <ha_ok> <fallback>
[[ -z "$PUSHGATEWAY_URL" ]] && return 0
local mode_num; case "$3" in quiet) mode_num=1;; cool) mode_num=2;; *) mode_num=0;; esac
local mode_num; case "$3" in quiet) mode_num=1;; cool) mode_num=2;; manual) mode_num=3;; *) mode_num=0;; esac
curl -fsS --max-time 5 --data-binary @- \
"$PUSHGATEWAY_URL/metrics/job/fan_control/instance/pve-r730" >/dev/null 2>&1 <<EOF || true
# TYPE pve_fan_control_cpu_temp_celsius gauge
@ -189,13 +218,23 @@ main() {
fi
fi
local mode ha_ok=1; mode="$(get_presence)"; [[ -z "$HA_TOKEN" ]] && ha_ok=0
local pct; pct="$(fc_decide "$mode" "$temp" "$current" "$DEADBAND")"
# HA-desired mode (auto/cool/quiet/manual); unreachable/unset => auto.
local ha_mode ha_ok=1; ha_mode="$(ha_entity_state "$MODE_ENTITY")"; [[ -z "$HA_TOKEN" ]] && ha_ok=0
[[ -z "$ha_mode" ]] && ha_mode="auto"
case "$ha_mode" in auto|cool|quiet|manual) ;; *) ha_mode="auto" ;; esac
local manual_pct=0
if [[ "$ha_mode" == "manual" ]]; then
manual_pct="$(ha_entity_state "$MANUAL_ENTITY")"; manual_pct="${manual_pct%%.*}"
[[ "$manual_pct" =~ ^[0-9]+$ ]] || manual_pct=0
fi
local presence="cool"; [[ "$ha_mode" == "auto" ]] && presence="$(get_presence)"
local eff; if [[ "$ha_mode" == "manual" ]]; then eff="manual"; elif [[ "$ha_mode" == "auto" ]]; then eff="$presence"; else eff="$ha_mode"; fi
local pct; pct="$(fc_resolve "$ha_mode" "$temp" "$manual_pct" "$presence" "$current" "$DEADBAND")"
if (( pct != current )); then
if set_manual "$pct"; then log "temp=${temp}C mode=${mode} fan=${pct}% (was ${current}%)"; current="$pct"
if set_manual "$pct"; then log "temp=${temp}C ha_mode=${ha_mode} eff=${eff} fan=${pct}% (was ${current}%)"; current="$pct"
else log "WARN set_manual ${pct}% failed"; fi
fi
push_metrics "$temp" "$current" "$mode" "$ha_ok" 0
push_metrics "$temp" "$current" "$eff" "$ha_ok" 0
(( RUN_ONCE == 1 )) && break || sleep "$LOOP_INTERVAL"
done
}

View file

@ -45,6 +45,18 @@ eq "decide down past band" 60 "$(fc_decide cool 69 80 3)" # 69+3=72 -> 60%
eq "decide 100 holds" 100 "$(fc_decide cool 77 100 3)" # 77+3=80 -> 100 -> hold
eq "decide 100 drops" 80 "$(fc_decide cool 75 100 3)" # 75+3=78 -> 80 < 100 -> drop
# --- fc_clamp / fc_resolve: HA mode resolution ---
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" 60 "$(fc_resolve cool 64 0 cool -1 3)"
eq "resolve quiet=quiet curve" 65 "$(fc_resolve quiet 80 0 cool -1 3)"
eq "resolve auto+empty=cool" 60 "$(fc_resolve auto 64 0 cool -1 3)"
eq "resolve auto+present=quiet" 20 "$(fc_resolve auto 64 0 quiet -1 3)"
eq "resolve cool hysteresis" 60 "$(fc_resolve cool 69 0 cool 80 3)"
# --- presence ---
now=1000000
eq "presence open -> quiet" quiet "$(fc_presence_mode Отворена 0 $now 900 Отворена)"