Home Assistant Unofficial Reference 2024.12.1
scene.py
Go to the documentation of this file.
1 """Allow users to set and activate scenes."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping, ValuesView
6 import logging
7 from typing import Any, NamedTuple, cast
8 
9 import voluptuous as vol
10 
11 from homeassistant import config as conf_util
12 from homeassistant.components.light import ATTR_TRANSITION
13 from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, STATES, Scene
14 from homeassistant.const import (
15  ATTR_ENTITY_ID,
16  ATTR_STATE,
17  CONF_ENTITIES,
18  CONF_ICON,
19  CONF_ID,
20  CONF_NAME,
21  CONF_PLATFORM,
22  SERVICE_RELOAD,
23  STATE_OFF,
24  STATE_ON,
25 )
26 from homeassistant.core import HomeAssistant, ServiceCall, State, callback
27 from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
28 from homeassistant.helpers import config_validation as cv, entity_platform
29 from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform
31  async_extract_entity_ids,
32  async_register_admin_service,
33 )
34 from homeassistant.helpers.state import async_reproduce_state
35 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
36 from homeassistant.loader import async_get_integration
37 
38 from .const import DOMAIN
39 
40 
41 def _convert_states(states: dict[str, Any]) -> dict[str, State]:
42  """Convert state definitions to State objects."""
43  result: dict[str, State] = {}
44 
45  for entity_id, info in states.items():
46  entity_id = cv.entity_id(entity_id)
47 
48  if isinstance(info, dict):
49  entity_attrs = info.copy()
50  state = entity_attrs.pop(ATTR_STATE, None)
51  attributes = entity_attrs
52  else:
53  state = info
54  attributes = {}
55 
56  # YAML translates 'on' to a boolean
57  # http://yaml.org/type/bool.html
58  if isinstance(state, bool):
59  state = STATE_ON if state else STATE_OFF
60  elif not isinstance(state, str):
61  raise vol.Invalid(f"State for {entity_id} should be a string")
62 
63  result[entity_id] = State(entity_id, state, attributes)
64 
65  return result
66 
67 
68 def _ensure_no_intersection(value: dict[str, Any]) -> dict[str, Any]:
69  """Validate that entities and snapshot_entities do not overlap."""
70  if (
71  CONF_SNAPSHOT not in value
72  or CONF_ENTITIES not in value
73  or all(
74  entity_id not in value[CONF_SNAPSHOT] for entity_id in value[CONF_ENTITIES]
75  )
76  ):
77  return value
78 
79  raise vol.Invalid("entities and snapshot_entities must not overlap")
80 
81 
82 CONF_SCENE_ID = "scene_id"
83 CONF_SNAPSHOT = "snapshot_entities"
84 DATA_PLATFORM = "homeassistant_scene"
85 EVENT_SCENE_RELOADED = "scene_reloaded"
86 STATES_SCHEMA = vol.All(dict, _convert_states)
87 
88 
89 PLATFORM_SCHEMA = vol.Schema(
90  {
91  vol.Required(CONF_PLATFORM): DOMAIN,
92  vol.Required(STATES): vol.All(
93  cv.ensure_list,
94  [
95  vol.Schema(
96  {
97  vol.Optional(CONF_ID): cv.string,
98  vol.Required(CONF_NAME): cv.string,
99  vol.Optional(CONF_ICON): cv.icon,
100  vol.Required(CONF_ENTITIES): STATES_SCHEMA,
101  vol.Optional("metadata"): dict,
102  }
103  )
104  ],
105  ),
106  },
107  extra=vol.ALLOW_EXTRA,
108 )
109 
110 CREATE_SCENE_SCHEMA = vol.All(
111  cv.has_at_least_one_key(CONF_ENTITIES, CONF_SNAPSHOT),
112  _ensure_no_intersection,
113  vol.Schema(
114  {
115  vol.Required(CONF_SCENE_ID): cv.slug,
116  vol.Optional(CONF_ENTITIES, default={}): STATES_SCHEMA,
117  vol.Optional(CONF_SNAPSHOT, default=[]): cv.entity_ids,
118  }
119  ),
120 )
121 
122 SERVICE_APPLY = "apply"
123 SERVICE_CREATE = "create"
124 SERVICE_DELETE = "delete"
125 
126 _LOGGER = logging.getLogger(__name__)
127 
128 
129 class SceneConfig(NamedTuple):
130  """Object for storing scene config."""
131 
132  id: str | None
133  name: str
134  icon: str | None
135  states: dict[str, State]
136 
137 
138 @callback
139 def scenes_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]:
140  """Return all scenes that reference the entity."""
141  if DATA_PLATFORM not in hass.data:
142  return []
143 
144  platform: EntityPlatform = hass.data[DATA_PLATFORM]
145 
146  scene_entities = cast(ValuesView[HomeAssistantScene], platform.entities.values())
147  return [
148  scene_entity.entity_id
149  for scene_entity in scene_entities
150  if entity_id in scene_entity.scene_config.states
151  ]
152 
153 
154 @callback
155 def entities_in_scene(hass: HomeAssistant, entity_id: str) -> list[str]:
156  """Return all entities in a scene."""
157  if DATA_PLATFORM not in hass.data:
158  return []
159 
160  platform: EntityPlatform = hass.data[DATA_PLATFORM]
161 
162  if (entity := platform.entities.get(entity_id)) is None:
163  return []
164 
165  return list(cast(HomeAssistantScene, entity).scene_config.states)
166 
167 
169  hass: HomeAssistant,
170  config: ConfigType,
171  async_add_entities: AddEntitiesCallback,
172  discovery_info: DiscoveryInfoType | None = None,
173 ) -> None:
174  """Set up Home Assistant scene entries."""
175  _process_scenes_config(hass, async_add_entities, config)
176 
177  # This platform can be loaded multiple times. Only first time register the service.
178  if hass.services.has_service(SCENE_DOMAIN, SERVICE_RELOAD):
179  return
180 
181  # Store platform for later.
182  platform = hass.data[DATA_PLATFORM] = entity_platform.async_get_current_platform()
183 
184  async def reload_config(call: ServiceCall) -> None:
185  """Reload the scene config."""
186  try:
187  config = await conf_util.async_hass_config_yaml(hass)
188  except HomeAssistantError as err:
189  _LOGGER.error(err)
190  return
191 
192  integration = await async_get_integration(hass, SCENE_DOMAIN)
193 
194  conf = await conf_util.async_process_component_and_handle_errors(
195  hass, config, integration
196  )
197 
198  if not (conf and platform):
199  return
200 
201  await platform.async_reset()
202 
203  # Extract only the config for the Home Assistant platform, ignore the rest.
204  for p_type, p_config in conf_util.config_per_platform(conf, SCENE_DOMAIN):
205  if p_type != DOMAIN:
206  continue
207 
208  _process_scenes_config(hass, async_add_entities, p_config)
209 
210  hass.bus.async_fire(EVENT_SCENE_RELOADED, context=call.context)
211 
212  async_register_admin_service(hass, SCENE_DOMAIN, SERVICE_RELOAD, reload_config)
213 
214  async def apply_service(call: ServiceCall) -> None:
215  """Apply a scene."""
216  reproduce_options = {}
217 
218  if ATTR_TRANSITION in call.data:
219  reproduce_options[ATTR_TRANSITION] = call.data.get(ATTR_TRANSITION)
220 
221  await async_reproduce_state(
222  hass,
223  call.data[CONF_ENTITIES].values(),
224  context=call.context,
225  reproduce_options=reproduce_options,
226  )
227 
228  hass.services.async_register(
229  SCENE_DOMAIN,
230  SERVICE_APPLY,
231  apply_service,
232  vol.Schema(
233  {
234  vol.Optional(ATTR_TRANSITION): vol.All(
235  vol.Coerce(float), vol.Clamp(min=0, max=6553)
236  ),
237  vol.Required(CONF_ENTITIES): STATES_SCHEMA,
238  }
239  ),
240  )
241 
242  async def create_service(call: ServiceCall) -> None:
243  """Create a scene."""
244  snapshot = call.data[CONF_SNAPSHOT]
245  entities = call.data[CONF_ENTITIES]
246 
247  for entity_id in snapshot:
248  if (state := hass.states.get(entity_id)) is None:
249  _LOGGER.warning(
250  "Entity %s does not exist and therefore cannot be snapshotted",
251  entity_id,
252  )
253  continue
254  entities[entity_id] = State(entity_id, state.state, state.attributes)
255 
256  if not entities:
257  _LOGGER.warning("Empty scenes are not allowed")
258  return
259 
260  scene_config = SceneConfig(None, call.data[CONF_SCENE_ID], None, entities)
261  entity_id = f"{SCENE_DOMAIN}.{scene_config.name}"
262  if (old := platform.entities.get(entity_id)) is not None:
263  if not isinstance(old, HomeAssistantScene) or not old.from_service:
264  _LOGGER.warning("The scene %s already exists", entity_id)
265  return
266  await platform.async_remove_entity(entity_id)
267  async_add_entities([HomeAssistantScene(hass, scene_config, from_service=True)])
268 
269  hass.services.async_register(
270  SCENE_DOMAIN, SERVICE_CREATE, create_service, CREATE_SCENE_SCHEMA
271  )
272 
273  async def delete_service(call: ServiceCall) -> None:
274  """Delete a dynamically created scene."""
275  entity_ids = await async_extract_entity_ids(hass, call)
276 
277  for entity_id in entity_ids:
278  scene = platform.entities.get(entity_id)
279  if scene is None:
281  translation_domain=SCENE_DOMAIN,
282  translation_key="entity_not_scene",
283  translation_placeholders={
284  "entity_id": entity_id,
285  },
286  )
287  assert isinstance(scene, HomeAssistantScene)
288  if not scene.from_service:
290  translation_domain=SCENE_DOMAIN,
291  translation_key="entity_not_dynamically_created",
292  translation_placeholders={
293  "entity_id": entity_id,
294  },
295  )
296 
297  await platform.async_remove_entity(entity_id)
298 
299  hass.services.async_register(
300  SCENE_DOMAIN,
301  SERVICE_DELETE,
302  delete_service,
303  cv.make_entity_service_schema({}),
304  )
305 
306 
308  hass: HomeAssistant, async_add_entities: AddEntitiesCallback, config: dict[str, Any]
309 ) -> None:
310  """Process multiple scenes and add them."""
311  # Check empty list
312  scene_config: list[dict[str, Any]]
313  if not (scene_config := config[STATES]):
314  return
315 
318  hass,
319  SceneConfig(
320  scene.get(CONF_ID),
321  scene[CONF_NAME],
322  scene.get(CONF_ICON),
323  scene[CONF_ENTITIES],
324  ),
325  )
326  for scene in scene_config
327  )
328 
329 
330 class HomeAssistantScene(Scene):
331  """A scene is a group of entities and the states we want them to be."""
332 
333  def __init__(
334  self, hass: HomeAssistant, scene_config: SceneConfig, from_service: bool = False
335  ) -> None:
336  """Initialize the scene."""
337  self.hasshass = hass
338  self.scene_configscene_config = scene_config
339  self.from_servicefrom_service = from_service
340 
341  @property
342  def name(self) -> str:
343  """Return the name of the scene."""
344  return self.scene_configscene_config.name
345 
346  @property
347  def icon(self) -> str | None:
348  """Return the icon of the scene."""
349  return self.scene_configscene_config.icon
350 
351  @property
352  def unique_id(self) -> str | None:
353  """Return unique ID."""
354  return self.scene_configscene_config.id
355 
356  @property
357  def extra_state_attributes(self) -> Mapping[str, Any]:
358  """Return the scene state attributes."""
359  attributes: dict[str, Any] = {ATTR_ENTITY_ID: list(self.scene_configscene_config.states)}
360  if (unique_id := self.unique_idunique_id) is not None:
361  attributes[CONF_ID] = unique_id
362  return attributes
363 
364  async def async_activate(self, **kwargs: Any) -> None:
365  """Activate scene. Try to get entities into requested state."""
366  await async_reproduce_state(
367  self.hasshass,
368  self.scene_configscene_config.states.values(),
369  context=self._context,
370  reproduce_options=kwargs,
371  )
None __init__(self, HomeAssistant hass, SceneConfig scene_config, bool from_service=False)
Definition: scene.py:335
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: scene.py:173
list[str] scenes_with_entity(HomeAssistant hass, str entity_id)
Definition: scene.py:139
dict[str, Any] _ensure_no_intersection(dict[str, Any] value)
Definition: scene.py:68
dict[str, State] _convert_states(dict[str, Any] states)
Definition: scene.py:41
list[str] entities_in_scene(HomeAssistant hass, str entity_id)
Definition: scene.py:155
None _process_scenes_config(HomeAssistant hass, AddEntitiesCallback async_add_entities, dict[str, Any] config)
Definition: scene.py:309
None async_register_admin_service(HomeAssistant hass, str domain, str service, Callable[[ServiceCall], Awaitable[None]|None] service_func, VolSchemaType schema=vol.Schema({}, extra=vol.PREVENT_EXTRA))
Definition: service.py:1121
set[str] async_extract_entity_ids(HomeAssistant hass, ServiceCall service_call, bool expand_group=True)
Definition: service.py:490
None async_reproduce_state(HomeAssistant hass, State|Iterable[State] states, *Context|None context=None, dict[str, Any]|None reproduce_options=None)
Definition: state.py:36
Integration async_get_integration(HomeAssistant hass, str domain)
Definition: loader.py:1354