Skip to content

Commit b4462a1

Browse files
feat(solax): cache plant and device info in storage and refresh it by data age
Every restart re-read the full plant list and every device from the cloud before Predbat could do anything, even though that information describes the hardware and barely changes. Both are now kept in storage, as gecloud does with its settings, and restored on startup when the data is still current. The refresh is driven by the age of the data rather than the time since startup: plant info is re-read once it is more than 8 hours old, device info once it is more than 30 minutes old. Restoring from the cache carries the age over, so a restart neither re-reads data that is still good nor waits a full interval to refresh data that was already nearly due. Entries are written with a 24 hour expiry so an abandoned cache is discarded by the storage layer rather than lingering. A plant info refresh that fails now keeps the previously known plants instead of aborting the run, which only remains fatal when nothing has ever been read. Realtime data is deliberately not cached, it is polled every 60 seconds, but it is no longer published when the read failed. Previously a failed poll republished the last known values, and before any successful read the plant totals were published as zero, which corrupts the energy totals Predbat consumes. Plant totals, battery sensors and per-device sensors are each held back independently, so one failing device does not suppress the rest, and static sensors such as capacity are always published. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e8b312b commit b4462a1

2 files changed

Lines changed: 569 additions & 49 deletions

File tree

apps/predbat/solax.py

Lines changed: 191 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
"work_mode_selfuse",
4545
"work_mode_feedin",
4646
]
47+
SOLAX_PLANT_INFO_MAX_AGE = 8 * 60 # Plant info is static, re-poll it once the data is this many minutes old
48+
SOLAX_DEVICE_INFO_MAX_AGE = 30 # Device info changes rarely, re-poll it once the data is this many minutes old
49+
SOLAX_CACHE_EXPIRY_HOURS = 24 # Cached plant and device info is discarded by the storage layer after this long
4750
SOLAX_COMMAND_RETRY_DELAY = 2.0
4851
SOLAX_COMMAND_MAX_RETRIES = 8
4952
SOLAX_REGIONS = {
@@ -378,6 +381,15 @@ def initialize(self, client_id, client_secret, region="eu", plant_id=None, autom
378381
self.realtime_device_data = {}
379382
self.controls = {}
380383

384+
# Which plants and devices failed their most recent realtime poll, stale data is not republished
385+
self.realtime_plant_failed = set()
386+
self.realtime_device_failed = set()
387+
388+
# When the plant and device info was last read, seeded from the storage cache on startup so that
389+
# a restart does not re-read data that is still current
390+
self.plant_info_updated = None
391+
self.device_info_updated = None
392+
381393
# Error tracking
382394
self.error_count = 0
383395

@@ -1298,6 +1310,101 @@ async def query_plant_info(self):
12981310
self.plant_info = result
12991311
return result
13001312

1313+
def data_is_due(self, updated, max_age_minutes):
1314+
"""
1315+
Work out whether data needs re-reading based on how old it is
1316+
1317+
Args:
1318+
updated: When the data was last read, or None if it never has been
1319+
max_age_minutes: How old the data may be before it is re-read
1320+
1321+
Returns:
1322+
True if the data should be read now
1323+
"""
1324+
if updated is None:
1325+
return True
1326+
return (datetime.now(timezone.utc) - updated).total_seconds() >= max_age_minutes * 60
1327+
1328+
async def load_cached_info(self, name, max_age_minutes):
1329+
"""
1330+
Load one static info cache entry if it is still within its maximum age
1331+
1332+
Args:
1333+
name: Cache entry name
1334+
max_age_minutes: How old the cached data may be before it is treated as missing
1335+
1336+
Returns:
1337+
tuple: (data, updated) where data is None when there is nothing usable to restore
1338+
"""
1339+
if not self.storage:
1340+
return None, None
1341+
1342+
cached = await self.storage.load("solax", name)
1343+
if not isinstance(cached, dict) or not cached:
1344+
self.log(f"SolaX API: No {name} in the storage cache, will poll")
1345+
return None, None
1346+
1347+
age = await self.storage.age("solax", name)
1348+
if age is None or age >= max_age_minutes:
1349+
self.log("SolaX API: Cached {} is stale (age {}), will poll".format(name, "{:.1f} minutes".format(age) if age is not None else "unknown"))
1350+
return None, None
1351+
1352+
self.log(f"SolaX API: Restored {name} from the storage cache (age {age:.1f} minutes)")
1353+
return cached, datetime.now(timezone.utc) - timedelta(minutes=age)
1354+
1355+
async def load_static_info(self):
1356+
"""
1357+
Restore the plant and device info from the storage cache
1358+
1359+
Plant and device info describe the hardware and barely change, so a restart can reuse whatever is
1360+
still within its maximum age rather than re-reading it all from the cloud. The recorded read time
1361+
comes from the cache age, so data that is already close to expiry is re-polled promptly.
1362+
"""
1363+
plant_cached, plant_updated = await self.load_cached_info("plant_info", SOLAX_PLANT_INFO_MAX_AGE)
1364+
if plant_cached and plant_cached.get("plant_info"):
1365+
self.plant_info = plant_cached["plant_info"]
1366+
self.plant_info_updated = plant_updated
1367+
1368+
device_cached, device_updated = await self.load_cached_info("device_info", SOLAX_DEVICE_INFO_MAX_AGE)
1369+
if device_cached and device_cached.get("device_info"):
1370+
self.device_info = device_cached["device_info"]
1371+
self.plant_inverters = device_cached.get("plant_inverters", {})
1372+
self.plant_batteries = device_cached.get("plant_batteries", {})
1373+
self.device_info_updated = device_updated
1374+
1375+
async def save_plant_info(self):
1376+
"""
1377+
Save the plant info to the storage cache
1378+
1379+
Returns:
1380+
True on success, False if there is no storage or the save failed
1381+
"""
1382+
if not self.storage:
1383+
return False
1384+
expiry = datetime.now(timezone.utc) + timedelta(hours=SOLAX_CACHE_EXPIRY_HOURS)
1385+
return await self.storage.save("solax", "plant_info", {"plant_info": self.plant_info}, format="json", expiry=expiry)
1386+
1387+
async def save_device_info(self):
1388+
"""
1389+
Save the device info and its plant mappings to the storage cache
1390+
1391+
Returns:
1392+
True on success, False if there is no storage or the save failed
1393+
"""
1394+
if not self.storage:
1395+
return False
1396+
return await self.storage.save(
1397+
"solax",
1398+
"device_info",
1399+
{
1400+
"device_info": self.device_info,
1401+
"plant_inverters": self.plant_inverters,
1402+
"plant_batteries": self.plant_batteries,
1403+
},
1404+
format="json",
1405+
expiry=datetime.now(timezone.utc) + timedelta(hours=SOLAX_CACHE_EXPIRY_HOURS),
1406+
)
1407+
13011408
async def query_device_info(self, plant_id, device_type, device_sn=None, business_type=None):
13021409
"""
13031410
Query device information with pagination support
@@ -1665,8 +1772,10 @@ async def query_device_realtime_data(self, sn, device_type, business_type=None,
16651772
if result is not None and len(result) > 0:
16661773
# One result per device SN
16671774
self.realtime_device_data[sn] = result[0]
1775+
self.realtime_device_failed.discard(sn)
16681776
self.log(f"SolaX API: Retrieved real-time data for device SN {sn} {result[0]}")
16691777
return result
1778+
self.realtime_device_failed.add(sn)
16701779
return None
16711780

16721781
def is_a1_hybrid_g2(self, device_sn):
@@ -2120,8 +2229,12 @@ async def publish_device_realtime_data(self):
21202229
# Per-plant accumulators for the load-power calculation (second pass below)
21212230
plant_save = {} # plant_id -> {"grid": W, "pv": W, "battery": W, "inverter_sn": sn, "friendly_name": name}
21222231

2123-
# Publish per-device realtime data
2232+
# Publish per-device realtime data, skipping any device that was not read on this cycle so that
2233+
# its sensors keep their previous values rather than being republished from stale data
21242234
for device_sn, realtime in self.realtime_device_data.items():
2235+
if device_sn in self.realtime_device_failed:
2236+
self.log(f"Warn: SolaX API: No realtime data read for device {device_sn}, keeping the previous sensor values")
2237+
continue
21252238
device = self.device_info.get(device_sn, {})
21262239
device_type = device.get("deviceType")
21272240
plant_id = device.get("plantId", "unknown").lower().replace(" ", "_")
@@ -2407,31 +2520,51 @@ async def publish_plant_info(self):
24072520
battery_temp = self.get_battery_temperature(plant_id)
24082521
charge_discharge_power = self.get_charge_discharge_power_battery(plant_id)
24092522

2410-
# Battery SOC
2411-
self.dashboard_item(
2412-
f"sensor.{self.prefix}_solax_{plant_id}_battery_soc",
2413-
state=battery_soc,
2414-
attributes={
2415-
"friendly_name": f"SolaX {plant_name} Battery SOC",
2416-
"unit_of_measurement": "kWh",
2417-
"device_class": "energy",
2418-
"state_class": "measurement",
2419-
"soc_max": battery_soc_max,
2420-
},
2421-
app="solax",
2422-
)
2423-
# Battery Charge/Discharge Power
2424-
self.dashboard_item(
2425-
f"sensor.{self.prefix}_solax_{plant_id}_battery_charge_discharge_power",
2426-
state=charge_discharge_power,
2427-
attributes={
2428-
"friendly_name": f"SolaX {plant_name} Battery Charge/Discharge Power",
2429-
"unit_of_measurement": "W",
2430-
"device_class": "power",
2431-
"state_class": "measurement",
2432-
},
2433-
app="solax",
2434-
)
2523+
# The battery sensors come from the device realtime data, so hold them back when a battery
2524+
# failed its last read rather than republishing stale values
2525+
battery_ok = not any(device_sn in self.realtime_device_failed for device_sn in self.plant_batteries.get(plant_id, []))
2526+
2527+
if battery_ok:
2528+
# Battery SOC
2529+
self.dashboard_item(
2530+
f"sensor.{self.prefix}_solax_{plant_id}_battery_soc",
2531+
state=battery_soc,
2532+
attributes={
2533+
"friendly_name": f"SolaX {plant_name} Battery SOC",
2534+
"unit_of_measurement": "kWh",
2535+
"device_class": "energy",
2536+
"state_class": "measurement",
2537+
"soc_max": battery_soc_max,
2538+
},
2539+
app="solax",
2540+
)
2541+
# Battery Charge/Discharge Power
2542+
self.dashboard_item(
2543+
f"sensor.{self.prefix}_solax_{plant_id}_battery_charge_discharge_power",
2544+
state=charge_discharge_power,
2545+
attributes={
2546+
"friendly_name": f"SolaX {plant_name} Battery Charge/Discharge Power",
2547+
"unit_of_measurement": "W",
2548+
"device_class": "power",
2549+
"state_class": "measurement",
2550+
},
2551+
app="solax",
2552+
)
2553+
# Battery temperature sensor
2554+
self.dashboard_item(
2555+
f"sensor.{self.prefix}_solax_{plant_id}_battery_temperature",
2556+
state=battery_temp,
2557+
attributes={
2558+
"friendly_name": f"SolaX {plant_name} Battery Temperature",
2559+
"unit_of_measurement": "°C",
2560+
"device_class": "temperature",
2561+
"state_class": "measurement",
2562+
},
2563+
app="solax",
2564+
)
2565+
else:
2566+
self.log(f"Warn: SolaX API: No battery realtime data read for plant {plant_id}, keeping the previous battery sensor values")
2567+
24352568
# Battery SOC max sensor
24362569
self.dashboard_item(
24372570
f"sensor.{self.prefix}_solax_{plant_id}_battery_capacity",
@@ -2445,19 +2578,6 @@ async def publish_plant_info(self):
24452578
app="solax",
24462579
)
24472580

2448-
# Battery temperature sensor
2449-
self.dashboard_item(
2450-
f"sensor.{self.prefix}_solax_{plant_id}_battery_temperature",
2451-
state=battery_temp,
2452-
attributes={
2453-
"friendly_name": f"SolaX {plant_name} Battery Temperature",
2454-
"unit_of_measurement": "°C",
2455-
"device_class": "temperature",
2456-
"state_class": "measurement",
2457-
},
2458-
app="solax",
2459-
)
2460-
24612581
# Battery max power sensor
24622582
self.dashboard_item(
24632583
f"sensor.{self.prefix}_solax_{plant_id}_battery_max_power",
@@ -2497,9 +2617,12 @@ async def publish_plant_info(self):
24972617
app="solax",
24982618
)
24992619

2500-
# Publish realtime data if available
2620+
# Publish realtime data, but only when this cycle actually read it. Publishing on a failed
2621+
# read would push stale totals, or zeros before the first successful read
25012622
realtime_plant_id = plant.get("plantId")
2502-
if realtime_plant_id and realtime_plant_id in self.realtime_data:
2623+
if realtime_plant_id and realtime_plant_id in self.realtime_plant_failed:
2624+
self.log(f"Warn: SolaX API: No realtime data read for plant {realtime_plant_id}, keeping the previous sensor values")
2625+
elif realtime_plant_id and realtime_plant_id in self.realtime_data:
25032626
realtime = self.realtime_data[realtime_plant_id]
25042627

25052628
# Total Yield sensor
@@ -2611,14 +2734,26 @@ async def run(self, seconds, first):
26112734
True on success, False on failure
26122735
"""
26132736
if first:
2614-
# Fetch plant information on startup
2615-
self.log("SolaX API: Fetching plant information...")
2616-
await self.query_plant_info()
2737+
# Reuse whatever hardware information is still current, a restart then avoids re-reading it all
2738+
await self.load_static_info()
26172739

2618-
if self.plant_info is None:
2740+
# Plant info is static, it is only re-read once the data itself has aged out
2741+
plant_info_refreshed = False
2742+
if self.data_is_due(self.plant_info_updated, SOLAX_PLANT_INFO_MAX_AGE):
2743+
self.log("SolaX API: Fetching plant information...")
2744+
result = await self.query_plant_info()
2745+
if result is not None and self.plant_info is not None:
2746+
self.plant_info_updated = datetime.now(timezone.utc)
2747+
plant_info_refreshed = True
2748+
await self.save_plant_info()
2749+
elif self.plant_info is None:
26192750
self.log("Warn: SolaX API: Failed to fetch plant information")
26202751
return False
2752+
else:
2753+
# A refresh that fails keeps whatever was read before rather than dropping the plants
2754+
self.log("Warn: SolaX API: Failed to refresh plant information, keeping the previous data")
26212755

2756+
if first or plant_info_refreshed:
26222757
self.plant_list = [plant.get('plantId') for plant in self.plant_info]
26232758
if self.plant_sn_filter:
26242759
self.plant_list = [pid for pid in self.plant_list if pid in self.plant_sn_filter]
@@ -2627,8 +2762,8 @@ async def run(self, seconds, first):
26272762
# Check readonly mode
26282763
is_readonly = self.get_state_wrapper(f'switch.{self.prefix}_set_read_only', default='off') == 'on'
26292764

2630-
if first or seconds % (30 * 60) == 0:
2631-
# Periodic plant info refresh every 30 minutes
2765+
# Device info is re-read once the data has aged out rather than on a fixed cycle since startup
2766+
if self.data_is_due(self.device_info_updated, SOLAX_DEVICE_INFO_MAX_AGE):
26322767
for plantID in self.plant_list:
26332768
self.log(f"SolaX API: Fetching device information for plant ID {plantID}...")
26342769
await self.query_device_info(plantID, device_type=SOLAX_DEVICE_TYPE_INVERTER) # Inverter
@@ -2637,9 +2772,17 @@ async def run(self, seconds, first):
26372772
# await self.query_device_info(plantID, device_type=SOLAX_DEVICE_TYPE_METER) # Meter
26382773
# await self.query_plant_statistics_daily(plantID)
26392774

2775+
self.device_info_updated = datetime.now(timezone.utc)
2776+
# Refresh the cache so that a restart can skip this read
2777+
await self.save_device_info()
2778+
26402779
if first or seconds % 60 == 0:
26412780
for plantID in self.plant_list:
2642-
await self.query_plant_realtime_data(plantID)
2781+
# Note what failed to read, those sensors are not republished from stale data below
2782+
if await self.query_plant_realtime_data(plantID) is None:
2783+
self.realtime_plant_failed.add(plantID)
2784+
else:
2785+
self.realtime_plant_failed.discard(plantID)
26432786
await self.query_device_realtime_data_all(plantID)
26442787

26452788
# Fetch controls first time only

0 commit comments

Comments
 (0)