Files
shaggy-solar/.claude/skills/lib/grid-cal-monitor

92 lines
4.6 KiB
Plaintext
Raw Normal View History

#!/usr/bin/env bash
# grid-cal-monitor — supervise a grid-assisted calibration charge to full, then
# auto-revert the inverter back to normal (battery-priority, solar-only charging).
#
# Assumes the operator already set, via lvx-control:
# output_priority = solar_utility_battery (SUB: grid powers loads + charges batt)
# charger_priority = solar_and_utility
# This script ONLY monitors and then REVERTS those two (POP->solar_battery_utility,
# PCP->solar_first). It changes nothing else. Reverting is trap-guaranteed on exit.
#
# Done = all 6 packs report SoC>=99 (BMS re-anchored), or pack_V>=56.2 with combined
# current tapered <20A for 2 polls. Safety: abort+revert if any cell>3.60V or temp>45C.
# Max runtime guard then revert regardless.
set -uo pipefail
HA="http://10.0.0.41:8123"; TOKEN_FILE="$HOME/.config/ha/token"
POWERMON_CONF="$HOME/.config/powermon/powermon.yaml"
CELL_ABORT=3.60; TEMP_ABORT=45; SOC_DONE=99
VFULL=56.2; ITAPER=20; MAX_HOURS=8; POLL_S=300
RUNDIR="$HOME/solar-runs"; mkdir -p "$RUNDIR"
LOG="$RUNDIR/gridcal-$(date +%Y%m%d).log"
REVERTED=0
log(){ printf '%s %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG"; }
read -r BHOST BPORT BUSER BPASS < <(awk '/^[^[:space:]]/{i=0}/^mqttbroker:/{i=1;next} i&&/^[[:space:]]+name:/{h=$2} i&&/^[[:space:]]+port:/{p=$2} i&&/^[[:space:]]+username:/{u=$2} i&&/^[[:space:]]+password:/{w=$2} END{print h,(p?p:1883),u,w}' "$POWERMON_CONF")
TOKEN="$(cat "$TOKEN_FILE")"
ha(){ curl -s -H "Authorization: Bearer $TOKEN" "$HA/api/states/$1"; }
st(){ ha "$1" | python3 -c 'import sys,json
try:print(json.load(sys.stdin).get("state",""))
except:print("")'; }
tc(){ ha "$1" | python3 -c 'import sys,json
try:
d=json.load(sys.stdin);s=float(d["state"]);u=d["attributes"].get("unit_of_measurement","")
print(round((s-32)*5/9,1) if "F" in u else round(s,1))
except:print("")'; }
P(){ echo "sensor.eg4_lifepower4_lifepower4_${1}_lifepower4_${1}_${2}"; }
pub(){ mosquitto_pub -h "$BHOST" -p "$BPORT" -u "$BUSER" -P "$BPASS" -t "solar/control/lvx6048/$1" -m "$2"; }
revert(){
[ "$REVERTED" = 1 ] && return 0
log "REVERT: output_priority->solar_battery_utility, charger_priority->solar_first"
pub output_priority solar_battery_utility; sleep 3
pub charger_priority solar_first; sleep 8
log "REVERT done. live POP=$(st sensor.lvx6048_lvx6048_1_output_source_priority)/$(st sensor.lvx6048_lvx6048_2_output_source_priority) PCP=$(st sensor.lvx6048_lvx6048_1_charger_source_priority)/$(st sensor.lvx6048_lvx6048_2_charger_source_priority)"
REVERTED=1
}
trap 'revert; log "exit"' EXIT INT TERM
# read packs -> "minSoC maxSoC maxcell maxtemp minV maxV totI ndone"
read_packs(){
local socs=() cells=() temps=() vs=() is=() i s t tmax
for i in 1 2 3 4 5 6; do
socs+=("$(st "$(P $i soc)")"); cells+=("$(st "$(P $i cell_voltage_max)")")
vs+=("$(st "$(P $i pack_voltage)")"); is+=("$(st "$(P $i pack_current)")")
tmax=0
for s in temperature_pcb temperature_01 temperature_02 temperature_03; do
t="$(tc "$(P $i $s)")"; t=${t%.*}; [[ "$t" =~ ^-?[0-9]+$ ]] && [ "$t" -gt "$tmax" ] && tmax=$t
done
temps+=("$tmax")
done
python3 - "${socs[*]}" "${cells[*]}" "${temps[*]}" "${vs[*]}" "${is[*]}" <<'PY'
import sys
f=lambda a:[float(x) for x in a.split() if x]
soc,cell,tmp,v,i=map(f,sys.argv[1:6])
print(f"{min(soc):.0f} {max(soc):.0f} {max(cell):.3f} {max(tmp):.0f} {min(v):.2f} {max(v):.2f} {sum(i):.0f} {len([s for s in soc if s>=99])}")
PY
}
log "=== grid-cal-monitor start (auto-revert on full/abort/exit) ==="
START=$(date +%s); taper_hits=0
while :; do
read MNS MXS MXCELL MXT MNV MXV TOTI NDONE <<<"$(read_packs)"
el=$(( ($(date +%s)-START)/60 ))
log "[+${el}m] SoC ${MNS}-${MXS}% packs@100=${NDONE}/6 | packV ${MNV}-${MXV} | totI ${TOTI}A | maxcell ${MXCELL}V maxtemp ${MXT}C"
# SAFETY
if (( $(python3 -c "print(1 if $MXCELL>$CELL_ABORT or $MXT>$TEMP_ABORT else 0)") )); then
log "!!! SAFETY ABORT: maxcell ${MXCELL}V / maxtemp ${MXT}C — reverting now"; exit 2; fi
# DONE: all re-anchored
if [ "$NDONE" = 6 ]; then log "COMPLETE: all 6 packs >=${SOC_DONE}% — re-anchored"; break; fi
# DONE backstop: at bulk + tapered for 2 consecutive polls
if (( $(python3 -c "print(1 if $MXV>=$VFULL and $TOTI<$ITAPER else 0)") )); then
taper_hits=$((taper_hits+1)); log " (at bulk + tapered, ${taper_hits}/2)"
[ "$taper_hits" -ge 2 ] && { log "COMPLETE: bulk reached + current tapered"; break; }
else taper_hits=0; fi
# TIMEOUT
if [ "$el" -ge $((MAX_HOURS*60)) ]; then log "TIMEOUT ${MAX_HOURS}h — reverting at packV ${MXV}"; break; fi
sleep "$POLL_S"
done
read MNS MXS _ _ _ MXV _ NDONE <<<"$(read_packs)"
log "RESULT: SoC ${MNS}-${MXS}%, packs@100=${NDONE}/6, packV up to ${MXV}"
# revert runs via trap