Skip to content

Commit 9875fec

Browse files
committed
Add Enphase IQ Gateway (Envoy) powermeter
Re-implementation of the stalled #245. Polls the Envoy's local HTTPS /production.json?details=1 and reports net-consumption as grid power, auto-detecting single- vs three-phase from the response. Optional Enlighten-cloud credentials seed and refresh the JWT on 401; static tokens are accepted as well. Uses aiohttp with per-instance SSL context attached via TCPConnector. VERIFY_SSL defaults to False (Envoy ships a self-signed cert with no public CA bundle) and only affects the local Envoy session — Enlighten cloud requests use a separate session with default system TLS so the user's password is never sent over an unverified connection. https://claude.ai/code/session_01NiEwZMNZm9batkSCQCaRaz
1 parent 2656a07 commit 9875fec

7 files changed

Lines changed: 478 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- **Added** MQTT Insights: optional `[MQTT_INSIGHTS]` section publishes internal state (grid power, targets, saturation, consumer topology, EMA poll interval) to MQTT with Home Assistant Device Discovery; per-consumer active/pause + manual target control; Shelly battery offline availability; auto-configured in the HA app when Mosquitto is installed ([#292](https://github.com/tomquist/astrameter/pull/292), [#294](https://github.com/tomquist/astrameter/pull/294), [#297](https://github.com/tomquist/astrameter/pull/297), [#300](https://github.com/tomquist/astrameter/pull/300), [#306](https://github.com/tomquist/astrameter/pull/306)).
1616
- **Added** opt-in web-based configuration editor (`WEB_CONFIG_ENABLED = True` in `[GENERAL]`) accessible at `http://<host>:52500/config`; supports editing all config sections and keys with type-appropriate inputs, comment preservation, and a Save & Restart button ([#319](https://github.com/tomquist/astrameter/pull/319)).
1717
- **Added** HomeWizard P1 powermeter via the device WebSocket API, with optional `VERIFY_SSL` ([#231](https://github.com/tomquist/astrameter/pull/231), [#254](https://github.com/tomquist/astrameter/pull/254)).
18+
- **Added** Enphase IQ Gateway (Envoy) powermeter via the local HTTPS `production.json` API, with optional Enlighten-cloud token acquisition and automatic refresh on 401, and auto-detection of single- vs three-phase readings ([#245](https://github.com/tomquist/astrameter/pull/245)).
1819
- **Added** SMA Energy Meter / Sunny Home Manager support via Speedwire multicast with device auto-detection and per-phase readings ([#252](https://github.com/tomquist/astrameter/pull/252)).
1920
- **Added** SML powermeter for smart meters over a local serial port (IR head), with optional per-phase OBIS overrides ([#229](https://github.com/tomquist/astrameter/pull/229)).
2021
- **Added** multi-phase support to the MQTT powermeter via `TOPICS` / `JSON_PATHS` ([#280](https://github.com/tomquist/astrameter/pull/280), [issue #136](https://github.com/tomquist/b2500-meter/issues/136)).

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,31 @@ SERIAL = your_device_serial
740740
# THROTTLE_INTERVAL = 0
741741
```
742742

743+
### Enphase Envoy (IQ Gateway)
744+
745+
Reads grid power from an [Enphase IQ Gateway / Envoy](https://enphase.com/installers/microinverters/iq-gateway) over the local HTTPS API (`/production.json?details=1`). The reading comes from the `net-consumption` measurement (positive = grid import, negative = export). Per-phase readings are reported automatically when the gateway exposes them; otherwise the aggregate single-phase value is used. Requires consumption CTs installed on the Envoy.
746+
747+
```ini
748+
[ENVOY]
749+
HOST = 192.168.1.120
750+
# Option A: pre-obtained long-lived JWT (recommended)
751+
TOKEN = eyJ...
752+
# Option B: let AstraMeter fetch and refresh tokens via the Enphase Enlighten cloud
753+
# USERNAME = you@example.com
754+
# PASSWORD = your-enphase-password
755+
# SERIAL = 123456789012
756+
# Envoy ships a self-signed certificate; verification is disabled by default.
757+
# VERIFY_SSL = False
758+
```
759+
760+
**Token acquisition.** Generate a long-lived (~1 year) static token at <https://entrez.enphaseenergy.com/>. Alternatively, configure `USERNAME`/`PASSWORD`/`SERIAL` and AstraMeter will fetch a token on first use and refresh it automatically when the Envoy returns 401.
761+
762+
**TLS.** `VERIFY_SSL` defaults to `False` because Enphase does not publish a CA bundle for the IQ Gateway's self-signed certificate. This option **only affects the local Envoy connection** — Enphase Enlighten cloud requests (login and token endpoints) always verify TLS using the system trust store, regardless of this setting.
763+
764+
**MFA.** The auto-fetch flow does not support Enlighten accounts with multi-factor authentication enabled. Those users must supply a static `TOKEN`.
765+
766+
**CT direction.** If your readings have the wrong sign (export shows as import or vice versa), one or more CTs are mounted backwards. Flip them in software with the global `POWER_MULTIPLIER = -1` (or per-phase, e.g. `POWER_MULTIPLIER = 1, -1, 1`).
767+
743768
### SMA Energy Meter
744769

745770
Reads an [SMA Energy Meter](https://www.sma.de/) (EM 1.0/2.0) or Sunny Home Manager via the **Speedwire** multicast protocol (UDP). The listener joins the default multicast group and reports per-phase active power (L1, L2, L3). Use `SERIAL_NUMBER = 0` to auto-detect the first meter seen on the network, or set the device serial to pin a specific unit. Like other UDP-based features, this requires the host to receive multicast traffic (use Docker host networking or equivalent).

config.ini.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,22 @@ THROTTLE_INTERVAL = 0
303303
## Per-powermeter throttling override (optional)
304304
#THROTTLE_INTERVAL = 0
305305

306+
#[ENVOY]
307+
## Enphase IQ Gateway (Envoy) via local HTTPS API
308+
## Reads grid power from /production.json?details=1 (net-consumption).
309+
## Auto-detects single- vs three-phase from the response.
310+
#HOST = 192.168.1.120
311+
## Option A: long-lived JWT token from https://entrez.enphaseenergy.com/
312+
#TOKEN = eyJ...
313+
## Option B: let AstraMeter obtain a token via the Enphase Enlighten cloud
314+
## (auto-refreshes on 401; not compatible with MFA-enabled Enlighten accounts)
315+
#USERNAME = you@example.com
316+
#PASSWORD = your-enphase-password
317+
#SERIAL = 123456789012
318+
## Envoy uses a self-signed certificate; verification is disabled by default.
319+
## Affects only the local Envoy connection — cloud requests always verify TLS.
320+
#VERIFY_SSL = False
321+
306322
#[SMA_ENERGY_METER]
307323
## SMA Energy Meter / Sunny Home Manager via Speedwire multicast
308324
## Listens for UDP multicast broadcasts and reports per-phase power (L1, L2, L3)

src/astrameter/config/config_loader.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from astrameter.powermeter import (
1414
AmisReader,
1515
Emlog,
16+
Envoy,
1617
ESPHome,
1718
HomeAssistant,
1819
HomeWizardPowermeter,
@@ -58,6 +59,7 @@
5859
JSON_HTTP_SECTION = "JSON_HTTP"
5960
TQ_EM_SECTION = "TQ_EM"
6061
HOMEWIZARD_SECTION = "HOMEWIZARD"
62+
ENVOY_SECTION = "ENVOY"
6163
SMA_ENERGY_METER_SECTION = "SMA_ENERGY_METER"
6264
MQTT_INSIGHTS_SECTION = "MQTT_INSIGHTS"
6365

@@ -379,6 +381,8 @@ def create_powermeter(
379381
return create_json_http_powermeter(section, config)
380382
elif section.startswith(HOMEWIZARD_SECTION):
381383
return create_homewizard_powermeter(section, config)
384+
elif section.startswith(ENVOY_SECTION):
385+
return create_envoy_powermeter(section, config)
382386
elif section.startswith(SMA_ENERGY_METER_SECTION):
383387
return create_sma_energy_meter_powermeter(section, config)
384388
elif section.startswith("MQTT") and not section.startswith(MQTT_INSIGHTS_SECTION):
@@ -661,6 +665,19 @@ def create_homewizard_powermeter(
661665
)
662666

663667

668+
def create_envoy_powermeter(
669+
section: str, config: configparser.ConfigParser
670+
) -> Powermeter:
671+
return Envoy(
672+
host=config.get(section, "HOST", fallback=""),
673+
token=config.get(section, "TOKEN", fallback=""),
674+
username=config.get(section, "USERNAME", fallback=""),
675+
password=config.get(section, "PASSWORD", fallback=""),
676+
serial=config.get(section, "SERIAL", fallback=""),
677+
verify_ssl=config.getboolean(section, "VERIFY_SSL", fallback=False),
678+
)
679+
680+
664681
def create_sma_energy_meter_powermeter(
665682
section: str, config: configparser.ConfigParser
666683
) -> Powermeter:

src/astrameter/powermeter/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .amisreader import AmisReader
22
from .base import Powermeter
33
from .emlog import Emlog
4+
from .envoy import Envoy
45
from .esphome import ESPHome
56
from .homeassistant import HomeAssistant
67
from .homewizard import HomeWizardPowermeter
@@ -31,6 +32,7 @@
3132
"DeadbandPowermeter",
3233
"ESPHome",
3334
"Emlog",
35+
"Envoy",
3436
"HampelPowermeter",
3537
"HomeAssistant",
3638
"HomeWizardPowermeter",

src/astrameter/powermeter/envoy.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import logging
5+
import ssl
6+
from typing import Any
7+
8+
import aiohttp
9+
from aiohttp import ClientResponseError, ClientTimeout, TCPConnector
10+
11+
from .base import Powermeter
12+
13+
logger = logging.getLogger("astrameter")
14+
15+
ENLIGHTEN_LOGIN_URL = "https://enlighten.enphaseenergy.com/login/login.json"
16+
ENTREZ_TOKEN_URL = "https://entrez.enphaseenergy.com/tokens"
17+
DEFAULT_TIMEOUT_SECONDS = 10.0
18+
19+
20+
def _build_ssl_context(verify_ssl: bool) -> ssl.SSLContext:
21+
ctx = ssl.create_default_context()
22+
if not verify_ssl:
23+
# Order matters: verify_mode=CERT_NONE requires check_hostname=False first.
24+
ctx.check_hostname = False
25+
ctx.verify_mode = ssl.CERT_NONE
26+
return ctx
27+
28+
29+
async def _obtain_token(
30+
cloud_session: aiohttp.ClientSession,
31+
username: str,
32+
password: str,
33+
serial: str,
34+
) -> str:
35+
async with cloud_session.post(
36+
ENLIGHTEN_LOGIN_URL,
37+
data={"user[email]": username, "user[password]": password},
38+
) as resp:
39+
resp.raise_for_status()
40+
login_payload = await resp.json(content_type=None)
41+
session_id = (
42+
login_payload.get("session_id") if isinstance(login_payload, dict) else None
43+
)
44+
if not session_id:
45+
message = (
46+
login_payload.get("message", "unknown")
47+
if isinstance(login_payload, dict)
48+
else "unknown"
49+
)
50+
raise ValueError(
51+
f"Envoy: Enlighten login response missing session_id (message: {message})"
52+
)
53+
54+
async with cloud_session.post(
55+
ENTREZ_TOKEN_URL,
56+
json={
57+
"session_id": session_id,
58+
"serial_num": serial,
59+
"username": username,
60+
},
61+
) as resp:
62+
resp.raise_for_status()
63+
token = (await resp.text()).strip()
64+
65+
if not token.startswith("eyJ") or token.count(".") != 2:
66+
raise ValueError(
67+
f"Envoy: entrez token endpoint did not return a JWT (body: {token[:200]!r})"
68+
)
69+
70+
logger.info("Envoy: obtained new JWT token from Enlighten cloud")
71+
return token
72+
73+
74+
class Envoy(Powermeter):
75+
def __init__(
76+
self,
77+
host: str,
78+
token: str = "",
79+
username: str = "",
80+
password: str = "",
81+
serial: str = "",
82+
verify_ssl: bool = False,
83+
) -> None:
84+
if not host:
85+
raise ValueError("Envoy: HOST is required")
86+
has_credentials = bool(username and password and serial)
87+
if not token and not has_credentials:
88+
raise ValueError("Envoy: provide either TOKEN or USERNAME/PASSWORD/SERIAL")
89+
90+
self.host = host
91+
self._username = username
92+
self._password = password
93+
self._serial = serial
94+
self._has_credentials = has_credentials
95+
self._verify_ssl = verify_ssl
96+
self._ssl_context = _build_ssl_context(verify_ssl)
97+
self._token = token
98+
self._token_lock = asyncio.Lock()
99+
self._session: aiohttp.ClientSession | None = None
100+
self._cloud_session: aiohttp.ClientSession | None = None
101+
102+
if not verify_ssl:
103+
logger.warning(
104+
"Envoy: TLS certificate verification is disabled for the local "
105+
"Envoy (VERIFY_SSL=False); use only on a trusted LAN. Enphase "
106+
"Enlighten cloud requests are unaffected and always use system TLS."
107+
)
108+
109+
async def start(self) -> None:
110+
if self._session is not None:
111+
return
112+
timeout = ClientTimeout(total=DEFAULT_TIMEOUT_SECONDS)
113+
self._session = aiohttp.ClientSession(
114+
connector=TCPConnector(ssl=self._ssl_context),
115+
timeout=timeout,
116+
)
117+
# Separate session for the Enphase cloud: always uses default system TLS,
118+
# never weakened by VERIFY_SSL=False on the local Envoy.
119+
self._cloud_session = aiohttp.ClientSession(timeout=timeout)
120+
121+
async def stop(self) -> None:
122+
if self._session is not None:
123+
await self._session.close()
124+
self._session = None
125+
if self._cloud_session is not None:
126+
await self._cloud_session.close()
127+
self._cloud_session = None
128+
129+
async def _ensure_token(self) -> None:
130+
if self._token:
131+
return
132+
async with self._token_lock:
133+
if self._token:
134+
return
135+
assert self._cloud_session is not None
136+
self._token = await _obtain_token(
137+
self._cloud_session, self._username, self._password, self._serial
138+
)
139+
140+
async def _refresh_token(self) -> None:
141+
async with self._token_lock:
142+
assert self._cloud_session is not None
143+
self._token = await _obtain_token(
144+
self._cloud_session, self._username, self._password, self._serial
145+
)
146+
147+
async def _get_production(self) -> dict[str, Any]:
148+
assert self._session is not None
149+
url = f"https://{self.host}/production.json?details=1"
150+
headers = {"Authorization": f"Bearer {self._token}"}
151+
async with self._session.get(url, headers=headers) as resp:
152+
resp.raise_for_status()
153+
data = await resp.json(content_type=None)
154+
return data if isinstance(data, dict) else {}
155+
156+
async def _fetch_production(self) -> dict[str, Any]:
157+
await self._ensure_token()
158+
try:
159+
return await self._get_production()
160+
except ClientResponseError as e:
161+
if e.status != 401 or not self._has_credentials:
162+
raise
163+
logger.info("Envoy: token rejected (401), refreshing")
164+
await self._refresh_token()
165+
return await self._get_production()
166+
167+
async def get_powermeter_watts(self) -> list[float]:
168+
data = await self._fetch_production()
169+
consumption = data.get("consumption")
170+
if not isinstance(consumption, list):
171+
raise ValueError(
172+
"Envoy: production.json missing 'consumption' array; "
173+
"consumption CTs are required"
174+
)
175+
176+
entry = next(
177+
(
178+
c
179+
for c in consumption
180+
if isinstance(c, dict) and c.get("measurementType") == "net-consumption"
181+
),
182+
None,
183+
)
184+
if entry is None:
185+
raise ValueError(
186+
"Envoy: response does not expose 'net-consumption'; "
187+
"consumption CTs are required"
188+
)
189+
190+
lines = entry.get("lines")
191+
if isinstance(lines, list) and lines:
192+
return [float(line["wNow"]) for line in lines[:3]]
193+
return [float(entry["wNow"])]

0 commit comments

Comments
 (0)