5050ID_KEY = "id"
5151
5252HISTORY_HOURLY_PATH = "/energy/historyHourly"
53+ # Hourly history does not need to be fetched with every 60-second state poll.
54+ HISTORY_HOURLY_REFRESH_INTERVAL = 30 * 60
55+ # Configuration, diagnostics, energy and device inventories change less often
56+ # than temperatures and operating modes.
57+ SLOW_RESOURCE_REFRESH_INTERVAL = 5 * 60
58+ SLOW_RESOURCE_PREFIXES = (
59+ "/gateway" ,
60+ "/energy" ,
61+ "/solarCircuits" ,
62+ "/devices" ,
63+ "/programs" ,
64+ "/system/appliance" ,
65+ )
66+ FAST_DEVICE_RESOURCE_MARKERS = (
67+ "/devices/list" ,
68+ "/etrv/" ,
69+ "/thermostat/" ,
70+ )
5371# Re-run the discovery reference walk at most this often so resources that
5472# appear later (e.g. solar enabled by an installer) get picked up.
5573REDISCOVERY_INTERVAL = 24 * 3600
5674# Throttle the bulk-failure WARNING to once per hour; repeats log at DEBUG.
5775BULK_WARN_INTERVAL = 3600
5876
5977
78+ def _is_slow_resource (path : str ) -> bool :
79+ """Return whether a resource can use the slower polling cadence."""
80+ if path .startswith ("/devices/" ) and any (
81+ marker in path for marker in FAST_DEVICE_RESOURCE_MARKERS
82+ ):
83+ return False
84+ return path == "/notifications" or path .startswith (SLOW_RESOURCE_PREFIXES )
85+
86+
6087async def _fetch_history_hourly_all (client : PoinTTAPIClient ) -> dict [str , Any ] | None :
6188 """Walk /energy/historyHourly pagination forward to collect every entry.
6289
@@ -97,16 +124,12 @@ async def _fetch_history_hourly_all(client: PoinTTAPIClient) -> dict[str, Any] |
97124 return first
98125
99126
100- async def _zone_roots (client : PoinTTAPIClient ) -> list [str ]:
101- """GET /zones and return one walk root per zone ("/zones/zn1", ...).
102-
103- Multi-zone gateways (ETRVs paired to rooms) list every zone here; walking
104- each one as a root gives it the same fetch depth zn1 always had. Falls
105- back to ["/zones/zn1"] when the listing is missing or fails, preserving
106- single-zone behavior.
107- """
127+ async def _discover_roots (
128+ client : PoinTTAPIClient , root : str , fallback : str
129+ ) -> list [str ]:
130+ """Return reference roots from a listing, or its static fallback."""
108131 try :
109- resp = await client .get ("/zones" )
132+ resp = await client .get (root )
110133 if isinstance (resp , dict ):
111134 roots = [
112135 r [ID_KEY ]
@@ -116,63 +139,27 @@ async def _zone_roots(client: PoinTTAPIClient) -> list[str]:
116139 if roots :
117140 return roots
118141 except ConfigEntryAuthFailed :
119- _LOGGER .debug ("POINTTAPI 401/403 on /zones, assuming single zone" )
142+ _LOGGER .debug ("POINTTAPI 401/403 on %s, using %s" , root , fallback )
120143 except Exception as err :
121144 _LOGGER .debug (
122- "POINTTAPI /zones listing unavailable (%s), assuming single zone " , err
145+ "POINTTAPI %s listing unavailable (%s), using %s " , root , err , fallback
123146 )
124- return ["/zones/zn1" ]
147+ return [fallback ]
125148
126149
127- async def _program_roots (client : PoinTTAPIClient ) -> list [str ]:
128- """GET /programs and return one walk root per listed program.
150+ async def _zone_roots (client : PoinTTAPIClient ) -> list [str ]:
151+ """Return one walk root per zone, with a zn1 fallback."""
152+ return await _discover_roots (client , "/zones" , "/zones/zn1" )
129153
130- Mirrors zone-root expansion: use the listing references as source-of-truth
131- and fall back to the top-level /programs root when discovery is missing or
132- unavailable.
133- """
134- try :
135- resp = await client .get ("/programs" )
136- if isinstance (resp , dict ):
137- roots = [
138- r [ID_KEY ]
139- for r in (resp .get (REFERENCES_KEY ) or [])
140- if isinstance (r , dict ) and r .get (ID_KEY )
141- ]
142- if roots :
143- return roots
144- except ConfigEntryAuthFailed :
145- _LOGGER .debug ("POINTTAPI 401/403 on /programs, using /programs root" )
146- except Exception as err :
147- _LOGGER .debug (
148- "POINTTAPI /programs listing unavailable (%s), using /programs root" , err
149- )
150- return ["/programs" ]
151154
155+ async def _program_roots (client : PoinTTAPIClient ) -> list [str ]:
156+ """Return one walk root per listed program."""
157+ return await _discover_roots (client , "/programs" , "/programs" )
152158
153- async def _device_roots (client : PoinTTAPIClient ) -> list [str ]:
154- """GET /devices and return one walk root per listed device.
155159
156- Mirrors zone/program root expansion and falls back to the top-level
157- /devices root when discovery is missing or unavailable.
158- """
159- try :
160- resp = await client .get ("/devices" )
161- if isinstance (resp , dict ):
162- roots = [
163- r [ID_KEY ]
164- for r in (resp .get (REFERENCES_KEY ) or [])
165- if isinstance (r , dict ) and r .get (ID_KEY )
166- ]
167- if roots :
168- return roots
169- except ConfigEntryAuthFailed :
170- _LOGGER .debug ("POINTTAPI 401/403 on /devices, using /devices root" )
171- except Exception as err :
172- _LOGGER .debug (
173- "POINTTAPI /devices listing unavailable (%s), using /devices root" , err
174- )
175- return ["/devices" ]
160+ async def _device_roots (client : PoinTTAPIClient ) -> list [str ]:
161+ """Return one walk root per listed device."""
162+ return await _discover_roots (client , "/devices" , "/devices" )
176163
177164
178165async def _fetch_paths (client : PoinTTAPIClient ) -> dict [str , Any ]:
@@ -195,6 +182,8 @@ async def _fetch_paths(client: PoinTTAPIClient) -> dict[str, Any]:
195182 roots .extend (await _device_roots (client ))
196183 continue
197184 roots .append (r )
185+ roots = list (dict .fromkeys (roots ))
186+ seen_references : set [str ] = set ()
198187 for root in roots :
199188 if root == "/energy/historyHourly" :
200189 try :
@@ -214,8 +203,9 @@ async def _fetch_paths(client: PoinTTAPIClient) -> dict[str, Any]:
214203 refs = resp .get (REFERENCES_KEY ) or []
215204 for ref in refs :
216205 ref_id = ref .get (ID_KEY ) if isinstance (ref , dict ) else None
217- if not ref_id :
206+ if not ref_id or ref_id in seen_references :
218207 continue
208+ seen_references .add (ref_id )
219209 try :
220210 sub = await client .get (ref_id )
221211 if isinstance (sub , dict ):
@@ -281,6 +271,12 @@ def __init__(
281271 self ._bulk_paths : list [str ] = []
282272 self ._last_discovery : float = 0.0
283273 self ._bulk_warned_at : float | None = None
274+ self ._history_hourly_data : dict [str , Any ] | None = None
275+ self ._last_history_hourly_fetch : float = 0.0
276+ self ._slow_bulk_paths : list [str ] = []
277+ self ._fast_bulk_paths : list [str ] = []
278+ self ._slow_data : dict [str , Any ] = {}
279+ self ._last_slow_fetch : float = 0.0
284280
285281 @property
286282 def client (self ) -> PoinTTAPIClient :
@@ -315,38 +311,71 @@ async def _fetch(self) -> dict[str, Any]:
315311 # The paginated historyHourly resource stays on sequential GETs
316312 # (bulk resourcePaths carry no query strings).
317313 self ._bulk_paths = [p for p in data if p != HISTORY_HOURLY_PATH ]
314+ self ._slow_bulk_paths = [p for p in self ._bulk_paths if _is_slow_resource (p )]
315+ self ._fast_bulk_paths = [p for p in self ._bulk_paths if not _is_slow_resource (p )]
316+ self ._slow_data = {
317+ p : data [p ] for p in self ._slow_bulk_paths if p in data
318+ }
319+ self ._last_slow_fetch = now
318320 self ._last_discovery = now
321+ history = data .get (HISTORY_HOURLY_PATH )
322+ if isinstance (history , dict ):
323+ self ._history_hourly_data = history
324+ self ._last_history_hourly_fetch = now
319325 return data
320326
321- try :
322- data = await self ._client .bulk (self ._bulk_paths )
323- except ConfigEntryAuthFailed :
324- raise
325- except Exception as err :
326- self ._log_bulk_failure (err )
327- return await _fetch_paths (self ._client )
328- if not data :
327+ slow_due = (
328+ not self ._slow_data
329+ or now - self ._last_slow_fetch >= SLOW_RESOURCE_REFRESH_INTERVAL
330+ )
331+ bulk_paths = self ._fast_bulk_paths + (
332+ self ._slow_bulk_paths if slow_due else []
333+ )
334+ if not bulk_paths :
335+ data = {}
336+ else :
337+ try :
338+ data = await self ._client .bulk (bulk_paths )
339+ except ConfigEntryAuthFailed :
340+ raise
341+ except Exception as err :
342+ self ._log_bulk_failure (err )
343+ return await _fetch_paths (self ._client )
344+ if not data and bulk_paths :
329345 # An all-paths-failed envelope would wipe entity state; treat as
330346 # a wholesale failure instead.
331347 self ._log_bulk_failure ("empty bulk result" )
332348 return await _fetch_paths (self ._client )
349+ if slow_due :
350+ self ._slow_data .update (
351+ {p : data [p ] for p in self ._slow_bulk_paths if p in data }
352+ )
353+ self ._last_slow_fetch = now
354+ data = {** self ._slow_data , ** data }
333355 _LOGGER .debug (
334356 "POINTTAPI bulk steady state: %d/%d paths returned" ,
335357 len (data ), len (self ._bulk_paths ),
336358 )
337359
338- if HISTORY_HOURLY_PATH in POINTTAPI_COORDINATOR_ROOTS :
360+ if (
361+ self ._history_hourly_data is None
362+ or now - self ._last_history_hourly_fetch
363+ >= HISTORY_HOURLY_REFRESH_INTERVAL
364+ ):
339365 try :
340366 merged = await _fetch_history_hourly_all (self ._client )
341367 if isinstance (merged , dict ):
342- data [HISTORY_HOURLY_PATH ] = merged
368+ self ._history_hourly_data = merged
369+ self ._last_history_hourly_fetch = now
343370 except ConfigEntryAuthFailed :
344- _LOGGER .debug ("POINTTAPI 401/403 on %s, skipping " , HISTORY_HOURLY_PATH )
371+ _LOGGER .debug ("POINTTAPI 401/403 on %s, keeping cached data " , HISTORY_HOURLY_PATH )
345372 except Exception as err :
346373 _LOGGER .debug (
347374 "POINTTAPI optional path %s not available: %s" ,
348375 HISTORY_HOURLY_PATH , err ,
349376 )
377+ if self ._history_hourly_data is not None :
378+ data [HISTORY_HOURLY_PATH ] = self ._history_hourly_data
350379 return data
351380
352381 def _log_bulk_failure (self , err : Any ) -> None :
0 commit comments