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
4750SOLAX_COMMAND_RETRY_DELAY = 2.0
4851SOLAX_COMMAND_MAX_RETRIES = 8
4952SOLAX_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