Home Assistant Unofficial Reference 2024.12.1
entry_data.py
Go to the documentation of this file.
1 """Runtime entry data for ESPHome stored in hass.data."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections import defaultdict
7 from collections.abc import Callable, Iterable
8 from dataclasses import dataclass, field
9 from functools import partial
10 import logging
11 from typing import TYPE_CHECKING, Any, Final, TypedDict, cast
12 
13 from aioesphomeapi import (
14  COMPONENT_TYPE_TO_INFO,
15  AlarmControlPanelInfo,
16  APIClient,
17  APIVersion,
18  BinarySensorInfo,
19  CameraInfo,
20  CameraState,
21  ClimateInfo,
22  CoverInfo,
23  DateInfo,
24  DateTimeInfo,
25  DeviceInfo,
26  EntityInfo,
27  EntityState,
28  Event,
29  EventInfo,
30  FanInfo,
31  LightInfo,
32  LockInfo,
33  MediaPlayerInfo,
34  MediaPlayerSupportedFormat,
35  NumberInfo,
36  SelectInfo,
37  SensorInfo,
38  SensorState,
39  SwitchInfo,
40  TextInfo,
41  TextSensorInfo,
42  TimeInfo,
43  UpdateInfo,
44  UserService,
45  ValveInfo,
46  build_unique_id,
47 )
48 from aioesphomeapi.model import ButtonInfo
49 from bleak_esphome.backend.device import ESPHomeBluetoothDevice
50 
51 from homeassistant.components.assist_satellite import AssistSatelliteConfiguration
52 from homeassistant.config_entries import ConfigEntry
53 from homeassistant.const import Platform
54 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
55 from homeassistant.helpers import entity_registry as er
56 from homeassistant.helpers.storage import Store
57 
58 from .const import DOMAIN
59 from .dashboard import async_get_dashboard
60 
61 type ESPHomeConfigEntry = ConfigEntry[RuntimeEntryData]
62 
63 
64 INFO_TO_COMPONENT_TYPE: Final = {v: k for k, v in COMPONENT_TYPE_TO_INFO.items()}
65 
66 _SENTINEL = object()
67 SAVE_DELAY = 120
68 _LOGGER = logging.getLogger(__name__)
69 
70 # Mapping from ESPHome info type to HA platform
71 INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
72  AlarmControlPanelInfo: Platform.ALARM_CONTROL_PANEL,
73  BinarySensorInfo: Platform.BINARY_SENSOR,
74  ButtonInfo: Platform.BUTTON,
75  CameraInfo: Platform.CAMERA,
76  ClimateInfo: Platform.CLIMATE,
77  CoverInfo: Platform.COVER,
78  DateInfo: Platform.DATE,
79  DateTimeInfo: Platform.DATETIME,
80  EventInfo: Platform.EVENT,
81  FanInfo: Platform.FAN,
82  LightInfo: Platform.LIGHT,
83  LockInfo: Platform.LOCK,
84  MediaPlayerInfo: Platform.MEDIA_PLAYER,
85  NumberInfo: Platform.NUMBER,
86  SelectInfo: Platform.SELECT,
87  SensorInfo: Platform.SENSOR,
88  SwitchInfo: Platform.SWITCH,
89  TextInfo: Platform.TEXT,
90  TextSensorInfo: Platform.SENSOR,
91  TimeInfo: Platform.TIME,
92  UpdateInfo: Platform.UPDATE,
93  ValveInfo: Platform.VALVE,
94 }
95 
96 
97 class StoreData(TypedDict, total=False):
98  """ESPHome storage data."""
99 
100  device_info: dict[str, Any]
101  services: list[dict[str, Any]]
102  api_version: dict[str, Any]
103 
104 
105 class ESPHomeStorage(Store[StoreData]):
106  """ESPHome Storage."""
107 
108 
109 @dataclass(slots=True)
111  """Store runtime data for esphome config entries."""
112 
113  entry_id: str
114  title: str
115  client: APIClient
116  store: ESPHomeStorage
117  state: defaultdict[type[EntityState], dict[int, EntityState]] = field(
118  default_factory=lambda: defaultdict(dict)
119  )
120  # When the disconnect callback is called, we mark all states
121  # as stale so we will always dispatch a state update when the
122  # device reconnects. This is the same format as state_subscriptions.
123  stale_state: set[tuple[type[EntityState], int]] = field(default_factory=set)
124  info: dict[type[EntityInfo], dict[int, EntityInfo]] = field(default_factory=dict)
125  services: dict[int, UserService] = field(default_factory=dict)
126  available: bool = False
127  expected_disconnect: bool = False # Last disconnect was expected (e.g. deep sleep)
128  device_info: DeviceInfo | None = None
129  bluetooth_device: ESPHomeBluetoothDevice | None = None
130  api_version: APIVersion = field(default_factory=APIVersion)
131  cleanup_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
132  disconnect_callbacks: set[CALLBACK_TYPE] = field(default_factory=set)
133  state_subscriptions: dict[tuple[type[EntityState], int], CALLBACK_TYPE] = field(
134  default_factory=dict
135  )
136  device_update_subscriptions: set[CALLBACK_TYPE] = field(default_factory=set)
137  static_info_update_subscriptions: set[Callable[[list[EntityInfo]], None]] = field(
138  default_factory=set
139  )
140  loaded_platforms: set[Platform] = field(default_factory=set)
141  platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
142  _storage_contents: StoreData | None = None
143  _pending_storage: Callable[[], StoreData] | None = None
144  assist_pipeline_update_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
145  assist_pipeline_state: bool = False
146  entity_info_callbacks: dict[
147  type[EntityInfo], list[Callable[[list[EntityInfo]], None]]
148  ] = field(default_factory=dict)
149  entity_info_key_updated_callbacks: dict[
150  tuple[type[EntityInfo], int], list[Callable[[EntityInfo], None]]
151  ] = field(default_factory=dict)
152  original_options: dict[str, Any] = field(default_factory=dict)
153  media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field(
154  default_factory=lambda: defaultdict(list)
155  )
156  assist_satellite_config_update_callbacks: list[
157  Callable[[AssistSatelliteConfiguration], None]
158  ] = field(default_factory=list)
159  assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field(
160  default_factory=list
161  )
162 
163  @property
164  def name(self) -> str:
165  """Return the name of the device."""
166  device_info = self.device_infodevice_info
167  return (device_info and device_info.name) or self.title
168 
169  @property
170  def friendly_name(self) -> str:
171  """Return the friendly name of the device."""
172  device_info = self.device_infodevice_info
173  return (device_info and device_info.friendly_name) or self.namename.title().replace(
174  "_", " "
175  )
176 
177  @callback
179  self,
180  entity_info_type: type[EntityInfo],
181  callback_: Callable[[list[EntityInfo]], None],
182  ) -> CALLBACK_TYPE:
183  """Register to receive callbacks when static info changes for an EntityInfo type."""
184  callbacks = self.entity_info_callbacks.setdefault(entity_info_type, [])
185  callbacks.append(callback_)
186  return partial(
187  self._async_unsubscribe_register_static_info_async_unsubscribe_register_static_info, callbacks, callback_
188  )
189 
190  @callback
192  self,
193  callbacks: list[Callable[[list[EntityInfo]], None]],
194  callback_: Callable[[list[EntityInfo]], None],
195  ) -> None:
196  """Unsubscribe to when static info is registered."""
197  callbacks.remove(callback_)
198 
199  @callback
201  self,
202  static_info: EntityInfo,
203  callback_: Callable[[EntityInfo], None],
204  ) -> CALLBACK_TYPE:
205  """Register to receive callbacks when static info is updated for a specific key."""
206  callback_key = (type(static_info), static_info.key)
207  callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, [])
208  callbacks.append(callback_)
209  return partial(
210  self._async_unsubscribe_static_key_info_updated_async_unsubscribe_static_key_info_updated, callbacks, callback_
211  )
212 
213  @callback
215  self,
216  callbacks: list[Callable[[EntityInfo], None]],
217  callback_: Callable[[EntityInfo], None],
218  ) -> None:
219  """Unsubscribe to when static info is updated ."""
220  callbacks.remove(callback_)
221 
222  @callback
223  def async_set_assist_pipeline_state(self, state: bool) -> None:
224  """Set the assist pipeline state."""
225  self.assist_pipeline_stateassist_pipeline_state = state
226  for update_callback in self.assist_pipeline_update_callbacks:
227  update_callback()
228 
229  @callback
231  self, update_callback: CALLBACK_TYPE
232  ) -> CALLBACK_TYPE:
233  """Subscribe to assist pipeline updates."""
234  self.assist_pipeline_update_callbacks.append(update_callback)
235  return partial(self._async_unsubscribe_assist_pipeline_update_async_unsubscribe_assist_pipeline_update, update_callback)
236 
237  @callback
239  self, update_callback: CALLBACK_TYPE
240  ) -> None:
241  """Unsubscribe to assist pipeline updates."""
242  self.assist_pipeline_update_callbacks.remove(update_callback)
243 
244  @callback
246  self, hass: HomeAssistant, static_infos: Iterable[EntityInfo], mac: str
247  ) -> None:
248  """Schedule the removal of an entity."""
249  # Remove from entity registry first so the entity is fully removed
250  ent_reg = er.async_get(hass)
251  for info in static_infos:
252  if entry := ent_reg.async_get_entity_id(
253  INFO_TYPE_TO_PLATFORM[type(info)], DOMAIN, build_unique_id(mac, info)
254  ):
255  ent_reg.async_remove(entry)
256 
257  @callback
258  def async_update_entity_infos(self, static_infos: Iterable[EntityInfo]) -> None:
259  """Call static info updated callbacks."""
260  callbacks = self.entity_info_key_updated_callbacks
261  for static_info in static_infos:
262  for callback_ in callbacks.get((type(static_info), static_info.key), ()):
263  callback_(static_info)
264 
266  self,
267  hass: HomeAssistant,
268  entry: ESPHomeConfigEntry,
269  platforms: set[Platform],
270  ) -> None:
271  async with self.platform_load_lock:
272  if needed := platforms - self.loaded_platforms:
273  await hass.config_entries.async_forward_entry_setups(entry, needed)
274  self.loaded_platforms |= needed
275 
277  self,
278  hass: HomeAssistant,
279  entry: ESPHomeConfigEntry,
280  infos: list[EntityInfo],
281  mac: str,
282  ) -> None:
283  """Distribute an update of static infos to all platforms."""
284  # First, load all platforms
285  needed_platforms = set()
286  if async_get_dashboard(hass):
287  needed_platforms.add(Platform.UPDATE)
288 
289  if self.device_infodevice_info and self.device_infodevice_info.voice_assistant_feature_flags_compat(
290  self.api_versionapi_version
291  ):
292  needed_platforms.add(Platform.BINARY_SENSOR)
293  needed_platforms.add(Platform.SELECT)
294 
295  ent_reg = er.async_get(hass)
296  registry_get_entity = ent_reg.async_get_entity_id
297  for info in infos:
298  platform = INFO_TYPE_TO_PLATFORM[type(info)]
299  needed_platforms.add(platform)
300  # If the unique id is in the old format, migrate it
301  # except if they downgraded and upgraded, there might be a duplicate
302  # so we want to keep the one that was already there.
303  if (
304  (old_unique_id := info.unique_id)
305  and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id))
306  and (new_unique_id := build_unique_id(mac, info)) != old_unique_id
307  and not registry_get_entity(platform, DOMAIN, new_unique_id)
308  ):
309  ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id)
310 
311  await self._ensure_platforms_loaded_ensure_platforms_loaded(hass, entry, needed_platforms)
312 
313  # Make a dict of the EntityInfo by type and send
314  # them to the listeners for each specific EntityInfo type
315  infos_by_type: dict[type[EntityInfo], list[EntityInfo]] = {}
316  for info in infos:
317  info_type = type(info)
318  if info_type not in infos_by_type:
319  infos_by_type[info_type] = []
320  infos_by_type[info_type].append(info)
321 
322  callbacks_by_type = self.entity_info_callbacks
323  for type_, entity_infos in infos_by_type.items():
324  if callbacks_ := callbacks_by_type.get(type_):
325  for callback_ in callbacks_:
326  callback_(entity_infos)
327 
328  # Finally update static info subscriptions
329  for callback_ in self.static_info_update_subscriptions:
330  callback_(infos)
331 
332  @callback
333  def async_subscribe_device_updated(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE:
334  """Subscribe to state updates."""
335  self.device_update_subscriptions.add(callback_)
336  return partial(self._async_unsubscribe_device_update_async_unsubscribe_device_update, callback_)
337 
338  @callback
339  def _async_unsubscribe_device_update(self, callback_: CALLBACK_TYPE) -> None:
340  """Unsubscribe to device updates."""
341  self.device_update_subscriptions.remove(callback_)
342 
343  @callback
345  self, callback_: Callable[[list[EntityInfo]], None]
346  ) -> CALLBACK_TYPE:
347  """Subscribe to static info updates."""
348  self.static_info_update_subscriptions.add(callback_)
349  return partial(self._async_unsubscribe_static_info_updated_async_unsubscribe_static_info_updated, callback_)
350 
351  @callback
353  self, callback_: Callable[[list[EntityInfo]], None]
354  ) -> None:
355  """Unsubscribe to static info updates."""
356  self.static_info_update_subscriptions.remove(callback_)
357 
358  @callback
360  self,
361  state_type: type[EntityState],
362  state_key: int,
363  entity_callback: CALLBACK_TYPE,
364  ) -> CALLBACK_TYPE:
365  """Subscribe to state updates."""
366  subscription_key = (state_type, state_key)
367  self.state_subscriptions[subscription_key] = entity_callback
368  return partial(self._async_unsubscribe_state_update_async_unsubscribe_state_update, subscription_key)
369 
370  @callback
372  self, subscription_key: tuple[type[EntityState], int]
373  ) -> None:
374  """Unsubscribe to state updates."""
375  self.state_subscriptions.pop(subscription_key)
376 
377  @callback
378  def async_update_state(self, state: EntityState) -> None:
379  """Distribute an update of state information to the target."""
380  key = state.key
381  state_type = type(state)
382  stale_state = self.stale_state
383  current_state_by_type = self.state[state_type]
384  current_state = current_state_by_type.get(key, _SENTINEL)
385  subscription_key = (state_type, key)
386  if (
387  current_state == state
388  and subscription_key not in stale_state
389  and state_type not in (CameraState, Event)
390  and not (
391  state_type is SensorState
392  and (platform_info := self.info.get(SensorInfo))
393  and (entity_info := platform_info.get(state.key))
394  and (cast(SensorInfo, entity_info)).force_update
395  )
396  ):
397  return
398  stale_state.discard(subscription_key)
399  current_state_by_type[key] = state
400  if subscription := self.state_subscriptions.get(subscription_key):
401  try:
402  subscription()
403  except Exception:
404  # If we allow this exception to raise it will
405  # make it all the way to data_received in aioesphomeapi
406  # which will cause the connection to be closed.
407  _LOGGER.exception("Error while calling subscription")
408 
409  @callback
410  def async_update_device_state(self) -> None:
411  """Distribute an update of a core device state like availability."""
412  for callback_ in self.device_update_subscriptions.copy():
413  callback_()
414 
415  async def async_load_from_store(self) -> tuple[list[EntityInfo], list[UserService]]:
416  """Load the retained data from store and return de-serialized data."""
417  if (restored := await self.store.async_load()) is None:
418  return [], []
419  self._storage_contents_storage_contents = restored.copy()
420 
421  self.device_infodevice_info = DeviceInfo.from_dict(restored.pop("device_info"))
422  self.api_versionapi_version = APIVersion.from_dict(restored.pop("api_version", {}))
423  infos: list[EntityInfo] = []
424  for comp_type, restored_infos in restored.items():
425  if TYPE_CHECKING:
426  restored_infos = cast(list[dict[str, Any]], restored_infos)
427  if comp_type not in COMPONENT_TYPE_TO_INFO:
428  continue
429  for info in restored_infos:
430  cls = COMPONENT_TYPE_TO_INFO[comp_type]
431  infos.append(cls.from_dict(info))
432  services = [
433  UserService.from_dict(service) for service in restored.pop("services", [])
434  ]
435  return infos, services
436 
437  def async_save_to_store(self) -> None:
438  """Generate dynamic data to store and save it to the filesystem."""
439  if TYPE_CHECKING:
440  assert self.device_infodevice_info is not None
441  store_data: StoreData = {
442  "device_info": self.device_infodevice_info.to_dict(),
443  "services": [],
444  "api_version": self.api_versionapi_version.to_dict(),
445  }
446  for info_type, infos in self.info.items():
447  comp_type = INFO_TO_COMPONENT_TYPE[info_type]
448  store_data[comp_type] = [info.to_dict() for info in infos.values()] # type: ignore[literal-required]
449 
450  store_data["services"] = [
451  service.to_dict() for service in self.services.values()
452  ]
453  if store_data == self._storage_contents_storage_contents:
454  return
455 
456  def _memorized_storage() -> StoreData:
457  self._pending_storage_pending_storage = None
458  self._storage_contents_storage_contents = store_data
459  return store_data
460 
461  self._pending_storage_pending_storage = _memorized_storage
462  self.store.async_delay_save(_memorized_storage, SAVE_DELAY)
463 
464  async def async_cleanup(self) -> None:
465  """Cleanup the entry data when disconnected or unloading."""
466  if self._pending_storage_pending_storage:
467  # Ensure we save the data if we are unloading before the
468  # save delay has passed.
469  await self.store.async_save(self._pending_storage_pending_storage())
470 
472  self, hass: HomeAssistant, entry: ESPHomeConfigEntry
473  ) -> None:
474  """Handle options update."""
475  if self.original_optionsoriginal_options == entry.options:
476  return
477  hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
478 
479  @callback
480  def async_on_disconnect(self) -> None:
481  """Call when the entry has been disconnected.
482 
483  Safe to call multiple times.
484  """
485  self.availableavailable = False
486  if self.bluetooth_device:
487  self.bluetooth_device.available = False
488  # Make a copy since calling the disconnect callbacks
489  # may also try to discard/remove themselves.
490  for disconnect_cb in self.disconnect_callbacksdisconnect_callbacks.copy():
491  disconnect_cb()
492  # Make sure to clear the set to give up the reference
493  # to it and make sure all the callbacks can be GC'd.
494  self.disconnect_callbacksdisconnect_callbacks.clear()
495  self.disconnect_callbacksdisconnect_callbacks = set()
496 
497  @callback
499  self, device_info: DeviceInfo, api_version: APIVersion
500  ) -> None:
501  """Call when the entry has been connected."""
502  self.availableavailable = True
503  if self.bluetooth_device:
504  self.bluetooth_device.available = True
505 
506  self.device_infodevice_info = device_info
507  self.api_versionapi_version = api_version
508  # Reset expected disconnect flag on successful reconnect
509  # as it will be flipped to False on unexpected disconnect.
510  #
511  # We use this to determine if a deep sleep device should
512  # be marked as unavailable or not.
513  self.expected_disconnectexpected_disconnect = True
514 
515  @callback
517  self,
518  callback_: Callable[[AssistSatelliteConfiguration], None],
519  ) -> CALLBACK_TYPE:
520  """Register to receive callbacks when the Assist satellite's configuration is updated."""
521  self.assist_satellite_config_update_callbacks.append(callback_)
522  return lambda: self.assist_satellite_config_update_callbacks.remove(callback_)
523 
524  @callback
526  self, config: AssistSatelliteConfiguration
527  ) -> None:
528  """Notify listeners that the Assist satellite configuration has been updated."""
529  for callback_ in self.assist_satellite_config_update_callbacks.copy():
530  callback_(config)
531 
532  @callback
534  self,
535  callback_: Callable[[str], None],
536  ) -> CALLBACK_TYPE:
537  """Register to receive callbacks when the Assist satellite's wake word is set."""
538  self.assist_satellite_set_wake_word_callbacks.append(callback_)
539  return lambda: self.assist_satellite_set_wake_word_callbacks.remove(callback_)
540 
541  @callback
542  def async_assist_satellite_set_wake_word(self, wake_word_id: str) -> None:
543  """Notify listeners that the Assist satellite wake word has been set."""
544  for callback_ in self.assist_satellite_set_wake_word_callbacks.copy():
545  callback_(wake_word_id)
None async_update_entity_infos(self, Iterable[EntityInfo] static_infos)
Definition: entry_data.py:258
CALLBACK_TYPE async_subscribe_device_updated(self, CALLBACK_TYPE callback_)
Definition: entry_data.py:333
None _async_unsubscribe_assist_pipeline_update(self, CALLBACK_TYPE update_callback)
Definition: entry_data.py:240
None _async_unsubscribe_static_key_info_updated(self, list[Callable[[EntityInfo], None]] callbacks, Callable[[EntityInfo], None] callback_)
Definition: entry_data.py:218
CALLBACK_TYPE async_register_assist_satellite_config_updated_callback(self, Callable[[AssistSatelliteConfiguration], None] callback_)
Definition: entry_data.py:519
None async_on_connect(self, DeviceInfo device_info, APIVersion api_version)
Definition: entry_data.py:500
CALLBACK_TYPE async_register_static_info_callback(self, type[EntityInfo] entity_info_type, Callable[[list[EntityInfo]], None] callback_)
Definition: entry_data.py:182
None async_remove_entities(self, HomeAssistant hass, Iterable[EntityInfo] static_infos, str mac)
Definition: entry_data.py:247
CALLBACK_TYPE async_subscribe_state_update(self, type[EntityState] state_type, int state_key, CALLBACK_TYPE entity_callback)
Definition: entry_data.py:364
CALLBACK_TYPE async_subscribe_assist_pipeline_update(self, CALLBACK_TYPE update_callback)
Definition: entry_data.py:232
None _async_unsubscribe_static_info_updated(self, Callable[[list[EntityInfo]], None] callback_)
Definition: entry_data.py:354
None _ensure_platforms_loaded(self, HomeAssistant hass, ESPHomeConfigEntry entry, set[Platform] platforms)
Definition: entry_data.py:270
CALLBACK_TYPE async_register_assist_satellite_set_wake_word_callback(self, Callable[[str], None] callback_)
Definition: entry_data.py:536
tuple[list[EntityInfo], list[UserService]] async_load_from_store(self)
Definition: entry_data.py:415
None _async_unsubscribe_state_update(self, tuple[type[EntityState], int] subscription_key)
Definition: entry_data.py:373
None _async_unsubscribe_device_update(self, CALLBACK_TYPE callback_)
Definition: entry_data.py:339
None async_update_static_infos(self, HomeAssistant hass, ESPHomeConfigEntry entry, list[EntityInfo] infos, str mac)
Definition: entry_data.py:282
None async_update_listener(self, HomeAssistant hass, ESPHomeConfigEntry entry)
Definition: entry_data.py:473
None async_assist_satellite_config_updated(self, AssistSatelliteConfiguration config)
Definition: entry_data.py:527
CALLBACK_TYPE async_subscribe_static_info_updated(self, Callable[[list[EntityInfo]], None] callback_)
Definition: entry_data.py:346
None _async_unsubscribe_register_static_info(self, list[Callable[[list[EntityInfo]], None]] callbacks, Callable[[list[EntityInfo]], None] callback_)
Definition: entry_data.py:195
CALLBACK_TYPE async_register_key_static_info_updated_callback(self, EntityInfo static_info, Callable[[EntityInfo], None] callback_)
Definition: entry_data.py:204
None async_assist_satellite_set_wake_word(self, str wake_word_id)
Definition: entry_data.py:542
bool add(self, _T matcher)
Definition: match.py:185
bool remove(self, _T matcher)
Definition: match.py:214
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
ESPHomeDashboardCoordinator|None async_get_dashboard(HomeAssistant hass)
Definition: dashboard.py:134
None async_load(HomeAssistant hass)
None async_delay_save(self, Callable[[], _T] data_func, float delay=0)
Definition: storage.py:444
None async_save(self, _T data)
Definition: storage.py:424