#!/usr/bin/env bash # solar-morning-run — unattended solar calibration top-off (+ opt-in throttle test). # # DEFAULT (scheduled): SOLAR-ONLY at the existing 60 A cap. No setting is changed. # Drives the natural solar charge to a full absorption that re-anchors the 6 EG4 pack # SoC counters, with a weather gate, safety monitoring, and re-anchor verification. # # OPT-IN throttle mode (THROTTLE=1 env): also raises the charge cap 60->80 A to test # whether the cap throttles midday harvest. IMPORTANT FIRMWARE CONSTRAINT (confirmed # live 2026-06-24): MCHGC is LOCKED while the inverter is charging — a cap change NAKs # in 'Battery' mode whenever charger_status='charging'. So the cap can only be set in a # true pre-charge idle window (very early dawn), and the revert can only complete once # charging stops (evening / battery full). cleanup() retries the revert; if it can't # (still charging), it WARNS and the cap stays 80 A (safe) until an idle window. Because # of this lock there is NO clean noon A/B — throttle mode only logs today's 80 A-cap # peak vs the historical 60 A baseline (weather-confounded). Use supervised, not blind. # # Safety: monitor-abort if any cell > 3.65 V or pack temp > 45 C. NOTE the script CANNOT # force-stop a charge (the relevant setters are locked while charging) — the pack BMS # over-voltage/over-temp protection is the real safety net, as always. # Weather gate: if PV hasn't ramped past 4 kW by 10:30, too cloudy — exit (revert if raised). # # solar-morning-run [--dry-run] # default: solar-only @ 60 A # THROTTLE=1 solar-morning-run # also raise cap to 80 A (supervised dawn use) set -uo pipefail # NOT -e: we handle errors explicitly so cleanup/revert always runs DRYRUN=0; [ "${1:-}" = "--dry-run" ] && DRYRUN=1 # ---- config ---- HA="http://10.0.0.41:8123" TOKEN_FILE="$HOME/.config/ha/token" POWERMON_CONF="$HOME/.config/powermon/powermon.yaml" CTRL_TOPIC="solar/control/lvx6048/max_charging_current" CAP_HIGH=80; CAP_LOW=60 CELL_ABORT=3.65 # V, any pack cell_voltage_max above this -> abort TEMP_ABORT=45 # C, any pack temp above this -> abort SOC_DONE=99 # all 6 packs >= this -> re-anchored PV_SUN_GATE=4000 # W combined; below this by SUN_DEADLINE -> too cloudy SUN_DEADLINE="10:30" MAX_HOURS=13 POLL_S=600 # monitor loop period [ "$DRYRUN" = 1 ] && POLL_S=2 # fast loop for validation BASE_PEAK_W=9459 # yesterday's 60A-cap noon peak, for the throttle comparison BASE_PEAK_A=120 # ...and the cap it was pinned at RUNDIR="$HOME/solar-runs"; mkdir -p "$RUNDIR" LOG="$RUNDIR/run-$(date +%Y%m%d).log" LOCK="$RUNDIR/.lock" RAISED=0 log(){ printf '%s %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG"; } # ---- broker creds from powermon.yaml (same source as the other tools) ---- 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" 2>/dev/null)" [ -z "$TOKEN" ] && { log "FATAL: no HA token at $TOKEN_FILE"; exit 1; } # ---- HA point reads ---- ha_json(){ curl -s -H "Authorization: Bearer $TOKEN" "$HA/api/states/$1"; } ha_state(){ ha_json "$1" | python3 -c 'import sys,json try:d=json.load(sys.stdin);print(d.get("state","")) except:print("")'; } ha_temp_c(){ ha_json "$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("")'; } PACK(){ echo "sensor.eg4_lifepower4_lifepower4_${1}_lifepower4_${1}_${2}"; } INV(){ echo "sensor.lvx6048_lvx6048_${1}_${2}"; } # combined live PV (both units, mppt1) pv_now(){ local a b a=$(ha_state "$(INV 1 mppt1_input_power)"); b=$(ha_state "$(INV 2 mppt1_input_power)") python3 - "$a" "$b" <<'PY' import sys def f(x): try: return float(x) except: return 0.0 print(int(f(sys.argv[1]) + f(sys.argv[2]))) PY } # ---- cap change via lvx-control (atomic mirror to both units) ---- set_cap(){ # $1 = amps local amps="$1" if [ "$DRYRUN" = 1 ]; then log "[dry-run] would publish $amps to $CTRL_TOPIC"; return 0; fi mosquitto_pub -h "$BHOST" -p "$BPORT" -u "$BUSER" -P "$BPASS" -t "$CTRL_TOPIC" -m "$amps" # confirm via the two result topics (fail shows 'Failed') local res; res=$(timeout 17 mosquitto_sub -h "$BHOST" -p "$BPORT" -u "$BUSER" -P "$BPASS" \ -W 15 -t powermon/lvx6048_1/result -t powermon/lvx6048_2/result 2>/dev/null) log "cap->$amps result: $(echo "$res" | tr '\n' '|')" echo "$res" | grep -qi 'fail' && return 1 return 0 } cleanup(){ local rc=$? tries=0 while [ "$RAISED" = 1 ] && [ "$tries" -lt 4 ]; do tries=$((tries+1)); log "CLEANUP: revert cap to ${CAP_LOW}A (try $tries)" if set_cap "$CAP_LOW"; then sleep 8 log "CLEANUP: live cap now $(ha_state $(INV 1 max_charging_current))/$(ha_state $(INV 2 max_charging_current)) A" RAISED=0 else log "CLEANUP: revert NAK'd (inverter still charging?) — wait 120s + retry" sleep 120 fi done [ "$RAISED" = 1 ] && log "CLEANUP: !!! COULD NOT REVERT — cap still ${CAP_HIGH}A (safe). Restore ${CAP_LOW}A via lvx-control once charging stops (evening)." log "run exit rc=$rc" flock -u 9 2>/dev/null || true } trap cleanup EXIT INT TERM # ---- read all 6 packs; echo "minSoC maxSoC maxCell maxTempC spreadV"; set ABORT_REASON ---- ABORT_REASON="" read_packs(){ local socs=() cells=() temps=() pv=() i t pvolt for i in 1 2 3 4 5 6; do socs+=("$(ha_state "$(PACK $i soc)")") cells+=("$(ha_state "$(PACK $i cell_voltage_max)")") # hottest of the pack's sensors (pcb + 01..03), unit-corrected to C local tmax=0 s for s in temperature_pcb temperature_01 temperature_02 temperature_03; do t="$(ha_temp_c "$(PACK $i $s)")"; t=${t%.*} [[ "$t" =~ ^-?[0-9]+$ ]] && [ "$t" -gt "$tmax" ] && tmax=$t done temps+=("$tmax") pv+=("$(ha_state "$(PACK $i pack_voltage)")") done python3 - "${socs[*]}" "${cells[*]}" "${temps[*]}" "${pv[*]}" <<'PY' import sys socs=[float(x) for x in sys.argv[1].split() if x] cells=[float(x) for x in sys.argv[2].split() if x] temps=[float(x) for x in sys.argv[3].split() if x] pv=[float(x) for x in sys.argv[4].split() if x] print(f"{min(socs):.0f} {max(socs):.0f} {max(cells):.3f} {max(temps):.0f} {max(pv)-min(pv):.2f} {len([s for s in socs if s>=99])}") PY } # ============================ run ============================ exec 9>"$LOCK" flock -n 9 || { log "another run holds the lock; exiting"; exit 0; } log "=== solar-morning-run start (dry-run=$DRYRUN) ===" # PHASE 0 — pre-flight read MNS MXS MXCELL MXT SPREAD NDONE <<<"$(read_packs)" log "PRE-FLIGHT: SoC ${MNS}-${MXS}% (spread $((MXS-MNS)) pts), maxcell ${MXCELL}V, maxtemp ${MXT}C, packV spread ${SPREAD}V, packs@100=${NDONE}/6" BEFORE_SPREAD=$((MXS-MNS)) if (( $(python3 -c "print(1 if $MXCELL>$CELL_ABORT or $MXT>$TEMP_ABORT else 0)") )); then log "PRE-FLIGHT FAIL: cell/temp already at limit — not starting"; exit 1; fi if (( $(python3 -c "print(1 if $SPREAD>0.3 else 0)") )); then log "PRE-FLIGHT WARN: pack voltage spread ${SPREAD}V >0.3 — possible REAL imbalance, not just drift. Proceeding read-only, NOT raising cap."; SKIP_CAP=1; fi # PHASE 1 — arm: raise cap to 80A ONLY in throttle mode AND a true pre-charge idle # window (MCHGC is firmware-locked while charging — detect via charger_status, NOT # device_mode, which reads 'Battery' even mid-charge). if [ "${THROTTLE:-0}" = 1 ] && [ "${SKIP_CAP:-0}" != 1 ]; then c1=$(ha_state "$(INV 1 mppt1_charger_status)"); c2=$(ha_state "$(INV 2 mppt1_charger_status)") log "throttle mode; charger_status: u1='$c1' u2='$c2'" if echo "$c1$c2" | grep -qiE 'charg'; then log "already charging -> MCHGC locked; staying solar-only @ ${CAP_LOW}A (no cap change)" elif set_cap "$CAP_HIGH"; then [ "$DRYRUN" = 1 ] || RAISED=1 log "ARMED: cap raised to ${CAP_HIGH}A (live: $(ha_state $(INV 1 max_charging_current))/$(ha_state $(INV 2 max_charging_current)))" else log "cap raise NAK'd -> solar-only @ ${CAP_LOW}A" fi else log "SOLAR-ONLY mode: cap stays ${CAP_LOW}A, no setting changed (set THROTTLE=1 to opt in)" fi # PHASE 2 — sun gate + throttle observation START=$(date +%s) log "waiting for PV to ramp (gate ${PV_SUN_GATE}W by ${SUN_DEADLINE})..." while :; do P=$(pv_now); now=$(date +%H:%M) log " PV=${P}W at $now" (( $(python3 -c "print(1 if $P>=$PV_SUN_GATE else 0)") )) && { log "sun gate passed (${P}W)"; break; } [ "$DRYRUN" = 1 ] && { log "[dry-run] skip sun wait -> exercise monitor loop"; break; } if [[ "$now" > "$SUN_DEADLINE" ]]; then log "TOO CLOUDY: PV ${P}W < ${PV_SUN_GATE}W by ${SUN_DEADLINE} — aborting day"; exit 0; fi sleep 300 done # throttle observation (only meaningful if the cap was actually raised this run) PKW=$(pv_now) if [ "$RAISED" = 1 ]; then log "THROTTLE: PV(@${CAP_HIGH}A cap)=${PKW}W now; baseline 60A-cap noon peak was ${BASE_PEAK_W}W(@${BASE_PEAK_A}A). If today's clear-noon peak climbs well above baseline, the 60A cap WAS throttling -> raise permanently. (Tracked each poll.)" else log "solar-only: PV ${PKW}W (no throttle comparison — cap unchanged at ${CAP_LOW}A)" fi # PHASE 3 — monitor charge to full log "monitoring charge to full (poll ${POLL_S}s, abort cell>${CELL_ABORT}V/temp>${TEMP_ABORT}C)..." iter=0 while :; do iter=$((iter+1)) read MNS MXS MXCELL MXT SPREAD NDONE <<<"$(read_packs)" P=$(pv_now) log " [$iter] SoC ${MNS}-${MXS}% packs@100=${NDONE}/6 maxcell ${MXCELL}V maxtemp ${MXT}C PV ${P}W" # 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 exceeded limit — reverting + stopping"; exit 2; fi # COMPLETE if [ "$NDONE" = 6 ]; then log "COMPLETE: all 6 packs >= ${SOC_DONE}% — re-anchored"; break; fi # TIMEOUT / sun gone el=$(( ($(date +%s)-START)/3600 )) if [ "$el" -ge "$MAX_HOURS" ]; then log "TIMEOUT: ${MAX_HOURS}h elapsed, not full — reverting"; break; fi if (( $(python3 -c "print(1 if $P<300 else 0)") )) && [ "$iter" -gt 2 ]; then log "SUN GONE: PV ${P}W and not full — incomplete, reverting (retry another clear day)"; break; fi if [ "$DRYRUN" = 1 ] && [ "$iter" -ge 2 ]; then log "[dry-run] stop after 2 iters"; break; fi sleep "$POLL_S" done # PHASE 4 — verify read MNS MXS _ _ _ NDONE <<<"$(read_packs)" log "RESULT: SoC spread ${BEFORE_SPREAD}pts -> $((MXS-MNS))pts, packs@100=${NDONE}/6" [ "$NDONE" = 6 ] && log "SUCCESS: all packs re-anchored to 100%." || log "PARTIAL: ${NDONE}/6 re-anchored — rerun on a clear day." # cleanup() runs on exit and reverts the cap