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.
- **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.
- **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

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