#!/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