platypush/platypush/plugins/mqtt.py

255 lines
11 KiB
Python

import io
import json
import os
import threading
from typing import Any, Optional, IO
from platypush.config import Config
from platypush.message import Message
from platypush.plugins import Plugin, action
class MqttPlugin(Plugin):
"""
This plugin allows you to send custom message to a message queue compatible
with the MQTT protocol, see http://mqtt.org/
Requires:
* **paho-mqtt** (``pip install paho-mqtt``)
"""
def __init__(self, host=None, port=1883, tls_cafile=None,
tls_certfile=None, tls_keyfile=None,
tls_version=None, tls_ciphers=None, tls_insecure=False,
username=None, password=None, client_id=None, timeout=None, **kwargs):
"""
:param host: If set, MQTT messages will by default routed to this host unless overridden in `send_message` (default: None)
:type host: str
:param port: If a default host is set, specify the listen port (default: 1883)
:type port: int
:param tls_cafile: If a default host is set and requires TLS/SSL, specify the certificate authority file (default: None)
:type tls_cafile: str
:param tls_certfile: If a default host is set and requires TLS/SSL, specify the certificate file (default: None)
:type tls_certfile: str
:param tls_keyfile: If a default host is set and requires TLS/SSL, specify the key file (default: None)
:type tls_keyfile: str
:param tls_version: If TLS/SSL is enabled on the MQTT server and it requires a certain TLS version, specify it
here (default: None). Supported versions: ``tls`` (automatic), ``tlsv1``, ``tlsv1.1``, ``tlsv1.2``.
:type tls_version: str
:param tls_ciphers: If a default host is set and requires TLS/SSL, specify the supported ciphers (default: None)
:type tls_ciphers: str
:param tls_insecure: Set to True to ignore TLS insecure warnings (default: False).
:type tls_insecure: bool
:param username: If a default host is set and requires user authentication, specify the username ciphers (default: None)
:type username: str
:param password: If a default host is set and requires user authentication, specify the password ciphers (default: None)
:type password: str
:param client_id: ID used to identify the client on the MQTT server (default: None).
If None is specified then ``Config.get('device_id')`` will be used.
:type client_id: str
:param timeout: Client timeout in seconds (default: None).
:type timeout: int
"""
super().__init__(**kwargs)
self.host = host
self.port = port
self.username = username
self.password = password
self.client_id = client_id or Config.get('device_id')
self.tls_cafile = self._expandpath(tls_cafile) if tls_cafile else None
self.tls_certfile = self._expandpath(tls_certfile) if tls_certfile else None
self.tls_keyfile = self._expandpath(tls_keyfile) if tls_keyfile else None
self.tls_version = self.get_tls_version(tls_version)
self.tls_insecure = tls_insecure
self.tls_ciphers = tls_ciphers
self.timeout = timeout
@staticmethod
def get_tls_version(version: Optional[str] = None):
import ssl
if not version:
return None
if type(version) == type(ssl.PROTOCOL_TLS):
return version
if isinstance(version, str):
version = version.lower()
if version == 'tls':
return ssl.PROTOCOL_TLS
if version == 'tlsv1':
return ssl.PROTOCOL_TLSv1
if version == 'tlsv1.1':
return ssl.PROTOCOL_TLSv1_1
if version == 'tlsv1.2':
return ssl.PROTOCOL_TLSv1_2
assert 'Unrecognized TLS version: {}'.format(version)
def _mqtt_args(self, **kwargs):
return {
'host': kwargs.get('host', self.host),
'port': kwargs.get('port', self.port),
'timeout': kwargs.get('timeout', self.timeout),
'tls_certfile': kwargs.get('tls_certfile', self.tls_certfile),
'tls_keyfile': kwargs.get('tls_keyfile', self.tls_keyfile),
'tls_version': kwargs.get('tls_version', self.tls_version),
'tls_ciphers': kwargs.get('tls_ciphers', self.tls_ciphers),
'username': kwargs.get('username', self.username),
'password': kwargs.get('password', self.password),
}
@staticmethod
def _expandpath(path: Optional[str] = None) -> Optional[str]:
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
def publish(self, topic: str, msg: Any, host: Optional[str] = None, port: Optional[int] = None,
reply_topic: Optional[str] = None, timeout: int = 60,
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):
"""
Sends a message to a topic.
:param topic: Topic/channel where the message will be delivered
:param msg: Message to be sent. It can be a list, a dict, or a Message object.
:param host: MQTT broker hostname/IP (default: default host configured on the plugin).
:param port: MQTT broker port (default: default port configured on the plugin).
:param reply_topic: If a ``reply_topic`` is specified, then the action will wait for a response on this topic.
:param timeout: If ``reply_topic`` is set, use this parameter to specify the maximum amount of time to
wait for a response (default: 60 seconds).
:param tls_cafile: If TLS/SSL is enabled on the MQTT server and the certificate requires a certificate authority
to authenticate it, `ssl_cafile` will point to the provided ca.crt file (default: None).
:param tls_certfile: If TLS/SSL is enabled on the MQTT server and a client certificate it required, specify it
here (default: None).
:param tls_keyfile: If TLS/SSL is enabled on the MQTT server and a client certificate key it required, specify
it here (default: None).
:param tls_version: If TLS/SSL is enabled on the MQTT server and it requires a certain TLS version, specify it
here (default: None). Supported versions: ``tls`` (automatic), ``tlsv1``, ``tlsv1.1``, ``tlsv1.2``.
:param tls_insecure: Set to True to ignore TLS insecure warnings (default: False).
:param tls_ciphers: If TLS/SSL is enabled on the MQTT server and an explicit list of supported ciphers is
required, specify it here (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).
"""
response_buffer = io.BytesIO()
client = None
try:
# Try to parse it as a platypush message or dump it to JSON from a dict/list
if isinstance(msg, (dict, list)):
msg = json.dumps(msg)
try:
msg = Message.build(json.loads(msg))
except Exception as e:
self.logger.debug(f'Not a valid JSON: {str(e)}')
host = host or self.host
port = port or self.port or 1883
assert host, 'No host specified'
client = self._get_client(tls_cafile=tls_cafile, tls_certfile=tls_certfile, tls_keyfile=tls_keyfile,
tls_version=tls_version, tls_ciphers=tls_ciphers, tls_insecure=tls_insecure,
username=username, password=password)
client.connect(host, port, keepalive=timeout)
response_received = threading.Event()
if reply_topic:
client.on_message = self._response_callback(reply_topic=reply_topic,
event=response_received,
buffer=response_buffer)
client.subscribe(reply_topic)
client.publish(topic, str(msg))
if not reply_topic:
return
client.loop_start()
ok = response_received.wait(timeout=timeout)
if not ok:
raise TimeoutError('Response timed out')
return response_buffer.getvalue()
finally:
response_buffer.close()
if client:
try:
client.loop_stop()
except Exception as e:
self.logger.warning(f'Could not stop client loop: {e}')
client.disconnect()
@staticmethod
def _response_callback(reply_topic: str, event: threading.Event, buffer: IO[bytes]):
def on_message(client, _, msg):
if msg.topic != reply_topic:
return
buffer.write(msg.payload)
client.loop_stop()
event.set()
return on_message
@action
def send_message(self, *args, **kwargs):
"""
Alias for :meth:`platypush.plugins.mqtt.MqttPlugin.publish`.
"""
return self.publish(*args, **kwargs)
# vim:sw=4:ts=4:et: