Home Assistant Unofficial Reference 2024.12.1
discovery.py
Go to the documentation of this file.
1 """Support for Tasmota device discovery."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Awaitable, Callable
6 import logging
7 from typing import TypedDict, cast
8 
9 from hatasmota import const as tasmota_const
10 from hatasmota.discovery import (
11  TasmotaDiscovery,
12  get_device_config as tasmota_get_device_config,
13  get_entities_for_platform as tasmota_get_entities_for_platform,
14  get_entity as tasmota_get_entity,
15  get_trigger as tasmota_get_trigger,
16  get_triggers as tasmota_get_triggers,
17  unique_id_from_hash,
18 )
19 from hatasmota.entity import TasmotaEntityConfig
20 from hatasmota.models import DiscoveryHashType, TasmotaDeviceConfig
21 from hatasmota.mqtt import TasmotaMQTTClient
22 from hatasmota.sensor import TasmotaBaseSensorConfig
23 from hatasmota.utils import get_topic_command, get_topic_stat
24 
25 from homeassistant.components import sensor
26 from homeassistant.config_entries import ConfigEntry
27 from homeassistant.core import HomeAssistant
28 from homeassistant.helpers import (
29  device_registry as dr,
30  entity_registry as er,
31  issue_registry as ir,
32 )
33 from homeassistant.helpers.dispatcher import async_dispatcher_send
34 from homeassistant.helpers.entity_registry import async_entries_for_device
35 
36 from .const import DOMAIN, PLATFORMS
37 
38 _LOGGER = logging.getLogger(__name__)
39 
40 ALREADY_DISCOVERED = "tasmota_discovered_components"
41 DISCOVERY_DATA = "tasmota_discovery_data"
42 TASMOTA_DISCOVERY_ENTITY_NEW = "tasmota_discovery_entity_new_{}"
43 TASMOTA_DISCOVERY_ENTITY_UPDATED = "tasmota_discovery_entity_updated_{}_{}_{}_{}"
44 TASMOTA_DISCOVERY_INSTANCE = "tasmota_discovery_instance"
45 
46 MQTT_TOPIC_URL = "https://tasmota.github.io/docs/Home-Assistant/#tasmota-integration"
47 
48 type SetupDeviceCallback = Callable[[TasmotaDeviceConfig, str], Awaitable[None]]
49 
50 
52  hass: HomeAssistant, discovery_hash: DiscoveryHashType
53 ) -> None:
54  """Clear entry in ALREADY_DISCOVERED list."""
55  if ALREADY_DISCOVERED not in hass.data:
56  # Discovery is shutting down
57  return
58  del hass.data[ALREADY_DISCOVERED][discovery_hash]
59 
60 
61 def set_discovery_hash(hass: HomeAssistant, discovery_hash: DiscoveryHashType) -> None:
62  """Set entry in ALREADY_DISCOVERED list."""
63  hass.data[ALREADY_DISCOVERED][discovery_hash] = {}
64 
65 
67  hass: HomeAssistant,
68  command_topic: str,
69  own_mac: str | None,
70  own_device_config: TasmotaDeviceConfig,
71 ) -> bool:
72  """Log and create repairs issue if several devices share the same topic."""
73  duplicated = False
74  offenders = []
75  for other_mac, other_config in hass.data[DISCOVERY_DATA].items():
76  if own_mac and other_mac == own_mac:
77  continue
78  if command_topic == get_topic_command(other_config):
79  offenders.append((other_mac, tasmota_get_device_config(other_config)))
80  issue_id = f"topic_duplicated_{command_topic}"
81  if offenders:
82  if own_mac:
83  offenders.append((own_mac, own_device_config))
84  offender_strings = [
85  f"'{cfg[tasmota_const.CONF_NAME]}' ({cfg[tasmota_const.CONF_IP]})"
86  for _, cfg in offenders
87  ]
88  _LOGGER.warning(
89  (
90  "Multiple Tasmota devices are sharing the same topic '%s'. Offending"
91  " devices: %s"
92  ),
93  command_topic,
94  ", ".join(offender_strings),
95  )
96  ir.async_create_issue(
97  hass,
98  DOMAIN,
99  issue_id,
100  data={
101  "key": "topic_duplicated",
102  "mac": " ".join([mac for mac, _ in offenders]),
103  "topic": command_topic,
104  },
105  is_fixable=False,
106  learn_more_url=MQTT_TOPIC_URL,
107  severity=ir.IssueSeverity.ERROR,
108  translation_key="topic_duplicated",
109  translation_placeholders={
110  "topic": command_topic,
111  "offenders": "\n\n* " + "\n\n* ".join(offender_strings),
112  },
113  )
114  duplicated = True
115  return duplicated
116 
117 
118 class DuplicatedTopicIssueData(TypedDict):
119  """Typed result dict."""
120 
121  key: str
122  mac: str
123  topic: str
124 
125 
126 async def async_start( # noqa: C901
127  hass: HomeAssistant,
128  discovery_topic: str,
129  config_entry: ConfigEntry,
130  tasmota_mqtt: TasmotaMQTTClient,
131  setup_device: SetupDeviceCallback,
132 ) -> None:
133  """Start Tasmota device discovery."""
134 
135  def _discover_entity(
136  tasmota_entity_config: TasmotaEntityConfig | None,
137  discovery_hash: DiscoveryHashType,
138  platform: str,
139  ) -> None:
140  """Handle adding or updating a discovered entity."""
141  if not tasmota_entity_config:
142  # Entity disabled, clean up entity registry
143  entity_registry = er.async_get(hass)
144  unique_id = unique_id_from_hash(discovery_hash)
145  entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id)
146  if entity_id:
147  _LOGGER.debug("Removing entity: %s %s", platform, discovery_hash)
148  entity_registry.async_remove(entity_id)
149  return
150 
151  if discovery_hash in hass.data[ALREADY_DISCOVERED]:
152  _LOGGER.debug(
153  "Entity already added, sending update: %s %s",
154  platform,
155  discovery_hash,
156  )
158  hass,
159  TASMOTA_DISCOVERY_ENTITY_UPDATED.format(*discovery_hash),
160  tasmota_entity_config,
161  )
162  else:
163  tasmota_entity = tasmota_get_entity(tasmota_entity_config, tasmota_mqtt)
164  if not tasmota_entity:
165  _LOGGER.error("Failed to create entity %s %s", platform, discovery_hash)
166  return
167 
168  _LOGGER.debug(
169  "Adding new entity: %s %s %s",
170  platform,
171  discovery_hash,
172  tasmota_entity.unique_id,
173  )
174 
175  hass.data[ALREADY_DISCOVERED][discovery_hash] = None
176 
178  hass,
179  TASMOTA_DISCOVERY_ENTITY_NEW.format(platform),
180  tasmota_entity,
181  discovery_hash,
182  )
183 
184  async def async_device_discovered(payload: dict, mac: str) -> None:
185  """Process the received message."""
186 
187  if ALREADY_DISCOVERED not in hass.data:
188  # Discovery is shutting down
189  return
190 
191  _LOGGER.debug("Received discovery data for tasmota device: %s", mac)
192  tasmota_device_config = tasmota_get_device_config(payload)
193  await setup_device(tasmota_device_config, mac)
194 
195  hass.data[DISCOVERY_DATA][mac] = payload
196 
197  add_entities = True
198 
199  command_topic = get_topic_command(payload) if payload else None
200  state_topic = get_topic_stat(payload) if payload else None
201 
202  # Create or clear issue if topic is missing prefix
203  issue_id = f"topic_no_prefix_{mac}"
204  if payload and command_topic == state_topic:
205  _LOGGER.warning(
206  "Tasmota device '%s' with IP %s doesn't set %%prefix%% in its topic",
207  tasmota_device_config[tasmota_const.CONF_NAME],
208  tasmota_device_config[tasmota_const.CONF_IP],
209  )
210  ir.async_create_issue(
211  hass,
212  DOMAIN,
213  issue_id,
214  data={"key": "topic_no_prefix"},
215  is_fixable=False,
216  learn_more_url=MQTT_TOPIC_URL,
217  severity=ir.IssueSeverity.ERROR,
218  translation_key="topic_no_prefix",
219  translation_placeholders={
220  "name": tasmota_device_config[tasmota_const.CONF_NAME],
221  "ip": tasmota_device_config[tasmota_const.CONF_IP],
222  },
223  )
224  add_entities = False
225  else:
226  ir.async_delete_issue(hass, DOMAIN, issue_id)
227 
228  # Clear previous issues caused by duplicated topic
229  issue_reg = ir.async_get(hass)
230  tasmota_issues = [
231  issue for key, issue in issue_reg.issues.items() if key[0] == DOMAIN
232  ]
233  for issue in tasmota_issues:
234  if issue.data and issue.data["key"] == "topic_duplicated":
235  issue_data: DuplicatedTopicIssueData = cast(
236  DuplicatedTopicIssueData, issue.data
237  )
238  macs = issue_data["mac"].split()
239  if mac not in macs:
240  continue
241  if payload and command_topic == issue_data["topic"]:
242  continue
243  if len(macs) > 2:
244  # This device is no longer duplicated, update the issue
245  warn_if_topic_duplicated(hass, issue_data["topic"], None, {})
246  continue
247  ir.async_delete_issue(hass, DOMAIN, issue.issue_id)
248 
249  if not payload:
250  return
251  assert isinstance(command_topic, str)
252 
253  # Warn and add issues if there are duplicated topics
254  if warn_if_topic_duplicated(hass, command_topic, mac, tasmota_device_config):
255  add_entities = False
256 
257  if not add_entities:
258  # Add the device entry so the user can identify the device, but do not add
259  # entities or triggers
260  return
261 
262  tasmota_triggers = tasmota_get_triggers(payload)
263  for trigger_config in tasmota_triggers:
264  discovery_hash: DiscoveryHashType = (
265  mac,
266  "automation",
267  "trigger",
268  trigger_config.trigger_id,
269  )
270  if discovery_hash in hass.data[ALREADY_DISCOVERED]:
271  _LOGGER.debug(
272  "Trigger already added, sending update: %s",
273  discovery_hash,
274  )
276  hass,
277  TASMOTA_DISCOVERY_ENTITY_UPDATED.format(*discovery_hash),
278  trigger_config,
279  )
280  elif trigger_config.is_active:
281  _LOGGER.debug("Adding new trigger: %s", discovery_hash)
282  hass.data[ALREADY_DISCOVERED][discovery_hash] = None
283 
284  tasmota_trigger = tasmota_get_trigger(trigger_config, tasmota_mqtt)
285 
287  hass,
288  TASMOTA_DISCOVERY_ENTITY_NEW.format("device_automation"),
289  tasmota_trigger,
290  discovery_hash,
291  )
292 
293  for platform in PLATFORMS:
294  tasmota_entities = tasmota_get_entities_for_platform(payload, platform)
295  for tasmota_entity_config, discovery_hash in tasmota_entities:
296  _discover_entity(tasmota_entity_config, discovery_hash, platform)
297 
298  async def async_sensors_discovered(
299  sensors: list[tuple[TasmotaBaseSensorConfig, DiscoveryHashType]], mac: str
300  ) -> None:
301  """Handle discovery of (additional) sensors."""
302  platform = sensor.DOMAIN
303 
304  device_registry = dr.async_get(hass)
305  entity_registry = er.async_get(hass)
306  device = device_registry.async_get_device(
307  connections={(dr.CONNECTION_NETWORK_MAC, mac)}
308  )
309 
310  if device is None:
311  _LOGGER.warning("Got sensors for unknown device mac: %s", mac)
312  return
313 
314  orphaned_entities = {
315  entry.unique_id
316  for entry in async_entries_for_device(
317  entity_registry, device.id, include_disabled_entities=True
318  )
319  if entry.domain == sensor.DOMAIN and entry.platform == DOMAIN
320  }
321  for tasmota_sensor_config, discovery_hash in sensors:
322  if tasmota_sensor_config:
323  orphaned_entities.discard(tasmota_sensor_config.unique_id)
324  _discover_entity(tasmota_sensor_config, discovery_hash, platform)
325  for unique_id in orphaned_entities:
326  entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id)
327  if entity_id:
328  _LOGGER.debug("Removing entity: %s %s", platform, entity_id)
329  entity_registry.async_remove(entity_id)
330 
331  hass.data[ALREADY_DISCOVERED] = {}
332  hass.data[DISCOVERY_DATA] = {}
333 
334  tasmota_discovery = TasmotaDiscovery(discovery_topic, tasmota_mqtt)
335  await tasmota_discovery.start_discovery(
336  async_device_discovered, async_sensors_discovered
337  )
338  hass.data[TASMOTA_DISCOVERY_INSTANCE] = tasmota_discovery
339 
340 
341 async def async_stop(hass: HomeAssistant) -> None:
342  """Stop Tasmota device discovery."""
343  hass.data.pop(ALREADY_DISCOVERED)
344  tasmota_discovery = hass.data.pop(TASMOTA_DISCOVERY_INSTANCE)
345  await tasmota_discovery.stop_discovery()
RoborockDataUpdateCoordinator|RoborockDataUpdateCoordinatorA01|None setup_device(HomeAssistant hass, UserData user_data, HomeDataDevice device, HomeDataProduct product_info, list[HomeDataRoom] home_data_rooms)
Definition: __init__.py:145
None async_start(HomeAssistant hass, str discovery_topic, ConfigEntry config_entry, TasmotaMQTTClient tasmota_mqtt, SetupDeviceCallback setup_device)
Definition: discovery.py:132
bool warn_if_topic_duplicated(HomeAssistant hass, str command_topic, str|None own_mac, TasmotaDeviceConfig own_device_config)
Definition: discovery.py:71
None clear_discovery_hash(HomeAssistant hass, DiscoveryHashType discovery_hash)
Definition: discovery.py:53
None async_stop(HomeAssistant hass)
Definition: discovery.py:341
None set_discovery_hash(HomeAssistant hass, DiscoveryHashType discovery_hash)
Definition: discovery.py:61
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
list[RegistryEntry] async_entries_for_device(EntityRegistry registry, str device_id, bool include_disabled_entities=False)