Add Enphase Envoy (IQ Gateway) powermeter support#336
Conversation
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
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 51 minutes and 47 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughA new Enphase Envoy (IQ Gateway) powermeter integration is added to the project. The feature includes configuration schema, factory methods for instantiation, a complete async HTTPS client implementation for local Envoy communication with JWT-based cloud authentication and automatic token refresh, and comprehensive test coverage. Changes
Sequence Diagram(s)sequenceDiagram
participant App as Application
participant Envoy as Envoy Client
participant LocalDevice as Local Envoy Device
participant Cloud as Enphase Cloud API
App->>Envoy: get_powermeter_watts()
alt No token or 401 response
Envoy->>Cloud: POST /login (username, password, serial)
Cloud-->>Envoy: 200 OK {session_id, token}
Envoy->>Envoy: Cache token
end
Envoy->>LocalDevice: GET /production.json?details=1<br/>Authorization: Bearer {token}
alt 200 OK
LocalDevice-->>Envoy: {consumption: [...]}
Envoy->>Envoy: Parse net-consumption<br/>Extract wNow values
Envoy-->>App: [float, ...] power readings
else 401 Unauthorized
Envoy->>Cloud: Refresh token<br/>(retry if credentials present)
Cloud-->>Envoy: New token
Envoy->>LocalDevice: Retry GET /production.json
LocalDevice-->>Envoy: {consumption: [...]}
Envoy-->>App: [float, ...] power readings
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/astrameter/powermeter/envoy.py (2)
135-148: PreferRuntimeErroroverassertfor lifecycle checks.
assert self._cloud_session is not None/assert self._session is not Noneare removed when Python runs with-O, at which point a forgottenstart()surfaces as an obscureAttributeError: 'NoneType' object has no attribute 'get'. The convention already established in this codebase (e.g.src/astrameter/powermeter/json_http.py) is:if self.session is None: raise RuntimeError("Session not started; call start() first")Aligning with that pattern keeps the error useful under optimized runs and matches the rest of the powermeter surface.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/astrameter/powermeter/envoy.py` around lines 135 - 148, Replace the runtime-only asserts with explicit lifecycle checks that raise RuntimeError so the error remains under -O; e.g., in the token acquisition path and in _refresh_token replace "assert self._cloud_session is not None" with an if-check that raises RuntimeError("Cloud session not started; call start() first") and replace "assert self._session is not None" in _get_production with if self._session is None: raise RuntimeError("Session not started; call start() first") to match the existing pattern used in json_http.py and keep failures descriptive in optimized runs.
140-165: Concurrent 401 responses cause redundant token refreshes.If multiple coroutines hit
_get_production()around the same time that the token expires, each will observe the 401 and sequentially call_refresh_token(). Theasyncio.Lockserializes them, but every waiter still issues its own_obtain_token()round-trip against the Enlighten cloud. This wastes auth calls and can trip Enphase's rate limits under bursty polling or restart-storm scenarios.Consider guarding against duplicate refreshes by comparing the token against the one used when the 401 was observed:
♻️ Proposed refactor
- async def _refresh_token(self) -> None: - async with self._token_lock: - assert self._cloud_session is not None - self._token = await _obtain_token( - self._cloud_session, self._username, self._password, self._serial - ) + async def _refresh_token(self, stale_token: str) -> None: + async with self._token_lock: + if self._token != stale_token: + # Another coroutine already refreshed while we were waiting. + return + assert self._cloud_session is not None + self._token = await _obtain_token( + self._cloud_session, self._username, self._password, self._serial + ) @@ async def _fetch_production(self) -> dict[str, Any]: await self._ensure_token() + token_used = self._token try: return await self._get_production() except ClientResponseError as e: if e.status != 401 or not self._has_credentials: raise logger.info("Envoy: token rejected (401), refreshing") - await self._refresh_token() + await self._refresh_token(token_used) return await self._get_production()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/astrameter/powermeter/envoy.py` around lines 140 - 165, Multiple coroutines seeing a 401 each trigger their own token refresh; modify _fetch_production to capture the current self._token (e.g., old_token = self._token) before calling _get_production, and in the ClientResponseError handler only call await self._refresh_token() if self._token is unchanged (self._token == old_token); if the token changed while waiting, skip refreshing and retry _get_production so waiters reuse the freshly obtained token. Keep existing calls to _ensure_token and the _refresh_token lock as-is; reference functions: _fetch_production, _get_production, _refresh_token, _ensure_token and attribute _token to implement this guard.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/astrameter/powermeter/envoy.py`:
- Around line 190-193: The per-line parse currently does a blind list
comprehension and will raise raw KeyError/TypeError for malformed entries;
update the logic in get_powermeter_watts to defensively validate each item in
lines (ensure each is a dict and contains a numeric "wNow") before casting to
float, and if any item is missing/invalid raise a descriptive ValueError
prefixed like other errors in this module (e.g., "Envoy: ...") rather than
letting KeyError/TypeError bubble up; refer to the variables lines and entry and
the "wNow" key when locating the code to change.
---
Nitpick comments:
In `@src/astrameter/powermeter/envoy.py`:
- Around line 135-148: Replace the runtime-only asserts with explicit lifecycle
checks that raise RuntimeError so the error remains under -O; e.g., in the token
acquisition path and in _refresh_token replace "assert self._cloud_session is
not None" with an if-check that raises RuntimeError("Cloud session not started;
call start() first") and replace "assert self._session is not None" in
_get_production with if self._session is None: raise RuntimeError("Session not
started; call start() first") to match the existing pattern used in json_http.py
and keep failures descriptive in optimized runs.
- Around line 140-165: Multiple coroutines seeing a 401 each trigger their own
token refresh; modify _fetch_production to capture the current self._token
(e.g., old_token = self._token) before calling _get_production, and in the
ClientResponseError handler only call await self._refresh_token() if self._token
is unchanged (self._token == old_token); if the token changed while waiting,
skip refreshing and retry _get_production so waiters reuse the freshly obtained
token. Keep existing calls to _ensure_token and the _refresh_token lock as-is;
reference functions: _fetch_production, _get_production, _refresh_token,
_ensure_token and attribute _token to implement this guard.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ea633a22-c19e-4ac3-b699-23c31ce1ba66
📒 Files selected for processing (7)
CHANGELOG.mdREADME.mdconfig.ini.examplesrc/astrameter/config/config_loader.pysrc/astrameter/powermeter/__init__.pysrc/astrameter/powermeter/envoy.pysrc/astrameter/powermeter/envoy_test.py
- Validate each per-phase line entry before casting; raise descriptive ValueError instead of leaking KeyError/TypeError for malformed payloads - Replace lifecycle asserts with explicit RuntimeError so the checks remain under python -O, matching json_http.py - Skip redundant token refresh on 401 when another coroutine has already rotated self._token while we were awaiting https://claude.ai/code/session_01NiEwZMNZm9batkSCQCaRaz
Summary
Adds support for reading grid power from Enphase IQ Gateway / Envoy devices via their local HTTPS API. The implementation includes automatic token management with cloud-based refresh capability and flexible authentication options.
Key Changes
New
Envoypowermeter class (src/astrameter/powermeter/envoy.py):net-consumptionmeasurements from/production.json?details=1endpointVERIFY_SSLsetting (defaults to False for self-signed certs), while cloud requests always use system trust storeComprehensive test suite (
src/astrameter/powermeter/envoy_test.py):Configuration integration:
[ENVOY]section support in config loader with parameters:HOST,TOKEN,USERNAME,PASSWORD,SERIAL,VERIFY_SSLconfig.ini.examplewith documented Envoy configuration blockEnvoyclass from powermeter moduleDocumentation (README.md):
Implementation Details
aiohttpfor async HTTPS requests with configurable SSL contextVERIFY_SSLsettinghttps://claude.ai/code/session_01NiEwZMNZm9batkSCQCaRaz
Summary by CodeRabbit
Release Notes
New Features
Documentation
Tests