Skip to content

Commit bc4d3f9

Browse files
committed
Harden POINTTAPI cache and startup flow
Debounce and restrict persistent cache data, refresh before forwarding platforms, simplify coordinator discovery, and add regression coverage.
1 parent a2d272e commit bc4d3f9

5 files changed

Lines changed: 205 additions & 104 deletions

File tree

custom_components/bosch/__init__.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -371,10 +371,7 @@ async def async_init(self) -> bool:
371371
self.hass, self.config_entry, self.gateway
372372
)
373373
self._data.coordinator = coordinator
374-
cached_data = await coordinator.async_load_persistent_cache()
375-
has_cached_data = bool(cached_data)
376-
if has_cached_data:
377-
coordinator.async_set_updated_data(cached_data)
374+
await coordinator.async_load_persistent_cache()
378375
device_registry = dr.async_get(self.hass)
379376
device_registry.async_get_or_create(
380377
config_entry_id=self.config_entry.entry_id,
@@ -384,17 +381,11 @@ async def async_init(self) -> bool:
384381
name=f"EasyControl (POINTTAPI) {self._host}",
385382
sw_version="",
386383
)
387-
if has_cached_data:
388-
await self.hass.config_entries.async_forward_entry_setups(
389-
self.config_entry,
390-
[p for p in self.supported_platforms if p],
391-
)
392384
await coordinator.async_config_entry_first_refresh()
393-
if not has_cached_data:
394-
await self.hass.config_entries.async_forward_entry_setups(
395-
self.config_entry,
396-
[p for p in self.supported_platforms if p],
397-
)
385+
await self.hass.config_entries.async_forward_entry_setups(
386+
self.config_entry,
387+
[p for p in self.supported_platforms if p],
388+
)
398389
_LOGGER.info(
399390
"POINTTAPI gateway ready: device_id=%s",
400391
self._host,

custom_components/bosch/pointtapi_coordinator.py

Lines changed: 74 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,13 @@
5252

5353
HISTORY_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.
5858
SLOW_RESOURCE_REFRESH_INTERVAL = 5 * 60
5959
PERSISTENT_CACHE_VERSION = 1
6060
PERSISTENT_CACHE_MAX_AGE = 7 * 24 * 3600
61+
PERSISTENT_CACHE_SAVE_DELAY = 60
6162
SLOW_RESOURCE_PREFIXES = (
6263
"/gateway",
6364
"/energy",
@@ -66,6 +67,28 @@
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+
)
6992
FAST_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+
90120
async 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

183193
async 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

208198
async 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:

unittests/test_pointtapi_coordinator.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,7 @@ async def mock_get(path):
431431
HISTORY_HOURLY_PATH,
432432
HISTORY_HOURLY_REFRESH_INTERVAL,
433433
PERSISTENT_CACHE_MAX_AGE,
434+
PERSISTENT_CACHE_SAVE_DELAY,
434435
SLOW_RESOURCE_REFRESH_INTERVAL,
435436
REDISCOVERY_INTERVAL,
436437
PoinTTAPIDataUpdateCoordinator,
@@ -478,6 +479,8 @@ async def test_persistent_cache_loads_only_fresh_slow_resources(self):
478479
"saved_at": time.time(),
479480
"data": {
480481
"/gateway/versionFirmware": {"value": "1.2.3"},
482+
"/gateway/identificationKey": {"value": "secret"},
483+
"/system/appliance/status": {"value": "ready"},
481484
"/zones/zn1/status": {"value": "idle"},
482485
},
483486
}
@@ -487,6 +490,8 @@ async def test_persistent_cache_loads_only_fresh_slow_resources(self):
487490

488491
assert snapshot is not None
489492
assert "/gateway/versionFirmware" in coord._slow_data
493+
assert "/gateway/identificationKey" not in coord._slow_data
494+
assert "/system/appliance/status" in coord._slow_data
490495
assert "/zones/zn1/status" not in coord._slow_data
491496

492497
@pytest.mark.asyncio
@@ -503,21 +508,28 @@ async def test_expired_persistent_cache_is_ignored(self):
503508
assert coord._slow_data == {}
504509

505510
@pytest.mark.asyncio
506-
async def test_persistent_cache_save_excludes_history(self):
511+
async def test_persistent_cache_save_is_delayed_and_allowlisted(self):
507512
coord = _bare_coordinator(AsyncMock())
508513
store = AsyncMock()
509514
coord._persistent_store = store
510515

511516
await coord._async_save_persistent_cache(
512517
{
513518
"/gateway/versionFirmware": {"value": "1.2.3"},
519+
"/gateway/identificationKey": {"value": "secret"},
520+
"/system/appliance/status": {"value": "ready"},
514521
HISTORY_HOURLY_PATH: {"value": []},
515522
}
516523
)
517524

518-
saved = store.async_save.await_args.args[0]
519-
assert HISTORY_HOURLY_PATH not in saved["data"]
525+
store.async_delay_save.assert_called_once()
526+
data_func, delay = store.async_delay_save.call_args.args
527+
saved = data_func()
528+
assert delay == PERSISTENT_CACHE_SAVE_DELAY
520529
assert "/gateway/versionFirmware" in saved["data"]
530+
assert "/gateway/identificationKey" not in saved["data"]
531+
assert HISTORY_HOURLY_PATH not in saved["data"]
532+
assert "/system/appliance/status" in saved["data"]
521533

522534
def test_bulk_failure_logging_is_throttled(self):
523535
coord = _bare_coordinator(AsyncMock())

unittests/test_pointtapi_new_entities.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def test_zone_average_temperature_sensors_are_discovered_per_zone(self):
6060
"/zones/zn2/temperatureActual",
6161
]
6262
assert descriptions[0].translation_key == "zone_average_temperature"
63+
assert STRINGS["entity"]["sensor"]["zone_average_temperature"]["name"] == "Average temperature"
6364
assert descriptions[0].native_unit_of_measurement == "°C"
6465
assert descriptions[0].device_class == "temperature"
6566
assert descriptions[0].state_class == "measurement"

0 commit comments

Comments
 (0)