Add solar monitoring/troubleshooting skills for agents

Four Skill-tool skills under .claude/skills/ that let an agent monitor and
troubleshoot the install (2x LVX6048, 6x EG4 LifePower4, OpenEVSE), grounded
in the real MQTT/HA topology rather than generic advice:

- solar-health-check  : whole-system sweep + cross-checks + R/Y/G verdict,
                        incl. cross-unit "silently-dead inverter" detection
- troubleshoot-inverter: FWS fault decode, parallel sync, USB link recovery
- troubleshoot-battery : per-pack imbalance vs SoC-counter-drift, RS485 silence
- power-usage         : PV/load/grid/battery balance + EVSE sessions

Shared lib:
- solar-snapshot : live MQTT capture (creds from powermon.yaml, no hardcoding)
- ha-history     : HA recorder lookback (token from ~/.config/ha/token)
REFERENCE.md documents topology, real HA entity_ids (doubled slug), known
issues, and a safe-remediation-only action policy (restarts yes; setters no).

Action boundary: diagnose + restart wedged daemons / recover USB links;
never touches inverter/battery setters or flash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:46:20 -04:00
parent 5484bb5fa6
commit aa97d65b0c
8 changed files with 792 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# solar-snapshot — capture the latest retained/published value of every MQTT
# topic matching a filter, over a short listen window, and print a clean table.
#
# Why a listen window: powermon/eg4-battery STATE topics are NOT retained — they
# are republished every poll cycle (GS ~5s, packs ~one cycle, EVSE on-change).
# So we subscribe for a few seconds and keep the last value seen per topic.
# (HA discovery `.../config` topics ARE retained and show up immediately.)
#
# Broker credentials are read from ~/.config/powermon/powermon.yaml (the same
# source the openevse + lvx-control tools use) so nothing is hardcoded here.
#
# NOTE on MQTT wildcards: `+` matches exactly ONE whole level, so it cannot be
# used as a name prefix. `homeassistant/sensor/lifepower4_+/state` matches NOTHING.
# To grab a family of entities, subscribe to the level wildcard and filter with -g:
# solar-snapshot -g lifepower4 'homeassistant/sensor/+/state'
#
# Usage:
# solar-snapshot [-w SECONDS] [-f] [-g GREP_RE] TOPIC_FILTER [TOPIC_FILTER ...]
# -w SECONDS listen window (default 12)
# -f print full topic path (default: strip homeassistant/<class>/ prefix)
# -g GREP_RE keep only topics whose path matches this extended-regex
#
# Examples:
# solar-snapshot -g 'lvx6048_1' 'homeassistant/sensor/+/state'
# solar-snapshot -w 18 -g 'lifepower4_[1-6]_soc' 'homeassistant/sensor/+/state'
# solar-snapshot 'openevse/#'
# solar-snapshot -w 6 'homeassistant/sensor/lvx6048_1_battery_voltage/state' \
# 'homeassistant/sensor/lifepower4_1_pack_voltage/state'
#
# Exit status reflects the formatting stage, not mosquitto_sub's benign -W
# window-expiry code, so callers don't misread a normal capture as a failure.
set -eu
WINDOW=12
FULL=0
GREP_RE=""
while getopts "w:fg:" opt; do
case "$opt" in
w) WINDOW="$OPTARG" ;;
f) FULL=1 ;;
g) GREP_RE="$OPTARG" ;;
*) echo "usage: solar-snapshot [-w SECONDS] [-f] [-g GREP_RE] TOPIC_FILTER..." >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
if [ "$#" -lt 1 ]; then
echo "usage: solar-snapshot [-w SECONDS] [-f] [-g GREP_RE] TOPIC_FILTER..." >&2
exit 2
fi
CONF="${POWERMON_CONF:-$HOME/.config/powermon/powermon.yaml}"
if [ ! -r "$CONF" ]; then
echo "solar-snapshot: cannot read broker config $CONF" >&2
exit 1
fi
# Pull host/port/user/pass from the mqttbroker: block of powermon.yaml.
# Keys are anchored to leading whitespace + exact key so `name:` doesn't also
# match `username:`.
read -r HOST PORT USER PASS < <(awk '
/^[^[:space:]]/ { inblk=0 }
/^mqttbroker:/ { inblk=1; next }
inblk && /^[[:space:]]+name:/ { h=$2 }
inblk && /^[[:space:]]+port:/ { p=$2 }
inblk && /^[[:space:]]+username:/ { u=$2 }
inblk && /^[[:space:]]+password:/ { w=$2 }
END { print h, (p?p:1883), u, w }
' "$CONF")
if [ -z "${HOST:-}" ]; then
echo "solar-snapshot: no mqttbroker.name found in $CONF" >&2
exit 1
fi
# Build -t args from filters.
TARGS=()
for f in "$@"; do TARGS+=(-t "$f"); done
# Subscribe for the window, then reduce to last-value-per-topic.
timeout "$((WINDOW + 2))" mosquitto_sub -h "$HOST" -p "$PORT" -u "$USER" -P "$PASS" \
-W "$WINDOW" -v "${TARGS[@]}" 2>/dev/null \
| { [ -n "$GREP_RE" ] && grep -E "$GREP_RE" || cat; } \
| awk -v full="$FULL" '
{ t=$1; $1=""; sub(/^ /,""); v=$0; last[t]=v; order[t]=NR }
END {
n=0
for (t in last) { keys[n++]=t }
# stable-ish sort by topic name
for (i=0;i<n;i++) for (j=i+1;j<n;j++) if (keys[j]<keys[i]) { tmp=keys[i];keys[i]=keys[j];keys[j]=tmp }
for (i=0;i<n;i++) {
t=keys[i]; disp=t
if (!full) { sub(/^homeassistant\/[^/]+\//,"",disp); sub(/\/state$/,"",disp) }
printf "%-44s %s\n", disp, last[t]
}
if (n==0) print "(no messages in window — topics idle, broker unreachable, or filter wrong)"
}'