Add scheduled events and scene/cover cross-references to PowerView#168474
Add scheduled events and scene/cover cross-references to PowerView#168474TheRealPiotrP wants to merge 2 commits intohome-assistant:devfrom
Conversation
Cover entities expose which scenes they belong to, and scene entities expose which covers they control, with resolved HA entity IDs in both directions. Gen2 uses the SceneMembers API endpoint to build the mappings. Gen3 embeds shadeIds directly in scene data, so no additional API call is needed.
Add a Switch platform for Gen2 and Gen3 hubs that exposes each scheduled event (automation) as a switch entity. Switches can be toggled to enable or disable individual scheduled events. Scene entities gain scheduled_event_ids and scheduled_event_entity_ids attributes that cross-reference the corresponding switch entities. Gen2 uses the scheduledevents API endpoint; Gen3 uses the automations endpoint. Both are handled transparently by the aiopvapi Automations class.
|
Hey there @bdraco, @kingy444, @trullock, mind taking a look at this pull request as it has been labeled with an integration ( Code owner commandsCode owners of
|
There was a problem hiding this comment.
Pull request overview
Adds scheduled-event (automation) switch entities and bidirectional scene↔cover↔schedule cross-reference attributes to the Hunter Douglas PowerView integration to support driving native hub scheduling from Home Assistant.
Changes:
- Expose PowerView automations/scheduled events as
switchentities (Gen2/Gen3). - Add cross-reference state attributes on
sceneandcoverentities (plus scene references on schedule switches), resolving HA entity_ids via the entity registry. - Extend test fixtures and add new tests validating entity creation, switch toggling, and cross-reference attributes across API versions.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| homeassistant/components/hunterdouglas_powerview/init.py | Fetch automations + scene members (v2), build shade↔scene and scene↔automation mappings, and include switch platform. |
| homeassistant/components/hunterdouglas_powerview/model.py | Extend runtime data model with automation data and mapping dictionaries. |
| homeassistant/components/hunterdouglas_powerview/switch.py | New schedule switch platform exposing enable/disable and scene linkage attributes. |
| homeassistant/components/hunterdouglas_powerview/scene.py | Add scene attributes for shade + scheduled event cross-references, with entity-registry resolution. |
| homeassistant/components/hunterdouglas_powerview/cover.py | Add cover attributes for scene cross-references and initialize mappings on add. |
| tests/components/hunterdouglas_powerview/conftest.py | Patch new automation + scene member API fetches and add fixtures for new JSON payloads. |
| tests/components/hunterdouglas_powerview/test_schedule.py | New tests for schedule switch creation, state, attributes, toggling, and scene references. |
| tests/components/hunterdouglas_powerview/test_scene.py | New tests for scene shade/scheduled-event cross-reference attributes across API versions. |
| tests/components/hunterdouglas_powerview/test_cover.py | New tests for cover scene cross-reference attributes across API versions. |
| self._attr_extra_state_attributes = { | ||
| STATE_ATTRIBUTE_ROOM_NAME: room_name, | ||
| "scene_name": scene_name, | ||
| "scene_id": automation.scene_id, | ||
| "scene_entity_id": None, | ||
| "execution_time": automation.get_execution_time(), | ||
| "execution_days": automation.get_execution_days(), | ||
| } | ||
|
|
||
| async def async_added_to_hass(self) -> None: | ||
| """Resolve scene PowerView ID to HA entity ID once registered.""" | ||
| await super().async_added_to_hass() | ||
| entity_registry = er.async_get(self.hass) | ||
| serial = self._device_info.serial_number | ||
| entity_id = entity_registry.async_get_entity_id( | ||
| Platform.SCENE, DOMAIN, f"{serial}_{self._automation.scene_id}" | ||
| ) | ||
| if entity_id: | ||
| self._attr_extra_state_attributes = { | ||
| **self._attr_extra_state_attributes, | ||
| "scene_entity_id": entity_id, | ||
| } | ||
| self.async_write_ha_state() |
There was a problem hiding this comment.
Resolve scene_entity_id dynamically (or listen for entity-registry updates) so the attribute stays correct after renames and isn’t missed due to platform setup ordering; resolving once in async_added_to_hass can leave it permanently None or stale.
| self._attr_extra_state_attributes = { | |
| STATE_ATTRIBUTE_ROOM_NAME: room_name, | |
| "scene_name": scene_name, | |
| "scene_id": automation.scene_id, | |
| "scene_entity_id": None, | |
| "execution_time": automation.get_execution_time(), | |
| "execution_days": automation.get_execution_days(), | |
| } | |
| async def async_added_to_hass(self) -> None: | |
| """Resolve scene PowerView ID to HA entity ID once registered.""" | |
| await super().async_added_to_hass() | |
| entity_registry = er.async_get(self.hass) | |
| serial = self._device_info.serial_number | |
| entity_id = entity_registry.async_get_entity_id( | |
| Platform.SCENE, DOMAIN, f"{serial}_{self._automation.scene_id}" | |
| ) | |
| if entity_id: | |
| self._attr_extra_state_attributes = { | |
| **self._attr_extra_state_attributes, | |
| "scene_entity_id": entity_id, | |
| } | |
| self.async_write_ha_state() | |
| self._extra_state_attributes = { | |
| STATE_ATTRIBUTE_ROOM_NAME: room_name, | |
| "scene_name": scene_name, | |
| "scene_id": automation.scene_id, | |
| "execution_time": automation.get_execution_time(), | |
| "execution_days": automation.get_execution_days(), | |
| } | |
| @property | |
| def extra_state_attributes(self) -> dict[str, Any]: | |
| """Return schedule attributes with the current scene entity ID.""" | |
| entity_registry = er.async_get(self.hass) | |
| serial = self._device_info.serial_number | |
| entity_id = entity_registry.async_get_entity_id( | |
| Platform.SCENE, DOMAIN, f"{serial}_{self._automation.scene_id}" | |
| ) | |
| return { | |
| **self._extra_state_attributes, | |
| "scene_entity_id": entity_id, | |
| } |
| entity_registry = er.async_get(self.hass) | ||
| config_entry_id = self.coordinator.config_entry.entry_id | ||
| all_entries = er.async_entries_for_config_entry( | ||
| entity_registry, config_entry_id | ||
| ) | ||
| serial = self._device_info.serial_number |
There was a problem hiding this comment.
Avoid recomputing cross-reference attributes by scanning the entire entity registry on every coordinator-driven state write (this can run every minute for every scene); cache the resolved entity_ids and refresh them via entity-registry update signals, or use targeted async_get_entity_id lookups for known unique_ids.
| shade_entity_ids: list[str] = [] | ||
| for shade_id in self._shade_ids: | ||
| prefix = f"{serial}_{shade_id}" | ||
| shade_entity_ids.extend( | ||
| e.entity_id | ||
| for e in all_entries | ||
| if e.domain == Platform.COVER | ||
| and (e.unique_id == prefix or e.unique_id.startswith(f"{prefix}_")) | ||
| ) | ||
|
|
||
| automation_entity_ids = [ | ||
| entity_id | ||
| for automation_id in self._automation_ids | ||
| if ( | ||
| entity_id := entity_registry.async_get_entity_id( | ||
| Platform.SWITCH, DOMAIN, f"{serial}_{automation_id}" | ||
| ) | ||
| ) | ||
| ] | ||
|
|
||
| return { | ||
| STATE_ATTRIBUTE_ROOM_NAME: self._room_name, | ||
| "shade_ids": self._shade_ids, | ||
| "shade_entity_ids": shade_entity_ids, | ||
| "scheduled_event_ids": self._automation_ids, | ||
| "scheduled_event_entity_ids": automation_entity_ids, | ||
| } |
There was a problem hiding this comment.
Sort and de-duplicate the computed ID lists before exposing them as state attributes to keep attribute ordering stable and avoid unnecessary state-change churn when API/registry ordering varies.
| scene_to_shade_ids: dict[int, list[int]] = {} | ||
| shade_to_scene_ids: dict[int, list[int]] = {} | ||
| if scene_member_data: | ||
| for member in scene_member_data.raw: | ||
| s_id = member["sceneId"] | ||
| sh_id = member["shadeId"] | ||
| scene_to_shade_ids.setdefault(s_id, []).append(sh_id) | ||
| shade_to_scene_ids.setdefault(sh_id, []).append(s_id) | ||
| elif hub.api_version >= 3: | ||
| # Gen3 embeds shadeIds directly in scene data; no separate API call needed | ||
| for scene in scene_data.processed.values(): | ||
| for sh_id in scene.raw_data.get("shadeIds", []): | ||
| scene_to_shade_ids.setdefault(scene.id, []).append(sh_id) | ||
| shade_to_scene_ids.setdefault(sh_id, []).append(scene.id) | ||
|
|
||
| scene_to_automation_ids: dict[int, list[int]] = {} | ||
| if automation_data: | ||
| for automation in automation_data.processed.values(): | ||
| if automation.scene_id is not None: | ||
| scene_to_automation_ids.setdefault(automation.scene_id, []).append( | ||
| automation.id | ||
| ) | ||
|
|
There was a problem hiding this comment.
De-duplicate (and ideally sort) the shade/scene/automation ID mappings when building them so downstream entity attributes remain deterministic and don’t accumulate duplicates if the upstream API returns repeated entries.
| scene_to_shade_ids: dict[int, list[int]] = {} | |
| shade_to_scene_ids: dict[int, list[int]] = {} | |
| if scene_member_data: | |
| for member in scene_member_data.raw: | |
| s_id = member["sceneId"] | |
| sh_id = member["shadeId"] | |
| scene_to_shade_ids.setdefault(s_id, []).append(sh_id) | |
| shade_to_scene_ids.setdefault(sh_id, []).append(s_id) | |
| elif hub.api_version >= 3: | |
| # Gen3 embeds shadeIds directly in scene data; no separate API call needed | |
| for scene in scene_data.processed.values(): | |
| for sh_id in scene.raw_data.get("shadeIds", []): | |
| scene_to_shade_ids.setdefault(scene.id, []).append(sh_id) | |
| shade_to_scene_ids.setdefault(sh_id, []).append(scene.id) | |
| scene_to_automation_ids: dict[int, list[int]] = {} | |
| if automation_data: | |
| for automation in automation_data.processed.values(): | |
| if automation.scene_id is not None: | |
| scene_to_automation_ids.setdefault(automation.scene_id, []).append( | |
| automation.id | |
| ) | |
| scene_to_shade_ids_map: dict[int, set[int]] = {} | |
| shade_to_scene_ids_map: dict[int, set[int]] = {} | |
| if scene_member_data: | |
| for member in scene_member_data.raw: | |
| s_id = member["sceneId"] | |
| sh_id = member["shadeId"] | |
| scene_to_shade_ids_map.setdefault(s_id, set()).add(sh_id) | |
| shade_to_scene_ids_map.setdefault(sh_id, set()).add(s_id) | |
| elif hub.api_version >= 3: | |
| # Gen3 embeds shadeIds directly in scene data; no separate API call needed | |
| for scene in scene_data.processed.values(): | |
| for sh_id in scene.raw_data.get("shadeIds", []): | |
| scene_to_shade_ids_map.setdefault(scene.id, set()).add(sh_id) | |
| shade_to_scene_ids_map.setdefault(sh_id, set()).add(scene.id) | |
| scene_to_automation_ids_map: dict[int, set[int]] = {} | |
| if automation_data: | |
| for automation in automation_data.processed.values(): | |
| if automation.scene_id is not None: | |
| scene_to_automation_ids_map.setdefault( | |
| automation.scene_id, set() | |
| ).add(automation.id) | |
| scene_to_shade_ids = { | |
| scene_id: sorted(shade_ids) | |
| for scene_id, shade_ids in sorted(scene_to_shade_ids_map.items()) | |
| } | |
| shade_to_scene_ids = { | |
| shade_id: sorted(scene_ids) | |
| for shade_id, scene_ids in sorted(shade_to_scene_ids_map.items()) | |
| } | |
| scene_to_automation_ids = { | |
| scene_id: sorted(automation_ids) | |
| for scene_id, automation_ids in sorted(scene_to_automation_ids_map.items()) | |
| } |
| scene_entity_ids: list[str] = [] | ||
| if self._scene_ids: | ||
| entity_registry = er.async_get(self.hass) | ||
| serial = self._device_info.serial_number | ||
| for scene_id in self._scene_ids: | ||
| entity_id = entity_registry.async_get_entity_id( | ||
| Platform.SCENE, DOMAIN, f"{serial}_{scene_id}" | ||
| ) | ||
| if entity_id: | ||
| scene_entity_ids.append(entity_id) | ||
| return { | ||
| STATE_ATTRIBUTE_ROOM_NAME: self._room_name, | ||
| "scene_ids": self._scene_ids, | ||
| "scene_entity_ids": scene_entity_ids, | ||
| } |
There was a problem hiding this comment.
Sort (and optionally de-duplicate) scene_ids / scene_entity_ids before returning them so attribute ordering is stable and doesn’t cause unnecessary state changes when the upstream mapping order varies.
Proposed change
Adds two new features to the Hunter Douglas PowerView integration:
Scheduled event switches: Exposes each scheduled event (automation) on Gen2 and Gen3 hubs as a
switchentity, allowing users to enable or disable individual scheduled events from Home Assistant. Gen2 uses thescheduledeventsAPI endpoint; Gen3 uses theautomationsendpoint — both handled transparently byaiopvapi'sAutomationsclass.Scene/cover cross-references: Cover entities gain a
scene_entity_idsattribute listing the scenes they belong to, and scene entities gainshade_ids/shade_entity_idsattributes listing the covers they control. Scene entities also gainscheduled_event_ids/scheduled_event_entity_idsattributes cross-referencing their corresponding switch entities. Entity IDs are resolved dynamically from the entity registry so they survive entity renames.Gen2 uses the
SceneMembersAPI endpoint to build shade↔scene mappings. Gen3 embedsshadeIdsdirectly in scene data, so no extra API call is needed.Type of change
Additional information
Checklist
ruff format homeassistant tests)If user exposed functionality or configuration variables are added/changed:
If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running:
python3 -m script.hassfest.requirements_all.txt.Updated by running
python3 -m script.gen_requirements_all.To help with the load of incoming pull requests: