Add Enphase Enlighten cloud integration#4235
Open
springfall2008 wants to merge 33 commits into
Open
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tions fatal_error_occurred() sets the shared, app-wide PredBat fatal_error flag that stops is_running() and triggers a full web-UI restart. Signalling it on every rejected Enphase login (a single 401, "too many active sessions", etc.) would take down the whole app for a transient blip. _login_rejected() now takes unrecoverable=False; MFA-required and account-blocked responses pass unrecoverable=True and still fail fast. All other rejections keep the existing cooldown/suspend bookkeeping and only signal fatal once login_reject_count reaches the 24h suspend tier (LOGIN_MAX_REJECTS). Also widened the "too many active sessions" text match to run regardless of HTTP status. MockEnphaseAPI.fatal_error_occurred() now records fatal_signalled instead of silently swallowing the call, so tests can assert the policy: a single transient rejection stays non-fatal, MFA is fatal immediately, and the 3-strike guard rail becomes fatal only on the 3rd rejection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds request_json() and _is_login_wall() to EnphaseAPI: single 401 re-login, HTML login-wall detection, BatteryConfig header-variant fallback, jittered retry/backoff for 429/5xx/connection errors, and record_api_call() instrumentation via the module-level function in predbat_metrics.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
battery_status.json has no "profile" key, so profile_label was always an empty string. The battery profile name will instead be sourced from self.profile[site_id]["profile"] (populated by get_profile) when the battery-profile sensor is published in a later task. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds publish_schedule_settings_ha/get_schedule_settings_ha, the local schedule model, and async select/number/switch event handlers that mutate it, plus a stub apply_battery_schedule (real write path is Task 7). Export controls are only published when dtg_supported(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cloud_entry.get("limit", -1) only substitutes -1 when the "limit" key
is absent, but get_schedules() always sets "limit": entry.get("limit"),
so the key is present with value None whenever an enabled, time-
matching cloud schedule has no numeric limit. int(None) then raised
TypeError, aborting the entire apply_battery_schedule write cycle for
that site every 5-minute poll until the cloud shape changed.
Read the cloud limit separately and treat a present-but-None value the
same as absent, so schedules_equal correctly reports "not equal" (a
write is needed) instead of crashing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add INVERTER_DEF["EnphaseCloud"] (output_charge_control: "none" - Enphase has no charge/discharge rate control, so Predbat falls back to its default max-rate charge_rate/discharge_rate handling), automatic_config() to wire every generic inverter arg to the entities enphase.py publishes for the first discovered site (discharge/export args only when dtg_supported()), a one-shot call from run() on first successful data load when self.automatic is set, and the enphase_cloud.yaml apps.yaml template. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Document the EnphaseAPI component in components.md, inverter-setup.md and apps-yaml.md, extend the cspell workspace dictionary, and make the feat/enphase branch pass the full validation gate (unit tests, interrogate, pre-commit). Also fixes markdownlint/cspell issues in the pre-existing Enphase planning docs (list indentation, an orphaned code fence, unknown words) so the branch is fully lint-clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
publish_data() polls every 60s but lifetime_energy only refreshes every 15 minutes, so derive_power() was advancing its baseline sample on every call even when the kWh delta was zero - producing 0W for ~14 ticks then a ~15x inflated spike on the refresh tick. derive_power() now only advances the baseline when the value actually changes, and publish_data() holds the last computed watts per channel (self.last_power_w) otherwise. Also guard get_profile()/apply_battery_schedule()'s reserve-PUT against a None self.user_id, which would otherwise raise an uncaught TypeError from aiohttp when building the request params. Adds test_publish_data_derived_power_multicycle to prove the fix across repeated unchanged ticks and a simulated 15-minute refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a new Enphase Enlighten cloud integration to Predbat, enabling monitoring + schedule-based battery control for Enphase IQ Battery (Encharge) systems via the unofficial Enlighten web API, and wires it into Predbat configuration, templates, docs, and unit tests.
Changes:
- Introduces
EnphaseAPIcomponent with login/guard-rails, polling/caching, HA entity publishing, and schedule write path. - Adds dedicated unit tests and registers them with the existing
unit_test.pyrunner. - Adds config schema, component registration, templates, documentation, and CSpell dictionary updates for Enphase-related terms.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| templates/enphase_cloud.yaml | New example apps.yaml template for Enphase Cloud configuration. |
| docs/superpowers/specs/2026-07-11-enphase-cloud-integration-design.md | Design spec describing intended API flows, entities, and control mapping. |
| docs/superpowers/plans/2026-07-11-enphase-cloud-integration.md | Implementation plan / task breakdown for the integration. |
| docs/inverter-setup.md | Adds Enphase Cloud setup section and template link. |
| docs/components.md | Adds “Enphase API (enphase)” component documentation section. |
| docs/apps-yaml.md | Documents new enphase_* configuration keys and EnphaseCloud inverter type. |
| apps/predbat/unit_test.py | Registers enphase_api tests in the test registry. |
| apps/predbat/tests/test_enphase_api.py | Adds unit tests for auth, request handling, parsing, publishing, events, and write paths. |
| apps/predbat/enphase.py | Implements the Enphase cloud component (auth, polling, sensors, controls, writes). |
| apps/predbat/config.py | Adds EnphaseCloud inverter definition and enphase_* schema keys. |
| apps/predbat/components.py | Registers the enphase component and wires config args. |
| .cspell/custom-dictionary-workspace.txt | Adds Enphase/Enlighten-related words for spellchecking. |
Adds a MockBase + main() CLI (all # pragma: no cover) matching the fox.py/gecloud.py pattern, so the Enphase component can be exercised against a real Enlighten account from the command line: python3 enphase.py --username <email> --password <pw> [--site-id ID] python3 enphase.py ... --write-schedule [--start-time --end-time --soc] Default mode logs in and runs one poll cycle, printing discovered sites, battery status/profile/settings/schedules and the published entities. --write-schedule drives the control entities to write a test CFG charge window, reads it back, then restores by disabling it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two robustness fixes surfaced by PR review and live testing: 1. login() detected 'too many active sessions' with a bare "session" substring, which also matches happy-path bodies (they contain session_id / session cookies), so valid credentials were rejected and login never completed. Now matches the specific phrase via is_too_many_sessions() (mirrors the reference integration). 2. The live Enlighten API returns the string "N/A" for numeric fields it cannot report (e.g. current_charge on a sleeping battery), so float()/ int() raised ValueError and aborted the poll. Added safe_float/safe_int helpers and applied them to every cloud-facing numeric conversion in the read methods (battery_status, latest_power, profile, battery_settings, schedule limits). Adds regression tests for both, including a happy-path login whose body text contains 'session_id' and battery_status/reads with 'N/A' values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… config Fixes surfaced by a PV-only, 2-site test account: - login() now dedupes discovered sites by id (Enlighten returned the same site twice, so sensors were published twice under one id). When more than one distinct site is found and no enphase_site_id is set, it logs which site is used. - run() operates on a single active site (configured enphase_site_id, else the first) instead of looping every site. The per-category refresh gates are keyed globally, so a multi-site loop left later sites with no data and published zeros; single-site keeps them correct and avoids duplicate publishing. - automatic_config() raising ValueError (e.g. a PV-only site with no controllable battery) is now caught in run(): it logs a warning and returns False instead of letting the exception abort the whole poll. Also dumps raw lifetime_energy channels in the standalone harness to help verify *_today units/semantics. Adds regression tests for dedupe, single publish, and no-battery -> run() returns False. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds _log_api_call(): logs every request (method, path, params) and a truncated, token-redacted view of the response. Wired into request_json (all data reads/writes) and the three login() calls. Gated by self.debug_api (default True for now) so it can be turned off later. Request bodies (which carry the password) are never logged; token/ auth_token/access_token response fields are redacted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… shape
Driven by real-account logging:
- lifetime_energy arrays are per-day increments in Wh (array indexed daily
from start_date; last element = today), so *_today sensors were ~1000x
too big. energy_today now converts Wh->kWh. Updated tests to use Wh inputs.
- automatic_config now fails (ValueError -> run() returns False) when the
site does not support charge-from-grid (CFG) scheduling, since Predbat
cannot control charging without it - not just when there is no battery.
- get_schedules parses the real response shape: per-family scheduleStatus
('active' seen on real accounts) drives the 'supported' flag, and count/
status are captured. The count>0 per-schedule detail shape still needs a
battery account to finalise (documented).
- Standalone harness now probes GET /pv/systems/<site>/today read-only so
its shape can be inspected for a cadence-independent today total.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ndpoint Real-account logging of GET /pv/systems/<site>/today showed it returns each channel's today total (Wh) under stats[0].totals plus intra-day 15-minute buckets (interval_length seconds from start_time). This is the cadence-independent source the reference integration uses for 'today'. - *_today sensors now come from /today totals (kWh), replacing the fragile 'last daily bucket of lifetime_energy' assumption. - Instantaneous pv/grid/battery power now come from the most recent completed 15-minute bucket (interval_power), which is stable within an interval and needs no cross-poll delta tracking - so derive_power and its prev_energy_sample/last_power_w machinery are removed. - get_lifetime_energy replaced by get_today; cache key lifetime_energy -> today; ENPHASE_CACHE_VERSION 1 -> 2 to drop stale caches. - Standalone harness dumps today totals + bucket metadata. Tests updated: today_channel_kwh, interval_power, get_today, and the publish/run tests now use the /today shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A battery account's logs exposed four response-shape mismatches:
- profile and batterySettings wrap their fields in a nested 'data' object
({"type":"profile-details","data":{...}}). We now unwrap it, so profile
name, reserve (batteryBackupPercentage), chargeFromGrid and veryLowSoc*
are read correctly (were empty/default before).
- battery schedule detail id is 'scheduleId', not 'id'. The write path uses
this id to update a schedule in place, so without it every apply would
have created a duplicate instead of editing the existing one. Also pick
the first non-deleted schedule per family.
- current_charge is a percent string like '0%'/'50%'; safe_float/safe_int
now strip a trailing '%' so SOC parses.
- /today totals have no charge/discharge/export keys for batteries - energy
is reported as source_dest flows. charge=solar_battery+grid_battery,
discharge=battery_home+battery_grid, export=solar_grid+battery_grid+
generator_grid (summed when no direct total present).
Confirmed working: cfg schedule (01:10-05:29, limit 100, enabled) parsed
with its scheduleId; profile self-consumption reserve 30; settings
chargeFromGrid + veryLowSoc read. The battery in the test account was in
an error state (all flows 0), so the flow-sum values themselves still need
confirming on a healthy battery. Tests updated with the real shapes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The control entities (number battery_schedule_reserve, charge/export window selects, target-SOC numbers, enable switches) were published from the default local schedule model, so they showed 0/00:00/off even when the monitoring sensors showed the real cloud values (e.g. reserve 30, a live CFG window 01:10-05:29 enabled). That was confusing and gave Predbat a wrong initial view of the inverter (its reserve/charge-window args point at these control entities). sync_local_schedule_from_cloud() now seeds the local schedule/control model once from the cloud profile (reserve) and schedules (cfg->charge window, dtg->export window, rbd->freeze), so the control entities start out mirroring the inverter's real state. Seeding is one-time per site, so a later Predbat/user write (or an external app change) is not clobbered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Battery SOC/available energy was bundled with the profile/settings/schedule reads under one gate, so raising the settings interval to 30 min would have left SOC up to 30 min stale - too stale for Predbat, which replans every few minutes from the current SOC. Split battery_status onto its own 5-minute tier (ENPHASE_REFRESH_STATUS) while profile/settings/schedules stay on the 30-minute settings tier (they change rarely or only via our own writes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er logging
- New sensor.<...>_system_status published from the /today siteStatus
('normal'/'comm' etc.) with the cloud's statusSeverity and human-readable
statusDesc as attributes, so a gateway-not-reporting fault is visible
instead of the system just looking like a flat 0% battery.
- New sensor.<...>_inverter_time = the last time the battery/gateway
actually reported (max per-battery last_report from battery_status, else
the /today last_report_date). It stays current while online and freezes
when the gateway goes offline, so Predbat's inverter-time skew/liveness
detection flags a stale/offline system. Wired into automatic_config so
Predbat picks it up.
- _log_api_call no longer dumps the tens-of-KB HTML login/marketing page
returned by the primary BatteryConfig variant before the cookie fallback;
it logs a short '(HTML page, N chars ...)' marker instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eze switch
Predbat cannot control a bespoke freeze switch, so the manual freeze_enable
switch/toggle is removed (local model field, publish, event handler, cloud
seed and HA read-back).
Freeze export is now derived automatically from the export/discharge target
SOC in apply_battery_schedule:
- target < 99 -> real forced export to that floor (DTG schedule)
- target == 99 -> freeze export: restrict battery discharge (RBD) over the
export window, DTG left disabled
- target == 100 -> disabled (same as no export): neither DTG nor RBD
Freeze charge is unchanged and handled by Predbat's existing mechanism
(raise the reserve to the current SOC and disable the charge window).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…branches Predbat cannot plan properly without export control, so automatic_config now fails a site that does not support the discharge-to-grid (DTG) schedule family, alongside the existing charge-from-grid (CFG) requirement. Since a configured inverter is therefore guaranteed to support DTG, the per-call dtg_supported() branches are removed: automatic_config always sets the discharge args, publish_schedule_settings_ha always publishes the export window controls, and apply_battery_schedule always writes the DTG schedule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Enphase cloud does not report a grid export power limit, and hardcoding export_limit=99999 in automatic_config overrode the user's apps.yaml export_limit. automatic_config now leaves export_limit unset, so a user with a known grid export cap can set it in apps.yaml (Predbat defaults to unlimited when neither sets it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add get_site_settings (GET /service/batteryConfig/api/v1/siteSettings/<site>) storing the capability flags (hasEncharge, hasAcb, showChargeFromGrid, isEnsemble, ...); fetched on the settings tier. - Fix the write-XSRF risk the endpoint survey surfaced: request_raw now folds the BatteryConfig 'x-csrf-token' response header into the cookie dict, and request_json absorbs cookies on every response, so the X-XSRF-Token used for writes stays fresh (session-cookie rotation is handled too). apply_battery_ schedule GETs siteSettings first as the web app's XSRF bootstrap before writing. - Refactor the reserve write into a reusable set_reserve(site_id, reserve). - Standalone harness: --write-reserve N writes the reserve to N, reads it back, then always restores the original value (a minimal, safe real-write test that leaves the customer system unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regression from the XSRF change: request_json absorbed the full cookie jar on every response, so when the primary BatteryConfig variant returned an HTML login wall, its anonymous session cookies overwrote our authenticated session - breaking the cookie-variant fallback and the site-family calls (401 'You need to sign in first'). Now request_json never merges cookies wholesale; it captures only a fresh XSRF token, and only from a genuine 200 success (never a login-wall/error response). Session-cookie rotation stays confined to login(). Adds a regression test that a login-wall response leaves cookie_header untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reserve/schedule write PUT returned 403 Forbidden: BatteryConfig writes use a double-submit CSRF check, requiring the XSRF token both as the X-XSRF-Token header AND as the XSRF-TOKEN cookie in the Cookie header, sourced from a fresh siteSettings GET. The earlier session-corruption fix had stopped absorbing cookies entirely, so the XSRF cookie never reached our Cookie header. Now request_json absorbs cookies on a genuine success only (never on a login-wall/error response, which was the original corruption source), so the fresh XSRF token lands in both self.cookie_header and self.xsrf_token. _absorb_cookies matches the token cookie by name pattern (XSRF-TOKEN / BP-XSRF-Token, any case). set_reserve now GETs siteSettings immediately before the PUT to bootstrap a fresh token (apply_battery_schedule already did). request_raw still folds the x-csrf-token response header into cookies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the pending-write / post-write-confirm-reread machinery with optimistic caching, per the simpler model: on a successful write, update our cached cloud copy to the written value and move on; the periodic settings re-read reconciles it later if the write did not actually land. - set_reserve caches self.profile[...]['reserve'] on success. - _write_schedule optimistically caches the written schedule on success. An update (PUT, we already hold the id) just updates the cache; a create (POST) re-reads schedules once to learn the cloud-assigned scheduleId (write responses don't return it), so later edits update in place instead of creating duplicates. - apply_battery_schedule no longer does the post-write get_schedules/get_profile confirm re-read. Removed pending_writes, _pending_active, clear_expired_pending_writes and ENPHASE_PENDING_TIMEOUT_MINUTES. This prevents write churn (a second apply with the same desired state is a cache hit and issues no write) without waiting on slow/again-pending cloud activation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an Enphase Enlighten cloud integration for Predbat, modelled on the existing Fox cloud component (
fox.py). It lets an Enphase IQ Battery (Encharge) system be monitored and controlled by Predbat asinverter_type: EnphaseCloud, with no local hardware access.Like the reference HA integration (barneyonline/ha-enphase-energy), it uses the unofficial Enlighten web API — the official developer API (api.enphaseenergy.com v4) is read-only and metered, so it cannot drive battery control.
What it does
pv/load/import/export/charge/dischargekWh (intraday); live consumption power; and held/estimatedpv_power/grid_power/battery_power.components.pyregistration,enphase_*apps.yaml schema,INVERTER_DEF["EnphaseCloud"],automatic_config(), and atemplates/enphase_cloud.yamlexample.Configuration
Testing
apps/predbat/tests/test_enphase_api.py— 29 unit tests covering auth/guard rails, request retries + 401 re-login + header-variant fallback, all cloud reads/parsers, sensors + derived power (incl. a multi-cycle non-inflation regression test), control entities + async events, the schedule write path (create/update/no-op/pending dedup), andautomatic_config/INVERTER_DEF.interrogate100% docstrings, pre-commit clean.This uses the unofficial Enlighten API and has only been unit-tested. Before relying on it, please verify against a live Enphase account: login succeeds, sensors match the Enlighten app, a manual charge window produces a CFG schedule visible in the Enlighten battery UI within ~5 min, and watch for HTTP 401/406/429 over 24h (the BatteryConfig header variant may need adjusting per region/firmware).
Docs
Added sections to
docs/components.md,docs/inverter-setup.md, anddocs/apps-yaml.md.🤖 Generated with Claude Code