From be4d1e8e01e21dfef7c37e39660f2ba016bfe275 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 11 Apr 2022 21:16:45 +0200 Subject: [PATCH] Proper support for native entities in zigbee.mqtt integration --- platypush/backend/zigbee/mqtt/__init__.py | 151 ++++++++++++++++------ platypush/plugins/zigbee/mqtt/__init__.py | 78 ++++++++--- 2 files changed, 168 insertions(+), 61 deletions(-) diff --git a/platypush/backend/zigbee/mqtt/__init__.py b/platypush/backend/zigbee/mqtt/__init__.py index fd805596..b7af6b1e 100644 --- a/platypush/backend/zigbee/mqtt/__init__.py +++ b/platypush/backend/zigbee/mqtt/__init__.py @@ -1,21 +1,38 @@ +import contextlib import json -from typing import Optional +from typing import Optional, Mapping from platypush.backend.mqtt import MqttBackend from platypush.context import get_plugin -from platypush.message.event.zigbee.mqtt import ZigbeeMqttOnlineEvent, ZigbeeMqttOfflineEvent, \ - ZigbeeMqttDevicePropertySetEvent, ZigbeeMqttDevicePairingEvent, ZigbeeMqttDeviceConnectedEvent, \ - ZigbeeMqttDeviceBannedEvent, ZigbeeMqttDeviceRemovedEvent, ZigbeeMqttDeviceRemovedFailedEvent, \ - ZigbeeMqttDeviceWhitelistedEvent, ZigbeeMqttDeviceRenamedEvent, ZigbeeMqttDeviceBindEvent, \ - ZigbeeMqttDeviceUnbindEvent, ZigbeeMqttGroupAddedEvent, ZigbeeMqttGroupAddedFailedEvent, \ - ZigbeeMqttGroupRemovedEvent, ZigbeeMqttGroupRemovedFailedEvent, ZigbeeMqttGroupRemoveAllEvent, \ - ZigbeeMqttGroupRemoveAllFailedEvent, ZigbeeMqttErrorEvent +from platypush.message.event.zigbee.mqtt import ( + ZigbeeMqttOnlineEvent, + ZigbeeMqttOfflineEvent, + ZigbeeMqttDevicePropertySetEvent, + ZigbeeMqttDevicePairingEvent, + ZigbeeMqttDeviceConnectedEvent, + ZigbeeMqttDeviceBannedEvent, + ZigbeeMqttDeviceRemovedEvent, + ZigbeeMqttDeviceRemovedFailedEvent, + ZigbeeMqttDeviceWhitelistedEvent, + ZigbeeMqttDeviceRenamedEvent, + ZigbeeMqttDeviceBindEvent, + ZigbeeMqttDeviceUnbindEvent, + ZigbeeMqttGroupAddedEvent, + ZigbeeMqttGroupAddedFailedEvent, + ZigbeeMqttGroupRemovedEvent, + ZigbeeMqttGroupRemovedFailedEvent, + ZigbeeMqttGroupRemoveAllEvent, + ZigbeeMqttGroupRemoveAllFailedEvent, + ZigbeeMqttErrorEvent, +) class ZigbeeMqttBackend(MqttBackend): """ Listen for events on a zigbee2mqtt service. + For historical reasons, this backend should be enabled together with the `zigbee.mqtt` plugin. + Triggers: * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent` when the service comes online. @@ -59,11 +76,22 @@ class ZigbeeMqttBackend(MqttBackend): """ - def __init__(self, host: Optional[str] = None, port: Optional[int] = None, base_topic='zigbee2mqtt', - tls_cafile: Optional[str] = None, tls_certfile: Optional[str] = None, - tls_keyfile: Optional[str] = None, tls_version: Optional[str] = None, - tls_ciphers: Optional[str] = None, username: Optional[str] = None, - password: Optional[str] = None, client_id: Optional[str] = None, *args, **kwargs): + def __init__( + self, + host: Optional[str] = None, + port: Optional[int] = None, + base_topic='zigbee2mqtt', + tls_cafile: Optional[str] = None, + tls_certfile: Optional[str] = None, + tls_keyfile: Optional[str] = None, + tls_version: Optional[str] = None, + tls_ciphers: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + client_id: Optional[str] = None, + *args, + **kwargs + ): """ :param host: MQTT broker host (default: host configured on the ``zigbee.mqtt`` plugin). :param port: MQTT broker port (default: 1883). @@ -87,6 +115,7 @@ class ZigbeeMqttBackend(MqttBackend): plugin = get_plugin('zigbee.mqtt') self.base_topic = base_topic or plugin.base_topic self._devices = {} + self._devices_info = {} self._groups = {} self._last_state = None self.server_info = { @@ -106,17 +135,28 @@ class ZigbeeMqttBackend(MqttBackend): **self.server_info, } - listeners = [{ - **self.server_info, - 'topics': [ - self.base_topic + '/' + topic - for topic in ['bridge/state', 'bridge/log', 'bridge/logging', 'bridge/devices', 'bridge/groups'] - ], - }] + listeners = [ + { + **self.server_info, + 'topics': [ + self.base_topic + '/' + topic + for topic in [ + 'bridge/state', + 'bridge/log', + 'bridge/logging', + 'bridge/devices', + 'bridge/groups', + ] + ], + } + ] super().__init__( - *args, subscribe_default_topic=False, - listeners=listeners, client_id=client_id, **kwargs + *args, + subscribe_default_topic=False, + listeners=listeners, + client_id=client_id, + **kwargs ) if not client_id: @@ -146,7 +186,7 @@ class ZigbeeMqttBackend(MqttBackend): if msg_type == 'devices': devices = {} - for dev in (text or []): + for dev in text or []: devices[dev['friendly_name']] = dev client.subscribe(self.base_topic + '/' + dev['friendly_name']) elif msg_type == 'pairing': @@ -155,7 +195,9 @@ class ZigbeeMqttBackend(MqttBackend): self.bus.post(ZigbeeMqttDeviceBannedEvent(device=text, **args)) elif msg_type in ['device_removed_failed', 'device_force_removed_failed']: force = msg_type == 'device_force_removed_failed' - self.bus.post(ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args)) + self.bus.post( + ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args) + ) elif msg_type == 'device_whitelisted': self.bus.post(ZigbeeMqttDeviceWhitelistedEvent(device=text, **args)) elif msg_type == 'device_renamed': @@ -181,7 +223,11 @@ class ZigbeeMqttBackend(MqttBackend): self.bus.post(ZigbeeMqttErrorEvent(error=text, **args)) elif msg.get('level') in ['warning', 'error']: log = getattr(self.logger, msg['level']) - log('zigbee2mqtt {}: {}'.format(msg['level'], text or msg.get('error', msg.get('warning')))) + log( + 'zigbee2mqtt {}: {}'.format( + msg['level'], text or msg.get('error', msg.get('warning')) + ) + ) def _process_devices(self, client, msg): devices_info = { @@ -191,10 +237,9 @@ class ZigbeeMqttBackend(MqttBackend): # noinspection PyProtectedMember event_args = {'host': client._host, 'port': client._port} - client.subscribe(*[ - self.base_topic + '/' + device - for device in devices_info.keys() - ]) + client.subscribe( + *[self.base_topic + '/' + device for device in devices_info.keys()] + ) for name, device in devices_info.items(): if name not in self._devices: @@ -203,7 +248,7 @@ class ZigbeeMqttBackend(MqttBackend): exposes = (device.get('definition', {}) or {}).get('exposes', []) client.publish( self.base_topic + '/' + name + '/get', - json.dumps(get_plugin('zigbee.mqtt').build_device_get_request(exposes)) + json.dumps(self._plugin.build_device_get_request(exposes)), ) devices_copy = [*self._devices.keys()] @@ -213,13 +258,13 @@ class ZigbeeMqttBackend(MqttBackend): del self._devices[name] self._devices = {device: {} for device in devices_info.keys()} + self._devices_info = devices_info def _process_groups(self, client, msg): # noinspection PyProtectedMember event_args = {'host': client._host, 'port': client._port} groups_info = { - group.get('friendly_name', group.get('id')): group - for group in msg + group.get('friendly_name', group.get('id')): group for group in msg } for name in groups_info.keys(): @@ -236,15 +281,13 @@ class ZigbeeMqttBackend(MqttBackend): def on_mqtt_message(self): def handler(client, _, msg): - topic = msg.topic[len(self.base_topic)+1:] + topic = msg.topic[len(self.base_topic) + 1 :] data = msg.payload.decode() if not data: return - try: + with contextlib.suppress(ValueError, TypeError): data = json.loads(data) - except (ValueError, TypeError): - pass if topic == 'bridge/state': self._process_state_message(client, data) @@ -260,17 +303,45 @@ class ZigbeeMqttBackend(MqttBackend): return name = suffix - changed_props = {k: v for k, v in data.items() if v != self._devices[name].get(k)} + changed_props = { + k: v for k, v in data.items() if v != self._devices[name].get(k) + } if changed_props: - # noinspection PyProtectedMember - self.bus.post(ZigbeeMqttDevicePropertySetEvent(host=client._host, port=client._port, - device=name, properties=changed_props)) + self._process_property_update(name, changed_props) + self.bus.post( + ZigbeeMqttDevicePropertySetEvent( + host=client._host, + port=client._port, + device=name, + properties=changed_props, + ) + ) self._devices[name].update(data) return handler + @property + def _plugin(self): + plugin = get_plugin('zigbee.mqtt') + assert plugin, 'The zigbee.mqtt plugin is not configured' + return plugin + + def _process_property_update(self, device_name: str, properties: Mapping): + device_info = self._devices_info.get(device_name) + if not (device_info and properties): + return + + self._plugin.publish_entities( + [ + { + **device_info, + 'state': properties, + } + ] + ) + def run(self): super().run() diff --git a/platypush/plugins/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py index f0d718bd..9b5636aa 100644 --- a/platypush/plugins/zigbee/mqtt/__init__.py +++ b/platypush/plugins/zigbee/mqtt/__init__.py @@ -3,6 +3,7 @@ import threading from queue import Queue from typing import Optional, List, Any, Dict, Union +from platypush.message import Mapping from platypush.message.response import Response from platypush.plugins.mqtt import MqttPlugin, action @@ -153,6 +154,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in self._info = { 'devices': {}, 'groups': {}, + 'devices_by_addr': {}, } def transform_entities(self, devices): @@ -163,6 +165,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in if not dev: continue + converted_entity = None dev_def = dev.get("definition") or {} dev_info = { "type": dev.get("type"), @@ -178,17 +181,18 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in "description": dev_def.get("description"), } - switch_info = self._get_switch_info(dev) + switch_info = self._get_switch_meta(dev) if switch_info: - compatible_entities.append( - Switch( - id=dev['ieee_address'], - name=dev.get('friendly_name'), - state=switch_info['property'] == switch_info['value_on'], - data=dev_info, - ) + converted_entity = Switch( + id=dev['ieee_address'], + name=dev.get('friendly_name'), + state=dev.get('state', {}).get('state') == 'ON', + data=dev_info, ) + if converted_entity: + compatible_entities.append(converted_entity) + return super().transform_entities(compatible_entities) # type: ignore def _get_network_info(self, **kwargs): @@ -244,11 +248,14 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in for device in info.get('devices', []) } + self._info['devices_by_addr'] = { + device['ieee_address']: device for device in info.get('devices', []) + } + self._info['groups'] = { group.get('name'): group for group in info.get('groups', []) } - self.publish_entities(self._info['devices'].values()) # type: ignore self.logger.info('Zigbee network configuration updated') return info finally: @@ -659,6 +666,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in return ret + def _get_device_info(self, device: str) -> Mapping: + return self._info['devices'].get( + device, self._info['devices_by_addr'].get(device, {}) + ) + # noinspection PyShadowingBuiltins @action def device_get( @@ -676,6 +688,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in :return: Key->value map of the device properties. """ kwargs = self._mqtt_args(**kwargs) + device_info = self._get_device_info(device) + if device_info: + device = device_info.get('friendly_name') or device_info['ieee_address'] if property: properties = self.publish( @@ -688,11 +703,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in assert property in properties, f'No such property: {property}' return {property: properties[property]} - refreshed = False if device not in self._info.get('devices', {}): # Refresh devices info self._get_network_info(**kwargs) - refreshed = True assert self._info.get('devices', {}).get(device), f'No such device: {device}' exposes = ( @@ -701,17 +714,24 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in if not exposes: return {} - device_info = self.publish( + device_state = self.publish( topic=self._topic(device) + '/get', reply_topic=self._topic(device), msg=self.build_device_get_request(exposes), **kwargs, - ) + ).output - if not refreshed: - self.publish_entities([device_info]) # type: ignore + if device_info: + self.publish_entities( + [ + { # type: ignore + **device_info, + 'state': device_state, + } + ] + ) - return device_info + return device_state @action def devices_get( @@ -1242,8 +1262,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.on` and turns on a Zigbee device with a writable binary property. """ - switch_info = self._get_switches_info().get(device) + switch_info = self._get_switch_info(device) assert switch_info, '{} is not a valid switch'.format(device) + device = switch_info.get('friendly_name') or switch_info['ieee_address'] props = self.device_set( device, switch_info['property'], switch_info['value_on'] ).output @@ -1257,8 +1278,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.off` and turns off a Zigbee device with a writable binary property. """ - switch_info = self._get_switches_info().get(device) + switch_info = self._get_switch_info(device) assert switch_info, '{} is not a valid switch'.format(device) + device = switch_info.get('friendly_name') or switch_info['ieee_address'] props = self.device_set( device, switch_info['property'], switch_info['value_off'] ).output @@ -1272,8 +1294,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.toggle` and toggles a Zigbee device with a writable binary property. """ - switch_info = self._get_switches_info().get(device) + switch_info = self._get_switch_info(device) assert switch_info, '{} is not a valid switch'.format(device) + device = switch_info.get('friendly_name') or switch_info['ieee_address'] props = self.device_set( device, switch_info['property'], switch_info['value_toggle'] ).output @@ -1281,6 +1304,17 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in device=device, props=props, switch_info=switch_info ) + def _get_switch_info(self, device: str): + switches_info = self._get_switches_info() + info = switches_info.get(device) + if info: + return info + + device_info = self._get_device_info(device) + if device_info: + device = device_info.get('friendly_name') or device_info['ieee_address'] + return switches_info.get(device) + @staticmethod def _properties_to_switch(device: str, props: dict, switch_info: dict) -> dict: return { @@ -1291,7 +1325,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in } @staticmethod - def _get_switch_info(device_info: dict) -> dict: + def _get_switch_meta(device_info: dict) -> dict: exposes = (device_info.get('definition', {}) or {}).get('exposes', []) for exposed in exposes: for feature in exposed.get('features', []): @@ -1302,6 +1336,8 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in and feature.get('access', 0) & 2 ): return { + 'friendly_name': device_info.get('friendly_name'), + 'ieee_address': device_info.get('friendly_name'), 'property': feature['property'], 'value_on': feature['value_on'], 'value_off': feature['value_off'], @@ -1316,7 +1352,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in switches_info = {} for device in devices: - info = self._get_switch_info(device) + info = self._get_switch_meta(device) if not info: continue