5252
5353HISTORY_HOURLY_PATH = "/energy/historyHourly"
5454# Hourly history does not need to be fetched with every 60-second state poll.
55- HISTORY_HOURLY_REFRESH_INTERVAL = 15 * 60
55+ HISTORY_HOURLY_REFRESH_INTERVAL = 30 * 60
5656# Configuration, diagnostics, energy and device inventories change less often
5757# than temperatures and operating modes.
5858SLOW_RESOURCE_REFRESH_INTERVAL = 5 * 60
5959PERSISTENT_CACHE_VERSION = 1
6060PERSISTENT_CACHE_MAX_AGE = 7 * 24 * 3600
61+ PERSISTENT_CACHE_SAVE_DELAY = 60
6162SLOW_RESOURCE_PREFIXES = (
6263 "/gateway" ,
6364 "/energy" ,
6667 "/programs" ,
6768 "/system/appliance" ,
6869)
70+ PERSISTENT_CACHE_PREFIXES = (
71+ "/solarCircuits" ,
72+ "/system/appliance" ,
73+ )
74+ PERSISTENT_GATEWAY_PATHS = frozenset (
75+ {
76+ "/gateway/brand" ,
77+ "/gateway/displayType" ,
78+ "/gateway/hmip/versionApplication" ,
79+ "/gateway/hmip/versionOS" ,
80+ "/gateway/productID" ,
81+ "/gateway/productType" ,
82+ "/gateway/update/lastCheck" ,
83+ "/gateway/update/lastUpdate" ,
84+ "/gateway/versionFirmware" ,
85+ "/gateway/versionFirmwareBuild" ,
86+ "/gateway/versionHardware" ,
87+ "/gateway/wifi/versionFirmware" ,
88+ "/gateway/wifi/versionFirmwareBuild" ,
89+ "/gateway/zigbee/versionFirmware" ,
90+ }
91+ )
6992FAST_DEVICE_RESOURCE_MARKERS = (
7093 "/devices/list" ,
7194 "/etrv/" ,
@@ -87,6 +110,13 @@ def _is_slow_resource(path: str) -> bool:
87110 return path == "/notifications" or path .startswith (SLOW_RESOURCE_PREFIXES )
88111
89112
113+ def _is_persistent_cache_resource (path : str ) -> bool :
114+ """Return whether a resource is explicitly safe to persist."""
115+ return path in PERSISTENT_GATEWAY_PATHS or path .startswith (
116+ PERSISTENT_CACHE_PREFIXES
117+ )
118+
119+
90120async def _fetch_history_hourly_all (client : PoinTTAPIClient ) -> dict [str , Any ] | None :
91121 """Walk /energy/historyHourly pagination forward to collect every entry.
92122
@@ -127,16 +157,12 @@ async def _fetch_history_hourly_all(client: PoinTTAPIClient) -> dict[str, Any] |
127157 return first
128158
129159
130- async def _zone_roots (client : PoinTTAPIClient ) -> list [str ]:
131- """GET /zones and return one walk root per zone ("/zones/zn1", ...).
132-
133- Multi-zone gateways (ETRVs paired to rooms) list every zone here; walking
134- each one as a root gives it the same fetch depth zn1 always had. Falls
135- back to ["/zones/zn1"] when the listing is missing or fails, preserving
136- single-zone behavior.
137- """
160+ async def _discover_roots (
161+ client : PoinTTAPIClient , root : str , fallback : str
162+ ) -> list [str ]:
163+ """Return reference roots from a listing, or its static fallback."""
138164 try :
139- resp = await client .get ("/zones" )
165+ resp = await client .get (root )
140166 if isinstance (resp , dict ):
141167 roots = [
142168 r [ID_KEY ]
@@ -146,63 +172,27 @@ async def _zone_roots(client: PoinTTAPIClient) -> list[str]:
146172 if roots :
147173 return roots
148174 except ConfigEntryAuthFailed :
149- _LOGGER .debug ("POINTTAPI 401/403 on /zones, assuming single zone" )
175+ _LOGGER .debug ("POINTTAPI 401/403 on %s, using %s" , root , fallback )
150176 except Exception as err :
151177 _LOGGER .debug (
152- "POINTTAPI /zones listing unavailable (%s), assuming single zone " , err
178+ "POINTTAPI %s listing unavailable (%s), using %s " , root , err , fallback
153179 )
154- return ["/zones/zn1" ]
180+ return [fallback ]
155181
156182
157- async def _program_roots (client : PoinTTAPIClient ) -> list [str ]:
158- """GET /programs and return one walk root per listed program.
183+ async def _zone_roots (client : PoinTTAPIClient ) -> list [str ]:
184+ """Return one walk root per zone, with a zn1 fallback."""
185+ return await _discover_roots (client , "/zones" , "/zones/zn1" )
159186
160- Mirrors zone-root expansion: use the listing references as source-of-truth
161- and fall back to the top-level /programs root when discovery is missing or
162- unavailable.
163- """
164- try :
165- resp = await client .get ("/programs" )
166- if isinstance (resp , dict ):
167- roots = [
168- r [ID_KEY ]
169- for r in (resp .get (REFERENCES_KEY ) or [])
170- if isinstance (r , dict ) and r .get (ID_KEY )
171- ]
172- if roots :
173- return roots
174- except ConfigEntryAuthFailed :
175- _LOGGER .debug ("POINTTAPI 401/403 on /programs, using /programs root" )
176- except Exception as err :
177- _LOGGER .debug (
178- "POINTTAPI /programs listing unavailable (%s), using /programs root" , err
179- )
180- return ["/programs" ]
187+
188+ async def _program_roots (client : PoinTTAPIClient ) -> list [str ]:
189+ """Return one walk root per listed program."""
190+ return await _discover_roots (client , "/programs" , "/programs" )
181191
182192
183193async def _device_roots (client : PoinTTAPIClient ) -> list [str ]:
184- """GET /devices and return one walk root per listed device.
185-
186- Mirrors zone/program root expansion and falls back to the top-level
187- /devices root when discovery is missing or unavailable.
188- """
189- try :
190- resp = await client .get ("/devices" )
191- if isinstance (resp , dict ):
192- roots = [
193- r [ID_KEY ]
194- for r in (resp .get (REFERENCES_KEY ) or [])
195- if isinstance (r , dict ) and r .get (ID_KEY )
196- ]
197- if roots :
198- return roots
199- except ConfigEntryAuthFailed :
200- _LOGGER .debug ("POINTTAPI 401/403 on /devices, using /devices root" )
201- except Exception as err :
202- _LOGGER .debug (
203- "POINTTAPI /devices listing unavailable (%s), using /devices root" , err
204- )
205- return ["/devices" ]
194+ """Return one walk root per listed device."""
195+ return await _discover_roots (client , "/devices" , "/devices" )
206196
207197
208198async def _fetch_paths (client : PoinTTAPIClient ) -> dict [str , Any ]:
@@ -344,21 +334,21 @@ async def async_load_persistent_cache(self) -> dict[str, Any] | None:
344334 self ._slow_data = {
345335 path : value
346336 for path , value in snapshot .items ()
347- if isinstance (path , str ) and _is_slow_resource (path )
337+ if isinstance (path , str ) and _is_persistent_cache_resource (path )
348338 }
349- self ._last_slow_fetch = time .monotonic ()
350- return snapshot
339+ return self ._slow_data
351340
352341 async def _async_save_persistent_cache (self , data : dict [str , Any ]) -> None :
353- """Persist resource data without credentials for the next startup ."""
342+ """Schedule persistence of explicitly allowlisted resource data ."""
354343 snapshot = {
355344 path : value
356345 for path , value in data .items ()
357- if path != HISTORY_HOURLY_PATH
346+ if _is_persistent_cache_resource ( path )
358347 }
359348 try :
360- await self ._persistent_store .async_save (
361- {"saved_at" : time .time (), "data" : snapshot }
349+ self ._persistent_store .async_delay_save (
350+ lambda : {"saved_at" : time .time (), "data" : snapshot },
351+ PERSISTENT_CACHE_SAVE_DELAY ,
362352 )
363353 except Exception as err :
364354 _LOGGER .debug ("POINTTAPI persistent cache write failed: %s" , err )
@@ -438,33 +428,30 @@ async def _fetch(self) -> dict[str, Any]:
438428 )
439429 self ._last_slow_fetch = now
440430 data = {** self ._slow_data , ** data }
441- if slow_due :
442- await self ._async_save_persistent_cache (data )
443431 _LOGGER .debug (
444432 "POINTTAPI bulk steady state: %d/%d paths returned" ,
445433 len (data ), len (self ._bulk_paths ),
446434 )
447435
448- if HISTORY_HOURLY_PATH in POINTTAPI_COORDINATOR_ROOTS :
449- if (
450- self ._history_hourly_data is None
451- or now - self ._last_history_hourly_fetch
452- >= HISTORY_HOURLY_REFRESH_INTERVAL
453- ):
454- try :
455- merged = await _fetch_history_hourly_all (self ._client )
456- if isinstance (merged , dict ):
457- self ._history_hourly_data = merged
458- self ._last_history_hourly_fetch = now
459- except ConfigEntryAuthFailed :
460- _LOGGER .debug ("POINTTAPI 401/403 on %s, keeping cached data" , HISTORY_HOURLY_PATH )
461- except Exception as err :
462- _LOGGER .debug (
463- "POINTTAPI optional path %s not available: %s" ,
464- HISTORY_HOURLY_PATH , err ,
465- )
466- if self ._history_hourly_data is not None :
467- data [HISTORY_HOURLY_PATH ] = self ._history_hourly_data
436+ if (
437+ self ._history_hourly_data is None
438+ or now - self ._last_history_hourly_fetch
439+ >= HISTORY_HOURLY_REFRESH_INTERVAL
440+ ):
441+ try :
442+ merged = await _fetch_history_hourly_all (self ._client )
443+ if isinstance (merged , dict ):
444+ self ._history_hourly_data = merged
445+ self ._last_history_hourly_fetch = now
446+ except ConfigEntryAuthFailed :
447+ _LOGGER .debug ("POINTTAPI 401/403 on %s, keeping cached data" , HISTORY_HOURLY_PATH )
448+ except Exception as err :
449+ _LOGGER .debug (
450+ "POINTTAPI optional path %s not available: %s" ,
451+ HISTORY_HOURLY_PATH , err ,
452+ )
453+ if self ._history_hourly_data is not None :
454+ data [HISTORY_HOURLY_PATH ] = self ._history_hourly_data
468455 return data
469456
470457 def _log_bulk_failure (self , err : Any ) -> None :
0 commit comments