Skip to content

Add Enphase Enlighten cloud integration#4235

Open
springfall2008 wants to merge 33 commits into
mainfrom
feat/enphase
Open

Add Enphase Enlighten cloud integration#4235
springfall2008 wants to merge 33 commits into
mainfrom
feat/enphase

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

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 as inverter_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

  • Auth: username/password login → session cookie + e-auth/manager JWTs → site discovery. No OAuth (the API has none). Lockout guard rails: 30s login reuse, 5-min cooldown, 24h suspend after 3 consecutive rejections; MFA/blocked accounts are detected and reported (MFA is not supported — must be disabled on the account).
  • Monitoring sensors: SOC %, available energy, capacity, max rate, reserve, profile, status; cumulative pv/load/import/export/charge/discharge kWh (intraday); live consumption power; and held/estimated pv_power/grid_power/battery_power.
  • Battery control by writing Enphase schedules: charge window → CFG (charge-from-grid) with target SOC; export window → DTG (discharge-to-grid, only where the site/region supports it); freeze → RBD (restrict discharge); reserve → battery profile. Cloud writes settle asynchronously (minutes), with pending-write tracking to avoid duplicate PUTs.
  • Predbat wiring: components.py registration, enphase_* apps.yaml schema, INVERTER_DEF["EnphaseCloud"], automatic_config(), and a templates/enphase_cloud.yaml example.

Configuration

enphase_username: !secret enphase_username
enphase_password: !secret enphase_password
enphase_automatic: True
# enphase_site_id: "1234567"          # optional, defaults to first site
# enphase_automatic_ignore_pv: False

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), and automatic_config/INVERTER_DEF.
  • Full suite green, interrogate 100% docstrings, pre-commit clean.

⚠️ Not yet validated against a real account

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, and docs/apps-yaml.md.

🤖 Generated with Claude Code

springfall2008 and others added 14 commits July 11, 2026 17:02
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>
Copilot AI review requested due to automatic review settings July 11, 2026 18:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 EnphaseAPI component 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.py runner.
  • 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.

Comment thread apps/predbat/enphase.py Outdated
Comment thread apps/predbat/enphase.py Outdated
springfall2008 and others added 13 commits July 11, 2026 20:15
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>
springfall2008 and others added 6 commits July 12, 2026 13:31
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>
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