Proper support for native entities in zigbee.mqtt integration

This commit is contained in:
Fabio Manganiello 2022-04-11 21:16:45 +02:00
parent db4ad5825e
commit be4d1e8e01
Signed by: blacklight
GPG key ID: D90FBA7F76362774
2 changed files with 168 additions and 61 deletions

View file

@ -1,21 +1,38 @@
import contextlib
import json import json
from typing import Optional from typing import Optional, Mapping
from platypush.backend.mqtt import MqttBackend from platypush.backend.mqtt import MqttBackend
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.message.event.zigbee.mqtt import ZigbeeMqttOnlineEvent, ZigbeeMqttOfflineEvent, \ from platypush.message.event.zigbee.mqtt import (
ZigbeeMqttDevicePropertySetEvent, ZigbeeMqttDevicePairingEvent, ZigbeeMqttDeviceConnectedEvent, \ ZigbeeMqttOnlineEvent,
ZigbeeMqttDeviceBannedEvent, ZigbeeMqttDeviceRemovedEvent, ZigbeeMqttDeviceRemovedFailedEvent, \ ZigbeeMqttOfflineEvent,
ZigbeeMqttDeviceWhitelistedEvent, ZigbeeMqttDeviceRenamedEvent, ZigbeeMqttDeviceBindEvent, \ ZigbeeMqttDevicePropertySetEvent,
ZigbeeMqttDeviceUnbindEvent, ZigbeeMqttGroupAddedEvent, ZigbeeMqttGroupAddedFailedEvent, \ ZigbeeMqttDevicePairingEvent,
ZigbeeMqttGroupRemovedEvent, ZigbeeMqttGroupRemovedFailedEvent, ZigbeeMqttGroupRemoveAllEvent, \ ZigbeeMqttDeviceConnectedEvent,
ZigbeeMqttGroupRemoveAllFailedEvent, ZigbeeMqttErrorEvent ZigbeeMqttDeviceBannedEvent,
ZigbeeMqttDeviceRemovedEvent,
ZigbeeMqttDeviceRemovedFailedEvent,
ZigbeeMqttDeviceWhitelistedEvent,
ZigbeeMqttDeviceRenamedEvent,
ZigbeeMqttDeviceBindEvent,
ZigbeeMqttDeviceUnbindEvent,
ZigbeeMqttGroupAddedEvent,
ZigbeeMqttGroupAddedFailedEvent,
ZigbeeMqttGroupRemovedEvent,
ZigbeeMqttGroupRemovedFailedEvent,
ZigbeeMqttGroupRemoveAllEvent,
ZigbeeMqttGroupRemoveAllFailedEvent,
ZigbeeMqttErrorEvent,
)
class ZigbeeMqttBackend(MqttBackend): class ZigbeeMqttBackend(MqttBackend):
""" """
Listen for events on a zigbee2mqtt service. Listen for events on a zigbee2mqtt service.
For historical reasons, this backend should be enabled together with the `zigbee.mqtt` plugin.
Triggers: Triggers:
* :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent` when the service comes online. * :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', def __init__(
tls_cafile: Optional[str] = None, tls_certfile: Optional[str] = None, self,
tls_keyfile: Optional[str] = None, tls_version: Optional[str] = None, host: Optional[str] = None,
tls_ciphers: Optional[str] = None, username: Optional[str] = None, port: Optional[int] = None,
password: Optional[str] = None, client_id: Optional[str] = None, *args, **kwargs): 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 host: MQTT broker host (default: host configured on the ``zigbee.mqtt`` plugin).
:param port: MQTT broker port (default: 1883). :param port: MQTT broker port (default: 1883).
@ -87,6 +115,7 @@ class ZigbeeMqttBackend(MqttBackend):
plugin = get_plugin('zigbee.mqtt') plugin = get_plugin('zigbee.mqtt')
self.base_topic = base_topic or plugin.base_topic self.base_topic = base_topic or plugin.base_topic
self._devices = {} self._devices = {}
self._devices_info = {}
self._groups = {} self._groups = {}
self._last_state = None self._last_state = None
self.server_info = { self.server_info = {
@ -106,17 +135,28 @@ class ZigbeeMqttBackend(MqttBackend):
**self.server_info, **self.server_info,
} }
listeners = [{ listeners = [
**self.server_info, {
'topics': [ **self.server_info,
self.base_topic + '/' + topic 'topics': [
for topic in ['bridge/state', 'bridge/log', 'bridge/logging', 'bridge/devices', 'bridge/groups'] self.base_topic + '/' + topic
], for topic in [
}] 'bridge/state',
'bridge/log',
'bridge/logging',
'bridge/devices',
'bridge/groups',
]
],
}
]
super().__init__( super().__init__(
*args, subscribe_default_topic=False, *args,
listeners=listeners, client_id=client_id, **kwargs subscribe_default_topic=False,
listeners=listeners,
client_id=client_id,
**kwargs
) )
if not client_id: if not client_id:
@ -146,7 +186,7 @@ class ZigbeeMqttBackend(MqttBackend):
if msg_type == 'devices': if msg_type == 'devices':
devices = {} devices = {}
for dev in (text or []): for dev in text or []:
devices[dev['friendly_name']] = dev devices[dev['friendly_name']] = dev
client.subscribe(self.base_topic + '/' + dev['friendly_name']) client.subscribe(self.base_topic + '/' + dev['friendly_name'])
elif msg_type == 'pairing': elif msg_type == 'pairing':
@ -155,7 +195,9 @@ class ZigbeeMqttBackend(MqttBackend):
self.bus.post(ZigbeeMqttDeviceBannedEvent(device=text, **args)) self.bus.post(ZigbeeMqttDeviceBannedEvent(device=text, **args))
elif msg_type in ['device_removed_failed', 'device_force_removed_failed']: elif msg_type in ['device_removed_failed', 'device_force_removed_failed']:
force = msg_type == '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': elif msg_type == 'device_whitelisted':
self.bus.post(ZigbeeMqttDeviceWhitelistedEvent(device=text, **args)) self.bus.post(ZigbeeMqttDeviceWhitelistedEvent(device=text, **args))
elif msg_type == 'device_renamed': elif msg_type == 'device_renamed':
@ -181,7 +223,11 @@ class ZigbeeMqttBackend(MqttBackend):
self.bus.post(ZigbeeMqttErrorEvent(error=text, **args)) self.bus.post(ZigbeeMqttErrorEvent(error=text, **args))
elif msg.get('level') in ['warning', 'error']: elif msg.get('level') in ['warning', 'error']:
log = getattr(self.logger, msg['level']) 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): def _process_devices(self, client, msg):
devices_info = { devices_info = {
@ -191,10 +237,9 @@ class ZigbeeMqttBackend(MqttBackend):
# noinspection PyProtectedMember # noinspection PyProtectedMember
event_args = {'host': client._host, 'port': client._port} event_args = {'host': client._host, 'port': client._port}
client.subscribe(*[ client.subscribe(
self.base_topic + '/' + device *[self.base_topic + '/' + device for device in devices_info.keys()]
for device in devices_info.keys() )
])
for name, device in devices_info.items(): for name, device in devices_info.items():
if name not in self._devices: if name not in self._devices:
@ -203,7 +248,7 @@ class ZigbeeMqttBackend(MqttBackend):
exposes = (device.get('definition', {}) or {}).get('exposes', []) exposes = (device.get('definition', {}) or {}).get('exposes', [])
client.publish( client.publish(
self.base_topic + '/' + name + '/get', 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()] devices_copy = [*self._devices.keys()]
@ -213,13 +258,13 @@ class ZigbeeMqttBackend(MqttBackend):
del self._devices[name] del self._devices[name]
self._devices = {device: {} for device in devices_info.keys()} self._devices = {device: {} for device in devices_info.keys()}
self._devices_info = devices_info
def _process_groups(self, client, msg): def _process_groups(self, client, msg):
# noinspection PyProtectedMember # noinspection PyProtectedMember
event_args = {'host': client._host, 'port': client._port} event_args = {'host': client._host, 'port': client._port}
groups_info = { groups_info = {
group.get('friendly_name', group.get('id')): group group.get('friendly_name', group.get('id')): group for group in msg
for group in msg
} }
for name in groups_info.keys(): for name in groups_info.keys():
@ -236,15 +281,13 @@ class ZigbeeMqttBackend(MqttBackend):
def on_mqtt_message(self): def on_mqtt_message(self):
def handler(client, _, msg): def handler(client, _, msg):
topic = msg.topic[len(self.base_topic)+1:] topic = msg.topic[len(self.base_topic) + 1 :]
data = msg.payload.decode() data = msg.payload.decode()
if not data: if not data:
return return
try: with contextlib.suppress(ValueError, TypeError):
data = json.loads(data) data = json.loads(data)
except (ValueError, TypeError):
pass
if topic == 'bridge/state': if topic == 'bridge/state':
self._process_state_message(client, data) self._process_state_message(client, data)
@ -260,17 +303,45 @@ class ZigbeeMqttBackend(MqttBackend):
return return
name = suffix 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: if changed_props:
# noinspection PyProtectedMember self._process_property_update(name, changed_props)
self.bus.post(ZigbeeMqttDevicePropertySetEvent(host=client._host, port=client._port, self.bus.post(
device=name, properties=changed_props)) ZigbeeMqttDevicePropertySetEvent(
host=client._host,
port=client._port,
device=name,
properties=changed_props,
)
)
self._devices[name].update(data) self._devices[name].update(data)
return handler 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): def run(self):
super().run() super().run()

View file

@ -3,6 +3,7 @@ import threading
from queue import Queue from queue import Queue
from typing import Optional, List, Any, Dict, Union from typing import Optional, List, Any, Dict, Union
from platypush.message import Mapping
from platypush.message.response import Response from platypush.message.response import Response
from platypush.plugins.mqtt import MqttPlugin, action from platypush.plugins.mqtt import MqttPlugin, action
@ -153,6 +154,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in
self._info = { self._info = {
'devices': {}, 'devices': {},
'groups': {}, 'groups': {},
'devices_by_addr': {},
} }
def transform_entities(self, devices): def transform_entities(self, devices):
@ -163,6 +165,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in
if not dev: if not dev:
continue continue
converted_entity = None
dev_def = dev.get("definition") or {} dev_def = dev.get("definition") or {}
dev_info = { dev_info = {
"type": dev.get("type"), "type": dev.get("type"),
@ -178,17 +181,18 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in
"description": dev_def.get("description"), "description": dev_def.get("description"),
} }
switch_info = self._get_switch_info(dev) switch_info = self._get_switch_meta(dev)
if switch_info: if switch_info:
compatible_entities.append( converted_entity = Switch(
Switch( id=dev['ieee_address'],
id=dev['ieee_address'], name=dev.get('friendly_name'),
name=dev.get('friendly_name'), state=dev.get('state', {}).get('state') == 'ON',
state=switch_info['property'] == switch_info['value_on'], data=dev_info,
data=dev_info,
)
) )
if converted_entity:
compatible_entities.append(converted_entity)
return super().transform_entities(compatible_entities) # type: ignore return super().transform_entities(compatible_entities) # type: ignore
def _get_network_info(self, **kwargs): 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', []) for device in info.get('devices', [])
} }
self._info['devices_by_addr'] = {
device['ieee_address']: device for device in info.get('devices', [])
}
self._info['groups'] = { self._info['groups'] = {
group.get('name'): group for group in info.get('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') self.logger.info('Zigbee network configuration updated')
return info return info
finally: finally:
@ -659,6 +666,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in
return ret 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 # noinspection PyShadowingBuiltins
@action @action
def device_get( 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. :return: Key->value map of the device properties.
""" """
kwargs = self._mqtt_args(**kwargs) 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: if property:
properties = self.publish( 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}' assert property in properties, f'No such property: {property}'
return {property: properties[property]} return {property: properties[property]}
refreshed = False
if device not in self._info.get('devices', {}): if device not in self._info.get('devices', {}):
# Refresh devices info # Refresh devices info
self._get_network_info(**kwargs) self._get_network_info(**kwargs)
refreshed = True
assert self._info.get('devices', {}).get(device), f'No such device: {device}' assert self._info.get('devices', {}).get(device), f'No such device: {device}'
exposes = ( exposes = (
@ -701,17 +714,24 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in
if not exposes: if not exposes:
return {} return {}
device_info = self.publish( device_state = self.publish(
topic=self._topic(device) + '/get', topic=self._topic(device) + '/get',
reply_topic=self._topic(device), reply_topic=self._topic(device),
msg=self.build_device_get_request(exposes), msg=self.build_device_get_request(exposes),
**kwargs, **kwargs,
) ).output
if not refreshed: if device_info:
self.publish_entities([device_info]) # type: ignore self.publish_entities(
[
{ # type: ignore
**device_info,
'state': device_state,
}
]
)
return device_info return device_state
@action @action
def devices_get( 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 Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.on` and turns on a Zigbee device with a writable
binary property. 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) 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( props = self.device_set(
device, switch_info['property'], switch_info['value_on'] device, switch_info['property'], switch_info['value_on']
).output ).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 Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.off` and turns off a Zigbee device with a
writable binary property. 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) 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( props = self.device_set(
device, switch_info['property'], switch_info['value_off'] device, switch_info['property'], switch_info['value_off']
).output ).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 Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.toggle` and toggles a Zigbee device with a
writable binary property. 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) 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( props = self.device_set(
device, switch_info['property'], switch_info['value_toggle'] device, switch_info['property'], switch_info['value_toggle']
).output ).output
@ -1281,6 +1304,17 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in
device=device, props=props, switch_info=switch_info 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 @staticmethod
def _properties_to_switch(device: str, props: dict, switch_info: dict) -> dict: def _properties_to_switch(device: str, props: dict, switch_info: dict) -> dict:
return { return {
@ -1291,7 +1325,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in
} }
@staticmethod @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', []) exposes = (device_info.get('definition', {}) or {}).get('exposes', [])
for exposed in exposes: for exposed in exposes:
for feature in exposed.get('features', []): 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 and feature.get('access', 0) & 2
): ):
return { return {
'friendly_name': device_info.get('friendly_name'),
'ieee_address': device_info.get('friendly_name'),
'property': feature['property'], 'property': feature['property'],
'value_on': feature['value_on'], 'value_on': feature['value_on'],
'value_off': feature['value_off'], 'value_off': feature['value_off'],
@ -1316,7 +1352,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in
switches_info = {} switches_info = {}
for device in devices: for device in devices:
info = self._get_switch_info(device) info = self._get_switch_meta(device)
if not info: if not info:
continue continue