Support for new zigbee2mqtt API and protocol (see #163)

This commit is contained in:
Fabio Manganiello 2021-02-06 02:19:15 +01:00
parent b57a241f52
commit 4c5a52417e
2 changed files with 406 additions and 109 deletions

View file

@ -98,6 +98,37 @@ class MqttPlugin(Plugin):
def _expandpath(path: Optional[str] = None) -> Optional[str]: def _expandpath(path: Optional[str] = None) -> Optional[str]:
return os.path.abspath(os.path.expanduser(path)) if path else None return os.path.abspath(os.path.expanduser(path)) if path else None
def _get_client(self, 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, tls_insecure: Optional[bool] = None,
username: Optional[str] = None, password: Optional[str] = None):
from paho.mqtt.client import Client
tls_cafile = self._expandpath(tls_cafile or self.tls_cafile)
tls_certfile = self._expandpath(tls_certfile or self.tls_certfile)
tls_keyfile = self._expandpath(tls_keyfile or self.tls_keyfile)
tls_ciphers = tls_ciphers or self.tls_ciphers
username = username or self.username
password = password or self.password
tls_version = tls_version or self.tls_version
if tls_version:
tls_version = self.get_tls_version(tls_version)
if tls_insecure is None:
tls_insecure = self.tls_insecure
client = Client()
if username and password:
client.username_pw_set(username, password)
if tls_cafile:
client.tls_set(ca_certs=tls_cafile, certfile=tls_certfile, keyfile=tls_keyfile,
tls_version=tls_version, ciphers=tls_ciphers)
client.tls_insecure_set(tls_insecure)
return client
@action @action
def publish(self, topic: str, msg: Any, host: Optional[str] = None, port: int = 1883, def publish(self, topic: str, msg: Any, host: Optional[str] = None, port: int = 1883,
reply_topic: Optional[str] = None, timeout: int = 60, reply_topic: Optional[str] = None, timeout: int = 60,
@ -129,41 +160,10 @@ class MqttPlugin(Plugin):
:param username: Specify it if the MQTT server requires authentication (default: None). :param username: Specify it if the MQTT server requires authentication (default: None).
:param password: Specify it if the MQTT server requires authentication (default: None). :param password: Specify it if the MQTT server requires authentication (default: None).
""" """
from paho.mqtt.client import Client response_buffer = io.BytesIO()
client = None
if not host and not self.host:
raise RuntimeError('No host specified and no default host configured')
if not host:
host = self.host
port = self.port
tls_cafile = self.tls_cafile
tls_certfile = self.tls_certfile
tls_keyfile = self.tls_keyfile
tls_version = self.tls_version
tls_ciphers = self.tls_ciphers
tls_insecure = self.tls_insecure
username = self.username
password = self.password
else:
tls_cafile = self._expandpath(tls_cafile)
tls_certfile = self._expandpath(tls_certfile)
tls_keyfile = self._expandpath(tls_keyfile)
if tls_version:
tls_version = self.get_tls_version(tls_version)
if tls_insecure is None:
tls_insecure = self.tls_insecure
client = Client()
if username and password:
client.username_pw_set(username, password)
if tls_cafile:
client.tls_set(ca_certs=tls_cafile, certfile=tls_certfile, keyfile=tls_keyfile,
tls_version=tls_version, ciphers=tls_ciphers)
client.tls_insecure_set(tls_insecure)
try:
# Try to parse it as a platypush message or dump it to JSON from a dict/list # Try to parse it as a platypush message or dump it to JSON from a dict/list
if isinstance(msg, (dict, list)): if isinstance(msg, (dict, list)):
msg = json.dumps(msg) msg = json.dumps(msg)
@ -174,10 +174,11 @@ class MqttPlugin(Plugin):
except: except:
pass pass
client.connect(host, port, keepalive=timeout) client = self._get_client(tls_cafile=tls_cafile, tls_certfile=tls_certfile, tls_keyfile=tls_keyfile,
response_buffer = io.BytesIO() tls_version=tls_version, tls_ciphers=tls_ciphers, tls_insecure=tls_insecure,
username=username, password=password)
try: client.connect(host, port, keepalive=timeout)
response_received = threading.Event() response_received = threading.Event()
if reply_topic: if reply_topic:
@ -198,6 +199,7 @@ class MqttPlugin(Plugin):
finally: finally:
response_buffer.close() response_buffer.close()
if client:
# noinspection PyBroadException # noinspection PyBroadException
try: try:
client.loop_stop() client.loop_stop()

View file

@ -1,3 +1,4 @@
import json
import threading import threading
from typing import Optional, List, Any, Dict from typing import Optional, List, Any, Dict
@ -12,18 +13,20 @@ class ZigbeeMqttPlugin(MqttPlugin):
In order to get started you'll need: In order to get started you'll need:
- A Zigbee USB adapter/sniffer (in this example I'll use the `CC2531 <https://hackaday.io/project/163487-zigbee-cc2531-smart-home-usb-adapter>`_. - A Zigbee USB adapter/sniffer (in this example I'll use the
`CC2531 <https://hackaday.io/project/163487-zigbee-cc2531-smart-home-usb-adapter>`_.
- A Zigbee debugger/emulator + downloader cable (only to flash the firmware). - A Zigbee debugger/emulator + downloader cable (only to flash the firmware).
Instructions: Instructions:
- Install `cc-tool <https://github.com/dashesy/cc-tool>`_ either from sources or from a package manager. - Install `cc-tool <https://github.com/dashesy/cc-tool>`_ either from sources or from a package manager.
- Connect the Zigbee to your PC/RaspberryPi in this way: USB -> CC debugger -> downloader cable -> CC2531 -> USB. - Connect the Zigbee to your PC/RaspberryPi in this way:
The debugger and the adapter should be connected *at the same time*. If the later ``cc-tool`` command throws up USB -> CC debugger -> downloader cable -> CC2531 -> USB
an error, put the device in sync while connected by pressing the _Reset_ button on the debugger. The debugger and the adapter should be connected *at the same time*. If the later ``cc-tool`` command throws
up an error, put the device in sync while connected by pressing the _Reset_ button on the debugger.
- Check where the device is mapped. On Linux it will usually be ``/dev/ttyACM0``. - Check where the device is mapped. On Linux it will usually be ``/dev/ttyACM0``.
- Download the latest `Z-Stack firmware <https://github.com/Koenkk/Z-Stack-firmware/tree/master/coordinator>`_ to your device. - Download the latest `Z-Stack firmware <https://github.com/Koenkk/Z-Stack-firmware/tree/master/coordinator>`_
Instructions for a CC2531 device: to your device. Instructions for a CC2531 device:
.. code-block:: shell .. code-block:: shell
@ -33,8 +36,8 @@ class ZigbeeMqttPlugin(MqttPlugin):
- You can disconnect your debugger and downloader cable once the firmware is flashed. - You can disconnect your debugger and downloader cable once the firmware is flashed.
- Install ``zigbee2mqtt``. First install a node/npm environment, then either install ``zigbee2mqtt`` manually or through your - Install ``zigbee2mqtt``. First install a node/npm environment, then either install ``zigbee2mqtt`` manually or
package manager. Manual instructions: through your package manager. Manual instructions:
.. code-block:: shell .. code-block:: shell
@ -82,7 +85,8 @@ class ZigbeeMqttPlugin(MqttPlugin):
- If it all goes fine, once the daemon is running and a new device is found you should see traces like this in - If it all goes fine, once the daemon is running and a new device is found you should see traces like this in
the output of ``zigbee2mqtt``:: the output of ``zigbee2mqtt``::
zigbee2mqtt:info 2019-11-09T12:19:56: Successfully interviewed '0x00158d0001dc126a', device has successfully been paired zigbee2mqtt:info 2019-11-09T12:19:56: Successfully interviewed '0x00158d0001dc126a', device has
successfully been paired
- You are now ready to use this integration. - You are now ready to use this integration.
@ -121,6 +125,55 @@ class ZigbeeMqttPlugin(MqttPlugin):
self.base_topic = base_topic self.base_topic = base_topic
self.timeout = timeout self.timeout = timeout
def _get_network_info(self, **kwargs):
self.logger.info('Fetching Zigbee network information')
client = None
mqtt_args = self._mqtt_args(**kwargs)
timeout = 30
if 'timeout' in mqtt_args:
timeout = mqtt_args.pop('timeout')
info = {
'state': None,
'info': {},
'config': {},
'devices': [],
'groups': [],
}
info_ready_events = {topic: threading.Event() for topic in info.keys()}
def _on_message():
def callback(_, __, msg):
topic = msg.topic.split('/')[-1]
if topic in info:
info[topic] = msg.payload.decode() if topic == 'state' else json.loads(msg.payload.decode())
info_ready_events[topic].set()
return callback
try:
host = mqtt_args.pop('host')
port = mqtt_args.pop('port')
client = self._get_client(**mqtt_args)
client.on_message = _on_message()
client.connect(host, port, keepalive=timeout)
client.subscribe(self.base_topic + '/bridge/#')
client.loop_start()
for event in info_ready_events.values():
info_ready = event.wait(timeout=timeout)
if not info_ready:
raise TimeoutError('A timeout occurred while fetching the Zigbee network information')
return info
finally:
try:
client.loop_stop()
client.disconnect()
except Exception as e:
self.logger.warning('Error on MQTT client disconnection: {}'.format(str(e)))
def _mqtt_args(self, host: Optional[str] = None, **kwargs): def _mqtt_args(self, host: Optional[str] = None, **kwargs):
if not host: if not host:
return { return {
@ -154,41 +207,171 @@ class ZigbeeMqttPlugin(MqttPlugin):
[ [
{ {
"dateCode": "20190608", "date_code": "20190608",
"friendly_name": "Coordinator", "friendly_name": "Coordinator",
"ieeeAddr": "0x00123456789abcde", "ieee_address": "0x00123456789abcde",
"lastSeen": 1579640601215, "network_address": 0,
"networkAddress": 0, "supported": false,
"softwareBuildID": "zStack12", "type": "Coordinator",
"type": "Coordinator" "interviewing": false,
"interviewing_completed": true,
"definition": null,
"endpoints": {
"13": {
"bindings": [],
"clusters": {
"input": ["genOta"],
"output": []
},
"output": []
}
}
},
{
"date_code": "20180906",
"friendly_name": "My Lightbulb",
"ieee_address": "0x00123456789abcdf",
"network_address": 52715,
"power_source": "Mains (single phase)",
"software_build_id": "5.127.1.26581",
"model_id": "LCT001",
"supported": true,
"interviewing": false,
"interviewing_completed": true,
"type": "Router",
"definition": {
"description": "Hue white and color ambiance E26/E27/E14",
"model": "9290012573A",
"vendor": "Philips",
"exposes": [
{
"features": [
{
"access": 7,
"description": "On/off state of this light",
"name": "state",
"property": "state",
"type": "binary",
"value_off": "OFF",
"value_on": "ON",
"value_toggle": "TOGGLE"
}, },
{ {
"dateCode": "20160906", "access": 7,
"friendly_name": "My Lightbulb", "description": "Brightness of this light",
"hardwareVersion": 1, "name": "brightness",
"ieeeAddr": "0x00123456789abcdf", "property": "brightness",
"lastSeen": 1579595191623, "type": "numeric",
"manufacturerID": 4107, "value_max": 254,
"manufacturerName": "Philips", "value_min": 0
"model": "8718696598283", },
"modelID": "LTW013", {
"networkAddress": 52715, "access": 7,
"powerSource": "Mains (single phase)", "description": "Color temperature of this light",
"softwareBuildID": "1.15.2_r19181", "name": "color_temp",
"type": "Router" "property": "color_temp",
"type": "numeric",
"unit": "mired",
"value_max": 500,
"value_min": 150
},
{
"description": "Color of this light in the CIE 1931 color space (x/y)",
"features": [
{
"access": 7,
"name": "x",
"property": "x",
"type": "numeric"
},
{
"access": 7,
"name": "y",
"property": "y",
"type": "numeric"
}
],
"name": "color_xy",
"property": "color",
"type": "composite"
}
],
"type": "light"
},
{
"access": 2,
"description": "Triggers an effect on the light (e.g. make light blink for a few seconds)",
"name": "effect",
"property": "effect",
"type": "enum",
"values": [
"blink",
"breathe",
"okay",
"channel_change",
"finish_effect",
"stop_effect"
]
},
{
"access": 1,
"description": "Link quality (signal strength)",
"name": "linkquality",
"property": "linkquality",
"type": "numeric",
"unit": "lqi",
"value_max": 255,
"value_min": 0
}
]
},
"endpoints": {
"11": {
"bindings": [],
"clusters": {
"input": [
"genBasic",
"genIdentify",
"genGroups",
"genScenes",
"genOnOff",
"genLevelCtrl",
"touchlink",
"lightingColorCtrl",
"manuSpecificUbisysDimmerSetup"
],
"output": [
"genOta"
]
},
"configured_reportings": []
},
"242": {
"bindings": [],
"clusters": {
"input": [
"greenPower"
],
"output": [
"greenPower"
]
},
"configured_reportings": []
}
}
} }
] ]
""" """
return self.publish( return self._get_network_info(**kwargs).get('devices')
topic=self._topic('bridge/config/devices/get'), msg='',
reply_topic=self._topic('bridge/config/devices'),
**self._mqtt_args(**kwargs))
def _permit_join_timeout_callback(self, permit: bool, **kwargs): def _permit_join_timeout_callback(self, permit: bool, **kwargs):
def callback(): def callback():
self.logger.info('Restoring permit_join state to {}'.format(permit)) self.logger.info('Restoring permit_join state to {}'.format(permit))
self.permit_join(permit, **kwargs) self.permit_join(permit, **kwargs)
return callback return callback
@action @action
@ -384,35 +567,148 @@ class ZigbeeMqttPlugin(MqttPlugin):
output.get('group_list', []) output.get('group_list', [])
@action @action
def groups(self, **kwargs): def groups(self, **kwargs) -> List[dict]:
""" """
Get the groups registered on the device. Get the groups registered on the device.
: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).
""" """
groups = self.publish(topic=self._topic('bridge/config/groups'), msg={}, return self._get_network_info(**kwargs).get('groups')
reply_topic=self._topic('bridge/log'),
**self._mqtt_args(**kwargs)).output.get('message', [])
# noinspection PyUnresolvedReferences @action
devices = { def info(self, **kwargs) -> dict:
device['ieeeAddr']: device """
for device in self.devices(**kwargs).output Get the information, configuration and state of the network.
}
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
(default: query the default configured device).
:return: Example:
.. code-block:: json
return [
{ {
'id': group['ID'], "state": "online",
'friendly_name': group['friendly_name'], "commit": "07cdc9d",
'optimistic': group.get('optimistic', False), "config": {
'devices': [ "advanced": {
devices[device.split('/')[0]] "adapter_concurrent": null,
for device in group.get('devices', []) "adapter_delay": null,
] "availability_blacklist": [],
"availability_blocklist": [],
"availability_passlist": [],
"availability_timeout": 0,
"availability_whitelist": [],
"cache_state": true,
"cache_state_persistent": true,
"cache_state_send_on_startup": true,
"channel": 11,
"elapsed": false,
"ext_pan_id": [
221,
221,
221,
221,
221,
221,
221,
221
],
"homeassistant_discovery_topic": "homeassistant",
"homeassistant_legacy_triggers": true,
"homeassistant_status_topic": "hass/status",
"last_seen": "disable",
"legacy_api": true,
"log_directory": "/opt/zigbee2mqtt/data/log/%TIMESTAMP%",
"log_file": "log.txt",
"log_level": "debug",
"log_output": [
"console",
"file"
],
"log_rotation": true,
"log_syslog": {},
"pan_id": 6754,
"report": false,
"soft_reset_timeout": 0,
"timestamp_format": "YYYY-MM-DD HH:mm:ss"
},
"ban": [],
"blocklist": [],
"device_options": {},
"devices": {
"0x00123456789abcdf": {
"friendly_name": "My Lightbulb"
}
},
"experimental": {
"output": "json"
},
"external_converters": [],
"groups": {},
"homeassistant": false,
"map_options": {
"graphviz": {
"colors": {
"fill": {
"coordinator": "#e04e5d",
"enddevice": "#fff8ce",
"router": "#4ea3e0"
},
"font": {
"coordinator": "#ffffff",
"enddevice": "#000000",
"router": "#ffffff"
},
"line": {
"active": "#009900",
"inactive": "#994444"
}
}
}
},
"mqtt": {
"base_topic": "zigbee2mqtt",
"force_disable_retain": false,
"include_device_information": false,
"server": "mqtt://localhost"
},
"passlist": [],
"permit_join": true,
"serial": {
"disable_led": false,
"port": "/dev/ttyUSB0"
},
"whitelist": []
},
"coordinator": {
"meta": {
"maintrel": 3,
"majorrel": 2,
"minorrel": 6,
"product": 0,
"revision": 20190608,
"transportrev": 2
},
"type": "zStack12"
},
"log_level": "debug",
"network": {
"channel": 11,
"extended_pan_id": "0xdddddddddddddddd",
"pan_id": 6754
},
"permit_join": true,
"version": "1.17.0"
}
"""
info = self._get_network_info(**kwargs)
return {
'state': info.get('state'),
'info': info.get('info'),
} }
for group in groups
]
@action @action
def group_add(self, name: str, id: Optional[int] = None, **kwargs): def group_add(self, name: str, id: Optional[int] = None, **kwargs):
@ -548,5 +844,4 @@ class ZigbeeMqttPlugin(MqttPlugin):
self.publish(topic=self._topic('bridge/unbind/' + source), self.publish(topic=self._topic('bridge/unbind/' + source),
msg=target, **self._mqtt_args(**kwargs)) msg=target, **self._mqtt_args(**kwargs))
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et: