diff --git a/apps/predbat/enphase.py b/apps/predbat/enphase.py index 065182fbb..25267e1d1 100644 --- a/apps/predbat/enphase.py +++ b/apps/predbat/enphase.py @@ -751,14 +751,111 @@ async def _write_schedule(self, site_id, family, start_time_ha, end_time_ha, lim await self.get_schedules(site_id) return result is not None + def _is_schedule_pending(self, site_id, family): + """Return True when the cached cloud schedule exists but is stuck in pending status.""" + family_key = family.lower() + entry = self.schedules.get(site_id, {}).get(family_key, {}) + # get_schedules always stores a "status" key (None when the cloud omits scheduleStatus), + # so coalesce to "" before comparing rather than relying on the dict-get default. + return isinstance(entry, dict) and (entry.get("status") or "").lower() == "pending" + + def _invalidate_cached_schedule(self, site_id, family): + """Clear the cached cloud schedule so the next apply detects a diff and retries. + + Sets startTime to an empty string so that schedules_equal fails its window + comparison for an enabled schedule (``""[:5] != start_hm``), causing + _write_schedule to re-issue the update on the next apply as a PUT (the id is + preserved). Invalidation only runs after a failed activation, which only happens + for enabled schedules, so the disable path is never affected. + """ + family_key = family.lower() + entry = self.schedules.get(site_id, {}).get(family_key) + if isinstance(entry, dict): + entry["startTime"] = "" + + async def _activate_control_mode(self, site_id, family, body, apply_cache, label): + """Commit a freshly written schedule to the gateway via a batterySettings PUT. + + A schedule write leaves the schedule in "pending" status; this follow-up PUT + (carrying a per-mode ``body``) transitions it to active so the gateway acts on it. + On success it clears the cached pending marker and calls ``apply_cache`` to + optimistically record the change in ``battery_settings``; on failure it logs and + invalidates the cached schedule so the next apply re-detects a diff and retries + write + activation. Returns True if the PUT succeeded. + """ + params = {"source": "enho"} + if self.user_id: + params["userId"] = self.user_id + result = await self.request_json("PUT", f"{BATTERY_CONFIG_BASE}/batterySettings/{site_id}", family="battery_config", params=params, json_body=body) + if result is not None: + apply_cache(self.battery_settings.setdefault(site_id, {})) + entry = self.schedules.get(site_id, {}).get(family.lower()) + if isinstance(entry, dict): + entry.pop("status", None) + else: + self.log(f"Warn: Enphase: {label} activation failed for site {site_id}") + self._invalidate_cached_schedule(site_id, family) + return result is not None + + async def _activate_cfg_mode(self, site_id, family=SCHEDULE_CHARGE): + """Activate charge-from-grid (CFG) after writing its schedule. + + The activation PUT both accepts the ITC disclaimer inline (``acceptedItcDisclaimer``) + and enables ``chargeFromGrid``, transitioning the pending schedule to active so the + gateway starts charging. See _activate_control_mode for success/failure handling. + """ + body = {"chargeFromGrid": True, "acceptedItcDisclaimer": datetime.now(timezone.utc).isoformat()} + + def apply_cache(settings): + """Record the enabled charge-from-grid setting in the battery-settings cache.""" + settings["chargeFromGrid"] = True + + return await self._activate_control_mode(site_id, family, body, apply_cache, "CFG") + + async def _activate_dtg_mode(self, site_id, family=SCHEDULE_EXPORT): + """Activate discharge-to-grid (DTG) after writing its schedule; see _activate_control_mode.""" + + def apply_cache(settings): + """Record the enabled dtgControl setting in the battery-settings cache.""" + settings.setdefault("dtgControl", {})["enabled"] = True + + return await self._activate_control_mode(site_id, family, {"dtgControl": {"enabled": True}}, apply_cache, "DTG") + + async def _activate_rbd_mode(self, site_id, family=SCHEDULE_FREEZE): + """Activate restrict-battery-discharge (RBD) after writing its schedule; see _activate_control_mode.""" + + def apply_cache(settings): + """Record the enabled rbdControl setting in the battery-settings cache.""" + settings.setdefault("rbdControl", {})["enabled"] = True + + return await self._activate_control_mode(site_id, family, {"rbdControl": {"enabled": True}}, apply_cache, "RBD") + + async def _write_and_activate(self, site_id, family, start_time_ha, end_time_ha, limit, enabled, activate, force_activate=False): + """Write one schedule family, then activate it when appropriate. + + Activation fires when the mode is enabled and any of: the schedule was just written, + ``force_activate`` is set (e.g. the underlying battery setting still needs enabling), or + the cached schedule is stuck pending. Activation PUTs are not schedule writes, so the + return value reflects only whether _write_schedule issued a write. + """ + wrote = await self._write_schedule(site_id, family, start_time_ha, end_time_ha, limit, enabled) + if enabled and (wrote or force_activate or self._is_schedule_pending(site_id, family)): + if not wrote: + self.log(f"Enphase: {family} schedule for site {site_id} needs activation; activating without rewrite") + await activate(site_id) + return wrote + async def _ensure_charge_from_grid(self, site_id): - """Enable the charge-from-grid setting, accepting the one-time ITC disclaimer first.""" + """Accept the one-time ITC disclaimer required before charge-from-grid can be enabled. + + The ``chargeFromGrid`` battery setting itself is enabled by the CFG activation PUT + (_activate_cfg_mode), so this only performs the disclaimer acceptance and avoids a + redundant second batterySettings write on the enable path. + """ if self.battery_settings.get(site_id, {}).get("chargeFromGrid"): return - self.log(f"Enphase: Enabling charge-from-grid on site {site_id}") + self.log(f"Enphase: Accepting ITC disclaimer 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. @@ -818,11 +915,14 @@ async def apply_battery_schedule(self, site_id): await self.set_reserve(site_id, desired_reserve) wrote = True - # Forced charge window (CFG) + # Forced charge window (CFG). Enabling chargeFromGrid *is* the activation PUT, so force + # activation whenever the setting is still off, even if the schedule already matches. charge = local.get("charge", {}) - if charge.get("enable"): + charge_enabled = bool(charge.get("enable")) + if charge_enabled: 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)) + cfg_needs_enable = not self.battery_settings.get(site_id, {}).get("chargeFromGrid") + wrote |= await self._write_and_activate(site_id, SCHEDULE_CHARGE, charge.get("start_time", "00:00:00"), charge.get("end_time", "00:00:00"), charge.get("soc", 100), charge_enabled, self._activate_cfg_mode, force_activate=cfg_needs_enable) # Export window. Predbat encodes the mode in the export/discharge target SOC: # < 99 -> real forced export to that floor (DTG) @@ -842,10 +942,10 @@ async def apply_battery_schedule(self, site_id): 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) + wrote |= await self._write_and_activate(site_id, SCHEDULE_EXPORT, export_start, export_end, dtg_limit, real_export, self._activate_dtg_mode) # 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) + wrote |= await self._write_and_activate(site_id, SCHEDULE_FREEZE, export_start, export_end, None, freeze_export, self._activate_rbd_mode) return wrote async def get_battery_status(self, site_id): diff --git a/apps/predbat/tests/test_enphase_api.py b/apps/predbat/tests/test_enphase_api.py index e7ae3d543..957528677 100644 --- a/apps/predbat/tests/test_enphase_api.py +++ b/apps/predbat/tests/test_enphase_api.py @@ -86,7 +86,7 @@ def set_http_response(self, path, status=200, json_data=None, text_data=None): 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}) + self.request_log.append({"method": method, "path": path, "json": json_body, "data": data, "params": params}) 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 "", {} @@ -1096,6 +1096,7 @@ def test_apply_updates_existing_by_id(): 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/batterySettings/12345", 200, {}) # activation PUT api.set_http_response( "/service/batteryConfig/api/v1/battery/sites/12345/schedules", 200, @@ -1120,6 +1121,7 @@ def test_apply_caches_write_no_rewrite(): 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, {}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) # activation PUT 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")] @@ -1150,6 +1152,421 @@ def test_apply_no_change_no_write(): assert writes == [] +def test_activate_cfg_mode(): + """_activate_cfg_mode PUTs batterySettings with acceptedItcDisclaimer to transition CFG from pending to active.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.battery_settings.clear() + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + run_async(api._activate_cfg_mode("12345")) + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"] == "/service/batteryConfig/api/v1/batterySettings/12345"] + assert len(puts) == 1 + body = puts[0]["json"] + assert body["chargeFromGrid"] is True + assert "acceptedItcDisclaimer" in body + # Verify required query params are sent + assert puts[0].get("params") == {"source": "enho", "userId": "9999"} + # Timestamp must be a recent UTC ISO-8601 string (e.g. "2026-07-14T20:25:00+00:00") + from datetime import datetime as dt, timezone as tz + + parsed = dt.fromisoformat(body["acceptedItcDisclaimer"]) + age = (dt.now(tz.utc) - parsed).total_seconds() + assert -5 <= age <= 60, f"acceptedItcDisclaimer age {age}s outside [-5, 60] — stale or future timestamp" + # Cached battery settings updated optimistically on success + assert api.battery_settings["12345"]["chargeFromGrid"] is True + + +def test_apply_activates_cfg_after_write(): + """apply_battery_schedule activates CFG mode after a schedule write when charge is enabled.""" + 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}} + # _ensure_charge_from_grid paths + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/acceptDisclaimer/12345", 200, {}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + # _write_schedule (POST) path + 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")) + # CFG schedule was created (POST to schedules) + posts = [r for r in api.request_log if r["method"] == "POST" and r["path"].endswith("/schedules")] + assert len(posts) == 1 + # Exactly one PUT to batterySettings: the activation call. The one-time disclaimer is a + # separate acceptDisclaimer POST, and enabling chargeFromGrid is folded into the activation + # PUT, so there is no redundant second batterySettings write. + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"] == "/service/batteryConfig/api/v1/batterySettings/12345"] + assert len(puts) == 1 + activation_puts = [p for p in puts if p.get("json", {}).get("acceptedItcDisclaimer")] + assert len(activation_puts) == 1 + assert activation_puts[0]["json"]["chargeFromGrid"] is True + assert activation_puts[0].get("params") == {"source": "enho", "userId": "9999"} + + +def test_apply_no_activate_cfg_when_charge_disabled(): + """_activate_cfg_mode is NOT called when the charge window is disabled.""" + 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": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}} + # Only the CFG write (no cloud cfg exists, so it will POST to schedules). + # charge is disabled, so no activation call. + 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")) + # No PUTs to batterySettings (no _ensure_charge_from_grid, no _activate_cfg_mode) + puts = [r for r in api.request_log if r["method"] == "PUT" and "batterySettings" in r["path"]] + assert puts == [] + + +def test_apply_no_activate_cfg_when_unchanged(): + """_activate_cfg_mode is NOT called when the CFG schedule already matches (no-op write).""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + # Cloud CFG already matches the local desired state + 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}} + run_async(api.apply_battery_schedule("12345")) + # No writes at all (schedules_match -> True, _ensure_charge_from_grid already done) + writes = [r for r in api.request_log if r["method"] in ("POST", "PUT")] + assert writes == [] + + +def test_activate_dtg_mode(): + """_activate_dtg_mode PUTs batterySettings with dtgControl.enabled to transition DTG from pending to active.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.battery_settings.clear() + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + run_async(api._activate_dtg_mode("12345")) + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"] == "/service/batteryConfig/api/v1/batterySettings/12345"] + assert len(puts) == 1 + body = puts[0]["json"] + assert body == {"dtgControl": {"enabled": True}} + # Verify required query params are sent + assert puts[0].get("params") == {"source": "enho", "userId": "9999"} + # Cached battery settings updated on success + assert api.battery_settings["12345"]["dtgControl"]["enabled"] is True + + +def test_activate_rbd_mode(): + """_activate_rbd_mode PUTs batterySettings with rbdControl.enabled to transition RBD from pending to active.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.battery_settings.clear() + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + run_async(api._activate_rbd_mode("12345")) + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"] == "/service/batteryConfig/api/v1/batterySettings/12345"] + assert len(puts) == 1 + body = puts[0]["json"] + assert body == {"rbdControl": {"enabled": True}} + # Verify required query params are sent + assert puts[0].get("params") == {"source": "enho", "userId": "9999"} + assert api.battery_settings["12345"]["rbdControl"]["enabled"] is True + + +def test_apply_activates_dtg_after_write(): + """apply_battery_schedule activates DTG mode after a schedule write when real export is enabled.""" + 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": "16:00:00", "end_time": "19:00:00", "soc": 60, "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": []}}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + run_async(api.apply_battery_schedule("12345")) + # DTG schedule was created (POST to schedules with scheduleType DTG) + posts = [r for r in api.request_log if r["method"] == "POST" and r["path"].endswith("/schedules")] + assert len(posts) == 1 + assert posts[0]["json"]["scheduleType"] == "DTG" + # Activation PUT with dtgControl.enabled + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"] == "/service/batteryConfig/api/v1/batterySettings/12345"] + activation = [p for p in puts if p.get("json", {}).get("dtgControl", {}).get("enabled") is True] + assert len(activation) == 1 + assert activation[0].get("params") == {"source": "enho", "userId": "9999"} + + +def test_apply_activates_rbd_after_write(): + """apply_battery_schedule activates RBD mode after a schedule write when freeze export is enabled.""" + 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": "22:00:00", "end_time": "23:00:00", "soc": 99, "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": []}}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + run_async(api.apply_battery_schedule("12345")) + # RBD schedule was created (POST to schedules with scheduleType RBD) + posts = [r for r in api.request_log if r["method"] == "POST" and r["path"].endswith("/schedules")] + assert len(posts) == 1 + assert posts[0]["json"]["scheduleType"] == "RBD" + # Activation PUT with rbdControl.enabled + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"] == "/service/batteryConfig/api/v1/batterySettings/12345"] + activation = [p for p in puts if p.get("json", {}).get("rbdControl", {}).get("enabled") is True] + assert len(activation) == 1 + assert activation[0].get("params") == {"source": "enho", "userId": "9999"} + + +def test_apply_no_activate_dtg_when_export_disabled(): + """_activate_dtg_mode is NOT called when export is disabled (no real or freeze export).""" + 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": "00:00:00", "end_time": "00:00:00", "soc": 5, "enable": False}, + } + # No schedule write (no cloud DTG, export disabled → enabled=False, _write_schedule returns False) + # No HTTP needed — nothing is written. + 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")) + # No PUTs to batterySettings (no activation for DTG or RBD) + puts = [r for r in api.request_log if r["method"] == "PUT" and "batterySettings" in r["path"]] + assert puts == [] + + +def test_apply_no_activate_dtg_when_freeze_not_real_export(): + """_activate_dtg_mode is NOT called for freeze export (soc == 99 triggers RBD, not DTG).""" + 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": "22:00:00", "end_time": "23:00:00", "soc": 99, "enable": True}, + } + # DTG is not written (real_export=False when soc==99), but RBD is. + 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": []}}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + run_async(api.apply_battery_schedule("12345")) + # RBD schedule was created + posts = [r for r in api.request_log if r["method"] == "POST" and r["path"].endswith("/schedules")] + assert len(posts) == 1 + assert posts[0]["json"]["scheduleType"] == "RBD" + # Activation PUTs: only rbdControl, no dtgControl + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"] == "/service/batteryConfig/api/v1/batterySettings/12345"] + dtg_activation = [p for p in puts if p.get("json", {}).get("dtgControl")] + rbd_activation = [p for p in puts if p.get("json", {}).get("rbdControl", {}).get("enabled") is True] + assert dtg_activation == [] + assert len(rbd_activation) == 1 + + +def test_apply_no_activate_dtg_when_unchanged(): + """_activate_dtg_mode is NOT called when the DTG schedule already matches (no-op write).""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + # Cloud DTG already matches local desired state + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": True, "id": "d1", "startTime": "16:00", "endTime": "19:00", "limit": 60, "enabled": 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": "16:00:00", "end_time": "19:00:00", "soc": 60, "enable": True}, + } + run_async(api.apply_battery_schedule("12345")) + # No writes at all (all schedules match cloud state, no reserve change) + writes = [r for r in api.request_log if r["method"] in ("POST", "PUT")] + assert writes == [] + + +def test_activate_cfg_mode_invalidates_cache_on_failure(): + """Cache is cleared on CFG activation failure so next apply retries write+activation.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.battery_settings.clear() + api.schedules["12345"] = {"cfg": {"supported": True, "id": "u1", "startTime": "02:00", "endTime": "05:00", "limit": 90, "enabled": True}} + # Set up a 404 response — activation will fail + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 404, {}) + ok = run_async(api._activate_cfg_mode("12345")) + assert ok is False + # Cached startTime must be cleared so schedules_equal detects a diff next time + cached = api.schedules["12345"]["cfg"] + assert cached["startTime"] == "" + assert "id" in cached # id preserved for PUT update + + +def test_activate_dtg_mode_invalidates_cache_on_failure(): + """Cache is cleared on DTG activation failure so next apply retries write+activation.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.battery_settings.clear() + api.schedules["12345"] = {"dtg": {"supported": True, "id": "d1", "startTime": "16:00", "endTime": "19:00", "limit": 60, "enabled": True}} + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 404, {}) + ok = run_async(api._activate_dtg_mode("12345")) + assert ok is False + cached = api.schedules["12345"]["dtg"] + assert cached["startTime"] == "" + assert "id" in cached + + +def test_activate_rbd_mode_invalidates_cache_on_failure(): + """Cache is cleared on RBD activation failure so next apply retries write+activation.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.battery_settings.clear() + api.schedules["12345"] = {"rbd": {"supported": True, "id": "r1", "startTime": "22:00", "endTime": "23:00", "limit": 99, "enabled": True}} + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 404, {}) + ok = run_async(api._activate_rbd_mode("12345")) + assert ok is False + cached = api.schedules["12345"]["rbd"] + assert cached["startTime"] == "" + assert "id" in cached + + +def test_apply_activates_pending_schedule_without_rewrite(): + """When a schedule matches but status is pending, activation is called without re-writing.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + # Cloud CFG already matches desired state BUT status is pending + api.schedules["12345"] = {"cfg": {"supported": True, "id": "u1", "startTime": "02:00", "endTime": "05:00", "limit": 90, "enabled": True, "status": "pending"}, "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}} + # Activation PUT succeeds + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + run_async(api.apply_battery_schedule("12345")) + # No schedule write (schedules equal) but activation was called + writes = [r for r in api.request_log if r["method"] in ("POST", "PUT") and "schedules" in r["path"]] + assert writes == [] + puts = [r for r in api.request_log if r["method"] == "PUT" and r["path"] == "/service/batteryConfig/api/v1/batterySettings/12345"] + assert len(puts) == 1 # activation PUT, not the _ensure_charge_from_grid one + + +def test_is_schedule_pending_null_status_is_not_pending(): + """_is_schedule_pending must treat a None status as not-pending, not crash on None.lower(). + + get_schedules always writes a "status" key (set to None when the cloud omits scheduleStatus), + so the present-but-None case is the common one and must be handled without raising. + """ + api = MockEnphaseAPI() + api.schedules["12345"] = {"cfg": {"supported": True, "status": None}} + assert api._is_schedule_pending("12345", "CFG") is False + # And the genuine pending case still returns True + api.schedules["12345"]["cfg"]["status"] = "PENDING" + assert api._is_schedule_pending("12345", "CFG") is True + + +def test_apply_no_crash_when_cached_status_none(): + """apply_battery_schedule must not raise when a matching cached schedule has status None. + + Reproduces the get_schedules cache shape (status key present, value None) for a matching, + enabled CFG schedule. Before the fix this hit None.lower() in _is_schedule_pending and + aborted the whole apply. + """ + 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, "status": None}, + "dtg": {"supported": True, "status": None}, + "rbd": {"supported": True, "status": None}, + } + 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}} + run_async(api.apply_battery_schedule("12345")) # must not raise + # Matching schedule + status None (not "pending") => no activation PUT + puts = [r for r in api.request_log if r["method"] == "PUT" and "batterySettings" in r["path"]] + assert puts == [] + + +def test_activate_dtg_mode_clears_pending_status_on_success(): + """A successful DTG activation clears the cached pending marker, like CFG does.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.schedules["12345"] = {"dtg": {"supported": True, "id": "d1", "startTime": "16:00", "endTime": "19:00", "limit": 60, "enabled": True, "status": "pending"}} + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + assert run_async(api._activate_dtg_mode("12345")) is True + assert "status" not in api.schedules["12345"]["dtg"] + + +def test_activate_rbd_mode_clears_pending_status_on_success(): + """A successful RBD activation clears the cached pending marker, like CFG does.""" + api = MockEnphaseAPI() + api.user_id = "9999" + api.schedules["12345"] = {"rbd": {"supported": True, "id": "r1", "startTime": "22:00", "endTime": "23:00", "limit": 99, "enabled": True, "status": "pending"}} + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + assert run_async(api._activate_rbd_mode("12345")) is True + assert "status" not in api.schedules["12345"]["rbd"] + + +def test_apply_pending_dtg_not_reactivated_after_success(): + """A pending DTG is activated once; the next unchanged apply issues no further activation PUT. + + Without clearing the pending marker on success, every subsequent apply would re-fire the + activation PUT while the cached status stayed "pending". + """ + api = MockEnphaseAPI() + api.user_id = "9999" + api.sites = [{"site_id": "12345", "name": "Home"}] + api.schedules["12345"] = {"cfg": {"supported": True}, "dtg": {"supported": True, "id": "d1", "startTime": "16:00", "endTime": "19:00", "limit": 60, "enabled": True, "status": "pending"}, "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": "16:00:00", "end_time": "19:00:00", "soc": 60, "enable": True}} + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + run_async(api.apply_battery_schedule("12345")) + run_async(api.apply_battery_schedule("12345")) + activation = [r for r in api.request_log if r["method"] == "PUT" and r["path"].endswith("/batterySettings/12345") and r.get("json", {}).get("dtgControl")] + assert len(activation) == 1 + + +def test_apply_activates_cfg_when_setting_off_but_schedule_matches(): + """When the CFG schedule already matches but chargeFromGrid is off, activation still fires. + + Enabling chargeFromGrid is the activation PUT, so a matching schedule that was never activated + must still be activated rather than silently skipped. Also covers the status-None path (#1). + """ + 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, "status": None}, + "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}} + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/acceptDisclaimer/12345", 200, {}) + api.set_http_response("/service/batteryConfig/api/v1/batterySettings/12345", 200, {}) + run_async(api.apply_battery_schedule("12345")) + # No schedule write (already matches), but activation fired because chargeFromGrid was off + schedule_writes = [r for r in api.request_log if r["method"] in ("POST", "PUT") and "schedules" in r["path"]] + assert schedule_writes == [] + activation = [r for r in api.request_log if r["method"] == "PUT" and r["path"].endswith("/batterySettings/12345") and r.get("json", {}).get("acceptedItcDisclaimer")] + assert len(activation) == 1 + assert api.battery_settings["12345"]["chargeFromGrid"] is True + + def run_enphase_api_tests(my_predbat): """Run all Enphase API tests, returning 0 on success.""" test_initialize_defaults() @@ -1211,5 +1628,26 @@ def run_enphase_api_tests(my_predbat): test_apply_caches_write_no_rewrite() test_set_reserve_caches_written_value() test_apply_no_change_no_write() + test_activate_cfg_mode() + test_apply_activates_cfg_after_write() + test_apply_no_activate_cfg_when_charge_disabled() + test_apply_no_activate_cfg_when_unchanged() + test_activate_dtg_mode() + test_activate_rbd_mode() + test_apply_activates_dtg_after_write() + test_apply_activates_rbd_after_write() + test_apply_no_activate_dtg_when_export_disabled() + test_apply_no_activate_dtg_when_freeze_not_real_export() + test_apply_no_activate_dtg_when_unchanged() + test_activate_cfg_mode_invalidates_cache_on_failure() + test_activate_dtg_mode_invalidates_cache_on_failure() + test_activate_rbd_mode_invalidates_cache_on_failure() + test_apply_activates_pending_schedule_without_rewrite() + test_is_schedule_pending_null_status_is_not_pending() + test_apply_no_crash_when_cached_status_none() + test_activate_dtg_mode_clears_pending_status_on_success() + test_activate_rbd_mode_clears_pending_status_on_success() + test_apply_pending_dtg_not_reactivated_after_success() + test_apply_activates_cfg_when_setting_off_but_schedule_matches() print("**** Enphase API tests passed ****") return 0