@@ -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+
13191432def _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
25132629def _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+
25602684class 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 :
0 commit comments