Home Assistant Unofficial Reference 2024.12.1
siren.py
Go to the documentation of this file.
1 """Support for MQTT sirens."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 import logging
7 from typing import Any, cast
8 
9 import voluptuous as vol
10 
11 from homeassistant.components import siren
13  ATTR_AVAILABLE_TONES,
14  ATTR_DURATION,
15  ATTR_TONE,
16  ATTR_VOLUME_LEVEL,
17  TURN_ON_SCHEMA,
18  SirenEntity,
19  SirenEntityFeature,
20  SirenTurnOnServiceParameters,
21  process_turn_on_params,
22 )
23 from homeassistant.config_entries import ConfigEntry
24 from homeassistant.const import (
25  CONF_NAME,
26  CONF_OPTIMISTIC,
27  CONF_PAYLOAD_OFF,
28  CONF_PAYLOAD_ON,
29 )
30 from homeassistant.core import HomeAssistant, callback
32 from homeassistant.helpers.entity_platform import AddEntitiesCallback
33 from homeassistant.helpers.json import json_dumps
34 from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
35 from homeassistant.helpers.template import Template
36 from homeassistant.helpers.typing import ConfigType, TemplateVarsType, VolSchemaType
37 from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object
38 
39 from . import subscription
40 from .config import MQTT_RW_SCHEMA
41 from .const import (
42  CONF_COMMAND_TEMPLATE,
43  CONF_COMMAND_TOPIC,
44  CONF_STATE_TOPIC,
45  CONF_STATE_VALUE_TEMPLATE,
46  PAYLOAD_EMPTY_JSON,
47  PAYLOAD_NONE,
48 )
49 from .entity import MqttEntity, async_setup_entity_entry_helper
50 from .models import (
51  MqttCommandTemplate,
52  MqttValueTemplate,
53  PublishPayloadType,
54  ReceiveMessage,
55 )
56 from .schemas import MQTT_ENTITY_COMMON_SCHEMA
57 
58 PARALLEL_UPDATES = 0
59 
60 DEFAULT_NAME = "MQTT Siren"
61 DEFAULT_PAYLOAD_ON = "ON"
62 DEFAULT_PAYLOAD_OFF = "OFF"
63 
64 ENTITY_ID_FORMAT = siren.DOMAIN + ".{}"
65 
66 CONF_AVAILABLE_TONES = "available_tones"
67 CONF_COMMAND_OFF_TEMPLATE = "command_off_template"
68 CONF_STATE_ON = "state_on"
69 CONF_STATE_OFF = "state_off"
70 CONF_SUPPORT_DURATION = "support_duration"
71 CONF_SUPPORT_VOLUME_SET = "support_volume_set"
72 
73 STATE = "state"
74 
75 PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend(
76  {
77  vol.Optional(CONF_AVAILABLE_TONES): cv.ensure_list,
78  vol.Optional(CONF_COMMAND_TEMPLATE): cv.template,
79  vol.Optional(CONF_COMMAND_OFF_TEMPLATE): cv.template,
80  vol.Optional(CONF_NAME): vol.Any(cv.string, None),
81  vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string,
82  vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string,
83  vol.Optional(CONF_STATE_OFF): cv.string,
84  vol.Optional(CONF_STATE_ON): cv.string,
85  vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template,
86  vol.Optional(CONF_SUPPORT_DURATION, default=True): cv.boolean,
87  vol.Optional(CONF_SUPPORT_VOLUME_SET, default=True): cv.boolean,
88  },
89 ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
90 
91 DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA))
92 
93 MQTT_SIREN_ATTRIBUTES_BLOCKED = frozenset(
94  {
95  ATTR_AVAILABLE_TONES,
96  ATTR_DURATION,
97  ATTR_TONE,
98  ATTR_VOLUME_LEVEL,
99  }
100 )
101 
102 SUPPORTED_BASE = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON
103 
104 SUPPORTED_ATTRIBUTES = {
105  ATTR_DURATION: SirenEntityFeature.DURATION,
106  ATTR_TONE: SirenEntityFeature.TONES,
107  ATTR_VOLUME_LEVEL: SirenEntityFeature.VOLUME_SET,
108 }
109 
110 _LOGGER = logging.getLogger(__name__)
111 
112 
114  hass: HomeAssistant,
115  config_entry: ConfigEntry,
116  async_add_entities: AddEntitiesCallback,
117 ) -> None:
118  """Set up MQTT siren through YAML and through MQTT discovery."""
120  hass,
121  config_entry,
122  MqttSiren,
123  siren.DOMAIN,
124  async_add_entities,
125  DISCOVERY_SCHEMA,
126  PLATFORM_SCHEMA_MODERN,
127  )
128 
129 
131  """Representation of a siren that can be controlled using MQTT."""
132 
133  _default_name = DEFAULT_NAME
134  _entity_id_format = ENTITY_ID_FORMAT
135  _attributes_extra_blocked = MQTT_SIREN_ATTRIBUTES_BLOCKED
136  _extra_attributes: dict[str, Any]
137 
138  _command_templates: dict[
139  str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType] | None
140  ]
141  _value_template: Callable[[ReceivePayloadType], ReceivePayloadType]
142  _state_on: str
143  _state_off: str
144  _optimistic: bool
145 
146  @staticmethod
147  def config_schema() -> VolSchemaType:
148  """Return the config schema."""
149  return DISCOVERY_SCHEMA
150 
151  def _setup_from_config(self, config: ConfigType) -> None:
152  """(Re)Setup the entity."""
153 
154  state_on: str | None = config.get(CONF_STATE_ON)
155  self._state_on_state_on = state_on if state_on else config[CONF_PAYLOAD_ON]
156 
157  state_off: str | None = config.get(CONF_STATE_OFF)
158  self._state_off_state_off = state_off if state_off else config[CONF_PAYLOAD_OFF]
159 
160  self._extra_attributes_extra_attributes = {}
161 
162  _supported_features = SUPPORTED_BASE
163  if config[CONF_SUPPORT_DURATION]:
164  _supported_features |= SirenEntityFeature.DURATION
165  self._extra_attributes_extra_attributes[ATTR_DURATION] = None
166 
167  if config.get(CONF_AVAILABLE_TONES):
168  _supported_features |= SirenEntityFeature.TONES
169  self._attr_available_tones_attr_available_tones = config[CONF_AVAILABLE_TONES]
170  self._extra_attributes_extra_attributes[ATTR_TONE] = None
171 
172  if config[CONF_SUPPORT_VOLUME_SET]:
173  _supported_features |= SirenEntityFeature.VOLUME_SET
174  self._extra_attributes_extra_attributes[ATTR_VOLUME_LEVEL] = None
175 
176  self._attr_supported_features_attr_supported_features = _supported_features
177  self._optimistic_optimistic = config[CONF_OPTIMISTIC] or CONF_STATE_TOPIC not in config
178  self._attr_assumed_state_attr_assumed_state = bool(self._optimistic_optimistic)
179  self._attr_is_on_attr_is_on = False if self._optimistic_optimistic else None
180 
181  command_template: Template | None = config.get(CONF_COMMAND_TEMPLATE)
182  command_off_template: Template | None = (
183  config.get(CONF_COMMAND_OFF_TEMPLATE) or command_template
184  )
185  self._command_templates_command_templates = {
186  CONF_COMMAND_TEMPLATE: MqttCommandTemplate(
187  command_template, entity=self
188  ).async_render
189  if command_template
190  else None,
191  CONF_COMMAND_OFF_TEMPLATE: MqttCommandTemplate(
192  command_off_template, entity=self
193  ).async_render
194  if command_off_template
195  else None,
196  }
197  self._value_template_value_template = MqttValueTemplate(
198  config.get(CONF_STATE_VALUE_TEMPLATE),
199  entity=self,
200  ).async_render_with_possible_json_value
201 
202  @callback
203  def _state_message_received(self, msg: ReceiveMessage) -> None:
204  """Handle new MQTT state messages."""
205  payload = self._value_template_value_template(msg.payload)
206  if not payload or payload == PAYLOAD_EMPTY_JSON:
207  _LOGGER.debug(
208  "Ignoring empty payload '%s' after rendering for topic %s",
209  payload,
210  msg.topic,
211  )
212  return
213  json_payload: dict[str, Any] = {}
214  if payload in [self._state_on_state_on, self._state_off_state_off, PAYLOAD_NONE]:
215  json_payload = {STATE: payload}
216  else:
217  try:
218  json_payload = json_loads_object(payload)
219  _LOGGER.debug(
220  (
221  "JSON payload detected after processing payload '%s' on"
222  " topic %s"
223  ),
224  json_payload,
225  msg.topic,
226  )
227  except JSON_DECODE_EXCEPTIONS:
228  _LOGGER.warning(
229  (
230  "No valid (JSON) payload detected after processing payload"
231  " '%s' on topic %s"
232  ),
233  json_payload,
234  msg.topic,
235  )
236  return
237  if STATE in json_payload:
238  if json_payload[STATE] == self._state_on_state_on:
239  self._attr_is_on_attr_is_on = True
240  if json_payload[STATE] == self._state_off_state_off:
241  self._attr_is_on_attr_is_on = False
242  if json_payload[STATE] == PAYLOAD_NONE:
243  self._attr_is_on_attr_is_on = None
244  del json_payload[STATE]
245 
246  if json_payload:
247  # process attributes
248  try:
249  params: SirenTurnOnServiceParameters
250  params = vol.All(TURN_ON_SCHEMA)(json_payload)
251  except vol.MultipleInvalid as invalid_siren_parameters:
252  _LOGGER.warning(
253  "Unable to update siren state attributes from payload '%s': %s",
254  json_payload,
255  invalid_siren_parameters,
256  )
257  return
258  # To be able to track changes to self._extra_attributes we assign
259  # a fresh copy to make the original tracked reference immutable.
260  self._extra_attributes_extra_attributes = dict(self._extra_attributes_extra_attributes)
261  self._update_update(process_turn_on_params(self, params))
262 
263  @callback
264  def _prepare_subscribe_topics(self) -> None:
265  """(Re)Subscribe to topics."""
266  if not self.add_subscriptionadd_subscription(
267  CONF_STATE_TOPIC,
268  self._state_message_received_state_message_received,
269  {"_attr_is_on", "_extra_attributes"},
270  ):
271  # Force into optimistic mode.
272  self._optimistic_optimistic = True
273  return
274 
275  async def _subscribe_topics(self) -> None:
276  """(Re)Subscribe to topics."""
277  subscription.async_subscribe_topics_internal(self.hasshasshass, self._sub_state_sub_state)
278 
279  @property
280  def extra_state_attributes(self) -> dict[str, Any] | None:
281  """Return the state attributes."""
282  extra_attributes = (
283  self._attr_extra_state_attributes_attr_extra_state_attributes
284  if hasattr(self, "_attr_extra_state_attributes")
285  else {}
286  )
287  if extra_attributes:
288  return dict({*self._extra_attributes_extra_attributes.items(), *extra_attributes.items()})
289  return self._extra_attributes_extra_attributes or None
290 
291  async def _async_publish(
292  self,
293  topic: str,
294  template: str,
295  value: Any,
296  variables: dict[str, Any] | None = None,
297  ) -> None:
298  """Publish MQTT payload with optional command template."""
299  template_variables: dict[str, Any] = {STATE: value}
300  if variables is not None:
301  template_variables.update(variables)
302  if command_template := self._command_templates_command_templates[template]:
303  payload = command_template(value, template_variables)
304  else:
305  payload = json_dumps(template_variables)
306  if payload and str(payload) != PAYLOAD_NONE:
307  await self.async_publish_with_configasync_publish_with_config(self._config_config[topic], payload)
308 
309  async def async_turn_on(self, **kwargs: Any) -> None:
310  """Turn the siren on.
311 
312  This method is a coroutine.
313  """
314  await self._async_publish_async_publish(
315  CONF_COMMAND_TOPIC,
316  CONF_COMMAND_TEMPLATE,
317  self._config_config[CONF_PAYLOAD_ON],
318  kwargs,
319  )
320  if self._optimistic_optimistic:
321  # Optimistically assume that siren has changed state.
322  _LOGGER.debug("Writing state attributes %s", kwargs)
323  self._attr_is_on_attr_is_on = True
324  self._update_update(cast(SirenTurnOnServiceParameters, kwargs))
325  self.async_write_ha_stateasync_write_ha_state()
326 
327  async def async_turn_off(self, **kwargs: Any) -> None:
328  """Turn the siren off.
329 
330  This method is a coroutine.
331  """
332  await self._async_publish_async_publish(
333  CONF_COMMAND_TOPIC,
334  CONF_COMMAND_OFF_TEMPLATE,
335  self._config_config[CONF_PAYLOAD_OFF],
336  )
337 
338  if self._optimistic_optimistic:
339  # Optimistically assume that siren has changed state.
340  self._attr_is_on_attr_is_on = False
341  self.async_write_ha_stateasync_write_ha_state()
342 
343  def _update(self, data: SirenTurnOnServiceParameters) -> None:
344  """Update the extra siren state attributes."""
345  self._extra_attributes_extra_attributes.update(
346  {
347  attribute: data_attr
348  for attribute, support in SUPPORTED_ATTRIBUTES.items()
349  if self._attr_supported_features_attr_supported_features & support
350  and attribute in data
351  and (data_attr := data[attribute]) # type: ignore[literal-required]
352  != self._extra_attributes_extra_attributes.get(attribute)
353  }
354  )
None async_publish_with_config(self, str topic, PublishPayloadType payload)
Definition: entity.py:1377
bool add_subscription(self, str state_topic_config_key, Callable[[ReceiveMessage], None] msg_callback, set[str]|None tracked_attributes, bool disable_encoding=False)
Definition: entity.py:1484
dict[str, Any]|None extra_state_attributes(self)
Definition: siren.py:280
None _setup_from_config(self, ConfigType config)
Definition: siren.py:151
None _update(self, SirenTurnOnServiceParameters data)
Definition: siren.py:343
None async_turn_off(self, **Any kwargs)
Definition: siren.py:327
None _state_message_received(self, ReceiveMessage msg)
Definition: siren.py:203
None _async_publish(self, str topic, str template, Any value, dict[str, Any]|None variables=None)
Definition: siren.py:297
None async_turn_on(self, **Any kwargs)
Definition: siren.py:309
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
None async_setup_entity_entry_helper(HomeAssistant hass, ConfigEntry entry, type[MqttEntity]|None entity_class, str domain, AddEntitiesCallback async_add_entities, VolSchemaType discovery_schema, VolSchemaType platform_schema_modern, dict[str, type[MqttEntity]]|None schema_class_mapping=None)
Definition: entity.py:245
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: siren.py:117
SirenTurnOnServiceParameters process_turn_on_params(SirenEntity siren, SirenTurnOnServiceParameters params)
Definition: __init__.py:68
str json_dumps(Any data)
Definition: json.py:149
JsonObjectType json_loads_object(bytes|bytearray|memoryview|str obj)
Definition: json.py:54