Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/predbat/fox.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,19 +321,27 @@ 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")
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:
Expand Down
18 changes: 11 additions & 7 deletions apps/predbat/gecloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand All @@ -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")

Expand Down Expand Up @@ -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"]:
Expand All @@ -1001,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]
elif not self.ems_device and self.devices_dict["gateway"] and len(self.device_list) == 1:
self.device_list = [self.gateway_device] + self.devices_dict["pv"][:]
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 = []
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 81 additions & 4 deletions apps/predbat/tests/test_fox_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -3736,16 +3737,90 @@ 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

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 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
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
Expand Down Expand Up @@ -5870,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)
Expand Down
10 changes: 5 additions & 5 deletions apps/predbat/tests/test_ge_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 []
Expand Down
Loading