Skip to content

Commit ceca83c

Browse files
committed
POINTTAPI: add writable per-zone assigned-program selects
- Add dynamic select entities for /zones/{id}/clockProgram - Build select options from decoded /programs names - Map selected display label back to numeric clockProgram id for writes - Add localization key for assigned program select in all supported locales - Extend tests for discovery, decoded display value, and write path
1 parent 02c8f7b commit ceca83c

11 files changed

Lines changed: 255 additions & 18 deletions

File tree

custom_components/bosch/pointtapi_entities.py

Lines changed: 163 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,6 +1316,119 @@ def _zone_assigned_program_attributes(
13161316
}
13171317

13181318

1319+
def _program_names_by_index(data: dict[str, Any]) -> dict[int, str]:
1320+
"""Return available program names keyed by numeric program index.
1321+
1322+
Program metadata can come either from `/programs/list` or from expanded
1323+
`/programs/pgN/name` resources. Names are base64-decoded when needed.
1324+
"""
1325+
program_names: dict[int, str] = {}
1326+
1327+
listing = _val(data, "/programs/list")
1328+
if isinstance(listing, list):
1329+
for item in listing:
1330+
if not isinstance(item, dict):
1331+
continue
1332+
raw_id = item.get("id")
1333+
if not isinstance(raw_id, str) or not raw_id.startswith("pg"):
1334+
continue
1335+
try:
1336+
index = int(raw_id[2:])
1337+
except ValueError:
1338+
continue
1339+
1340+
decoded = _decode_zone_name(item.get("name"))
1341+
if isinstance(decoded, str) and decoded.strip():
1342+
program_names[index] = decoded
1343+
else:
1344+
program_names.setdefault(index, raw_id)
1345+
1346+
for path in data:
1347+
if not (
1348+
isinstance(path, str)
1349+
and path.startswith("/programs/pg")
1350+
and path.endswith("/name")
1351+
):
1352+
continue
1353+
raw = path[len("/programs/pg") : -len("/name")]
1354+
try:
1355+
index = int(raw)
1356+
except ValueError:
1357+
continue
1358+
1359+
decoded = _decode_zone_name(_val(data, path))
1360+
if isinstance(decoded, str) and decoded.strip():
1361+
program_names[index] = decoded
1362+
else:
1363+
program_names.setdefault(index, f"pg{index}")
1364+
1365+
return program_names
1366+
1367+
1368+
def _zone_program_option_map(data: dict[str, Any]) -> dict[str, int]:
1369+
"""Map display labels to clockProgram numeric values.
1370+
1371+
Duplicate labels are disambiguated by appending the program id.
1372+
"""
1373+
names_by_index = _program_names_by_index(data)
1374+
if not names_by_index:
1375+
return {}
1376+
1377+
option_map: dict[str, int] = {}
1378+
used_labels: set[str] = set()
1379+
for index in sorted(names_by_index):
1380+
base = names_by_index.get(index, f"pg{index}")
1381+
label = base.strip() if isinstance(base, str) else f"pg{index}"
1382+
if not label:
1383+
label = f"pg{index}"
1384+
if label in used_labels:
1385+
label = f"{label} (pg{index})"
1386+
used_labels.add(label)
1387+
option_map[label] = index
1388+
return option_map
1389+
1390+
1391+
def _zone_program_current_option(data: dict[str, Any], zone_id: str) -> str | None:
1392+
"""Resolve the currently assigned program display label for one zone."""
1393+
raw = _val(data, f"/zones/{zone_id}/clockProgram")
1394+
try:
1395+
current_index = int(float(raw))
1396+
except (TypeError, ValueError):
1397+
return None
1398+
1399+
for label, index in _zone_program_option_map(data).items():
1400+
if index == current_index:
1401+
return label
1402+
return None
1403+
1404+
1405+
def _zone_program_write_value(option: str, data: dict[str, Any]) -> int:
1406+
"""Map a selected display label to the API clockProgram value."""
1407+
option_map = _zone_program_option_map(data)
1408+
if option not in option_map:
1409+
raise HomeAssistantError(f"Unsupported program option: {option}")
1410+
return option_map[option]
1411+
1412+
1413+
def _pointtapi_zone_program_select_descriptions(
1414+
data: dict[str, Any] | None = None,
1415+
) -> tuple["BoschPoinTTAPISelectEntityDescription", ...]:
1416+
"""Return one program select per discovered zone."""
1417+
if not data:
1418+
return ()
1419+
1420+
return tuple(
1421+
BoschPoinTTAPISelectEntityDescription(
1422+
key=f"/zones/{zone_id}/clockProgram",
1423+
translation_key="assigned_program_select",
1424+
options_fn=lambda d: tuple(_zone_program_option_map(d).keys()),
1425+
current_option_fn=lambda d, zid=zone_id: _zone_program_current_option(d, zid),
1426+
option_to_value_fn=lambda option, d: _zone_program_write_value(option, d),
1427+
)
1428+
for zone_id in pointtapi_zone_ids(data)
1429+
)
1430+
1431+
13191432
def _pointtapi_zone_assigned_program_sensor_descriptions(
13201433
data: dict[str, Any] | None = None,
13211434
) -> tuple[BoschPoinTTAPISensorEntityDescription, ...]:
@@ -2508,6 +2621,9 @@ class BoschPoinTTAPISelectEntityDescription(SelectEntityDescription):
25082621
"""Select description for POINTTAPI option paths."""
25092622

25102623
options: tuple[str, ...] = ()
2624+
options_fn: Callable[[dict[str, Any]], tuple[str, ...]] | None = None
2625+
current_option_fn: Callable[[dict[str, Any]], str | None] | None = None
2626+
option_to_value_fn: Callable[[str, dict[str, Any]], Any] | None = None
25112627

25122628

25132629
def _select_state_key(value: str) -> str:
@@ -2557,6 +2673,14 @@ def _normalize_select_option(raw_option: Any, supported_options: set[str]) -> st
25572673
)
25582674

25592675

2676+
def _pointtapi_select_descriptions(
2677+
data: dict[str, Any] | None = None,
2678+
) -> tuple[BoschPoinTTAPISelectEntityDescription, ...]:
2679+
"""Return all POINTTAPI select descriptions, including dynamic per-zone ones."""
2680+
data = data or {}
2681+
return POINTTAPI_SELECT_DESCRIPTIONS + _pointtapi_zone_program_select_descriptions(data)
2682+
2683+
25602684
class BoschPoinTTAPISelectEntity(
25612685
CoordinatorEntity[PoinTTAPIDataUpdateCoordinator], SelectEntity
25622686
):
@@ -2580,25 +2704,42 @@ def __init__(
25802704
self._path = description.key
25812705
slug = description.key.strip("/").replace("/", "_")
25822706
self._attr_unique_id = f"{entry_id}_pointtapi_select_{slug}"
2583-
self._attr_options = [_select_state_key(option) for option in description.options]
2707+
self._attr_options = []
25842708
self._attr_device_info = _resolve_device_info(
25852709
uuid,
25862710
description.key,
25872711
language=self._language,
25882712
data=coordinator.data or {},
25892713
)
25902714
self._current_option: str | None = None
2715+
self._supported_option_keys: set[str] = set()
2716+
self._refresh_supported_options(coordinator.data or {})
2717+
2718+
def _refresh_supported_options(self, data: dict[str, Any]) -> None:
2719+
"""Refresh options for static or dynamic select descriptions."""
2720+
if self.entity_description.options_fn is not None:
2721+
options = [opt for opt in self.entity_description.options_fn(data) if isinstance(opt, str)]
2722+
self._attr_options = options
2723+
self._supported_option_keys = set(options)
2724+
return
2725+
2726+
self._attr_options = [_select_state_key(option) for option in self.entity_description.options]
25912727
self._supported_option_keys = {
2592-
_select_state_key(option) for option in description.options
2728+
_select_state_key(option) for option in self.entity_description.options
25932729
}
25942730

25952731
@callback
25962732
def _handle_coordinator_update(self) -> None:
25972733
data = self.coordinator.data or {}
2598-
raw_option = _val(data, self._path)
2599-
self._current_option = _normalize_select_option(
2600-
raw_option, self._supported_option_keys
2601-
)
2734+
self._refresh_supported_options(data)
2735+
if self.entity_description.current_option_fn is not None:
2736+
option = self.entity_description.current_option_fn(data)
2737+
self._current_option = option if option in self._supported_option_keys else None
2738+
else:
2739+
raw_option = _val(data, self._path)
2740+
self._current_option = _normalize_select_option(
2741+
raw_option, self._supported_option_keys
2742+
)
26022743
self.async_write_ha_state()
26032744

26042745
@property
@@ -2618,16 +2759,23 @@ async def async_select_option(self, option: str) -> None:
26182759
if option not in self._supported_option_keys:
26192760
raise HomeAssistantError(f"Unsupported select option: {option}")
26202761
try:
2621-
api_option = next(
2622-
(
2623-
raw_option
2624-
for raw_option in self.entity_description.options
2625-
if _select_state_key(raw_option) == option
2626-
),
2627-
option,
2628-
)
2762+
data = self.coordinator.data or {}
2763+
if self.entity_description.option_to_value_fn is not None:
2764+
api_option = self.entity_description.option_to_value_fn(option, data)
2765+
else:
2766+
api_option = next(
2767+
(
2768+
raw_option
2769+
for raw_option in self.entity_description.options
2770+
if _select_state_key(raw_option) == option
2771+
),
2772+
option,
2773+
)
26292774
await self.coordinator.client.put(self._path, api_option)
2630-
self._current_option = _select_state_key(option)
2775+
if self.entity_description.current_option_fn is not None:
2776+
self._current_option = option
2777+
else:
2778+
self._current_option = _select_state_key(option)
26312779
self.async_write_ha_state()
26322780
await self.coordinator.async_request_refresh()
26332781
except ConfigEntryAuthFailed:

custom_components/bosch/select.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919
from .pointtapi_entities import (
2020
BoschPoinTTAPISelectEntity,
21-
POINTTAPI_SELECT_DESCRIPTIONS,
21+
_pointtapi_select_descriptions,
2222
)
2323

2424

@@ -29,11 +29,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
2929
coordinator = rt_data.coordinator
3030
if coordinator:
3131
uuid = config_entry.data.get(UUID)
32+
descriptions = _pointtapi_select_descriptions(coordinator.data or {})
3233
async_add_entities([
3334
BoschPoinTTAPISelectEntity(
3435
coordinator, config_entry.entry_id, uuid, desc
3536
)
36-
for desc in POINTTAPI_SELECT_DESCRIPTIONS
37+
for desc in descriptions
3738
])
3839
else:
3940
async_add_entities([])

custom_components/bosch/strings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,9 @@
354354
"manual": "Manual"
355355
}
356356
},
357+
"assigned_program_select": {
358+
"name": "Assigned program"
359+
},
357360
"pir_sensitivity": {
358361
"name": "PIR sensitivity",
359362
"state": {

custom_components/bosch/translations/de.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,9 @@
319319
"name": "Zusätzliches Warmwasser"
320320
},
321321
"open_window_detection": {
322+
"assigned_program_select": {
323+
"name": "Zugewiesenes Programm"
324+
},
322325
"name": "Fenster-offen-Erkennung"
323326
},
324327
"thermal_disinfect": {

custom_components/bosch/translations/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,9 @@
340340
"name": "Extra hot water"
341341
},
342342
"open_window_detection": {
343+
"assigned_program_select": {
344+
"name": "Assigned program"
345+
},
343346
"name": "Open window detection"
344347
},
345348
"thermal_disinfect": {

custom_components/bosch/translations/fr.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,9 @@
319319
"name": "Eau chaude supplémentaire"
320320
},
321321
"open_window_detection": {
322+
"assigned_program_select": {
323+
"name": "Programme assigné"
324+
},
322325
"name": "Détection fenêtre ouverte"
323326
},
324327
"thermal_disinfect": {

custom_components/bosch/translations/it.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,9 @@
319319
"name": "Acqua calda aggiuntiva"
320320
},
321321
"open_window_detection": {
322+
"assigned_program_select": {
323+
"name": "Programma assegnato"
324+
},
322325
"name": "Rilevamento finestra aperta"
323326
},
324327
"thermal_disinfect": {

custom_components/bosch/translations/nl.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,9 @@
319319
"name": "Extra warm water"
320320
},
321321
"open_window_detection": {
322+
"assigned_program_select": {
323+
"name": "Toegewezen programma"
324+
},
322325
"name": "Open-raamdetectie"
323326
},
324327
"thermal_disinfect": {

custom_components/bosch/translations/pl.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,9 @@
319319
"name": "Dodatkowa ciepła woda"
320320
},
321321
"open_window_detection": {
322+
"assigned_program_select": {
323+
"name": "Przypisany program"
324+
},
322325
"name": "Wykrywanie otwartego okna"
323326
},
324327
"thermal_disinfect": {

custom_components/bosch/translations/sk.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,9 @@
319319
"name": "Dodatočný ohrev vody"
320320
},
321321
"open_window_detection": {
322+
"assigned_program_select": {
323+
"name": "Priradený program"
324+
},
322325
"name": "Detekcia otvoreného okna"
323326
},
324327
"thermal_disinfect": {

0 commit comments

Comments
 (0)