forked from platypush/platypush
Proper support for native entities in zigbee.mqtt integration
This commit is contained in:
parent
db4ad5825e
commit
be4d1e8e01
2 changed files with 168 additions and 61 deletions
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue