Skip to content

Commit 09856cb

Browse files
springfall2008claudepre-commit-ci-lite[bot]
authored
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/custom-dictionary-workspace.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ armhf
2525
armv
2626
ASHP
2727
asyncio
28+
authoriser
2829
autodocstring
2930
autoflake
3031
automations
@@ -76,6 +77,7 @@ corruptplans
7677
cprofile
7778
creds
7879
crosscharge
80+
customauthorizer
7981
customisation
8082
Customise
8183
cvalue
@@ -138,6 +140,7 @@ energythroughput
138140
enho
139141
Enlighten
140142
enlm
143+
enph
141144
enphase
142145
enphaseenergy
143146
Enpower
@@ -368,6 +371,7 @@ predheat
368371
preseed
369372
preseeded
370373
prevs
374+
protobuf
371375
psum
372376
pvbat
373377
pvenergytotal

apps/predbat/enphase.py

Lines changed: 210 additions & 8 deletions
Large diffs are not rendered by default.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Enphase Enlighten livestream "Data Channel" message.
2+
//
3+
// Schema published by Enphase at
4+
// https://assets-enlighten.enphaseenergy.com/mobile/static/proto/DataMsg.proto and
5+
// .../MeterSummaryData.proto, combined here into one file (their DataMsg imports
6+
// HemsStreamMessage.proto but does not reference it, so that import is dropped).
7+
//
8+
// Regenerate enphase_livestream_pb2.py after editing:
9+
// python -m grpc_tools.protoc -I apps/predbat --python_out=apps/predbat \
10+
// apps/predbat/enphase_livestream.proto
11+
syntax = "proto3";
12+
13+
enum MeterSumGridState {
14+
OPER_RELAY_UNKNOWN = 0;
15+
OPER_RELAY_OPEN = 1;
16+
OPER_RELAY_CLOSED = 2;
17+
OPER_RELAY_OFFGRID_AC_GRID_PRESENT = 3;
18+
OPER_RELAY_OFFGRID_READY_FOR_RESYNC_CMD = 4;
19+
OPER_RELAY_WAITING_TO_INITIALIZE_ON_GRID = 5;
20+
OPER_RELAY_GEN_OPEN = 6;
21+
OPER_RELAY_GEN_CLOSED = 7;
22+
OPER_RELAY_GEN_STARTUP = 8;
23+
OPER_RELAY_GEN_SYNC_READY = 9;
24+
OPER_RELAY_GEN_AC_STABLE = 10;
25+
OPER_RELAY_GEN_AC_UNSTABLE = 11;
26+
}
27+
28+
enum BattMode {
29+
BATT_MODE_FULL_BACKUP = 0;
30+
BATT_MODE_SELF_CONS = 1;
31+
BATT_MODE_SAVINGS = 2;
32+
}
33+
34+
enum DryContactId {
35+
NC1 = 0;
36+
NC2 = 1;
37+
NO1 = 2;
38+
NO2 = 3;
39+
}
40+
41+
enum DryContactRelayState {
42+
DC_RELAY_STATE_INVALID = 0;
43+
DC_RELAY_OFF = 1;
44+
DC_RELAY_ON = 2;
45+
}
46+
47+
enum MeterType {
48+
METER_TYPE_NONE = 0;
49+
METER_TYPE_PV = 1;
50+
METER_TYPE_STORAGE = 2;
51+
}
52+
53+
// One measured channel. agg_p_mw is real power in milliwatts (divide by 1000 for W);
54+
// agg_s_mva is apparent power in milli-VA.
55+
message MeterChannel {
56+
int64 agg_p_mw = 1;
57+
int64 agg_s_mva = 2;
58+
repeated int64 agg_p_ph_mw = 3;
59+
repeated int64 agg_s_ph_mva = 4;
60+
optional string device_sn = 5;
61+
}
62+
63+
message AggMeterChannel {
64+
int64 agg_p_mw = 1;
65+
int64 agg_s_mva = 2;
66+
repeated int64 agg_p_ph_mw = 3;
67+
repeated int64 agg_s_ph_mva = 4;
68+
optional MeterType type = 5;
69+
repeated MeterChannel channels = 6;
70+
}
71+
72+
message MeterSummaryData {
73+
MeterChannel pv = 1;
74+
MeterChannel storage = 2;
75+
MeterChannel grid = 3;
76+
MeterChannel load = 4;
77+
MeterSumGridState grid_relay = 5;
78+
int32 soc = 6;
79+
MeterChannel generator = 7;
80+
MeterSumGridState gen_relay = 8;
81+
uint32 phase_count = 9;
82+
bool is_split_phase = 10;
83+
repeated AggMeterChannel meter_channel = 14;
84+
}
85+
86+
message DryContactStatus {
87+
DryContactId id = 1;
88+
DryContactRelayState state = 2;
89+
}
90+
91+
message DryContactName {
92+
DryContactId id = 1;
93+
string load_name = 2;
94+
}
95+
96+
message LoadStatus {
97+
string id = 1;
98+
string relay_status = 2;
99+
float power = 3;
100+
}
101+
102+
message PowerMatchStatus {
103+
bool status = 1;
104+
uint32 totalPCUCount = 2;
105+
uint32 runningPCUCount = 3;
106+
bool isSupported = 4;
107+
}
108+
109+
message DataMsg {
110+
int32 protocol_ver = 1;
111+
uint64 timestamp = 2;
112+
MeterSummaryData meters = 3;
113+
BattMode batt_mode = 4;
114+
int32 backup_soc = 5;
115+
repeated DryContactStatus dry_contact_relay_status = 6;
116+
repeated DryContactName dry_contact_relay_name = 7;
117+
repeated LoadStatus load_status = 8;
118+
PowerMatchStatus power_match_status = 9;
119+
}

apps/predbat/enphase_livestream_pb2.py

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/predbat/predbat.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
import pytz
3636
import asyncio
3737

38-
THIS_VERSION = "v8.47.6"
38+
THIS_VERSION = "v8.47.7"
3939

4040
from download import predbat_update_move, predbat_update_download, check_install, DEFAULT_PREDBAT_REPOSITORY
4141
from const import MINUTE_WATT

0 commit comments

Comments
 (0)