2121import base64
2222import json
2323import random
24+ import ssl
2425import uuid
26+ from urllib .parse import urlencode
2527
2628import aiohttp
2729
2830from component_base import ComponentBase
2931from mock_base import MockBase
3032from 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.
3452BASE_TIME = datetime .strptime ("00:00" , "%H:%M" )
4664ENPHASE_REFRESH_STATUS = 5 # battery SOC/available energy - needs to stay fresh for planning
4765ENPHASE_REFRESH_ENERGY = 5 # today energy totals
4866ENPHASE_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" ]
5171ENPHASE_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+
165234def _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"\n gateway 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 ("\n Done" )
15901762
15911763
0 commit comments