Merged `zwave.mqtt` backend into the `zwave.mqtt` plugin

This commit is contained in:
Fabio Manganiello 2023-01-29 02:34:48 +01:00
parent 0e56d0fff6
commit 8aff181956
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
7 changed files with 316 additions and 276 deletions

View File

@ -21,9 +21,10 @@ class ZigbeeMqttBackend(Backend):
super().run() super().run()
warnings.warn( warnings.warn(
''' '''
The zigbee.mqtt has been merged into the zigbee.mqtt plugin. It is The zigbee.mqtt backend has been merged into the zigbee.mqtt
now deprecated and it will be removed in a future version. Remove plugin. It is now deprecated and it will be removed in a future
any references to it from your configuration. version.
Please remove any references to it from your configuration.
''', ''',
DeprecationWarning, DeprecationWarning,
) )

View File

@ -1,222 +1,34 @@
import contextlib import warnings
import json
from queue import Queue, Empty
from typing import Optional, Type
from platypush.backend.mqtt import MqttBackend from platypush.backend import Backend
from platypush.context import get_plugin
from platypush.config import Config
from platypush.message.event.zwave import (
ZwaveEvent,
ZwaveNodeAddedEvent,
ZwaveValueChangedEvent,
ZwaveNodeRemovedEvent,
ZwaveNodeRenamedEvent,
ZwaveNodeReadyEvent,
ZwaveNodeEvent,
ZwaveNodeAsleepEvent,
ZwaveNodeAwakeEvent,
ZwaveValueRemovedEvent,
)
class ZwaveMqttBackend(MqttBackend): class ZwaveMqttBackend(Backend):
""" """
Listen for events on a `zwave-js-ui <https://github.com/zwave-js/zwave-js-ui>`_ Listen for events on a zwave2mqtt service.
service. For historical reasons, this should be enabled together with the
``zwave.mqtt`` plugin, even though the actual configuration is only
specified on the plugin. For this reason, this backend will be deprecated in
the near future and merged with its associated plugin.
Triggers: **WARNING**: This backend is **DEPRECATED** and it will be removed in a
future version.
* :class:`platypush.message.event.zwave.ZwaveNodeEvent` when a node attribute changes. It has been merged with
* :class:`platypush.message.event.zwave.ZwaveNodeAddedEvent` when a node is added to the network. :class:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin`.
* :class:`platypush.message.event.zwave.ZwaveNodeRemovedEvent` when a node is removed from the network.
* :class:`platypush.message.event.zwave.ZwaveNodeRenamedEvent` when a node is renamed.
* :class:`platypush.message.event.zwave.ZwaveNodeReadyEvent` when a node is ready.
* :class:`platypush.message.event.zwave.ZwaveValueChangedEvent` when the value of a node on the network
changes.
* :class:`platypush.message.event.zwave.ZwaveNodeAsleepEvent` when a node goes into sleep mode.
* :class:`platypush.message.event.zwave.ZwaveNodeAwakeEvent` when a node goes back into awake mode.
Requires:
* **paho-mqtt** (``pip install paho-mqtt``)
* A `zwave-js-ui instance <https://github.com/zwave-js/zwave-js-ui>`_.
* The :class:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin` plugin configured.
Now you can simply configure the `zwave.mqtt` plugin in order to enable
the Zwave integration - no need to enable both the plugin and the backend.
""" """
def __init__(self, client_id: Optional[str] = None, *args, **kwargs):
"""
:param client_id: MQTT client ID (default: ``<device_id>-zwavejs-mqtt``, to prevent clashes with the
:class:`platypush.backend.mqtt.MqttBackend` ``client_id``.
"""
from platypush.plugins.zwave.mqtt import ZwaveMqttPlugin
plugin: Optional[ZwaveMqttPlugin] = get_plugin('zwave.mqtt')
assert plugin, 'The zwave.mqtt plugin is not configured'
self.plugin = plugin
self._nodes = {}
self._groups = {}
self._last_state = None
self._events_queue = Queue()
self.events_topic = self.plugin.events_topic
self.server_info = {
'host': self.plugin.host,
'port': self.plugin.port or self._default_mqtt_port,
'tls_cafile': self.plugin.tls_cafile,
'tls_certfile': self.plugin.tls_certfile,
'tls_ciphers': self.plugin.tls_ciphers,
'tls_keyfile': self.plugin.tls_keyfile,
'tls_version': self.plugin.tls_version,
'username': self.plugin.username,
'password': self.plugin.password,
}
listeners = [
{
**self.server_info,
'topics': [
self.plugin.events_topic + '/node/' + topic
for topic in [
'node_ready',
'node_sleep',
'node_value_updated',
'node_metadata_updated',
'node_wakeup',
]
],
}
]
super().__init__(
*args,
subscribe_default_topic=False,
listeners=listeners,
client_id=client_id,
**kwargs,
)
if not client_id:
self.client_id = (
str(self.client_id or Config.get('device_id')) + '-zwavejs-mqtt'
)
def _dispatch_event(
self,
event_type: Type[ZwaveEvent],
node: dict,
value: Optional[dict] = None,
**kwargs,
):
node_id = node.get('id')
assert node_id is not None, 'No node ID specified'
# This is far from efficient (we are querying the latest version of the whole
# node for every event we receive), but this is the best we can do with recent
# versions of ZWaveJS that only transmit partial representations of the node and
# the value. The alternative would be to come up with a complex logic for merging
# cached and new values, with the risk of breaking back-compatibility with earlier
# implementations of zwavejs2mqtt.
node = kwargs['node'] = self.plugin.get_nodes(node_id).output # type: ignore
node_values = node.get('values', {})
if node and value:
# Infer the value_id structure if it's not provided on the event
value_id = value.get('id')
if value_id is None:
value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}"
if 'propertyKey' in value:
value_id += '-' + str(value['propertyKey'])
# Prepend the node_id to value_id if it's not available in node['values']
# (compatibility with more recent versions of ZwaveJS that don't provide
# the value_id on the events)
if value_id not in node_values:
value_id = f"{node_id}-{value_id}"
if value_id not in node_values:
self.logger.warning(f'value_id {value_id} not found on node {node_id}')
return
value = kwargs['value'] = node_values[value_id]
if issubclass(event_type, ZwaveNodeEvent):
# If the node has been removed, remove it from the cache
if event_type == ZwaveNodeRemovedEvent:
self._nodes.pop(node_id, None)
# If this node_id wasn't cached before, then it's a new node
elif node_id not in self._nodes:
event_type = ZwaveNodeAddedEvent
# If the name has changed, we have a rename event
elif node['name'] != self._nodes[node_id]['name']:
event_type = ZwaveNodeRenamedEvent
# If nothing relevant has changed, update the cached instance and return
else:
self._nodes[node_id] = node
return
evt = event_type(**kwargs)
self._events_queue.put(evt)
if (
value
and issubclass(event_type, ZwaveValueChangedEvent)
and event_type != ZwaveValueRemovedEvent
):
self.plugin.publish_entities([kwargs['value']]) # type: ignore
def on_mqtt_message(self):
def handler(_, __, msg):
if not msg.topic.startswith(self.events_topic):
return
topic = (
msg.topic[(len(self.events_topic) + 1) :].split('/').pop() # noqa: E203
)
data = msg.payload.decode()
if not data:
return
with contextlib.suppress(ValueError, TypeError):
data = json.loads(data)['data']
try:
if topic == 'node_value_updated':
self._dispatch_event(
ZwaveValueChangedEvent, node=data[0], value=data[1]
)
elif topic == 'node_metadata_updated':
self._dispatch_event(ZwaveNodeEvent, node=data[0])
elif topic == 'node_sleep':
self._dispatch_event(ZwaveNodeAsleepEvent, node=data[0])
elif topic == 'node_wakeup':
self._dispatch_event(ZwaveNodeAwakeEvent, node=data[0])
elif topic == 'node_ready':
self._dispatch_event(ZwaveNodeReadyEvent, node=data[0])
elif topic == 'node_removed':
self._dispatch_event(ZwaveNodeRemovedEvent, node=data[0])
except Exception as e:
self.logger.exception(e)
return handler
def run(self): def run(self):
super().run() super().run()
self.logger.debug('Refreshing Z-Wave nodes') warnings.warn(
self._nodes = self.plugin.get_nodes().output # type: ignore '''
The zwave.mqtt backend has been merged into the zwave.mqtt plugin.
It is now deprecated and it will be removed in a future version.
Please remove any references to it from your configuration.
''',
DeprecationWarning,
)
while not self.should_stop(): self.wait_stop()
try:
evt = self._events_queue.get(block=True, timeout=1)
except Empty:
continue
self.bus.post(evt)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -106,8 +106,8 @@ class EntityManagerMixin:
""" """
from . import publish_entities from . import publish_entities
entities = self.transform_entities(entities) transformed_entities = self.transform_entities(entities)
publish_entities(entities) publish_entities(transformed_entities)
def manages(*entities: Type[Entity]): def manages(*entities: Type[Entity]):
@ -125,7 +125,7 @@ def manages(*entities: Type[Entity]):
init(self, *args, **kwargs) init(self, *args, **kwargs)
plugin.__init__ = __init__ plugin.__init__ = __init__ # type: ignore
# Inject the EntityManagerMixin # Inject the EntityManagerMixin
if EntityManagerMixin not in plugin.__bases__: if EntityManagerMixin not in plugin.__bases__:
plugin.__bases__ = (EntityManagerMixin,) + plugin.__bases__ plugin.__bases__ = (EntityManagerMixin,) + plugin.__bases__

View File

@ -3,7 +3,15 @@ import re
import threading import threading
from queue import Queue from queue import Queue
from typing import Optional, List, Any, Dict, Type, Union, Tuple from typing import (
Any,
Dict,
List,
Optional,
Tuple,
Type,
Union,
)
from platypush.entities import Entity, manages from platypush.entities import Entity, manages
from platypush.entities.batteries import Battery from platypush.entities.batteries import Battery
@ -210,7 +218,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
self.base_topic = base_topic self.base_topic = base_topic
self.timeout = timeout self.timeout = timeout
self._info = { self._info: Dict[str, dict] = {
'devices_by_addr': {}, 'devices_by_addr': {},
'devices_by_name': {}, 'devices_by_name': {},
'groups': {}, 'groups': {},
@ -346,7 +354,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
def _get_device_url(device_info: dict) -> Optional[str]: def _get_device_url(device_info: dict) -> Optional[str]:
model = device_info.get('definition', {}).get('model') model = device_info.get('definition', {}).get('model')
if not model: if not model:
return return None
return f'https://www.zigbee2mqtt.io/devices/{model}.html' return f'https://www.zigbee2mqtt.io/devices/{model}.html'
@ -354,7 +362,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
def _get_image_url(device_info: dict) -> Optional[str]: def _get_image_url(device_info: dict) -> Optional[str]:
model = device_info.get('definition', {}).get('model') model = device_info.get('definition', {}).get('model')
if not model: if not model:
return return None
return f'https://www.zigbee2mqtt.io/images/devices/{model}.jpg' return f'https://www.zigbee2mqtt.io/images/devices/{model}.jpg'
@ -366,7 +374,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
if 'timeout' in mqtt_args: if 'timeout' in mqtt_args:
timeout = mqtt_args.pop('timeout') timeout = mqtt_args.pop('timeout')
info = { info: Dict[str, Any] = {
'state': None, 'state': None,
'info': {}, 'info': {},
'config': {}, 'config': {},
@ -374,7 +382,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
'groups': [], 'groups': [],
} }
info_ready_events = {topic: threading.Event() for topic in info.keys()} info_ready_events = {topic: threading.Event() for topic in info}
def _on_message(): def _on_message():
def callback(_, __, msg): def callback(_, __, msg):
@ -426,9 +434,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
client.loop_stop() client.loop_stop()
client.disconnect() client.disconnect()
except Exception as e: except Exception as e:
self.logger.warning( self.logger.warning('Error on MQTT client disconnection: %s', e)
'Error on MQTT client disconnection: {}'.format(str(e))
)
return info return info
@ -438,12 +444,12 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
@staticmethod @staticmethod
def _parse_response(response: Union[dict, Response]) -> dict: def _parse_response(response: Union[dict, Response]) -> dict:
if isinstance(response, Response): if isinstance(response, Response):
response = response.output # type: ignore[reportGeneralTypeIssues] response = dict(response.output)
assert response.get('status') != 'error', response.get( # type: ignore[reportGeneralTypeIssues] assert response.get('status') != 'error', response.get(
'error', 'zigbee2mqtt error' 'error', 'zigbee2mqtt error'
) )
return response # type: ignore[reportGeneralTypeIssues] return response
@action @action
def devices(self, **kwargs) -> List[Dict[str, Any]]: def devices(self, **kwargs) -> List[Dict[str, Any]]:
@ -784,10 +790,10 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
devices = self.devices().output # type: ignore[reportGeneralTypeIssues] devices = self.devices().output # type: ignore[reportGeneralTypeIssues]
assert not [ assert not [
dev for dev in devices if dev.get('friendly_name') == name dev for dev in devices if dev.get('friendly_name') == name
], 'A device named {} already exists on the network'.format(name) ], f'A device named {name} already exists on the network'
if device: if device:
req = { req: Dict[str, Any] = {
'from': device, 'from': device,
'to': name, 'to': name,
} }
@ -807,7 +813,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
) )
@staticmethod @staticmethod
def _build_device_get_request(values: List[Dict[str, Any]]) -> dict: def _build_device_get_request(values: List[Dict[str, Any]]) -> Dict[str, Any]:
def extract_value(value: dict, root: dict, depth: int = 0): def extract_value(value: dict, root: dict, depth: int = 0):
for feature in value.get('features', []): for feature in value.get('features', []):
new_root = root new_root = root
@ -829,7 +835,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
root[value['property']] = root.get(value['property'], {}) root[value['property']] = root.get(value['property'], {})
root = root[value['property']] root = root[value['property']]
ret = {} ret: Dict[str, Any] = {}
for value in values: for value in values:
extract_value(value, root=ret) extract_value(value, root=ret)
@ -954,7 +960,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
def worker(device: str, q: Queue): def worker(device: str, q: Queue):
q.put(self.device_get(device, **kwargs).output) # type: ignore[reportGeneralTypeIssues] q.put(self.device_get(device, **kwargs).output) # type: ignore[reportGeneralTypeIssues]
queues = {} queues: Dict[str, Queue] = {}
workers = {} workers = {}
response = {} response = {}
@ -971,15 +977,15 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
workers[device].join(timeout=kwargs.get('timeout')) workers[device].join(timeout=kwargs.get('timeout'))
except Exception as e: except Exception as e:
self.logger.warning( self.logger.warning(
'An error occurred while getting the status of the device {}: {}'.format( 'An error occurred while getting the status of the device %s: %s',
device, str(e) device,
) e,
) )
return response return response
@action @action
def status(self, device: Optional[str] = None, *args, **kwargs): def status(self, *args, device: Optional[str] = None, **kwargs):
""" """
Get the status of a device (by friendly name) or of all the connected devices (it wraps :meth:`.devices_get`). Get the status of a device (by friendly name) or of all the connected devices (it wraps :meth:`.devices_get`).
@ -1369,9 +1375,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
for group in self.groups().output # type: ignore[reportGeneralTypeIssues] for group in self.groups().output # type: ignore[reportGeneralTypeIssues]
} }
assert ( assert name not in groups, f'A group named {name} already exists on the network'
name not in groups
), 'A group named {} already exists on the network'.format(name)
return self._parse_response( return self._parse_response(
self.publish( # type: ignore[reportGeneralTypeIssues] self.publish( # type: ignore[reportGeneralTypeIssues]
@ -1433,17 +1437,14 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
(default: query the default configured device). (default: query the default configured device).
""" """
remove_suffix = '_all' if device is None else ''
return self._parse_response( return self._parse_response(
self.publish( # type: ignore[reportGeneralTypeIssues] self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic( topic=self._topic(
'bridge/request/group/members/remove{}'.format( f'bridge/request/group/members/remove{remove_suffix}'
'_all' if device is None else ''
)
), ),
reply_topic=self._topic( reply_topic=self._topic(
'bridge/response/group/members/remove{}'.format( f'bridge/response/group/members/remove{remove_suffix}'
'_all' if device is None else ''
)
), ),
msg={ msg={
'group': group, 'group': group,
@ -1505,9 +1506,9 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
Turn on/set to true a switch, a binary property or an option. Turn on/set to true a switch, a binary property or an option.
""" """
device, prop_info = self._get_switch_info(device) device, prop_info = self._get_switch_info(device)
self.device_set( return self.device_set(
device, prop_info['property'], prop_info.get('value_on', 'ON') device, prop_info['property'], prop_info.get('value_on', 'ON')
).output # type: ignore[reportGeneralTypeIssues] )
@action @action
def off(self, device, *_, **__): def off(self, device, *_, **__):
@ -1515,9 +1516,9 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
Turn off/set to false a switch, a binary property or an option. Turn off/set to false a switch, a binary property or an option.
""" """
device, prop_info = self._get_switch_info(device) device, prop_info = self._get_switch_info(device)
self.device_set( return self.device_set(
device, prop_info['property'], prop_info.get('value_off', 'OFF') device, prop_info['property'], prop_info.get('value_off', 'OFF')
).output # type: ignore[reportGeneralTypeIssues] )
@action @action
def toggle(self, device, *_, **__): def toggle(self, device, *_, **__):
@ -1527,7 +1528,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
device, prop_info = self._get_switch_info(device) device, prop_info = self._get_switch_info(device)
prop = prop_info['property'] prop = prop_info['property']
device_state = self.device_get(device).output # type: ignore device_state = self.device_get(device).output # type: ignore
self.device_set( return self.device_set(
device, device,
prop, prop,
prop_info.get( prop_info.get(
@ -1536,7 +1537,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
if device_state.get(prop) == prop_info.get('value_on', 'ON') if device_state.get(prop) == prop_info.get('value_on', 'ON')
else 'ON', else 'ON',
), ),
).output # type: ignore[reportGeneralTypeIssues] )
def _get_switch_info(self, name: str) -> Tuple[str, dict]: def _get_switch_info(self, name: str) -> Tuple[str, dict]:
name, prop = self._ieee_address(name, with_property=True) name, prop = self._ieee_address(name, with_property=True)
@ -1924,6 +1925,8 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
listener = ZigbeeMqttListener() listener = ZigbeeMqttListener()
listener.start() listener.start()
self.wait_stop() self.wait_stop()
listener.stop()
listener.join() listener.join()

View File

@ -36,8 +36,9 @@ from platypush.entities.switches import EnumSwitch, Switch
from platypush.entities.temperature import TemperatureSensor from platypush.entities.temperature import TemperatureSensor
from platypush.message.event.zwave import ZwaveNodeRenamedEvent, ZwaveNodeEvent from platypush.message.event.zwave import ZwaveNodeRenamedEvent, ZwaveNodeEvent
from platypush.context import get_backend, get_bus from platypush.context import get_bus
from platypush.message.response import Response from platypush.message.response import Response
from platypush.plugins import RunnablePlugin
from platypush.plugins.mqtt import MqttPlugin, action from platypush.plugins.mqtt import MqttPlugin, action
from platypush.plugins.zwave._base import ZwaveBasePlugin from platypush.plugins.zwave._base import ZwaveBasePlugin
from platypush.plugins.zwave._constants import command_class_by_name from platypush.plugins.zwave._constants import command_class_by_name
@ -45,7 +46,7 @@ from platypush.plugins.zwave._constants import command_class_by_name
_NOT_IMPLEMENTED_ERR = NotImplementedError('Not implemented by zwave.mqtt') _NOT_IMPLEMENTED_ERR = NotImplementedError('Not implemented by zwave.mqtt')
class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
""" """
This plugin allows you to manage a Z-Wave network over MQTT through This plugin allows you to manage a Z-Wave network over MQTT through
`zwave-js-ui <https://github.com/zwave-js/zwave-js-ui>`_. `zwave-js-ui <https://github.com/zwave-js/zwave-js-ui>`_.
@ -76,6 +77,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
* **paho-mqtt** (``pip install paho-mqtt``) * **paho-mqtt** (``pip install paho-mqtt``)
Triggers:
* :class:`platypush.message.event.zwave.ZwaveNodeEvent` when a node attribute changes.
* :class:`platypush.message.event.zwave.ZwaveNodeAddedEvent` when a node is added to the network.
* :class:`platypush.message.event.zwave.ZwaveNodeRemovedEvent` when a node is removed from the network.
* :class:`platypush.message.event.zwave.ZwaveNodeRenamedEvent` when a node is renamed.
* :class:`platypush.message.event.zwave.ZwaveNodeReadyEvent` when a node is ready.
* :class:`platypush.message.event.zwave.ZwaveValueChangedEvent` when the value of a node on the network
changes.
* :class:`platypush.message.event.zwave.ZwaveNodeAsleepEvent` when a node goes into sleep mode.
* :class:`platypush.message.event.zwave.ZwaveNodeAwakeEvent` when a node goes back into awake mode.
""" """
# These classes are ignored by the entity parsing logic # These classes are ignored by the entity parsing logic
@ -156,40 +169,30 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
self.base_topic = topic_prefix + '/{}/ZWAVE_GATEWAY-' + name self.base_topic = topic_prefix + '/{}/ZWAVE_GATEWAY-' + name
self.events_topic = self.base_topic.format('_EVENTS') self.events_topic = self.base_topic.format('_EVENTS')
self.timeout = timeout self.timeout = timeout
self._info = { self._info: Mapping[str, dict] = {
'devices': {}, 'devices': {},
'groups': {}, 'groups': {},
} }
self._nodes_cache = { self._nodes_cache: Dict[str, dict] = {
'by_id': {}, 'by_id': {},
'by_name': {}, 'by_name': {},
} }
self._values_cache = { self._values_cache: Dict[str, dict] = {
'by_id': {}, 'by_id': {},
'by_label': {}, 'by_label': {},
} }
self._scenes_cache = { self._scenes_cache: Dict[str, dict] = {
'by_id': {}, 'by_id': {},
'by_label': {}, 'by_label': {},
} }
self._groups_cache = {} self._groups_cache: Dict[str, dict] = {}
@staticmethod
def _get_backend():
backend = get_backend('zwave.mqtt')
if not backend:
raise AssertionError('zwave.mqtt backend not configured')
return backend
def _api_topic(self, api: str) -> str: def _api_topic(self, api: str) -> str:
return self.base_topic.format('_CLIENTS') + '/api/{}'.format(api) return self.base_topic.format('_CLIENTS') + f'/api/{api}'
def _topic(self, topic):
return self.base_topic + '/' + topic
@staticmethod @staticmethod
def _parse_response(response: Union[dict, Response]) -> dict: def _parse_response(response: Union[dict, Response]) -> dict:
@ -222,6 +225,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
def _convert_timestamp(t: Optional[int]) -> Optional[datetime]: def _convert_timestamp(t: Optional[int]) -> Optional[datetime]:
if t: if t:
return datetime.fromtimestamp(t / 1000) return datetime.fromtimestamp(t / 1000)
return None
def _get_scene( def _get_scene(
self, self,
@ -375,7 +379,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
'device_id': device_id.replace('0x', ''), 'device_id': device_id.replace('0x', ''),
'name': node.get('name'), 'name': node.get('name'),
'capabilities': capabilities, 'capabilities': capabilities,
'manufacturer_id': '0x{:04x}'.format(node['manufacturerId']) 'manufacturer_id': f'0x{node["manufacturerId"]:04x}'
if node.get('manufacturerId') if node.get('manufacturerId')
else None, else None,
'manufacturer_name': node.get('manufacturer'), 'manufacturer_name': node.get('manufacturer'),
@ -395,7 +399,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
'is_security_device': node.get('supportsSecurity'), 'is_security_device': node.get('supportsSecurity'),
'is_sleeping': node.get('ready') and node.get('status') == 'Asleep', 'is_sleeping': node.get('ready') and node.get('status') == 'Asleep',
'last_update': cls._convert_timestamp(node.get('lastActive')), 'last_update': cls._convert_timestamp(node.get('lastActive')),
'product_id': '0x{:04x}'.format(node['productId']) 'product_id': f'0x{node["productId"]:04x}'
if node.get('productId') if node.get('productId')
else None, else None,
'product_type': '0x{:04x}'.format(node['productType']) 'product_type': '0x{:04x}'.format(node['productType'])
@ -785,7 +789,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
product_type = node.get('product_type') product_type = node.get('product_type')
firmware_version = node.get('firmware_version', '0.0') firmware_version = node.get('firmware_version', '0.0')
if not (manufacturer_id and product_id and product_type): if not (manufacturer_id and product_id and product_type):
return return None
return ( return (
'https://devices.zwave-js.io/?jumpTo=' 'https://devices.zwave-js.io/?jumpTo='
@ -849,9 +853,6 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
return list(new_values.values()) return list(new_values.values())
def _topic_by_value_id(self, value_id: str) -> str:
return self.topic_prefix + '/' + '/'.join(value_id.split('-'))
def _filter_values( def _filter_values(
self, self,
command_classes: Optional[Iterable[str]] = None, # type: ignore[reportGeneralTypeIssues] command_classes: Optional[Iterable[str]] = None, # type: ignore[reportGeneralTypeIssues]
@ -1080,7 +1081,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
self._api_request('refreshInfo', node_id, **kwargs) self._api_request('refreshInfo', node_id, **kwargs)
@action @action
def request_node_neighbour_update(self, **kwargs): def request_node_neighbour_update(self, *_, **kwargs):
""" """
Request a neighbours list update. Request a neighbours list update.
@ -1271,7 +1272,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
return nodes return nodes
@action @action
def get_node_stats(self, **_): def get_node_stats(self, *_, **__):
""" """
Get the statistics of a node on the network (not implemented by zwavejs2mqtt). Get the statistics of a node on the network (not implemented by zwavejs2mqtt).
""" """
@ -2286,5 +2287,15 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
for dev in devices for dev in devices
] ]
def main(self):
from ._listener import ZwaveMqttListener
listener = ZwaveMqttListener()
listener.start()
self.wait_stop()
listener.stop()
listener.join()
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -0,0 +1,197 @@
import contextlib
import json
from queue import Queue, Empty
from typing import Optional, Type
from platypush.backend.mqtt import MqttBackend
from platypush.context import get_bus, get_plugin
from platypush.config import Config
from platypush.message.event.zwave import (
ZwaveEvent,
ZwaveNodeAddedEvent,
ZwaveValueChangedEvent,
ZwaveNodeRemovedEvent,
ZwaveNodeRenamedEvent,
ZwaveNodeReadyEvent,
ZwaveNodeEvent,
ZwaveNodeAsleepEvent,
ZwaveNodeAwakeEvent,
ZwaveValueRemovedEvent,
)
class ZwaveMqttListener(MqttBackend):
"""
Internal MQTT listener for ``zwave.mqtt`` events.
"""
def __init__(self, *args, **kwargs):
self._nodes = {}
self._groups = {}
self._last_state = None
self._events_queue = Queue()
self.events_topic = self.plugin.events_topic
self.server_info = {
'host': self.plugin.host,
'port': self.plugin.port or self._default_mqtt_port,
'tls_cafile': self.plugin.tls_cafile,
'tls_certfile': self.plugin.tls_certfile,
'tls_ciphers': self.plugin.tls_ciphers,
'tls_keyfile': self.plugin.tls_keyfile,
'tls_version': self.plugin.tls_version,
'username': self.plugin.username,
'password': self.plugin.password,
}
listeners = [
{
**self.server_info,
'topics': [
self.plugin.events_topic + '/node/' + topic
for topic in [
'node_ready',
'node_sleep',
'node_value_updated',
'node_metadata_updated',
'node_wakeup',
]
],
}
]
super().__init__(
*args,
subscribe_default_topic=False,
listeners=listeners,
**kwargs,
)
self.client_id = (
str(self.client_id or Config.get('device_id')) + '-zwavejs-mqtt'
)
@property
def plugin(self):
from platypush.plugins.zwave.mqtt import ZwaveMqttPlugin
plugin: Optional[ZwaveMqttPlugin] = get_plugin('zwave.mqtt')
assert plugin, 'The zwave.mqtt plugin is not configured'
return plugin
def _dispatch_event(
self,
event_type: Type[ZwaveEvent],
node: dict,
value: Optional[dict] = None,
**kwargs,
):
node_id = node.get('id')
assert node_id is not None, 'No node ID specified'
# This is far from efficient (we are querying the latest version of the whole
# node for every event we receive), but this is the best we can do with recent
# versions of ZWaveJS that only transmit partial representations of the node and
# the value. The alternative would be to come up with a complex logic for merging
# cached and new values, with the risk of breaking back-compatibility with earlier
# implementations of zwavejs2mqtt.
node = kwargs['node'] = self.plugin.get_nodes(node_id).output # type: ignore
node_values = node.get('values', {})
if node and value:
# Infer the value_id structure if it's not provided on the event
value_id = value.get('id')
if value_id is None:
value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}"
if 'propertyKey' in value:
value_id += '-' + str(value['propertyKey'])
# Prepend the node_id to value_id if it's not available in node['values']
# (compatibility with more recent versions of ZwaveJS that don't provide
# the value_id on the events)
if value_id not in node_values:
value_id = f"{node_id}-{value_id}"
if value_id not in node_values:
self.logger.warning(
'value_id %s not found on node %s', value_id, node_id
)
return
value = kwargs['value'] = node_values[value_id]
if issubclass(event_type, ZwaveNodeEvent):
# If the node has been removed, remove it from the cache
if event_type == ZwaveNodeRemovedEvent:
self._nodes.pop(node_id, None)
# If this node_id wasn't cached before, then it's a new node
elif node_id not in self._nodes:
event_type = ZwaveNodeAddedEvent
# If the name has changed, we have a rename event
elif node['name'] != self._nodes[node_id]['name']:
event_type = ZwaveNodeRenamedEvent
# If nothing relevant has changed, update the cached instance and return
else:
self._nodes[node_id] = node
return
evt = event_type(**kwargs)
self._events_queue.put(evt)
if (
value
and issubclass(event_type, ZwaveValueChangedEvent)
and event_type != ZwaveValueRemovedEvent
):
self.plugin.publish_entities([kwargs['value']]) # type: ignore
def on_mqtt_message(self):
def handler(_, __, msg):
if not msg.topic.startswith(self.events_topic):
return
topic = (
msg.topic[(len(self.events_topic) + 1) :].split('/').pop() # noqa: E203
)
data = msg.payload.decode()
if not data:
return
with contextlib.suppress(ValueError, TypeError):
data = json.loads(data)['data']
try:
if topic == 'node_value_updated':
self._dispatch_event(
ZwaveValueChangedEvent, node=data[0], value=data[1]
)
elif topic == 'node_metadata_updated':
self._dispatch_event(ZwaveNodeEvent, node=data[0])
elif topic == 'node_sleep':
self._dispatch_event(ZwaveNodeAsleepEvent, node=data[0])
elif topic == 'node_wakeup':
self._dispatch_event(ZwaveNodeAwakeEvent, node=data[0])
elif topic == 'node_ready':
self._dispatch_event(ZwaveNodeReadyEvent, node=data[0])
elif topic == 'node_removed':
self._dispatch_event(ZwaveNodeRemovedEvent, node=data[0])
except Exception as e:
self.logger.exception(e)
return handler
def run(self):
super().run()
self.logger.debug('Refreshing Z-Wave nodes')
self._nodes = self.plugin.get_nodes().output # type: ignore
while not self.should_stop():
try:
evt = self._events_queue.get(block=True, timeout=1)
except Empty:
continue
get_bus().post(evt)
# vim:sw=4:ts=4:et:

View File

@ -1,5 +1,21 @@
manifest: manifest:
events: {} events:
platypush.message.event.zwave.ZwaveNodeAddedEvent: >
when a node is added to the network.
platypush.message.event.zwave.ZwaveNodeAsleepEvent: >
when a node goes into sleep mode.
platypush.message.event.zwave.ZwaveNodeAwakeEvent: >
when a node goes back into awake mode.
platypush.message.event.zwave.ZwaveNodeEvent: >
when a node attribute changes.
platypush.message.event.zwave.ZwaveNodeReadyEvent: >
when a node is ready.
platypush.message.event.zwave.ZwaveNodeRemovedEvent: >
when a node is removed from the network.
platypush.message.event.zwave.ZwaveNodeRenamedEvent: >
when a node is renamed.
platypush.message.event.zwave.ZwaveValueChangedEvent: >
when the value of a node on the network changes.
install: install:
pip: pip:
- paho-mqtt - paho-mqtt