Skip to content

Commit a557f58

Browse files
POINTTAPI cleanup: thermostat-valve entities, fast/slow polling, translations (#20)
* Refine POINTTAPI number description composition * Clean up POINTTAPI follow-up helpers * Polish thermostat valve labels and protocol display * Polish thermostat valve labels and protocol display * Polish thermostat valve labels and fix integer-like counters * Clarify boost switch labels across locales * Add thermostat valve child lock and valve offset support Commit the thermostat valve POINTTAPI support and localized labels. * Add thermostat valve temperature actual sensor Commit the final thermostat valve actual-temperature sensor support and translations. * Enrich unit tests * Fix Ruff E712 boolean comparison in POINTTAPI entity * Refresh README entities inventory and update credits * Polish README: badges, complete entity matrix, and credits update * Increase unit test coverage * Increase coverage for config and POINTTAPI flows * Optimize POINTTAPI polling performance * Persist POINTTAPI startup data * Add zone average temperature sensors * Keep live valve telemetry on fast polling Keep battery, RSSI, and device metadata on the slower polling cadence while preserving fast updates for live thermostat valve telemetry. * Harden POINTTAPI cache and startup flow Debounce and restrict persistent cache data, refresh before forwarding platforms, simplify coordinator discovery, and add regression coverage. * Remove unused POINTTAPI persistent cache
1 parent f2dbf0c commit a557f58

26 files changed

Lines changed: 3433 additions & 356 deletions

README.md

Lines changed: 145 additions & 60 deletions
Large diffs are not rendered by default.

custom_components/bosch/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,6 @@ async def async_init(self) -> bool:
371371
self.hass, self.config_entry, self.gateway
372372
)
373373
self._data.coordinator = coordinator
374-
await coordinator.async_config_entry_first_refresh()
375374
device_registry = dr.async_get(self.hass)
376375
device_registry.async_get_or_create(
377376
config_entry_id=self.config_entry.entry_id,
@@ -381,6 +380,7 @@ async def async_init(self) -> bool:
381380
name=f"EasyControl (POINTTAPI) {self._host}",
382381
sw_version="",
383382
)
383+
await coordinator.async_config_entry_first_refresh()
384384
await self.hass.config_entries.async_forward_entry_setups(
385385
self.config_entry,
386386
[p for p in self.supported_platforms if p],

custom_components/bosch/pointtapi_client.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,17 @@ async def bulk(self, paths: list[str]) -> dict:
117117
to sequential GETs. Raises ConfigEntryAuthFailed on 401/403 like get().
118118
"""
119119
result: dict = {}
120+
token = await self._token_callback()
120121
for i in range(0, len(paths), MAX_BULK_PATHS):
121-
result.update(await self._bulk_single(paths[i : i + MAX_BULK_PATHS]))
122+
result.update(
123+
await self._bulk_single(paths[i : i + MAX_BULK_PATHS], token)
124+
)
122125
return result
123126

124-
async def _bulk_single(self, paths: list[str]) -> dict:
127+
async def _bulk_single(self, paths: list[str], token: str | None = None) -> dict:
125128
"""Issue one bulk POST for up to MAX_BULK_PATHS paths."""
126-
token = await self._token_callback()
129+
if token is None:
130+
token = await self._token_callback()
127131
headers = {"Authorization": f"Bearer {token}", "Content-Type": APP_JSON}
128132
body = json.dumps([{"gatewayId": self._device_id, "resourcePaths": list(paths)}])
129133
async with self._session.post(

custom_components/bosch/pointtapi_coordinator.py

Lines changed: 98 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,40 @@
5050
ID_KEY = "id"
5151

5252
HISTORY_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.
5573
REDISCOVERY_INTERVAL = 24 * 3600
5674
# Throttle the bulk-failure WARNING to once per hour; repeats log at DEBUG.
5775
BULK_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+
6087
async 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

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

Comments
 (0)