Commit 09856cb
feat(enphase): read measured power from the Enlighten livestream (#4431)
* feat(enphase): read measured power from the Enlighten livestream
The instantaneous power sensors were derived from the /today 15-minute
energy buckets, which cannot produce a usable house load: consumption is
the residual of much larger terms, so on a site cycling 30 kWh a day
through the battery to serve a 5 kWh house load it is unphysical about a
fifth of the time. load_power was instead published from get_latest_power,
which reports PRODUCTION, not consumption - it reads 0-1 W all night and
tracks the PV ramp by day.
The Enlighten app streams a protobuf DataMsg once a second over
MQTT-on-WebSockets from AWS IoT, carrying separately METERED pv, storage,
grid and load channels plus SOC. Predbat now takes one reading per cycle -
connect, first message, disconnect, the same lifecycle the web app uses -
rather than holding the stream open and re-authorising every 900s.
Credentials are bootstrapped from /pv/aws_sigv4/livestream.json using the
gateway serial, which /today already carries, so no extra discovery call
is needed. AWS IoT's custom authorizer is fed through the MQTT CONNECT
username: the WebSocket takes no query string and no password, because a
browser cannot set custom headers on a WebSocket.
Verified against 379 frames captured from a real session: the channels
satisfy load = pv + grid + battery to 0.0 W on every frame, load reads
154-1979 W where PV reads 4452-4933 W, and the signs already match
Predbat's convention. One of those frames is committed as a test fixture.
The bucket-derived values remain the fallback for pv/grid/battery when the
stream is unavailable, so a failure degrades rather than blanking the
sensors; load is left empty in that case rather than published wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* [pre-commit.ci lite] apply automatic fixes
* fix(enphase): keep livestream credentials out of the log
The livestream bootstrap response carries aws_token_value and aws_digest,
the live credentials for the account's AWS IoT stream. Debug API logging
redacted only token/auth_token/access_token, so both were written out in
full - and Predbat logs are routinely shared for debugging. The endpoint
and topic are still logged so the call stays diagnosable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(enphase): treat a pending schedule family as supported
The cloud reports a schedule family as scheduleStatus "pending" while a
change settles on the gateway - the normal state straight after any write
Predbat makes. Only "active"/"enabled"/"supported"/"available" counted as
supported, so a site with a perfectly good charge-from-grid family was
judged incapable of it, automatic_config raised and run() returned False:
Warn: Automatic configuration skipped - Charge-from-grid (CFG)
scheduling not supported on this site, cannot configure
seen on a site whose cfg family held an active schedule and whose profile
reported scheduleSupported true for both cfg and dtg.
"pending" now counts as supported, and so does any family that actually
holds a schedule, whatever the status string says. "not_supported" still
reports unsupported.
Also aligns the livestream fallback test with the settled-bucket selection
that landed on main in #4430: the helper froze time one bucket too early,
and load now falls back to the energy-balance residual rather than being
left empty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(enphase): publish grid power positive when exporting
Predbat's convention is grid positive on EXPORT - web.py's power flow
reads `grid_power >= 10` as exporting, and sigenergy.py documents the same
- but enphase.py published `import - export`, so every Enphase user's grid
power has been inverted, showing import where the flow diagram expects
export. The livestream's grid channel is negative while exporting too, so
both the measured and the bucket-derived paths needed flipping.
The residual load derivation follows from the sign change: in Predbat's
signs (grid +export, battery +discharge) the balance is pv + battery -
grid, not pv + grid + battery. Load values are unchanged by this; only the
grid sensor's sign moves.
Battery power is left alone: `discharge - charge` (positive on discharge)
already matches the core convention in inverter.py, which detects charging
as `power < -threshold`, and sigenergy's documented mapping. Note that
web.py's power flow reads battery the opposite way (`>= 10` as charging),
which looks like a display bug affecting every integration rather than
something to correct here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(enphase): never republish a stale livestream reading
Livestream readings are instantaneous and carry no usable timestamp of
their own (DataMsg.timestamp is a constant), so keeping one around past
its moment presents an old measurement as current. Two ways that happened:
- Caching. _load_cache restores each key's storage age into data_age, so a
restart within ENPHASE_REFRESH_POWER left the refresh gate satisfied,
get_live_power unrun and the restored reading published as live.
live_power is now in-memory only.
- A failed read left the previous reading in place, so a stream outage
mid-run republished the last good measurement indefinitely, no restart
required. A failure now drops it.
Either way the sensors fall back to the bucket values, which lag but are
genuinely current.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(enphase): age livestream readings out instead of dropping them
Clearing the reading on a failed read made a single missed connection flip
all four sensors onto the bucket fallback, which lags 15-30 minutes - a
bigger visible step than simply holding the last measurement a little
longer. A reading now stays in use for ENPHASE_LIVE_MAX_AGE_MINUTES and is
ignored after that, so a blip is absorbed while genuinely old data still
stops being presented as current.
Readings are stamped on arrival because DataMsg.timestamp is a constant
and the payload cannot date itself. They remain in-memory only, so nothing
survives a restart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>1 parent e7b7539 commit 09856cb
7 files changed
Lines changed: 663 additions & 16 deletions
File tree
- .cspell
- apps/predbat
- tests
- docs
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
| |||
76 | 77 | | |
77 | 78 | | |
78 | 79 | | |
| 80 | + | |
79 | 81 | | |
80 | 82 | | |
81 | 83 | | |
| |||
138 | 140 | | |
139 | 141 | | |
140 | 142 | | |
| 143 | + | |
141 | 144 | | |
142 | 145 | | |
143 | 146 | | |
| |||
368 | 371 | | |
369 | 372 | | |
370 | 373 | | |
| 374 | + | |
371 | 375 | | |
372 | 376 | | |
373 | 377 | | |
| |||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
38 | | - | |
| 38 | + | |
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
| |||
0 commit comments