Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support KNX devices."""
2 
3 from __future__ import annotations
4 
5 import contextlib
6 import logging
7 from pathlib import Path
8 from typing import Final
9 
10 import voluptuous as vol
11 from xknx import XKNX
12 from xknx.core import XknxConnectionState
13 from xknx.core.telegram_queue import TelegramQueue
14 from xknx.dpt import DPTBase
15 from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException
16 from xknx.io import ConnectionConfig, ConnectionType, SecureConfig
17 from xknx.telegram import AddressFilter, Telegram
18 from xknx.telegram.address import DeviceGroupAddress, GroupAddress, InternalGroupAddress
19 from xknx.telegram.apci import GroupValueResponse, GroupValueWrite
20 
21 from homeassistant.config_entries import ConfigEntry
22 from homeassistant.const import (
23  CONF_EVENT,
24  CONF_HOST,
25  CONF_PORT,
26  CONF_TYPE,
27  EVENT_HOMEASSISTANT_STOP,
28  Platform,
29 )
30 from homeassistant.core import Event, HomeAssistant
31 from homeassistant.exceptions import ConfigEntryNotReady
32 from homeassistant.helpers.device_registry import DeviceEntry
33 from homeassistant.helpers.reload import async_integration_yaml_config
34 from homeassistant.helpers.storage import STORAGE_DIR
35 from homeassistant.helpers.typing import ConfigType
36 
37 from .const import (
38  CONF_KNX_CONNECTION_TYPE,
39  CONF_KNX_EXPOSE,
40  CONF_KNX_INDIVIDUAL_ADDRESS,
41  CONF_KNX_KNXKEY_FILENAME,
42  CONF_KNX_KNXKEY_PASSWORD,
43  CONF_KNX_LOCAL_IP,
44  CONF_KNX_MCAST_GRP,
45  CONF_KNX_MCAST_PORT,
46  CONF_KNX_RATE_LIMIT,
47  CONF_KNX_ROUTE_BACK,
48  CONF_KNX_ROUTING,
49  CONF_KNX_ROUTING_BACKBONE_KEY,
50  CONF_KNX_ROUTING_SECURE,
51  CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE,
52  CONF_KNX_SECURE_DEVICE_AUTHENTICATION,
53  CONF_KNX_SECURE_USER_ID,
54  CONF_KNX_SECURE_USER_PASSWORD,
55  CONF_KNX_STATE_UPDATER,
56  CONF_KNX_TELEGRAM_LOG_SIZE,
57  CONF_KNX_TUNNEL_ENDPOINT_IA,
58  CONF_KNX_TUNNELING,
59  CONF_KNX_TUNNELING_TCP,
60  CONF_KNX_TUNNELING_TCP_SECURE,
61  DATA_HASS_CONFIG,
62  DOMAIN,
63  KNX_ADDRESS,
64  KNX_MODULE_KEY,
65  SUPPORTED_PLATFORMS_UI,
66  SUPPORTED_PLATFORMS_YAML,
67  TELEGRAM_LOG_DEFAULT,
68 )
69 from .device import KNXInterfaceDevice
70 from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure
71 from .project import STORAGE_KEY as PROJECT_STORAGE_KEY, KNXProject
72 from .schema import (
73  BinarySensorSchema,
74  ButtonSchema,
75  ClimateSchema,
76  CoverSchema,
77  DateSchema,
78  DateTimeSchema,
79  EventSchema,
80  ExposeSchema,
81  FanSchema,
82  LightSchema,
83  NotifySchema,
84  NumberSchema,
85  SceneSchema,
86  SelectSchema,
87  SensorSchema,
88  SwitchSchema,
89  TextSchema,
90  TimeSchema,
91  WeatherSchema,
92 )
93 from .services import register_knx_services
94 from .storage.config_store import KNXConfigStore
95 from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams
96 from .websocket import register_panel
97 
98 _LOGGER = logging.getLogger(__name__)
99 
100 _KNX_YAML_CONFIG: Final = "knx_yaml_config"
101 
102 CONFIG_SCHEMA = vol.Schema(
103  {
104  DOMAIN: vol.All(
105  vol.Schema(
106  {
107  **EventSchema.SCHEMA,
108  **ExposeSchema.platform_node(),
109  **BinarySensorSchema.platform_node(),
110  **ButtonSchema.platform_node(),
111  **ClimateSchema.platform_node(),
112  **CoverSchema.platform_node(),
113  **DateSchema.platform_node(),
114  **DateTimeSchema.platform_node(),
115  **FanSchema.platform_node(),
116  **LightSchema.platform_node(),
117  **NotifySchema.platform_node(),
118  **NumberSchema.platform_node(),
119  **SceneSchema.platform_node(),
120  **SelectSchema.platform_node(),
121  **SensorSchema.platform_node(),
122  **SwitchSchema.platform_node(),
123  **TextSchema.platform_node(),
124  **TimeSchema.platform_node(),
125  **WeatherSchema.platform_node(),
126  }
127  ),
128  )
129  },
130  extra=vol.ALLOW_EXTRA,
131 )
132 
133 
134 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
135  """Start the KNX integration."""
136  hass.data[DATA_HASS_CONFIG] = config
137  if (conf := config.get(DOMAIN)) is not None:
138  hass.data[_KNX_YAML_CONFIG] = dict(conf)
139 
141  return True
142 
143 
144 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
145  """Load a config entry."""
146  # `_KNX_YAML_CONFIG` is only set in async_setup.
147  # It's None when reloading the integration or no `knx` key in configuration.yaml
148  config = hass.data.pop(_KNX_YAML_CONFIG, None)
149  if config is None:
150  _conf = await async_integration_yaml_config(hass, DOMAIN)
151  if not _conf or DOMAIN not in _conf:
152  # generate defaults
153  config = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN]
154  else:
155  config = _conf[DOMAIN]
156  try:
157  knx_module = KNXModule(hass, config, entry)
158  await knx_module.start()
159  except XKNXException as ex:
160  raise ConfigEntryNotReady from ex
161 
162  hass.data[KNX_MODULE_KEY] = knx_module
163 
164  if CONF_KNX_EXPOSE in config:
165  for expose_config in config[CONF_KNX_EXPOSE]:
166  knx_module.exposures.append(
167  create_knx_exposure(hass, knx_module.xknx, expose_config)
168  )
169  configured_platforms_yaml = {
170  platform for platform in SUPPORTED_PLATFORMS_YAML if platform in config
171  }
172  await hass.config_entries.async_forward_entry_setups(
173  entry,
174  {
175  Platform.SENSOR, # always forward sensor for system entities (telegram counter, etc.)
176  *SUPPORTED_PLATFORMS_UI, # forward all platforms that support UI entity management
177  *configured_platforms_yaml, # forward yaml-only managed platforms on demand,
178  },
179  )
180 
181  await register_panel(hass)
182 
183  return True
184 
185 
186 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
187  """Unloading the KNX platforms."""
188  knx_module = hass.data.get(KNX_MODULE_KEY)
189  if not knx_module:
190  # if not loaded directly return
191  return True
192 
193  for exposure in knx_module.exposures:
194  exposure.async_remove()
195 
196  configured_platforms_yaml = {
197  platform
198  for platform in SUPPORTED_PLATFORMS_YAML
199  if platform in knx_module.config_yaml
200  }
201  unload_ok = await hass.config_entries.async_unload_platforms(
202  entry,
203  {
204  Platform.SENSOR, # always unload system entities (telegram counter, etc.)
205  *SUPPORTED_PLATFORMS_UI, # unload all platforms that support UI entity management
206  *configured_platforms_yaml, # unload yaml-only managed platforms if configured,
207  },
208  )
209  if unload_ok:
210  await knx_module.stop()
211  hass.data.pop(DOMAIN)
212 
213  return unload_ok
214 
215 
216 async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
217  """Update a given config entry."""
218  await hass.config_entries.async_reload(entry.entry_id)
219 
220 
221 async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
222  """Remove a config entry."""
223 
224  def remove_files(storage_dir: Path, knxkeys_filename: str | None) -> None:
225  """Remove KNX files."""
226  if knxkeys_filename is not None:
227  with contextlib.suppress(FileNotFoundError):
228  (storage_dir / knxkeys_filename).unlink()
229  with contextlib.suppress(FileNotFoundError):
230  (storage_dir / PROJECT_STORAGE_KEY).unlink()
231  with contextlib.suppress(FileNotFoundError):
232  (storage_dir / TELEGRAMS_STORAGE_KEY).unlink()
233  with contextlib.suppress(FileNotFoundError, OSError):
234  (storage_dir / DOMAIN).rmdir()
235 
236  storage_dir = Path(hass.config.path(STORAGE_DIR))
237  knxkeys_filename = entry.data.get(CONF_KNX_KNXKEY_FILENAME)
238  await hass.async_add_executor_job(remove_files, storage_dir, knxkeys_filename)
239 
240 
242  hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
243 ) -> bool:
244  """Remove a config entry from a device."""
245  knx_module = hass.data[KNX_MODULE_KEY]
246  if not device_entry.identifiers.isdisjoint(
247  knx_module.interface_device.device_info["identifiers"]
248  ):
249  # can not remove interface device
250  return False
251  for entity in knx_module.config_store.get_entity_entries():
252  if entity.device_id == device_entry.id:
253  await knx_module.config_store.delete_entity(entity.entity_id)
254  return True
255 
256 
257 class KNXModule:
258  """Representation of KNX Object."""
259 
260  def __init__(
261  self, hass: HomeAssistant, config: ConfigType, entry: ConfigEntry
262  ) -> None:
263  """Initialize KNX module."""
264  self.hasshass = hass
265  self.config_yamlconfig_yaml = config
266  self.connectedconnected = False
267  self.exposures: list[KNXExposeSensor | KNXExposeTime] = []
268  self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {}
269  self.entryentry = entry
270 
271  self.projectproject = KNXProject(hass=hass, entry=entry)
272  self.config_storeconfig_store = KNXConfigStore(hass=hass, config_entry=entry)
273 
274  self.xknxxknx = XKNX(
275  address_format=self.projectproject.get_address_format(),
276  connection_config=self.connection_configconnection_config(),
277  rate_limit=self.entryentry.data[CONF_KNX_RATE_LIMIT],
278  state_updater=self.entryentry.data[CONF_KNX_STATE_UPDATER],
279  )
280  self.xknxxknx.connection_manager.register_connection_state_changed_cb(
281  self.connection_state_changed_cbconnection_state_changed_cb
282  )
283  self.telegramstelegrams = Telegrams(
284  hass=hass,
285  xknx=self.xknxxknx,
286  project=self.projectproject,
287  log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT),
288  )
289  self.interface_deviceinterface_device = KNXInterfaceDevice(
290  hass=hass, entry=entry, xknx=self.xknxxknx
291  )
292 
293  self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {}
294  self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {}
295  self.knx_event_callback: TelegramQueue.Callback = self.register_event_callbackregister_event_callback()
296 
297  self.entryentry.async_on_unload(
298  self.hasshass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stopstop)
299  )
300  self.entryentry.async_on_unload(self.entryentry.add_update_listener(async_update_entry))
301 
302  async def start(self) -> None:
303  """Start XKNX object. Connect to tunneling or Routing device."""
304  await self.projectproject.load_project(self.xknxxknx)
305  await self.config_storeconfig_store.load_data()
306  await self.telegramstelegrams.load_history()
307  await self.xknxxknx.start()
308 
309  async def stop(self, event: Event | None = None) -> None:
310  """Stop XKNX object. Disconnect from tunneling or Routing device."""
311  await self.xknxxknx.stop()
312  await self.telegramstelegrams.save_history()
313 
314  def connection_config(self) -> ConnectionConfig:
315  """Return the connection_config."""
316  _conn_type: str = self.entryentry.data[CONF_KNX_CONNECTION_TYPE]
317  _knxkeys_file: str | None = (
318  self.hasshass.config.path(
319  STORAGE_DIR,
320  self.entryentry.data[CONF_KNX_KNXKEY_FILENAME],
321  )
322  if self.entryentry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None
323  else None
324  )
325  if _conn_type == CONF_KNX_ROUTING:
326  return ConnectionConfig(
327  connection_type=ConnectionType.ROUTING,
328  individual_address=self.entryentry.data[CONF_KNX_INDIVIDUAL_ADDRESS],
329  multicast_group=self.entryentry.data[CONF_KNX_MCAST_GRP],
330  multicast_port=self.entryentry.data[CONF_KNX_MCAST_PORT],
331  local_ip=self.entryentry.data.get(CONF_KNX_LOCAL_IP),
332  auto_reconnect=True,
333  secure_config=SecureConfig(
334  knxkeys_password=self.entryentry.data.get(CONF_KNX_KNXKEY_PASSWORD),
335  knxkeys_file_path=_knxkeys_file,
336  ),
337  threaded=True,
338  )
339  if _conn_type == CONF_KNX_TUNNELING:
340  return ConnectionConfig(
341  connection_type=ConnectionType.TUNNELING,
342  gateway_ip=self.entryentry.data[CONF_HOST],
343  gateway_port=self.entryentry.data[CONF_PORT],
344  local_ip=self.entryentry.data.get(CONF_KNX_LOCAL_IP),
345  route_back=self.entryentry.data.get(CONF_KNX_ROUTE_BACK, False),
346  auto_reconnect=True,
347  secure_config=SecureConfig(
348  knxkeys_password=self.entryentry.data.get(CONF_KNX_KNXKEY_PASSWORD),
349  knxkeys_file_path=_knxkeys_file,
350  ),
351  threaded=True,
352  )
353  if _conn_type == CONF_KNX_TUNNELING_TCP:
354  return ConnectionConfig(
355  connection_type=ConnectionType.TUNNELING_TCP,
356  individual_address=self.entryentry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA),
357  gateway_ip=self.entryentry.data[CONF_HOST],
358  gateway_port=self.entryentry.data[CONF_PORT],
359  auto_reconnect=True,
360  secure_config=SecureConfig(
361  knxkeys_password=self.entryentry.data.get(CONF_KNX_KNXKEY_PASSWORD),
362  knxkeys_file_path=_knxkeys_file,
363  ),
364  threaded=True,
365  )
366  if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE:
367  return ConnectionConfig(
368  connection_type=ConnectionType.TUNNELING_TCP_SECURE,
369  individual_address=self.entryentry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA),
370  gateway_ip=self.entryentry.data[CONF_HOST],
371  gateway_port=self.entryentry.data[CONF_PORT],
372  secure_config=SecureConfig(
373  user_id=self.entryentry.data.get(CONF_KNX_SECURE_USER_ID),
374  user_password=self.entryentry.data.get(CONF_KNX_SECURE_USER_PASSWORD),
375  device_authentication_password=self.entryentry.data.get(
376  CONF_KNX_SECURE_DEVICE_AUTHENTICATION
377  ),
378  knxkeys_password=self.entryentry.data.get(CONF_KNX_KNXKEY_PASSWORD),
379  knxkeys_file_path=_knxkeys_file,
380  ),
381  auto_reconnect=True,
382  threaded=True,
383  )
384  if _conn_type == CONF_KNX_ROUTING_SECURE:
385  return ConnectionConfig(
386  connection_type=ConnectionType.ROUTING_SECURE,
387  individual_address=self.entryentry.data[CONF_KNX_INDIVIDUAL_ADDRESS],
388  multicast_group=self.entryentry.data[CONF_KNX_MCAST_GRP],
389  multicast_port=self.entryentry.data[CONF_KNX_MCAST_PORT],
390  local_ip=self.entryentry.data.get(CONF_KNX_LOCAL_IP),
391  secure_config=SecureConfig(
392  backbone_key=self.entryentry.data.get(CONF_KNX_ROUTING_BACKBONE_KEY),
393  latency_ms=self.entryentry.data.get(
394  CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE
395  ),
396  knxkeys_password=self.entryentry.data.get(CONF_KNX_KNXKEY_PASSWORD),
397  knxkeys_file_path=_knxkeys_file,
398  ),
399  auto_reconnect=True,
400  threaded=True,
401  )
402  return ConnectionConfig(
403  auto_reconnect=True,
404  secure_config=SecureConfig(
405  knxkeys_password=self.entryentry.data.get(CONF_KNX_KNXKEY_PASSWORD),
406  knxkeys_file_path=_knxkeys_file,
407  ),
408  threaded=True,
409  )
410 
411  def connection_state_changed_cb(self, state: XknxConnectionState) -> None:
412  """Call invoked after a KNX connection state change was received."""
413  self.connectedconnected = state == XknxConnectionState.CONNECTED
414  for device in self.xknxxknx.devices:
415  device.after_update()
416 
417  def telegram_received_cb(self, telegram: Telegram) -> None:
418  """Call invoked after a KNX telegram was received."""
419  # Not all telegrams have serializable data.
420  data: int | tuple[int, ...] | None = None
421  value = None
422  if (
423  isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse))
424  and telegram.payload.value is not None
425  and isinstance(
426  telegram.destination_address, (GroupAddress, InternalGroupAddress)
427  )
428  ):
429  data = telegram.payload.value.value
430  if transcoder := (
431  self.group_address_transcoder.get(telegram.destination_address)
432  or next(
433  (
434  _transcoder
435  for _filter, _transcoder in self._address_filter_transcoder.items()
436  if _filter.match(telegram.destination_address)
437  ),
438  None,
439  )
440  ):
441  try:
442  value = transcoder.from_knx(telegram.payload.value)
443  except (ConversionError, CouldNotParseTelegram) as err:
444  _LOGGER.warning(
445  (
446  "Error in `knx_event` at decoding type '%s' from"
447  " telegram %s\n%s"
448  ),
449  transcoder.__name__,
450  telegram,
451  err,
452  )
453 
454  self.hasshass.bus.async_fire(
455  "knx_event",
456  {
457  "data": data,
458  "destination": str(telegram.destination_address),
459  "direction": telegram.direction.value,
460  "value": value,
461  "source": str(telegram.source_address),
462  "telegramtype": telegram.payload.__class__.__name__,
463  },
464  )
465 
466  def register_event_callback(self) -> TelegramQueue.Callback:
467  """Register callback for knx_event within XKNX TelegramQueue."""
468  address_filters = []
469  for filter_set in self.config_yamlconfig_yaml[CONF_EVENT]:
470  _filters = list(map(AddressFilter, filter_set[KNX_ADDRESS]))
471  address_filters.extend(_filters)
472  if (dpt := filter_set.get(CONF_TYPE)) and (
473  transcoder := DPTBase.parse_transcoder(dpt)
474  ):
475  self._address_filter_transcoder.update(
476  {_filter: transcoder for _filter in _filters}
477  )
478 
479  return self.xknxxknx.telegram_queue.register_telegram_received_cb(
480  self.telegram_received_cbtelegram_received_cb,
481  address_filters=address_filters,
482  group_addresses=[],
483  match_for_outgoing=True,
484  )
None stop(self, Event|None event=None)
Definition: __init__.py:309
TelegramQueue.Callback register_event_callback(self)
Definition: __init__.py:466
None connection_state_changed_cb(self, XknxConnectionState state)
Definition: __init__.py:411
None __init__(self, HomeAssistant hass, ConfigType config, ConfigEntry entry)
Definition: __init__.py:262
None telegram_received_cb(self, Telegram telegram)
Definition: __init__.py:417
ConnectionConfig connection_config(self)
Definition: __init__.py:314
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
KNXExposeSensor|KNXExposeTime create_knx_exposure(HomeAssistant hass, XKNX xknx, ConfigType config)
Definition: expose.py:43
None register_knx_services(HomeAssistant hass)
Definition: services.py:45
None register_panel(HomeAssistant hass)
Definition: websocket.py:44
None async_update_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:216
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:144
None async_remove_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:221
bool async_remove_config_entry_device(HomeAssistant hass, ConfigEntry config_entry, DeviceEntry device_entry)
Definition: __init__.py:243
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:134
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:186
def load_data(hass, url=None, filepath=None, username=None, password=None, authentication=None, num_retries=5, verify_ssl=None)
Definition: __init__.py:305
ConfigType|None async_integration_yaml_config(HomeAssistant hass, str integration_name)
Definition: reload.py:142