Build a Home Assistant add-on that polls an Enphase IQ Gateway over the LAN
(firmware 7/8, JWT token auth), enables its live data stream, and publishes
power + energy to Home Assistant via MQTT auto-discovery. Read-only.
Token lifecycle handled in code (auto-refresh). Match the structure and
conventions of the existing Solis-Local / CosyLocal add-ons.
A verified reference implementation already exists in this folder
(enphase-envoy-local/); this document is the spec it satisfies. Implement to
the spec, then run the verification in §9 — it must pass.
- IQ Gateway, metered (
imeter=true), single-phase ~240 V / 50 Hz. - Serial
<your-gateway-serial>, softwareD8.x→ JWT "web token" auth regime. - 13 microinverters; 2 CTs (production + net/grid). House "load" is gateway-derived (production − grid), not a third CT.
- No active battery on this unit (storage power 0). Keep battery sensors; they read 0 until an IQ Battery is present.
- HA add-on; Alpine python base images (copy
build.yamlfrom Solis-Local). - S6 service +
bashiorun script: read options → export env →exec python3. - HTTP via Python stdlib
urllib(norequests). Only pip dependency ispaho-mqtt==1.6.1. - Gateway uses a self-signed cert → disable TLS verification for gateway
calls only (
ssl.CERT_NONE). Cloud calls use normal verification. - One HA device, MQTT availability topic, retained discovery + state.
Enphase-Local/
repository.json
README.md
enphase-envoy-local/
config.yaml
build.yaml
Dockerfile
CHANGELOG.md
translations/en.json
rootfs/etc/services.d/enphase-envoy/run
rootfs/etc/services.d/enphase-envoy/finish
rootfs/opt/enphase/enphase_envoy.py
Dockerfile installs paho-mqtt==1.6.1, COPY rootfs /, and chmod a+x the
run, finish, and enphase_envoy.py files.
slug: enphase_envoy_local, services: [mqtt:need], map: [share:rw, addon_config:rw],
startup: application, boot: auto. Options keys must equal schema keys.
| Option | Schema | Default | Purpose |
|---|---|---|---|
envoy_host |
str |
envoy.local |
Gateway host/IP |
envoy_serial |
str? |
"" |
Serial; blank → auto from /info.xml |
enlighten_email |
str? |
"" |
Cloud login for token mint/refresh |
enlighten_password |
password? |
"" |
Cloud password |
manual_token |
str? |
"" |
Pasted-token fallback |
mqtt_host |
str? |
"" |
Blank → auto from HA MQTT service |
mqtt_port |
port |
1883 |
|
mqtt_user |
str? |
"" |
|
mqtt_pass |
password? |
"" |
|
poll_interval |
int(2,300) |
5 |
Seconds between cycles |
debug |
bool |
false |
Verbose logging |
The run script falls back to bashio::services mqtt … when mqtt_host is
empty, and exports TOKEN_DIR=/data, LOG_DIR=/config.
| Method | Path | Auth | Used for | Cadence |
|---|---|---|---|---|
| GET | /info.xml |
none | serial, software, pn (device block) | startup |
| POST | /ivp/livedata/stream body {"enable":1} |
Bearer | turn live stream on | setup + if sc_stream≠enabled |
| GET | /ivp/livedata/status |
Bearer | live powers + soc + connection.sc_stream |
each cycle |
| GET | /production.json |
Bearer | energy totals + inverter count | each cycle |
| GET | /ivp/meters/readings |
Bearer | grid voltage + frequency | each cycle |
Base URL https://<envoy_host>. All Bearer calls retry once after refreshing
the token on HTTP 401.
oid is the MQTT object id and the key the parse functions emit. All power
sensors state_class=measurement; energy total_increasing.
| oid | Name | Unit | device_class | Source → transform |
|---|---|---|---|---|
pv_power |
PV Production | W | power | livedata meters.pv.agg_p_mw → round(/1000) |
house_load |
House Load | W | power | livedata meters.load.agg_p_mw → round(/1000) (gateway-derived) |
grid_power |
Grid Power | W | power | livedata meters.grid.agg_p_mw → round(/1000); +import / −export |
grid_import |
Grid Import | W | power | derived: max(0, grid_power) |
grid_export |
Grid Export | W | power | derived: max(0, −grid_power) |
battery_power |
Battery Power | W | power | livedata meters.storage.agg_p_mw → round(/1000); +discharge / −charge |
battery_soc |
Battery SOC | % | battery | livedata meters.soc → round |
pv_today |
PV Generation Today | kWh | energy | production eim/production.whToday → round(/1000,3) |
pv_lifetime |
PV Generation Lifetime | kWh | energy | production eim/production.whLifetime → round(/1000,3) |
consumption_today |
House Consumption Today | kWh | energy | consumption total-consumption.whToday → round(/1000,3) |
consumption_lifetime |
House Consumption Lifetime | kWh | energy | consumption total-consumption.whLifetime → round(/1000,3) |
microinverters_online |
Microinverters Online | — | — | production inverters.activeCount (int) |
grid_voltage |
Grid Voltage | V | voltage | meters/readings: first entry with voltage>100 → round(,1) |
grid_frequency |
Grid Frequency | Hz | frequency | same entry .freq → round(,2) |
Units/signs: livedata agg_p_mw is milliwatts. Grid sign validated by
power balance (PV − load − export = 0). Battery sign convention assumed
(+discharge/−charge), unverifiable until a battery flows.
Keep the parse functions pure (no I/O) so they are unit-testable.
- Constants:
VERSION,LOGIN_URL=https://enlighten.enphaseenergy.com/login/login.json,TOKENS_URL=https://entrez.enphaseenergy.com/tokens, aCERT_NONEssl context,DEFAULT_CONFIG,SENSORS(the §6 table). - Pure functions:
http_request(method,url,…)(urllib, returns(status, body), catchesHTTPError);jwt_exp(token)(base64url-decode payload, returnexpor 0);parse_livedata(js),parse_production(js),parse_meters(readings)per §6. EnphaseClient:detect_info()(regex/info.xml),_mint_cloud()(login.json →session_id→ entreztokens),ensure_token(force)(§8),api()/api_json()(Bearer + 401-retry),enable_livedata().MQTTPublisher: discovery (§ below) +publish, LWToffline,expire_after.EnphaseController:setup()(info → token → mqtt → enable stream),poll_once()(3 GETs → merge parse dicts),run()(setup-retry loop, then poll loop with exponential backoff 5→60 s, publishonline/offline),probe()(one-shot print, no MQTT).main: env→config (build_config),argparse(--host,--probe,--debug), logging to stdout +RotatingFileHandlerunderLOG_DIR, SIGTERM/SIGINT → graceful stop.
Refresh margin = 7 days. Order:
- If
not forceand the in-memory token is valid (exp − now > 7d) → return it. - If
not forceand cached/data/enphase_token.jwtis valid → load + return. - If
not forceandmanual_tokenis valid → use, cache, return. - Mint: if
enlighten_email+enlighten_passwordset →login.json(formuser[email],user[password]) →session_id→ POSTtokens(JSONsession_id,serial_num,username) → JWT; cache + return. (Resolve serial from/info.xmlfirst if blank.) - Fallback if minting impossible:
manual_token(any), else stale cached file, else raise.
api() calls ensure_token(force=True) and retries once on HTTP 401. The 7-day
margin means normal polling refreshes the token before expiry unattended.
- Discovery config (retained JSON):
homeassistant/<component>/enphase_envoy/<oid>/config—<component>=sensorfor §6 rows,binary_sensorfor Gateway Status. - State:
enphase_envoy/<oid>/state. - Availability / status:
enphase_envoy/status(online/offline); LWT retainedoffline. - Each sensor payload:
name,unique_id=enphase_envoy_<oid>,state_topic,availability:[{topic:status}],device,expire_after = max(120, poll*6), plusunit_of_measurement/device_class/state_class/iconwhere set. device:identifiers:["enphase_envoy_local"],name:"Enphase IQ Gateway",manufacturer:"Enphase",modelfrom/info.xmlpn,sw_versionfrom/info.xmlsoftware,serial_number= serial.- Gateway Status:
binary_sensor,device_class:connectivity,entity_category:diagnostic, on/off =online/offline.
-
python3 -m py_compile enphase_envoy.py→ OK. -
config.yaml+build.yamlparse as YAML;optionskeys ==schemakeys.translations/en.json,repository.jsonparse as JSON. -
Offline parse test — feed these fixtures into the parse functions and assert the outputs exactly:
Fixtures: livedata
pv.agg_p_mw=894450, load=571756, grid=-322694, storage=0, soc=0; productioninverters.activeCount=13,eim/production whToday=17644.116 whLifetime=24461605.116,total-consumption whToday=19963.024 whLifetime=66764709.024; meters[{voltage:240.212,freq:49.938}, …].oid expected pv_power 894 house_load 572 grid_power −323 grid_import 0 grid_export 323 battery_power 0 battery_soc 0 pv_today 17.644 pv_lifetime 24461.605 consumption_today 19.963 consumption_lifetime 66764.709 microinverters_online 13 grid_voltage 240.2 grid_frequency 49.94 -
jwt_expreturns the payloadexp;_valid()true for a 2027 exp, false for junk.
- Repo matches §3;
run/finish/*.pyare executable. - Add-on starts, reads
/info.xml, obtains a token (mint or manual/cache). - Enables livedata; first poll publishes all 14 sensors (CTs present).
- Entities appear under one Enphase IQ Gateway device; Gateway Status tracks availability.
- Survives gateway/MQTT drop: exponential backoff, status →
offline. - Token auto-refreshes on 401 and within 7 days of expiry — unattended ~1 yr.
- §10 verification all green.
-
--probeprints values without MQTT.
- No control/writes (read-only).
- IQ Battery / Ensemble (
/ivp/ensemble/inventory,/ivp/ensemble/secctrl) for true SOC + charge/discharge — add when storage is fitted. - Optional: feed production/grid CTs into the Predbat load model.
If supplying manual_token: log in at https://enlighten.enphaseenergy.com,
then open https://enlighten.enphaseenergy.com/entrez-auth-token?serial_num=<serial>
and copy only the JWT (eyJ…, two dots — no JSON wrapper). The programmatic
login.json route fails with 401 {"message":"Invalid"} when the account has MFA;
credentials in the add-on still work for minting only if MFA is off, otherwise use
the manual token.