Skip to content

Commit 9c3b948

Browse files
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>
1 parent b6282b6 commit 9c3b948

7 files changed

Lines changed: 500 additions & 12 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: 181 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,32 @@
2121
import base64
2222
import json
2323
import random
24+
import ssl
2425
import uuid
26+
from urllib.parse import urlencode
2527

2628
import aiohttp
2729

2830
from component_base import ComponentBase
2931
from mock_base import MockBase
3032
from predbat_metrics import record_api_call
3133

34+
try:
35+
import enphase_livestream_pb2 as livestream_pb
36+
37+
HAS_LIVESTREAM_PROTOBUF = True
38+
except (ImportError, Exception):
39+
livestream_pb = None
40+
HAS_LIVESTREAM_PROTOBUF = False
41+
42+
try:
43+
import aiomqtt
44+
45+
HAS_AIOMQTT = True
46+
except (ImportError, Exception):
47+
aiomqtt = None
48+
HAS_AIOMQTT = False
49+
3250
# Defined locally (not imported from utils) - every cloud component defines its own
3351
# copy of this table rather than sharing one, matching the pattern used by fox.py.
3452
BASE_TIME = datetime.strptime("00:00", "%H:%M")
@@ -46,8 +64,10 @@
4664
ENPHASE_REFRESH_STATUS = 5 # battery SOC/available energy - needs to stay fresh for planning
4765
ENPHASE_REFRESH_ENERGY = 5 # today energy totals
4866
ENPHASE_REFRESH_POWER = 5 # latest instantaneous power
67+
ENPHASE_LIVESTREAM_TIMEOUT = 15 # seconds to wait for a livestream message before giving up
68+
LIVESTREAM_BOOTSTRAP = "/pv/aws_sigv4/livestream.json" # returns the AWS IoT endpoint, topic and authorizer credentials
4969

50-
ENPHASE_CACHE_KEYS = ["sites", "battery_status", "battery_settings", "profile", "schedules", "site_settings", "today", "latest_power"]
70+
ENPHASE_CACHE_KEYS = ["sites", "battery_status", "battery_settings", "profile", "schedules", "site_settings", "today", "latest_power", "live_power"]
5171
ENPHASE_CACHE_VERSION = 2
5272

5373
# Battery profiles accepted by the profile endpoint
@@ -162,6 +182,55 @@ def enphase_time_to_ha(value):
162182
return text + ":00"
163183

164184

185+
def gateway_serial(today):
186+
"""Return the gateway (Envoy) serial recorded by get_today, or None."""
187+
return (today or {}).get("serial")
188+
189+
190+
def livestream_username(boot, site_id):
191+
"""Build the MQTT CONNECT username that AWS IoT's custom authorizer expects.
192+
193+
The livestream WebSocket carries no query parameters and no password - a browser cannot set
194+
custom headers on a WebSocket - so the authorizer name, the token and the token's signature all
195+
travel in the username as a leading-'?' query string. Field order matches the Enlighten web app.
196+
"""
197+
return "?" + urlencode(
198+
[
199+
("x-amz-customauthorizer-name", boot.get("aws_authorizer", "")),
200+
(boot.get("aws_token_key", "enph_token"), boot.get("aws_token_value", "")),
201+
("site-id", str(site_id)),
202+
("x-amz-customauthorizer-signature", boot.get("aws_digest", "")),
203+
("evse-count", "0"),
204+
("env", "prod"),
205+
]
206+
)
207+
208+
209+
def decode_livestream_message(payload):
210+
"""Decode one livestream DataMsg into per-channel watts plus battery SOC.
211+
212+
``agg_p_mw`` is real power in milliwatts. The channels are measured, not derived, and satisfy
213+
load = pv + grid + battery exactly. Signs already match Predbat's convention (grid negative when
214+
exporting, battery positive when discharging). Returns None if the payload will not decode.
215+
"""
216+
if not HAS_LIVESTREAM_PROTOBUF or not payload:
217+
return None
218+
try:
219+
message = livestream_pb.DataMsg()
220+
message.ParseFromString(payload)
221+
except Exception:
222+
return None
223+
meters = message.meters
224+
watts = lambda channel: round(channel.agg_p_mw / 1000.0, 1) # noqa: E731 - milliwatts -> watts
225+
return {
226+
"pv": watts(meters.pv),
227+
"battery": watts(meters.storage),
228+
"grid": watts(meters.grid),
229+
"load": watts(meters.load),
230+
"soc": int(meters.soc),
231+
}
232+
233+
165234
def _schedule_id_of(entry):
166235
"""Return the cloud id of a schedule detail entry ('scheduleId', or 'id' on older shapes)."""
167236
return entry.get("scheduleId") or entry.get("id")
@@ -249,6 +318,7 @@ def initialize(self, username, password, site_id=None, automatic=False, automati
249318
self.site_settings = {}
250319
self.today = {} # per-site today totals (Wh) + intra-day 15-minute buckets, from /today
251320
self.latest_power = {}
321+
self.live_power = {} # measured pv/grid/battery/load watts + soc from the Enlighten livestream
252322

253323
# Local (HA-side) schedule model, written by events, applied on write switch
254324
self.local_schedule = {}
@@ -359,6 +429,8 @@ async def run(self, seconds, first):
359429
await self.get_today(site_id)
360430
if self._needs_refresh("latest_power", ENPHASE_REFRESH_POWER):
361431
await self.get_latest_power(site_id)
432+
# Measured instantaneous power; falls back to the /today buckets if unavailable.
433+
await self.get_live_power(site_id)
362434
self.sync_local_schedule_from_cloud(site_id)
363435
await self.publish_data(site_id)
364436
await self.publish_schedule_settings_ha(site_id)
@@ -391,7 +463,6 @@ async def publish_data(self, site_id):
391463
profile = self.profile.get(site_id, {})
392464
settings = self.battery_settings.get(site_id, {})
393465
today = self.today.get(site_id, {})
394-
power = self.latest_power.get(site_id, {})
395466

396467
self.dashboard_item(
397468
f"{entity_base}_soc_percent",
@@ -489,13 +560,6 @@ async def publish_data(self, site_id):
489560
app="enphase",
490561
)
491562

492-
self.dashboard_item(
493-
f"{entity_base}_load_power",
494-
state=power.get("watts"),
495-
attributes={"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", "friendly_name": "Enphase Load Power", "icon": "mdi:home-lightning-bolt"},
496-
app="enphase",
497-
)
498-
499563
# Instantaneous power from the most recent completed intra-day 15-minute energy bucket of
500564
# the /today arrays (Wh per interval -> average watts over that interval). This reads a
501565
# single bucket value per poll, so it is inherently stable within an interval and needs no
@@ -510,6 +574,21 @@ async def publish_data(self, site_id):
510574
grid_power = channel_watts.get("import", 0.0) - channel_watts.get("export", 0.0)
511575
battery_power = channel_watts.get("discharge", 0.0) - channel_watts.get("charge", 0.0)
512576

577+
# Prefer the livestream when we have one: those channels are separately metered and
578+
# instantaneous, where the buckets are 15-minute averages and cannot yield a usable house
579+
# load at all. The bucket values above remain the fallback when the stream is unavailable.
580+
live = self.live_power.get(site_id) or {}
581+
if live:
582+
pv_power = live.get("pv", pv_power)
583+
grid_power = live.get("grid", grid_power)
584+
battery_power = live.get("battery", battery_power)
585+
self.dashboard_item(
586+
f"{entity_base}_load_power",
587+
state=live.get("load") if live else None,
588+
attributes={"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", "friendly_name": "Enphase Load Power", "icon": "mdi:home-lightning-bolt"},
589+
app="enphase",
590+
)
591+
513592
self.dashboard_item(
514593
f"{entity_base}_pv_power",
515594
state=pv_power,
@@ -1075,6 +1154,8 @@ async def get_today(self, site_id):
10751154
"interval_length": stat.get("interval_length"),
10761155
# Site health: siteStatus is "normal"/"comm" (communication fault) etc., with a
10771156
# human-readable status description when there is a problem (e.g. gateway not reporting).
1157+
# Gateway (Envoy) serial, needed to bootstrap the livestream - saves a separate call.
1158+
"serial": ((data.get("connectionDetails") or [{}])[0] or {}).get("serial_num"),
10781159
"site_status": data.get("siteStatus"),
10791160
"status_severity": status_details.get("statusSeverity"),
10801161
"status_desc": status_details.get("statusDesc"),
@@ -1083,6 +1164,77 @@ async def get_today(self, site_id):
10831164
await self._save_cache("today", self.today)
10841165
return self.today[site_id]
10851166

1167+
async def get_live_power(self, site_id):
1168+
"""Fetch one instantaneous, measured power reading from the Enlighten livestream.
1169+
1170+
The Enlighten app streams a protobuf `DataMsg` once a second over MQTT-on-WebSockets from
1171+
AWS IoT, carrying separately METERED pv/storage/grid/load channels plus SOC. That is the
1172+
only source of a real house-load figure: the /today energy buckets can only yield load as
1173+
the residual of much larger numbers, which is unusable while the battery cycles, and
1174+
get_latest_power reports production rather than consumption.
1175+
1176+
Predbat only needs one sample per cycle, so this connects, takes the first message and
1177+
disconnects - the same lifecycle the web app uses - rather than holding the stream open and
1178+
re-authorising every `live_stream_duration` (900s). Returns the reading, or None on any
1179+
failure, leaving the caller to fall back to the bucket-derived values.
1180+
"""
1181+
if not (HAS_AIOMQTT and HAS_LIVESTREAM_PROTOBUF):
1182+
return None
1183+
serial = gateway_serial(self.today.get(site_id, {}))
1184+
if not serial:
1185+
return None
1186+
boot = await self.request_json("GET", LIVESTREAM_BOOTSTRAP, params={"serial_num": serial})
1187+
if not boot or not boot.get("aws_iot_endpoint") or not boot.get("live_stream_topic"):
1188+
return None
1189+
reading = await self._read_livestream(site_id, boot, serial)
1190+
if reading:
1191+
self.live_power[site_id] = reading
1192+
await self._save_cache("live_power", self.live_power)
1193+
return reading
1194+
1195+
async def _read_livestream(self, site_id, boot, serial):
1196+
"""Connect to AWS IoT, take the first livestream message for a site, then disconnect.
1197+
1198+
Credentials ride in the MQTT CONNECT username (see livestream_username) because the
1199+
WebSocket carries no query string and no password. Any failure is logged and swallowed -
1200+
the livestream is an enhancement, never a reason to fail a cycle.
1201+
"""
1202+
timeout = safe_float(boot.get("timeout"), ENPHASE_LIVESTREAM_TIMEOUT) or ENPHASE_LIVESTREAM_TIMEOUT
1203+
topic = boot.get("live_stream_topic")
1204+
1205+
async def consume():
1206+
"""Subscribe and return the first decodable reading."""
1207+
async with aiomqtt.Client(
1208+
hostname=boot["aws_iot_endpoint"],
1209+
port=443,
1210+
transport="websockets",
1211+
websocket_path="/mqtt",
1212+
tls_context=ssl.create_default_context(),
1213+
identifier=f"em-paho-mqtt-{random.randint(10000, 99999)}-{serial}",
1214+
username=livestream_username(boot, site_id),
1215+
clean_session=True,
1216+
keepalive=60,
1217+
) as client:
1218+
await client.subscribe(topic, qos=0)
1219+
async for message in client.messages:
1220+
reading = decode_livestream_message(bytes(message.payload))
1221+
if reading:
1222+
return reading
1223+
return None
1224+
1225+
try:
1226+
reading = await asyncio.wait_for(consume(), timeout=timeout)
1227+
except asyncio.TimeoutError:
1228+
self.log(f"Warn: Enphase: Livestream timed out after {timeout}s for site {site_id}")
1229+
record_api_call("enphase", False, "livestream_timeout")
1230+
return None
1231+
except Exception as error:
1232+
self.log(f"Warn: Enphase: Livestream failed for site {site_id}: {error}")
1233+
record_api_call("enphase", False, "livestream_error")
1234+
return None
1235+
record_api_call("enphase", True)
1236+
return reading
1237+
10861238
async def get_latest_power(self, site_id):
10871239
"""Fetch and normalise the latest instantaneous power reading for a site."""
10881240
data = await self.request_json("GET", f"/app-api/{site_id}/get_latest_power")
@@ -1586,6 +1738,26 @@ async def test_enphase_api(username, password, site_id): # pragma: no cover
15861738
if values:
15871739
print(f" {channel}: len={len(values)} last5={values[-5:]}")
15881740

1741+
# Livestream: prove the connect -> read one message -> disconnect cycle works against the
1742+
# real account, and cross-check it against the bucket-derived values it replaces.
1743+
print(f"\ngateway serial: {gateway_serial(today)}")
1744+
print(f"protobuf available: {HAS_LIVESTREAM_PROTOBUF} aiomqtt available: {HAS_AIOMQTT}")
1745+
for attempt in range(1, 4):
1746+
started = datetime.now(timezone.utc)
1747+
reading = await api.get_live_power(sid)
1748+
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
1749+
if not reading:
1750+
print(f" livestream attempt {attempt}: FAILED after {elapsed:.1f}s")
1751+
continue
1752+
balance = reading["pv"] + reading["grid"] + reading["battery"] - reading["load"]
1753+
print(f" livestream attempt {attempt} ({elapsed:.1f}s): pv={reading['pv']}W grid={reading['grid']}W battery={reading['battery']}W load={reading['load']}W soc={reading['soc']}%")
1754+
print(f" energy balance (pv+grid+battery-load) = {balance:.1f} W <- expect ~0")
1755+
arrays = today.get("arrays") or {}
1756+
now_ts = datetime.now(timezone.utc).timestamp()
1757+
bucket = {channel: interval_power(arrays.get(channel, []), today.get("start_time"), today.get("interval_length"), now_ts) for channel in ("production", "import", "export", "charge", "discharge")}
1758+
print(f" bucket-derived for comparison: pv={bucket['production']}W grid={bucket['import'] - bucket['export']}W battery={bucket['discharge'] - bucket['charge']}W")
1759+
print(" (buckets are a 15-minute average and lag; large differences are expected)")
1760+
15891761
print("\nDone")
15901762

15911763

0 commit comments

Comments
 (0)