Add solar-morning-run controller (solar-only calibration default)

Unattended morning runner for the calibration top-off. DEFAULT is solar-only
@ 60A: no setter, reads telemetry, weather-gates (PV<4kW by 10:30 -> abort),
monitors the charge with cell>3.65V / temp>45C aborts, verifies all 6 packs
re-anchor to 100%. Validated end-to-end via --dry-run against live HA.

Key firmware finding baked in (confirmed live): MCHGC is LOCKED while charging
(NAKs even in device_mode 'Battery' when charger_status='charging') -- so the
80A throttle test is opt-in (THROTTLE=1), gated on a true pre-charge idle
window, with retry-on-revert and a guaranteed-safe fallback (cap stays 80A
until idle if revert NAKs). No clean noon A/B is possible; documented as such.

Also handles the HA pack-temperature unit trap (entities report degF; the
script reads unit_of_measurement and converts to degC for the safety check).

REFERENCE: documented the MCHGC charging-lock under known issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 08:07:49 -04:00
parent 4d6c6c109b
commit 34d34f6e6c
2 changed files with 234 additions and 0 deletions

View File

@@ -130,6 +130,15 @@ MQTT — compute them yourself from the raw entities when working off the Pi.
Daily kWh must come from HA recorder or ET deltas. Daily kWh must come from HA recorder or ET deltas.
- **Parallel cluster**: changing inverter settings on only one unit risks fault 86 - **Parallel cluster**: changing inverter settings on only one unit risks fault 86
(desync). `lvx-control` always mirrors to both — that's why setters go through it. (desync). `lvx-control` always mirrors to both — that's why setters go through it.
- **MCHGC (max_charging_current) is firmware-LOCKED while charging** — confirmed live
2026-06-25: a cap change NAKs ("Failed") on BOTH units whenever `mppt1_charger_status`
= `charging`, even though `device_mode` still reads `Battery`. So the cap is only
settable in a true pre-charge idle window (dawn) and revertible only once charging
stops. Detect charging via `charger_status`, NOT `device_mode`. This is why
`solar-morning-run` defaults to solar-only @ 60 A and gates the 80 A throttle behind
an idle check. Same lock applies via `flash.py` (it's an inverter-side lock).
- **MCHGC `0`/Full equivalent — see** `battery_re_discharge_voltage` gotcha in the
calibration notes (`stop_charge_voltage` is really re-discharge; firmware NAKs 0).
## Action policy for these skills ## Action policy for these skills

View File

@@ -0,0 +1,225 @@
#!/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