diff --git a/.claude/skills/calibration-charge/SKILL.md b/.claude/skills/calibration-charge/SKILL.md index 2ec26f5..71c9027 100644 --- a/.claude/skills/calibration-charge/SKILL.md +++ b/.claude/skills/calibration-charge/SKILL.md @@ -53,18 +53,26 @@ Record and check: curve to bulk 56.4 V and holds absorption on its own — exactly the clean termination the BMS needs to re-anchor. No flash, no risk. Just monitor §4 and verify §5; skip §3. This is the method to default to. -- **Grid-assist — ADVANCED / UNVALIDATED:** the field flash.py calls - `stop_charge_voltage` is really `battery_re_discharge_voltage`; the firmware **NAKs - `0`/"Full"** (confirmed 2026-06-24 — both units rejected `BUCD480,000`). The corrected - lever raises it 54.0 → 56.0 so grid charges higher, but it may band-oscillate near - 56 V instead of holding a clean absorption, so it is **not proven** to re-anchor. - Only use as a supervised experiment when solar can't reach full and you accept it - may not fully work. Needs §3. +- **Grid-assist — for cloudy days (CORRECT lever, validated 2026-06-25):** force a full + grid charge by switching **output priority to SUB**, NOT by touching voltage + thresholds. In the everyday SBU mode the inverter won't grid-charge unless the bank is + critically low; SUB makes it run loads on grid AND charge the battery to full. Both + setters go through lvx-control (all-mode-safe, atomic, no flash). Needs §3. + (Do NOT use `re_discharge`/flash.py — firmware NAKs it; see memory + `project_lvx6048_grid_charge_lever`.) -## 3. Grid-assist (ADVANCED): apply the calibration profile (USER-CONFIRMED setter change) -Run `flash.py diff` FIRST and confirm the ONLY change is `stop_charge_voltage 54.0 -> -56.0` before applying. If any apply NAKs, the cluster is unchanged (flash.py aborts on -first failure) — fall back to solar-only. +## 3. Grid-assist: enable grid charging via output priority (USER-CONFIRMED) +Publish via lvx-control (atomic to both units, no powermon stop): +```bash +B=... # broker creds from ~/.config/powermon/powermon.yaml +mosquitto_pub ... -t solar/control/lvx6048/charger_priority -m solar_and_utility +mosquitto_pub ... -t solar/control/lvx6048/output_priority -m solar_utility_battery # SUB +``` +Confirm within ~1 min: `device_mode` -> Hybrid/Line, `line_power_direction` -> input, +pack current jumps. Verify BOTH units match (output_source_priority) — parallel sync. +Then run `lib/grid-cal-monitor` (detached) to drive/verify/auto-revert; it reverts +`output_priority->solar_battery_utility` + `charger_priority->solar_first` on completion +(trap-guaranteed). Skip §3 entirely for the solar-only method. Mirror to BOTH inverters (parallel cluster — mismatched settings throw fault 86). `flash.py apply` stops powermon for exclusive USB, so MQTT telemetry pauses briefly. ```bash diff --git a/.claude/skills/lib/grid-cal-monitor b/.claude/skills/lib/grid-cal-monitor new file mode 100755 index 0000000..2e8046f --- /dev/null +++ b/.claude/skills/lib/grid-cal-monitor @@ -0,0 +1,91 @@ +#!/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 diff --git a/LVX6048/lvx-flash/profiles/eg4-lp4-v2-calibration.yaml b/LVX6048/lvx-flash/profiles/eg4-lp4-v2-calibration.yaml index f93c5c0..8257869 100644 --- a/LVX6048/lvx-flash/profiles/eg4-lp4-v2-calibration.yaml +++ b/LVX6048/lvx-flash/profiles/eg4-lp4-v2-calibration.yaml @@ -7,6 +7,12 @@ # / high-load stretches the bank may go weeks without a full charge and the coulomb # counters drift (e.g. pack 6 read 76% while physically at ~53% on 2026-06-24). # +# !!! DEPRECATED 2026-06-25 — DO NOT USE. The re_discharge lever does not work for +# grid calibration: firmware NAKs stop_charge_voltage above float (56 > 54), and it's +# the wrong mechanism anyway. The CORRECT grid-charge lever is output priority -> SUB +# (solar_utility_battery) via lvx-control — see the calibration-charge skill §3 and +# memory project_lvx6048_grid_charge_lever. Kept only as a record of the dead end. +# # !!! 2026-06-24 FINDING — grid-assist lever corrected, still UNVALIDATED !!! # The original idea (stop_charge_voltage: 0 = "Full") was REJECTED by the firmware: # `flash.py apply` got an inverter NAK on `BUCD480,000` on BOTH units (no change made).