diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index a90dbf437..0e644764d 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -3,6 +3,7 @@ activepower adata adbase afci +agentic AIO AIO's aiofiles @@ -63,6 +64,7 @@ compareform configform connack Consolas +cooldown coro cprofile creds @@ -102,21 +104,32 @@ docstrings dstamp dstart dwindow +eauth Eddi elif emqx emszzzz +Encharge enctype endfor endhour endminute +endswith endt Energi Energidata Energidataservice energydataservice energythroughput +enho +Enlighten +enlm +enphase +enphaseenergy +Enpower +entrez epod +etok euids EVDC evergen @@ -166,6 +179,7 @@ hainterface halfhourly hanres HAOS +hasattr hass hassapi hassio @@ -181,6 +195,7 @@ husforbrukning hypervolt iboost idag +idef idetails idxs iflux @@ -189,8 +204,10 @@ inday INTELLI intelligentdevice interp +intraday invbatpower invname +isinstance isoformat isort itemtype @@ -199,6 +216,7 @@ jedlix jsyaml kaiming keepalive +khtml killall kopt Kostal @@ -224,6 +242,7 @@ lockstep logdata loglines lookback +lstrip luxpower markdownlint matplotlib @@ -257,12 +276,14 @@ mppt mprn mpxn mqtt +mtok mult myenergi mypy nattribute ncalls nearr +newtok njpwerner nobat nocharge @@ -293,6 +314,7 @@ pbat pbgw pdata pdetails +pendings perc percnt peterhaban @@ -322,6 +344,7 @@ pvwatts pwindow pyjwt pylint +pypi pyproject pytest pytz @@ -330,9 +353,11 @@ rarr reactivepower recp redownload +Referer refetches regionid regname +relogin remainings remotecontrol resetmidnight @@ -342,6 +367,7 @@ rname Roboto rowspan rstart +rstrip rtype ruamel Rvrt @@ -357,6 +383,9 @@ Selfrun Selfuse semodbus Sergoe +sess +setattr +setdefault SFMB sigcloud sigen @@ -391,6 +420,7 @@ stepline Stromligning strptime Strømligning +subclassing submessage substep sunspec @@ -424,12 +454,15 @@ tottime trapz Trefor treforsiphone +treforsouthwell +tunables twinx tzfile tzpath unconfigured unsmoothed unstaged +urlsafe useid userinterface varh diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 4951f79c5..dcbf6171c 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -33,6 +33,7 @@ from ha import HAInterface, HAHistory from db_manager import DatabaseManager from fox import FoxAPI +from enphase import EnphaseAPI from kraken import KrakenAPI from web_mcp import PredbatMCPServer @@ -240,6 +241,36 @@ }, "phase": 1, }, + "enphase": { + "class": EnphaseAPI, + "name": "Enphase API", + "event_filter": "predbat_enphase_", + "args": { + "username": { + "required": True, + "config": "enphase_username", + }, + "password": { + "required": True, + "config": "enphase_password", + }, + "site_id": { + "required": False, + "config": "enphase_site_id", + }, + "automatic": { + "required": False, + "default": False, + "config": "enphase_automatic", + }, + "automatic_ignore_pv": { + "required": False, + "default": False, + "config": "enphase_automatic_ignore_pv", + }, + }, + "phase": 1, + }, "kraken": { "class": KrakenAPI, "name": "Kraken Energy (EDF/E.ON)", diff --git a/apps/predbat/config.py b/apps/predbat/config.py index ec826f9c5..b2e4adbd1 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -1917,6 +1917,35 @@ "charge_discharge_with_rate": False, "target_soc_used_for_discharge": True, }, + "EnphaseCloud": { + "name": "EnphaseCloud", + "has_rest_api": False, + "has_mqtt_api": False, + "output_charge_control": "none", + "charge_control_immediate": False, + "has_charge_enable_time": True, + "has_discharge_enable_time": True, + "has_target_soc": True, + "has_reserve_soc": True, + "has_timed_pause": False, + "charge_time_format": "HH:MM:SS", + "charge_time_entity_is_option": True, + "soc_units": "%", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "has_ge_eco_toggle": False, + "has_fox_inverter_mode": False, + "time_button_press": True, + "clock_time_format": "%Y-%m-%d %H:%M:%S", + "write_and_poll_sleep": 2, + "has_time_window": False, + "support_charge_freeze": True, + "support_discharge_freeze": True, + "has_idle_time": False, + "can_span_midnight": False, + "charge_discharge_with_rate": False, + "target_soc_used_for_discharge": True, + }, "SolaxCloud": { "name": "SolaxCloud", "has_rest_api": False, @@ -2200,6 +2229,11 @@ "fox_auth_method": {"type": "string", "empty": False}, "fox_token_expires_at": {"type": "string", "empty": False}, "fox_token_hash": {"type": "string", "empty": False}, + "enphase_username": {"type": "string", "empty": False}, + "enphase_password": {"type": "string", "empty": False}, + "enphase_site_id": {"type": "string", "empty": False}, + "enphase_automatic": {"type": "boolean"}, + "enphase_automatic_ignore_pv": {"type": "boolean"}, "octopus_intelligent_slot": {"type": "sensor|sensor_list", "sensor_type": "boolean|action", "entries": "num_cars", "optional_entries": True}, "octopus_ready_time": {"type": "sensor|sensor_list", "sensor_type": "string", "entries": "num_cars", "optional_entries": True}, "octopus_charge_limit": {"type": "sensor|sensor_list", "sensor_type": "float", "entries": "num_cars", "optional_entries": True}, diff --git a/apps/predbat/enphase.py b/apps/predbat/enphase.py new file mode 100644 index 000000000..065182fbb --- /dev/null +++ b/apps/predbat/enphase.py @@ -0,0 +1,1564 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Enphase Enlighten cloud component +# +# Talks to the unofficial Enphase Enlighten web-app API (the same endpoints the +# Enlighten web/mobile apps use). There is no official API with battery control. +# Reference behaviour derived from https://github.com/barneyonline/ha-enphase-energy +# ----------------------------------------------------------------------------- + +"""Enphase Enlighten cloud API client component. + +Talks to the unofficial Enphase Enlighten web-app API used by the Enlighten +web/mobile apps, since there is no official API offering battery control. +""" + +from datetime import datetime, timedelta, timezone +import asyncio +import base64 +import json +import random +import uuid + +import aiohttp + +from component_base import ComponentBase +from predbat_metrics import record_api_call + +# Defined locally (not imported from utils) - every cloud component defines its own +# copy of this table rather than sharing one, matching the pattern used by fox.py. +BASE_TIME = datetime.strptime("00:00", "%H:%M") +OPTIONS_TIME_FULL = [((BASE_TIME + timedelta(seconds=minute * 60)).strftime("%H:%M") + ":00") for minute in range(0, 24 * 60, 1)] + +BASE_URL = "https://enlighten.enphaseenergy.com" +LOGIN_PATH = "/login/login.json" +SELF_TOKEN_PATH = "/users/self/token" +SITE_SEARCH_PATH = "/app-api/search_sites.json" +BATTERY_CONFIG_BASE = "/service/batteryConfig/api/v1" + +# Refresh ages in minutes for each data category +ENPHASE_REFRESH_STATIC = 24 * 60 # sites list - rarely changes +ENPHASE_REFRESH_SETTINGS = 30 # profile, battery settings, schedule config - change rarely / only via our own writes +ENPHASE_REFRESH_STATUS = 5 # battery SOC/available energy - needs to stay fresh for planning +ENPHASE_REFRESH_ENERGY = 5 # today energy totals +ENPHASE_REFRESH_POWER = 5 # latest instantaneous power + +ENPHASE_CACHE_KEYS = ["sites", "battery_status", "battery_settings", "profile", "schedules", "site_settings", "today", "latest_power"] +ENPHASE_CACHE_VERSION = 2 + +# Battery profiles accepted by the profile endpoint +PROFILE_SELF_CONSUMPTION = "self-consumption" +PROFILE_COST_SAVINGS = "cost_savings" +PROFILE_BACKUP_ONLY = "backup_only" + +# Schedule families +SCHEDULE_CHARGE = "CFG" # charge from grid +SCHEDULE_EXPORT = "DTG" # discharge to grid +SCHEDULE_FREEZE = "RBD" # restrict battery discharge + +ENPHASE_RETRIES = 5 + +# Browser mimicry - Enlighten rejects non-browser requests with 406/login walls +ENPHASE_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15" +BATTERY_UI_ORIGIN = "https://battery-profile-ui.enphaseenergy.com" + +# Format for the published inverter_time sensor - matches INVERTER_DEF["EnphaseCloud"] clock_time_format +ENPHASE_CLOCK_FORMAT = "%Y-%m-%d %H:%M:%S" + + +def safe_float(value, default=0.0): + """Convert a value to float, returning default for None or non-numeric values. + + The Enphase cloud returns strings like "N/A" (fields it cannot report), blanks, and percentages + with a trailing "%" (e.g. current_charge is "0%"/"50%"), so a bare float() would raise; this + coerces "N/A"/blank to the default and strips a trailing "%" so a percentage parses to its number. + """ + try: + return float(value) + except (TypeError, ValueError): + pass + if isinstance(value, str): + cleaned = value.strip().rstrip("%").strip() + try: + return float(cleaned) + except ValueError: + return default + return default + + +def safe_int(value, default=0): + """Convert a value to int, returning default for None or non-numeric values (e.g. Enphase 'N/A'/'0%').""" + result = safe_float(value, None) + return default if result is None else int(result) + + +# Battery/grid flow decomposition in the /today totals. Enphase reports energy as source_dest +# pairs rather than single "charge"/"discharge"/"export" totals, so those channels are summed from +# their component flows when a direct total is not present. (source_dest = energy from source to +# dest, in Wh.) Verified key names against a battery account; the summed values themselves still +# need confirmation on a healthy (non-error) battery. +TODAY_FLOW_COMPONENTS = { + "charge": ("solar_battery", "grid_battery"), # energy into the battery + "discharge": ("battery_home", "battery_grid"), # energy out of the battery + "export": ("solar_grid", "battery_grid", "generator_grid"), # energy to the grid +} + + +def today_channel_kwh(today_data, channel): + """Return today's kWh total for a channel from a /today payload's totals (Wh), 0.0 if absent. + + The /today endpoint reports each channel's running total for the current day in Wh under + `stats[0].totals`; Predbat works in kWh so the value is divided by 1000. This is cadence- + independent (unlike indexing the daily lifetime_energy array) - the cloud provides the total + directly regardless of whether the site buckets energy per 15 minutes or per day. Battery + charge/discharge and export have no single total key, so they are summed from their component + source-to-destination flows (see TODAY_FLOW_COMPONENTS) when no direct total is present. + """ + totals = (today_data or {}).get("totals") or {} + if channel in totals: + return round(safe_float(totals.get(channel)) / 1000.0, 3) + components = TODAY_FLOW_COMPONENTS.get(channel) + if components: + total_wh = sum(safe_float(totals.get(component)) for component in components) + return round(total_wh / 1000.0, 3) + return 0.0 + + +def interval_power(values, start_time, interval_length, now_ts): + """Estimate current watts from the most recent completed intra-day energy bucket. + + `values` is the /today array of per-interval energy in Wh (each bucket covers `interval_length` + seconds starting at `start_time`, a Unix timestamp at local midnight). The bucket index for the + current time is (now - start_time) / interval_length; the last COMPLETED bucket is one before + that. Its energy divided by the bucket duration in hours gives average watts over that bucket - + a stable, correct instantaneous estimate that needs no cross-poll delta tracking. Returns 0.0 + when the data is missing/empty or the timing is unusable. + """ + if not values or not interval_length or start_time is None or now_ts is None: + return 0.0 + hours = interval_length / 3600.0 + if hours <= 0: + return 0.0 + index = int((now_ts - start_time) / interval_length) - 1 + if index < 0: + index = 0 + if index >= len(values): + index = len(values) - 1 + return round(safe_float(values[index]) / hours, 1) + + +def ha_time_to_enphase(value): + """Convert an HA 'HH:MM:SS' option time to Enphase 'HH:MM' format.""" + return str(value)[:5] + + +def enphase_time_to_ha(value): + """Convert an Enphase 'HH:MM' time to the HA 'HH:MM:SS' option format.""" + text = str(value or "00:00")[:5] + return text + ":00" + + +def schedules_equal(cloud_entry, start_hm, end_hm, limit, enabled): + """Return True when a cloud schedule entry already matches the desired window/limit/enable state.""" + if not cloud_entry or "startTime" not in cloud_entry: + # No cloud schedule: equal only when we want it disabled + return not enabled + if bool(cloud_entry.get("enabled")) != bool(enabled): + return False + if not enabled: + return True # both disabled - window/limit are irrelevant + if str(cloud_entry.get("startTime", ""))[:5] != start_hm or str(cloud_entry.get("endTime", ""))[:5] != end_hm: + return False + cloud_limit = cloud_entry.get("limit") + if limit is not None and (cloud_limit is None or int(cloud_limit) != int(limit)): + return False + return True + + +def decode_jwt_claims(token): + """Decode the payload segment of a JWT without verifying the signature.""" + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload.encode("ascii"))) + except (IndexError, ValueError): + return {} + + +def is_too_many_sessions(text): + """Return True only when a login response body reports Enlighten's 'too many active sessions' error. + + Matches the specific phrase, not a bare 'session' substring: a successful login body contains keys + like 'session_id' and Enlighten session cookies, so a loose match would falsely reject valid logins. + """ + lowered = str(text or "").lower() + if "too many active sessions" in lowered: + return True + return "active sessions" in lowered and "too many" in lowered + + +class EnphaseAPI(ComponentBase): + """Enphase Enlighten cloud API client component.""" + + # Login guard-rail tunables - protect the Enphase account from lockout + LOGIN_REUSE_SECONDS = 30 + LOGIN_COOLDOWN_SECONDS = 300 + LOGIN_SUSPEND_SECONDS = 24 * 3600 + LOGIN_MAX_REJECTS = 3 + + def initialize(self, username, password, site_id=None, automatic=False, automatic_ignore_pv=False): + """Initialise the Enphase API component state.""" + self.username = username + self.password = password + self.site_id = str(site_id) if site_id else None + self.automatic = automatic + self.automatic_ignore_pv = automatic_ignore_pv + + # Verbose API-call logging (each request + a truncated, token-redacted response). + # On for now to aid diagnosis of the unofficial API's real responses; can be disabled later. + self.debug_api = True + + # Auth state + self.cookie_header = "" # serialised cookie header for Enlighten + self.eauth_token = None # JWT from /users/self/token + self.manager_token = None # enlighten_manager_token_production cookie JWT + self.xsrf_token = None + self.user_id = None # decoded from JWT, needed by BatteryConfig + self.token_expires_at = None + + # Login guard rails (avoid Enphase account lockout) + self.login_last_success = None # datetime of last successful login + self.login_cooldown_until = None # datetime before which logins are banned + self.login_reject_count = 0 # consecutive rejected logins + + # Cloud data + self.sites = [] + self.battery_status = {} + self.battery_settings = {} + self.profile = {} + self.schedules = {} + self.site_settings = {} + self.today = {} # per-site today totals (Wh) + intra-day 15-minute buckets, from /today + self.latest_power = {} + + # Local (HA-side) schedule model, written by events, applied on write switch + self.local_schedule = {} + # Sites whose local schedule/control model has been seeded from the cloud state (once), + # so the control entities start out mirroring the inverter's real schedule/reserve + self._schedule_seeded = set() + + # BatteryConfig header variant: "primary" (e-auth-token + requestid) or + # "cookie_eauth" fallback (cookie + XHR header) needed on some regions/firmware + self.battery_config_variant = "primary" + + # Age (datetime of last update) per cached data category + self.data_age = {} + self.failures_total = 0 + self.requests_today = 0 + self.last_midnight_utc = None + self.last_error_status = None # HTTP status (or None) of the most recent request_json() failure + + def is_alive(self): + """Return True when the component has started and discovered a site.""" + return self.api_started and bool(self.sites) + + def _data_age_minutes(self, key): + """Return the age in minutes of the in-memory data for a cache key, or None if unknown.""" + timestamp = self.data_age.get(key, None) + if timestamp is None: + return None + return (datetime.now(timezone.utc) - timestamp).total_seconds() / 60.0 + + def _needs_refresh(self, key, max_age_minutes): + """Return True if the data for a cache key is missing or older than max_age_minutes.""" + age = self._data_age_minutes(key) + return age is None or age >= max_age_minutes + + async def _save_cache(self, key, data): + """Save data to storage under the enphase module and record its update time.""" + now = datetime.now(timezone.utc) + self.data_age[key] = now + if self.storage: + await self.storage.save("enphase", key, data, format="json", expiry=now + timedelta(days=1)) + + async def _load_cache(self, key): + """Load cached data for a key from storage, recording its age. Returns None if absent.""" + if not self.storage: + return None + data = await self.storage.load("enphase", key) + if data is None: + return None + age = await self.storage.age("enphase", key) + if age is None: + return None + self.data_age[key] = datetime.now(timezone.utc) - timedelta(minutes=age) + return data + + async def load_cached_data(self): + """Restore cached cloud data from storage on startup to avoid re-polling after a reboot.""" + if not self.storage: + return + version = await self.storage.load("enphase", "cache_version") + if version != ENPHASE_CACHE_VERSION: + self.log("Enphase: Cache version changed, forcing full refresh") + await self.storage.save("enphase", "cache_version", ENPHASE_CACHE_VERSION, format="json") + return + for key in ENPHASE_CACHE_KEYS: + data = await self._load_cache(key) + if data is not None: + setattr(self, key, data) + if self.sites: + self.update_success_timestamp() + + async def run(self, seconds, first): + """Main polling body, invoked every 60 seconds by ComponentBase.""" + if first: + await self.load_cached_data() + + # Midnight counter reset + current_midnight = self.midnight_utc + if self.last_midnight_utc is not None and self.last_midnight_utc != current_midnight: + self.log(f"Enphase: Midnight reset - requests_today: {self.requests_today}") + self.requests_today = 0 + self.last_midnight_utc = current_midnight + + # Ensure we are logged in (guard rails inside login()) + if not self.eauth_token: + if not await self.login(): + return bool(self.sites) # stay alive on cached data if we have it + + if first or self._needs_refresh("sites", ENPHASE_REFRESH_STATIC): + if not await self.login(): + return bool(self.sites) + + # Predbat controls a single battery system: operate on one active site (the configured + # enphase_site_id, else the first discovered). The per-category refresh gates below are + # keyed globally, so processing one site per cycle also keeps them correct. + site_id = self.sites[0]["site_id"] if self.sites else None + if site_id: + # SOC/available energy must stay fresh for planning, so it is on the fast tier; + # the profile/settings/schedule config changes rarely (or only via our own writes), + # so it polls on the slower settings tier. (gated on "profile", which get_profile stamps) + if self._needs_refresh("battery_status", ENPHASE_REFRESH_STATUS): + await self.get_battery_status(site_id) + if self._needs_refresh("profile", ENPHASE_REFRESH_SETTINGS): + await self.get_profile(site_id) + await self.get_battery_settings(site_id) + await self.get_site_settings(site_id) + await self.get_schedules(site_id) + if self._needs_refresh("today", ENPHASE_REFRESH_ENERGY): + await self.get_today(site_id) + if self._needs_refresh("latest_power", ENPHASE_REFRESH_POWER): + await self.get_latest_power(site_id) + self.sync_local_schedule_from_cloud(site_id) + await self.publish_data(site_id) + await self.publish_schedule_settings_ha(site_id) + + # Automatic configuration on first successful data load. A site with no controllable + # battery (e.g. PV-only) cannot be configured as a Predbat inverter - log and report + # not-ready rather than letting the ValueError abort the whole poll. + if first and self.automatic: + try: + await self.automatic_config() + except ValueError as error: + self.log(f"Warn: Enphase: Automatic configuration skipped - {error}") + return False + + return True + + async def publish_data(self, site_id): + """Publish battery, energy-today and derived instantaneous power sensors for a site. + + Reads from the normalised per-site data stores populated by the various `get_*()` + methods, guarding every lookup with `.get()` defaults so a site missing one data + category (e.g. no today data fetched yet) cannot crash the whole publish. The + battery profile name is read from `self.profile`, not `self.battery_status` (the latter + has no profile field - see the note in `get_battery_status`). + """ + entity_base = f"sensor.{self.prefix}_enphase_{site_id}" + now_utc = datetime.now(timezone.utc) + + status = self.battery_status.get(site_id, {}) + profile = self.profile.get(site_id, {}) + settings = self.battery_settings.get(site_id, {}) + today = self.today.get(site_id, {}) + power = self.latest_power.get(site_id, {}) + + self.dashboard_item( + f"{entity_base}_soc_percent", + state=status.get("soc_percent"), + attributes={"unit_of_measurement": "%", "friendly_name": "Enphase Battery SOC", "icon": "mdi:battery-50"}, + app="enphase", + ) + self.dashboard_item( + f"{entity_base}_soc_kw", + state=status.get("available_energy"), + attributes={"unit_of_measurement": "kWh", "device_class": "energy", "state_class": "measurement", "friendly_name": "Enphase Battery Available Energy", "icon": "mdi:battery-charging-50"}, + app="enphase", + ) + self.dashboard_item( + f"{entity_base}_battery_capacity", + state=status.get("max_capacity"), + attributes={"unit_of_measurement": "kWh", "device_class": "energy", "state_class": "measurement", "friendly_name": "Enphase Battery Capacity", "icon": "mdi:battery-high"}, + app="enphase", + ) + max_power_kw = status.get("max_power_kw") + battery_rate_max = max_power_kw * 1000.0 if max_power_kw is not None else None + self.dashboard_item( + f"{entity_base}_battery_rate_max", + state=battery_rate_max, + attributes={"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", "friendly_name": "Enphase Battery Max Rate", "icon": "mdi:battery-charging-high"}, + app="enphase", + ) + self.dashboard_item( + f"{entity_base}_battery_status", + state=status.get("status"), + attributes={"friendly_name": "Enphase Battery Status", "icon": "mdi:information-outline"}, + app="enphase", + ) + self.dashboard_item( + f"{entity_base}_battery_profile", + state=profile.get("profile"), + attributes={"friendly_name": "Enphase Battery Profile", "icon": "mdi:cog-outline"}, + app="enphase", + ) + self.dashboard_item( + f"{entity_base}_battery_reserve", + state=profile.get("reserve"), + attributes={"unit_of_measurement": "%", "friendly_name": "Enphase Battery Reserve", "icon": "mdi:battery-lock"}, + app="enphase", + ) + reserve_min = settings.get("veryLowSocMin") + if reserve_min is None: + reserve_min = 5 + self.dashboard_item( + f"{entity_base}_battery_reserve_min", + state=reserve_min, + attributes={"unit_of_measurement": "%", "friendly_name": "Enphase Battery Reserve Minimum", "icon": "mdi:battery-alert"}, + app="enphase", + ) + + # Site communication/health status (siteStatus "normal"/"comm" etc.), with the cloud's + # human-readable description as an attribute so a gateway-not-reporting fault is visible. + self.dashboard_item( + f"{entity_base}_system_status", + state=today.get("site_status"), + attributes={"friendly_name": "Enphase System Status", "icon": "mdi:cloud-check-outline", "severity": today.get("status_severity"), "description": today.get("status_desc")}, + app="enphase", + ) + + # Inverter time: the last time the battery/gateway actually reported to the Enphase cloud. + # Predbat uses this for liveness - it stays current while the system is online and freezes + # when the gateway goes offline, so its growing skew tells Predbat the inverter is stale. + last_report_ts = status.get("last_report") or today.get("last_report_date") + if last_report_ts: + inverter_time = datetime.fromtimestamp(float(last_report_ts), self.local_tz) + else: + inverter_time = datetime.now(self.local_tz) + self.dashboard_item( + f"{entity_base}_inverter_time", + state=inverter_time.strftime(ENPHASE_CLOCK_FORMAT), + attributes={"friendly_name": "Enphase Inverter Time", "icon": "mdi:clock-outline"}, + app="enphase", + ) + + # Today's energy totals (kWh), one dashboard sensor per channel, sourced from the /today + # totals (which are cadence-independent - the cloud reports the running daily total directly). + energy_channels = { + "production": ("pv_today", "Enphase PV Today", "mdi:solar-power"), + "consumption": ("load_today", "Enphase Load Today", "mdi:home-lightning-bolt"), + "import": ("import_today", "Enphase Import Today", "mdi:transmission-tower-import"), + "export": ("export_today", "Enphase Export Today", "mdi:transmission-tower-export"), + "charge": ("battery_charge_today", "Enphase Battery Charge Today", "mdi:battery-plus"), + "discharge": ("battery_discharge_today", "Enphase Battery Discharge Today", "mdi:battery-minus"), + } + for channel, (name, friendly, icon) in energy_channels.items(): + self.dashboard_item( + f"{entity_base}_{name}", + state=today_channel_kwh(today, channel), + attributes={"unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total_increasing", "friendly_name": friendly, "icon": icon}, + app="enphase", + ) + + self.dashboard_item( + f"{entity_base}_load_power", + state=power.get("watts"), + attributes={"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", "friendly_name": "Enphase Load Power", "icon": "mdi:home-lightning-bolt"}, + app="enphase", + ) + + # Instantaneous power from the most recent completed intra-day 15-minute energy bucket of + # the /today arrays (Wh per interval -> average watts over that interval). This reads a + # single bucket value per poll, so it is inherently stable within an interval and needs no + # cross-poll delta tracking. + arrays = today.get("arrays", {}) + start_time = today.get("start_time") + interval_length = today.get("interval_length") + now_ts = now_utc.timestamp() + channel_watts = {channel: interval_power(arrays.get(channel, []), start_time, interval_length, now_ts) for channel in ("production", "import", "export", "charge", "discharge")} + + pv_power = channel_watts.get("production", 0.0) + grid_power = channel_watts.get("import", 0.0) - channel_watts.get("export", 0.0) + battery_power = channel_watts.get("discharge", 0.0) - channel_watts.get("charge", 0.0) + + self.dashboard_item( + f"{entity_base}_pv_power", + state=pv_power, + attributes={"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", "friendly_name": "Enphase PV Power", "icon": "mdi:solar-power"}, + app="enphase", + ) + self.dashboard_item( + f"{entity_base}_grid_power", + state=grid_power, + attributes={"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", "friendly_name": "Enphase Grid Power", "icon": "mdi:transmission-tower"}, + app="enphase", + ) + self.dashboard_item( + f"{entity_base}_battery_power", + state=battery_power, + attributes={"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", "friendly_name": "Enphase Battery Power", "icon": "mdi:battery-charging"}, + app="enphase", + ) + + def _default_local_schedule(self): + """Return an empty local schedule model.""" + return { + "reserve": 0, + "charge": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 100, "enable": False}, + "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, + } + + def sync_local_schedule_from_cloud(self, site_id): + """One-time seed of the local schedule/control model from the current cloud state. + + So the published control entities (reserve, charge/export window times, target SOC and + enable) start out reflecting the inverter's real schedule and reserve rather than the + defaults. Runs only once per site (the first time cloud data is available); after that the + control entities are driven by Predbat/user writes, so a later external change in the app + is shown by the monitoring sensors but does not clobber Predbat's desired control values. + """ + if site_id in self._schedule_seeded: + return + profile = self.profile.get(site_id) + schedules = self.schedules.get(site_id) + if not profile and not schedules: + return # no cloud data yet - nothing to seed from + local = self.local_schedule.setdefault(site_id, self._default_local_schedule()) + reserve = (profile or {}).get("reserve") + if reserve: + local["reserve"] = reserve + for direction, family_key in (("charge", "cfg"), ("export", "dtg")): + entry = (schedules or {}).get(family_key, {}) + if entry.get("startTime"): + local[direction]["start_time"] = enphase_time_to_ha(entry.get("startTime")) + local[direction]["end_time"] = enphase_time_to_ha(entry.get("endTime")) + if entry.get("limit") is not None: + local[direction]["soc"] = entry.get("limit") + local[direction]["enable"] = bool(entry.get("enabled")) + self._schedule_seeded.add(site_id) + + async def publish_schedule_settings_ha(self, site_id): + """Publish the schedule control entities for a site. + + Publishes the reserve control plus both the charge-from-grid and export (discharge-to-grid) + window controls (a configured inverter always supports both - automatic_config requires + DTG). There is no separate freeze control: Predbat freezes charge via the reserve, and + freeze-export is derived automatically from an export target SOC of 99%. + """ + local = self.local_schedule.setdefault(site_id, self._default_local_schedule()) + reserve_min = int(self.battery_settings.get(site_id, {}).get("veryLowSocMin", 5) or 5) + base_name = f"{self.prefix}_enphase_{site_id}_battery_schedule" + + self.dashboard_item( + f"number.{base_name}_reserve", + state=local.get("reserve", 0), + attributes={"min": reserve_min, "max": 100, "step": 1, "unit_of_measurement": "%", "friendly_name": f"Enphase {site_id} Battery Schedule Reserve", "icon": "mdi:gauge"}, + app="enphase", + ) + + # A configured Enphase inverter always supports both charge and export (automatic_config + # requires DTG), so both window controls are always published. + for direction in ["charge", "export"]: + window = local.get(direction, {}) + for attribute in ["start_time", "end_time"]: + value = window.get(attribute, "00:00:00") + if value not in OPTIONS_TIME_FULL: + value = "00:00:00" + self.dashboard_item( + f"select.{base_name}_{direction}_{attribute}", + state=value, + attributes={ + "options": OPTIONS_TIME_FULL, + "friendly_name": f"Enphase {site_id} Battery Schedule {direction.capitalize()} {attribute.replace('_', ' ').capitalize()}", + "icon": "mdi:clock-outline", + }, + app="enphase", + ) + self.dashboard_item( + f"number.{base_name}_{direction}_soc", + state=int(window.get("soc", 100 if direction == "charge" else reserve_min)), + attributes={"min": 5, "max": 100, "step": 1, "unit_of_measurement": "%", "friendly_name": f"Enphase {site_id} Battery Schedule {direction.capitalize()} Soc", "icon": "mdi:gauge"}, + app="enphase", + ) + self.dashboard_item( + f"switch.{base_name}_{direction}_enable", + state="on" if window.get("enable") else "off", + attributes={"friendly_name": f"Enphase {site_id} Battery Schedule {direction.capitalize()} Enable", "icon": "mdi:check-circle-outline"}, + app="enphase", + ) + self.dashboard_item( + f"switch.{base_name}_{direction}_write", + state="off", + attributes={"friendly_name": f"Enphase {site_id} Battery Schedule {direction.capitalize()} Write", "icon": "mdi:upload"}, + app="enphase", + ) + + async def get_schedule_settings_ha(self, site_id): + """Read the current schedule control entity states from HA into the local schedule model.""" + local = self.local_schedule.setdefault(site_id, self._default_local_schedule()) + base_name = f"{self.prefix}_enphase_{site_id}_battery_schedule" + local["reserve"] = int(float(self.get_state_wrapper(f"number.{base_name}_reserve", local.get("reserve", 0)) or 0)) + for direction in ["charge", "export"]: + window = local.setdefault(direction, {}) + for attribute in ["start_time", "end_time"]: + value = self.get_state_wrapper(f"select.{base_name}_{direction}_{attribute}", window.get(attribute, "00:00:00")) + if value in OPTIONS_TIME_FULL: + window[attribute] = value + window["soc"] = int(float(self.get_state_wrapper(f"number.{base_name}_{direction}_soc", window.get("soc", 100)) or 0)) + window["enable"] = str(self.get_state_wrapper(f"switch.{base_name}_{direction}_enable", "on" if window.get("enable") else "off")).lower() == "on" + + def _parse_entity(self, entity_id): + """Split a published entity id into (site_id, attribute_name), or (None, None) if not ours.""" + try: + name = entity_id.split(".", 1)[1] + except IndexError: + return None, None + marker = f"{self.prefix}_enphase_" + if not name.startswith(marker): + return None, None + remainder = name[len(marker) :] + for site in self.sites: + site_id = site["site_id"] + if remainder.startswith(site_id + "_"): + return site_id, remainder[len(site_id) + 1 :] + return None, None + + def _toggle_to_bool(self, service, current): + """Convert an HA switch service call into the resulting boolean state.""" + if service == "turn_on": + return True + if service == "turn_off": + return False + return not current + + async def select_event(self, entity_id, value): + """Handle a select entity change routed from HA, updating the local schedule model.""" + site_id, attribute = self._parse_entity(entity_id) + if not site_id or not attribute.startswith("battery_schedule_"): + return + field = attribute[len("battery_schedule_") :] + local = self.local_schedule.setdefault(site_id, self._default_local_schedule()) + for direction in ["charge", "export"]: + for time_key in ["start_time", "end_time"]: + if field == f"{direction}_{time_key}" and value in OPTIONS_TIME_FULL: + local[direction][time_key] = value + await self.publish_schedule_settings_ha(site_id) + + async def number_event(self, entity_id, value): + """Handle a number entity change routed from HA, updating the local schedule model. + + The reserve is a live setting (Predbat's freeze-charge relies on it taking effect at once), + so a reserve change is written to Enphase immediately here - like Fox - rather than waiting + for the write button. The per-window target-SOC numbers are staged and applied on the button. + """ + site_id, attribute = self._parse_entity(entity_id) + if not site_id or not attribute.startswith("battery_schedule_"): + return + field = attribute[len("battery_schedule_") :] + local = self.local_schedule.setdefault(site_id, self._default_local_schedule()) + if field == "reserve": + local["reserve"] = int(float(value)) + # Apply immediately (skipping a redundant write when it already matches the cached cloud value) + if local["reserve"] and local["reserve"] != int(self.profile.get(site_id, {}).get("reserve", -1)): + await self.set_reserve(site_id, local["reserve"]) + for direction in ["charge", "export"]: + if field == f"{direction}_soc": + local[direction]["soc"] = int(float(value)) + await self.publish_schedule_settings_ha(site_id) + + async def switch_event(self, entity_id, service): + """Handle a switch service call routed from HA, updating the local schedule model. + + Turning on a "_write" switch triggers `apply_battery_schedule(site_id)`, which + diffs the local schedule model against the cloud and issues only the changed writes. + """ + site_id, attribute = self._parse_entity(entity_id) + if not site_id or not attribute.startswith("battery_schedule_"): + return + field = attribute[len("battery_schedule_") :] + local = self.local_schedule.setdefault(site_id, self._default_local_schedule()) + for direction in ["charge", "export"]: + if field == f"{direction}_enable": + local[direction]["enable"] = self._toggle_to_bool(service, local[direction]["enable"]) + if field == f"{direction}_write" and self._toggle_to_bool(service, False): + await self.apply_battery_schedule(site_id) + await self.publish_schedule_settings_ha(site_id) + + def _site_timezone(self, site_id): + """Return the IANA timezone to use for schedule writes.""" + timezone_name = self.site_settings.get(site_id, {}).get("timezone") + return timezone_name or str(self.local_tz) + + async def _write_schedule(self, site_id, family, start_time_ha, end_time_ha, limit, enabled): + """Create/update one Enphase schedule family if it differs from our cached cloud state. + + Converts the HA "HH:MM:SS" option times to Enphase "HH:MM" format, then compares against + the cached cloud schedule via `schedules_equal()`. A matching schedule is a no-op. Otherwise + it updates the existing schedule by id (`PUT`) or creates a new one (`POST`). On success it + optimistically updates our cached copy to the written state and returns - the periodic + settings re-read will correct it if the write did not actually land. A create additionally + re-reads the schedules once, to capture the cloud-assigned scheduleId (so later edits update + in place rather than creating duplicates - the write responses do not return the id). + Returns True if a write was issued. + """ + start_hm = ha_time_to_enphase(start_time_ha) + end_hm = ha_time_to_enphase(end_time_ha) + family_key = family.lower() + cloud_entry = self.schedules.get(site_id, {}).get(family_key, {}) + if schedules_equal(cloud_entry, start_hm, end_hm, limit, enabled): + return False # already matches our cached state - nothing to do + + payload = {"timezone": self._site_timezone(site_id), "startTime": start_hm, "endTime": end_hm, "scheduleType": family, "days": [1, 2, 3, 4, 5, 6, 7], "isEnabled": bool(enabled)} + if limit is not None: + payload["limit"] = int(limit) + schedule_id = cloud_entry.get("id") + if schedule_id: + self.log(f"Enphase: Updating {family} schedule {schedule_id} on site {site_id}: {start_hm}-{end_hm} limit={limit} enabled={enabled}") + result = await self.request_json("PUT", f"{BATTERY_CONFIG_BASE}/battery/sites/{site_id}/schedules/{schedule_id}", family="battery_config", json_body=payload) + if result is not None: + # Optimistically cache the new state, preserving the id and other fields. + updated = dict(cloud_entry) + updated.update({"startTime": start_hm, "endTime": end_hm, "limit": limit, "enabled": bool(enabled)}) + self.schedules.setdefault(site_id, {})[family_key] = updated + else: + self.log(f"Enphase: Creating {family} schedule on site {site_id}: {start_hm}-{end_hm} limit={limit} enabled={enabled}") + result = await self.request_json("POST", f"{BATTERY_CONFIG_BASE}/battery/sites/{site_id}/schedules", family="battery_config", json_body=payload) + if result is not None: + # Re-read once so we learn the new schedule's cloud-assigned id for future edits. + await self.get_schedules(site_id) + return result is not None + + async def _ensure_charge_from_grid(self, site_id): + """Enable the charge-from-grid setting, accepting the one-time ITC disclaimer first.""" + if self.battery_settings.get(site_id, {}).get("chargeFromGrid"): + return + self.log(f"Enphase: Enabling charge-from-grid on site {site_id}") + await self.request_json("POST", f"{BATTERY_CONFIG_BASE}/batterySettings/acceptDisclaimer/{site_id}", family="battery_config", json_body={"disclaimer-type": "itc"}) + await self.request_json("PUT", f"{BATTERY_CONFIG_BASE}/batterySettings/{site_id}", family="battery_config", json_body={"chargeFromGrid": True}) + self.battery_settings.setdefault(site_id, {})["chargeFromGrid"] = True + + async def set_reserve(self, site_id, reserve): + """Write the battery backup reserve (batteryBackupPercentage) via a profile PUT. + + Preserves the current profile name (so only the reserve changes). Returns the parsed + response, or None on failure. + """ + # Bootstrap a fresh XSRF token immediately before the write (its x-csrf-token response header + # and XSRF cookie are absorbed for the double-submit the PUT needs). + await self.get_site_settings(site_id) + cloud = self.profile.get(site_id, {}) + profile_name = cloud.get("profile") or PROFILE_SELF_CONSUMPTION + self.log(f"Enphase: Setting reserve to {int(reserve)}% (profile {profile_name}) on site {site_id}") + params = {"source": "enho"} + if self.user_id: + params["userId"] = self.user_id + result = await self.request_json("PUT", f"{BATTERY_CONFIG_BASE}/profile/{site_id}", family="battery_config", params=params, json_body={"profile": profile_name, "batteryBackupPercentage": int(reserve)}) + if result is not None: + # Optimistically cache the written reserve; the periodic profile re-read will correct + # it if the write did not actually land (e.g. the gateway never activated it). + self.profile.setdefault(site_id, {})["reserve"] = int(reserve) + return result + + async def apply_battery_schedule(self, site_id): + """Diff the local schedule model against the cloud and issue only the changed writes. + + Reads the latest control-entity state into `local_schedule` first, then writes (only + where the desired state differs from the cached cloud state): + 1. Reserve, via a profile PUT that preserves the current profile name. + 2. Forced charge-from-grid window (schedule family "CFG"), enabling the + `chargeFromGrid` battery setting first if required. + 3. The export window, derived from Predbat's export/discharge target SOC: + - target below 99% -> a real forced export to that floor (schedule family "DTG"), + only on sites that support it; + - target of exactly 99% -> "freeze export" (hold, don't discharge), mapped to the + restrict-battery-discharge family ("RBD") over the export window; + - target of 100% -> disabled (same as no export). + Each write optimistically updates our cached cloud copy on success and moves on; the + periodic settings re-read reconciles it later if a write did not actually land. Returns + True if any write was issued. + + Freeze *charge* is not handled here - Predbat freezes charge via the reserve (raising it + to the current SOC) and disabling the charge window, using the existing reserve/charge + controls. + """ + await self.get_schedule_settings_ha(site_id) + # Bootstrap a fresh XSRF token before writing (the web app GETs siteSettings first); its + # x-csrf-token response header is absorbed by request_json for the writes below. + await self.get_site_settings(site_id) + local = self.local_schedule.get(site_id, self._default_local_schedule()) + wrote = False + + # Reserve via profile PUT, preserving the current profile name + desired_reserve = int(local.get("reserve", 0)) + cloud = self.profile.get(site_id, {}) + if desired_reserve and desired_reserve != int(cloud.get("reserve", -1)): + await self.set_reserve(site_id, desired_reserve) + wrote = True + + # Forced charge window (CFG) + charge = local.get("charge", {}) + if charge.get("enable"): + await self._ensure_charge_from_grid(site_id) + wrote |= await self._write_schedule(site_id, SCHEDULE_CHARGE, charge.get("start_time", "00:00:00"), charge.get("end_time", "00:00:00"), charge.get("soc", 100), charge.get("enable", False)) + + # Export window. Predbat encodes the mode in the export/discharge target SOC: + # < 99 -> real forced export to that floor (DTG) + # == 99 -> freeze export / restrict discharge (RBD) + # == 100 (or disabled) -> no export + export = local.get("export", {}) + export_enabled = bool(export.get("enable", False)) + export_soc = int(export.get("soc", 5)) + export_start = export.get("start_time", "00:00:00") + export_end = export.get("end_time", "00:00:00") + real_export = export_enabled and export_soc < 99 + freeze_export = export_enabled and export_soc == 99 + + # Clamp the DTG floor to at least the reserve: Enphase will not discharge below the backup + # reserve, and Predbat's own discharge target is max(export, reserve), so keep the written + # limit consistent rather than requesting an export below the reserve it can never reach. + dtg_limit = max(export_soc, int(local.get("reserve", 0))) + + # Forced export to a target (DTG). A configured inverter always supports DTG. + wrote |= await self._write_schedule(site_id, SCHEDULE_EXPORT, export_start, export_end, dtg_limit, real_export) + + # Freeze export = restrict battery discharge (RBD) over the export window + wrote |= await self._write_schedule(site_id, SCHEDULE_FREEZE, export_start, export_end, None, freeze_export) + return wrote + + async def get_battery_status(self, site_id): + """Fetch and normalise battery SOC/capacity/power for a site.""" + data = await self.request_json("GET", f"/pv/settings/{site_id}/battery_status.json") + if data is None: + return None + batteries = data.get("storages") or [] + total_capacity = sum(safe_float(b.get("max_capacity")) for b in batteries) + total_available = sum(safe_float(b.get("available_energy")) for b in batteries) + if total_capacity > 0: + soc_percent = round(total_available / total_capacity * 100.0, 1) + else: + soc_percent = safe_float(data.get("current_charge")) + # Most recent per-battery report time (Unix seconds) - the "last time the battery was + # online". This stays fresh while the battery reports and freezes when the gateway goes + # offline, so it drives Predbat's inverter-time liveness/skew detection. + report_times = [safe_float(b.get("last_report"), None) for b in batteries] + last_report = max([t for t in report_times if t], default=None) + self.battery_status[site_id] = { + "soc_percent": soc_percent, + "available_energy": safe_float(data.get("available_energy"), total_available), + "max_capacity": safe_float(data.get("max_capacity"), total_capacity), + "max_power_kw": safe_float(data.get("max_power")), + "status": str(data.get("status", "")), + "last_report": last_report, + # Note: this payload has no "profile" key. The battery profile name is + # sourced from self.profile[site_id]["profile"], populated by get_profile(). + "batteries": batteries, + } + await self._save_cache("battery_status", self.battery_status) + return self.battery_status[site_id] + + async def get_today(self, site_id): + """Fetch today's per-channel totals and intra-day 15-minute buckets for a site. + + Uses GET /pv/systems//today, whose stats[0].totals gives each channel's running + total for today (in Wh) and whose per-channel arrays are intra-day energy buckets of + stats[0].interval_length seconds starting at stats[0].start_time. Stored normalised for + publish_data to turn into the *_today (kWh) and instantaneous power (W) sensors. + """ + data = await self.request_json("GET", f"/pv/systems/{site_id}/today") + if data is None: + return None + stats = data.get("stats") or [] + stat = stats[0] if isinstance(stats, list) and stats else {} + channels = ("production", "consumption", "import", "export", "charge", "discharge") + status_details = data.get("statusDetails") or {} + self.today[site_id] = { + "totals": stat.get("totals") or {}, + "arrays": {channel: (stat.get(channel) or []) for channel in channels}, + "start_time": stat.get("start_time"), + "interval_length": stat.get("interval_length"), + # Site health: siteStatus is "normal"/"comm" (communication fault) etc., with a + # human-readable status description when there is a problem (e.g. gateway not reporting). + "site_status": data.get("siteStatus"), + "status_severity": status_details.get("statusSeverity"), + "status_desc": status_details.get("statusDesc"), + "last_report_date": data.get("last_report_date"), + } + await self._save_cache("today", self.today) + return self.today[site_id] + + async def get_latest_power(self, site_id): + """Fetch and normalise the latest instantaneous power reading for a site.""" + data = await self.request_json("GET", f"/app-api/{site_id}/get_latest_power") + if data is None: + return None + latest_power = data.get("latest_power") or {} + timestamp = safe_float(latest_power.get("time"), None) + if timestamp is not None and timestamp > 1e12: + timestamp = timestamp / 1000.0 + self.latest_power[site_id] = { + "watts": safe_float(latest_power.get("value")), + "time": timestamp, + } + await self._save_cache("latest_power", self.latest_power) + return self.latest_power[site_id] + + async def get_profile(self, site_id): + """Fetch and store the battery operating profile and backup reserve for a site.""" + params = {"source": "enho"} + if self.user_id: + params["userId"] = self.user_id + data = await self.request_json("GET", f"{BATTERY_CONFIG_BASE}/profile/{site_id}", family="battery_config", params=params) + if data is None: + return None + # The real response wraps the fields in a "data" object: {"type": "profile-details", "data": {...}} + body = data.get("data") if isinstance(data.get("data"), dict) else data + self.profile[site_id] = { + "profile": str(body.get("profile", "")), + "reserve": safe_int(body.get("batteryBackupPercentage")), + } + await self._save_cache("profile", self.profile) + return self.profile[site_id] + + async def get_battery_settings(self, site_id): + """Fetch and store the battery charge-from-grid and low-SOC settings for a site.""" + data = await self.request_json("GET", f"{BATTERY_CONFIG_BASE}/batterySettings/{site_id}", family="battery_config", params={"source": "enlm"}) + if data is None: + return None + # The real response wraps the fields in a "data" object: {"type": "battery-details", "data": {...}} + body = data.get("data") if isinstance(data.get("data"), dict) else data + self.battery_settings[site_id] = { + "chargeFromGrid": bool(body.get("chargeFromGrid", False)), + "veryLowSoc": safe_int(body.get("veryLowSoc"), None), + "veryLowSocMin": safe_int(body.get("veryLowSocMin"), None), + "veryLowSocMax": safe_int(body.get("veryLowSocMax"), None), + } + await self._save_cache("battery_settings", self.battery_settings) + return self.battery_settings[site_id] + + async def get_site_settings(self, site_id): + """Fetch the BatteryConfig site feature/capability flags for a site. + + GET /service/batteryConfig/api/v1/siteSettings/?userId=. Returns capability + flags under a "data" object (hasEncharge, hasAcb, showChargeFromGrid, isEnsemble, ...). + This is also the web app's primary XSRF bootstrap: the response's x-csrf-token header (and + cookies) are absorbed by request_json, refreshing the token used for subsequent writes. + """ + params = {} + if self.user_id: + params["userId"] = self.user_id + data = await self.request_json("GET", f"{BATTERY_CONFIG_BASE}/siteSettings/{site_id}", family="battery_config", params=params or None) + if data is None: + return None + body = data.get("data") if isinstance(data.get("data"), dict) else data + self.site_settings[site_id] = body + await self._save_cache("site_settings", self.site_settings) + return self.site_settings[site_id] + + async def get_schedules(self, site_id): + """Fetch and normalise the charge/export/freeze battery schedules for a site. + + Each family (cfg/dtg/rbd) reports ``scheduleStatus``, ``count`` and, when count > 0, a + ``details`` list of schedule objects. A schedule object (confirmed against a battery + account) carries ``scheduleId``, ``startTime``/``endTime`` (HH:MM), ``limit``, ``days``, + ``isEnabled`` and ``isDeleted``. Only the first schedule per family is used (Predbat drives + one window per direction); the ``scheduleId`` is what the write path updates in place. + """ + data = await self.request_json("GET", f"{BATTERY_CONFIG_BASE}/battery/sites/{site_id}/schedules", family="battery_config") + if data is None: + return None + parsed = {} + for family_key in ("cfg", "dtg", "rbd"): + family_data = data.get(family_key) or {} + details = family_data.get("details") or [] + # Prefer the first non-deleted schedule + entry = next((item for item in details if isinstance(item, dict) and not item.get("isDeleted")), details[0] if details else {}) + # "supported" gates whether Predbat can use this schedule family. Real accounts report + # a per-family scheduleStatus ("active" seen so far); treat the usable statuses as + # supported, with a fallback to the (unverified) boolean flags. + status_text = str(family_data.get("scheduleStatus", "")).strip().lower() + supported = status_text in ("active", "enabled", "supported", "available") or bool(family_data.get("scheduleSupported") or family_data.get("forceScheduleSupported")) + parsed[family_key] = { + "id": entry.get("scheduleId") or entry.get("id"), + "startTime": entry.get("startTime"), + "endTime": entry.get("endTime"), + "limit": safe_int(entry.get("limit"), None), + "enabled": bool(entry.get("isEnabled", False)), + "supported": supported, + "count": safe_int(family_data.get("count"), 0), + "status": family_data.get("scheduleStatus"), + } + self.schedules[site_id] = parsed + await self._save_cache("schedules", self.schedules) + return parsed + + def dtg_supported(self, site_id): + """Return True when the export-to-grid (dtg) schedule family is supported for a site.""" + return bool(self.schedules.get(site_id, {}).get("dtg", {}).get("supported", False)) + + async def automatic_config(self): + """Automatically configure Predbat inverter args from the discovered Enphase site. + + Single-site only: the first discovered site is used. Points every generic Predbat + inverter arg at the entities published by `publish_data()` / `publish_schedule_settings_ha()` + for that site. Export/discharge args are only set when the site supports the "dtg" + (discharge-to-grid) schedule family, since not every Enphase system offers it. + """ + if not self.sites: + raise ValueError("Enphase API: No sites found, cannot configure") + site_id = self.sites[0]["site_id"] + status = self.battery_status.get(site_id, {}) + if not status.get("max_capacity"): + raise ValueError("Enphase API: No battery found on site, cannot configure") + # Predbat needs both charge and export control to plan properly, so require the + # charge-from-grid (CFG) and discharge-to-grid (DTG) schedule families. If either is + # unsupported, fail configuration rather than publishing an inverter Predbat cannot drive. + if not self.schedules.get(site_id, {}).get("cfg", {}).get("supported", False): + raise ValueError("Enphase API: Charge-from-grid (CFG) scheduling not supported on this site, cannot configure") + if not self.dtg_supported(site_id): + raise ValueError("Enphase API: Discharge-to-grid (DTG) scheduling not supported on this site, cannot configure") + entity = f"{self.prefix}_enphase_{site_id}" + + self.set_arg("inverter_type", ["EnphaseCloud"]) + self.set_arg("num_inverters", 1) + self.set_arg("load_today", [f"sensor.{entity}_load_today"]) + self.set_arg("import_today", [f"sensor.{entity}_import_today"]) + self.set_arg("export_today", [f"sensor.{entity}_export_today"]) + if not self.automatic_ignore_pv: + self.set_arg("pv_today", [f"sensor.{entity}_pv_today"]) + self.set_arg("pv_power", [f"sensor.{entity}_pv_power"]) + self.set_arg("soc_percent", [f"sensor.{entity}_soc_percent"]) + self.set_arg("soc_max", [f"sensor.{entity}_battery_capacity"]) + self.set_arg("battery_rate_max", [f"sensor.{entity}_battery_rate_max"]) + self.set_arg("battery_power", [f"sensor.{entity}_battery_power"]) + self.set_arg("grid_power", [f"sensor.{entity}_grid_power"]) + self.set_arg("load_power", [f"sensor.{entity}_load_power"]) + self.set_arg("reserve", [f"number.{entity}_battery_schedule_reserve"]) + self.set_arg("battery_min_soc", [f"sensor.{entity}_battery_reserve_min"]) + self.set_arg("inverter_time", [f"sensor.{entity}_inverter_time"]) + self.set_arg("charge_start_time", [f"select.{entity}_battery_schedule_charge_start_time"]) + self.set_arg("charge_end_time", [f"select.{entity}_battery_schedule_charge_end_time"]) + self.set_arg("charge_limit", [f"number.{entity}_battery_schedule_charge_soc"]) + self.set_arg("scheduled_charge_enable", [f"switch.{entity}_battery_schedule_charge_enable"]) + self.set_arg("scheduled_discharge_enable", [f"switch.{entity}_battery_schedule_export_enable"]) + self.set_arg("discharge_start_time", [f"select.{entity}_battery_schedule_export_start_time"]) + self.set_arg("discharge_end_time", [f"select.{entity}_battery_schedule_export_end_time"]) + self.set_arg("discharge_target_soc", [f"number.{entity}_battery_schedule_export_soc"]) + self.set_arg("schedule_write_button", [f"switch.{entity}_battery_schedule_charge_write"]) + # export_limit is deliberately not set here: the Enphase cloud does not report a grid + # export power limit, and hardcoding one would override the user's apps.yaml export_limit. + # Leaving it unset lets the user configure it (Predbat defaults to unlimited otherwise). + + def login_allowed(self): + """Return True when a password login attempt is currently permitted by the guard rails.""" + if self.login_cooldown_until and datetime.now(timezone.utc) < self.login_cooldown_until: + return False + return True + + def _login_rejected(self, reason, unrecoverable=False): + """Record a rejected login and set the appropriate cooldown. + + Fatal (app-wide) error signalling is reserved for genuinely unrecoverable + states (MFA required, account blocked) or once the suspend tier is reached + after repeated transient rejections - a single 401/403/no-token/session + rejection must not mark the whole app as not-running. + """ + self.login_reject_count += 1 + if self.login_reject_count >= self.LOGIN_MAX_REJECTS: + delay = self.LOGIN_SUSPEND_SECONDS + else: + delay = self.LOGIN_COOLDOWN_SECONDS + self.login_cooldown_until = datetime.now(timezone.utc) + timedelta(seconds=delay) + self.log(f"Warn: Enphase: Login rejected ({reason}), cooling down for {delay} seconds (rejection {self.login_reject_count})") + if unrecoverable or self.login_reject_count >= self.LOGIN_MAX_REJECTS: + self.fatal_error_occurred() + + async def login(self): + """Authenticate with Enlighten: password login, token mint, site discovery.""" + # Reuse a very recent successful login (coalesces concurrent 401 refreshes) + if self.login_last_success and (datetime.now(timezone.utc) - self.login_last_success).total_seconds() < self.LOGIN_REUSE_SECONDS and self.eauth_token: + return True + if not self.login_allowed(): + self.log("Warn: Enphase: Login suppressed by cooldown after previous rejections") + return False + + headers = { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "User-Agent": ENPHASE_USER_AGENT, + "Referer": BASE_URL + "/", + } + status, data, text, cookies = await self.request_raw("POST", BASE_URL + LOGIN_PATH, headers=headers, data={"user[email]": self.username, "user[password]": self.password}) + # Log the response only - the request body (password) is never passed to the logger. + self._log_api_call("POST", LOGIN_PATH, None, status, data, text) + + if status in (401, 403): + self._login_rejected("invalid credentials") + return False + if isinstance(data, dict) and data.get("requires_mfa"): + self._login_rejected("account requires MFA - disable MFA on the Enphase account to use this component", unrecoverable=True) + return False + if isinstance(data, dict) and data.get("isBlocked"): + self._login_rejected("account is blocked", unrecoverable=True) + return False + if is_too_many_sessions(text): + # "Too many active sessions" - detect regardless of HTTP status, Enlighten sometimes returns 200 + self._login_rejected("too many active sessions") + return False + if status != 200: + self._login_rejected(f"http status {status}") + return False + + # Persist cookies from the login (session cookie + manager token JWT) + self._absorb_cookies(cookies) + + # Mint the e-auth/bearer token; Enlighten may rotate the session cookie here + status, token_data, text, cookies = await self.request_raw("GET", BASE_URL + SELF_TOKEN_PATH, headers=self.get_headers("site")) + self._log_api_call("GET", SELF_TOKEN_PATH, None, status, token_data, text) + self._absorb_cookies(cookies) + if status == 200 and isinstance(token_data, dict): + token = token_data.get("token") or token_data.get("auth_token") or token_data.get("access_token") + if token: + self.eauth_token = token + claims = decode_jwt_claims(token) + self.user_id = str(claims.get("user_id") or claims.get("userId") or claims.get("sub") or "") or None + self.token_expires_at = token_data.get("expires_at") or token_data.get("expiresAt") or claims.get("exp") + if not self.eauth_token: + self._login_rejected("no auth token returned") + return False + + # Discover sites + status, sites_data, text, cookies = await self.request_raw("GET", BASE_URL + SITE_SEARCH_PATH, headers=self.get_headers("site"), params={"searchText": "", "favourite": "false"}) + self._log_api_call("GET", SITE_SEARCH_PATH, None, status, sites_data, text) + sites = [] + if status == 200: + entries = sites_data if isinstance(sites_data, list) else (sites_data or {}).get("sites", []) + for entry in entries: + sid = str(entry.get("site_id") or entry.get("id") or "") + if sid and (not self.site_id or sid == self.site_id): + sites.append({"site_id": sid, "name": entry.get("name", sid)}) + if sites: + # Deduplicate by site id preserving order - Enlighten can return the same site more than once + seen = set() + deduped = [] + for site in sites: + if site["site_id"] not in seen: + seen.add(site["site_id"]) + deduped.append(site) + self.sites = deduped + await self._save_cache("sites", self.sites) + if len(self.sites) > 1 and not self.site_id: + self.log(f"Warn: Enphase: {len(self.sites)} sites found; using the first ({self.sites[0]['site_id']}). Set enphase_site_id to choose a specific site.") + + self.login_last_success = datetime.now(timezone.utc) + self.login_reject_count = 0 + self.login_cooldown_until = None + self.log(f"Enphase: Login successful, {len(self.sites)} site(s)") + return True + + def _absorb_cookies(self, cookies): + """Merge response cookies into the serialised cookie header and pick out special tokens.""" + if not cookies: + return + current = {} + for part in self.cookie_header.split("; "): + if "=" in part: + name, value = part.split("=", 1) + current[name] = value + current.update(cookies) + self.cookie_header = "; ".join(f"{k}={v}" for k, v in current.items() if v) + self.manager_token = current.get("enlighten_manager_token_production", self.manager_token) + # The XSRF token cookie is named XSRF-TOKEN or BP-XSRF-Token (case varies); take the first + # match. It is used for BatteryConfig writes as both the X-XSRF-Token header and cookie. + for name, value in current.items(): + if value and "xsrf" in name.lower() and "token" in name.lower(): + self.xsrf_token = value + break + + def get_headers(self, family, write=False): + """Build request headers for an endpoint family ('site' or 'battery_config').""" + if family == "battery_config": + headers = { + "Accept": "application/json, text/plain, */*", + "Origin": BATTERY_UI_ORIGIN, + "Referer": BATTERY_UI_ORIGIN + "/", + "User-Agent": ENPHASE_USER_AGENT, + "e-auth-token": self.eauth_token or "", + } + if self.battery_config_variant == "cookie_eauth": + # Fallback variant needed on some regions/firmware: cookie-backed with XHR marker + headers["X-Requested-With"] = "XMLHttpRequest" + if self.cookie_header: + headers["Cookie"] = self.cookie_header + else: + headers["requestid"] = str(uuid.uuid4()) + bearer = self.manager_token or self.eauth_token + if bearer: + headers["Authorization"] = f"Bearer {bearer}" + if self.user_id: + headers["Username"] = self.user_id + if write: + headers["Content-Type"] = "application/json" + if self.xsrf_token: + headers["X-XSRF-Token"] = self.xsrf_token + return headers + + headers = { + "Accept": "application/json, text/plain, */*", + "User-Agent": ENPHASE_USER_AGENT, + "X-Requested-With": "XMLHttpRequest", + "Referer": BASE_URL + "/", + } + if self.cookie_header: + headers["Cookie"] = self.cookie_header + if self.eauth_token: + headers["Authorization"] = f"Bearer {self.eauth_token}" + headers["e-auth-token"] = self.eauth_token + if self.xsrf_token: + headers["X-CSRF-Token"] = self.xsrf_token + return headers + + async def request_raw(self, method, url, headers=None, data=None, json_body=None, params=None): + """Perform one HTTP request, returning (status, json_or_none, text, cookie_dict). Overridden in tests.""" + async with aiohttp.ClientSession() as session: + async with session.request(method, url, headers=headers, data=data, json=json_body, params=params, timeout=aiohttp.ClientTimeout(total=60)) as response: + text = await response.text() + cookies = {key: morsel.value for key, morsel in response.cookies.items()} + # The BatteryConfig service returns a fresh XSRF token in the x-csrf-token response + # header on every call; fold it into the cookie dict so _absorb_cookies keeps our + # X-XSRF-Token current for the next write (the web app's primary bootstrap mechanism). + csrf_header = response.headers.get("x-csrf-token") or response.headers.get("X-CSRF-Token") + if csrf_header: + cookies["XSRF-TOKEN"] = csrf_header + json_data = None + content_type = response.headers.get("Content-Type", "") + if "json" in content_type: + try: + json_data = await response.json(content_type=None) + except ValueError: + json_data = None + return response.status, json_data, text, cookies + + def _log_api_call(self, method, path, params, status, json_data, text): + """Log one API call and a truncated, token-redacted view of its response (when debug_api is on).""" + if not self.debug_api: + return + if isinstance(json_data, dict): + redacted = dict(json_data) + for key in ("token", "auth_token", "access_token"): + if key in redacted: + redacted[key] = "***redacted***" + preview = json.dumps(redacted, default=str) + elif json_data is not None: + preview = json.dumps(json_data, default=str) + elif self._is_login_wall(json_data, text): + # Don't dump the full HTML login/marketing page (tens of KB) - it is noise, and it is + # the expected trigger for the BatteryConfig header-variant fallback. + preview = f"(HTML page, {len(text or '')} chars - login wall / variant fallback)" + else: + preview = text or "" + param_str = f" params={params}" if params else "" + self.log(f"Enphase API: {method} {path}{param_str} -> {status} {preview}") + + def _is_login_wall(self, json_data, text): + """Return True when a JSON endpoint answered with an HTML login page instead of JSON. + + Enlighten sometimes responds to an expired/invalid session with a 200 status and an + HTML login page body rather than a 401, so this must be checked independently of status. + """ + if json_data is not None: + return False + stripped = (text or "").lstrip().lower() + return stripped.startswith("= 500: + record_api_call("enphase", False, "rate_limit" if status == 429 else "server_error") + await asyncio.sleep(min(30, (retry + 1) * (2 + random.random() * 3))) + continue + + if status != 200: + self.log(f"Warn: Enphase: HTTP {status} on {path}") + record_api_call("enphase", False, "client_error") + self.last_error_status = status + self.failures_total += 1 + return None + + # Absorb cookies from a genuine success only: this keeps the session cookie current + # and captures the fresh XSRF token (into both self.cookie_header and self.xsrf_token), + # which BatteryConfig writes require as a double-submit (XSRF-TOKEN cookie + X-XSRF-Token + # header). It is deliberately NOT done for login-wall/error responses (handled above), + # whose anonymous cookies would otherwise corrupt our authenticated session. + self._absorb_cookies(cookies) + + record_api_call("enphase", True) + self.update_success_timestamp() + return json_data + + self.failures_total += 1 + return None + + +class MockBase: # pragma: no cover + """Minimal stand-in for the Predbat base object so EnphaseAPI can run standalone. + + Provides just the attributes and methods ComponentBase and EnphaseAPI read + from ``self.base`` (prefix, args, timezone/clock, state/arg accessors and a + logger). It deliberately has no ``components`` attribute, so ``self.storage`` + resolves to None and the disk cache is skipped for a standalone run. + """ + + def __init__(self): + """Initialise the mock base with the current clock and empty state stores.""" + self.local_tz = datetime.now().astimezone().tzinfo + self.now_utc = datetime.now(self.local_tz) + self.prefix = "predbat" + self.args = {} + self.midnight_utc = self.now_utc.replace(hour=0, minute=0, second=0, microsecond=0) + self.minutes_now = self.now_utc.hour * 60 + self.now_utc.minute + self.fatal_error = False + self.had_errors = False + self.entities = {} + + def get_state_wrapper(self, entity_id, default=None, attribute=None, refresh=False, required_unit=None, raw=None): + """Return the stored state (or full record when raw) for an entity id.""" + if raw: + return self.entities.get(entity_id, {}) + else: + return self.entities.get(entity_id, {}).get("state", default) + + def set_state_wrapper(self, entity_id, state, attributes=None, app=None): + """Store an entity's state and attributes in memory.""" + self.entities[entity_id] = {"state": state, "attributes": attributes or {}} + + def log(self, message): + """Print a timestamped log line to stdout.""" + print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}") + + def dashboard_item(self, entity_id, state=None, attributes=None, app=None): + """Print and store a published entity, so a standalone run shows what it publishes.""" + print(f"ENTITY: {entity_id} = {state}") + if attributes: + if "options" in attributes: + attributes["options"] = "..." + print(f" Attributes: {json.dumps(attributes, indent=2)}") + self.set_state_wrapper(entity_id, state, attributes) + + def get_arg(self, arg, default=None, indirect=True, combine=False, attribute=None, index=None, domain=None, can_override=True, required_unit=None): + """Return the default for any requested arg (no real config in standalone mode).""" + return default + + def set_arg(self, key, value): + """Print an arg that automatic_config would set, resolving any referenced entity state.""" + state = None + if isinstance(value, str) and "." in value: + state = self.get_state_wrapper(value, default=None) + elif isinstance(value, list): + state = "n/a []" + for v in value: + if isinstance(v, str) and "." in v: + state = self.get_state_wrapper(v, default=None) + break + else: + state = "n/a" + print(f"Set arg {key} = {value} (state={state})") + + +async def test_enphase_api(username, password, site_id): # pragma: no cover + """Log in and run one poll cycle, printing the discovered sites and read data.""" + mock_base = MockBase() + api = EnphaseAPI(mock_base, username=username, password=password, site_id=site_id, automatic=True) + + print("Calling run() once (login, reads, publish, automatic_config)...") + result = await api.run(seconds=0, first=True) + print(f"run() returned: {result}") + print(f"Discovered sites: {api.sites}") + + for site in api.sites: + sid = site["site_id"] + print(f"\n--- Site {sid} ({site.get('name', '')}) ---") + print(f"battery_status: {json.dumps(api.battery_status.get(sid, {}), default=str, indent=2)}") + print(f"profile: {api.profile.get(sid)}") + print(f"battery_settings: {api.battery_settings.get(sid)}") + print(f"schedules: {json.dumps(api.schedules.get(sid, {}), default=str, indent=2)}") + print(f"dtg_supported: {api.dtg_supported(sid)}") + + # Dump the normalised today data: per-channel totals (Wh) and 15-minute bucket metadata, + # so the published *_today (kWh) and instantaneous power values can be sanity-checked. + today = api.today.get(sid, {}) + print(f"today totals (Wh): {today.get('totals')}") + print(f"today interval_length={today.get('interval_length')} start_time={today.get('start_time')}") + for channel, values in (today.get("arrays") or {}).items(): + if values: + print(f" {channel}: len={len(values)} last5={values[-5:]}") + + print("\nDone") + + +async def test_write_schedule(username, password, site_id, start_time, end_time, soc): # pragma: no cover + """Write a test charge-from-grid window via the control entities, read it back, then disable it. + + Drives the same path Predbat uses: it sets the published control entities to the requested + window, calls apply_battery_schedule (which reads those entities and writes an Enphase CFG + schedule), reads the schedules back, then restores by disabling the charge window again. + """ + mock_base = MockBase() + api = EnphaseAPI(mock_base, username=username, password=password, site_id=site_id, automatic=False) + + print("Logging in and loading current state...") + await api.run(seconds=0, first=True) + if not api.sites: + print("No sites found") + return + sid = api.sites[0]["site_id"] + print(f"Existing schedules for {sid}:\n{json.dumps(api.schedules.get(sid, {}), default=str, indent=2)}") + + base_name = f"{api.prefix}_enphase_{sid}_battery_schedule" + + # Set the charge control entities to the requested window and enable it + mock_base.set_state_wrapper(f"select.{base_name}_charge_start_time", start_time) + mock_base.set_state_wrapper(f"select.{base_name}_charge_end_time", end_time) + mock_base.set_state_wrapper(f"number.{base_name}_charge_soc", soc) + mock_base.set_state_wrapper(f"switch.{base_name}_charge_enable", "on") + print(f"Applying test CFG charge window {start_time}-{end_time} @ {soc}%...") + await api.apply_battery_schedule(sid) + await api.get_schedules(sid) + print(f"Read-back schedules after write:\n{json.dumps(api.schedules.get(sid, {}), default=str, indent=2)}") + + # Restore: disable the test charge window again + print("Restoring: disabling the test charge window...") + mock_base.set_state_wrapper(f"switch.{base_name}_charge_enable", "off") + await api.apply_battery_schedule(sid) + print("Done") + + +async def test_write_reserve(username, password, site_id, value): # pragma: no cover + """Write the battery reserve to a test value, read it back, then restore the original. + + A minimal, safe real-write test: it changes only the reserve (batteryBackupPercentage), + prints the before/after values, and always puts the original value back so the customer's + system is left as it was. + """ + mock_base = MockBase() + api = EnphaseAPI(mock_base, username=username, password=password, site_id=site_id, automatic=False) + + print("Logging in and loading current state...") + await api.run(seconds=0, first=True) + if not api.sites: + print("No sites found") + return + sid = api.sites[0]["site_id"] + + original = api.profile.get(sid, {}).get("reserve") + print(f"Current reserve on site {sid}: {original}%") + if original is None: + print("Could not read current reserve - aborting without writing") + return + + try: + print(f"Writing test reserve {value}%...") + await api.set_reserve(sid, value) + await api.get_profile(sid) + print(f"Read-back reserve after write: {api.profile.get(sid, {}).get('reserve')}% (note: may lag by minutes)") + finally: + print(f"Restoring original reserve {original}%...") + await api.set_reserve(sid, original) + await api.get_profile(sid) + print(f"Read-back reserve after restore: {api.profile.get(sid, {}).get('reserve')}%") + print("Done") + + +def main(): # pragma: no cover + """Command-line entry point for exercising the Enphase component standalone.""" + import argparse + + parser = argparse.ArgumentParser(description="Test the Enphase Enlighten cloud component") + parser.add_argument("--username", required=True, help="Enlighten account e-mail") + parser.add_argument("--password", required=True, help="Enlighten account password") + parser.add_argument("--site-id", default=None, help="Restrict to a single Enphase site id") + parser.add_argument("--write-schedule", action="store_true", help="Write a test charge window and read it back instead of a read-only run") + parser.add_argument("--start-time", default="02:00:00", help="Test charge window start (HH:MM:SS)") + parser.add_argument("--end-time", default="05:00:00", help="Test charge window end (HH:MM:SS)") + parser.add_argument("--soc", type=int, default=80, help="Test charge target SOC percent") + parser.add_argument("--write-reserve", type=int, default=None, help="Write this reserve %% (e.g. 25), then restore the original - a safe real-write test") + + args = parser.parse_args() + + if args.write_reserve is not None: + asyncio.run(test_write_reserve(args.username, args.password, args.site_id, args.write_reserve)) + elif args.write_schedule: + asyncio.run(test_write_schedule(args.username, args.password, args.site_id, args.start_time, args.end_time, args.soc)) + else: + asyncio.run(test_enphase_api(args.username, args.password, args.site_id)) + + +if __name__ == "__main__": + main() diff --git a/apps/predbat/tests/test_enphase_api.py b/apps/predbat/tests/test_enphase_api.py new file mode 100644 index 000000000..e7ae3d543 --- /dev/null +++ b/apps/predbat/tests/test_enphase_api.py @@ -0,0 +1,1215 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Test Enphase API functions +# ----------------------------------------------------------------------------- + +from datetime import datetime, timedelta, timezone +import base64 +import json as json_module +import pytz +from enphase import EnphaseAPI, ENPHASE_REFRESH_SETTINGS +from tests.test_infra import run_async + + +def _b64(payload): + """Base64url-encode a dict as a JWT payload segment without padding.""" + raw = json_module.dumps(payload).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +class MockBase: + """Mock base object for ComponentBase properties in Enphase API tests.""" + + def __init__(self): + """Initialise MockBase with default config.""" + self.midnight_utc = datetime.now(pytz.utc).replace(hour=0, minute=0, second=0, microsecond=0) + self.config = {} + + def get_arg(self, key, default=None, **kwargs): + """Return config value or default.""" + return self.config.get(key, default) + + +class MockEnphaseAPI(EnphaseAPI): + """Mock EnphaseAPI that avoids ComponentBase construction and real HTTP.""" + + def __init__(self): + """Set up the mock without calling ComponentBase.__init__.""" + self.prefix = "predbat" + self.base = MockBase() + self.local_tz = pytz.timezone("Europe/London") + # Note: "storage" is a read-only property on ComponentBase (base.components.get_component("storage")), + # so it cannot be assigned directly here. MockBase has no "components" attribute, so the inherited + # property naturally evaluates to None for these tests. + self.api_started = False + self.initialize(username="user@example.com", password="secret") + + # Test instrumentation + self.http_responses = {} # path -> dict(status, json_data, text_data) + self.request_log = [] + self.dashboard_items = {} + self.mock_ha_states = {} + self.args_set = {} + self.fatal_signalled = False + + def log(self, message): + """Swallow log output in tests.""" + pass + + def update_success_timestamp(self): + """Swallow health-tracking in tests.""" + pass + + def fatal_error_occurred(self): + """Record that a fatal error was signalled, for test assertions.""" + self.fatal_signalled = True + + def dashboard_item(self, entity_id, state, attributes, app=None): + """Record dashboard items instead of publishing to HA.""" + self.dashboard_items[entity_id] = {"state": state, "attributes": attributes, "app": app} + + def get_state_wrapper(self, entity_id, default=None): + """Return a mocked HA state.""" + return self.mock_ha_states.get(entity_id, default) + + def set_arg(self, key, value): + """Record args set by automatic_config.""" + self.args_set[key] = value + + def set_http_response(self, path, status=200, json_data=None, text_data=None): + """Prime a canned HTTP response for a URL path.""" + self.http_responses[path] = {"status": status, "json_data": json_data, "text_data": text_data} + + async def request_raw(self, method, url, headers=None, data=None, json_body=None, params=None): + """Return canned responses instead of performing HTTP.""" + path = url.split("enphaseenergy.com", 1)[-1].split("?")[0] + self.request_log.append({"method": method, "path": path, "json": json_body, "data": data}) + response = self.http_responses.get(path, {"status": 404, "json_data": None, "text_data": "not found"}) + return response["status"], response["json_data"], response.get("text_data") or "", {} + + +def test_initialize_defaults(): + """initialize() must set all state fields with correct defaults.""" + api = MockEnphaseAPI() + assert api.username == "user@example.com" + assert api.password == "secret" + assert api.site_id is None + assert api.automatic is False + assert api.sites == [] + assert api.battery_status == {} + assert api.schedules == {} + assert api.data_age == {} + assert api.login_reject_count == 0 + + +def test_needs_refresh(): + """_needs_refresh returns True when data is absent or stale, False when fresh.""" + api = MockEnphaseAPI() + assert api._needs_refresh("battery_status", ENPHASE_REFRESH_SETTINGS) is True + api.data_age["battery_status"] = datetime.now(timezone.utc) + assert api._needs_refresh("battery_status", ENPHASE_REFRESH_SETTINGS) is False + api.data_age["battery_status"] = datetime.now(timezone.utc) - timedelta(minutes=ENPHASE_REFRESH_SETTINGS + 1) + assert api._needs_refresh("battery_status", ENPHASE_REFRESH_SETTINGS) is True + + +def test_is_alive(): + """is_alive requires api_started and at least one discovered site.""" + api = MockEnphaseAPI() + assert not api.is_alive() + api.api_started = True + assert not api.is_alive() + api.sites = [{"site_id": "12345"}] + assert api.is_alive() + + +def test_login_success(): + """Successful login mints tokens, extracts user id and discovers sites.""" + api = MockEnphaseAPI() + # JWT with payload {"user_id": "9999", "exp": 4102444800} (header/sig irrelevant, unverified decode) + jwt = "eyJhbGciOiJIUzI1NiJ9." + _b64({"user_id": "9999", "exp": 4102444800}) + ".sig" + api.set_http_response("/login/login.json", 200, {"success": True, "session_id": "sess1"}) + api.set_http_response("/users/self/token", 200, {"token": jwt, "expires_at": 4102444800}) + api.set_http_response("/app-api/search_sites.json", 200, [{"site_id": 12345, "name": "Home"}]) + assert run_async(api.login()) is True + assert api.eauth_token == jwt + assert api.user_id == "9999" + assert api.sites[0]["site_id"] == "12345" + assert api.login_reject_count == 0 + + +def test_is_too_many_sessions(): + """is_too_many_sessions matches the specific phrase, never a bare 'session' substring.""" + from enphase import is_too_many_sessions + + assert is_too_many_sessions("Too many active sessions") is True + assert is_too_many_sessions("Error: too many active sessions for this account") is True + assert is_too_many_sessions("too many logins; active sessions exceeded") is True + # Happy-path bodies contain 'session_id'/'session' but must NOT match: + assert is_too_many_sessions('{"success": true, "session_id": "abc"}') is False + assert is_too_many_sessions("session created") is False + assert is_too_many_sessions("") is False + assert is_too_many_sessions(None) is False + + +def test_login_happy_path_with_session_text(): + """A successful login whose response body text contains 'session_id' must not be mis-read as 'too many sessions'.""" + api = MockEnphaseAPI() + jwt = "eyJhbGciOiJIUzI1NiJ9." + _b64({"user_id": "9999", "exp": 4102444800}) + ".sig" + # Real Enlighten returns the JSON body as text too - it contains 'session_id' and session cookies. + api.set_http_response("/login/login.json", 200, {"success": True, "session_id": "sess1"}, text_data='{"success": true, "session_id": "sess1", "message": "session created"}') + api.set_http_response("/users/self/token", 200, {"token": jwt, "expires_at": 4102444800}) + api.set_http_response("/app-api/search_sites.json", 200, [{"site_id": 12345, "name": "Home"}]) + assert run_async(api.login()) is True + assert api.login_reject_count == 0 + assert api.fatal_signalled is False + + +def test_login_too_many_sessions(): + """A body reporting 'too many active sessions' (even with HTTP 200) rejects the login and cools down.""" + api = MockEnphaseAPI() + api.set_http_response("/login/login.json", 200, None, text_data="Error: Too many active sessions for this account") + assert run_async(api.login()) is False + assert api.login_reject_count == 1 + assert api.login_cooldown_until is not None + + +def test_login_mfa_rejected(): + """MFA-required accounts must fail with a fatal error immediately, not retry.""" + api = MockEnphaseAPI() + api.set_http_response("/login/login.json", 200, {"requires_mfa": True}) + assert run_async(api.login()) is False + assert api.login_reject_count == 1 + assert api.login_cooldown_until is not None + assert api.fatal_signalled is True + + +def test_login_transient_rejection_not_fatal(): + """A single transient 401 must set a cooldown but must NOT signal a fatal, app-wide error.""" + api = MockEnphaseAPI() + api.set_http_response("/login/login.json", 401, None) + assert run_async(api.login()) is False + assert api.login_reject_count == 1 + assert api.login_cooldown_until is not None + assert api.fatal_signalled is False + + +def test_login_guard_rails(): + """Three consecutive rejections suspend login for 24 hours and only then signal fatal.""" + api = MockEnphaseAPI() + api.set_http_response("/login/login.json", 401, None) + for i in range(3): + api.login_cooldown_until = None # expire cooldown to allow next attempt + run_async(api.login()) + if i == 0: + # A single transient rejection must not be fatal on its own. + assert api.fatal_signalled is False + assert api.login_reject_count == 3 + assert api.fatal_signalled is True + remaining = (api.login_cooldown_until - datetime.now(timezone.utc)).total_seconds() + assert remaining > 23 * 3600 + # While suspended, login() refuses without making a request + count = len(api.request_log) + assert run_async(api.login()) is False + assert len(api.request_log) == count + + +def test_login_reuse_window(): + """A login success within 30 seconds is reused, not repeated.""" + api = MockEnphaseAPI() + api.login_last_success = datetime.now(timezone.utc) + api.eauth_token = "tok" + count = len(api.request_log) + assert run_async(api.login()) is True + assert len(api.request_log) == count + + +def test_get_headers_site(): + """Site-family headers carry cookie, tokens and browser mimicry.""" + api = MockEnphaseAPI() + api.cookie_header = "a=b" + api.eauth_token = "tok" + api.xsrf_token = "xs" + headers = api.get_headers("site") + assert headers["Cookie"] == "a=b" + assert headers["e-auth-token"] == "tok" + assert headers["Authorization"] == "Bearer tok" + assert headers["X-CSRF-Token"] == "xs" + assert headers["X-Requested-With"] == "XMLHttpRequest" + assert "Mozilla" in headers["User-Agent"] + + +def test_get_headers_battery_config(): + """BatteryConfig headers use the battery-profile-ui origin, manager token bearer and user id.""" + api = MockEnphaseAPI() + api.eauth_token = "etok" + api.manager_token = "mtok" + api.user_id = "9999" + headers = api.get_headers("battery_config", write=True) + assert headers["Origin"] == "https://battery-profile-ui.enphaseenergy.com" + assert headers["Authorization"] == "Bearer mtok" + assert headers["e-auth-token"] == "etok" + assert headers["Username"] == "9999" + assert "requestid" in headers + + +def test_request_json_success(): + """request_json returns parsed JSON and counts the request.""" + api = MockEnphaseAPI() + api.set_http_response("/pv/settings/12345/battery_status.json", 200, {"storages": []}) + result = run_async(api.request_json("GET", "/pv/settings/12345/battery_status.json")) + assert result == {"storages": []} + assert api.requests_today == 1 + + +def test_request_json_401_relogin(): + """A 401 triggers one re-login and one retry.""" + api = MockEnphaseAPI() + api.eauth_token = "expired" + calls = {"n": 0} + + async def fake_raw(method, url, headers=None, data=None, json_body=None, params=None): + """Return 401 once then 200, and 200 for the login chain.""" + path = url.split("enphaseenergy.com", 1)[-1].split("?")[0] + api.request_log.append({"method": method, "path": path}) + if path == "/login/login.json": + return 200, {"success": True, "session_id": "s"}, "", {} + if path == "/users/self/token": + return 200, {"token": "newtok"}, "", {} + if path == "/app-api/search_sites.json": + return 200, [{"site_id": 12345, "name": "Home"}], "", {} + calls["n"] += 1 + if calls["n"] == 1: + return 401, None, "", {} + return 200, {"ok": True}, "", {} + + api.request_raw = fake_raw + result = run_async(api.request_json("GET", "/some/data.json")) + assert result == {"ok": True} + assert api.eauth_token == "newtok" + + +def test_request_json_login_wall(): + """An HTML body on a JSON endpoint is treated as auth failure, not a crash.""" + api = MockEnphaseAPI() + api.login_cooldown_until = datetime.now(timezone.utc) + timedelta(hours=1) # block re-login + api.set_http_response("/some/data.json", 200, None, text_data="login") + result = run_async(api.request_json("GET", "/some/data.json")) + assert result is None + + +def test_battery_config_variant_fallback(): + """A BatteryConfig auth failure switches header variant before re-logging in.""" + api = MockEnphaseAPI() + api.eauth_token = "tok" + calls = {"n": 0} + + async def fake_raw(method, url, headers=None, data=None, json_body=None, params=None): + """Reject the primary variant once, accept the cookie variant.""" + calls["n"] += 1 + if "requestid" in (headers or {}): + return 401, None, "", {} + return 200, {"ok": True}, "", {} + + api.request_raw = fake_raw + result = run_async(api.request_json("GET", "/service/batteryConfig/api/v1/profile/12345", family="battery_config")) + assert result == {"ok": True} + assert api.battery_config_variant == "cookie_eauth" + assert calls["n"] == 2 + + +BATTERY_STATUS_PAYLOAD = { + "current_charge": 55, + "available_energy": 5.5, + "max_capacity": 10.0, + "max_power": 3.84, + "storages": [ + {"id": 1, "serial_num": "B1", "current_charge": 50, "available_energy": 2.5, "max_capacity": 5.0, "status": "normal", "last_report": 1783548194}, + {"id": 2, "serial_num": "B2", "current_charge": 60, "available_energy": 3.0, "max_capacity": 5.0, "status": "normal", "last_report": 1783549411}, + ], +} + + +def test_get_battery_status(): + """battery_status parses site totals and capacity-weighted SOC.""" + api = MockEnphaseAPI() + api.set_http_response("/pv/settings/12345/battery_status.json", 200, BATTERY_STATUS_PAYLOAD) + run_async(api.get_battery_status("12345")) + status = api.battery_status["12345"] + assert status["max_capacity"] == 10.0 + assert status["soc_percent"] == 55.0 # (2.5+3.0)/(5+5)*100 + assert status["max_power_kw"] == 3.84 + assert status["last_report"] == 1783549411 # most recent per-battery report time + + +def test_inverter_time_and_system_status_sensors(): + """publish_data publishes system_status from siteStatus and inverter_time from the last report.""" + api = MockEnphaseAPI() + site_id = "12345" + # An offline system: batteries last reported days ago; siteStatus reports a comm fault. + api.battery_status[site_id] = {"soc_percent": 0.0, "available_energy": 0.0, "max_capacity": 20.0, "max_power_kw": 6.33, "status": "", "last_report": 1783549411, "batteries": []} + api.profile[site_id] = {"profile": "self-consumption", "reserve": 30} + api.today[site_id] = {"totals": {"production": 0}, "arrays": {}, "start_time": None, "interval_length": 900, "site_status": "comm", "status_severity": "warning", "status_desc": "Your gateway has not reported since Jul 8"} + api.latest_power[site_id] = {"watts": 100.0, "time": 1760000000} + run_async(api.publish_data(site_id)) + items = api.dashboard_items + + status_item = items["sensor.predbat_enphase_12345_system_status"] + assert status_item["state"] == "comm" + assert status_item["attributes"]["severity"] == "warning" + assert "gateway" in status_item["attributes"]["description"] + + # inverter_time = the battery last_report (1783549411) formatted with the clock format, in local tz. + from enphase import ENPHASE_CLOCK_FORMAT + + expected = datetime.fromtimestamp(1783549411, api.local_tz).strftime(ENPHASE_CLOCK_FORMAT) + assert items["sensor.predbat_enphase_12345_inverter_time"]["state"] == expected + + +def test_log_api_call_suppresses_html(): + """The verbose logger must not dump a full HTML login/marketing page - just a short marker.""" + api = MockEnphaseAPI() + captured = [] + api.log = lambda message: captured.append(message) + api.debug_api = True + html = "" + ("x" * 50000) + "" + api._log_api_call("GET", "/service/batteryConfig/api/v1/profile/12345", None, 200, None, html) + assert len(captured) == 1 + assert "" not in captured[0] + assert "HTML page" in captured[0] + assert "xxxxx" not in captured[0] + + +def test_safe_float_int_handle_na(): + """safe_float/safe_int coerce Enphase 'N/A'/blank/None strings to the default without raising.""" + from enphase import safe_float, safe_int + + assert safe_float("N/A") == 0.0 + assert safe_float("N/A", None) is None + assert safe_float(None) == 0.0 + assert safe_float("") == 0.0 + assert safe_float("3.5") == 3.5 + assert safe_float(4) == 4.0 + # Percentage strings (e.g. battery current_charge is "0%"/"50%") + assert safe_float("0%") == 0.0 + assert safe_float("50%") == 50.0 + assert safe_int("0%") == 0 + assert safe_int("55%") == 55 + assert safe_int("N/A") == 0 + assert safe_int("N/A", None) is None + assert safe_int(None) == 0 + assert safe_int("5.0") == 5 + assert safe_int(7) == 7 + + +def test_get_battery_status_handles_na(): + """A real-world battery_status payload with 'N/A' numeric fields must not crash get_battery_status.""" + api = MockEnphaseAPI() + payload = { + "current_charge": "N/A", + "available_energy": "N/A", + "max_capacity": "N/A", + "max_power": "N/A", + "status": "unknown", + "storages": [{"id": 1, "serial_num": "B1", "current_charge": "N/A", "available_energy": "N/A", "max_capacity": "N/A", "status": "sleeping"}], + } + api.set_http_response("/pv/settings/12345/battery_status.json", 200, payload) + result = run_async(api.get_battery_status("12345")) + assert result is not None + status = api.battery_status["12345"] + # Total capacity is 0 (all N/A -> 0), so SOC falls back to site current_charge (also N/A -> 0) + assert status["soc_percent"] == 0.0 + assert status["max_capacity"] == 0.0 + assert status["max_power_kw"] == 0.0 + + +def test_reads_handle_na_values(): + """latest_power, profile and battery_settings tolerate 'N/A' numeric fields from the live API.""" + api = MockEnphaseAPI() + api.set_http_response("/app-api/12345/get_latest_power", 200, {"latest_power": {"value": "N/A", "time": "N/A"}}) + run_async(api.get_latest_power("12345")) + assert api.latest_power["12345"]["watts"] == 0.0 + assert api.latest_power["12345"]["time"] is None + + api.set_http_response("/service/batteryConfig/api/v1/profile/12345", 200, {"profile": "self-consumption", "batteryBackupPercentage": "N/A"}) + run_async(api.get_profile("12345")) + assert api.profile["12345"]["reserve"] == 0 + + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {"chargeFromGrid": True, "veryLowSoc": "N/A", "veryLowSocMin": "N/A", "veryLowSocMax": "N/A"}) + run_async(api.get_battery_settings("12345")) + assert api.battery_settings["12345"]["veryLowSocMin"] is None + # Publishing must not crash when veryLowSocMin is None (reserve_min falls back to 5) + api.battery_status["12345"] = {"soc_percent": 55.0, "available_energy": 5.5, "max_capacity": 10.0, "max_power_kw": 3.84, "status": "normal", "batteries": []} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.today["12345"] = {"totals": {"production": 1000}, "arrays": {}, "start_time": None, "interval_length": 900} + api.latest_power["12345"] = {"watts": 100.0, "time": 1760000000} + run_async(api.publish_data("12345")) + assert api.dashboard_items["sensor.predbat_enphase_12345_battery_reserve_min"]["state"] == 5 + + +def test_reads_nested_data_shape(): + """profile and batterySettings responses wrap their fields in a 'data' object - parse it.""" + api = MockEnphaseAPI() + # Real profile response shape from a battery account + api.set_http_response( + "/service/batteryConfig/api/v1/profile/12345", + 200, + {"type": "profile-details", "data": {"profile": "self-consumption", "batteryBackupPercentage": 30, "batteryBackupPercentageMin": 5, "batteryBackupPercentageMax": 100}}, + ) + run_async(api.get_profile("12345")) + assert api.profile["12345"]["profile"] == "self-consumption" + assert api.profile["12345"]["reserve"] == 30 + + api.set_http_response( + "/service/batteryConfig/api/v1/batterySettings/12345", + 200, + {"type": "battery-details", "data": {"chargeFromGrid": True, "veryLowSoc": 5, "veryLowSocMin": 5, "veryLowSocMax": 25}}, + ) + run_async(api.get_battery_settings("12345")) + settings = api.battery_settings["12345"] + assert settings["chargeFromGrid"] is True + assert settings["veryLowSocMin"] == 5 + assert settings["veryLowSocMax"] == 25 + + +def test_get_site_settings(): + """siteSettings parses the nested 'data' capability flags.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.set_http_response( + "/service/batteryConfig/api/v1/siteSettings/12345", + 200, + {"type": "site-settings", "data": {"hasEncharge": True, "hasAcb": False, "showChargeFromGrid": True, "isEnsemble": True, "countryCode": "GB"}}, + ) + run_async(api.get_site_settings("12345")) + flags = api.site_settings["12345"] + assert flags["hasEncharge"] is True + assert flags["showChargeFromGrid"] is True + assert flags["countryCode"] == "GB" + + +def test_set_reserve_writes_profile_put(): + """set_reserve PUTs the profile with the new batteryBackupPercentage, preserving the profile name.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.profile["12345"] = {"profile": "cost_savings", "reserve": 30} + api.set_http_response("/service/batteryConfig/api/v1/profile/12345", 200, {}) + run_async(api.set_reserve("12345", 25)) + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"].endswith("/profile/12345")] + assert len(puts) == 1 + body = puts[0]["json"] + assert body["batteryBackupPercentage"] == 25 + assert body["profile"] == "cost_savings" # existing profile preserved + + +def test_request_json_absorbs_xsrf_token(): + """A successful BatteryConfig response refreshes the XSRF token AND puts it in the cookie header. + + BatteryConfig writes need it as a double-submit: the X-XSRF-Token header (from self.xsrf_token) + and the XSRF-TOKEN cookie (in the Cookie header) must both be present and match. + """ + api = MockEnphaseAPI() + api.eauth_token = "tok" + api.battery_config_variant = "cookie_eauth" + + async def raw_with_xsrf(method, url, headers=None, data=None, json_body=None, params=None): + """Return a 200 with a fresh XSRF token in the cookie dict (as request_raw folds the header).""" + return 200, {"ok": True}, "", {"XSRF-TOKEN": "fresh-token-123"} + + api.request_raw = raw_with_xsrf + run_async(api.request_json("GET", "/service/batteryConfig/api/v1/siteSettings/12345", family="battery_config")) + assert api.xsrf_token == "fresh-token-123" + assert "XSRF-TOKEN=fresh-token-123" in api.cookie_header # cookie side of the double-submit + write_headers = api.get_headers("battery_config", write=True) + assert write_headers["X-XSRF-Token"] == "fresh-token-123" # header side + assert "XSRF-TOKEN=fresh-token-123" in write_headers["Cookie"] + + +def test_absorb_cookies_bp_xsrf_name(): + """The XSRF token cookie is captured whatever its exact name (e.g. BP-XSRF-Token).""" + api = MockEnphaseAPI() + api._absorb_cookies({"BP-XSRF-Token": "bp-token-9"}) + assert api.xsrf_token == "bp-token-9" + + +def test_login_wall_does_not_corrupt_session(): + """An HTML login-wall response must NOT merge its anonymous cookies into our live session.""" + api = MockEnphaseAPI() + api.eauth_token = "tok" + api.cookie_header = "_enlighten_4_session=good; e-auth=x" + api.battery_config_variant = "cookie_eauth" # already on fallback, so no variant switch + api.login_cooldown_until = datetime.now(timezone.utc) + timedelta(hours=1) # block re-login + + async def raw_login_wall(method, url, headers=None, data=None, json_body=None, params=None): + """Return an HTML login wall carrying a fresh anonymous session cookie.""" + return 200, None, "please sign in", {"_enlighten_4_session": "ANONYMOUS", "XSRF-TOKEN": "wall"} + + api.request_raw = raw_login_wall + result = run_async(api.request_json("GET", "/service/batteryConfig/api/v1/profile/12345", family="battery_config")) + assert result is None # login wall is treated as failure + assert api.cookie_header == "_enlighten_4_session=good; e-auth=x" # session cookie NOT clobbered + assert api.xsrf_token != "wall" # no XSRF captured from a failed/login-wall response + + +def test_get_battery_status_percent_soc(): + """Site current_charge like '0%' must parse; capacity-weighted SOC uses storages.""" + api = MockEnphaseAPI() + payload = { + "current_charge": "50%", + "available_energy": 10.0, + "max_capacity": 20.0, + "max_power": 6.33, + "storages": [], # no per-battery breakdown -> fall back to current_charge + } + api.set_http_response("/pv/settings/12345/battery_status.json", 200, payload) + run_async(api.get_battery_status("12345")) + assert api.battery_status["12345"]["soc_percent"] == 50.0 # "50%" fallback parsed + assert api.battery_status["12345"]["max_power_kw"] == 6.33 + + +def test_today_channel_kwh(): + """today_channel_kwh reads today's per-channel total (Wh) and converts to kWh, 0.0 if absent.""" + from enphase import today_channel_kwh + + today = {"totals": {"production": 45287, "consumption": 12000}} + assert today_channel_kwh(today, "production") == 45.287 # 45287 Wh -> 45.287 kWh + assert today_channel_kwh(today, "consumption") == 12.0 + assert today_channel_kwh(today, "import") == 0.0 # channel absent -> 0 + assert today_channel_kwh({"totals": {"production": "N/A"}}, "production") == 0.0 + assert today_channel_kwh({}, "production") == 0.0 + # Battery charge/discharge/export have no single total - summed from source_dest flow components. + flows = {"totals": {"solar_battery": 3000, "grid_battery": 1000, "battery_home": 2500, "battery_grid": 500, "solar_grid": 4000}} + assert today_channel_kwh(flows, "charge") == 4.0 # solar_battery + grid_battery = 4000 Wh + assert today_channel_kwh(flows, "discharge") == 3.0 # battery_home + battery_grid = 3000 Wh + assert today_channel_kwh(flows, "export") == 4.5 # solar_grid + battery_grid = 4500 Wh + # A direct total key wins over the flow sum when present. + assert today_channel_kwh({"totals": {"charge": 9000, "solar_battery": 1000}}, "charge") == 9.0 + + +def test_interval_power(): + """interval_power converts the most recent completed 15-minute Wh bucket into watts.""" + from enphase import interval_power + + # 96 fifteen-minute buckets from midnight; interval_length 900s. now = start + 82.5 intervals. + start = 1783724400 + interval = 900 + values = [0] * 96 + values[80] = 199 # 199 Wh in the 15-min bucket -> 199 / 0.25h = 796 W + values[81] = 103 + now_ts = start + int(81.5 * interval) # current interval index 81 -> last completed = 80 + assert interval_power(values, start, interval, now_ts) == 796.0 + # Missing/empty data -> 0 + assert interval_power([], start, interval, now_ts) == 0.0 + assert interval_power(values, None, interval, now_ts) == 0.0 + assert interval_power(values, start, 0, now_ts) == 0.0 + + +def test_get_schedules_parses_families(): + """Schedules read stores cfg/dtg/rbd entries (real detail shape with scheduleId) and dtg support.""" + api = MockEnphaseAPI() + # Real detail shape from a battery account: scheduleId (not id), scheduleStatus per family. + payload = { + "type": "BATTERY_SCHEDULES_CONFIG", + "cfg": {"scheduleStatus": "active", "count": 1, "details": [{"scheduleId": "2e2e08a8-b3b7", "startTime": "01:10", "endTime": "05:29", "limit": 100, "scheduleType": "CFG", "days": [1, 2, 3, 4, 5, 6, 7], "isDeleted": False, "isEnabled": True}]}, + "dtg": {"scheduleStatus": "not_supported", "count": 0, "details": []}, + "rbd": {"scheduleStatus": "active", "count": 0, "details": []}, + } + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules", 200, payload) + run_async(api.get_schedules("12345")) + cfg = api.schedules["12345"]["cfg"] + assert cfg["id"] == "2e2e08a8-b3b7" # sourced from scheduleId + assert cfg["startTime"] == "01:10" and cfg["endTime"] == "05:29" + assert cfg["limit"] == 100 and cfg["enabled"] is True and cfg["supported"] is True + assert cfg["count"] == 1 + assert api.dtg_supported("12345") is False # 'not_supported' status + + +def test_automatic_config(): + """automatic_config points every inverter arg at the published entities.""" + api = MockEnphaseAPI() + api.sites = [{"site_id": "12345", "name": "Home"}] + api.battery_status["12345"] = {"soc_percent": 55.0, "available_energy": 5.5, "max_capacity": 10.0, "max_power_kw": 3.84, "status": "normal", "batteries": []} + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + run_async(api.automatic_config()) + args = api.args_set + assert args["inverter_type"] == ["EnphaseCloud"] + assert args["num_inverters"] == 1 + assert args["soc_percent"] == ["sensor.predbat_enphase_12345_soc_percent"] + assert args["soc_max"] == ["sensor.predbat_enphase_12345_battery_capacity"] + assert args["battery_rate_max"] == ["sensor.predbat_enphase_12345_battery_rate_max"] + assert args["load_today"] == ["sensor.predbat_enphase_12345_load_today"] + assert args["import_today"] == ["sensor.predbat_enphase_12345_import_today"] + assert args["export_today"] == ["sensor.predbat_enphase_12345_export_today"] + assert args["pv_today"] == ["sensor.predbat_enphase_12345_pv_today"] + assert args["charge_start_time"] == ["select.predbat_enphase_12345_battery_schedule_charge_start_time"] + assert args["charge_limit"] == ["number.predbat_enphase_12345_battery_schedule_charge_soc"] + assert args["scheduled_charge_enable"] == ["switch.predbat_enphase_12345_battery_schedule_charge_enable"] + assert args["scheduled_discharge_enable"] == ["switch.predbat_enphase_12345_battery_schedule_export_enable"] + assert args["discharge_start_time"] == ["select.predbat_enphase_12345_battery_schedule_export_start_time"] + assert args["discharge_target_soc"] == ["number.predbat_enphase_12345_battery_schedule_export_soc"] + assert args["reserve"] == ["number.predbat_enphase_12345_battery_schedule_reserve"] + assert args["battery_min_soc"] == ["sensor.predbat_enphase_12345_battery_reserve_min"] + assert args["inverter_time"] == ["sensor.predbat_enphase_12345_inverter_time"] + assert args["schedule_write_button"] == ["switch.predbat_enphase_12345_battery_schedule_charge_write"] + # export_limit is left unset so the user's apps.yaml value (if any) is respected. + assert "export_limit" not in args + + +def test_automatic_config_no_dtg_raises(): + """A site without DTG (export) support must fail to configure - Predbat needs export control.""" + api = MockEnphaseAPI() + api.sites = [{"site_id": "12345", "name": "Home"}] + api.battery_status["12345"] = {"soc_percent": 55.0, "available_energy": 5.5, "max_capacity": 10.0, "max_power_kw": 3.84, "status": "normal", "batteries": []} + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": False}, "rbd": {"supported": True}} + raised = False + try: + run_async(api.automatic_config()) + except ValueError as error: + raised = True + assert "DTG" in str(error) or "export" in str(error).lower() + assert raised + assert "inverter_type" not in api.args_set + + +def test_automatic_config_no_charge_support_raises(): + """A battery site that does not support CFG (charge-from-grid) scheduling must fail to configure.""" + api = MockEnphaseAPI() + api.sites = [{"site_id": "12345", "name": "Home"}] + api.battery_status["12345"] = {"soc_percent": 55.0, "available_energy": 5.5, "max_capacity": 10.0, "max_power_kw": 3.84, "status": "normal", "batteries": []} + # Has a battery, but charge scheduling is not supported + api.schedules["12345"] = {"cfg": {"supported": False}, "dtg": {"supported": False}, "rbd": {"supported": True}} + raised = False + try: + run_async(api.automatic_config()) + except ValueError as error: + raised = True + assert "CFG" in str(error) or "charge" in str(error).lower() + assert raised + assert "inverter_type" not in api.args_set + + +def test_get_schedules_supported_from_status(): + """scheduleStatus 'active' marks a family supported; the real battery-less response is handled.""" + api = MockEnphaseAPI() + # Real response shape for a site (no scheduleSupported / details fields, just status + count) + payload = {"type": "BATTERY_SCHEDULES_CONFIG", "cfg": {"scheduleStatus": "active", "count": 0}, "dtg": {"scheduleStatus": "not_supported", "count": 0}, "rbd": {"scheduleStatus": "active", "count": 0}} + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules", 200, payload) + run_async(api.get_schedules("12345")) + assert api.schedules["12345"]["cfg"]["supported"] is True + assert api.schedules["12345"]["cfg"]["count"] == 0 + assert api.schedules["12345"]["cfg"]["status"] == "active" + assert api.dtg_supported("12345") is False # 'not_supported' status + + +def test_inverter_def_enphase(): + """EnphaseCloud INVERTER_DEF exists with the agreed capability flags.""" + from config import INVERTER_DEF + + idef = INVERTER_DEF["EnphaseCloud"] + assert idef["has_rest_api"] is False + assert idef["has_target_soc"] is True + assert idef["time_button_press"] is True + assert idef["charge_time_entity_is_option"] is True + assert idef["can_span_midnight"] is False + assert idef["target_soc_used_for_discharge"] is True + assert idef["has_fox_inverter_mode"] is False + + +def test_run_first_polls_all_tiers(): + """First run() logs in, fetches every tier and publishes.""" + api = MockEnphaseAPI() + # prime auth short-circuit + api.login_last_success = datetime.now(timezone.utc) + api.eauth_token = "tok" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.set_http_response("/pv/settings/12345/battery_status.json", 200, BATTERY_STATUS_PAYLOAD) + api.set_http_response("/pv/systems/12345/today", 200, {"stats": [{"totals": {"production": 1000}, "production": [1000], "start_time": 1783724400, "interval_length": 900}]}) + api.set_http_response("/app-api/12345/get_latest_power", 200, {"latest_power": {"value": 450, "units": "w", "time": 1760000000}}) + api.set_http_response("/service/batteryConfig/api/v1/profile/12345", 200, {"profile": "self-consumption", "batteryBackupPercentage": 20}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {"chargeFromGrid": True, "veryLowSoc": 10, "veryLowSocMin": 5, "veryLowSocMax": 25}) + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules", 200, {"cfg": {"scheduleSupported": True, "details": []}, "dtg": {"scheduleSupported": True, "details": []}, "rbd": {"scheduleSupported": True, "details": []}}) + result = run_async(api.run(0, True)) + assert result + assert api.battery_status["12345"]["soc_percent"] == 55.0 + assert api.profile["12345"]["reserve"] == 20 + assert api.latest_power["12345"]["watts"] == 450 + + +def test_log_api_call_redacts_token(): + """API-call logging redacts JWT token fields and truncates long bodies, and is gated by debug_api.""" + api = MockEnphaseAPI() + captured = [] + api.log = lambda message: captured.append(message) + + api.debug_api = True + api._log_api_call("GET", "/users/self/token", None, 200, {"token": "secret-jwt-value", "expires_at": 123}, "") + assert len(captured) == 1 + assert "secret-jwt-value" not in captured[0] + assert "***redacted***" in captured[0] + assert "expires_at" in captured[0] + + # When disabled, nothing is logged + captured.clear() + api.debug_api = False + api._log_api_call("GET", "/pv/settings/1/battery_status.json", None, 200, {"x": 1}, "") + assert captured == [] + + +def test_login_dedupes_sites(): + """Duplicate sites in the search response collapse to a single entry (no double-publish).""" + api = MockEnphaseAPI() + jwt = "eyJhbGciOiJIUzI1NiJ9." + _b64({"user_id": "9999", "exp": 4102444800}) + ".sig" + api.set_http_response("/login/login.json", 200, {"success": True, "session_id": "sess1"}) + api.set_http_response("/users/self/token", 200, {"token": jwt}) + # Enlighten returns the same site twice + api.set_http_response("/app-api/search_sites.json", 200, [{"site_id": 2627346, "name": "Home"}, {"site_id": 2627346, "name": "Home"}]) + assert run_async(api.login()) is True + assert len(api.sites) == 1 + assert api.sites[0]["site_id"] == "2627346" + + +def test_run_single_site_publishes_once(): + """run() operates on one active site, so duplicate site entries publish sensors only once.""" + api = MockEnphaseAPI() + api.login_last_success = datetime.now(timezone.utc) + api.eauth_token = "tok" + # Two identical site entries (as an un-deduped cache might hold) + api.sites = [{"site_id": "2627346", "name": "Home"}, {"site_id": "2627346", "name": "Home"}] + api.set_http_response("/pv/settings/2627346/battery_status.json", 200, {"current_charge": "N/A", "storages": []}) + api.set_http_response("/pv/systems/2627346/today", 200, {"stats": [{"totals": {"production": 1000}, "production": [1000], "start_time": 1783724400, "interval_length": 900}]}) + api.set_http_response("/app-api/2627346/get_latest_power", 200, {"latest_power": {"value": 454, "time": 1760000000}}) + api.set_http_response("/service/batteryConfig/api/v1/profile/2627346", 200, {"profile": "self-consumption", "batteryBackupPercentage": 0}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/2627346", 200, {"chargeFromGrid": False}) + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/2627346/schedules", 200, {"cfg": {"scheduleSupported": True, "details": []}, "dtg": {"scheduleSupported": False, "details": []}, "rbd": {"scheduleSupported": True, "details": []}}) + # Count how many times the soc_percent sensor is published in one run cycle + published = [] + real_dashboard_item = api.dashboard_item + + def counting_dashboard_item(entity_id, state, attributes, app=None): + """Record publications of the soc_percent sensor.""" + if entity_id.endswith("_soc_percent"): + published.append(entity_id) + real_dashboard_item(entity_id, state, attributes, app) + + api.dashboard_item = counting_dashboard_item + assert run_async(api.run(0, True)) is True + assert published == ["sensor.predbat_enphase_2627346_soc_percent"] # published exactly once, not twice + + +def test_run_no_battery_returns_false_without_raising(): + """A PV-only site (no controllable battery) must not crash automatic_config - run() returns False.""" + api = MockEnphaseAPI() + api.automatic = True + api.login_last_success = datetime.now(timezone.utc) + api.eauth_token = "tok" + api.sites = [{"site_id": "2627346", "name": "Home"}] + # PV-only: battery_status has no capacity + api.set_http_response("/pv/settings/2627346/battery_status.json", 200, {"current_charge": "N/A", "storages": []}) + api.set_http_response("/pv/systems/2627346/today", 200, {"stats": [{"totals": {"production": 1000}, "production": [1000], "start_time": 1783724400, "interval_length": 900}]}) + api.set_http_response("/app-api/2627346/get_latest_power", 200, {"latest_power": {"value": 454, "time": 1760000000}}) + api.set_http_response("/service/batteryConfig/api/v1/profile/2627346", 200, {"profile": "self-consumption", "batteryBackupPercentage": 0}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/2627346", 200, {"chargeFromGrid": False}) + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/2627346/schedules", 200, {"cfg": {"scheduleSupported": True, "details": []}, "dtg": {"scheduleSupported": False, "details": []}, "rbd": {"scheduleSupported": True, "details": []}}) + # Must return False (not raise), and must not have set inverter_type + assert run_async(api.run(0, True)) is False + assert "inverter_type" not in api.args_set + # Sensors were still published (monitoring works) + assert "sensor.predbat_enphase_2627346_pv_today" in api.dashboard_items + + +def test_get_today(): + """get_today normalises the /today payload into totals (Wh) and intra-day bucket metadata.""" + api = MockEnphaseAPI() + payload = {"stats": [{"totals": {"production": 45287}, "production": [0, 100, 200], "start_time": 1783724400, "interval_length": 900}]} + api.set_http_response("/pv/systems/12345/today", 200, payload) + run_async(api.get_today("12345")) + today = api.today["12345"] + assert today["totals"] == {"production": 45287} + assert today["arrays"]["production"] == [0, 100, 200] + assert today["arrays"]["consumption"] == [] # missing channel -> empty + assert today["interval_length"] == 900 + assert today["start_time"] == 1783724400 + + +def test_publish_data_sensors(): + """publish_data creates the full monitoring sensor set from the /today totals and buckets.""" + api = MockEnphaseAPI() + api.battery_status["12345"] = {"soc_percent": 55.0, "available_energy": 5.5, "max_capacity": 10.0, "max_power_kw": 3.84, "status": "normal", "batteries": []} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.battery_settings["12345"] = {"chargeFromGrid": True, "veryLowSoc": 10, "veryLowSocMin": 5, "veryLowSocMax": 25} + # today.totals are per-channel Wh totals for today; publish converts to kWh. + start = 1783724400 + prod_buckets = [0] * 96 + prod_buckets[80] = 1000 # 1000 Wh in a 15-min bucket -> 4000 W + api.today["12345"] = { + "totals": {"production": 3500, "consumption": 2200, "import": 1000, "export": 400, "charge": 800, "discharge": 600}, + "arrays": {"production": prod_buckets, "import": [], "export": [], "charge": [], "discharge": []}, + "start_time": start, + "interval_length": 900, + } + api.latest_power["12345"] = {"watts": 450.0, "time": 1760000000} + # Freeze "now" so interval_power selects bucket 80 (last completed at index 81 -> 80). + import enphase as enphase_module + + original_datetime = enphase_module.datetime + + class _FixedDatetime(original_datetime): + @classmethod + def now(cls, tz=None): + """Return a fixed time inside interval index 81.""" + return original_datetime.fromtimestamp(start + int(81.5 * 900), tz) + + enphase_module.datetime = _FixedDatetime + try: + run_async(api.publish_data("12345")) + finally: + enphase_module.datetime = original_datetime + items = api.dashboard_items + assert items["sensor.predbat_enphase_12345_soc_percent"]["state"] == 55.0 + assert items["sensor.predbat_enphase_12345_battery_capacity"]["state"] == 10.0 + assert items["sensor.predbat_enphase_12345_battery_rate_max"]["state"] == 3840.0 + assert items["sensor.predbat_enphase_12345_pv_today"]["state"] == 3.5 # 3500 Wh -> 3.5 kWh + assert items["sensor.predbat_enphase_12345_load_today"]["state"] == 2.2 + assert items["sensor.predbat_enphase_12345_import_today"]["state"] == 1.0 + assert items["sensor.predbat_enphase_12345_export_today"]["state"] == 0.4 + assert items["sensor.predbat_enphase_12345_load_power"]["state"] == 450.0 + assert items["sensor.predbat_enphase_12345_battery_reserve_min"]["state"] == 5 + assert items["sensor.predbat_enphase_12345_pv_power"]["state"] == 4000.0 # 1000 Wh / 0.25h + + +def test_sync_local_schedule_from_cloud(): + """Control entities are seeded (once) from the real cloud reserve and schedule windows.""" + api = MockEnphaseAPI() + site_id = "12345" + api.profile[site_id] = {"profile": "self-consumption", "reserve": 30} + api.schedules[site_id] = { + "cfg": {"id": "c1", "startTime": "01:10", "endTime": "05:29", "limit": 100, "enabled": True, "supported": True}, + "dtg": {"id": "d1", "startTime": "23:30", "endTime": "00:10", "limit": 30, "enabled": False, "supported": True}, + "rbd": {"id": None, "startTime": None, "endTime": None, "limit": None, "enabled": False, "supported": True}, + } + api.sync_local_schedule_from_cloud(site_id) + local = api.local_schedule[site_id] + assert local["reserve"] == 30 # matches the cloud batteryBackupPercentage, not the default 0 + assert local["charge"]["start_time"] == "01:10:00" + assert local["charge"]["end_time"] == "05:29:00" + assert local["charge"]["soc"] == 100 + assert local["charge"]["enable"] is True + assert local["export"]["start_time"] == "23:30:00" + assert local["export"]["enable"] is False + + # Published control entities now reflect the seeded values, not defaults. + api.battery_settings[site_id] = {"veryLowSocMin": 5} + run_async(api.publish_schedule_settings_ha(site_id)) + items = api.dashboard_items + assert items["number.predbat_enphase_12345_battery_schedule_reserve"]["state"] == 30 + assert items["select.predbat_enphase_12345_battery_schedule_charge_start_time"]["state"] == "01:10:00" + assert items["switch.predbat_enphase_12345_battery_schedule_charge_enable"]["state"] == "on" + + # Seeding is one-time: a later cloud change (or a user edit) is not clobbered by re-sync. + local["reserve"] = 45 # simulate a Predbat/user edit + api.profile[site_id]["reserve"] = 99 # cloud changed externally + api.sync_local_schedule_from_cloud(site_id) + assert api.local_schedule[site_id]["reserve"] == 45 # not re-seeded + + +def test_publish_schedule_entities(): + """Both the charge and export window controls are published (a configured inverter has DTG).""" + api = MockEnphaseAPI() + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + api.battery_settings["12345"] = {"veryLowSocMin": 5} + run_async(api.publish_schedule_settings_ha("12345")) + items = api.dashboard_items + assert "select.predbat_enphase_12345_battery_schedule_charge_start_time" in items + assert "number.predbat_enphase_12345_battery_schedule_charge_soc" in items + assert "switch.predbat_enphase_12345_battery_schedule_charge_write" in items + assert "number.predbat_enphase_12345_battery_schedule_reserve" in items + assert "select.predbat_enphase_12345_battery_schedule_export_start_time" in items + assert "number.predbat_enphase_12345_battery_schedule_export_soc" in items + + +def test_event_handlers_update_local_schedule(): + """select/number/switch events mutate the local schedule model.""" + api = MockEnphaseAPI() + api.sites = [{"site_id": "12345", "name": "Home"}] + api.local_schedule["12345"] = { + "reserve": 20, + "charge": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 100, "enable": False}, + "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, + "freeze": {"enable": False}, + } + run_async(api.select_event("select.predbat_enphase_12345_battery_schedule_charge_start_time", "02:30:00")) + assert api.local_schedule["12345"]["charge"]["start_time"] == "02:30:00" + run_async(api.number_event("number.predbat_enphase_12345_battery_schedule_charge_soc", 85)) + assert api.local_schedule["12345"]["charge"]["soc"] == 85 + run_async(api.switch_event("switch.predbat_enphase_12345_battery_schedule_charge_enable", "turn_on")) + assert api.local_schedule["12345"]["charge"]["enable"] is True + + +def test_reserve_number_event_writes_immediately(): + """A reserve number change is written to Enphase at once (like Fox), not deferred to the button.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.profile["12345"] = {"profile": "self-consumption", "reserve": 30} + api.set_http_response("/service/batteryConfig/api/v1/profile/12345", 200, {"message": "success"}) + run_async(api.number_event("number.predbat_enphase_12345_battery_schedule_reserve", 25)) + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"].endswith("/profile/12345")] + assert len(puts) == 1 # written immediately + assert puts[0]["json"]["batteryBackupPercentage"] == 25 + assert api.profile["12345"]["reserve"] == 25 # cached + # A no-op change (already 25) does not write again + api.request_log.clear() + run_async(api.number_event("number.predbat_enphase_12345_battery_schedule_reserve", 25)) + assert [r for r in api.request_log if r["method"] == "PUT"] == [] + + +def test_write_switch_triggers_apply(): + """Turning on the write switch calls apply_battery_schedule for the site.""" + api = MockEnphaseAPI() + api.sites = [{"site_id": "12345", "name": "Home"}] + api.local_schedule["12345"] = {"reserve": 20, "charge": {"start_time": "02:00:00", "end_time": "05:00:00", "soc": 90, "enable": True}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, "freeze": {"enable": False}} + applied = [] + + async def fake_apply(site_id): + """Record the applied site.""" + applied.append(site_id) + + api.apply_battery_schedule = fake_apply + run_async(api.switch_event("switch.predbat_enphase_12345_battery_schedule_charge_write", "turn_on")) + assert applied == ["12345"] + + +def test_schedules_equal(): + """schedules_equal compares window, limit and enable state.""" + from enphase import schedules_equal + + cloud = {"id": "u1", "startTime": "02:00", "endTime": "05:00", "limit": 90, "enabled": True} + assert schedules_equal(cloud, "02:00", "05:00", 90, True) + assert not schedules_equal(cloud, "02:00", "05:30", 90, True) + assert not schedules_equal(cloud, "02:00", "05:00", 80, True) + assert not schedules_equal(cloud, "02:00", "05:00", 90, False) + assert not schedules_equal(None, "02:00", "05:00", 90, True) + + +def test_schedules_equal_none_limit(): + """schedules_equal must not crash when the cloud entry has a present-but-None limit key.""" + from enphase import schedules_equal + + cloud = {"id": "u1", "startTime": "02:00", "endTime": "05:00", "limit": None, "enabled": True} + # We want a specific limit but the cloud entry has none - not equal, no crash. + assert not schedules_equal(cloud, "02:00", "05:00", 90, True) + # We don't require a limit at all and windows match - equal. + assert schedules_equal(cloud, "02:00", "05:00", None, True) + + +def test_apply_charge_schedule_creates(): + """apply writes a CFG schedule via POST when none exists, enabling charge-from-grid first.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.battery_settings["12345"] = {"chargeFromGrid": False, "veryLowSocMin": 5} + api.local_schedule["12345"] = {"reserve": 20, "charge": {"start_time": "02:00:00", "end_time": "05:00:00", "soc": 90, "enable": True}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, "freeze": {"enable": False}} + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/acceptDisclaimer/12345", 200, {}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules", 200, {"cfg": {"scheduleSupported": True, "details": []}, "dtg": {"scheduleSupported": True, "details": []}, "rbd": {"scheduleSupported": True, "details": []}}) + run_async(api.apply_battery_schedule("12345")) + posts = [r for r in api.request_log if r["method"] == "POST" and r["path"].endswith("/schedules")] + assert len(posts) == 1 + body = posts[0]["json"] + assert body["scheduleType"] == "CFG" and body["startTime"] == "02:00" and body["endTime"] == "05:00" and body["limit"] == 90 and body["isEnabled"] is True + disclaimers = [r for r in api.request_log if "acceptDisclaimer" in r["path"]] + assert len(disclaimers) == 1 + + +def _apply_export_case(export_soc): + """Run apply with a given export target SOC and return the schedule POST scheduleTypes.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.battery_settings["12345"] = {"chargeFromGrid": True, "veryLowSocMin": 5} + api.local_schedule["12345"] = { + "reserve": 20, + "charge": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 100, "enable": False}, + "export": {"start_time": "23:00:00", "end_time": "23:30:00", "soc": export_soc, "enable": True}, + } + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules", 200, {"cfg": {"scheduleSupported": True, "details": []}, "dtg": {"scheduleSupported": True, "details": []}, "rbd": {"scheduleSupported": True, "details": []}}) + run_async(api.apply_battery_schedule("12345")) + posts = [r for r in api.request_log if r["method"] == "POST" and r["path"].endswith("/schedules")] + return posts + + +def test_apply_export_target_selects_dtg_rbd_or_none(): + """Export target drives the family: <99 -> DTG (real export), ==99 -> RBD (freeze), ==100 -> none.""" + # Freeze export: target exactly 99 -> restrict battery discharge (RBD), no DTG. + freeze_posts = _apply_export_case(99) + freeze_types = [p["json"]["scheduleType"] for p in freeze_posts] + assert "RBD" in freeze_types and "DTG" not in freeze_types + rbd = next(p["json"] for p in freeze_posts if p["json"]["scheduleType"] == "RBD") + assert rbd["startTime"] == "23:00" and rbd["endTime"] == "23:30" and rbd["isEnabled"] is True + + # Real export: target below 99 -> DTG to that floor, no RBD. + export_posts = _apply_export_case(30) + export_types = [p["json"]["scheduleType"] for p in export_posts] + assert "DTG" in export_types and "RBD" not in export_types + dtg = next(p["json"] for p in export_posts if p["json"]["scheduleType"] == "DTG") + assert dtg["limit"] == 30 and dtg["isEnabled"] is True + + # Target of 100 is the same as disabled: neither DTG nor RBD is written. + none_posts = _apply_export_case(100) + none_types = [p["json"]["scheduleType"] for p in none_posts] + assert "DTG" not in none_types and "RBD" not in none_types + + +def test_apply_export_dtg_limit_clamped_to_reserve(): + """An export target below the reserve is clamped up to the reserve (Enphase won't go below it).""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 30} + api.battery_settings["12345"] = {"chargeFromGrid": True, "veryLowSocMin": 5} + api.local_schedule["12345"] = { + "reserve": 30, + "charge": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 100, "enable": False}, + "export": {"start_time": "16:00:00", "end_time": "19:00:00", "soc": 10, "enable": True}, # target 10 < reserve 30 + } + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules", 200, {"cfg": {"scheduleSupported": True, "details": []}, "dtg": {"scheduleSupported": True, "details": []}, "rbd": {"scheduleSupported": True, "details": []}}) + run_async(api.apply_battery_schedule("12345")) + dtg = next(r["json"] for r in api.request_log if r["method"] == "POST" and r["path"].endswith("/schedules") and r["json"]["scheduleType"] == "DTG") + assert dtg["limit"] == 30 # clamped up to the reserve, not the requested 10 + assert dtg["isEnabled"] is True + + +def test_apply_updates_existing_by_id(): + """apply uses PUT /schedules/ when the family already has a schedule.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.schedules["12345"] = {"cfg": {"supported": True, "id": "u1", "startTime": "01:00", "endTime": "04:00", "limit": 80, "enabled": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.battery_settings["12345"] = {"chargeFromGrid": True, "veryLowSocMin": 5} + api.local_schedule["12345"] = {"reserve": 20, "charge": {"start_time": "02:00:00", "end_time": "05:00:00", "soc": 90, "enable": True}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, "freeze": {"enable": False}} + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules/u1", 200, {}) + api.set_http_response( + "/service/batteryConfig/api/v1/battery/sites/12345/schedules", + 200, + {"cfg": {"scheduleSupported": True, "details": [{"id": "u1", "startTime": "02:00", "endTime": "05:00", "limit": 90, "isEnabled": True}]}, "dtg": {"scheduleSupported": True, "details": []}, "rbd": {"scheduleSupported": True, "details": []}}, + ) + run_async(api.apply_battery_schedule("12345")) + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"].endswith("/schedules/u1")] + assert len(puts) == 1 + # On success the cached cloud copy is optimistically updated to the written state (no re-read on update). + cfg = api.schedules["12345"]["cfg"] + assert cfg["startTime"] == "02:00" and cfg["endTime"] == "05:00" and cfg["limit"] == 90 and cfg["enabled"] is True + assert cfg["id"] == "u1" # id preserved + + +def test_apply_caches_write_no_rewrite(): + """After a successful update, a second apply with the same desired state issues no write (cache hit).""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.schedules["12345"] = {"cfg": {"supported": True, "id": "u1", "startTime": "01:00", "endTime": "04:00", "limit": 80, "enabled": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.battery_settings["12345"] = {"chargeFromGrid": True, "veryLowSocMin": 5} + api.local_schedule["12345"] = {"reserve": 20, "charge": {"start_time": "02:00:00", "end_time": "05:00:00", "soc": 90, "enable": True}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}} + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules/u1", 200, {}) + run_async(api.apply_battery_schedule("12345")) + run_async(api.apply_battery_schedule("12345")) # second apply, unchanged desired state + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"].endswith("/schedules/u1")] + assert len(puts) == 1 # written once, not re-written (optimistic cache prevents churn) + + +def test_set_reserve_caches_written_value(): + """set_reserve optimistically caches the written reserve on success (no confirm re-read needed).""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.profile["12345"] = {"profile": "self-consumption", "reserve": 30} + api.set_http_response("/service/batteryConfig/api/v1/profile/12345", 200, {"message": "success"}) + run_async(api.set_reserve("12345", 25)) + assert api.profile["12345"]["reserve"] == 25 + + +def test_apply_no_change_no_write(): + """apply issues no schedule writes when cloud already matches the local schedule.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.schedules["12345"] = {"cfg": {"supported": True, "id": "u1", "startTime": "02:00", "endTime": "05:00", "limit": 90, "enabled": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.battery_settings["12345"] = {"chargeFromGrid": True, "veryLowSocMin": 5} + api.local_schedule["12345"] = {"reserve": 20, "charge": {"start_time": "02:00:00", "end_time": "05:00:00", "soc": 90, "enable": True}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, "freeze": {"enable": False}} + run_async(api.apply_battery_schedule("12345")) + writes = [r for r in api.request_log if r["method"] in ("POST", "PUT")] + assert writes == [] + + +def run_enphase_api_tests(my_predbat): + """Run all Enphase API tests, returning 0 on success.""" + test_initialize_defaults() + test_needs_refresh() + test_is_alive() + test_login_success() + test_is_too_many_sessions() + test_login_happy_path_with_session_text() + test_login_too_many_sessions() + test_login_mfa_rejected() + test_safe_float_int_handle_na() + test_get_battery_status_handles_na() + test_reads_handle_na_values() + test_log_api_call_redacts_token() + test_login_dedupes_sites() + test_run_single_site_publishes_once() + test_run_no_battery_returns_false_without_raising() + test_login_transient_rejection_not_fatal() + test_login_guard_rails() + test_login_reuse_window() + test_get_headers_site() + test_get_headers_battery_config() + test_request_json_success() + test_request_json_401_relogin() + test_request_json_login_wall() + test_battery_config_variant_fallback() + test_get_battery_status() + test_inverter_time_and_system_status_sensors() + test_log_api_call_suppresses_html() + test_reads_nested_data_shape() + test_get_site_settings() + test_set_reserve_writes_profile_put() + test_request_json_absorbs_xsrf_token() + test_login_wall_does_not_corrupt_session() + test_absorb_cookies_bp_xsrf_name() + test_get_battery_status_percent_soc() + test_today_channel_kwh() + test_interval_power() + test_get_schedules_parses_families() + test_automatic_config() + test_automatic_config_no_dtg_raises() + test_automatic_config_no_charge_support_raises() + test_get_schedules_supported_from_status() + test_inverter_def_enphase() + test_run_first_polls_all_tiers() + test_get_today() + test_publish_data_sensors() + test_sync_local_schedule_from_cloud() + test_publish_schedule_entities() + test_event_handlers_update_local_schedule() + test_reserve_number_event_writes_immediately() + test_write_switch_triggers_apply() + test_schedules_equal() + test_schedules_equal_none_limit() + test_apply_charge_schedule_creates() + test_apply_export_target_selects_dtg_rbd_or_none() + test_apply_export_dtg_limit_clamped_to_reserve() + test_apply_updates_existing_by_id() + test_apply_caches_write_no_rewrite() + test_set_reserve_caches_written_value() + test_apply_no_change_no_write() + print("**** Enphase API tests passed ****") + return 0 diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index c011aa9b3..377a1941b 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -105,6 +105,7 @@ from tests.test_add_now_to_octopus_slot import test_add_now_to_octopus_slot from tests.test_dynamic_load import test_dynamic_load_car_slot_cancellation from tests.test_fox_api import run_fox_api_tests +from tests.test_enphase_api import run_enphase_api_tests from tests.test_solcast import run_solcast_tests from tests.test_open_meteo import run_open_meteo_tests from tests.test_rate_add_io_slots import run_rate_add_io_slots_tests @@ -281,6 +282,7 @@ def main(): ("saving_session_auto_join_toggle", test_saving_session_auto_join_toggle, "Saving session auto-join toggle test (issue #4120)", False), ("alert_feed", test_alert_feed, "Alert feed tests", False), ("fox_api", run_fox_api_tests, "Fox API tests", False), + ("enphase_api", run_enphase_api_tests, "Enphase API tests", False), ("solcast", run_solcast_tests, "Solcast API tests", False), ("open_meteo", run_open_meteo_tests, "Open-Meteo solar forecast provider tests", False), ("solax", run_solax_tests, "SolaX API tests", False), diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 002fee314..b83b3a73f 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -186,6 +186,11 @@ pred_bat: forecast_solar_api_key: !secret forecast_solar_api_key # Forecast.solar API key (if using Forecast.solar) ge_cloud_key: !secret ge_cloud_key # GivEnergy API key (if using GE Cloud) fox_key: !secret fox_key # Fox ESS API key and username (if using Fox Cloud) + enphase_username: !secret enphase_username # Enphase Enlighten account e-mail (if using Enphase Cloud) + enphase_password: !secret enphase_password # Enphase Enlighten account password (if using Enphase Cloud) + enphase_site_id: !secret enphase_site_id # Enphase Enlighten site id, optional (if using Enphase Cloud) + enphase_automatic: True # Automatically configure Predbat inverter settings (if using Enphase Cloud) + enphase_automatic_ignore_pv: False # Skip PV sensors during automatic configuration (if using Enphase Cloud) axle_api_key: !secret axle_api_key # Axle API key (if using Axle VPP) kraken_key: !secret kraken_key # Kraken API key (if using Kraken component) kraken_password: !secret kraken_password # Kraken password (if using Kraken component) @@ -770,6 +775,7 @@ The number of inverters you have. If you increase this above 1 you must provide inverter_type defaults to 'GE' (GivEnergy) if not set in `apps.yaml`, or should be set to one of the inverter types that are already pre-programmed into Predbat: + EnphaseCloud: Enphase Cloud integration (EXPERIMENTAL) FoxCloud: Fox Cloud integration FoxESS: FoxESS via modbus GE: GivEnergy via GivTCP diff --git a/docs/components.md b/docs/components.md index e2f444486..bf97e37eb 100644 --- a/docs/components.md +++ b/docs/components.md @@ -17,6 +17,7 @@ This document provides a comprehensive overview of all Predbat components, their - [Axle Energy VPP (axle)](#axle-energy-vpp-axle) - [Ohme Charger (ohme)](#ohme-charger-ohme) - [Fox ESS API (fox)](#fox-ess-api-fox) + - [Enphase API (enphase)](#enphase-api-enphase) - [Solax Cloud API (Solax)](#solax-cloud-api-solax) - [Solis Cloud API (Solis)](#solis-cloud-api-solis) - [Sigenergy Cloud API (Sigenergy)](#sigenergy-cloud-api-sigenergy) @@ -524,6 +525,78 @@ Integrates with Fox ESS inverters for monitoring and controlling Fox ESS battery --- +### Enphase API (enphase) + +**Can be restarted:** Yes + +#### What it does (enphase) + +Connects Predbat to the Enphase Enlighten cloud for monitoring and battery control of Enphase IQ Battery systems, with no local hardware access required. Predbat logs in through the same web endpoints used by the Enlighten app/web site, publishes monitoring sensors, and can write battery schedules to control charging and discharging. + +#### When to enable (enphase) + +- You have an Enphase IQ Battery system managed through the Enlighten app +- You want cloud-based monitoring and control without any local integration +- Your Enphase account does not have multi-factor authentication (MFA) enabled + +#### Important notes (enphase) + +- **EXPERIMENTAL**: this uses the unofficial Enlighten web-app API - there is no official Enphase API with battery control, and Enphase may change it without notice +- Accounts with multi-factor authentication (MFA) enabled are **not supported** - disable MFA on the Enphase account before use +- Predbat controls the battery by writing Enphase schedules: charge windows become charge-from-grid (CFG) schedules with a target SOC, export windows become discharge-to-grid (DTG) schedules, freeze-export windows use restrict-battery-discharge (RBD) schedules, and the reserve is set through the battery profile. `automatic_config` requires both CFG and DTG support and fails configuration if either is missing +- On a successful write, Predbat optimistically updates its local cache and moves on rather than waiting to re-read the cloud - the periodic schedule/profile re-read (every 30 minutes) corrects the cache later if a write didn't actually land +- Repeated login failures back off automatically to protect the Enphase account from lockout: a 5 minute cooldown after each rejection, rising to a 24 hour suspension after 3 consecutive rejections + +#### Configuration Options (enphase) + +| Option | Type | Required | Default | Config Key | Description | +| ------ | ---- | -------- | ------- | ---------- | ----------- | +| `username` | String | Yes | - | `enphase_username` | Your Enphase Enlighten account e-mail address | +| `password` | String | Yes | - | `enphase_password` | Your Enphase Enlighten account password | +| `site_id` | String | No | First site found | `enphase_site_id` | Restrict Predbat to a single Enlighten site id | +| `automatic` | Boolean | No | false | `enphase_automatic` | Set to `true` to automatically configure Predbat to use the Enphase inverter (no manual apps.yaml sensor updates required) | +| `automatic_ignore_pv` | Boolean | No | false | `enphase_automatic_ignore_pv` | When `automatic` is enabled, set to `true` to prevent Enphase Cloud from overwriting `pv_power` and `pv_today` config | + +#### Published Entities (enphase) + +For each site (`{site_id}` in the entity names), the component creates: + +**Battery Sensors:** + +- Battery SOC (%) +- Battery available energy (kWh) +- Battery capacity (kWh) +- Battery max rate (W) +- Battery status +- Battery profile +- Battery reserve (%) +- Battery reserve minimum (%) + +**Energy Sensors:** + +- PV/load/import/export today (kWh) +- Battery charge/discharge today (kWh) + +**Power Sensors (derived from the most recent completed 15-minute energy bucket):** + +- PV power, grid power, battery power, load power (W) + +**Control Entities (per site):** + +- Battery schedule reserve (number, %) - written to Enphase immediately on change, like Fox +- Charge/export start and end time (select, HH:MM:SS format) +- Charge/export target SOC (number, %) +- Charge/export enable (switch) +- Charge/export write (switch) - triggers the cloud write for that schedule + +A configured site always supports both charge and export control - `automatic_config` requires both +the charge-from-grid (CFG) and discharge-to-grid (DTG) schedule families to be available, so both sets +of controls are always published. There is no separate freeze control: freeze-export is derived +automatically (and written as a restrict-battery-discharge schedule) whenever the export target SOC is +set to exactly 99%; 100% already means export is disabled. + +--- + ### Solis Cloud API (solis) **Can be restarted:** Yes diff --git a/docs/inverter-setup.md b/docs/inverter-setup.md index 0b02e7d57..5d734ead2 100644 --- a/docs/inverter-setup.md +++ b/docs/inverter-setup.md @@ -39,6 +39,7 @@ Once you get everything working please share the configuration as a GitHub issue | [Givenergy with GE Cloud](#givenergy-with-ge-cloud) | [ge_cloud](https://github.com/springfall2008/ge_cloud) | [givenergy_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/givenergy_cloud.yaml) | | [Givenergy with GE Cloud EMS](#givenergy-with-ge-cloud-ems) | [ge_cloud EMS](https://github.com/springfall2008/ge_cloud) | [givenergy_ems.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/givenergy_ems.yaml) | | [Givenergy/Octopus No Home Assistant](#givenergy-octopus-cloud-direct---no-home-assistant) | n/a | [ge_cloud_octopus_standalone.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/ge_cloud_octopus_standalone.yaml) | + | [Enphase Cloud](#enphase-cloud) | Predbat | [enphase_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/enphase_cloud.yaml) | | [Fox](#fox) | [Foxess](https://github.com/nathanmarlor/foxess_modbus/) | [fox.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/fox.yaml) | | [Fox Cloud](#fox-cloud) | Predbat | [fox_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/refs/heads/main/templates/fox_cloud.yaml) | | [Fronius GEN24](#fronius-gen24) | [Fronius](https://www.home-assistant.io/integrations/fronius/) + [fronius-modbus-control](https://github.com/knackerbrot/fronius-modbus-control) | [fronius.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/fronius.yaml) | @@ -187,6 +188,20 @@ This is being worked on by the author of GivTCP, e.g. see [GivTCP issue: unable Launch Predbat with hass.py (from the Predbat-addon repository) either via a Docker or just on a Linux/MAC/WSL command line shell. +## Enphase Cloud + +**Experimental** + +Predbat has a built-in Enphase Cloud integration that logs in to the Enphase Enlighten cloud (the same unofficial web endpoints used by the Enlighten app/web site) for monitoring and battery control of Enphase IQ Battery systems - no local Home Assistant integration is required. + +**Important**: there is no official Enphase API with battery control, so this relies on the unofficial Enlighten web-app API which Enphase may change without notice. Accounts with multi-factor authentication (MFA) enabled are **not supported** - disable MFA on the Enphase account before use. + +- Copy the [enphase_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/enphase_cloud.yaml) template over the top of the supplied `apps.yaml` and set `enphase_username` and `enphase_password` to your Enlighten account credentials. +- Set `enphase_automatic: True` to have Predbat wire up all the sensor and control entities automatically - no manual `apps.yaml` sensor configuration is required. +- Predbat controls the battery by writing Enphase schedules: charge windows become charge-from-grid (CFG) schedules, export windows become discharge-to-grid (DTG) schedules, and freeze-export windows use restrict-battery-discharge (RBD) schedules, with the reserve set through the battery profile. `automatic_config` requires the site to support both CFG and DTG and fails configuration otherwise. Writes are cached optimistically and corrected by the next periodic re-read if they didn't land. + +See the components documentation for details [Components - Enphase API](components.md#enphase-api-enphase) + ## Fox Thanks to the work of @PeterHaban, for this Predbat configuration for Fox ESS inverters which Peter has working with a ECS4100h7 and UK Octopus Cosy. It runs off the work modes and charge/discharge rates. diff --git a/docs/superpowers/plans/2026-07-11-enphase-cloud-integration.md b/docs/superpowers/plans/2026-07-11-enphase-cloud-integration.md new file mode 100644 index 000000000..8abd6d7ac --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-enphase-cloud-integration.md @@ -0,0 +1,1935 @@ +# Enphase Cloud Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an `EnphaseAPI` component to Predbat giving full monitoring and battery control of Enphase IQ Battery systems via the unofficial Enlighten cloud API, so an Enphase site works as a Predbat-controlled inverter (`inverter_type: EnphaseCloud`). + +**Architecture:** New `apps/predbat/enphase.py` subclassing `ComponentBase` (fox.py pattern: `initialize()` + async `run()` polled every 60s, age-tiered refresh, Storage-backed cache). It publishes HA entities via `dashboard_item()`; Predbat's `Inverter` class drives the published control entities, configured by `INVERTER_DEF["EnphaseCloud"]` + `automatic_config()`. Control maps: Self-Use → profile `self-consumption`; Forced Charge → `cfg` schedule (limit = target SOC); Forced Export → `dtg` schedule (limit = SOC floor, capability-gated); freeze → `rbd` schedule; reserve → `batteryBackupPercentage`. + +**Tech Stack:** Python 3, aiohttp, existing Predbat framework (`ComponentBase`, Storage, `dashboard_item`), unit tests via `coverage/unit_test.py` registry. + +**Spec:** `docs/superpowers/specs/2026-07-11-enphase-cloud-integration-design.md` — read it before starting. + +## Global Constraints + +- Work on branch `feature/enphase_cloud` (created in Task 0), NOT `fix/solax_token` or `main`. +- Line length: 256 chars (Black) / 250 (Flake8). Variable naming: `lower_case_with_underscores`. +- **Every function and class needs a docstring** (interrogate enforces 100%). +- British English spellings in comments/docs (CSpell en-gb); add new words (Enphase, Enlighten, Encharge, entrez, enho, enlm) to `.cspell/custom-dictionary-workspace.txt` (keep it alphabetically sorted). +- Tests: run from `coverage/` directory. ALWAYS save test output to a file and grep the file, never pipe to grep. Command pattern: + `cd coverage && ./run_all --test enphase_api > /tmp/enphase_test.txt 2>&1; grep -E "PASS|FAIL|Error" /tmp/enphase_test.txt` +- Storage abstraction only — no direct file access for caching. +- `initialize(**kwargs)` is the constructor hook — do NOT override `__init__` (ComponentBase calls `initialize`). +- All Enphase writes are change-gated: never PUT a value that already matches the cloud state. +- Commit after every task with a `feat:`/`test:`/`docs:` message ending in the Co-Authored-By line from repo convention. + +--- + +### Task 0: Branch and spec commit + +**Files:** + +- Commit: `docs/superpowers/specs/2026-07-11-enphase-cloud-integration-design.md` + +- [ ] **Step 1: Create the feature branch from main and commit the spec** + +```bash +cd /Users/treforsouthwell/predbat/batpred +git stash --include-untracked --quiet || true # only if fix/solax_token has uncommitted work; check git status first +git checkout main && git pull +git checkout -b feature/enphase_cloud +git add docs/superpowers/specs/2026-07-11-enphase-cloud-integration-design.md +git commit -m "docs: add Enphase cloud integration design spec + +Co-Authored-By: Claude Fable 5 " +``` + +Expected: branch `feature/enphase_cloud` exists with the spec committed. If `git status` showed unrelated uncommitted changes on `fix/solax_token`, leave them stashed and note the stash ref in the task report. + +--- + +### Task 1: Component skeleton, registration, config schema + +**Files:** + +- Create: `apps/predbat/enphase.py` +- Modify: `apps/predbat/components.py` (import near line 35; `COMPONENT_LIST` entry after the `"fox"` entry ending at line 242) +- Modify: `apps/predbat/config.py` (APPS_SCHEMA, after `fox_token_hash` at line 2202) +- Create: `apps/predbat/tests/test_enphase_api.py` +- Modify: `apps/predbat/unit_test.py` (import + registry entry — follow the pattern of `run_fox_api_tests` at lines ~107 and ~283) + +**Interfaces:** + +- Produces: `class EnphaseAPI(ComponentBase)` with `initialize(username, password, site_id=None, automatic=False, automatic_ignore_pv=False)`, `is_alive()`, cache helpers `_save_cache(key, data)`, `_load_cache(key)`, `_needs_refresh(key, max_age_minutes)`, `_data_age_minutes(key)`, `load_cached_data()`; module constants `ENPHASE_REFRESH_STATIC=1440`, `ENPHASE_REFRESH_SETTINGS=5`, `ENPHASE_REFRESH_ENERGY=15`, `ENPHASE_REFRESH_POWER=1`, `ENPHASE_CACHE_KEYS`, `ENPHASE_CACHE_VERSION=1`, URL constants. +- Produces (tests): `MockEnphaseAPI(EnphaseAPI)` test double and `run_enphase_api_tests(my_predbat)` entry point that later tasks extend. + +- [ ] **Step 1: Write the failing test file** + +Create `apps/predbat/tests/test_enphase_api.py`: + +```python +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Test Enphase API functions +# ----------------------------------------------------------------------------- + +from datetime import datetime, timedelta, timezone +import pytz +from enphase import EnphaseAPI, ENPHASE_CACHE_KEYS, ENPHASE_CACHE_VERSION, ENPHASE_REFRESH_SETTINGS +from tests.test_infra import run_async + + +class MockBase: + """Mock base object for ComponentBase properties in Enphase API tests.""" + + def __init__(self): + """Initialise MockBase with default config.""" + self.midnight_utc = datetime.now(pytz.utc).replace(hour=0, minute=0, second=0, microsecond=0) + self.config = {} + + def get_arg(self, key, default=None, **kwargs): + """Return config value or default.""" + return self.config.get(key, default) + + +class MockEnphaseAPI(EnphaseAPI): + """Mock EnphaseAPI that avoids ComponentBase construction and real HTTP.""" + + def __init__(self): + """Set up the mock without calling ComponentBase.__init__.""" + self.prefix = "predbat" + self.base = MockBase() + self.local_tz = pytz.timezone("Europe/London") + self.storage = None + self.api_started = False + self.initialize(username="user@example.com", password="secret") + + # Test instrumentation + self.http_responses = {} # path -> dict(status, json_data, text_data) + self.request_log = [] + self.dashboard_items = {} + self.mock_ha_states = {} + self.args_set = {} + + def log(self, message): + """Swallow log output in tests.""" + pass + + def record_api_call(self, *args, **kwargs): + """Swallow telemetry in tests.""" + pass + + def update_success_timestamp(self): + """Swallow health-tracking in tests.""" + pass + + def dashboard_item(self, entity_id, state, attributes, app=None): + """Record dashboard items instead of publishing to HA.""" + self.dashboard_items[entity_id] = {"state": state, "attributes": attributes, "app": app} + + def get_state_wrapper(self, entity_id, default=None): + """Return a mocked HA state.""" + return self.mock_ha_states.get(entity_id, default) + + def set_arg(self, key, value): + """Record args set by automatic_config.""" + self.args_set[key] = value + + def set_http_response(self, path, status=200, json_data=None, text_data=None): + """Prime a canned HTTP response for a URL path.""" + self.http_responses[path] = {"status": status, "json_data": json_data, "text_data": text_data} + + async def request_raw(self, method, url, headers=None, data=None, json_body=None, params=None): + """Return canned responses instead of performing HTTP.""" + path = url.split("enphaseenergy.com", 1)[-1].split("?")[0] + self.request_log.append({"method": method, "path": path, "json": json_body, "data": data}) + response = self.http_responses.get(path, {"status": 404, "json_data": None, "text_data": "not found"}) + return response["status"], response["json_data"], response.get("text_data") or "", {} + + +def test_initialize_defaults(): + """initialize() must set all state fields with correct defaults.""" + api = MockEnphaseAPI() + assert api.username == "user@example.com" + assert api.password == "secret" + assert api.site_id is None + assert api.automatic is False + assert api.sites == [] + assert api.battery_status == {} + assert api.schedules == {} + assert api.data_age == {} + assert api.login_reject_count == 0 + + +def test_needs_refresh(): + """_needs_refresh returns True when data is absent or stale, False when fresh.""" + api = MockEnphaseAPI() + assert api._needs_refresh("battery_status", ENPHASE_REFRESH_SETTINGS) is True + api.data_age["battery_status"] = datetime.now(timezone.utc) + assert api._needs_refresh("battery_status", ENPHASE_REFRESH_SETTINGS) is False + api.data_age["battery_status"] = datetime.now(timezone.utc) - timedelta(minutes=ENPHASE_REFRESH_SETTINGS + 1) + assert api._needs_refresh("battery_status", ENPHASE_REFRESH_SETTINGS) is True + + +def test_is_alive(): + """is_alive requires api_started and at least one discovered site.""" + api = MockEnphaseAPI() + assert not api.is_alive() + api.api_started = True + assert not api.is_alive() + api.sites = [{"site_id": "12345"}] + assert api.is_alive() + + +def run_enphase_api_tests(my_predbat): + """Run all Enphase API tests, returning 0 on success.""" + test_initialize_defaults() + test_needs_refresh() + test_is_alive() + print("**** Enphase API tests passed ****") + return 0 +``` + +- [ ] **Step 2: Register the test and run it to verify failure** + +In `apps/predbat/unit_test.py`, next to the fox imports (~line 107) add: + +```python +from tests.test_enphase_api import run_enphase_api_tests +``` + +and in the registry list next to `("fox_api", ...)` (~line 283) add: + +```python + ("enphase_api", run_enphase_api_tests, "Enphase API tests", False), +``` + +Run: `cd coverage && ./run_all --test enphase_api > /tmp/enphase_t1.txt 2>&1; grep -iE "error|fail|passed" /tmp/enphase_t1.txt` +Expected: FAIL with `ModuleNotFoundError: No module named 'enphase'`. + +- [ ] **Step 3: Create `apps/predbat/enphase.py` skeleton** + +```python +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Enphase Enlighten cloud component +# +# Talks to the unofficial Enphase Enlighten web-app API (the same endpoints the +# Enlighten web/mobile apps use). There is no official API with battery control. +# Reference behaviour derived from https://github.com/barneyonline/ha-enphase-energy +# ----------------------------------------------------------------------------- + +from datetime import datetime, timedelta, timezone +import asyncio +import random + +import aiohttp + +from component_base import ComponentBase +from utils import OPTIONS_TIME_FULL # verify import location: grep "OPTIONS_TIME_FULL" apps/predbat/*.py — fox.py imports it; reuse the same source module + +BASE_URL = "https://enlighten.enphaseenergy.com" +LOGIN_PATH = "/login/login.json" +SELF_TOKEN_PATH = "/users/self/token" +SITE_SEARCH_PATH = "/app-api/search_sites.json" +BATTERY_CONFIG_BASE = "/service/batteryConfig/api/v1" + +# Refresh ages in minutes for each data category +ENPHASE_REFRESH_STATIC = 24 * 60 +ENPHASE_REFRESH_SETTINGS = 5 +ENPHASE_REFRESH_ENERGY = 15 +ENPHASE_REFRESH_POWER = 1 + +ENPHASE_CACHE_KEYS = ["sites", "battery_status", "battery_settings", "profile", "schedules", "site_settings", "lifetime_energy", "latest_power"] +ENPHASE_CACHE_VERSION = 1 + +# Battery profiles accepted by the profile endpoint +PROFILE_SELF_CONSUMPTION = "self-consumption" +PROFILE_COST_SAVINGS = "cost_savings" +PROFILE_BACKUP_ONLY = "backup_only" + +# Schedule families +SCHEDULE_CHARGE = "CFG" # charge from grid +SCHEDULE_EXPORT = "DTG" # discharge to grid +SCHEDULE_FREEZE = "RBD" # restrict battery discharge + +ENPHASE_RETRIES = 5 + +# Browser mimicry - Enlighten rejects non-browser requests with 406/login walls +ENPHASE_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15" +BATTERY_UI_ORIGIN = "https://battery-profile-ui.enphaseenergy.com" + + +class EnphaseAPI(ComponentBase): + """Enphase Enlighten cloud API client component.""" + + def initialize(self, username, password, site_id=None, automatic=False, automatic_ignore_pv=False): + """Initialise the Enphase API component state.""" + self.username = username + self.password = password + self.site_id = str(site_id) if site_id else None + self.automatic = automatic + self.automatic_ignore_pv = automatic_ignore_pv + + # Auth state + self.cookie_header = "" # serialised cookie header for Enlighten + self.eauth_token = None # JWT from /users/self/token + self.manager_token = None # enlighten_manager_token_production cookie JWT + self.xsrf_token = None + self.user_id = None # decoded from JWT, needed by BatteryConfig + self.token_expires_at = None + + # Login guard rails (avoid Enphase account lockout) + self.login_last_success = None # datetime of last successful login + self.login_cooldown_until = None # datetime before which logins are banned + self.login_reject_count = 0 # consecutive rejected logins + + # Cloud data + self.sites = [] + self.battery_status = {} + self.battery_settings = {} + self.profile = {} + self.schedules = {} + self.site_settings = {} + self.lifetime_energy = {} + self.latest_power = {} + + # Local (HA-side) schedule model, written by events, applied on write switch + self.local_schedule = {} + + # Derived power state: previous cumulative kWh samples per channel + self.prev_energy_sample = {} + + # Pending writes awaiting cloud settle confirmation + self.pending_writes = {} + + # BatteryConfig header variant: "primary" (e-auth-token + requestid) or + # "cookie_eauth" fallback (cookie + XHR header) needed on some regions/firmware + self.battery_config_variant = "primary" + + # Age (datetime of last update) per cached data category + self.data_age = {} + self.failures_total = 0 + self.requests_today = 0 + self.last_midnight_utc = None + + def is_alive(self): + """Return True when the component has started and discovered a site.""" + return self.api_started and bool(self.sites) + + def _data_age_minutes(self, key): + """Return the age in minutes of the in-memory data for a cache key, or None if unknown.""" + timestamp = self.data_age.get(key, None) + if timestamp is None: + return None + return (datetime.now(timezone.utc) - timestamp).total_seconds() / 60.0 + + def _needs_refresh(self, key, max_age_minutes): + """Return True if the data for a cache key is missing or older than max_age_minutes.""" + age = self._data_age_minutes(key) + return age is None or age >= max_age_minutes + + async def _save_cache(self, key, data): + """Save data to storage under the enphase module and record its update time.""" + now = datetime.now(timezone.utc) + self.data_age[key] = now + if self.storage: + await self.storage.save("enphase", key, data, format="json", expiry=now + timedelta(days=1)) + + async def _load_cache(self, key): + """Load cached data for a key from storage, recording its age. Returns None if absent.""" + if not self.storage: + return None + data = await self.storage.load("enphase", key) + if data is None: + return None + age = await self.storage.age("enphase", key) + if age is None: + return None + self.data_age[key] = datetime.now(timezone.utc) - timedelta(minutes=age) + return data + + async def load_cached_data(self): + """Restore cached cloud data from storage on startup to avoid re-polling after a reboot.""" + if not self.storage: + return + version = await self.storage.load("enphase", "cache_version") + if version != ENPHASE_CACHE_VERSION: + self.log("Enphase: Cache version changed, forcing full refresh") + await self.storage.save("enphase", "cache_version", ENPHASE_CACHE_VERSION, format="json") + return + for key in ENPHASE_CACHE_KEYS: + data = await self._load_cache(key) + if data is not None: + setattr(self, key, data) + if self.sites: + self.update_success_timestamp() + + async def run(self, seconds, first): + """Main polling body, invoked every 60 seconds by ComponentBase.""" + if first: + await self.load_cached_data() + # Later tasks fill in: login, per-tier refresh, publishing + return True +``` + +Note for the implementer: check where `OPTIONS_TIME_FULL` actually lives (`grep -rn "OPTIONS_TIME_FULL" apps/predbat/fox.py apps/predbat/utils.py apps/predbat/config.py`) and import from the same module fox.py uses. If `record_api_call` in fox is provided by ComponentBase/base, mirror the same call pattern (`grep -n "record_api_call" apps/predbat/fox.py apps/predbat/component_base.py`). + +- [ ] **Step 4: Register the component and config schema** + +`apps/predbat/components.py` — add import next to `from fox import FoxAPI` (line 35): + +```python +from enphase import EnphaseAPI +``` + +Add to `COMPONENT_LIST` directly after the `"fox"` entry (line 242): + +```python + "enphase": { + "class": EnphaseAPI, + "name": "Enphase API", + "event_filter": "predbat_enphase_", + "args": { + "username": { + "required": True, + "config": "enphase_username", + }, + "password": { + "required": True, + "config": "enphase_password", + }, + "site_id": { + "required": False, + "config": "enphase_site_id", + }, + "automatic": { + "required": False, + "default": False, + "config": "enphase_automatic", + }, + "automatic_ignore_pv": { + "required": False, + "default": False, + "config": "enphase_automatic_ignore_pv", + }, + }, + "phase": 1, + }, +``` + +`apps/predbat/config.py` — add to `APPS_SCHEMA` after `"fox_token_hash"` (line 2202): + +```python + "enphase_username": {"type": "string", "empty": False}, + "enphase_password": {"type": "string", "empty": False}, + "enphase_site_id": {"type": "string", "empty": False}, + "enphase_automatic": {"type": "boolean"}, + "enphase_automatic_ignore_pv": {"type": "boolean"}, +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd coverage && ./run_all --test enphase_api > /tmp/enphase_t1.txt 2>&1; grep -iE "error|fail|passed" /tmp/enphase_t1.txt` +Expected: `**** Enphase API tests passed ****`, exit success. + +- [ ] **Step 6: Commit** + +```bash +git add apps/predbat/enphase.py apps/predbat/components.py apps/predbat/config.py apps/predbat/tests/test_enphase_api.py apps/predbat/unit_test.py +git commit -m "feat: add Enphase cloud component skeleton and registration + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: Authentication — login flow, headers, guard rails + +**Files:** + +- Modify: `apps/predbat/enphase.py` +- Test: `apps/predbat/tests/test_enphase_api.py` + +**Interfaces:** + +- Consumes: Task 1 skeleton (`request_raw` overridable seam, auth state fields). +- Produces: + - `async login(self) -> bool` — full login chain; sets `cookie_header`, `eauth_token`, `manager_token`, `xsrf_token`, `user_id`, `token_expires_at`, `sites` (list of dicts with at least `site_id` and `name`); respects guard rails; returns True on success. + - `login_allowed(self) -> bool` — guard-rail check. + - `get_headers(self, family, write=False) -> dict` — `family` in `("site", "battery_config")`. + - `async request_raw(self, method, url, headers=None, data=None, json_body=None, params=None) -> (status, json_data, text, cookies)` — the only method that touches aiohttp; overridden in tests. + - Module function `decode_jwt_claims(token) -> dict` (unverified payload decode for `exp` and user id). + +- [ ] **Step 1: Write failing tests** + +Add to `test_enphase_api.py` (and call them from `run_enphase_api_tests`): + +```python +def test_login_success(): + """Successful login mints tokens, extracts user id and discovers sites.""" + api = MockEnphaseAPI() + # JWT with payload {"user_id": "9999", "exp": 4102444800} (header/sig irrelevant, unverified decode) + jwt = "eyJhbGciOiJIUzI1NiJ9." + _b64({"user_id": "9999", "exp": 4102444800}) + ".sig" + api.set_http_response("/login/login.json", 200, {"success": True, "session_id": "sess1"}) + api.set_http_response("/users/self/token", 200, {"token": jwt, "expires_at": 4102444800}) + api.set_http_response("/app-api/search_sites.json", 200, [{"site_id": 12345, "name": "Home"}]) + assert run_async(api.login()) is True + assert api.eauth_token == jwt + assert api.user_id == "9999" + assert api.sites[0]["site_id"] == "12345" + assert api.login_reject_count == 0 + + +def test_login_mfa_rejected(): + """MFA-required accounts must fail with a fatal error, not retry.""" + api = MockEnphaseAPI() + api.set_http_response("/login/login.json", 200, {"requires_mfa": True}) + assert run_async(api.login()) is False + assert api.login_reject_count == 1 + assert api.login_cooldown_until is not None + + +def test_login_guard_rails(): + """Three consecutive rejections suspend login for 24 hours.""" + api = MockEnphaseAPI() + api.set_http_response("/login/login.json", 401, None) + for _ in range(3): + api.login_cooldown_until = None # expire cooldown to allow next attempt + run_async(api.login()) + assert api.login_reject_count == 3 + remaining = (api.login_cooldown_until - datetime.now(timezone.utc)).total_seconds() + assert remaining > 23 * 3600 + # While suspended, login() refuses without making a request + count = len(api.request_log) + assert run_async(api.login()) is False + assert len(api.request_log) == count + + +def test_login_reuse_window(): + """A login success within 30 seconds is reused, not repeated.""" + api = MockEnphaseAPI() + api.login_last_success = datetime.now(timezone.utc) + api.eauth_token = "tok" + count = len(api.request_log) + assert run_async(api.login()) is True + assert len(api.request_log) == count + + +def test_get_headers_site(): + """Site-family headers carry cookie, tokens and browser mimicry.""" + api = MockEnphaseAPI() + api.cookie_header = "a=b" + api.eauth_token = "tok" + api.xsrf_token = "xs" + headers = api.get_headers("site") + assert headers["Cookie"] == "a=b" + assert headers["e-auth-token"] == "tok" + assert headers["Authorization"] == "Bearer tok" + assert headers["X-CSRF-Token"] == "xs" + assert headers["X-Requested-With"] == "XMLHttpRequest" + assert "Mozilla" in headers["User-Agent"] + + +def test_get_headers_battery_config(): + """BatteryConfig headers use the battery-profile-ui origin, manager token bearer and user id.""" + api = MockEnphaseAPI() + api.eauth_token = "etok" + api.manager_token = "mtok" + api.user_id = "9999" + headers = api.get_headers("battery_config", write=True) + assert headers["Origin"] == "https://battery-profile-ui.enphaseenergy.com" + assert headers["Authorization"] == "Bearer mtok" + assert headers["e-auth-token"] == "etok" + assert headers["Username"] == "9999" + assert "requestid" in headers +``` + +Add helper at top of the test file: + +```python +import base64 +import json as json_module + + +def _b64(payload): + """Base64url-encode a dict as a JWT payload segment without padding.""" + raw = json_module.dumps(payload).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd coverage && ./run_all --test enphase_api > /tmp/enphase_t2.txt 2>&1; grep -iE "error|fail|passed" /tmp/enphase_t2.txt` +Expected: FAIL — `AttributeError` (`login` / `get_headers` not defined). + +- [ ] **Step 3: Implement auth in `enphase.py`** + +```python +def decode_jwt_claims(token): + """Decode the payload segment of a JWT without verifying the signature.""" + import base64 + import json + + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload.encode("ascii"))) + except (IndexError, ValueError): + return {} +``` + +Methods on `EnphaseAPI`: + +```python + LOGIN_REUSE_SECONDS = 30 + LOGIN_COOLDOWN_SECONDS = 300 + LOGIN_SUSPEND_SECONDS = 24 * 3600 + LOGIN_MAX_REJECTS = 3 + + def login_allowed(self): + """Return True when a password login attempt is currently permitted by the guard rails.""" + if self.login_cooldown_until and datetime.now(timezone.utc) < self.login_cooldown_until: + return False + return True + + def _login_rejected(self, reason): + """Record a rejected login and set the appropriate cooldown.""" + self.login_reject_count += 1 + if self.login_reject_count >= self.LOGIN_MAX_REJECTS: + delay = self.LOGIN_SUSPEND_SECONDS + else: + delay = self.LOGIN_COOLDOWN_SECONDS + self.login_cooldown_until = datetime.now(timezone.utc) + timedelta(seconds=delay) + self.log(f"Warn: Enphase: Login rejected ({reason}), cooling down for {delay} seconds (rejection {self.login_reject_count})") + self.fatal_error_occurred(f"Enphase login rejected: {reason}") + + async def login(self): + """Authenticate with Enlighten: password login, token mint, site discovery.""" + # Reuse a very recent successful login (coalesces concurrent 401 refreshes) + if self.login_last_success and (datetime.now(timezone.utc) - self.login_last_success).total_seconds() < self.LOGIN_REUSE_SECONDS and self.eauth_token: + return True + if not self.login_allowed(): + self.log("Warn: Enphase: Login suppressed by cooldown after previous rejections") + return False + + headers = { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "User-Agent": ENPHASE_USER_AGENT, + "Referer": BASE_URL + "/", + } + status, data, text, cookies = await self.request_raw("POST", BASE_URL + LOGIN_PATH, headers=headers, data={"user[email]": self.username, "user[password]": self.password}) + + if status in (401, 403): + self._login_rejected("invalid credentials") + return False + if isinstance(data, dict) and data.get("requires_mfa"): + self._login_rejected("account requires MFA - disable MFA on the Enphase account to use this component") + return False + if isinstance(data, dict) and data.get("isBlocked"): + self._login_rejected("account is blocked") + return False + if status != 200: + # Includes "too many active sessions" responses - treat as rejection + reason = "too many active sessions" if "session" in str(text).lower() else f"http status {status}" + self._login_rejected(reason) + return False + + # Persist cookies from the login (session cookie + manager token JWT) + self._absorb_cookies(cookies) + + # Mint the e-auth/bearer token; Enlighten may rotate the session cookie here + status, token_data, text, cookies = await self.request_raw("GET", BASE_URL + SELF_TOKEN_PATH, headers=self.get_headers("site")) + self._absorb_cookies(cookies) + if status == 200 and isinstance(token_data, dict): + token = token_data.get("token") or token_data.get("auth_token") or token_data.get("access_token") + if token: + self.eauth_token = token + claims = decode_jwt_claims(token) + self.user_id = str(claims.get("user_id") or claims.get("userId") or claims.get("sub") or "") or None + self.token_expires_at = token_data.get("expires_at") or token_data.get("expiresAt") or claims.get("exp") + if not self.eauth_token: + self._login_rejected("no auth token returned") + return False + + # Discover sites + status, sites_data, text, cookies = await self.request_raw("GET", BASE_URL + SITE_SEARCH_PATH, headers=self.get_headers("site"), params={"searchText": "", "favourite": "false"}) + sites = [] + if status == 200: + entries = sites_data if isinstance(sites_data, list) else (sites_data or {}).get("sites", []) + for entry in entries: + sid = str(entry.get("site_id") or entry.get("id") or "") + if sid and (not self.site_id or sid == self.site_id): + sites.append({"site_id": sid, "name": entry.get("name", sid)}) + if sites: + self.sites = sites + await self._save_cache("sites", sites) + + self.login_last_success = datetime.now(timezone.utc) + self.login_reject_count = 0 + self.login_cooldown_until = None + self.log(f"Enphase: Login successful, {len(self.sites)} site(s)") + return True + + def _absorb_cookies(self, cookies): + """Merge response cookies into the serialised cookie header and pick out special tokens.""" + if not cookies: + return + current = {} + for part in self.cookie_header.split("; "): + if "=" in part: + name, value = part.split("=", 1) + current[name] = value + current.update(cookies) + self.cookie_header = "; ".join(f"{k}={v}" for k, v in current.items() if v) + self.manager_token = current.get("enlighten_manager_token_production", self.manager_token) + self.xsrf_token = current.get("XSRF-TOKEN", current.get("BP-XSRF-Token", self.xsrf_token)) + + def get_headers(self, family, write=False): + """Build request headers for an endpoint family ('site' or 'battery_config').""" + import uuid + + if family == "battery_config": + headers = { + "Accept": "application/json, text/plain, */*", + "Origin": BATTERY_UI_ORIGIN, + "Referer": BATTERY_UI_ORIGIN + "/", + "User-Agent": ENPHASE_USER_AGENT, + "e-auth-token": self.eauth_token or "", + } + if self.battery_config_variant == "cookie_eauth": + # Fallback variant needed on some regions/firmware: cookie-backed with XHR marker + headers["X-Requested-With"] = "XMLHttpRequest" + if self.cookie_header: + headers["Cookie"] = self.cookie_header + else: + headers["requestid"] = str(uuid.uuid4()) + bearer = self.manager_token or self.eauth_token + if bearer: + headers["Authorization"] = f"Bearer {bearer}" + if self.user_id: + headers["Username"] = self.user_id + if write: + headers["Content-Type"] = "application/json" + if self.xsrf_token: + headers["X-XSRF-Token"] = self.xsrf_token + return headers + + headers = { + "Accept": "application/json, text/plain, */*", + "User-Agent": ENPHASE_USER_AGENT, + "X-Requested-With": "XMLHttpRequest", + "Referer": BASE_URL + "/", + } + if self.cookie_header: + headers["Cookie"] = self.cookie_header + if self.eauth_token: + headers["Authorization"] = f"Bearer {self.eauth_token}" + headers["e-auth-token"] = self.eauth_token + if self.xsrf_token: + headers["X-CSRF-Token"] = self.xsrf_token + return headers + + async def request_raw(self, method, url, headers=None, data=None, json_body=None, params=None): + """Perform one HTTP request, returning (status, json_or_none, text, cookie_dict). Overridden in tests.""" + async with aiohttp.ClientSession() as session: + async with session.request(method, url, headers=headers, data=data, json=json_body, params=params, timeout=aiohttp.ClientTimeout(total=60)) as response: + text = await response.text() + cookies = {key: morsel.value for key, morsel in response.cookies.items()} + json_data = None + content_type = response.headers.get("Content-Type", "") + if "json" in content_type: + try: + json_data = await response.json(content_type=None) + except ValueError: + json_data = None + return response.status, json_data, text, cookies +``` + +Note: `fatal_error_occurred` comes from ComponentBase (`component_base.py:268`); `MockEnphaseAPI` must stub it — add `def fatal_error_occurred(self, message): pass` (with docstring) to the mock. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd coverage && ./run_all --test enphase_api > /tmp/enphase_t2.txt 2>&1; grep -iE "error|fail|passed" /tmp/enphase_t2.txt` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/predbat/enphase.py apps/predbat/tests/test_enphase_api.py +git commit -m "feat: Enphase Enlighten login flow with lockout guard rails + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: Request helper with retries and 401 re-login + +**Files:** + +- Modify: `apps/predbat/enphase.py` +- Test: `apps/predbat/tests/test_enphase_api.py` + +**Interfaces:** + +- Consumes: `login()`, `get_headers()`, `request_raw()` from Task 2. +- Produces: `async request_json(self, method, path, family="site", json_body=None, data=None, params=None) -> data_or_None`. Behaviour contract: builds `BASE_URL + path`; on 401 performs one `login()` + one retry; detects HTML login walls (text starting `login") + result = run_async(api.request_json("GET", "/some/data.json")) + assert result is None + + +def test_battery_config_variant_fallback(): + """A BatteryConfig auth failure switches header variant before re-logging in.""" + api = MockEnphaseAPI() + api.eauth_token = "tok" + calls = {"n": 0} + + async def fake_raw(method, url, headers=None, data=None, json_body=None, params=None): + """Reject the primary variant once, accept the cookie variant.""" + calls["n"] += 1 + if "requestid" in (headers or {}): + return 401, None, "", {} + return 200, {"ok": True}, "", {} + + api.request_raw = fake_raw + result = run_async(api.request_json("GET", "/service/batteryConfig/api/v1/profile/12345", family="battery_config")) + assert result == {"ok": True} + assert api.battery_config_variant == "cookie_eauth" + assert calls["n"] == 2 +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd coverage && ./run_all --test enphase_api > /tmp/enphase_t3.txt 2>&1; grep -iE "error|fail|passed" /tmp/enphase_t3.txt` +Expected: FAIL — `request_json` not defined. + +- [ ] **Step 3: Implement `request_json`** + +```python + def _is_login_wall(self, json_data, text): + """Return True when a JSON endpoint answered with an HTML login page.""" + if json_data is not None: + return False + stripped = (text or "").lstrip().lower() + return stripped.startswith("= 500: + # Honour Retry-After when present in a headers dict returned via cookies param is not possible; + # sleep with jittered backoff instead + await asyncio.sleep(min(30, (retry + 1) * (2 + random.random() * 3))) + continue + if status != 200: + self.log(f"Warn: Enphase: HTTP {status} on {path}") + self.last_error_status = status + self.failures_total += 1 + return None + self.update_success_timestamp() + return json_data + self.failures_total += 1 + return None +``` + +Note: if `record_api_call` is not available on the mock, it is already stubbed in Task 1's `MockEnphaseAPI`. Verify the real method exists in the codebase (`grep -n "def record_api_call" apps/predbat/*.py`) and match its signature; fox calls `record_api_call("fox", ...)` — copy the exact argument shape fox uses. + +- [ ] **Step 4: Run tests to verify pass, then commit** + +Run: `cd coverage && ./run_all --test enphase_api > /tmp/enphase_t3.txt 2>&1; grep -iE "error|fail|passed" /tmp/enphase_t3.txt` +Expected: PASS. + +```bash +git add apps/predbat/enphase.py apps/predbat/tests/test_enphase_api.py +git commit -m "feat: Enphase request helper with retries and 401 re-login + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: Cloud reads, parsers, and the run() polling loop + +**Files:** + +- Modify: `apps/predbat/enphase.py` +- Test: `apps/predbat/tests/test_enphase_api.py` + +**Interfaces:** + +- Consumes: `request_json()` (Task 3), cache helpers (Task 1). +- Produces: + - `async get_battery_status(site_id)` → stores normalised dict in `self.battery_status[site_id]`: `{"soc_percent": float, "available_energy": float, "max_capacity": float, "max_power_kw": float, "batteries": [...], "status": str, "profile_label": str}` from `GET /pv/settings//battery_status.json` (site fields `current_charge`, `available_energy`, `max_capacity`, `max_power`; per-battery list under `storages`; aggregate SOC = capacity-weighted `available_energy/max_capacity*100`, fallback site `current_charge`). + - `async get_lifetime_energy(site_id)` → stores raw payload; module function `energy_today(payload, channel)` returns today's kWh (last element of the `channel` array; today's index = days between `start_date` and `last_report_date`, defensively the last element). + - `async get_latest_power(site_id)` → stores `{"watts": float, "time": ts}` from `GET /app-api//get_latest_power` (`latest_power.value`, `latest_power.time`; timestamps may be seconds or milliseconds — treat > 10^12 as ms). + - `async get_profile(site_id)` → `GET {BATTERY_CONFIG_BASE}/profile/?source=enho&userId=` (family `battery_config`) → stores `{"profile": str, "reserve": int}` (keys `profile`, `batteryBackupPercentage`). + - `async get_battery_settings(site_id)` → `GET {BATTERY_CONFIG_BASE}/batterySettings/?source=enlm` → stores `chargeFromGrid`, `veryLowSoc`, `veryLowSocMin`, `veryLowSocMax`. + - `async get_schedules(site_id)` → `GET {BATTERY_CONFIG_BASE}/battery/sites//schedules` → stores per-family (`cfg`/`dtg`/`rbd`) dict: `{"id", "startTime", "endTime", "limit", "enabled", "supported"}` — parse each family's `details` list (first entry) plus control flags (`scheduleSupported`, `forceScheduleSupported`). + - `dtg_supported(site_id) -> bool` — from schedules control flags / site settings. + - `run(seconds, first)` extended: ensures login when `eauth_token` is None; refresh tiers — sites daily (`ENPHASE_REFRESH_STATIC`), battery_status/profile/settings/schedules every `ENPHASE_REFRESH_SETTINGS`, lifetime_energy every `ENPHASE_REFRESH_ENERGY`, latest_power every `ENPHASE_REFRESH_POWER`; midnight counter reset; each successful fetch `_save_cache`d. + +- [ ] **Step 1: Write failing tests** — canned payloads through `set_http_response`, e.g.: + +```python +BATTERY_STATUS_PAYLOAD = { + "current_charge": 55, + "available_energy": 5.5, + "max_capacity": 10.0, + "max_power": 3.84, + "storages": [ + {"id": 1, "serial_num": "B1", "current_charge": 50, "available_energy": 2.5, "max_capacity": 5.0, "status": "normal"}, + {"id": 2, "serial_num": "B2", "current_charge": 60, "available_energy": 3.0, "max_capacity": 5.0, "status": "normal"}, + ], +} + + +def test_get_battery_status(): + """battery_status parses site totals and capacity-weighted SOC.""" + api = MockEnphaseAPI() + api.set_http_response("/pv/settings/12345/battery_status.json", 200, BATTERY_STATUS_PAYLOAD) + run_async(api.get_battery_status("12345")) + status = api.battery_status["12345"] + assert status["max_capacity"] == 10.0 + assert status["soc_percent"] == 55.0 # (2.5+3.0)/(5+5)*100 + assert status["max_power_kw"] == 3.84 + + +def test_energy_today(): + """energy_today returns the final (today's) entry of a channel array.""" + payload = {"start_date": "2026-07-09", "last_report_date": "2026-07-11", "production": [10.0, 12.0, 3.5], "consumption": [8.0, 9.0, 2.2]} + from enphase import energy_today + + assert energy_today(payload, "production") == 3.5 + assert energy_today(payload, "consumption") == 2.2 + assert energy_today(payload, "import") == 0.0 # missing channel -> 0 + + +def test_get_schedules_parses_families(): + """Schedules read stores cfg/dtg/rbd entries and dtg support flag.""" + api = MockEnphaseAPI() + payload = { + "cfg": {"scheduleSupported": True, "details": [{"id": "u1", "startTime": "02:00", "endTime": "05:00", "limit": 90, "isEnabled": True}]}, + "dtg": {"scheduleSupported": False, "details": []}, + "rbd": {"scheduleSupported": True, "details": []}, + } + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules", 200, payload) + run_async(api.get_schedules("12345")) + cfg = api.schedules["12345"]["cfg"] + assert cfg["id"] == "u1" and cfg["limit"] == 90 and cfg["enabled"] is True + assert api.dtg_supported("12345") is False + + +def test_run_first_polls_all_tiers(): + """First run() logs in, fetches every tier and publishes.""" + api = MockEnphaseAPI() + # prime auth short-circuit + api.login_last_success = datetime.now(timezone.utc) + api.eauth_token = "tok" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.set_http_response("/pv/settings/12345/battery_status.json", 200, BATTERY_STATUS_PAYLOAD) + api.set_http_response("/pv/systems/12345/lifetime_energy", 200, {"production": [1.0], "consumption": [1.0], "import": [0.5], "export": [0.2], "charge": [0.1], "discharge": [0.1], "start_date": "2026-07-11"}) + api.set_http_response("/app-api/12345/get_latest_power", 200, {"latest_power": {"value": 450, "units": "w", "time": 1760000000}}) + api.set_http_response("/service/batteryConfig/api/v1/profile/12345", 200, {"profile": "self-consumption", "batteryBackupPercentage": 20}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {"chargeFromGrid": True, "veryLowSoc": 10, "veryLowSocMin": 5, "veryLowSocMax": 25}) + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules", 200, {"cfg": {"scheduleSupported": True, "details": []}, "dtg": {"scheduleSupported": True, "details": []}, "rbd": {"scheduleSupported": True, "details": []}}) + result = run_async(api.run(0, True)) + assert result + assert api.battery_status["12345"]["soc_percent"] == 55.0 + assert api.profile["12345"]["reserve"] == 20 + assert api.latest_power["12345"]["watts"] == 450 +``` + +- [ ] **Step 2: Run to verify failure** (same command pattern; expect AttributeError/ImportError). + +- [ ] **Step 3: Implement** the six read methods, `energy_today`, `dtg_supported` and the extended `run()`: + +```python +def energy_today(payload, channel): + """Return today's kWh for a lifetime_energy channel (last array entry), 0.0 if absent.""" + values = (payload or {}).get(channel) or [] + if not values: + return 0.0 + try: + return float(values[-1] or 0.0) + except (TypeError, ValueError): + return 0.0 +``` + +`run()` body after the Task 1 `load_cached_data` block: + +```python + # Midnight counter reset + current_midnight = self.midnight_utc + if self.last_midnight_utc is not None and self.last_midnight_utc != current_midnight: + self.log(f"Enphase: Midnight reset - requests_today: {self.requests_today}") + self.requests_today = 0 + self.last_midnight_utc = current_midnight + + # Ensure we are logged in (guard rails inside login()) + if not self.eauth_token: + if not await self.login(): + return bool(self.sites) # stay alive on cached data if we have it + + if first or self._needs_refresh("sites", ENPHASE_REFRESH_STATIC): + if not await self.login(): + return bool(self.sites) + + for site in self.sites: + site_id = site["site_id"] + if self._needs_refresh("battery_status", ENPHASE_REFRESH_SETTINGS): + await self.get_battery_status(site_id) + await self.get_profile(site_id) + await self.get_battery_settings(site_id) + await self.get_schedules(site_id) + if self._needs_refresh("lifetime_energy", ENPHASE_REFRESH_ENERGY): + await self.get_lifetime_energy(site_id) + if self._needs_refresh("latest_power", ENPHASE_REFRESH_POWER): + await self.get_latest_power(site_id) + await self.publish_data(site_id) # Task 5 + await self.publish_schedule_settings_ha(site_id) # Task 6 (no-op until then: guard with hasattr or add stub now) + return True +``` + +Add stub methods now so run() is complete (filled in Tasks 5/6): + +```python + async def publish_data(self, site_id): + """Publish monitoring sensors for a site (implemented in a later task).""" + pass + + async def publish_schedule_settings_ha(self, site_id): + """Publish schedule control entities for a site (implemented in a later task).""" + pass +``` + +Each `get_*` method follows this shape (battery_status shown; the others differ only in path/family/parse): + +```python + async def get_battery_status(self, site_id): + """Fetch and normalise battery SOC/capacity/power for a site.""" + data = await self.request_json("GET", f"/pv/settings/{site_id}/battery_status.json") + if data is None: + return None + batteries = data.get("storages") or [] + total_capacity = sum(float(b.get("max_capacity", 0) or 0) for b in batteries) + total_available = sum(float(b.get("available_energy", 0) or 0) for b in batteries) + if total_capacity > 0: + soc_percent = round(total_available / total_capacity * 100.0, 1) + else: + soc_percent = float(data.get("current_charge", 0) or 0) + self.battery_status[site_id] = { + "soc_percent": soc_percent, + "available_energy": float(data.get("available_energy", total_available) or 0), + "max_capacity": float(data.get("max_capacity", total_capacity) or 0), + "max_power_kw": float(data.get("max_power", 0) or 0), + "status": str(data.get("status", "")), + "batteries": batteries, + } + await self._save_cache("battery_status", self.battery_status) + return self.battery_status[site_id] +``` + +Profile GET uses `family="battery_config"` and params `{"source": "enho", "userId": self.user_id}`; batterySettings GET params `{"source": "enlm"}`. + +- [ ] **Step 4: Run tests to verify pass, then commit** + +```bash +git add apps/predbat/enphase.py apps/predbat/tests/test_enphase_api.py +git commit -m "feat: Enphase cloud reads, parsers and polling loop + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: Monitoring sensor publishing (incl. derived power) + +**Files:** + +- Modify: `apps/predbat/enphase.py` (replace `publish_data` stub) +- Test: `apps/predbat/tests/test_enphase_api.py` + +**Interfaces:** + +- Consumes: normalised data stores from Task 4. +- Produces: `publish_data(site_id)` creating (entity ids all prefixed `sensor.{prefix}_enphase_{site_id}_`): `soc_percent` (%), `soc_kw` (kWh available), `battery_capacity` (kWh), `battery_rate_max` (W = `max_power_kw*1000`), `battery_reserve` (%), `battery_reserve_min` (% = `veryLowSocMin` fallback 5), `battery_status`, `battery_profile`, `pv_today`/`load_today`/`import_today`/`export_today`/`battery_charge_today`/`battery_discharge_today` (kWh, `state_class: total_increasing`, `device_class: energy`), `load_power` (W from latest_power), `pv_power`/`grid_power`/`battery_power` (W, derived). Also module function `derive_power(prev_sample, new_kwh, now_utc) -> (watts, new_sample)` where a sample is `(kwh, datetime)`; watts = `(new_kwh - prev_kwh) * 1000 / hours_elapsed`, clamped to 0 when the delta is negative (daily reset) or the window is < 60 seconds. +- Sign conventions: `grid_power` positive = import (derive from `import` minus `export` deltas); `battery_power` positive = discharge (`discharge` minus `charge` deltas); `pv_power` from `production` delta. + +- [ ] **Step 1: Write failing tests** + +```python +def test_derive_power(): + """derive_power converts kWh deltas over elapsed time into watts.""" + from enphase import derive_power + + now = datetime.now(timezone.utc) + prev = (1.0, now - timedelta(minutes=5)) + watts, sample = derive_power(prev, 1.1, now) + assert abs(watts - 1200.0) < 1.0 # 0.1 kWh in 5 min = 1.2 kW + assert sample == (1.1, now) + # Negative delta (midnight reset) clamps to zero + watts, _ = derive_power((5.0, now - timedelta(minutes=5)), 0.0, now) + assert watts == 0.0 + # No previous sample yields zero + watts, _ = derive_power(None, 2.0, now) + assert watts == 0.0 + + +def test_publish_data_sensors(): + """publish_data creates the full monitoring sensor set.""" + api = MockEnphaseAPI() + api.battery_status["12345"] = {"soc_percent": 55.0, "available_energy": 5.5, "max_capacity": 10.0, "max_power_kw": 3.84, "status": "normal", "batteries": []} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.battery_settings["12345"] = {"chargeFromGrid": True, "veryLowSoc": 10, "veryLowSocMin": 5, "veryLowSocMax": 25} + api.lifetime_energy["12345"] = {"production": [3.5], "consumption": [2.2], "import": [1.0], "export": [0.4], "charge": [0.8], "discharge": [0.6]} + api.latest_power["12345"] = {"watts": 450.0, "time": 1760000000} + run_async(api.publish_data("12345")) + items = api.dashboard_items + assert items["sensor.predbat_enphase_12345_soc_percent"]["state"] == 55.0 + assert items["sensor.predbat_enphase_12345_battery_capacity"]["state"] == 10.0 + assert items["sensor.predbat_enphase_12345_battery_rate_max"]["state"] == 3840.0 + assert items["sensor.predbat_enphase_12345_pv_today"]["state"] == 3.5 + assert items["sensor.predbat_enphase_12345_load_today"]["state"] == 2.2 + assert items["sensor.predbat_enphase_12345_import_today"]["state"] == 1.0 + assert items["sensor.predbat_enphase_12345_export_today"]["state"] == 0.4 + assert items["sensor.predbat_enphase_12345_load_power"]["state"] == 450.0 + assert items["sensor.predbat_enphase_12345_battery_reserve_min"]["state"] == 5 + assert "sensor.predbat_enphase_12345_pv_power" in items +``` + +- [ ] **Step 2: Run to verify failure.** + +- [ ] **Step 3: Implement.** Follow fox `publish_data` (fox.py:1744) attribute conventions: energy sensors get `{"unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total_increasing", "friendly_name": ..., "icon": "mdi:..."}`; power sensors `{"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement", ...}`; percentages `{"unit_of_measurement": "%", ...}`. `derive_power`: + +```python +def derive_power(prev_sample, new_kwh, now_utc): + """Estimate average watts from the change in a cumulative kWh counter since the previous sample.""" + new_sample = (new_kwh, now_utc) + if not prev_sample: + return 0.0, new_sample + prev_kwh, prev_time = prev_sample + seconds = (now_utc - prev_time).total_seconds() + if seconds < 60: + return 0.0, prev_sample + delta = new_kwh - prev_kwh + if delta < 0: + return 0.0, new_sample + return round(delta * 1000.0 * 3600.0 / seconds, 1), new_sample +``` + +In `publish_data`, keep per-site previous samples in `self.prev_energy_sample[site_id][channel]` and compute: `pv_power` from `production`; `grid_power` = import-watts − export-watts; `battery_power` = discharge-watts − charge-watts. + +- [ ] **Step 4: Run tests to verify pass, then commit** + +```bash +git add apps/predbat/enphase.py apps/predbat/tests/test_enphase_api.py +git commit -m "feat: Enphase monitoring sensors with derived power estimates + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 6: Control entities, local schedule model, HA events + +**Files:** + +- Modify: `apps/predbat/enphase.py` (replace `publish_schedule_settings_ha` stub; add `get_schedule_settings_ha`, event handlers) +- Test: `apps/predbat/tests/test_enphase_api.py` + +**Interfaces:** + +- Consumes: `self.schedules`, `self.profile`, `dtg_supported()` (Task 4). +- Produces: + - `publish_schedule_settings_ha(site_id)` — entities (pattern `{domain}.{prefix}_enphase_{site_id}_battery_schedule_...`): + - `number ..._reserve` (min = `veryLowSocMin` fallback 5, max 100, step 1, %) + - per direction `charge` and (only if `dtg_supported`) `export`: `select ..._{direction}_start_time` / `..._end_time` (options `OPTIONS_TIME_FULL`, value "HH:MM:SS"), `number ..._{direction}_soc` (min 5 max 100 step 1 %), `switch ..._{direction}_enable`, `switch ..._{direction}_write` (always published "off") + - `switch ..._freeze_enable` plus freeze times reuse the charge window (rbd written during apply when freeze enabled) + - `get_schedule_settings_ha(site_id)` — reads entity states back from HA into `self.local_schedule[site_id]` = `{"reserve": int, "charge": {"start_time", "end_time", "soc", "enable"}, "export": {...}, "freeze": {"enable"}}`. + - `select_event(entity_id, value)`, `number_event(entity_id, value)`, `switch_event(entity_id, service)` — update `local_schedule`; a `turn_on` of a `_write` switch calls `apply_battery_schedule(site_id)` (Task 7 — stub `async def apply_battery_schedule(self, site_id)` now, docstring + `pass`). Entity id parsing: strip `{domain}.{prefix}_enphase_`, split off `site_id`, remainder names the attribute. `switch` services: `turn_on`/`turn_off`/`toggle` (see fox `apply_service_to_toggle`, fox.py:2004). + - Times: HA selects hold "HH:MM:SS" (`OPTIONS_TIME_FULL`); Enphase wants "HH:MM" — module functions `ha_time_to_enphase(value)` (`value[:5]`) and `enphase_time_to_ha(value)` (`value + ":00"`). + +- [ ] **Step 1: Write failing tests** + +```python +def test_publish_schedule_entities(): + """Control entities are published for charge, and export only when dtg supported.""" + api = MockEnphaseAPI() + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": False}, "rbd": {"supported": True}} + api.battery_settings["12345"] = {"veryLowSocMin": 5} + run_async(api.publish_schedule_settings_ha("12345")) + items = api.dashboard_items + assert "select.predbat_enphase_12345_battery_schedule_charge_start_time" in items + assert "number.predbat_enphase_12345_battery_schedule_charge_soc" in items + assert "switch.predbat_enphase_12345_battery_schedule_charge_write" in items + assert "number.predbat_enphase_12345_battery_schedule_reserve" in items + assert "select.predbat_enphase_12345_battery_schedule_export_start_time" not in items + + +def test_event_handlers_update_local_schedule(): + """select/number/switch events mutate the local schedule model.""" + api = MockEnphaseAPI() + api.sites = [{"site_id": "12345", "name": "Home"}] + api.local_schedule["12345"] = {"reserve": 20, "charge": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 100, "enable": False}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, "freeze": {"enable": False}} + run_async(api.select_event("select.predbat_enphase_12345_battery_schedule_charge_start_time", "02:30:00")) + assert api.local_schedule["12345"]["charge"]["start_time"] == "02:30:00" + run_async(api.number_event("number.predbat_enphase_12345_battery_schedule_charge_soc", 85)) + assert api.local_schedule["12345"]["charge"]["soc"] == 85 + run_async(api.switch_event("switch.predbat_enphase_12345_battery_schedule_charge_enable", "turn_on")) + assert api.local_schedule["12345"]["charge"]["enable"] is True + + +def test_write_switch_triggers_apply(): + """Turning on the write switch calls apply_battery_schedule for the site.""" + api = MockEnphaseAPI() + api.sites = [{"site_id": "12345", "name": "Home"}] + api.local_schedule["12345"] = {"reserve": 20, "charge": {"start_time": "02:00:00", "end_time": "05:00:00", "soc": 90, "enable": True}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, "freeze": {"enable": False}} + applied = [] + + async def fake_apply(site_id): + """Record the applied site.""" + applied.append(site_id) + + api.apply_battery_schedule = fake_apply + run_async(api.switch_event("switch.predbat_enphase_12345_battery_schedule_charge_write", "turn_on")) + assert applied == ["12345"] +``` + +Note: check how fox's `switch_event` schedules async work from a sync event callback (`grep -n "def switch_event" -A 15 apps/predbat/fox.py`) — it is a `async def` called by the Components dispatcher, or it uses `create_task`. Match fox exactly (if fox's event handlers are async, make these async and adapt the tests with `run_async`). + +- [ ] **Step 2: Run to verify failure.** + +- [ ] **Step 3: Implement.** Entity attribute dicts follow fox.py:1290 conventions (friendly names "Enphase {site} Battery Schedule ..."). Core code: + +```python +def ha_time_to_enphase(value): + """Convert an HA 'HH:MM:SS' option time to Enphase 'HH:MM' format.""" + return str(value)[:5] + + +def enphase_time_to_ha(value): + """Convert an Enphase 'HH:MM' time to the HA 'HH:MM:SS' option format.""" + text = str(value or "00:00")[:5] + return text + ":00" +``` + +Methods on `EnphaseAPI`: + +```python + def _default_local_schedule(self): + """Return an empty local schedule model.""" + return {"reserve": 0, "charge": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 100, "enable": False}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, "freeze": {"enable": False}} + + async def publish_schedule_settings_ha(self, site_id): + """Publish the schedule control entities for a site.""" + local = self.local_schedule.setdefault(site_id, self._default_local_schedule()) + reserve_min = int(self.battery_settings.get(site_id, {}).get("veryLowSocMin", 5) or 5) + base_name = f"{self.prefix}_enphase_{site_id}_battery_schedule" + + self.dashboard_item( + f"number.{base_name}_reserve", + state=local.get("reserve", 0), + attributes={"min": reserve_min, "max": 100, "step": 1, "unit_of_measurement": "%", "friendly_name": f"Enphase {site_id} Battery Schedule Reserve", "icon": "mdi:gauge"}, + app="enphase", + ) + + directions = ["charge"] + if self.dtg_supported(site_id): + directions.append("export") + for direction in directions: + window = local.get(direction, {}) + for attribute in ["start_time", "end_time"]: + value = window.get(attribute, "00:00:00") + if value not in OPTIONS_TIME_FULL: + value = "00:00:00" + self.dashboard_item( + f"select.{base_name}_{direction}_{attribute}", + state=value, + attributes={"options": OPTIONS_TIME_FULL, "friendly_name": f"Enphase {site_id} Battery Schedule {direction.capitalize()} {attribute.replace('_', ' ').capitalize()}", "icon": "mdi:clock-outline"}, + app="enphase", + ) + self.dashboard_item( + f"number.{base_name}_{direction}_soc", + state=int(window.get("soc", 100 if direction == "charge" else reserve_min)), + attributes={"min": 5, "max": 100, "step": 1, "unit_of_measurement": "%", "friendly_name": f"Enphase {site_id} Battery Schedule {direction.capitalize()} Soc", "icon": "mdi:gauge"}, + app="enphase", + ) + self.dashboard_item( + f"switch.{base_name}_{direction}_enable", + state="on" if window.get("enable") else "off", + attributes={"friendly_name": f"Enphase {site_id} Battery Schedule {direction.capitalize()} Enable", "icon": "mdi:check-circle-outline"}, + app="enphase", + ) + self.dashboard_item( + f"switch.{base_name}_{direction}_write", + state="off", + attributes={"friendly_name": f"Enphase {site_id} Battery Schedule {direction.capitalize()} Write", "icon": "mdi:upload"}, + app="enphase", + ) + self.dashboard_item( + f"switch.{base_name}_freeze_enable", + state="on" if local.get("freeze", {}).get("enable") else "off", + attributes={"friendly_name": f"Enphase {site_id} Battery Schedule Freeze Enable", "icon": "mdi:snowflake"}, + app="enphase", + ) + + async def get_schedule_settings_ha(self, site_id): + """Read the current schedule control entity states from HA into the local schedule model.""" + local = self.local_schedule.setdefault(site_id, self._default_local_schedule()) + base_name = f"{self.prefix}_enphase_{site_id}_battery_schedule" + local["reserve"] = int(float(self.get_state_wrapper(f"number.{base_name}_reserve", local.get("reserve", 0)) or 0)) + for direction in ["charge", "export"]: + window = local.setdefault(direction, {}) + for attribute in ["start_time", "end_time"]: + value = self.get_state_wrapper(f"select.{base_name}_{direction}_{attribute}", window.get(attribute, "00:00:00")) + if value in OPTIONS_TIME_FULL: + window[attribute] = value + window["soc"] = int(float(self.get_state_wrapper(f"number.{base_name}_{direction}_soc", window.get("soc", 100)) or 0)) + window["enable"] = str(self.get_state_wrapper(f"switch.{base_name}_{direction}_enable", "on" if window.get("enable") else "off")).lower() == "on" + local.setdefault("freeze", {})["enable"] = str(self.get_state_wrapper(f"switch.{base_name}_freeze_enable", "off")).lower() == "on" + + def _parse_entity(self, entity_id): + """Split a published entity id into (site_id, attribute_name), or (None, None) if not ours.""" + try: + name = entity_id.split(".", 1)[1] + except IndexError: + return None, None + marker = f"{self.prefix}_enphase_" + if not name.startswith(marker): + return None, None + remainder = name[len(marker):] + for site in self.sites: + site_id = site["site_id"] + if remainder.startswith(site_id + "_"): + return site_id, remainder[len(site_id) + 1:] + return None, None + + def _toggle_to_bool(self, service, current): + """Convert an HA switch service call into the resulting boolean state.""" + if service == "turn_on": + return True + if service == "turn_off": + return False + return not current + + async def select_event(self, entity_id, value): + """Handle a select entity change routed from HA.""" + site_id, attribute = self._parse_entity(entity_id) + if not site_id or not attribute.startswith("battery_schedule_"): + return + field = attribute[len("battery_schedule_"):] + local = self.local_schedule.setdefault(site_id, self._default_local_schedule()) + for direction in ["charge", "export"]: + for time_key in ["start_time", "end_time"]: + if field == f"{direction}_{time_key}" and value in OPTIONS_TIME_FULL: + local[direction][time_key] = value + await self.publish_schedule_settings_ha(site_id) + + async def number_event(self, entity_id, value): + """Handle a number entity change routed from HA.""" + site_id, attribute = self._parse_entity(entity_id) + if not site_id or not attribute.startswith("battery_schedule_"): + return + field = attribute[len("battery_schedule_"):] + local = self.local_schedule.setdefault(site_id, self._default_local_schedule()) + if field == "reserve": + local["reserve"] = int(float(value)) + for direction in ["charge", "export"]: + if field == f"{direction}_soc": + local[direction]["soc"] = int(float(value)) + await self.publish_schedule_settings_ha(site_id) + + async def switch_event(self, entity_id, service): + """Handle a switch service call routed from HA.""" + site_id, attribute = self._parse_entity(entity_id) + if not site_id or not attribute.startswith("battery_schedule_"): + return + field = attribute[len("battery_schedule_"):] + local = self.local_schedule.setdefault(site_id, self._default_local_schedule()) + if field == "freeze_enable": + local["freeze"]["enable"] = self._toggle_to_bool(service, local["freeze"]["enable"]) + for direction in ["charge", "export"]: + if field == f"{direction}_enable": + local[direction]["enable"] = self._toggle_to_bool(service, local[direction]["enable"]) + if field == f"{direction}_write" and self._toggle_to_bool(service, False): + await self.apply_battery_schedule(site_id) + await self.publish_schedule_settings_ha(site_id) +``` + +These are async because ComponentBase declares async event handlers (`component_base.py:340/352/364`) and fox's are async too (`fox.py:1985/1994/2000`) — the Components dispatcher awaits them. + +- [ ] **Step 4: Run tests to verify pass, then commit** + +```bash +git add apps/predbat/enphase.py apps/predbat/tests/test_enphase_api.py +git commit -m "feat: Enphase schedule control entities and HA event handling + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 7: Write path — apply_battery_schedule + +**Files:** + +- Modify: `apps/predbat/enphase.py` +- Test: `apps/predbat/tests/test_enphase_api.py` + +**Interfaces:** + +- Consumes: `local_schedule` (Task 6), `schedules`/`profile`/`battery_settings` cloud state (Task 4), `request_json` (Task 3). +- Produces: + - `async apply_battery_schedule(site_id)` — top-level diff-and-write: + 1. Desired state from `local_schedule[site_id]`. + 2. Reserve/profile: if desired reserve != cloud `profile["reserve"]`, `PUT {BATTERY_CONFIG_BASE}/profile/` body `{"profile": , "batteryBackupPercentage": reserve}` (family `battery_config`, params `{"source": "enho", "userId": self.user_id}`). + 3. Charge: if `charge.enable` — ensure `chargeFromGrid` true first (if false: `POST {BATTERY_CONFIG_BASE}/batterySettings/acceptDisclaimer/` body `{"disclaimer-type": "itc"}` once — track `self.disclaimer_accepted`; then `PUT batterySettings` `{"chargeFromGrid": True}`), then `_write_schedule(site_id, "CFG", start, end, limit=charge.soc, enabled=True)`. If not enabled and a cloud cfg schedule is enabled → `_write_schedule(..., enabled=False)`. + 4. Export: same via `"DTG"` with `limit=export.soc`, only when `dtg_supported(site_id)`. + 5. Freeze: `freeze.enable` → `_write_schedule(site_id, "RBD", charge window times, limit=None, enabled=True)`; else disable if cloud-enabled. + - `async _write_schedule(site_id, family, start_time_ha, end_time_ha, limit, enabled)` — converts "HH:MM:SS"→"HH:MM"; no-op when the cloud entry already matches (`schedules_equal`); update by id when the family has an existing schedule (`PUT .../schedules/`), else create (`POST .../schedules`); payload `{"timezone": tz, "startTime": "HH:MM", "endTime": "HH:MM", "scheduleType": family, "days": [1,2,3,4,5,6,7], "limit": limit, "isEnabled": enabled}` (omit `limit` when None). Timezone from site settings if present else `str(self.local_tz)`. + - Module function `schedules_equal(cloud_entry, start_hm, end_hm, limit, enabled) -> bool`. + - Write-settle: after any write, mark `self.pending_writes[(site_id, family)] = desired`; re-fetch schedules; if the read does not yet reflect the write, keep pending (do not re-PUT) until it confirms or `ENPHASE_PENDING_TIMEOUT_MINUTES = 15` passes. `run()` clears confirmed/expired pendings each cycle. + +- [ ] **Step 1: Write failing tests** + +```python +def test_schedules_equal(): + """schedules_equal compares window, limit and enable state.""" + from enphase import schedules_equal + + cloud = {"id": "u1", "startTime": "02:00", "endTime": "05:00", "limit": 90, "enabled": True} + assert schedules_equal(cloud, "02:00", "05:00", 90, True) + assert not schedules_equal(cloud, "02:00", "05:30", 90, True) + assert not schedules_equal(cloud, "02:00", "05:00", 80, True) + assert not schedules_equal(cloud, "02:00", "05:00", 90, False) + assert not schedules_equal(None, "02:00", "05:00", 90, True) + + +def test_apply_charge_schedule_creates(): + """apply writes a CFG schedule via POST when none exists, enabling charge-from-grid first.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.battery_settings["12345"] = {"chargeFromGrid": False, "veryLowSocMin": 5} + api.local_schedule["12345"] = {"reserve": 20, "charge": {"start_time": "02:00:00", "end_time": "05:00:00", "soc": 90, "enable": True}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, "freeze": {"enable": False}} + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/acceptDisclaimer/12345", 200, {}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules", 200, {"cfg": {"scheduleSupported": True, "details": []}, "dtg": {"scheduleSupported": True, "details": []}, "rbd": {"scheduleSupported": True, "details": []}}) + run_async(api.apply_battery_schedule("12345")) + posts = [r for r in api.request_log if r["method"] == "POST" and r["path"].endswith("/schedules")] + assert len(posts) == 1 + body = posts[0]["json"] + assert body["scheduleType"] == "CFG" and body["startTime"] == "02:00" and body["endTime"] == "05:00" and body["limit"] == 90 and body["isEnabled"] is True + disclaimers = [r for r in api.request_log if "acceptDisclaimer" in r["path"]] + assert len(disclaimers) == 1 + + +def test_apply_updates_existing_by_id(): + """apply uses PUT /schedules/ when the family already has a schedule.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.schedules["12345"] = {"cfg": {"supported": True, "id": "u1", "startTime": "01:00", "endTime": "04:00", "limit": 80, "enabled": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.battery_settings["12345"] = {"chargeFromGrid": True, "veryLowSocMin": 5} + api.local_schedule["12345"] = {"reserve": 20, "charge": {"start_time": "02:00:00", "end_time": "05:00:00", "soc": 90, "enable": True}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, "freeze": {"enable": False}} + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules/u1", 200, {}) + api.set_http_response("/service/batteryConfig/api/v1/battery/sites/12345/schedules", 200, {"cfg": {"scheduleSupported": True, "details": [{"id": "u1", "startTime": "02:00", "endTime": "05:00", "limit": 90, "isEnabled": True}]}, "dtg": {"scheduleSupported": True, "details": []}, "rbd": {"scheduleSupported": True, "details": []}}) + run_async(api.apply_battery_schedule("12345")) + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"].endswith("/schedules/u1")] + assert len(puts) == 1 + # After the confirming re-read matches, no pending write remains + assert ("12345", "CFG") not in api.pending_writes + + +def test_apply_no_change_no_write(): + """apply issues no schedule writes when cloud already matches the local schedule.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.schedules["12345"] = {"cfg": {"supported": True, "id": "u1", "startTime": "02:00", "endTime": "05:00", "limit": 90, "enabled": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.battery_settings["12345"] = {"chargeFromGrid": True, "veryLowSocMin": 5} + api.local_schedule["12345"] = {"reserve": 20, "charge": {"start_time": "02:00:00", "end_time": "05:00:00", "soc": 90, "enable": True}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, "freeze": {"enable": False}} + run_async(api.apply_battery_schedule("12345")) + writes = [r for r in api.request_log if r["method"] in ("POST", "PUT")] + assert writes == [] + + +def test_pending_write_suppresses_duplicate(): + """While a write is pending confirmation, apply does not re-issue the same PUT.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.schedules["12345"] = {"cfg": {"supported": True, "id": "u1", "startTime": "01:00", "endTime": "04:00", "limit": 80, "enabled": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + api.profile["12345"] = {"profile": "self-consumption", "reserve": 20} + api.battery_settings["12345"] = {"chargeFromGrid": True, "veryLowSocMin": 5} + api.local_schedule["12345"] = {"reserve": 20, "charge": {"start_time": "02:00:00", "end_time": "05:00:00", "soc": 90, "enable": True}, "export": {"start_time": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, "freeze": {"enable": False}} + api.pending_writes[("12345", "CFG")] = {"start": "02:00", "end": "05:00", "limit": 90, "enabled": True, "time": datetime.now(timezone.utc)} + run_async(api.apply_battery_schedule("12345")) + writes = [r for r in api.request_log if r["method"] in ("POST", "PUT") and "/schedules" in r["path"]] + assert writes == [] +``` + +- [ ] **Step 2: Run to verify failure.** + +- [ ] **Step 3: Implement.** Add module constant `ENPHASE_PENDING_TIMEOUT_MINUTES = 15`. Core code: + +```python +def schedules_equal(cloud_entry, start_hm, end_hm, limit, enabled): + """Return True when a cloud schedule entry already matches the desired window/limit/enable state.""" + if not cloud_entry or "startTime" not in cloud_entry: + # No cloud schedule: equal only when we want it disabled + return not enabled + if bool(cloud_entry.get("enabled")) != bool(enabled): + return False + if not enabled: + return True # both disabled - window/limit are irrelevant + if str(cloud_entry.get("startTime", ""))[:5] != start_hm or str(cloud_entry.get("endTime", ""))[:5] != end_hm: + return False + if limit is not None and int(cloud_entry.get("limit", -1)) != int(limit): + return False + return True +``` + +Methods on `EnphaseAPI`: + +```python + def _site_timezone(self, site_id): + """Return the IANA timezone to use for schedule writes.""" + timezone_name = self.site_settings.get(site_id, {}).get("timezone") + return timezone_name or str(self.local_tz) + + def _pending_active(self, site_id, family): + """Return True when a write for this family is still awaiting cloud confirmation.""" + pending = self.pending_writes.get((site_id, family)) + if not pending: + return False + age_minutes = (datetime.now(timezone.utc) - pending["time"]).total_seconds() / 60.0 + if age_minutes > ENPHASE_PENDING_TIMEOUT_MINUTES: + self.log(f"Warn: Enphase: Pending {family} write for site {site_id} timed out after {ENPHASE_PENDING_TIMEOUT_MINUTES} minutes") + del self.pending_writes[(site_id, family)] + return False + return True + + async def _write_schedule(self, site_id, family, start_time_ha, end_time_ha, limit, enabled): + """Create/update one Enphase schedule family if it differs from the cloud state. Returns True if a write was issued.""" + start_hm = ha_time_to_enphase(start_time_ha) + end_hm = ha_time_to_enphase(end_time_ha) + family_key = family.lower() + cloud_entry = self.schedules.get(site_id, {}).get(family_key, {}) + if schedules_equal(cloud_entry, start_hm, end_hm, limit, enabled): + self.pending_writes.pop((site_id, family), None) # confirmed + return False + if self._pending_active(site_id, family): + return False # a matching write is still settling; don't spam duplicates + + payload = {"timezone": self._site_timezone(site_id), "startTime": start_hm, "endTime": end_hm, "scheduleType": family, "days": [1, 2, 3, 4, 5, 6, 7], "isEnabled": bool(enabled)} + if limit is not None: + payload["limit"] = int(limit) + schedule_id = cloud_entry.get("id") + if schedule_id: + self.log(f"Enphase: Updating {family} schedule {schedule_id} on site {site_id}: {start_hm}-{end_hm} limit={limit} enabled={enabled}") + result = await self.request_json("PUT", f"{BATTERY_CONFIG_BASE}/battery/sites/{site_id}/schedules/{schedule_id}", family="battery_config", json_body=payload) + else: + self.log(f"Enphase: Creating {family} schedule on site {site_id}: {start_hm}-{end_hm} limit={limit} enabled={enabled}") + result = await self.request_json("POST", f"{BATTERY_CONFIG_BASE}/battery/sites/{site_id}/schedules", family="battery_config", json_body=payload) + # Record as pending regardless of result - Enphase writes can 400 yet still land + self.pending_writes[(site_id, family)] = {"start": start_hm, "end": end_hm, "limit": limit, "enabled": enabled, "time": datetime.now(timezone.utc)} + return result is not None + + async def _ensure_charge_from_grid(self, site_id): + """Enable the charge-from-grid setting, accepting the one-time ITC disclaimer first.""" + if self.battery_settings.get(site_id, {}).get("chargeFromGrid"): + return + self.log(f"Enphase: Enabling charge-from-grid on site {site_id}") + await self.request_json("POST", f"{BATTERY_CONFIG_BASE}/batterySettings/acceptDisclaimer/{site_id}", family="battery_config", json_body={"disclaimer-type": "itc"}) + await self.request_json("PUT", f"{BATTERY_CONFIG_BASE}/batterySettings/{site_id}", family="battery_config", json_body={"chargeFromGrid": True}) + self.battery_settings.setdefault(site_id, {})["chargeFromGrid"] = True + + async def apply_battery_schedule(self, site_id): + """Diff the local schedule model against the cloud and issue only the changed writes.""" + await self.get_schedule_settings_ha(site_id) + local = self.local_schedule.get(site_id, self._default_local_schedule()) + wrote = False + + # Reserve via profile PUT, preserving the current profile name + desired_reserve = int(local.get("reserve", 0)) + cloud = self.profile.get(site_id, {}) + if desired_reserve and desired_reserve != int(cloud.get("reserve", -1)): + profile_name = cloud.get("profile") or PROFILE_SELF_CONSUMPTION + self.log(f"Enphase: Setting reserve to {desired_reserve}% (profile {profile_name}) on site {site_id}") + await self.request_json("PUT", f"{BATTERY_CONFIG_BASE}/profile/{site_id}", family="battery_config", params={"source": "enho", "userId": self.user_id}, json_body={"profile": profile_name, "batteryBackupPercentage": desired_reserve}) + wrote = True + + # Forced charge window (CFG) + charge = local.get("charge", {}) + if charge.get("enable"): + await self._ensure_charge_from_grid(site_id) + wrote |= await self._write_schedule(site_id, SCHEDULE_CHARGE, charge.get("start_time", "00:00:00"), charge.get("end_time", "00:00:00"), charge.get("soc", 100), charge.get("enable", False)) + + # Forced export window (DTG), only where supported + export = local.get("export", {}) + if self.dtg_supported(site_id): + wrote |= await self._write_schedule(site_id, SCHEDULE_EXPORT, export.get("start_time", "00:00:00"), export.get("end_time", "00:00:00"), export.get("soc", 5), export.get("enable", False)) + + # Freeze (RBD) reuses the charge window times + freeze_enabled = local.get("freeze", {}).get("enable", False) + wrote |= await self._write_schedule(site_id, SCHEDULE_FREEZE, charge.get("start_time", "00:00:00"), charge.get("end_time", "00:00:00"), None, freeze_enabled) + + if wrote: + # Re-read to confirm; writes settle asynchronously so pendings may persist for minutes + await self.get_schedules(site_id) + for family in (SCHEDULE_CHARGE, SCHEDULE_EXPORT, SCHEDULE_FREEZE): + pending = self.pending_writes.get((site_id, family)) + if pending and schedules_equal(self.schedules.get(site_id, {}).get(family.lower(), {}), pending["start"], pending["end"], pending["limit"], pending["enabled"]): + del self.pending_writes[(site_id, family)] + await self.get_profile(site_id) +``` + +Nuance the tests depend on: `schedules_equal(None, ..., enabled=True)` is False (test asserts `not schedules_equal(None, ...)`) but a missing cloud entry with `enabled=False` desired is equal (no write needed to disable a non-existent schedule) — that is why `test_apply_no_change_no_write` sees zero writes for export/freeze. + +- [ ] **Step 4: Run tests to verify pass, then commit** + +```bash +git add apps/predbat/enphase.py apps/predbat/tests/test_enphase_api.py +git commit -m "feat: Enphase battery schedule write path with settle confirmation + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 8: INVERTER_DEF, automatic_config, template + +**Files:** + +- Modify: `apps/predbat/config.py` (add `"EnphaseCloud"` after the `"FoxCloud"` dict ending at line 1919) +- Modify: `apps/predbat/enphase.py` (add `automatic_config`) +- Create: `templates/enphase_cloud.yaml` (copy `templates/fox_cloud.yaml` as the base, swap fox keys for `enphase_username`/`enphase_password`/`enphase_automatic`) +- Test: `apps/predbat/tests/test_enphase_api.py` + +**Interfaces:** + +- Consumes: entity naming from Tasks 5/6. +- Produces: `INVERTER_DEF["EnphaseCloud"]`; `automatic_config()` setting all inverter args. + +- [ ] **Step 1: Write failing test** + +```python +def test_automatic_config(): + """automatic_config points every inverter arg at the published entities.""" + api = MockEnphaseAPI() + api.sites = [{"site_id": "12345", "name": "Home"}] + api.battery_status["12345"] = {"soc_percent": 55.0, "available_energy": 5.5, "max_capacity": 10.0, "max_power_kw": 3.84, "status": "normal", "batteries": []} + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": True}, "rbd": {"supported": True}} + run_async(api.automatic_config()) + args = api.args_set + assert args["inverter_type"] == ["EnphaseCloud"] + assert args["num_inverters"] == 1 + assert args["soc_percent"] == ["sensor.predbat_enphase_12345_soc_percent"] + assert args["soc_max"] == ["sensor.predbat_enphase_12345_battery_capacity"] + assert args["battery_rate_max"] == ["sensor.predbat_enphase_12345_battery_rate_max"] + assert args["load_today"] == ["sensor.predbat_enphase_12345_load_today"] + assert args["import_today"] == ["sensor.predbat_enphase_12345_import_today"] + assert args["export_today"] == ["sensor.predbat_enphase_12345_export_today"] + assert args["pv_today"] == ["sensor.predbat_enphase_12345_pv_today"] + assert args["charge_start_time"] == ["select.predbat_enphase_12345_battery_schedule_charge_start_time"] + assert args["charge_limit"] == ["number.predbat_enphase_12345_battery_schedule_charge_soc"] + assert args["scheduled_charge_enable"] == ["switch.predbat_enphase_12345_battery_schedule_charge_enable"] + assert args["scheduled_discharge_enable"] == ["switch.predbat_enphase_12345_battery_schedule_export_enable"] + assert args["discharge_start_time"] == ["select.predbat_enphase_12345_battery_schedule_export_start_time"] + assert args["discharge_target_soc"] == ["number.predbat_enphase_12345_battery_schedule_export_soc"] + assert args["reserve"] == ["number.predbat_enphase_12345_battery_schedule_reserve"] + assert args["battery_min_soc"] == ["sensor.predbat_enphase_12345_battery_reserve_min"] + assert args["schedule_write_button"] == ["switch.predbat_enphase_12345_battery_schedule_charge_write"] + assert args["export_limit"] == [99999] + + +def test_automatic_config_no_dtg(): + """Without dtg support, discharge args are not set.""" + api = MockEnphaseAPI() + api.sites = [{"site_id": "12345", "name": "Home"}] + api.battery_status["12345"] = {"soc_percent": 55.0, "available_energy": 5.5, "max_capacity": 10.0, "max_power_kw": 3.84, "status": "normal", "batteries": []} + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": False}, "rbd": {"supported": True}} + run_async(api.automatic_config()) + assert "discharge_start_time" not in api.args_set + assert "scheduled_discharge_enable" not in api.args_set +``` + +Also add a config-level check: + +```python +def test_inverter_def_enphase(): + """EnphaseCloud INVERTER_DEF exists with the agreed capability flags.""" + from config import INVERTER_DEF + + idef = INVERTER_DEF["EnphaseCloud"] + assert idef["has_rest_api"] is False + assert idef["has_target_soc"] is True + assert idef["time_button_press"] is True + assert idef["charge_time_entity_is_option"] is True + assert idef["can_span_midnight"] is False + assert idef["target_soc_used_for_discharge"] is True + assert idef["has_fox_inverter_mode"] is False +``` + +- [ ] **Step 2: Run to verify failure.** + +- [ ] **Step 3: Implement.** `INVERTER_DEF["EnphaseCloud"]` (config.py, after FoxCloud): + +```python + "EnphaseCloud": { + "name": "EnphaseCloud", + "has_rest_api": False, + "has_mqtt_api": False, + "output_charge_control": "none", + "charge_control_immediate": False, + "has_charge_enable_time": True, + "has_discharge_enable_time": True, + "has_target_soc": True, + "has_reserve_soc": True, + "has_timed_pause": False, + "charge_time_format": "HH:MM:SS", + "charge_time_entity_is_option": True, + "soc_units": "%", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "has_ge_eco_toggle": False, + "has_fox_inverter_mode": False, + "time_button_press": True, + "clock_time_format": "%Y-%m-%d %H:%M:%S", + "write_and_poll_sleep": 2, + "has_time_window": False, + "support_charge_freeze": True, + "support_discharge_freeze": True, + "has_idle_time": False, + "can_span_midnight": False, + "charge_discharge_with_rate": False, + "target_soc_used_for_discharge": True, + }, +``` + +**Check before committing:** `grep -n "output_charge_control" apps/predbat/inverter.py | head` — confirm `"none"` is an accepted value (search for how the value is consumed). If only `"power"`/`"current"` are handled, use `"power"` like FoxCloud and simply do not set `charge_rate`/`discharge_rate` args in `automatic_config` (Predbat then uses `battery_rate_max`). Record which option was chosen in the commit message. + +`automatic_config` (enphase.py) — mirror fox.py:2158 but single-site: + +```python + async def automatic_config(self): + """Automatically configure Predbat inverter args from the discovered Enphase site.""" + if not self.sites: + raise ValueError("Enphase API: No sites found, cannot configure") + site_id = self.sites[0]["site_id"] + status = self.battery_status.get(site_id, {}) + if not status.get("max_capacity"): + raise ValueError("Enphase API: No battery found on site, cannot configure") + entity = f"{self.prefix}_enphase_{site_id}" + has_dtg = self.dtg_supported(site_id) + + self.set_arg("inverter_type", ["EnphaseCloud"]) + self.set_arg("num_inverters", 1) + self.set_arg("load_today", [f"sensor.{entity}_load_today"]) + self.set_arg("import_today", [f"sensor.{entity}_import_today"]) + self.set_arg("export_today", [f"sensor.{entity}_export_today"]) + if not self.automatic_ignore_pv: + self.set_arg("pv_today", [f"sensor.{entity}_pv_today"]) + self.set_arg("pv_power", [f"sensor.{entity}_pv_power"]) + self.set_arg("soc_percent", [f"sensor.{entity}_soc_percent"]) + self.set_arg("soc_max", [f"sensor.{entity}_battery_capacity"]) + self.set_arg("battery_rate_max", [f"sensor.{entity}_battery_rate_max"]) + self.set_arg("battery_power", [f"sensor.{entity}_battery_power"]) + self.set_arg("grid_power", [f"sensor.{entity}_grid_power"]) + self.set_arg("load_power", [f"sensor.{entity}_load_power"]) + self.set_arg("reserve", [f"number.{entity}_battery_schedule_reserve"]) + self.set_arg("battery_min_soc", [f"sensor.{entity}_battery_reserve_min"]) + self.set_arg("charge_start_time", [f"select.{entity}_battery_schedule_charge_start_time"]) + self.set_arg("charge_end_time", [f"select.{entity}_battery_schedule_charge_end_time"]) + self.set_arg("charge_limit", [f"number.{entity}_battery_schedule_charge_soc"]) + self.set_arg("scheduled_charge_enable", [f"switch.{entity}_battery_schedule_charge_enable"]) + if has_dtg: + self.set_arg("scheduled_discharge_enable", [f"switch.{entity}_battery_schedule_export_enable"]) + self.set_arg("discharge_start_time", [f"select.{entity}_battery_schedule_export_start_time"]) + self.set_arg("discharge_end_time", [f"select.{entity}_battery_schedule_export_end_time"]) + self.set_arg("discharge_target_soc", [f"number.{entity}_battery_schedule_export_soc"]) + self.set_arg("schedule_write_button", [f"switch.{entity}_battery_schedule_charge_write"]) + self.set_arg("export_limit", [99999]) +``` + +Call it from `run()` on first successful data load when `self.automatic` is true (mirror where fox calls it — `grep -n "automatic_config" apps/predbat/fox.py`). + +- [ ] **Step 4: Run tests to verify pass, then commit** + +```bash +git add apps/predbat/config.py apps/predbat/enphase.py apps/predbat/tests/test_enphase_api.py templates/enphase_cloud.yaml +git commit -m "feat: EnphaseCloud inverter definition and automatic configuration + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 9: Documentation, spell dictionary, full validation + +**Files:** + +- Modify: `docs/components.md` (new section after the Fox section ending ~line 523; add TOC entry near line 19) +- Modify: `docs/inverter-setup.md` (new "Enphase Cloud" section near the "Fox Cloud" section at line 233; template table rows near lines 42-43) +- Modify: `docs/apps-yaml.md` (`enphase_username` etc. near fox_key at line 188; `EnphaseCloud` in the inverter_type list near lines 773-774) +- Modify: `.cspell/custom-dictionary-workspace.txt` + +**Interfaces:** + +- Consumes: everything prior; this task gates the branch on repo-wide checks. + +- [ ] **Step 1: Write docs** + +`docs/components.md` section content (adapt formatting to the Fox section's exact style): + +```markdown +## Enphase API (enphase) + +Connects Predbat to the Enphase Enlighten cloud for monitoring and battery control of +Enphase IQ Battery systems, with no local hardware access required. + +**Important**: this uses the unofficial Enlighten web-app API (there is no official API +with battery control). Enphase may change it without notice. Accounts with multi-factor +authentication (MFA) enabled are not supported - disable MFA on the Enphase account. + +Predbat controls the battery by writing Enphase schedules: charge windows become +charge-from-grid (CFG) schedules with a target SOC, export windows become +discharge-to-grid (DTG) schedules (only in regions where Enphase enables DTG), freeze +modes use restrict-battery-discharge (RBD) schedules, and the reserve is set via the +battery profile. Cloud writes can take a few minutes to settle. + +| Option | apps.yaml key | Description | +|--------|---------------|-------------| +| username | enphase_username | Enlighten account e-mail (required) | +| password | enphase_password | Enlighten account password (required) | +| site_id | enphase_site_id | Restrict to one site id (optional, defaults to the first site) | +| automatic | enphase_automatic | Automatically configure Predbat inverter settings | +| automatic_ignore_pv | enphase_automatic_ignore_pv | Skip PV sensors during automatic configuration | +``` + +Also cover: sensors published, control entities, the login-cooldown behaviour (repeated login failures back off up to 24 h to protect the account). + +- [ ] **Step 2: Add dictionary words** + +Append to `.cspell/custom-dictionary-workspace.txt` (file is auto-sorted on commit; re-stage after pre-commit runs): `Encharge`, `Enlighten`, `Enpower`, `Enphase`, `enho`, `enlm`, `entrez` (skip any already present — check with grep first). + +- [ ] **Step 3: Run the full test suite and pre-commit** + +```bash +cd coverage && ./run_all --quick > /tmp/enphase_full.txt 2>&1; grep -iE "fail|error" /tmp/enphase_full.txt | head -30 +cd .. && ./run_pre_commit > /tmp/enphase_precommit.txt 2>&1; tail -30 /tmp/enphase_precommit.txt +``` + +Expected: no test failures; pre-commit clean (interrogate 100%, flake8, black, cspell). Fix anything flagged, re-stage auto-fixed files. + +- [ ] **Step 4: Commit and push** + +```bash +git add docs/components.md docs/inverter-setup.md docs/apps-yaml.md .cspell/custom-dictionary-workspace.txt +git commit -m "docs: Enphase cloud component documentation + +Co-Authored-By: Claude Fable 5 " +``` + +Do not open a PR yet — the component should first be validated against a real Enphase account (see Verification below). + +--- + +## Verification (post-implementation, needs a real account) + +The unofficial API cannot be fully validated by unit tests. Before raising a PR: + +1. Configure `enphase_username`/`enphase_password` in a test apps.yaml with `enphase_automatic: true`. +2. Confirm: login succeeds, sensors appear and update, SOC matches the Enlighten app. +3. Trigger a short manual charge window via the published entities and confirm a CFG schedule appears in the Enlighten battery settings UI within ~5 minutes. +4. Watch for HTTP 401/406/429 in the logs over 24 h (header variants may need the `cookie_eauth_compatible` fallback from the reference repo — `_battery_config_cookie_eauth_headers` shape — if BatteryConfig calls fail with auth errors despite a valid login). + +## Notes for implementers + +- The reference implementation is cloned at `/private/tmp/claude-501/-Users-treforsouthwell-predbat-batpred/1c6147ca-7c80-4457-95be-562fc8092e24/scratchpad/ha-enphase-energy` (custom_components/enphase_ev/api.py, battery_runtime.py). Re-clone from if missing. Use it to answer payload-shape questions; do not copy code wholesale (different licence and style). +- Fox reference points: `FoxAPI.initialize` fox.py:338, `run` fox.py:407, cache helpers fox.py:565-643, schedule entities fox.py:1290, events fox.py:1985-2116, `automatic_config` fox.py:2158, registry components.py:204, INVERTER_DEF config.py:1891, schema config.py:2196, mock pattern tests/test_fox_api.py:50. diff --git a/docs/superpowers/specs/2026-07-11-enphase-cloud-integration-design.md b/docs/superpowers/specs/2026-07-11-enphase-cloud-integration-design.md new file mode 100644 index 000000000..c5ab11320 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-enphase-cloud-integration-design.md @@ -0,0 +1,254 @@ +# Enphase Cloud Integration — Design + +Date: 2026-07-11 +Status: Approved (design review complete) + +## Goal + +Add an Enphase cloud component to Predbat, modelled on the existing Fox cloud +integration (`apps/predbat/fox.py`), providing full monitoring and battery +control for Enphase IQ Battery (Encharge) systems via the Enphase Enlighten +cloud, so that an Enphase site can be used as a Predbat-controlled inverter +with no local hardware access. + +Scope decisions (agreed): + +- **Full control in one go** — monitoring and battery control ship together. +- **Password authentication only** in v1. The unofficial Enlighten API has no + OAuth; the official OAuth developer API (api.enphaseenergy.com v4) is + read-only, metered and unusable for control. Email-OTP MFA accounts are + detected and rejected with a clear error; MFA support may be added later. +- **Battery + solar only** — no EV charger, heat pump, tariff, storm guard or + grid on/off control. +- **Fox-style schedule entities** — Predbat's Inverter class drives published + HA entities; all Enphase API knowledge stays inside `enphase.py`. + +## Background: the Enphase Enlighten API + +Reference implementation: +(HA custom integration `enphase_ev`, MIT-compatible reference; bespoke aiohttp +client, no pypi dependency). Key facts verified from its source: + +- Base URL `https://enlighten.enphaseenergy.com` (unofficial web-app API). + Browser-mimicry headers (User-Agent, Referer, Origin, + `X-Requested-With: XMLHttpRequest`) are mandatory; wrong headers yield + HTTP 406 or an HTML login wall. +- **Auth chain**: `POST /login/login.json` (form body `user[email]`, + `user[password]`) → Rails session cookie (`_enlighten_4_session`) and JWT + cookie `enlighten_manager_token_production` → `GET /users/self/token` → + e-auth JWT (sent as both `Authorization: Bearer` and `e-auth-token`). + No refresh token: on 401, re-run the password login. MFA is signalled by + `requires_mfa` / `login_otp_nonce`. +- **Reads**: + - `/pv/settings//battery_status.json` — per-battery and site SOC + (`current_charge` %), `available_energy` kWh, `max_capacity` kWh, + `available_power`/`max_power` kW, status, live profile label. + - `/pv/systems//today` — cadence-independent per-channel `totals` + (Wh) for the current day plus 15-minute interval energy buckets + (`interval_length`, `start_time`) per channel, including the flow + decomposition (`solar_home`, `solar_grid`, `grid_home`, `battery_home`, + `battery_grid`, `grid_battery`, `solar_battery`) used to derive + charge/discharge/export totals that have no single channel of their own. + **Implementation note (superseding this bullet's original research):** an + earlier iteration read `/pv/systems//lifetime_energy` (daily-bucketed + totals requiring delta-tracking across polls); it was replaced by `/today` + because its totals are correct regardless of the site's reporting cadence + and its interval buckets give a stable instantaneous power estimate with no + cross-poll state. See `EnphaseAPI.get_today()`, `today_channel_kwh()` and + `interval_power()` in `enphase.py`. + - `/app-api//get_latest_power` — latest real consumption power sample. + - `/app-api/search_sites.json` — site discovery; + `/app-api//devices.json` — device inventory. +- **Battery control** ("BatteryConfig" microservice, + `/service/batteryConfig/api/v1/...`; needs Bearer JWT + + `Origin/Referer: https://battery-profile-ui.enphaseenergy.com`, `username` + and `requestid` headers; multiple regional header variants exist): + - `PUT /profile/` — set profile: `self-consumption`, `cost_savings`, + `backup_only`, `ai_optimisation`; body includes `batteryBackupPercentage` + (reserve %). + - `GET/PUT /batterySettings/` — `chargeFromGrid` toggle, shutdown SOC + (`veryLowSoc`); `POST /batterySettings/acceptDisclaimer/` — one-time + ITC disclaimer required before charge-from-grid. + - `GET/POST /battery/sites//schedules`, `PUT /schedules/`, + `POST /schedules//delete` — persistent schedule objects with families: + - `cfg` (charge from grid): `startTime`/`endTime` (HH:MM), `days`, + `timezone`, `enabled`, `limit` = **charge target SOC %** (5–100). + - `dtg` (discharge to grid): same shape; `limit` = **SOC floor** the + battery discharges down to. **Feature-gated per site** — availability + flags (`scheduleSupported`, `forceScheduleSupported`, + `forceScheduleOpted`, `batteryLimitSupport`, country/region) come from + site settings and the schedule control payload. + - `rbd` (restrict battery discharge): a window in which the battery will + not discharge — used for Predbat freeze modes. +- **Quirks to design for**: writes settle asynchronously (re-read to confirm; + profile changes can stay "pending" for minutes); Enlighten rejects logins + with "too many active sessions"; OTP/login endpoints 429 aggressively; + ms-vs-s timestamps and payload aliases vary by endpoint family. + +## Architecture + +### 1. Component: `apps/predbat/enphase.py` + +`class EnphaseAPI(ComponentBase)` — no `OAuthMixin` (cookie/JWT auth, not +OAuth). Same shape as `FoxAPI`: + +- `initialize(username, password, site_id=None, automatic=False, + automatic_ignore_pv=False)` — no `__init__` override (ComponentBase calls + `initialize(**kwargs)`). +- `async run(seconds, first)` — polled every 60 s by `ComponentBase.start()`, + with age-based refresh tiers (constants mirroring `FOX_REFRESH_*`): + - static (site discovery, devices, site settings/capability flags): 1440 min + - battery settings + profile + schedules: 5 min + - `battery_status.json` (SOC/power limits): 5 min + - `lifetime_energy` (+ `today` snapshot): 15 min + - `get_latest_power`: 1 min +- Persistent cache through Storage (`ENPHASE_CACHE_KEYS` + + `ENPHASE_CACHE_VERSION`), restored on first run (`load_cached_data()` + pattern), so restarts do not hammer the API and data survives outages. +- `is_alive()` = `api_started` and a discovered site with battery data. +- Daily API-call counters and `record_api_call("enphase", ...)` telemetry, + as fox does. + +### 2. Authentication module (inside enphase.py, isolated) + +- Login flow as above; session cookie jar + both JWTs held in memory and + cached (encrypted-at-rest not required — matches existing components that + store tokens via Storage). +- One `get_headers(family)` helper building per-endpoint-family headers + (`site` reads vs `batteryConfig` control), including browser mimicry. +- BatteryConfig header-variant probing: primary variant first, fall back + through the known variants on auth-shaped failures, cache the working + variant per site via Storage. +- 401 handling: single silent re-login then one retry — behind guard rails + copied from the reference implementation: a fresh login is reused for 30 s + across concurrent failures; a rejected login triggers a 5-minute cooldown; + 3 consecutive rejections or a "too many active sessions" response suspends + login attempts for 24 h (component reports unhealthy via + `fatal_error_occurred`). +- MFA (`requires_mfa`) → fatal error with a log message instructing the user + to disable MFA on the Enphase account. Documented limitation. +- Login-wall detection: HTML body on a JSON endpoint is treated as an auth + failure, never a JSON parse crash. +- Request helper `request_get/post` with bounded retries, `Retry-After` + honouring, and jittered backoff (fox `request_get` pattern). + +### 3. Published entities + +Per site, under `{prefix}_enphase_{site_id}_`: + +**Sensors (monitoring)** + +| Entity | Source | +|---|---| +| `soc_percent` | site `current_charge` (capacity-weighted per-battery fallback) | +| `soc_kw` (available energy) | site `available_energy` | +| `battery_capacity` | site `max_capacity` | +| `battery_rate_max` | site `max_power` (assumed symmetric) | +| `battery_reserve` | profile `batteryBackupPercentage` | +| `battery_status`, `battery_profile` | battery_status / profile reads | +| `pv_today`, `load_today`, `import_today`, `export_today`, `battery_charge_today`, `battery_discharge_today` | today's entries of `lifetime_energy` arrays (cumulative kWh, intraday-updating) | +| `load_power` | `get_latest_power` (real sample) | +| `pv_power`, `grid_power`, `battery_power` | derived by differentiating the `lifetime_energy` flow channels over the polling window (the reference integration's method); documented as estimates | + +**Controls (consumed by Predbat's Inverter class)** — fox naming pattern +`..._battery_schedule_{direction}_{attribute}`: + +- `select` charge/export window start & end times (`OPTIONS_TIME_FULL`) +- `number` charge target SOC, export target SOC, reserve +- `switch` charge enable, export enable, and a `..._write` apply button +- Export controls are only published when the site's capability flags report + `dtg` support; otherwise Predbat is configured without forced-export. + +### 4. Control mapping (write path) + +Events (`select_event`/`number_event`/`switch_event`, routed by +`event_filter="predbat_enphase_"`) mutate a local schedule model; pressing the +write switch calls `apply_battery_schedule()` which diffs desired vs actual +and issues only the changed calls: + +| Predbat intent | Enphase calls | +|---|---| +| Self-Use (no active window) | `PUT profile` → `self-consumption` + reserve %; cfg/dtg schedules disabled | +| Forced Charge window | ensure `chargeFromGrid` enabled (accept ITC disclaimer once); create/update `cfg` schedule: window times, `limit` = charge target SOC | +| Forced Export window | create/update `dtg` schedule: window times, `limit` = export target SOC (only when site supports dtg) | +| Charge/discharge freeze | `rbd` schedule window (battery discharge blocked, SOC held) | +| Reserve | `batteryBackupPercentage` via profile PUT | + +Write-settle handling: after a write, bounded re-reads confirm the change +(writes land asynchronously, sometimes minutes later); a pending flag +suppresses duplicate PUTs; all writes are change-gated (no-op if the value +already matches), following fox `write_setting_from_event`. + +### 5. Predbat wiring (config only — no core-code changes) + +- `components.py`: `COMPONENT_LIST["enphase"]` — `class: EnphaseAPI`, + `name: "Enphase API"`, `event_filter: "predbat_enphase_"`, `phase: 1`, + args: `username` → `enphase_username` (required), `password` → + `enphase_password` (required), `site_id` → `enphase_site_id`, + `automatic` → `enphase_automatic`, `automatic_ignore_pv` → + `enphase_automatic_ignore_pv`. +- `config.py`: `APPS_SCHEMA` entries for those keys, and + `INVERTER_DEF["EnphaseCloud"]` modelled on `FoxCloud`: HA-entity control + (`has_rest_api: False`), `time_button_press: True`, + `charge_time_entity_is_option: True`, charge target SOC supported (via cfg + `limit`), export target SOC supported where dtg is available, and + `can_span_midnight: False` — Enphase windows are HH:MM within one day, and + this flag makes Predbat split midnight-crossing windows automatically + (same as FoxCloud). +- `automatic_config()`: sets `inverter_type=["EnphaseCloud"]` and points + `soc_percent`, `soc_max`, `battery_rate_max`, `load_today`, `pv_today`, + `import_today`, `export_today`, charge/export window entities, target SOC + numbers, reserve and `schedule_write_button` at the published entities + (fox.py:2204 pattern). +- Template `templates/enphase_cloud.yaml`. + +### 6. Testing + +`apps/predbat/tests/test_enphase_api.py` using the fox mock pattern: +`MockEnphaseAPI(EnphaseAPI)` overriding `request_get`/`request_post`, +`dashboard_item`, state wrappers and `log`, with +`set_http_response(path, status, json_data, ...)` to simulate HTTP statuses, +HTML login walls and Enphase payloads. Coverage targets: + +- login happy path (cookie + token extraction), MFA rejection, login-wall + detection, "too many sessions" 24 h suspension, 401 → re-login → retry, + cooldown behaviour +- header construction per endpoint family; BatteryConfig variant fallback + + caching +- parsing: battery_status (per-battery weighting), lifetime_energy (today + extraction, aliases), latest power (ms vs s timestamps), derived power +- schedule model: compute/validate/diff, cfg/dtg/rbd payload construction, + limit bounds, dtg capability gating, write-settle confirm + pending state +- event handlers (select/number/switch) and `apply_battery_schedule` +- `automatic_config()` arg wiring +- cache save/load and cache-version migration + +Registered in `unit_test.py` as `("enphase_api", run_enphase_api_tests, ...)`. +100% docstring coverage (interrogate) as enforced repo-wide. + +### 7. Documentation + +- `docs/components.md`: "Enphase API (enphase)" section — what it does, + config table, MFA limitation, unofficial-API risk statement. +- `docs/inverter-setup.md`: "Enphase Cloud" section. +- `docs/apps-yaml.md`: `enphase_*` keys. +- `.cspell/custom-dictionary-workspace.txt`: Enphase, Enlighten, Encharge, + Enpower, entrez, etc. + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Unofficial API changes without notice | Isolate all endpoint/paths/headers in constants; cache-version gate; health monitoring surfaces breakage quickly | +| Account lockout / too-many-sessions | Login guard rails (30 s reuse, 5 min cooldown, 24 h suspension); never burst password logins | +| Writes settle slowly (minutes) | Predbat already writes schedules ahead of window start (fox `time_button_press` model); pending-state + confirm re-reads | +| dtg unavailable in many regions | Capability-gated: component publishes export controls only when supported; Predbat plans without forced export otherwise | +| Regional header variants | Variant probing with per-site caching of the working variant | +| MFA accounts | Detected, clear error, documented; future enhancement (OTP entry entity) | + +## Out of scope (future enhancements) + +- Email-OTP MFA support via an OTP input entity +- AWS IoT MQTT livestream for true real-time power +- EV charger (IQ EVSE), heat pump, tariff, storm guard, grid on/off +- Legacy AC Battery (HTML-scraped endpoints) diff --git a/templates/enphase_cloud.yaml b/templates/enphase_cloud.yaml new file mode 100644 index 000000000..572ee7142 --- /dev/null +++ b/templates/enphase_cloud.yaml @@ -0,0 +1,388 @@ +# ------------------------------------------------------------------ +# This is an example configuration, please modify it +# ------------------------------------------------------------------ +--- +pred_bat: + module: predbat + class: PredBat + + # Sets the prefix for all created entities in HA - only change if you want to run more than once instance + prefix: predbat + + # Timezone to work in + timezone: Europe/London + + # Currency, symbol for main currency second symbol for 1/100s e.g. $ c or £ p or e c + currency_symbols: + - '£' + - 'p' + + # Number of threads to use in plan calculation + # Can be auto for automatic, 0 for off or values 1-N for a fixed number + threads: auto + + # XXX: This is a configuration template, delete this line once you edit your configuration + template: True + + # Sets the maximum period of zero load before the gap is filled, default 30 minutes + # To disable set it to 1440 + load_filter_threshold: 30 + + # Enter the username/password you use to log in to the Enphase Enlighten app/web site. + # There is no official Enphase API with battery control, so Predbat talks to the same + # unofficial web endpoints the Enlighten app uses. + enphase_username: 'xxxxxxx' + enphase_password: 'xxxxxxx' + enphase_automatic: True + # When True, Predbat won't take over pv_today/pv_power from Enphase (e.g. if you already + # have a better PV sensor configured elsewhere) + enphase_automatic_ignore_pv: False + # + # Run balance inverters every N seconds (0=disabled) - only for multi-inverter + #balance_inverters_seconds: 60 + # + + # Some inverters don't turn off when the rate is set to 0, still charge or discharge at around 200w + # The value can be set here in watts to model this (doesn't change operation) + inverter_battery_rate_min: + - 200 + + # Workaround to limit the maximum reserve setting, some inverters won't allow 100% to be set + # Comment out if your inverter allows 100% + #inverter_reserve_max : 98 + + # Some batteries tail off their charge rate at high soc% + # enter the charging curve here as a % of the max charge rate for each soc percentage. + # the default is 1.0 (full power) + # The example below is from GE 9.5kwh battery with latest firmware and gen1 inverter + battery_charge_power_curve: + 100 : 0.15 + 99 : 0.15 + 98 : 0.22 + 97 : 0.31 + 96 : 0.42 + 95 : 0.48 + 94 : 0.58 + 93 : 0.68 + 92 : 0.77 + 91 : 0.85 + 90 : 0.94 + battery_discharge_power_curve: + 4: 1.0 + + # Inverter clock skew in minutes, e.g. 1 means it's 1 minute fast and -1 is 1 minute slow + # Separate start and end options are applied to the start and end time windows, mostly as you want to start late (not early) and finish early (not late) + # Separate discharge skew for discharge windows only + inverter_clock_skew_start: 0 + inverter_clock_skew_end: 0 + inverter_clock_skew_discharge_start: 0 + inverter_clock_skew_discharge_end: 0 + + # Clock skew adjusts the Appdaemon time + # This is the time that Predbat takes actions like starting discharge/charging + # Only use this for workarounds if your inverter time is correct but Predbat is somehow wrong (AppDaemon issue) + # 1 means add 1 minute to AppDaemon time, -1 takes it away + clock_skew: 0 + + # Solcast cloud interface, set this or the local interface below + #solcast_host: 'https://api.solcast.com.au/' + #solcast_api_key: 'xxxx' + #solcast_poll_hours: 8 + + # Set these to match solcast sensor names if not using the cloud interface + # The regular expression (re:) makes the solcast bit optional + # If these don't match find your own names in Home Assistant + pv_forecast_today: re:(sensor.(solcast_|)(pv_forecast_|)forecast_today) + pv_forecast_tomorrow: re:(sensor.(solcast_|)(pv_forecast_|)forecast_tomorrow) + pv_forecast_d3: re:(sensor.(solcast_|)(pv_forecast_|)forecast_(day_3|d3)) + pv_forecast_d4: re:(sensor.(solcast_|)(pv_forecast_|)forecast_(day_4|d4)) + + # car_charging_energy defines an incrementing sensor which measures the charge added to your car + # is used for car_charging_hold feature to filter out car charging from the previous load data + # Automatically set to detect Wallbox and Zappi, if it doesn't match manually enter your sensor name + # Also adjust car_charging_energy_scale if it's not in kwH to fix the units + car_charging_energy: 're:(sensor.myenergi_zappi_[0-9a-z]+_charge_added_session|sensor.wallbox_portal_added_energy)' + + # Defines the number of cars modelled by the system, set to 0 for no car + num_cars: 1 + + # car_charging_planned is set to a sensor which when positive indicates the car will charged in the upcoming low rate slots + # This should not be needed if you use Intelligent Octopus slots which will take priority if enabled + # The list of possible values is in car_charging_planned_response + # Auto matches Zappi and Wallbox, or change it for your own + # One entry per car + car_charging_planned: + - 're:(sensor.wallbox_portal_status_description|sensor.myenergi_zappi_[0-9a-z]+_plug_status)' + + car_charging_planned_response: + - 'yes' + - 'on' + - 'true' + - 'connected' + - 'ev connected' + - 'charging' + - 'paused' + - 'waiting for car demand' + - 'waiting for ev' + - 'scheduled' + - 'enabled' + - 'latched' + - 'locked' + - 'plugged in' + - 'waiting' + + # In some cases car planning is difficult (e.g. Ohme with Intelligent doesn't report slots) + # The car charging now can be set to a sensor to indicate the car is charging and to plan + # for it to charge during this 30 minute slot + #car_charging_now: + # - off + + # Positive responses for car_charging_now + car_charging_now_response: + - 'yes' + - 'on' + - 'true' + + # To make planned car charging more accurate, either using car_charging_planned or the Octopus Energy plugin, + # specify your battery size in kwh, charge limit % and current car battery soc % sensors/values. + # If you have Intelligent Octopus the battery size and limit will be extracted from the Octopus Energy plugin directly. + # Set the car SoC% if you have it to give an accurate forecast of the cars battery levels. + # One entry per car if you have multiple cars. + #car_charging_battery_size: + # - 75 + #car_charging_limit: + # - 're:number.tsunami_charge_limit' + #car_charging_soc: + # - 're:sensor.tsunami_battery' + + # One per car, when true only one car can charge at once, when False multiple cars can charge at once + #car_charging_exclusive: + # - True + + # If you have Octopus Intelligent Go and are not using the Octopus Direct connection method, enable the intelligent slot information to add to pricing + # Will automatically disable if not found, or comment out to disable fully + # When enabled it overrides the 'car_charging_planned' feature and predict the car charging based on the intelligent plan (unless Octopus intelligent charging is False) + # This matches the intelligent slot from the Octopus Energy integration + octopus_intelligent_slot: 're:(binary_sensor.octopus_energy([0-9a-z_]+|)_intelligent_dispatching)' + octopus_ready_time: 're:((select|time).octopus_energy_([0-9a-z_]+|)_intelligent_target_time)' + octopus_charge_limit: 're:(number.octopus_energy([0-9a-z_]+|)_intelligent_charge_target)' + + # Example alternative configuration for Ohme integration release >=v0.6.1 + #octopus_intelligent_slot: 'binary_sensor.ohme_slot_active' + #octopus_ready_time: 'time.ohme_target_time' + #octopus_charge_limit: 'number.ohme_target_percent' + + # Set this to False if you use Octopus Intelligent slot for car planning but when on another tariff e.g. Agile + #octopus_slot_low_rate: False + + # Carbon Intensity data from National grid + # carbon_postcode: 'SW1 5NA' + # carbon_automatic: True + + # Octopus saving session points to the saving session Sensor in the Octopus plugin, when enabled saving sessions will be at the assumed + # Rate is read automatically from the add-in and converted to pence using the conversion rate below (default is 8) + octopus_saving_session: 're:(event.octopus_energy([0-9a-z_]+|)_saving_session_event(s|))' + octopus_saving_session_octopoints_per_penny: 8 + + # Octopus free session points to the free session Sensor in the Octopus plugin + # Note: You must enable this event sensor in the Octopus Integration in Home Assistant for it to work + octopus_free_session: 're:(event.octopus_energy_([0-9a-z_]+|)_octoplus_free_electricity_session_events)' + + # Alternative scraper from Octopus web site if the above is not working + # octopus_free_url: 'http://octopus.energy/free-electricity' + + # Enter your Axle VPP API key if you have signed up to the Axle service in the UK + # axle_api_key: "xxxxxxx" + + # Energy rates + # Please set one of these three, if multiple are set then Octopus is used first, second rates_import/rates_export and latest basic metric + + # Set import and export entity to point to the Octopus Energy plugin import and export sensors + # automatically matches your meter number assuming you have only one (no need to edit the below) + # Will be ignored if you don't have the sensor but will error if you do have one and it's incorrect + # Note: To get detailed energy rates you need to go in and manually enable the following events in HA + # event.octopus_energy_electricity_xxxxxxxx_previous_day_rates + # event.octopus_energy_electricity_xxxxxxxx_current_day_rates + # event.octopus_energy_electricity_xxxxxxxx_next_day_rates + # and if you have export enable: + # event.octopus_energy_electricity_xxxxxxxx_export_previous_day_rates + # event.octopus_energy_electricity_xxxxxxxx_export_current_day_rates + # event.octopus_energy_electricity_xxxxxxxx_export_next_day_rates + # Predbat will automatically find the event. entities from the link below to the sensors + metric_octopus_import: 're:(sensor.(octopus_energy_|)electricity_[0-9a-z]+_[0-9a-z]+_current_rate)' + metric_octopus_export: 're:(sensor.(octopus_energy_|)electricity_[0-9a-z]+_[0-9a-z]+_export_current_rate)' + + # Standing charge in pounds, can be set to a sensor or manually entered (e.g. 0.50 is 50p) + # The default below will pick up the standing charge from the Octopus Plugin + # The standing charge only impacts the cost graphs and doesn't change the way Predbat plans + # If you don't want to show the standing charge then just delete this line or set to zero + metric_standing_charge: 're:(sensor.(octopus_energy_|)electricity_[0-9a-z]+_[0-9a-z]+_current_standing_charge)' + + # Energy data service (https://github.com/MTrab/energidataservice) + #metric_energidataservice_import: 'sensor.energi_data_service_import' + #metric_energidataservice_export: 'sensor.energi_data_service_export' + + # Or set your actual rates across time for import and export + # If start/end is missing it's assumed to be a fixed rate + # Gaps are filled with zero rate + #rates_import: + # - start: "00:30:00" + # end: "04:30:00" + # rate: 7.5 + # - start: "04:30:00" + # end: "00:30:00" + # rate: 40.0 + # + #rates_export: + # - rate: 4.2 + + # Can be used instead of the plugin to get import rates directly online + # Overrides metric_octopus_import and rates_import + # See the 'energy rates' part of the documentation for instructions on how to find the correct URL for your tariff and DNO region + # + # rates_import_octopus_url : "https://api.octopus.energy/v1/products/FLUX-IMPORT-23-02-14/electricity-tariffs/E-1R-FLUX-IMPORT-23-02-14-A/standard-unit-rates" + # rates_import_octopus_url : "https://api.octopus.energy/v1/products/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-A/standard-unit-rates" + + # Overrides metric_octopus_export and rates_export + # rates_export_octopus_url: "https://api.octopus.energy/v1/products/FLUX-EXPORT-23-02-14/electricity-tariffs/E-1R-FLUX-EXPORT-23-02-14-A/standard-unit-rates" + # rates_export_octopus_url: "https://api.octopus.energy/v1/products/AGILE-OUTGOING-19-05-13/electricity-tariffs/E-1R-AGILE-OUTGOING-19-05-13-A/standard-unit-rates/" + + # Import rates can be overridden with rate_import_override + # Export rates can be overridden with rate_export_override + # Use the same format as above, but a date can be included if it just applies for a set day (e.g. Octopus power ups) + # This will override even the Octopus plugin rates if enabled + # + #rates_import_override: + # - date: '2023-09-10' + # start: '14:00:00' + # end: '14:30:00' + # rate: 112 + # load_scaling: 0.8 + + # Days previous sets how many days of load history Predbat searches when forecasting your future house load. + # By default this uses a weighted-bucket forecast that automatically accounts for day-of-week, holiday mode + # and recency (days_previous_auto) - see the documentation for other options, such as picking specific + # days/weights instead, or using Load ML. + days_previous: + - 7 + + # Number of hours forward to forecast, best left as-is unless you have specific reason + forecast_hours: 48 + + # Specify the devices that notifies are sent to, the default is 'notify' which goes to all + #notify_devices: + # - mobile_app_treforsiphone12_2 + + # Battery scaling makes the battery smaller (e.g. 0.9) or bigger than its reported + # If you have an 80% DoD battery that falsely reports it's kwh then set it to 0.8 to report the real figures + # One per inverter + battery_scaling: + - 1.0 + + # Can be used to scale import and export data, used for workarounds + import_export_scaling: 1.0 + + # Export triggers: + # For each trigger give a name, the minutes of export needed and the energy required in that time + # Multiple triggers can be set at once so in total you could use too much energy if all run + # Creates an entity called 'binary_sensor.predbat_export_trigger_' which will be turned On when the condition is valid + # connect this to your automation to start whatever you want to trigger + export_triggers: + - name: 'large' + minutes: 60 + energy: 1.0 + - name: 'small' + minutes: 15 + energy: 0.25 + + # If you have a sensor that gives the energy consumed by your solar diverter then add it here + # this will make the predictions more accurate. It should be an incrementing sensor, it can reset at midnight or not + # It's assumed to be in Kwh but scaling can be applied if need be + #iboost_energy_today: 'sensor.xxxxx' + #iboost_energy_scaling: 1.0 + # Gas rates for comparison + #metric_octopus_gas: 're:(sensor.(octopus_energy_|)gas_[0-9a-z]+_[0-9a-z]+_current_rate)' + + # Nordpool market energy rates + #futurerate_url: 'https://dataportal-api.nordpoolgroup.com/api/DayAheadPrices?date=DATE&market=N2EX_DayAhead&deliveryArea=UK¤cy=GBP' + #futurerate_adjust_import: True + #futurerate_adjust_export: False + #futurerate_peak_start: "16:00:00" + #futurerate_peak_end: "19:00:00" + #futurerate_peak_premium_import: 14 + #futurerate_peak_premium_export: 6.5 + + # Tariff comparison feature + # Adjust this list to the tariffs you want to compare, include your current tariff also + # Octopus region code (https://energy-stats.uk/dno-region-codes-explained/) + #octopus_region: "A" + #compare_list: + # - id: 'current' + # name: 'Current Tariff' + # - id: 'cap_seg' + # name: 'Price cap import/Seg export' + # rates_import: + # - rate: 24.86 + # rates_export: + # - rate: 4.1 + # - id: 'igo_fixed' + # name: 'Intelligent GO import/Fixed export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/INTELLI-BB-VAR-23-03-01/electricity-tariffs/E-1R-INTELLI-BB-VAR-23-03-01-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/OUTGOING-VAR-BB-24-10-26/electricity-tariffs/E-1R-OUTGOING-VAR-BB-24-10-26-{octopus_region}/standard-unit-rates/' + # - id: 'igo_agile' + # name: 'Intelligent GO import/Agile export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/INTELLI-BB-VAR-23-03-01/electricity-tariffs/E-1R-INTELLI-BB-VAR-23-03-01-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-OUTGOING-BB-23-02-28/electricity-tariffs/E-1R-AGILE-OUTGOING-BB-23-02-28-{octopus_region}/standard-unit-rates/' + # - id: 'go_fixed' + # name: 'GO import/Fixed export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/GO-VAR-BB-23-02-07/electricity-tariffs/E-1R-GO-VAR-BB-23-02-07-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/OUTGOING-VAR-BB-24-10-26/electricity-tariffs/E-1R-OUTGOING-VAR-BB-24-10-26-{octopus_region}/standard-unit-rates/' + # - id: 'go_agile' + # name: 'GO import/Agile export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/GO-VAR-BB-23-02-07/electricity-tariffs/E-1R-GO-VAR-BB-23-02-07-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-OUTGOING-BB-23-02-28/electricity-tariffs/E-1R-AGILE-OUTGOING-BB-23-02-28-{octopus_region}/standard-unit-rates/' + # - id: 'agile_fixed' + # name: 'Agile import/Fixed export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/OUTGOING-VAR-BB-24-10-26/electricity-tariffs/E-1R-OUTGOING-VAR-BB-24-10-26-{octopus_region}/standard-unit-rates/' + # - id: 'agile_agile' + # name: 'Agile import/Agile export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-OUTGOING-BB-23-02-28/electricity-tariffs/E-1R-AGILE-OUTGOING-BB-23-02-28-{octopus_region}/standard-unit-rates/' + # - id: 'flux' + # name: 'Flux import/Export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/FLUX-IMPORT-23-02-14/electricity-tariffs/E-1R-FLUX-IMPORT-23-02-14-{octopus_region}/standard-unit-rates' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/FLUX-EXPORT-BB-23-02-14/electricity-tariffs/E-1R-FLUX-EXPORT-BB-23-02-14-{octopus_region}/standard-unit-rates' + # - id: 'cosy_fixed' + # name: 'Cosy import/Fixed export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/COSY-22-12-08/electricity-tariffs/E-1R-COSY-22-12-08-{octopus_region}/standard-unit-rates' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/OUTGOING-VAR-BB-24-10-26/electricity-tariffs/E-1R-OUTGOING-VAR-BB-24-10-26-{octopus_region}/standard-unit-rates/' + # - id: 'cosy_agile' + # name: 'Cosy import/Agile export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/COSY-22-12-08/electricity-tariffs/E-1R-COSY-22-12-08-{octopus_region}/standard-unit-rates' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/AGILE-OUTGOING-BB-23-02-28/electricity-tariffs/E-1R-AGILE-OUTGOING-BB-23-02-28-{octopus_region}/standard-unit-rates/' + # - id: 'snug_fixed' + # name: 'Snug import/Fixed export' + # rates_import_octopus_url: 'https://api.octopus.energy/v1/products/SNUG-24-11-07/electricity-tariffs/E-1R-SNUG-24-11-07-{octopus_region}/standard-unit-rates/' + # rates_export_octopus_url: 'https://api.octopus.energy/v1/products/OUTGOING-VAR-BB-24-10-26/electricity-tariffs/E-1R-OUTGOING-VAR-BB-24-10-26-{octopus_region}/standard-unit-rates/' + + # Alert feeds - customise to your country and the alert types, severity and keep value + #alerts: + # url: "https://feeds.meteoalarm.org/feeds/meteoalarm-legacy-atom-united-kingdom" + # event: "(Amber|Yellow|Orange|Red).*(Wind|Snow|Fog|Rain|Thunderstorm|Avalanche|Frost|Heat|Coastal event|Flood|Forestfire|Ice|Low temperature|Storm|Tornado|Tsunami|Volcano|Wildfire)" + # severity: "Moderate|Severe|Extreme" + # certainty: "Possible|Likely|Expected" + # keep: 40 + + # Watch list, a list of sensors to watch for changes and then update the plan if they change + # This is useful for things like the Octopus Intelligent Slot sensor so that the plan update as soon as you plugin in + # Only uncomment the items you actually have set up above in apps.yaml, of course you can add your own as well + # Note those using +[] are lists that are appended to this list, whereas {} items are single items only + #watch_list: + # - '{octopus_intelligent_slot}' + # - '{octopus_ready_time}' + # - '{octopus_charge_limit}' + # - '{octopus_saving_session}' + # - '+[car_charging_planned]' + # - '+[car_charging_soc]' + # - '{car_charging_now}'