Skip to content

Add Enphase Envoy (IQ Gateway) powermeter support#336

Merged
tomquist merged 2 commits intodevelopfrom
claude/merge-stale-pr-ZCz2C
Apr 20, 2026
Merged

Add Enphase Envoy (IQ Gateway) powermeter support#336
tomquist merged 2 commits intodevelopfrom
claude/merge-stale-pr-ZCz2C

Conversation

@tomquist
Copy link
Copy Markdown
Owner

@tomquist tomquist commented Apr 20, 2026

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 Envoy powermeter class (src/astrameter/powermeter/envoy.py):

    • Reads net-consumption measurements from /production.json?details=1 endpoint
    • Automatically detects single-phase vs three-phase configurations from response
    • Supports two authentication modes:
      • Static long-lived JWT tokens (recommended)
      • Dynamic token fetch/refresh via Enphase Enlighten cloud (with auto-refresh on 401)
    • Separate TLS handling: local Envoy connection respects VERIFY_SSL setting (defaults to False for self-signed certs), while cloud requests always use system trust store
    • Thread-safe token refresh with async lock to prevent concurrent token requests
  • Comprehensive test suite (src/astrameter/powermeter/envoy_test.py):

    • 13 test cases covering single-phase, three-phase, error handling, authentication flows
    • Tests for missing data validation, 401 refresh logic, credential validation
    • SSL context configuration verification
  • Configuration integration:

    • Added [ENVOY] section support in config loader with parameters: HOST, TOKEN, USERNAME, PASSWORD, SERIAL, VERIFY_SSL
    • Updated config.ini.example with documented Envoy configuration block
    • Exported Envoy class from powermeter module
  • Documentation (README.md):

    • Added Enphase Envoy section with setup instructions
    • Documented token acquisition methods and MFA limitations
    • Explained TLS verification behavior and CT direction troubleshooting

Implementation Details

  • Uses aiohttp for async HTTPS requests with configurable SSL context
  • Token refresh triggered by 401 responses only when credentials are configured
  • Cloud session always uses default system TLS regardless of local VERIFY_SSL setting
  • Validates response structure and raises descriptive errors for missing consumption data or CTs
  • Supports up to 3 per-phase readings; falls back to aggregate value if lines unavailable

https://claude.ai/code/session_01NiEwZMNZm9batkSCQCaRaz

Summary by CodeRabbit

Release Notes

  • New Features

    • Enphase IQ Gateway (Envoy) powermeter integration now supported with local HTTPS connectivity, automatic token management, and automatic detection of single or three-phase power readings.
  • Documentation

    • Added Envoy configuration documentation with options for JWT tokens or automatic credential-based authentication.
  • Tests

    • Added comprehensive Envoy integration test coverage.

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
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

Warning

Rate limit exceeded

@tomquist has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 51 minutes and 47 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9aa43738-a5df-412e-91f8-239dee9179c6

📥 Commits

Reviewing files that changed from the base of the PR and between 9875fec and 992ca6c.

📒 Files selected for processing (2)
  • src/astrameter/powermeter/envoy.py
  • src/astrameter/powermeter/envoy_test.py

Walkthrough

A 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

Cohort / File(s) Summary
Documentation
CHANGELOG.md, README.md
Added changelog entry and detailed README section documenting Envoy integration, including local HTTPS API endpoint, authentication options (static token or username/password), CT direction correction via POWER_MULTIPLIER, and per-phase vs. single-phase reading handling.
Configuration
config.ini.example
Added new optional [ENVOY] configuration section with keys for host selection, JWT token or username/password authentication, required serial value, and VERIFY_SSL toggle (defaults to disabled for self-signed certificates).
Implementation
src/astrameter/config/config_loader.py, src/astrameter/powermeter/__init__.py
Extended factory methods and exports to recognize ENVOY configuration sections and route them to a new create_envoy_powermeter() constructor; added Envoy class to module exports.
Core Implementation
src/astrameter/powermeter/envoy.py
Implemented Envoy powermeter client with async aiohttp sessions for local Envoy (HTTPS with configurable SSL verification) and Enphase cloud (system TLS). Includes JWT token acquisition via Enlighten cloud, automatic token refresh on HTTP 401, production data parsing from /production.json?details=1, and per-line or aggregate power reading extraction. Manages token refresh concurrency via asyncio.Lock; includes error handling for missing auth, invalid tokens, and malformed responses.
Tests
src/astrameter/powermeter/envoy_test.py
Comprehensive test coverage for power parsing (single-phase and multi-phase), authentication flows (static token vs. credential-based token acquisition), HTTP 401 refresh behavior, SSL/certificate verification configuration, and constructor validation; uses mocked aiohttp sessions and responses.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding Enphase Envoy powermeter support. It is specific, directly related to the changeset, and reflects the primary feature being introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/merge-stale-pr-ZCz2C

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@tomquist tomquist marked this pull request as ready for review April 20, 2026 05:17
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/astrameter/powermeter/envoy.py (2)

135-148: Prefer RuntimeError over assert for lifecycle checks.

assert self._cloud_session is not None / assert self._session is not None are removed when Python runs with -O, at which point a forgotten start() surfaces as an obscure AttributeError: '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(). The asyncio.Lock serializes 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

📥 Commits

Reviewing files that changed from the base of the PR and between 2656a07 and 9875fec.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • README.md
  • config.ini.example
  • src/astrameter/config/config_loader.py
  • src/astrameter/powermeter/__init__.py
  • src/astrameter/powermeter/envoy.py
  • src/astrameter/powermeter/envoy_test.py

Comment thread src/astrameter/powermeter/envoy.py Outdated
- 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
@tomquist tomquist merged commit 979d4ba into develop Apr 20, 2026
13 checks passed
@tomquist tomquist deleted the claude/merge-stale-pr-ZCz2C branch April 20, 2026 05:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants