From 690033e957720a3fb6eedd6e69cd45547f3ebffa Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Tue, 16 Jun 2026 20:16:18 +0100 Subject: [PATCH 1/3] Fix GECloud with extra PV inverter, update fox to not refresh device info on boot --- apps/predbat/fox.py | 2 +- apps/predbat/gecloud.py | 16 +++++++++++----- apps/predbat/predbat.py | 2 +- apps/predbat/tests/test_fox_api.py | 9 +++++---- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 79f6599f6..0dd475204 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -333,7 +333,7 @@ async def run(self, seconds, first): return False # Device detail and battery charging times rarely change - refresh based on age - if first or self._needs_refresh("device_detail", FOX_REFRESH_STATIC): + if self._needs_refresh("device_detail", FOX_REFRESH_STATIC): detail_updated = False battery_updated = False for device in self.device_list: diff --git a/apps/predbat/gecloud.py b/apps/predbat/gecloud.py index 7b7e6c12b..d9a34c8b5 100644 --- a/apps/predbat/gecloud.py +++ b/apps/predbat/gecloud.py @@ -793,6 +793,7 @@ async def async_automatic_config(self, devices): batteries = devices["battery"] batteries_real = devices["battery"] num_inverters = len(batteries) + pvs = devices.get("pv", []) if not devices["ems"] and devices["gateway"] and len(batteries) > 1: # Only use gateway as main control if we have multiple batteries @@ -856,12 +857,10 @@ def build_entities(domain, candidates): self.set_arg("load_today", [f"sensor.{self.prefix}_gecloud_{device}_consumption_total" for device in batteries]) self.set_arg("import_today", [f"sensor.{self.prefix}_gecloud_{device}_grid_import_total" for device in batteries]) self.set_arg("export_today", [f"sensor.{self.prefix}_gecloud_{device}_grid_export_total" for device in batteries]) - self.set_arg("pv_today", [f"sensor.{self.prefix}_gecloud_{device}_solar_total" for device in batteries]) self.set_arg("charge_rate", build_entities("number", ["battery_charge_power"])) self.set_arg("battery_rate_max", [f"sensor.{self.prefix}_gecloud_{device}_max_charge_rate" for device in batteries]) self.set_arg("discharge_rate", build_entities("number", ["battery_discharge_power"])) self.set_arg("battery_power", [f"sensor.{self.prefix}_gecloud_{device}_battery_power" for device in batteries]) - self.set_arg("pv_power", [f"sensor.{self.prefix}_gecloud_{device}_solar_power" for device in batteries]) self.set_arg("load_power", [f"sensor.{self.prefix}_gecloud_{device}_consumption_power" for device in batteries]) self.set_arg("grid_power", [f"sensor.{self.prefix}_gecloud_{device}_grid_power" for device in batteries]) self.set_arg("soc_percent", [f"sensor.{self.prefix}_gecloud_{device}_battery_percent" for device in batteries]) @@ -880,6 +879,9 @@ def build_entities(domain, candidates): self.set_arg("battery_scaling", [f"sensor.{self.prefix}_gecloud_{device}_battery_dod_soh" for device in batteries]) self.set_arg("inverter_limit", [f"sensor.{self.prefix}_gecloud_{device}_max_inverter_rate" for device in batteries]) + self.set_arg("pv_today", [f"sensor.{self.prefix}_gecloud_{device}_solar_total" for device in batteries + pvs]) + self.set_arg("pv_power", [f"sensor.{self.prefix}_gecloud_{device}_solar_power" for device in batteries + pvs]) + if len(batteries): self.set_arg("battery_temperature_history", f"sensor.{self.prefix}_gecloud_{batteries[0]}_battery_temperature") @@ -990,7 +992,7 @@ async def run(self, seconds, first): # Build a list of devices to poll: # Use all battery inverter serials and also add the EMS device if it's distinct. - self.device_list = self.devices_dict["battery"][:] + self.device_list = self.devices_dict["battery"][:] + self.devices_dict["pv"][:] self.ems_device = None if self.devices_dict["ems"]: @@ -1004,7 +1006,7 @@ async def run(self, seconds, first): if not self.ems_device and self.devices_dict["gateway"] and len(self.device_list) > 1: self.gateway_device = self.devices_dict["gateway"] self.log("GECloud: Found Gateway device {} and multiple batteries, using only the gateway device".format(self.gateway_device)) - self.device_list = [self.gateway_device] + self.device_list = [self.gateway_device] + self.devices_dict["pv"][:] elif not self.ems_device and self.devices_dict["gateway"] and len(self.device_list) == 1: self.log("GECloud: Found Gateway device {} but only one battery, using the battery device for polling".format(self.devices_dict["gateway"])) @@ -1484,7 +1486,7 @@ async def async_get_devices(self): """ device_list = await self.async_get_inverter_data_retry(GE_API_DEVICES) - result = {"gateway": None, "ems": None, "battery": [], "battery_meters": {}} + result = {"gateway": None, "ems": None, "battery": [], "battery_meters": {}, "pv": []} if device_list is None: return result @@ -1505,6 +1507,8 @@ async def async_get_devices(self): result["ems"] = serial elif "gateway" in model or "gw2" in model: result["gateway"] = serial + elif "giv-pv" in model: + result["pv"].append(serial) elif batteries or info.get("battery"): result["battery"].append(serial) result["battery_meters"][serial] = meter_serials @@ -1607,6 +1611,8 @@ async def async_get_inverter_data(self, endpoint, serial="", setting_id="", post # ongoing access-denied condition. self.api_auth_failed = status in [401, 403] + self.log("GECloud: Request to {} returned status {} with data {}".format(endpoint, status, data)) + if status in [200, 201]: if data is None: data = {} diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 28d167a7c..ebb3ded37 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -36,7 +36,7 @@ import requests import asyncio -THIS_VERSION = "v8.40.9" +THIS_VERSION = "v8.40.10" from download import predbat_update_move, predbat_update_download, check_install, resolve_predbat_repository, DEFAULT_PREDBAT_REPOSITORY from const import MINUTE_WATT diff --git a/apps/predbat/tests/test_fox_api.py b/apps/predbat/tests/test_fox_api.py index e07640e50..d89a8424c 100644 --- a/apps/predbat/tests/test_fox_api.py +++ b/apps/predbat/tests/test_fox_api.py @@ -3718,8 +3718,9 @@ def test_run_device_list_failure_does_not_mark_cache_fresh(my_predbat): def test_run_first_refreshes_device_list_despite_fresh_cache(my_predbat): """ - Test run() always re-fetches the device list and detail on first start, even when the - cached data is still fresh, so a new inverter or changed serial number is picked up + Test run() always re-fetches the device list on first start, even when the cached data + is still fresh, so a new inverter or changed serial number is picked up. Device detail + and all other age-gated categories are skipped while the cache is fresh. """ print(" - test_run_first_refreshes_device_list_despite_fresh_cache") @@ -3736,10 +3737,10 @@ def test_run_first_refreshes_device_list_despite_fresh_cache(my_predbat): result = run_async(fox.run(0, first=True)) assert result == True - # Device list and detail must always refresh on first start regardless of cache age + # Device list must always refresh on first start regardless of cache age assert "get_device_list" in fox.method_calls - assert "get_device_detail:TEST123" in fox.method_calls # Age-based categories with fresh data should NOT be re-fetched on first start + assert "get_device_detail:TEST123" not in fox.method_calls assert "get_real_time_data:TEST123" not in fox.method_calls assert "get_device_settings:TEST123" not in fox.method_calls From fa4ebd058507a2ece769579def21ee239771c524 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Tue, 16 Jun 2026 20:20:13 +0100 Subject: [PATCH 2/3] Fox cache invalidate on device list change --- apps/predbat/fox.py | 8 ++++ apps/predbat/tests/test_fox_api.py | 76 ++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 0dd475204..57878aaa8 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -321,12 +321,20 @@ async def run(self, seconds, first): # Device list rarely changes - refresh based on the age of the cached data if first or self._needs_refresh("device_list", FOX_REFRESH_STATIC): + prev_sns = {d.get("deviceSN") for d in self.device_list} devices = await self.get_device_list() self.log("Fox API: Found {} devices".format(len(self.device_list))) # Only persist and reset the 24h refresh timer when the poll actually succeeded; on a # transient API failure we keep any cached list and retry on the next cycle if devices: await self._save_cache("device_list", self.device_list) + # If the set of device serial numbers changed, drop all per-device caches so + # every category is re-fetched for the new device immediately + new_sns = {d.get("deviceSN") for d in self.device_list} + if new_sns != prev_sns: + for key in FOX_CACHE_KEYS: + if key != "device_list": + self.data_age.pop(key, None) if not self.device_list: self.log("Error: FoxAPI: No devices found, unable to start API") diff --git a/apps/predbat/tests/test_fox_api.py b/apps/predbat/tests/test_fox_api.py index d89a8424c..51acc3424 100644 --- a/apps/predbat/tests/test_fox_api.py +++ b/apps/predbat/tests/test_fox_api.py @@ -3747,6 +3747,80 @@ def test_run_first_refreshes_device_list_despite_fresh_cache(my_predbat): return False +def test_run_new_device_invalidates_detail_cache(my_predbat): + """ + Test that when the device list changes (new inverter added), the device_detail cache is + invalidated so the new device's detail is fetched immediately rather than waiting 24 hours. + """ + print(" - test_run_new_device_invalidates_detail_cache") + + from datetime import datetime, timezone + + fox = MockFoxAPIWithRunTracking() + # Start with one device and a fully fresh cache + fox.device_list = [{"deviceSN": "TEST123"}] + now = datetime.now(timezone.utc) + for key in FOX_CACHE_KEYS: + fox.data_age[key] = now + + # Simulate get_device_list returning a second, new device + async def get_device_list_with_new_device(): + fox.method_calls.append("get_device_list") + fox.device_list = [{"deviceSN": "TEST123"}, {"deviceSN": "NEW456"}] + return fox.device_list + + fox.get_device_list = get_device_list_with_new_device + + result = run_async(fox.run(0, first=True)) + + assert result == True + assert "get_device_list" in fox.method_calls + # All per-device caches must be refetched for both old and new devices + assert "get_device_detail:TEST123" in fox.method_calls + assert "get_device_detail:NEW456" in fox.method_calls + assert "get_device_settings:TEST123" in fox.method_calls + assert "get_device_settings:NEW456" in fox.method_calls + assert "get_real_time_data:TEST123" in fox.method_calls + assert "get_real_time_data:NEW456" in fox.method_calls + + return False + + +def test_run_unchanged_device_list_preserves_cache(my_predbat): + """ + Test that when the device list is re-fetched but the set of serial numbers is unchanged, + per-device caches are not invalidated and no redundant API calls are made. + """ + print(" - test_run_unchanged_device_list_preserves_cache") + + from datetime import datetime, timezone + + fox = MockFoxAPIWithRunTracking() + fox.device_list = [{"deviceSN": "TEST123"}] + now = datetime.now(timezone.utc) + for key in FOX_CACHE_KEYS: + fox.data_age[key] = now + + # get_device_list returns the same device SN as before + async def get_device_list_same(): + fox.method_calls.append("get_device_list") + fox.device_list = [{"deviceSN": "TEST123"}] + return fox.device_list + + fox.get_device_list = get_device_list_same + + result = run_async(fox.run(0, first=True)) + + assert result == True + assert "get_device_list" in fox.method_calls + # Cache is still fresh and device set unchanged — no per-device fetches should happen + assert "get_device_detail:TEST123" not in fox.method_calls + assert "get_device_settings:TEST123" not in fox.method_calls + assert "get_real_time_data:TEST123" not in fox.method_calls + + return False + + def test_run_with_automatic_config(my_predbat): """ Test run() with automatic=True calls automatic_config @@ -5871,6 +5945,8 @@ def run_fox_api_tests(my_predbat): failed |= test_run_realtime_refresh_after_cache_expires(my_predbat) failed |= test_run_device_list_failure_does_not_mark_cache_fresh(my_predbat) failed |= test_run_first_refreshes_device_list_despite_fresh_cache(my_predbat) + failed |= test_run_new_device_invalidates_detail_cache(my_predbat) + failed |= test_run_unchanged_device_list_preserves_cache(my_predbat) failed |= test_run_with_automatic_config(my_predbat) failed |= test_run_without_automatic_config(my_predbat) failed |= test_run_midnight_reset(my_predbat) From e1897b0c89d3df9de80918eb7157928c72759383 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Tue, 16 Jun 2026 20:35:56 +0100 Subject: [PATCH 3/3] review fixes --- apps/predbat/gecloud.py | 6 ++---- apps/predbat/tests/test_fox_api.py | 2 +- apps/predbat/tests/test_ge_cloud.py | 10 +++++----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/predbat/gecloud.py b/apps/predbat/gecloud.py index d9a34c8b5..0ab287481 100644 --- a/apps/predbat/gecloud.py +++ b/apps/predbat/gecloud.py @@ -1003,11 +1003,11 @@ async def run(self, seconds, first): self.device_list.append(self.ems_device) self.gateway_device = None - if not self.ems_device and self.devices_dict["gateway"] and len(self.device_list) > 1: + if not self.ems_device and self.devices_dict["gateway"] and len(self.devices_dict["battery"]) > 1: self.gateway_device = self.devices_dict["gateway"] self.log("GECloud: Found Gateway device {} and multiple batteries, using only the gateway device".format(self.gateway_device)) self.device_list = [self.gateway_device] + self.devices_dict["pv"][:] - elif not self.ems_device and self.devices_dict["gateway"] and len(self.device_list) == 1: + elif not self.ems_device and self.devices_dict["gateway"] and len(self.devices_dict["battery"]) <= 1: self.log("GECloud: Found Gateway device {} but only one battery, using the battery device for polling".format(self.devices_dict["gateway"])) self.evc_device_list = [] @@ -1611,8 +1611,6 @@ async def async_get_inverter_data(self, endpoint, serial="", setting_id="", post # ongoing access-denied condition. self.api_auth_failed = status in [401, 403] - self.log("GECloud: Request to {} returned status {} with data {}".format(endpoint, status, data)) - if status in [200, 201]: if data is None: data = {} diff --git a/apps/predbat/tests/test_fox_api.py b/apps/predbat/tests/test_fox_api.py index 51acc3424..f9f55006d 100644 --- a/apps/predbat/tests/test_fox_api.py +++ b/apps/predbat/tests/test_fox_api.py @@ -3775,7 +3775,7 @@ async def get_device_list_with_new_device(): assert result == True assert "get_device_list" in fox.method_calls - # All per-device caches must be refetched for both old and new devices + # All per-device caches must be re-fetched for both old and new devices assert "get_device_detail:TEST123" in fox.method_calls assert "get_device_detail:NEW456" in fox.method_calls assert "get_device_settings:TEST123" in fox.method_calls diff --git a/apps/predbat/tests/test_ge_cloud.py b/apps/predbat/tests/test_ge_cloud.py index dd686f749..f89d5ce9b 100644 --- a/apps/predbat/tests/test_ge_cloud.py +++ b/apps/predbat/tests/test_ge_cloud.py @@ -982,7 +982,7 @@ async def mock_retry(*args, **kwargs): result = await ge_cloud.async_get_devices() - if result != {"gateway": None, "ems": None, "battery": [], "battery_meters": {}}: + if result != {"gateway": None, "ems": None, "battery": [], "battery_meters": {}, "pv": []}: print("ERROR: Expected empty result dict, got {}".format(result)) return 1 return 0 @@ -1817,7 +1817,7 @@ async def test(): expected_settings = {"sid-1": {"name": "Battery Reserve", "value": 4, "validation_rules": ["between:0,100"], "validation": ""}} async def mock_get_devices(): - return {"battery": ["inv001"], "ems": None, "gateway": None} + return {"battery": ["inv001"], "ems": None, "gateway": None, "pv": [], "battery_meters": {}} async def mock_get_evc_devices(): return [] @@ -1899,7 +1899,7 @@ async def test(): poll_calls = [] async def mock_get_devices(): - return {"battery": ["inv001"], "ems": None, "gateway": None} + return {"battery": ["inv001"], "ems": None, "gateway": None, "pv": [], "battery_meters": {}} async def mock_get_evc_devices(): return [] @@ -1978,7 +1978,7 @@ async def test(): # Mock all the async functions called by run() async def mock_get_devices(): call_order.append("async_get_devices") - return {"battery": ["inv001"], "ems": None, "gateway": None} + return {"battery": ["inv001"], "ems": None, "gateway": None, "pv": [], "battery_meters": {}} async def mock_get_evc_devices(): call_order.append("async_get_evc_devices") @@ -3577,7 +3577,7 @@ def _make_run_mocks(ge_cloud, enable_default_calls=None): """Attach minimal async mocks to ge_cloud so run() can execute without real I/O.""" async def mock_get_devices(): - return {"battery": ["inv001"], "ems": None, "gateway": None} + return {"battery": ["inv001"], "ems": None, "gateway": None, "pv": [], "battery_meters": {}} async def mock_get_evc_devices(): return []