diff --git a/changelog.md b/changelog.md
index 65c96329c..42415d90e 100644
--- a/changelog.md
+++ b/changelog.md
@@ -2,9 +2,14 @@
## Unreleased changes
+### HA integration
+
+- added `knx.exposure_register` service allowing to add and remove ExposeSensor at runtime
+
### Internals
- remove DPTComparator: DPTBinary and DPTArray are not equal, even if their .value is, and are never equal to `None`.
+- add Device.shutdown() method (used eg. when removing ExposeSensor)
## 0.16.0 APCI possibilities considerably increased 2021-01-01
diff --git a/home-assistant-plugin/custom_components/xknx/__init__.py b/home-assistant-plugin/custom_components/xknx/__init__.py
index 308dcc8c1..fab7ec68c 100644
--- a/home-assistant-plugin/custom_components/xknx/__init__.py
+++ b/home-assistant-plugin/custom_components/xknx/__init__.py
@@ -5,7 +5,6 @@
import voluptuous as vol
from xknx import XKNX
from xknx.core.telegram_queue import TelegramQueue
-from xknx.devices import DateTime, ExposeSensor
from xknx.dpt import DPTArray, DPTBase, DPTBinary
from xknx.exceptions import XKNXException
from xknx.io import (
@@ -18,26 +17,20 @@
from xknx.telegram.apci import GroupValueResponse, GroupValueWrite
from homeassistant.const import (
- CONF_ENTITY_ID,
CONF_HOST,
CONF_PORT,
EVENT_HOMEASSISTANT_STOP,
SERVICE_RELOAD,
- STATE_OFF,
- STATE_ON,
- STATE_UNAVAILABLE,
- STATE_UNKNOWN,
)
-from homeassistant.core import callback
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import async_get_platforms
-from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ServiceCallType
from .const import DOMAIN, SupportedPlatforms
+from .expose import create_knx_exposure
from .factory import create_knx_device
from .schema import (
BinarySensorSchema,
@@ -74,6 +67,7 @@
SERVICE_XKNX_ATTR_TYPE = "type"
SERVICE_XKNX_ATTR_REMOVE = "remove"
SERVICE_XKNX_EVENT_REGISTER = "event_register"
+SERVICE_XKNX_EXPOSURE_REGISTER = "exposure_register"
CONFIG_SCHEMA = vol.Schema(
{
@@ -169,12 +163,27 @@
}
)
+SERVICE_XKNX_EXPOSURE_REGISTER_SCHEMA = vol.Any(
+ ExposeSchema.SCHEMA.extend(
+ {
+ vol.Optional(SERVICE_XKNX_ATTR_REMOVE, default=False): cv.boolean,
+ }
+ ),
+ vol.Schema(
+ # for removing only `address` is required
+ {
+ vol.Required(SERVICE_XKNX_ATTR_ADDRESS): cv.string,
+ vol.Required(SERVICE_XKNX_ATTR_REMOVE): vol.All(cv.boolean, True),
+ },
+ extra=vol.ALLOW_EXTRA,
+ ),
+)
+
async def async_setup(hass, config):
"""Set up the KNX component."""
try:
hass.data[DOMAIN] = KNXModule(hass, config)
- hass.data[DOMAIN].async_create_exposures()
await hass.data[DOMAIN].start()
except XKNXException as ex:
_LOGGER.warning("Could not connect to KNX interface: %s", ex)
@@ -182,6 +191,12 @@ async def async_setup(hass, config):
f"Could not connect to KNX interface:
{ex}", title="KNX"
)
+ if CONF_XKNX_EXPOSE in config[DOMAIN]:
+ for expose_config in config[DOMAIN][CONF_XKNX_EXPOSE]:
+ hass.data[DOMAIN].exposures.append(
+ create_knx_exposure(hass, hass.data[DOMAIN].xknx, expose_config)
+ )
+
for platform in SupportedPlatforms:
if platform.value in config[DOMAIN]:
for device_config in config[DOMAIN][platform.value]:
@@ -214,6 +229,14 @@ async def async_setup(hass, config):
schema=SERVICE_XKNX_EVENT_REGISTER_SCHEMA,
)
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_XKNX_EXPOSURE_REGISTER,
+ hass.data[DOMAIN].service_exposure_register_modify,
+ schema=SERVICE_XKNX_EXPOSURE_REGISTER_SCHEMA,
+ )
+
async def reload_service_handler(service_call: ServiceCallType) -> None:
"""Remove all KNX components and load new ones from config."""
@@ -248,6 +271,7 @@ def __init__(self, hass, config):
self.config = config
self.connected = False
self.exposures = []
+ self.service_exposures = {}
self.init_xknx()
self._knx_event_callback: TelegramQueue.Callback = self.register_callback()
@@ -316,34 +340,6 @@ def connection_config_tunneling(self):
auto_reconnect=True,
)
- @callback
- def async_create_exposures(self):
- """Create exposures."""
- if CONF_XKNX_EXPOSE not in self.config[DOMAIN]:
- return
- for to_expose in self.config[DOMAIN][CONF_XKNX_EXPOSE]:
- expose_type = to_expose.get(ExposeSchema.CONF_XKNX_EXPOSE_TYPE)
- entity_id = to_expose.get(CONF_ENTITY_ID)
- attribute = to_expose.get(ExposeSchema.CONF_XKNX_EXPOSE_ATTRIBUTE)
- default = to_expose.get(ExposeSchema.CONF_XKNX_EXPOSE_DEFAULT)
- address = to_expose.get(ExposeSchema.CONF_XKNX_EXPOSE_ADDRESS)
- if expose_type.lower() in ["time", "date", "datetime"]:
- exposure = KNXExposeTime(self.xknx, expose_type, address)
- exposure.async_register()
- self.exposures.append(exposure)
- else:
- exposure = KNXExposeSensor(
- self.hass,
- self.xknx,
- expose_type,
- entity_id,
- attribute,
- default,
- address,
- )
- exposure.async_register()
- self.exposures.append(exposure)
-
async def telegram_received_cb(self, telegram):
"""Call invoked after a KNX telegram was received."""
data = None
@@ -378,9 +374,51 @@ async def service_event_register_modify(self, call):
"""Service for adding or removing a GroupAddress to the knx_event filter."""
group_address = GroupAddress(call.data.get(SERVICE_XKNX_ATTR_ADDRESS))
if call.data.get(SERVICE_XKNX_ATTR_REMOVE):
- self._knx_event_callback.group_addresses.remove(group_address)
+ try:
+ self._knx_event_callback.group_addresses.remove(group_address)
+ except ValueError:
+ _LOGGER.warning(
+ "Service event_register could not remove event for '%s'",
+ group_address,
+ )
elif group_address not in self._knx_event_callback.group_addresses:
self._knx_event_callback.group_addresses.append(group_address)
+ _LOGGER.debug(
+ "Service event_register registered event for '%s'",
+ group_address,
+ )
+
+ async def service_exposure_register_modify(self, call):
+ """Service for adding or removing an exposure to KNX bus."""
+ group_address = call.data.get(SERVICE_XKNX_ATTR_ADDRESS)
+
+ if call.data.get(SERVICE_XKNX_ATTR_REMOVE):
+ try:
+ removed_exposure = self.service_exposures.pop(group_address)
+ except KeyError:
+ _LOGGER.warning(
+ "Service exposure_register could not remove exposure for '%s'",
+ group_address,
+ )
+ else:
+ removed_exposure.shutdown()
+ return
+
+ if group_address in self.service_exposures:
+ replaced_exposure = self.service_exposures.pop(group_address)
+ _LOGGER.warning(
+ "Service exposure_register replacing already registered exposure for '%s' - %s",
+ group_address,
+ replaced_exposure.device.name,
+ )
+ replaced_exposure.shutdown()
+ exposure = create_knx_exposure(self.hass, self.xknx, call.data)
+ self.service_exposures[group_address] = exposure
+ _LOGGER.debug(
+ "Service exposure_register registered exposure for '%s' - %s",
+ group_address,
+ exposure.device.name,
+ )
async def service_send_to_knx_bus(self, call):
"""Service for sending an arbitrary KNX message to the KNX bus."""
@@ -404,93 +442,3 @@ def calculate_payload(attr_payload):
payload=GroupValueWrite(calculate_payload(attr_payload)),
)
await self.xknx.telegrams.put(telegram)
-
-
-class KNXExposeTime:
- """Object to Expose Time/Date object to KNX bus."""
-
- def __init__(self, xknx: XKNX, expose_type: str, address: str):
- """Initialize of Expose class."""
- self.xknx = xknx
- self.expose_type = expose_type
- self.address = address
- self.device = None
-
- @callback
- def async_register(self):
- """Register listener."""
- self.device = DateTime(
- self.xknx,
- name=self.expose_type.capitalize(),
- broadcast_type=self.expose_type.upper(),
- localtime=True,
- group_address=self.address,
- )
-
-
-class KNXExposeSensor:
- """Object to Expose Home Assistant entity to KNX bus."""
-
- def __init__(self, hass, xknx, expose_type, entity_id, attribute, default, address):
- """Initialize of Expose class."""
- self.hass = hass
- self.xknx = xknx
- self.type = expose_type
- self.entity_id = entity_id
- self.expose_attribute = attribute
- self.expose_default = default
- self.address = address
- self.device = None
-
- @callback
- def async_register(self):
- """Register listener."""
- if self.expose_attribute is not None:
- _name = self.entity_id + "__" + self.expose_attribute
- else:
- _name = self.entity_id
- self.device = ExposeSensor(
- self.xknx,
- name=_name,
- group_address=self.address,
- value_type=self.type,
- )
- async_track_state_change_event(
- self.hass, [self.entity_id], self._async_entity_changed
- )
-
- async def _async_entity_changed(self, event):
- """Handle entity change."""
- new_state = event.data.get("new_state")
- if new_state is None:
- return
- if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
- return
-
- if self.expose_attribute is not None:
- new_attribute = new_state.attributes.get(self.expose_attribute)
- old_state = event.data.get("old_state")
-
- if old_state is not None:
- old_attribute = old_state.attributes.get(self.expose_attribute)
- if old_attribute == new_attribute:
- # don't send same value sequentially
- return
- await self._async_set_knx_value(new_attribute)
- else:
- await self._async_set_knx_value(new_state.state)
-
- async def _async_set_knx_value(self, value):
- """Set new value on xknx ExposeSensor."""
- if value is None:
- if self.expose_default is None:
- return
- value = self.expose_default
-
- if self.type == "binary":
- if value == STATE_ON:
- value = True
- elif value == STATE_OFF:
- value = False
-
- await self.device.set(value)
diff --git a/home-assistant-plugin/custom_components/xknx/expose.py b/home-assistant-plugin/custom_components/xknx/expose.py
new file mode 100644
index 000000000..14c7997d2
--- /dev/null
+++ b/home-assistant-plugin/custom_components/xknx/expose.py
@@ -0,0 +1,147 @@
+"""Exposures to KNX bus."""
+from typing import Union
+
+from xknx import XKNX
+from xknx.devices import DateTime, ExposeSensor
+
+from homeassistant.const import (
+ CONF_ENTITY_ID,
+ STATE_OFF,
+ STATE_ON,
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.helpers.typing import ConfigType
+
+from .schema import ExposeSchema
+
+
+def create_knx_exposure(
+ hass: HomeAssistant, xknx: XKNX, config: ConfigType
+) -> Union["KNXExposeSensor", "KNXExposeTime"]:
+ """Create exposures from config."""
+ expose_type = config.get(ExposeSchema.CONF_XKNX_EXPOSE_TYPE)
+ entity_id = config.get(CONF_ENTITY_ID)
+ attribute = config.get(ExposeSchema.CONF_XKNX_EXPOSE_ATTRIBUTE)
+ default = config.get(ExposeSchema.CONF_XKNX_EXPOSE_DEFAULT)
+ address = config.get(ExposeSchema.CONF_XKNX_EXPOSE_ADDRESS)
+ if expose_type.lower() in ["time", "date", "datetime"]:
+ exposure = KNXExposeTime(xknx, expose_type, address)
+ exposure.async_register()
+ else:
+ exposure = KNXExposeSensor(
+ hass,
+ xknx,
+ expose_type,
+ entity_id,
+ attribute,
+ default,
+ address,
+ )
+ exposure.async_register()
+ return exposure
+
+
+class KNXExposeSensor:
+ """Object to Expose Home Assistant entity to KNX bus."""
+
+ def __init__(self, hass, xknx, expose_type, entity_id, attribute, default, address):
+ """Initialize of Expose class."""
+ self.hass = hass
+ self.xknx = xknx
+ self.type = expose_type
+ self.entity_id = entity_id
+ self.expose_attribute = attribute
+ self.expose_default = default
+ self.address = address
+ self.device = None
+ self._remove_listener = None
+
+ @callback
+ def async_register(self):
+ """Register listener."""
+ if self.expose_attribute is not None:
+ _name = self.entity_id + "__" + self.expose_attribute
+ else:
+ _name = self.entity_id
+ self.device = ExposeSensor(
+ self.xknx,
+ name=_name,
+ group_address=self.address,
+ value_type=self.type,
+ )
+ self._remove_listener = async_track_state_change_event(
+ self.hass, [self.entity_id], self._async_entity_changed
+ )
+
+ def shutdown(self) -> None:
+ """Prepare for deletion."""
+ if self._remove_listener is not None:
+ self._remove_listener()
+ if self.device is not None:
+ self.device.shutdown()
+
+ async def _async_entity_changed(self, event):
+ """Handle entity change."""
+ new_state = event.data.get("new_state")
+ if new_state is None:
+ return
+ if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
+ return
+
+ if self.expose_attribute is not None:
+ new_attribute = new_state.attributes.get(self.expose_attribute)
+ old_state = event.data.get("old_state")
+
+ if old_state is not None:
+ old_attribute = old_state.attributes.get(self.expose_attribute)
+ if old_attribute == new_attribute:
+ # don't send same value sequentially
+ return
+ await self._async_set_knx_value(new_attribute)
+ else:
+ await self._async_set_knx_value(new_state.state)
+
+ async def _async_set_knx_value(self, value):
+ """Set new value on xknx ExposeSensor."""
+ if value is None:
+ if self.expose_default is None:
+ return
+ value = self.expose_default
+
+ if self.type == "binary":
+ if value == STATE_ON:
+ value = True
+ elif value == STATE_OFF:
+ value = False
+
+ await self.device.set(value)
+
+
+class KNXExposeTime:
+ """Object to Expose Time/Date object to KNX bus."""
+
+ def __init__(self, xknx: XKNX, expose_type: str, address: str):
+ """Initialize of Expose class."""
+ self.xknx = xknx
+ self.expose_type = expose_type
+ self.address = address
+ self.device = None
+
+ @callback
+ def async_register(self):
+ """Register listener."""
+ self.device = DateTime(
+ self.xknx,
+ name=self.expose_type.capitalize(),
+ broadcast_type=self.expose_type.upper(),
+ localtime=True,
+ group_address=self.address,
+ )
+
+ def shutdown(self):
+ """Prepare for deletion."""
+ if self.device is not None:
+ self.device.shutdown()
diff --git a/home-assistant-plugin/custom_components/xknx/services.yaml b/home-assistant-plugin/custom_components/xknx/services.yaml
index cab8c100b..142baca2f 100644
--- a/home-assistant-plugin/custom_components/xknx/services.yaml
+++ b/home-assistant-plugin/custom_components/xknx/services.yaml
@@ -18,3 +18,25 @@ event_register:
example: "1/1/0"
remove:
description: "Optional. If `True` the group address will be removed. Defaults to `False`."
+exposure_register:
+ description: "Add or remove exposures to KNX bus. Only exposures added with this service can be removed."
+ fields:
+ address:
+ description: "Required. Group address state or attribute updates will be sent to. GroupValueRead requests will be answered. Per address only one exposure can be registered."
+ example: "1/1/0"
+ type:
+ description: "Required. Telegrams will be encoded as given DPT. 'binary' and all Knx sensor types are valid values (see https://www.home-assistant.io/integrations/sensor.knx)"
+ example: "percentU8"
+ entity_id:
+ description: "Required. Entity id to be exposed."
+ example: "light.kitchen"
+ required: true
+ attribute:
+ description: "Optional. Attribute of the entity that shall be sent to the KNX bus. If not set the state will be sent. Eg. for a light the state is eigther “on” or “off” - with attribute you can expose its “brightness”."
+ example: "brightness"
+ required: false
+ default:
+ description: "Optional. Default value to send to the bus if the state or attribute value is None. Eg. a light with state “off” has no brightness attribute so a default value of 0 could be used. If not set (or None) no value would be sent to the bus and a GroupReadRequest to the address would return the last known value."
+ example: "0"
+ remove:
+ description: "Optional. If `True` the exposure will be removed. Only `address` is required for removal."
diff --git a/test/devices_tests/devices_test.py b/test/devices_tests/devices_test.py
index 89098ed2b..f6dc49d0d 100644
--- a/test/devices_tests/devices_test.py
+++ b/test/devices_tests/devices_test.py
@@ -142,6 +142,19 @@ def test_contains(self):
self.assertTrue("Living-Room.Light_2" in xknx.devices)
self.assertFalse("Living-Room.Light_3" in xknx.devices)
+ @patch.multiple(Device, __abstractmethods__=set())
+ def test_add_remove(self):
+ """Tesst add and remove functions."""
+ xknx = XKNX()
+ device1 = Device(xknx, "TestDevice1")
+ device2 = Device(xknx, "TestDevice2")
+ self.assertEqual(len(xknx.devices), 2)
+ device1.shutdown()
+ self.assertEqual(len(xknx.devices), 1)
+ self.assertFalse("TestDevice1" in xknx.devices)
+ device2.shutdown()
+ self.assertEqual(len(xknx.devices), 0)
+
def test_modification_of_device(self):
"""Test if devices object does store references and not copies of objects."""
xknx = XKNX()
diff --git a/xknx/devices/binary_sensor.py b/xknx/devices/binary_sensor.py
index 80b283522..b9bfcc320 100644
--- a/xknx/devices/binary_sensor.py
+++ b/xknx/devices/binary_sensor.py
@@ -76,11 +76,14 @@ def _iter_remote_values(self) -> Iterator[RemoteValueSwitch]:
def __del__(self) -> None:
"""Destructor. Cleaning up if this was not done before."""
- if self._reset_task:
- self._reset_task.cancel()
-
- if self._context_task:
- self._context_task.cancel()
+ try:
+ if self._reset_task:
+ self._reset_task.cancel()
+ if self._context_task:
+ self._context_task.cancel()
+ except RuntimeError:
+ pass
+ super().__del__()
@classmethod
def from_config(cls, xknx: "XKNX", name: str, config: Any) -> "BinarySensor":
diff --git a/xknx/devices/datetime.py b/xknx/devices/datetime.py
index dcf552469..6c2bc5982 100644
--- a/xknx/devices/datetime.py
+++ b/xknx/devices/datetime.py
@@ -49,7 +49,11 @@ def __init__(
def __del__(self) -> None:
"""Destructor. Cleaning up if this was not done before."""
if self._broadcast_task:
- self._broadcast_task.cancel()
+ try:
+ self._broadcast_task.cancel()
+ except RuntimeError:
+ pass
+ super().__del__()
def _iter_remote_values(self) -> Iterator[RemoteValueDateTime]:
"""Iterate the devices RemoteValue classes."""
diff --git a/xknx/devices/device.py b/xknx/devices/device.py
index 4e68d8a9b..ed09f3332 100644
--- a/xknx/devices/device.py
+++ b/xknx/devices/device.py
@@ -37,12 +37,26 @@ def __init__(
self.xknx.devices.add(self)
+ def __del__(self) -> None:
+ """Remove Device form Devices."""
+ try:
+ self.shutdown()
+ except ValueError:
+ pass
+
+ def shutdown(self) -> None:
+ """Prepare for deletion. Remove callbacks and device form Devices vector."""
+ self.xknx.devices.remove(self)
+ self.device_updated_cbs = []
+ for remote_value in self._iter_remote_values():
+ remote_value.__del__()
+
@abstractmethod
def _iter_remote_values(self) -> Iterator[RemoteValue]:
"""Iterate the devices RemoteValue classes."""
# yield self.remote_value
- # or
# yield from ()
+ yield from ()
def register_device_updated_cb(self, device_updated_cb: DeviceCallbackType) -> None:
"""Register device updated callback."""
diff --git a/xknx/devices/devices.py b/xknx/devices/devices.py
index 338fec33e..5dae9e3f0 100644
--- a/xknx/devices/devices.py
+++ b/xknx/devices/devices.py
@@ -67,6 +67,10 @@ def add(self, device: Device) -> None:
device.register_device_updated_cb(self.device_updated)
self.__devices.append(device)
+ def remove(self, device: Device) -> None:
+ """Remove device from devices vector."""
+ self.__devices.remove(device)
+
async def device_updated(self, device: Device) -> None:
"""Call all registered device updated callbacks of device."""
for device_updated_cb in self.device_updated_cbs:
diff --git a/xknx/devices/switch.py b/xknx/devices/switch.py
index ca89b1083..28eccf43c 100644
--- a/xknx/devices/switch.py
+++ b/xknx/devices/switch.py
@@ -58,7 +58,11 @@ def _iter_remote_values(self) -> Iterator[RemoteValueSwitch]:
def __del__(self) -> None:
"""Destructor. Cleaning up if this was not done before."""
if self._reset_task:
- self._reset_task.cancel()
+ try:
+ self._reset_task.cancel()
+ except RuntimeError:
+ pass
+ super().__del__()
@classmethod
def from_config(cls, xknx: "XKNX", name: str, config: Any) -> "Switch":
diff --git a/xknx/remote_value/remote_value.py b/xknx/remote_value/remote_value.py
index 319f36152..73b1f75f6 100644
--- a/xknx/remote_value/remote_value.py
+++ b/xknx/remote_value/remote_value.py
@@ -78,7 +78,9 @@ def __del__(self) -> None:
"""Destructor. Removing self from StateUpdater if was registered."""
try:
self.xknx.state_updater.unregister_remote_value(self)
- except KeyError:
+ except (KeyError, AttributeError):
+ # KeyError if it was never added to StateUpdater
+ # AttributeError if instantiation failed (tests mostly)
pass
@property