From 4bab9d26071c87066d2a1ba98b06f2d4c05f2357 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 29 Sep 2022 10:51:16 +0200 Subject: [PATCH 1/8] [#224] Implemented Wallabag integration --- CHANGELOG.md | 6 + docs/source/platypush/plugins/wallabag.rst | 5 + docs/source/plugins.rst | 1 + platypush/plugins/wallabag/__init__.py | 405 +++++++++++++++++++++ platypush/plugins/wallabag/manifest.yaml | 3 + platypush/schemas/wallabag.py | 147 ++++++++ 6 files changed, 567 insertions(+) create mode 100644 docs/source/platypush/plugins/wallabag.rst create mode 100644 platypush/plugins/wallabag/__init__.py create mode 100644 platypush/plugins/wallabag/manifest.yaml create mode 100644 platypush/schemas/wallabag.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee36b106..3804ba2b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2. +## [Unreleased] + +### Added + +- Added [Wallabag integration](https://git.platypush.tech/platypush/platypush/issues/224). + ## [0.23.6] - 2022-09-19 ### Fixed diff --git a/docs/source/platypush/plugins/wallabag.rst b/docs/source/platypush/plugins/wallabag.rst new file mode 100644 index 000000000..2a6a54aca --- /dev/null +++ b/docs/source/platypush/plugins/wallabag.rst @@ -0,0 +1,5 @@ +``wallabag`` +============ + +.. automodule:: platypush.plugins.wallabag + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 436232a29..ba4c0ace9 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -138,6 +138,7 @@ Plugins platypush/plugins/user.rst platypush/plugins/utils.rst platypush/plugins/variable.rst + platypush/plugins/wallabag.rst platypush/plugins/weather.buienradar.rst platypush/plugins/weather.darksky.rst platypush/plugins/weather.openweathermap.rst diff --git a/platypush/plugins/wallabag/__init__.py b/platypush/plugins/wallabag/__init__.py new file mode 100644 index 000000000..776e3afe6 --- /dev/null +++ b/platypush/plugins/wallabag/__init__.py @@ -0,0 +1,405 @@ +import json +import os +import pathlib +import requests +import time + +from datetime import datetime, timedelta +from typing import Iterable, List, Optional +from urllib.parse import urljoin + +from platypush.config import Config +from platypush.plugins import Plugin, action +from platypush.schemas.wallabag import WallabagEntrySchema + + +class WallabagPlugin(Plugin): + """ + Plugin to interact with Wallabag (https://wallabag.it), + an open-source alternative to Instapaper and Pocket. + """ + + _default_credentials_file = os.path.join( + str(Config.get('workdir')), 'wallabag', 'credentials.json' + ) + + def __init__( + self, + client_id: str, + client_secret: str, + server_url: str = 'https://wallabag.it', + username: Optional[str] = None, + password: Optional[str] = None, + credentials_file: str = _default_credentials_file, + **kwargs, + ): + """ + :param client_id: Client ID for your application - you can create one + at ``/developer``. + :param client_secret: Client secret for your application - you can + create one at ``/developer``. + :param server_url: Base URL of the Wallabag server (default: ``https://wallabag.it``). + :param username: Wallabag username. Only needed for the first login, + you can remove it afterwards. Alternatively, you can provide it + on the :meth:`.login` method. + :param password: Wallabag password. Only needed for the first login, + you can remove it afterwards. Alternatively, you can provide it + on the :meth:`.login` method. + :param credentials_file: Path to the file where the OAuth session + parameters will be stored (default: + ``/wallabag/credentials.json``). + """ + super().__init__(**kwargs) + self._client_id = client_id + self._client_secret = client_secret + self._server_url = server_url + self._username = username + self._password = password + self._credentials_file = os.path.expanduser(credentials_file) + self._session = {} + + def _oauth_open_saved_session(self): + try: + with open(self._credentials_file, 'r') as f: + data = json.load(f) + except Exception as e: + self.logger.warning('Could not load %s: %s', self._credentials_file, e) + return + + self._session = { + 'username': data['username'], + 'client_id': data.get('client_id', self._client_id), + 'client_secret': data.get('client_secret', self._client_secret), + 'access_token': data['access_token'], + 'refresh_token': data['refresh_token'], + } + + if data.get('expires_at') and time.time() > data['expires_at']: + self.logger.info('OAuth token expired, refreshing it') + self._oauth_refresh_token() + + def _oauth_refresh_token(self): + url = urljoin(self._server_url, '/oauth/v2/token') + rs = requests.post( + url, + json={ + 'grant_type': 'refresh_token', + 'client_id': self._client_id, + 'client_secret': self._client_secret, + 'access_token': self._session['access_token'], + 'refresh_token': self._session['refresh_token'], + }, + ) + + rs.raise_for_status() + rs = rs.json() + self._session.update( + { + 'access_token': rs['access_token'], + 'refresh_token': rs['refresh_token'], + 'expires_at': ( + int( + ( + datetime.now() + timedelta(seconds=rs['expires_in']) + ).timestamp() + ) + if rs.get('expires_in') + else None + ), + } + ) + + self._oauth_flush_session() + + def _oauth_create_new_session(self, username: str, password: str): + url = urljoin(self._server_url, '/oauth/v2/token') + rs = requests.post( + url, + json={ + 'grant_type': 'password', + 'client_id': self._client_id, + 'client_secret': self._client_secret, + 'username': username, + 'password': password, + }, + ) + + rs.raise_for_status() + rs = rs.json() + self._session = { + 'client_id': self._client_id, + 'client_secret': self._client_secret, + 'username': username, + 'access_token': rs['access_token'], + 'refresh_token': rs['refresh_token'], + 'expires_at': ( + int((datetime.now() + timedelta(seconds=rs['expires_in'])).timestamp()) + if rs.get('expires_in') + else None + ), + } + + self._oauth_flush_session() + + def _oauth_flush_session(self): + pathlib.Path(self._credentials_file).parent.mkdir(parents=True, exist_ok=True) + + pathlib.Path(self._credentials_file).touch(mode=0o600, exist_ok=True) + with open(self._credentials_file, 'w') as f: + f.write(json.dumps(self._session)) + + @action + def login(self, username: Optional[str] = None, password: Optional[str] = None): + """ + Create a new user session if not logged in. + + :param username: Default ``username`` override. + :param password: Default ``password`` override. + """ + self._oauth_open_saved_session() + if self._session: + return + + username = username or self._username + password = password or self._password + assert ( + username and password + ), 'No stored user session and no username/password provided' + + self._oauth_create_new_session(username, password) + + def _request(self, url: str, method: str, *args, as_json=True, **kwargs): + url = urljoin(self._server_url, f'api/{url}') + func = getattr(requests, method.lower()) + self.login() + kwargs['headers'] = { + **kwargs.get('headers', {}), + 'Authorization': f'Bearer {self._session["access_token"]}', + } + + rs = func(url, *args, **kwargs) + rs.raise_for_status() + return rs.json() if as_json else rs.text + + @action + def list( + self, + archived: bool = True, + starred: bool = False, + sort: str = 'created', + descending: bool = False, + page: int = 1, + limit: int = 30, + tags: Optional[Iterable[str]] = None, + since: Optional[int] = None, + full: bool = True, + ) -> List[dict]: + """ + List saved links. + + :param archived: Include archived items (default: ``True``). + :param starred: Include only starred items (default: ``False``). + :param sort: Timestamp sort criteria. Supported: ``created``, + ``updated``, ``archived`` (default: ``created``). + :param descending: Sort in descending order (default: ``False``). + :param page: Results page to be retrieved (default: ``1``). + :param limit: Maximum number of entries per page (default: ``30``). + :param tags: Filter by a list of tags. + :param since: Return entries created after this timestamp (as a UNIX + timestamp). + :param full: Include the full parsed body of the saved entry. + :return: .. schema:: wallabag.WallabagEntrySchema(many=True) + """ + rs = self._request( + '/entries.json', + method='get', + params={ + 'archived': int(archived), + 'starred': int(starred), + 'sort': sort, + 'order': 'desc' if descending else 'asc', + 'page': page, + 'perPage': limit, + 'tags': ','.join(tags or []), + 'since': since or 0, + 'detail': 'full' if full else 'metadata', + }, + ) + + return WallabagEntrySchema().dump( + rs.get('_embedded', {}).get('items', []), many=True + ) + + @action + def search( + self, + term: str, + page: int = 1, + limit: int = 30, + ) -> List[dict]: + """ + Search links by some text. + + :param term: Term to be searched. + :param page: Results page to be retrieved (default: ``1``). + :param limit: Maximum number of entries per page (default: ``30``). + :return: .. schema:: wallabag.WallabagEntrySchema(many=True) + """ + rs = self._request( + '/search.json', + method='get', + params={ + 'term': term, + 'page': page, + 'perPage': limit, + }, + ) + + return WallabagEntrySchema().dump( + rs.get('_embedded', {}).get('items', []), many=True + ) + + @action + def get(self, id: int) -> Optional[dict]: + """ + Get the content and metadata of a link by ID. + + :param id: Entry ID. + :return: .. schema:: wallabag.WallabagEntrySchema + """ + rs = self._request(f'/entries/{id}.json', method='get') + return WallabagEntrySchema().dump(rs) # type: ignore + + @action + def export(self, id: int, file: str, format: str = 'txt'): + """ + Export a saved entry to a file in the specified format. + + :param id: Entry ID. + :param file: Output filename. + :param format: Output format. Supported: ``txt``, ``xml``, ``csv``, + ``pdf``, ``epub`` and ``mobi`` (default: ``txt``). + """ + rs = self._request( + f'/entries/{id}/export.{format}', method='get', as_json=False + ) + + if isinstance(rs, str): + rs = rs.encode() + with open(os.path.expanduser(file), 'wb') as f: + f.write(rs) + + @action + def save( + self, + url: str, + title: Optional[str] = None, + content: Optional[str] = None, + tags: Optional[Iterable[str]] = None, + authors: Optional[Iterable[str]] = None, + archived: bool = False, + starred: bool = False, + public: bool = False, + language: Optional[str] = None, + preview_picture: Optional[str] = None, + ) -> Optional[dict]: + """ + Save a link to Wallabag. + + :param url: URL to be saved. + :param title: Entry title (default: parsed from the page content). + :param content: Entry content (default: parsed from the entry itself). + :param tags: List of tags to attach to the entry. + :param authors: List of authors of the entry (default: parsed from the + page content). + :param archived: Whether the entry should be created in the archive + (default: ``False``). + :param starred: Whether the entry should be starred (default: + ``False``). + :param public: Whether the entry should be publicly available. If so, a + public URL will be generated (default: ``False``). + :param language: Language of the entry. + :param preview_picture: URL of a picture to be used for the preview + (default: parsed from the page itself). + :return: .. schema:: wallabag.WallabagEntrySchema + """ + rs = self._request( + '/entries.json', + method='post', + json={ + 'url': url, + 'title': title, + 'content': content, + 'tags': ','.join(tags or []), + 'authors': ','.join(authors or []), + 'archive': int(archived), + 'starred': int(starred), + 'public': int(public), + 'language': language, + 'preview_picture': preview_picture, + }, + ) + + return WallabagEntrySchema().dump(rs) # type: ignore + + @action + def update( + self, + id: int, + title: Optional[str] = None, + content: Optional[str] = None, + tags: Optional[Iterable[str]] = None, + authors: Optional[Iterable[str]] = None, + archived: bool = False, + starred: bool = False, + public: bool = False, + language: Optional[str] = None, + preview_picture: Optional[str] = None, + ) -> Optional[dict]: + """ + Update a link entry saved to Wallabag. + + :param id: Entry ID. + :param title: New entry title. + :param content: New entry content. + :param tags: List of tags to attach to the entry. + :param authors: List of authors of the entry. + :param archived: Archive/unarchive the entry. + :param starred: Star/unstar the entry. + :param public: Mark the entry as public/private. + :param language: Change the language of the entry. + :param preview_picture: Change the preview picture URL. + :return: .. schema:: wallabag.WallabagEntrySchema + """ + rs = self._request( + f'/entries/{id}.json', + method='patch', + json={ + 'title': title, + 'content': content, + 'tags': ','.join(tags or []), + 'authors': ','.join(authors or []), + 'archive': int(archived), + 'starred': int(starred), + 'public': int(public), + 'language': language, + 'preview_picture': preview_picture, + }, + ) + + return WallabagEntrySchema().dump(rs) # type: ignore + + @action + def delete(self, id: int) -> Optional[dict]: + """ + Delete an entry by ID. + + :param id: Entry ID. + :return: .. schema:: wallabag.WallabagEntrySchema + """ + rs = self._request( + f'/entries/{id}.json', + method='delete', + ) + + return WallabagEntrySchema().dump(rs) # type: ignore diff --git a/platypush/plugins/wallabag/manifest.yaml b/platypush/plugins/wallabag/manifest.yaml new file mode 100644 index 000000000..cfa48a90e --- /dev/null +++ b/platypush/plugins/wallabag/manifest.yaml @@ -0,0 +1,3 @@ +manifest: + package: platypush.plugins.wallabag + type: plugin diff --git a/platypush/schemas/wallabag.py b/platypush/schemas/wallabag.py new file mode 100644 index 000000000..337e13c52 --- /dev/null +++ b/platypush/schemas/wallabag.py @@ -0,0 +1,147 @@ +from marshmallow import Schema, fields + +from platypush.schemas import DateTime + + +class WallabagSchema(Schema): + pass + + +class WallabagAnnotationSchema(WallabagSchema): + id = fields.Integer( + required=True, + dump_only=True, + metadata={'example': 2345}, + ) + + text = fields.String( + attribute='quote', + metadata={ + 'example': 'Some memorable quote', + }, + ) + + comment = fields.String( + attribute='text', + metadata={ + 'example': 'My comment on this memorable quote', + }, + ) + + ranges = fields.Function( + lambda data: [ + [int(r['startOffset']), int(r['endOffset'])] for r in data.get('ranges', []) + ], + metadata={ + 'example': [[100, 180]], + }, + ) + + created_at = DateTime( + metadata={ + 'description': 'When the annotation was created', + }, + ) + + updated_at = DateTime( + metadata={ + 'description': 'When the annotation was last updated', + }, + ) + + +class WallabagEntrySchema(WallabagSchema): + id = fields.Integer( + required=True, + dump_only=True, + metadata={'example': 1234}, + ) + + url = fields.URL( + required=True, + metadata={ + 'description': 'Original URL', + 'example': 'https://example.com/article/some-title', + }, + ) + + preview_picture = fields.URL( + metadata={ + 'description': 'Preview picture URL', + 'example': 'https://example.com/article/some-title.jpg', + }, + ) + + is_archived = fields.Boolean() + is_starred = fields.Boolean() + is_public = fields.Boolean() + mimetype = fields.String( + metadata={ + 'example': 'text/html', + }, + ) + + title = fields.String( + metadata={ + 'description': 'Title of the saved page', + }, + ) + + content = fields.String( + metadata={ + 'description': 'Parsed content', + } + ) + + language = fields.String( + metadata={ + 'example': 'en', + } + ) + + annotations = fields.List(fields.Nested(WallabagAnnotationSchema)) + + published_by = fields.List( + fields.String, + metadata={ + 'example': ['Author 1', 'Author 2'], + }, + ) + + tags = fields.Function( + lambda data: [tag['label'] for tag in data.get('tags', [])], + metadata={ + 'example': ['tech', 'programming'], + }, + ) + + reading_time = fields.Integer( + metadata={ + 'description': 'Estimated reading time, in minutes', + 'example': 10, + } + ) + + created_at = DateTime( + metadata={ + 'description': 'When the entry was created', + }, + ) + + updated_at = DateTime( + metadata={ + 'description': 'When the entry was last updated', + }, + ) + + starred_at = DateTime( + metadata={ + 'description': 'If the entry is starred, when was it last marked', + }, + ) + + published_at = DateTime( + metadata={ + 'description': 'When the entry was initially published', + }, + ) From 82ab7face2543145cfab2a3e9f6a5a367a8de9de Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 30 Sep 2022 03:10:37 +0200 Subject: [PATCH 2/8] A more robust logic to detect the webserver local bind address --- platypush/backend/http/app/utils.py | 91 +++++++++++++++++++---------- 1 file changed, 60 insertions(+), 31 deletions(-) diff --git a/platypush/backend/http/app/utils.py b/platypush/backend/http/app/utils.py index 17c2a4916..70113b456 100644 --- a/platypush/backend/http/app/utils.py +++ b/platypush/backend/http/app/utils.py @@ -35,13 +35,15 @@ def logger(): 'format': '%(asctime)-15s|%(levelname)5s|%(name)s|%(message)s', } - level = (Config.get('backend.http') or {}).get('logging') or \ - (Config.get('logging') or {}).get('level') + level = (Config.get('backend.http') or {}).get('logging') or ( + Config.get('logging') or {} + ).get('level') filename = (Config.get('backend.http') or {}).get('filename') if level: - log_args['level'] = getattr(logging, level.upper()) \ - if isinstance(level, str) else level + log_args['level'] = ( + getattr(logging, level.upper()) if isinstance(level, str) else level + ) if filename: log_args['filename'] = filename @@ -65,6 +67,7 @@ def get_message_response(msg): # noinspection PyProtectedMember def get_http_port(): from platypush.backend.http import HttpBackend + http_conf = Config.get('backend.http') return http_conf.get('port', HttpBackend._DEFAULT_HTTP_PORT) @@ -72,6 +75,7 @@ def get_http_port(): # noinspection PyProtectedMember def get_websocket_port(): from platypush.backend.http import HttpBackend + http_conf = Config.get('backend.http') return http_conf.get('websocket_port', HttpBackend._DEFAULT_WEBSOCKET_PORT) @@ -89,17 +93,13 @@ def send_message(msg, wait_for_response=True): if isinstance(msg, Request) and wait_for_response: response = get_message_response(msg) - logger().debug('Processing response on the HTTP backend: {}'. - format(response)) + logger().debug('Processing response on the HTTP backend: {}'.format(response)) return response def send_request(action, wait_for_response=True, **kwargs): - msg = { - 'type': 'request', - 'action': action - } + msg = {'type': 'request', 'action': action} if kwargs: msg['args'] = kwargs @@ -113,8 +113,10 @@ def _authenticate_token(): if 'X-Token' in request.headers: user_token = request.headers['X-Token'] - elif 'Authorization' in request.headers and request.headers['Authorization'].startswith('Bearer '): - user_token = request.headers['Authorization'][len('Bearer '):] + elif 'Authorization' in request.headers and request.headers[ + 'Authorization' + ].startswith('Bearer '): + user_token = request.headers['Authorization'][7:] elif 'token' in request.args: user_token = request.args.get('token') else: @@ -176,7 +178,10 @@ def _authenticate_csrf_token(): if user is None: return False - return session.csrf_token is None or request.form.get('csrf_token') == session.csrf_token + return ( + session.csrf_token is None + or request.form.get('csrf_token') == session.csrf_token + ) def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=False): @@ -208,7 +213,9 @@ def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=Fals if session_auth_ok: return f(*args, **kwargs) - return redirect('/login?redirect=' + (redirect_page or request.url), 307) + return redirect( + '/login?redirect=' + (redirect_page or request.url), 307 + ) # CSRF token check if check_csrf_token: @@ -217,15 +224,22 @@ def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=Fals return abort(403, 'Invalid or missing csrf_token') if n_users == 0 and 'session' not in skip_methods: - return redirect('/register?redirect=' + (redirect_page or request.url), 307) + return redirect( + '/register?redirect=' + (redirect_page or request.url), 307 + ) - if ('http' not in skip_methods and http_auth_ok) or \ - ('token' not in skip_methods and token_auth_ok) or \ - ('session' not in skip_methods and session_auth_ok): + if ( + ('http' not in skip_methods and http_auth_ok) + or ('token' not in skip_methods and token_auth_ok) + or ('session' not in skip_methods and session_auth_ok) + ): return f(*args, **kwargs) - return Response('Authentication required', 401, - {'WWW-Authenticate': 'Basic realm="Login required"'}) + return Response( + 'Authentication required', + 401, + {'WWW-Authenticate': 'Basic realm="Login required"'}, + ) return wrapper @@ -233,42 +247,57 @@ def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=Fals def get_routes(): - routes_dir = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'routes') + routes_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'routes') routes = [] base_module = '.'.join(__name__.split('.')[:-1]) - for path, dirs, files in os.walk(routes_dir): + for path, _, files in os.walk(routes_dir): for f in files: if f.endswith('.py'): mod_name = '.'.join( - (base_module + '.' + os.path.join(path, f).replace( - os.path.dirname(__file__), '')[1:].replace(os.sep, '.')).split('.') - [:(-2 if f == '__init__.py' else -1)]) + ( + base_module + + '.' + + os.path.join(path, f) + .replace(os.path.dirname(__file__), '')[1:] + .replace(os.sep, '.') + ).split('.')[: (-2 if f == '__init__.py' else -1)] + ) try: mod = importlib.import_module(mod_name) if hasattr(mod, '__routes__'): routes.extend(mod.__routes__) except Exception as e: - logger().warning('Could not import routes from {}/{}: {}: {}'. - format(path, f, type(e), str(e))) + logger().warning( + 'Could not import routes from {}/{}: {}: {}'.format( + path, f, type(e), str(e) + ) + ) return routes def get_local_base_url(): http_conf = Config.get('backend.http') or {} - return '{proto}://localhost:{port}'.format( + bind_address = http_conf.get('bind_address') + if not bind_address or bind_address == '0.0.0.0': + bind_address = 'localhost' + + return '{proto}://{host}:{port}'.format( proto=('https' if http_conf.get('ssl_cert') else 'http'), - port=get_http_port()) + host=bind_address, + port=get_http_port(), + ) def get_remote_base_url(): http_conf = Config.get('backend.http') or {} return '{proto}://{host}:{port}'.format( proto=('https' if http_conf.get('ssl_cert') else 'http'), - host=get_ip_or_hostname(), port=get_http_port()) + host=get_ip_or_hostname(), + port=get_http_port(), + ) # vim:sw=4:ts=4:et: From fef7aff245e3cb34f085af89c232de1c4031c727 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 30 Sep 2022 10:41:56 +0200 Subject: [PATCH 3/8] LINT fixes for mpv plugin --- platypush/plugins/media/mpv/__init__.py | 166 +++++++++++++++--------- 1 file changed, 104 insertions(+), 62 deletions(-) diff --git a/platypush/plugins/media/mpv/__init__.py b/platypush/plugins/media/mpv/__init__.py index f032f3289..19853cbae 100644 --- a/platypush/plugins/media/mpv/__init__.py +++ b/platypush/plugins/media/mpv/__init__.py @@ -3,8 +3,15 @@ import threading from platypush.context import get_bus from platypush.plugins.media import PlayerState, MediaPlugin -from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ - MediaPauseEvent, MediaStopEvent, NewPlayingMediaEvent, MediaSeekEvent, MediaResumeEvent +from platypush.message.event.media import ( + MediaPlayEvent, + MediaPlayRequestEvent, + MediaPauseEvent, + MediaStopEvent, + NewPlayingMediaEvent, + MediaSeekEvent, + MediaResumeEvent, +) from platypush.plugins import action @@ -66,29 +73,58 @@ class MediaMpvPlugin(MediaPlugin): def _event_callback(self): def callback(event): - from mpv import MpvEventID as Event - from mpv import MpvEventEndFile as EndFile + from mpv import ( + MpvEvent, + MpvEventID as Event, + MpvEventEndFile as EndFile, + ) self.logger.info('Received mpv event: {}'.format(event)) + if isinstance(event, MpvEvent): + event = event.as_dict() + evt = event.get('event_id') if not evt: return - if (evt == Event.FILE_LOADED or evt == Event.START_FILE) and self._get_current_resource(): + if ( + evt == Event.FILE_LOADED or evt == Event.START_FILE + ) and self._get_current_resource(): self._playback_rebounce_event.set() - self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource(), - title=self._player.filename) + self._post_event( + NewPlayingMediaEvent, + resource=self._get_current_resource(), + title=self._player.filename, + ) elif evt == Event.PLAYBACK_RESTART: self._playback_rebounce_event.set() - self._post_event(MediaPlayEvent, resource=self._get_current_resource(), title=self._player.filename) + self._post_event( + MediaPlayEvent, + resource=self._get_current_resource(), + title=self._player.filename, + ) elif evt == Event.PAUSE: - self._post_event(MediaPauseEvent, resource=self._get_current_resource(), title=self._player.filename) + self._post_event( + MediaPauseEvent, + resource=self._get_current_resource(), + title=self._player.filename, + ) elif evt == Event.UNPAUSE: - self._post_event(MediaResumeEvent, resource=self._get_current_resource(), title=self._player.filename) - elif evt == Event.SHUTDOWN or evt == Event.IDLE or ( - evt == Event.END_FILE and event.get('event', {}).get('reason') in - [EndFile.EOF, EndFile.ABORTED, EndFile.QUIT]): + self._post_event( + MediaResumeEvent, + resource=self._get_current_resource(), + title=self._player.filename, + ) + elif ( + evt == Event.SHUTDOWN + or evt == Event.IDLE + or ( + evt == Event.END_FILE + and event.get('event', {}).get('reason') + in [EndFile.EOF, EndFile.ABORTED, EndFile.QUIT] + ) + ): playback_rebounced = self._playback_rebounce_event.wait(timeout=0.5) if playback_rebounced: self._playback_rebounce_event.clear() @@ -147,7 +183,7 @@ class MediaMpvPlugin(MediaPlugin): @action def pause(self): - """ Toggle the paused state """ + """Toggle the paused state""" if not self._player: return None, 'No mpv instance is running' @@ -156,7 +192,7 @@ class MediaMpvPlugin(MediaPlugin): @action def quit(self): - """ Stop and quit the player """ + """Stop and quit the player""" if not self._player: return None, 'No mpv instance is running' @@ -167,19 +203,19 @@ class MediaMpvPlugin(MediaPlugin): @action def stop(self): - """ Stop and quit the player """ + """Stop and quit the player""" return self.quit() @action def voldown(self, step=10.0): - """ Volume down by (default: 10)% """ + """Volume down by (default: 10)%""" if not self._player: return None, 'No mpv instance is running' return self.set_volume(self._player.volume - step) @action def volup(self, step=10.0): - """ Volume up by (default: 10)% """ + """Volume up by (default: 10)%""" if not self._player: return None, 'No mpv instance is running' return self.set_volume(self._player.volume + step) @@ -211,14 +247,13 @@ class MediaMpvPlugin(MediaPlugin): return None, 'No mpv instance is running' if not self._player.seekable: return None, 'The resource is not seekable' - pos = min(self._player.time_pos + self._player.time_remaining, - max(0, position)) + pos = min(self._player.time_pos + self._player.time_remaining, max(0, position)) self._player.time_pos = pos return self.status() @action def back(self, offset=30.0): - """ Back by (default: 30) seconds """ + """Back by (default: 30) seconds""" if not self._player: return None, 'No mpv instance is running' if not self._player.seekable: @@ -228,47 +263,44 @@ class MediaMpvPlugin(MediaPlugin): @action def forward(self, offset=30.0): - """ Forward by (default: 30) seconds """ + """Forward by (default: 30) seconds""" if not self._player: return None, 'No mpv instance is running' if not self._player.seekable: return None, 'The resource is not seekable' - pos = min(self._player.time_pos + self._player.time_remaining, - self._player.time_pos + offset) + pos = min( + self._player.time_pos + self._player.time_remaining, + self._player.time_pos + offset, + ) return self.seek(pos) @action def next(self): - """ Play the next item in the queue """ + """Play the next item in the queue""" if not self._player: return None, 'No mpv instance is running' self._player.playlist_next() @action def prev(self): - """ Play the previous item in the queue """ + """Play the previous item in the queue""" if not self._player: return None, 'No mpv instance is running' self._player.playlist_prev() @action def toggle_subtitles(self, visible=None): - """ Toggle the subtitles visibility """ + """Toggle the subtitles visibility""" return self.toggle_property('sub_visibility') @action def add_subtitles(self, filename): - """ Add a subtitles file """ + """Add a subtitles file""" return self._player.sub_add(filename) - @action - def remove_subtitles(self, sub_id): - """ Remove a subtitles track by id """ - return self._player.sub_remove(sub_id) - @action def toggle_fullscreen(self): - """ Toggle the fullscreen mode """ + """Toggle the fullscreen mode""" return self.toggle_property('fullscreen') # noinspection PyShadowingBuiltins @@ -319,15 +351,17 @@ class MediaMpvPlugin(MediaPlugin): @action def set_subtitles(self, filename, *args, **kwargs): - """ Sets media subtitles from filename """ + """Sets media subtitles from filename""" # noinspection PyTypeChecker return self.set_property(subfile=filename, sub_visibility=True) @action - def remove_subtitles(self): - """ Removes (hides) the subtitles """ + def remove_subtitles(self, sub_id=None): + """Removes (hides) the subtitles""" if not self._player: return None, 'No mpv instance is running' + if sub_id: + return self._player.sub_remove(sub_id) self._player.sub_visibility = False @action @@ -350,7 +384,7 @@ class MediaMpvPlugin(MediaPlugin): @action def mute(self): - """ Toggle mute state """ + """Toggle mute state""" if not self._player: return None, 'No mpv instance is running' mute = not self._player.mute @@ -382,28 +416,35 @@ class MediaMpvPlugin(MediaPlugin): return {'state': PlayerState.STOP.value} return { - 'audio_channels': getattr(self._player, 'audio_channels'), - 'audio_codec': getattr(self._player, 'audio_codec_name'), - 'delay': getattr(self._player, 'delay'), - 'duration': getattr(self._player, 'playback_time', 0) + getattr(self._player, 'playtime_remaining', 0) - if getattr(self._player, 'playtime_remaining') else None, - 'filename': getattr(self._player, 'filename'), - 'file_size': getattr(self._player, 'file_size'), - 'fullscreen': getattr(self._player, 'fs'), - 'mute': getattr(self._player, 'mute'), - 'name': getattr(self._player, 'name'), - 'pause': getattr(self._player, 'pause'), - 'percent_pos': getattr(self._player, 'percent_pos'), - 'position': getattr(self._player, 'playback_time'), - 'seekable': getattr(self._player, 'seekable'), - 'state': (PlayerState.PAUSE.value if self._player.pause else PlayerState.PLAY.value), - 'title': getattr(self._player, 'media_title') or getattr(self._player, 'filename'), + 'audio_channels': getattr(self._player, 'audio_channels', None), + 'audio_codec': getattr(self._player, 'audio_codec_name', None), + 'delay': getattr(self._player, 'delay', None), + 'duration': getattr(self._player, 'playback_time', 0) + + getattr(self._player, 'playtime_remaining', 0) + if getattr(self._player, 'playtime_remaining', None) + else None, + 'filename': getattr(self._player, 'filename', None), + 'file_size': getattr(self._player, 'file_size', None), + 'fullscreen': getattr(self._player, 'fs', None), + 'mute': getattr(self._player, 'mute', None), + 'name': getattr(self._player, 'name', None), + 'pause': getattr(self._player, 'pause', None), + 'percent_pos': getattr(self._player, 'percent_pos', None), + 'position': getattr(self._player, 'playback_time', None), + 'seekable': getattr(self._player, 'seekable', None), + 'state': ( + PlayerState.PAUSE.value + if self._player.pause + else PlayerState.PLAY.value + ), + 'title': getattr(self._player, 'media_title', None) + or getattr(self._player, 'filename', None), 'url': self._get_current_resource(), - 'video_codec': getattr(self._player, 'video_codec'), - 'video_format': getattr(self._player, 'video_format'), - 'volume': getattr(self._player, 'volume'), - 'volume_max': getattr(self._player, 'volume_max'), - 'width': getattr(self._player, 'width'), + 'video_codec': getattr(self._player, 'video_codec', None), + 'video_format': getattr(self._player, 'video_format', None), + 'volume': getattr(self._player, 'volume', None), + 'volume_max': getattr(self._player, 'volume_max', None), + 'width': getattr(self._player, 'width', None), } def on_stop(self, callback): @@ -413,12 +454,13 @@ class MediaMpvPlugin(MediaPlugin): if not self._player or not self._player.stream_path: return - return ('file://' if os.path.isfile(self._player.stream_path) - else '') + self._player.stream_path + return ( + 'file://' if os.path.isfile(self._player.stream_path) else '' + ) + self._player.stream_path def _get_resource(self, resource): if self._is_youtube_resource(resource): - return resource # mpv can handle YouTube streaming natively + return resource # mpv can handle YouTube streaming natively return super()._get_resource(resource) From ae226a5b01de271419ce0d2db207c3cfc71b24df Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 30 Sep 2022 10:50:28 +0200 Subject: [PATCH 4/8] Added `tts.mimic3` integration. Closes: #226 --- CHANGELOG.md | 1 + docs/source/platypush/plugins/tts.mimic3.rst | 5 + docs/source/plugins.rst | 1 + .../http/app/routes/plugins/tts/__init__.py | 0 .../http/app/routes/plugins/tts/mimic3.py | 46 +++++++ platypush/plugins/tts/mimic3/__init__.py | 119 ++++++++++++++++++ platypush/plugins/tts/mimic3/manifest.yaml | 6 + platypush/schemas/tts/mimic3.py | 51 ++++++++ 8 files changed, 229 insertions(+) create mode 100644 docs/source/platypush/plugins/tts.mimic3.rst create mode 100644 platypush/backend/http/app/routes/plugins/tts/__init__.py create mode 100644 platypush/backend/http/app/routes/plugins/tts/mimic3.py create mode 100644 platypush/plugins/tts/mimic3/__init__.py create mode 100644 platypush/plugins/tts/mimic3/manifest.yaml create mode 100644 platypush/schemas/tts/mimic3.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3804ba2b7..e2f4333fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ reported only starting from v0.20.2. ### Added - Added [Wallabag integration](https://git.platypush.tech/platypush/platypush/issues/224). +- Added [Mimic3 TTS integration](https://git.platypush.tech/platypush/platypush/issues/226). ## [0.23.6] - 2022-09-19 diff --git a/docs/source/platypush/plugins/tts.mimic3.rst b/docs/source/platypush/plugins/tts.mimic3.rst new file mode 100644 index 000000000..d6e280b32 --- /dev/null +++ b/docs/source/platypush/plugins/tts.mimic3.rst @@ -0,0 +1,5 @@ +``tts.mimic3`` +============== + +.. automodule:: platypush.plugins.tts.mimic3 + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index ba4c0ace9..8c4241897 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -132,6 +132,7 @@ Plugins platypush/plugins/trello.rst platypush/plugins/tts.rst platypush/plugins/tts.google.rst + platypush/plugins/tts.mimic3.rst platypush/plugins/tv.samsung.ws.rst platypush/plugins/twilio.rst platypush/plugins/udp.rst diff --git a/platypush/backend/http/app/routes/plugins/tts/__init__.py b/platypush/backend/http/app/routes/plugins/tts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/platypush/backend/http/app/routes/plugins/tts/mimic3.py b/platypush/backend/http/app/routes/plugins/tts/mimic3.py new file mode 100644 index 000000000..8bf37f6d4 --- /dev/null +++ b/platypush/backend/http/app/routes/plugins/tts/mimic3.py @@ -0,0 +1,46 @@ +import requests +from urllib.parse import urljoin + +from flask import abort, request, Blueprint + +from platypush.backend.http.app import template_folder + +mimic3 = Blueprint('mimic3', __name__, template_folder=template_folder) + +# Declare routes list +__routes__ = [ + mimic3, +] + + +@mimic3.route('/tts/mimic3/say', methods=['GET']) +def proxy_tts_request(): + """ + This route is used to proxy the POST request to the Mimic3 TTS server + through a GET, so it can be easily processed as a URL through a media + plugin. + """ + required_args = { + 'text', + 'server_url', + 'voice', + } + + missing_args = required_args.difference(set(request.args.keys())) + if missing_args: + abort(400, f'Missing parameters: {missing_args}') + + args = {arg: request.args[arg] for arg in required_args} + + rs = requests.post( + urljoin(args['server_url'], '/api/tts'), + data=args['text'], + params={ + 'voice': args['voice'], + }, + ) + + return rs.content + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/tts/mimic3/__init__.py b/platypush/plugins/tts/mimic3/__init__.py new file mode 100644 index 000000000..6bcf80157 --- /dev/null +++ b/platypush/plugins/tts/mimic3/__init__.py @@ -0,0 +1,119 @@ +import requests +from typing import Optional +from urllib.parse import urljoin, urlencode +from platypush.backend.http.app.utils import get_local_base_url + +from platypush.context import get_backend +from platypush.plugins import action +from platypush.plugins.tts import TtsPlugin +from platypush.schemas.tts.mimic3 import Mimic3VoiceSchema + + +class TtsMimic3Plugin(TtsPlugin): + """ + TTS plugin that uses the `Mimic3 webserver + `_ provided by `Mycroft + `_ as a text-to-speech engine. + + The easiest way to deploy a Mimic3 instance is probably via Docker: + + .. code-block:: bash + + $ mkdir -p "$HOME/.local/share/mycroft/mimic3" + $ chmod a+rwx "$HOME/.local/share/mycroft/mimic3" + $ docker run --rm \ + -p 59125:59125 \ + -v "%h/.local/share/mycroft/mimic3:/home/mimic3/.local/share/mycroft/mimic3" \ + 'mycroftai/mimic3' + + Requires: + + * At least a *media plugin* (see + :class:`platypush.plugins.media.MediaPlugin`) enabled/configured - + used for speech playback. + * The ``http`` backend (:class:`platypush.backend.http.HttpBackend`) + enabled - used for proxying the API calls. + + """ + + def __init__( + self, + server_url: str, + voice: str = 'en_UK/apope_low', + media_plugin: Optional[str] = None, + player_args: Optional[dict] = None, + **kwargs + ): + """ + :param server_url: Base URL of the web server that runs the Mimic3 engine. + :param voice: Default voice to be used (default: ``en_UK/apope_low``). + You can get a full list of the voices available on the server + through :method:`.voices`. + :param media_plugin: Media plugin to be used for audio playback. Supported: + + - ``media.gstreamer`` + - ``media.omxplayer`` + - ``media.mplayer`` + - ``media.mpv`` + - ``media.vlc`` + + :param player_args: Optional arguments that should be passed to the player plugin's + :meth:`platypush.plugins.media.MediaPlugin.play` method. + """ + super().__init__(media_plugin=media_plugin, player_args=player_args, **kwargs) + + self.server_url = server_url + self.voice = voice + + @action + def say( + self, + text: str, + server_url: Optional[str] = None, + voice: Optional[str] = None, + player_args: Optional[dict] = None, + ): + """ + Say some text. + + :param text: Text to say. + :param server_url: Default ``server_url`` override. + :param voice: Default ``voice`` override. + :param player_args: Default ``player_args`` override. + """ + server_url = server_url or self.server_url + voice = voice or self.voice + player_args = player_args or self.player_args + http = get_backend('http') + assert http, 'http backend not configured' + assert self.media_plugin, 'No media plugin configured' + + url = ( + urljoin(get_local_base_url(), '/tts/mimic3/say') + + '?' + + urlencode( + { + 'text': text, + 'server_url': server_url, + 'voice': voice, + } + ) + ) + + self.media_plugin.play(url, **player_args) + + @action + def voices(self, server_url: Optional[str] = None): + """ + List the voices available on the server. + + :param server_url: Default ``server_url`` override. + :return: .. schema:: tts.mimi3.Mimic3VoiceSchema(many=True) + """ + server_url = server_url or self.server_url + rs = requests.get(urljoin(server_url, '/api/voices')) + rs.raise_for_status() + return Mimic3VoiceSchema().dump(rs.json(), many=True) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/tts/mimic3/manifest.yaml b/platypush/plugins/tts/mimic3/manifest.yaml new file mode 100644 index 000000000..4119497ec --- /dev/null +++ b/platypush/plugins/tts/mimic3/manifest.yaml @@ -0,0 +1,6 @@ +manifest: + events: {} + install: + pip: [] + package: platypush.plugins.tts.mimic3 + type: plugin diff --git a/platypush/schemas/tts/mimic3.py b/platypush/schemas/tts/mimic3.py new file mode 100644 index 000000000..a5819ff44 --- /dev/null +++ b/platypush/schemas/tts/mimic3.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class Mimic3Schema(Schema): + pass + + +class Mimic3VoiceSchema(Mimic3Schema): + key = fields.String( + required=True, + dump_only=True, + metadata={ + 'description': 'Unique voice ID', + 'example': 'en_UK/apope_low', + }, + ) + + language = fields.String( + required=True, + dump_only=True, + metadata={ + 'example': 'en_UK', + }, + ) + + language_english = fields.String( + metadata={ + 'description': 'Name of the language (in English)', + } + ) + + language_native = fields.String( + metadata={ + 'description': 'Name of the language (in the native language)', + } + ) + + name = fields.String( + metadata={ + 'example': 'apope_low', + } + ) + + sample_text = fields.String( + metadata={ + 'example': 'Some text', + } + ) + + description = fields.String() + aliases = fields.List(fields.String) From 1d78c3e7539545b30b9d6b27b8a5062c026429aa Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 30 Sep 2022 10:56:08 +0200 Subject: [PATCH 5/8] FIX: Broken docstring --- platypush/plugins/tts/mimic3/__init__.py | 2 +- platypush/schemas/tts/__init__.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 platypush/schemas/tts/__init__.py diff --git a/platypush/plugins/tts/mimic3/__init__.py b/platypush/plugins/tts/mimic3/__init__.py index 6bcf80157..a7e60c730 100644 --- a/platypush/plugins/tts/mimic3/__init__.py +++ b/platypush/plugins/tts/mimic3/__init__.py @@ -48,7 +48,7 @@ class TtsMimic3Plugin(TtsPlugin): :param server_url: Base URL of the web server that runs the Mimic3 engine. :param voice: Default voice to be used (default: ``en_UK/apope_low``). You can get a full list of the voices available on the server - through :method:`.voices`. + through :meth:`.voices`. :param media_plugin: Media plugin to be used for audio playback. Supported: - ``media.gstreamer`` diff --git a/platypush/schemas/tts/__init__.py b/platypush/schemas/tts/__init__.py new file mode 100644 index 000000000..e69de29bb From fed7c2c6ffbd2bbc4fb030dd6f2a109009a505a7 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 30 Sep 2022 11:30:57 +0200 Subject: [PATCH 6/8] Fixed typo in schema path --- platypush/plugins/tts/mimic3/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platypush/plugins/tts/mimic3/__init__.py b/platypush/plugins/tts/mimic3/__init__.py index a7e60c730..d5fdea2b0 100644 --- a/platypush/plugins/tts/mimic3/__init__.py +++ b/platypush/plugins/tts/mimic3/__init__.py @@ -108,7 +108,7 @@ class TtsMimic3Plugin(TtsPlugin): List the voices available on the server. :param server_url: Default ``server_url`` override. - :return: .. schema:: tts.mimi3.Mimic3VoiceSchema(many=True) + :return: .. schema:: tts.mimic3.Mimic3VoiceSchema(many=True) """ server_url = server_url or self.server_url rs = requests.get(urljoin(server_url, '/api/voices')) From 85f583a0ad5c6fbc84b88037002c3fec5dac5cf6 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 30 Sep 2022 11:47:19 +0200 Subject: [PATCH 7/8] Reduced :maxdepth: of toctree in documentation. Recent versions of Sphinx get a bit too zealous about generating deeply nested toctrees. --- docs/source/backends.rst | 2 +- docs/source/events.rst | 2 +- docs/source/index.rst | 2 +- docs/source/plugins.rst | 2 +- docs/source/responses.rst | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/source/backends.rst b/docs/source/backends.rst index aabb0af0b..74b225f60 100644 --- a/docs/source/backends.rst +++ b/docs/source/backends.rst @@ -3,7 +3,7 @@ Backends ======== .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Backends: platypush/backend/adafruit.io.rst diff --git a/docs/source/events.rst b/docs/source/events.rst index ec048ba54..12eb30e72 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -3,7 +3,7 @@ Events ====== .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Events: platypush/events/adafruit.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index 04e8c1888..9c901e0a8 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -16,7 +16,7 @@ For more information on Platypush check out: .. _Blog articles: https://blog.platypush.tech .. toctree:: - :maxdepth: 3 + :maxdepth: 2 :caption: Contents: backends diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 8c4241897..0ee00ffcc 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -3,7 +3,7 @@ Plugins ======= .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Plugins: platypush/plugins/adafruit.io.rst diff --git a/docs/source/responses.rst b/docs/source/responses.rst index 418a49777..906f70f5c 100644 --- a/docs/source/responses.rst +++ b/docs/source/responses.rst @@ -3,7 +3,7 @@ Responses ========= .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Responses: platypush/responses/bluetooth.rst From b88983f055038cc773ac57793f507ae5febd92b9 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Wed, 5 Oct 2022 01:13:47 +0200 Subject: [PATCH 8/8] Added `qos` argument to `mqtt.publish`. --- platypush/plugins/mqtt/__init__.py | 188 ++++++++++++++++++++--------- 1 file changed, 132 insertions(+), 56 deletions(-) diff --git a/platypush/plugins/mqtt/__init__.py b/platypush/plugins/mqtt/__init__.py index d757211cc..20b7034c0 100644 --- a/platypush/plugins/mqtt/__init__.py +++ b/platypush/plugins/mqtt/__init__.py @@ -21,44 +21,68 @@ class MqttPlugin(Plugin): """ - 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): + 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) + :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) + :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) + :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) + :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) + :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``. + :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) + :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). + :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) + :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) + :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. + :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). @@ -83,10 +107,11 @@ class MqttPlugin(Plugin): @staticmethod def get_tls_version(version: Optional[str] = None): import ssl + if not version: return None - if type(version) == type(ssl.PROTOCOL_TLS): + if isinstance(version, type(ssl.PROTOCOL_TLS)): return version if isinstance(version, str): @@ -120,10 +145,17 @@ class MqttPlugin(Plugin): 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): + 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) @@ -144,43 +176,77 @@ class MqttPlugin(Plugin): 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_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): + 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, + qos: int = 0, + ): """ 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). + :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). + :param qos: Quality of Service (_QoS_) for the message - see `MQTT QoS + `_ + (default: 0). """ response_buffer = io.BytesIO() client = None @@ -199,20 +265,29 @@ class MqttPlugin(Plugin): 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 = 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.on_message = self._response_callback( + reply_topic=reply_topic, + event=response_received, + buffer=response_buffer, + ) client.subscribe(reply_topic) - client.publish(topic, str(msg)) + client.publish(topic, str(msg), qos=qos) if not reply_topic: return @@ -241,6 +316,7 @@ class MqttPlugin(Plugin): buffer.write(msg.payload) client.loop_stop() event.set() + return on_message @action