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:
@@ -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
|
||||
|
||||
|
||||
225
.claude/skills/lib/solar-morning-run
Executable file
225
.claude/skills/lib/solar-morning-run
Executable 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
|
||||
Reference in New Issue
Block a user