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 untrusted user: blacklight
GPG key ID: D90FBA7F76362774
2 changed files with 168 additions and 61 deletions

View file

@ -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 = [{
listeners = [
{
**self.server_info,
'topics': [
self.base_topic + '/' + topic
for topic in ['bridge/state', 'bridge/log', 'bridge/logging', 'bridge/devices', 'bridge/groups']
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():
@ -241,10 +286,8 @@ class ZigbeeMqttBackend(MqttBackend):
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()

View file

@ -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,16 +181,17 @@ 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(
converted_entity = Switch(
id=dev['ieee_address'],
name=dev.get('friendly_name'),
state=switch_info['property'] == switch_info['value_on'],
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
@ -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 device_info:
self.publish_entities(
[
{ # type: ignore
**device_info,
'state': device_state,
}
]
)
if not refreshed:
self.publish_entities([device_info]) # type: ignore
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