This document describes how the integration is structured, how data flows, and where to extend it.
custom_components/enphase_battery/
├── __init__.py # setup/unload/migrate entry; runtime_data wiring
├── coordinator.py # DataUpdateCoordinator: orchestrates local/cloud/hybrid fetch
├── energy.py # EnergyTracker: daily counters, 24h consumption, backup time
├── const.py # constants + DeviceInfo helpers (no HA logic)
├── api/
│ ├── __init__.py # re-exports the public client surface
│ ├── cloud_client.py # EnphaseBatteryAPI — Enlighten cloud REST client
│ ├── local_client.py # EnphaseEnvoyLocalAPI — local Envoy/IQ Gateway client
│ ├── models.py # BatteryData / BatteryDevice TypedDicts
│ └── exceptions.py # typed API exceptions (auth vs connection)
├── config_flow.py # ConfigFlow + OptionsFlow (user/local/cloud/reauth/reconfigure/zeroconf)
├── diagnostics.py # async_get_config_entry_diagnostics (redacted)
├── system_health.py # cloud reachability for the System Health panel
├── sensor.py / binary_sensor.py / switch.py / select.py / number.py
├── strings.json + translations/{en,es,fr}.json
├── manifest.json + icons.json
config entry ──> async_setup_entry (__init__.py)
│ creates EnphaseBatteryDataUpdateCoordinator
│ await coordinator.async_config_entry_first_refresh()
▼
coordinator._async_setup() # once: authenticate + load EnergyTracker
│ (EnvoyAuthError/EnphaseBatteryAuthError -> ConfigEntryAuthFailed)
│ (connection error -> UpdateFailed -> ConfigEntryNotReady)
▼
coordinator._async_update_data() # every poll
│ local_client.get_battery_data() -> BatteryData (local / hybrid)
│ cloud_client.get_battery_data() -> BatteryData (cloud)
│ hybrid: merge cached cloud control states into BatteryData
│ energy.update(data) # derived energy values
▼
coordinator.data : BatteryData
│
▼
entities (CoordinatorEntity) read coordinator.data
Connection modes (from connection_mode in the entry data):
- local — poll the Envoy directly every 10s.
- cloud — poll Enlighten every 60s.
- hybrid (local +
enable_cloud_control) — local data, cloud control: the switches/select/number write through the cloud client; the coordinator merges the cloud control states (cached ~1 min) into the local data.
- All entities are
CoordinatorEntitysubclasses; they only readcoordinator.dataand never call the API directly (control entities call the cloud client through the coordinator'sapi). _attr_has_entity_name = True; names/labels come fromtranslations/.- unique_id =
f"{coordinator.unique_id_prefix}_{key}"whereunique_id_prefixis the config entry id. Individual batteries usef"{prefix}_battery_{serial}_{key}".
Add a system sensor — add a BatterySensorBase subclass in sensor.py and
instantiate it in async_setup_entry; add its translation_key to the four
translation files.
Add a switch — append an EnphaseBatterySwitchEntityDescription to the
SWITCHES tuple in switch.py (give it a key, icon, and a set_fn calling
the cloud client) and add the key under entity.switch in the translations.
Add an API call — add the method to api/cloud_client.py or
api/local_client.py; return a BatteryData-shaped dict (extend
api/models.py if you add new keys).
Add a connection mode / data field — extend BatteryData in
api/models.py, populate it in the relevant client, and consume it in the
coordinator or an entity.
Legacy enphase_battery_<key> unique IDs are rewritten to
<entry_id>_<key> by _async_migrate_unique_ids in __init__.py during
async_migrate_entry. The migration is idempotent and covered by tests.
- Zeroconf: the Envoy advertises
_enphase-envoy._tcp.local..async_step_zeroconfreads its serial (unique_id) and host, aborts/updates if already configured, thenasync_step_zeroconf_confirmcollects the Enlighten credentials needed for a local setup. - DHCP: matched by hostname/OUI in the manifest;
async_step_dhcpresolves the serial from the unauthenticated/infoendpoint, dedupes against existing entries, and reuses the same confirm step. - Repair issue
control_disabledis created/cleared by_async_manage_control_issuein__init__.pydepending on whether battery control is available (cloud or hybrid) or not (local-only). - Stale devices:
async_remove_config_entry_devicelets the user delete an individual battery that is no longer reported; the hub and active batteries cannot be removed.
ruff check/ruff format --checkmypy --strict(clean)pytest --cov-fail-under=98(currently ~99%)hassfest+ HACS validation (CI:.github/workflows/)