diff --git a/docs/source/events.rst b/docs/source/events.rst index 5ca337d9d..8fdb115e9 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -20,6 +20,7 @@ Events platypush/events/custom.rst platypush/events/dbus.rst platypush/events/distance.rst + platypush/events/entities.rst platypush/events/file.rst platypush/events/foursquare.rst platypush/events/geo.rst diff --git a/docs/source/platypush/events/entities.rst b/docs/source/platypush/events/entities.rst new file mode 100644 index 000000000..206b44d4c --- /dev/null +++ b/docs/source/platypush/events/entities.rst @@ -0,0 +1,5 @@ +``entities`` +============ + +.. automodule:: platypush.message.event.entities + :members: diff --git a/docs/source/platypush/plugins/entities.rst b/docs/source/platypush/plugins/entities.rst new file mode 100644 index 000000000..a17e2e298 --- /dev/null +++ b/docs/source/platypush/plugins/entities.rst @@ -0,0 +1,5 @@ +``entities`` +============ + +.. automodule:: platypush.plugins.entities + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index b848e5930..8c2f0a260 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -32,6 +32,7 @@ Plugins platypush/plugins/db.rst platypush/plugins/dbus.rst platypush/plugins/dropbox.rst + platypush/plugins/entities.rst platypush/plugins/esp.rst platypush/plugins/ffmpeg.rst platypush/plugins/file.rst diff --git a/platypush/__init__.py b/platypush/__init__.py index 7de379c26..c50e7e94e 100644 --- a/platypush/__init__.py +++ b/platypush/__init__.py @@ -9,11 +9,13 @@ import argparse import logging import os import sys +from typing import Optional from .bus.redis import RedisBus from .config import Config from .context import register_backends, register_plugins from .cron.scheduler import CronScheduler +from .entities import init_entities_engine, EntitiesEngine from .event.processor import EventProcessor from .logger import Logger from .message.event import Event @@ -96,6 +98,7 @@ class Daemon: self.no_capture_stdout = no_capture_stdout self.no_capture_stderr = no_capture_stderr self.event_processor = EventProcessor() + self.entities_engine: Optional[EntitiesEngine] = None self.requests_to_process = requests_to_process self.processed_requests = 0 self.cron_scheduler = None @@ -199,16 +202,25 @@ class Daemon: """Stops the backends and the bus""" from .plugins import RunnablePlugin - for backend in self.backends.values(): - backend.stop() + if self.backends: + for backend in self.backends.values(): + backend.stop() for plugin in get_enabled_plugins().values(): if isinstance(plugin, RunnablePlugin): plugin.stop() - self.bus.stop() + if self.bus: + self.bus.stop() + self.bus = None + if self.cron_scheduler: self.cron_scheduler.stop() + self.cron_scheduler = None + + if self.entities_engine: + self.entities_engine.stop() + self.entities_engine = None def run(self): """Start the daemon""" @@ -230,6 +242,9 @@ class Daemon: # Initialize the plugins register_plugins(bus=self.bus) + # Initialize the entities engine + self.entities_engine = init_entities_engine() + # Start the cron scheduler if Config.get_cronjobs(): self.cron_scheduler = CronScheduler(jobs=Config.get_cronjobs()) diff --git a/platypush/backend/covid19/__init__.py b/platypush/backend/covid19/__init__.py index 598f10871..1be1db016 100644 --- a/platypush/backend/covid19/__init__.py +++ b/platypush/backend/covid19/__init__.py @@ -3,8 +3,7 @@ import os from typing import Optional, Union, List, Dict, Any from sqlalchemy import create_engine, Column, Integer, String, DateTime -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from platypush.backend import Backend from platypush.config import Config @@ -17,10 +16,10 @@ Session = scoped_session(sessionmaker()) class Covid19Update(Base): - """ Models the Covid19Data table """ + """Models the Covid19Data table""" __tablename__ = 'covid19data' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} country = Column(String, primary_key=True) confirmed = Column(Integer, nullable=False, default=0) @@ -40,7 +39,12 @@ class Covid19Backend(Backend): """ # noinspection PyProtectedMember - def __init__(self, country: Optional[Union[str, List[str]]], poll_seconds: Optional[float] = 3600.0, **kwargs): + def __init__( + self, + country: Optional[Union[str, List[str]]], + poll_seconds: Optional[float] = 3600.0, + **kwargs + ): """ :param country: Default country (or list of countries) to retrieve the stats for. It can either be the full country name or the country code. Special values: @@ -56,7 +60,9 @@ class Covid19Backend(Backend): super().__init__(poll_seconds=poll_seconds, **kwargs) self._plugin: Covid19Plugin = get_plugin('covid19') self.country: List[str] = self._plugin._get_countries(country) - self.workdir = os.path.join(os.path.expanduser(Config.get('workdir')), 'covid19') + self.workdir = os.path.join( + os.path.expanduser(Config.get('workdir')), 'covid19' + ) self.dbfile = os.path.join(self.workdir, 'data.db') os.makedirs(self.workdir, exist_ok=True) @@ -67,22 +73,30 @@ class Covid19Backend(Backend): self.logger.info('Stopped Covid19 backend') def _process_update(self, summary: Dict[str, Any], session: Session): - update_time = datetime.datetime.fromisoformat(summary['Date'].replace('Z', '+00:00')) + update_time = datetime.datetime.fromisoformat( + summary['Date'].replace('Z', '+00:00') + ) - self.bus.post(Covid19UpdateEvent( - country=summary['Country'], - country_code=summary['CountryCode'], - confirmed=summary['TotalConfirmed'], - deaths=summary['TotalDeaths'], - recovered=summary['TotalRecovered'], - update_time=update_time, - )) + self.bus.post( + Covid19UpdateEvent( + country=summary['Country'], + country_code=summary['CountryCode'], + confirmed=summary['TotalConfirmed'], + deaths=summary['TotalDeaths'], + recovered=summary['TotalRecovered'], + update_time=update_time, + ) + ) - session.merge(Covid19Update(country=summary['CountryCode'], - confirmed=summary['TotalConfirmed'], - deaths=summary['TotalDeaths'], - recovered=summary['TotalRecovered'], - last_updated_at=update_time)) + session.merge( + Covid19Update( + country=summary['CountryCode'], + confirmed=summary['TotalConfirmed'], + deaths=summary['TotalDeaths'], + recovered=summary['TotalRecovered'], + last_updated_at=update_time, + ) + ) def loop(self): # noinspection PyUnresolvedReferences @@ -90,23 +104,30 @@ class Covid19Backend(Backend): if not summaries: return - engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False}) + engine = create_engine( + 'sqlite:///{}'.format(self.dbfile), + connect_args={'check_same_thread': False}, + ) Base.metadata.create_all(engine) Session.configure(bind=engine) session = Session() last_records = { record.country: record - for record in session.query(Covid19Update).filter(Covid19Update.country.in_(self.country)).all() + for record in session.query(Covid19Update) + .filter(Covid19Update.country.in_(self.country)) + .all() } for summary in summaries: country = summary['CountryCode'] last_record = last_records.get(country) - if not last_record or \ - summary['TotalConfirmed'] != last_record.confirmed or \ - summary['TotalDeaths'] != last_record.deaths or \ - summary['TotalRecovered'] != last_record.recovered: + if ( + not last_record + or summary['TotalConfirmed'] != last_record.confirmed + or summary['TotalDeaths'] != last_record.deaths + or summary['TotalRecovered'] != last_record.recovered + ): self._process_update(summary=summary, session=session) session.commit() diff --git a/platypush/backend/github/__init__.py b/platypush/backend/github/__init__.py index ad49b73d4..0a1bc3e67 100644 --- a/platypush/backend/github/__init__.py +++ b/platypush/backend/github/__init__.py @@ -6,15 +6,28 @@ from typing import Optional, List import requests from sqlalchemy import create_engine, Column, String, DateTime -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker, scoped_session +from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from platypush.backend import Backend from platypush.config import Config -from platypush.message.event.github import GithubPushEvent, GithubCommitCommentEvent, GithubCreateEvent, \ - GithubDeleteEvent, GithubEvent, GithubForkEvent, GithubWikiEvent, GithubIssueCommentEvent, GithubIssueEvent, \ - GithubMemberEvent, GithubPublicEvent, GithubPullRequestEvent, GithubPullRequestReviewCommentEvent, \ - GithubReleaseEvent, GithubSponsorshipEvent, GithubWatchEvent +from platypush.message.event.github import ( + GithubPushEvent, + GithubCommitCommentEvent, + GithubCreateEvent, + GithubDeleteEvent, + GithubEvent, + GithubForkEvent, + GithubWikiEvent, + GithubIssueCommentEvent, + GithubIssueEvent, + GithubMemberEvent, + GithubPublicEvent, + GithubPullRequestEvent, + GithubPullRequestReviewCommentEvent, + GithubReleaseEvent, + GithubSponsorshipEvent, + GithubWatchEvent, +) Base = declarative_base() Session = scoped_session(sessionmaker()) @@ -71,8 +84,17 @@ class GithubBackend(Backend): _base_url = 'https://api.github.com' - def __init__(self, user: str, user_token: str, repos: Optional[List[str]] = None, org: Optional[str] = None, - poll_seconds: int = 60, max_events_per_scan: Optional[int] = 10, *args, **kwargs): + def __init__( + self, + user: str, + user_token: str, + repos: Optional[List[str]] = None, + org: Optional[str] = None, + poll_seconds: int = 60, + max_events_per_scan: Optional[int] = 10, + *args, + **kwargs + ): """ If neither ``repos`` nor ``org`` is specified then the backend will monitor all new events on user level. @@ -102,17 +124,23 @@ class GithubBackend(Backend): def _request(self, uri: str, method: str = 'get') -> dict: method = getattr(requests, method.lower()) - return method(self._base_url + uri, auth=(self.user, self.user_token), - headers={'Accept': 'application/vnd.github.v3+json'}).json() + return method( + self._base_url + uri, + auth=(self.user, self.user_token), + headers={'Accept': 'application/vnd.github.v3+json'}, + ).json() def _init_db(self): - engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False}) + engine = create_engine( + 'sqlite:///{}'.format(self.dbfile), + connect_args={'check_same_thread': False}, + ) Base.metadata.create_all(engine) Session.configure(bind=engine) @staticmethod def _to_datetime(time_string: str) -> datetime.datetime: - """ Convert ISO 8061 string format with leading 'Z' into something understandable by Python """ + """Convert ISO 8061 string format with leading 'Z' into something understandable by Python""" return datetime.datetime.fromisoformat(time_string[:-1] + '+00:00') @staticmethod @@ -128,7 +156,11 @@ class GithubBackend(Backend): def _get_last_event_time(self, uri: str): with self.db_lock: record = self._get_or_create_resource(uri=uri, session=Session()) - return record.last_updated_at.replace(tzinfo=datetime.timezone.utc) if record.last_updated_at else None + return ( + record.last_updated_at.replace(tzinfo=datetime.timezone.utc) + if record.last_updated_at + else None + ) def _update_last_event_time(self, uri: str, last_updated_at: datetime.datetime): with self.db_lock: @@ -158,9 +190,18 @@ class GithubBackend(Backend): 'WatchEvent': GithubWatchEvent, } - event_type = event_mapping[event['type']] if event['type'] in event_mapping else GithubEvent - return event_type(event_type=event['type'], actor=event['actor'], repo=event.get('repo', {}), - payload=event['payload'], created_at=cls._to_datetime(event['created_at'])) + event_type = ( + event_mapping[event['type']] + if event['type'] in event_mapping + else GithubEvent + ) + return event_type( + event_type=event['type'], + actor=event['actor'], + repo=event.get('repo', {}), + payload=event['payload'], + created_at=cls._to_datetime(event['created_at']), + ) def _events_monitor(self, uri: str, method: str = 'get'): def thread(): @@ -175,7 +216,10 @@ class GithubBackend(Backend): fired_events = [] for event in events: - if self.max_events_per_scan and len(fired_events) >= self.max_events_per_scan: + if ( + self.max_events_per_scan + and len(fired_events) >= self.max_events_per_scan + ): break event_time = self._to_datetime(event['created_at']) @@ -189,14 +233,19 @@ class GithubBackend(Backend): for event in fired_events: self.bus.post(event) - self._update_last_event_time(uri=uri, last_updated_at=new_last_event_time) + self._update_last_event_time( + uri=uri, last_updated_at=new_last_event_time + ) except Exception as e: - self.logger.warning('Encountered exception while fetching events from {}: {}'.format( - uri, str(e))) + self.logger.warning( + 'Encountered exception while fetching events from {}: {}'.format( + uri, str(e) + ) + ) self.logger.exception(e) - finally: - if self.wait_stop(timeout=self.poll_seconds): - break + + if self.wait_stop(timeout=self.poll_seconds): + break return thread @@ -206,12 +255,30 @@ class GithubBackend(Backend): if self.repos: for repo in self.repos: - monitors.append(threading.Thread(target=self._events_monitor('/networks/{repo}/events'.format(repo=repo)))) + monitors.append( + threading.Thread( + target=self._events_monitor( + '/networks/{repo}/events'.format(repo=repo) + ) + ) + ) if self.org: - monitors.append(threading.Thread(target=self._events_monitor('/orgs/{org}/events'.format(org=self.org)))) + monitors.append( + threading.Thread( + target=self._events_monitor( + '/orgs/{org}/events'.format(org=self.org) + ) + ) + ) if not (self.repos or self.org): - monitors.append(threading.Thread(target=self._events_monitor('/users/{user}/events'.format(user=self.user)))) + monitors.append( + threading.Thread( + target=self._events_monitor( + '/users/{user}/events'.format(user=self.user) + ) + ) + ) for monitor in monitors: monitor.start() @@ -222,4 +289,5 @@ class GithubBackend(Backend): self.logger.info('Github backend terminated') + # vim:sw=4:ts=4:et: diff --git a/platypush/backend/http/request/rss/__init__.py b/platypush/backend/http/request/rss/__init__.py index b16565dc5..7ca6d9c64 100644 --- a/platypush/backend/http/request/rss/__init__.py +++ b/platypush/backend/http/request/rss/__init__.py @@ -2,11 +2,17 @@ import datetime import enum import os -from sqlalchemy import create_engine, Column, Integer, String, DateTime, \ - Enum, ForeignKey +from sqlalchemy import ( + create_engine, + Column, + Integer, + String, + DateTime, + Enum, + ForeignKey, +) -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from sqlalchemy.sql.expression import func from platypush.backend.http.request import HttpRequest @@ -44,18 +50,31 @@ class RssUpdates(HttpRequest): """ - user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + \ - 'Chrome/62.0.3202.94 Safari/537.36' + user_agent = ( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/62.0.3202.94 Safari/537.36' + ) - def __init__(self, url, title=None, headers=None, params=None, max_entries=None, - extract_content=False, digest_format=None, user_agent: str = user_agent, - body_style: str = 'font-size: 22px; ' + - 'font-family: "Merriweather", Georgia, "Times New Roman", Times, serif;', - title_style: str = 'margin-top: 30px', - subtitle_style: str = 'margin-top: 10px; page-break-after: always', - article_title_style: str = 'page-break-before: always', - article_link_style: str = 'color: #555; text-decoration: none; border-bottom: 1px dotted', - article_content_style: str = '', *argv, **kwargs): + def __init__( + self, + url, + title=None, + headers=None, + params=None, + max_entries=None, + extract_content=False, + digest_format=None, + user_agent: str = user_agent, + body_style: str = 'font-size: 22px; ' + + 'font-family: "Merriweather", Georgia, "Times New Roman", Times, serif;', + title_style: str = 'margin-top: 30px', + subtitle_style: str = 'margin-top: 10px; page-break-after: always', + article_title_style: str = 'page-break-before: always', + article_link_style: str = 'color: #555; text-decoration: none; border-bottom: 1px dotted', + article_content_style: str = '', + *argv, + **kwargs, + ): """ :param url: URL to the RSS feed to be monitored. :param title: Optional title for the feed. @@ -91,7 +110,9 @@ class RssUpdates(HttpRequest): # If true, then the http.webpage plugin will be used to parse the content self.extract_content = extract_content - self.digest_format = digest_format.lower() if digest_format else None # Supported formats: html, pdf + self.digest_format = ( + digest_format.lower() if digest_format else None + ) # Supported formats: html, pdf os.makedirs(os.path.expanduser(os.path.dirname(self.dbfile)), exist_ok=True) @@ -119,7 +140,11 @@ class RssUpdates(HttpRequest): @staticmethod def _get_latest_update(session, source_id): - return session.query(func.max(FeedEntry.published)).filter_by(source_id=source_id).scalar() + return ( + session.query(func.max(FeedEntry.published)) + .filter_by(source_id=source_id) + .scalar() + ) def _parse_entry_content(self, link): self.logger.info('Extracting content from {}'.format(link)) @@ -130,14 +155,20 @@ class RssUpdates(HttpRequest): errors = response.errors if not output: - self.logger.warning('Mercury parser error: {}'.format(errors or '[unknown error]')) + self.logger.warning( + 'Mercury parser error: {}'.format(errors or '[unknown error]') + ) return return output.get('content') def get_new_items(self, response): import feedparser - engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False}) + + engine = create_engine( + 'sqlite:///{}'.format(self.dbfile), + connect_args={'check_same_thread': False}, + ) Base.metadata.create_all(engine) Session.configure(bind=engine) @@ -157,12 +188,16 @@ class RssUpdates(HttpRequest): content = u''' <h1 style="{title_style}">{title}</h1> - <h2 style="{subtitle_style}">Feeds digest generated on {creation_date}</h2>'''.\ - format(title_style=self.title_style, title=self.title, subtitle_style=self.subtitle_style, - creation_date=datetime.datetime.now().strftime('%d %B %Y, %H:%M')) + <h2 style="{subtitle_style}">Feeds digest generated on {creation_date}</h2>'''.format( + title_style=self.title_style, + title=self.title, + subtitle_style=self.subtitle_style, + creation_date=datetime.datetime.now().strftime('%d %B %Y, %H:%M'), + ) - self.logger.info('Parsed {:d} items from RSS feed <{}>' - .format(len(feed.entries), self.url)) + self.logger.info( + 'Parsed {:d} items from RSS feed <{}>'.format(len(feed.entries), self.url) + ) for entry in feed.entries: if not entry.published_parsed: @@ -171,9 +206,10 @@ class RssUpdates(HttpRequest): try: entry_timestamp = datetime.datetime(*entry.published_parsed[:6]) - if latest_update is None \ - or entry_timestamp > latest_update: - self.logger.info('Processed new item from RSS feed <{}>'.format(self.url)) + if latest_update is None or entry_timestamp > latest_update: + self.logger.info( + 'Processed new item from RSS feed <{}>'.format(self.url) + ) entry.summary = entry.summary if hasattr(entry, 'summary') else None if self.extract_content: @@ -188,9 +224,13 @@ class RssUpdates(HttpRequest): <a href="{link}" target="_blank" style="{article_link_style}">{title}</a> </h1> <div class="_parsed-content" style="{article_content_style}">{content}</div>'''.format( - article_title_style=self.article_title_style, article_link_style=self.article_link_style, - article_content_style=self.article_content_style, link=entry.link, title=entry.title, - content=entry.content) + article_title_style=self.article_title_style, + article_link_style=self.article_link_style, + article_content_style=self.article_content_style, + link=entry.link, + title=entry.title, + content=entry.content, + ) e = { 'entry_id': entry.id, @@ -207,21 +247,32 @@ class RssUpdates(HttpRequest): if self.max_entries and len(entries) > self.max_entries: break except Exception as e: - self.logger.warning('Exception encountered while parsing RSS ' + - 'RSS feed {}: {}'.format(entry.link, str(e))) + self.logger.warning( + 'Exception encountered while parsing RSS ' + + f'RSS feed {entry.link}: {e}' + ) self.logger.exception(e) source_record.last_updated_at = parse_start_time digest_filename = None if entries: - self.logger.info('Parsed {} new entries from the RSS feed {}'.format( - len(entries), self.title)) + self.logger.info( + 'Parsed {} new entries from the RSS feed {}'.format( + len(entries), self.title + ) + ) if self.digest_format: - digest_filename = os.path.join(self.workdir, 'cache', '{}_{}.{}'.format( - datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), - self.title, self.digest_format)) + digest_filename = os.path.join( + self.workdir, + 'cache', + '{}_{}.{}'.format( + datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), + self.title, + self.digest_format, + ), + ) os.makedirs(os.path.dirname(digest_filename), exist_ok=True) @@ -233,12 +284,15 @@ class RssUpdates(HttpRequest): </head> <body style="{body_style}">{content}</body> </html> - '''.format(title=self.title, body_style=self.body_style, content=content) + '''.format( + title=self.title, body_style=self.body_style, content=content + ) with open(digest_filename, 'w', encoding='utf-8') as f: f.write(content) elif self.digest_format == 'pdf': from weasyprint import HTML, CSS + try: from weasyprint.fonts import FontConfiguration except ImportError: @@ -246,37 +300,47 @@ class RssUpdates(HttpRequest): body_style = 'body { ' + self.body_style + ' }' font_config = FontConfiguration() - css = [CSS('https://fonts.googleapis.com/css?family=Merriweather'), - CSS(string=body_style, font_config=font_config)] + css = [ + CSS('https://fonts.googleapis.com/css?family=Merriweather'), + CSS(string=body_style, font_config=font_config), + ] HTML(string=content).write_pdf(digest_filename, stylesheets=css) else: - raise RuntimeError('Unsupported format: {}. Supported formats: ' + - 'html or pdf'.format(self.digest_format)) + raise RuntimeError( + f'Unsupported format: {self.digest_format}. Supported formats: html, pdf' + ) - digest_entry = FeedDigest(source_id=source_record.id, - format=self.digest_format, - filename=digest_filename) + digest_entry = FeedDigest( + source_id=source_record.id, + format=self.digest_format, + filename=digest_filename, + ) session.add(digest_entry) - self.logger.info('{} digest ready: {}'.format(self.digest_format, digest_filename)) + self.logger.info( + '{} digest ready: {}'.format(self.digest_format, digest_filename) + ) session.commit() self.logger.info('Parsing RSS feed {}: completed'.format(self.title)) - return NewFeedEvent(request=dict(self), response=entries, - source_id=source_record.id, - source_title=source_record.title, - title=self.title, - digest_format=self.digest_format, - digest_filename=digest_filename) + return NewFeedEvent( + request=dict(self), + response=entries, + source_id=source_record.id, + source_title=source_record.title, + title=self.title, + digest_format=self.digest_format, + digest_filename=digest_filename, + ) class FeedSource(Base): - """ Models the FeedSource table, containing RSS sources to be parsed """ + """Models the FeedSource table, containing RSS sources to be parsed""" __tablename__ = 'FeedSource' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) title = Column(String) @@ -285,10 +349,10 @@ class FeedSource(Base): class FeedEntry(Base): - """ Models the FeedEntry table, which contains RSS entries """ + """Models the FeedEntry table, which contains RSS entries""" __tablename__ = 'FeedEntry' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) entry_id = Column(String) @@ -301,15 +365,15 @@ class FeedEntry(Base): class FeedDigest(Base): - """ Models the FeedDigest table, containing feed digests either in HTML - or PDF format """ + """Models the FeedDigest table, containing feed digests either in HTML + or PDF format""" class DigestFormat(enum.Enum): html = 1 pdf = 2 __tablename__ = 'FeedDigest' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) source_id = Column(Integer, ForeignKey('FeedSource.id'), nullable=False) @@ -317,4 +381,5 @@ class FeedDigest(Base): filename = Column(String, nullable=False) created_at = Column(DateTime, nullable=False, default=datetime.datetime.utcnow) + # vim:sw=4:ts=4:et: diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index c27e627e6..d4c538cf9 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -8,7 +8,7 @@ "name": "platypush", "version": "0.1.0", "dependencies": { - "@fortawesome/fontawesome-free": "^5.15.4", + "@fortawesome/fontawesome-free": "^6.1.1", "axios": "^0.21.4", "core-js": "^3.21.1", "lato-font": "^3.0.0", @@ -1731,9 +1731,9 @@ } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz", - "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz", + "integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg==", "hasInstallScript": true, "engines": { "node": ">=6" @@ -12917,9 +12917,9 @@ } }, "@fortawesome/fontawesome-free": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz", - "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==" + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz", + "integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg==" }, "@humanwhocodes/config-array": { "version": "0.5.0", diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json index 531f041d0..d62b06598 100644 --- a/platypush/backend/http/webapp/package.json +++ b/platypush/backend/http/webapp/package.json @@ -8,7 +8,7 @@ "lint": "vue-cli-service lint" }, "dependencies": { - "@fortawesome/fontawesome-free": "^5.15.4", + "@fortawesome/fontawesome-free": "^6.1.1", "axios": "^0.21.4", "core-js": "^3.21.1", "lato-font": "^3.0.0", diff --git a/platypush/backend/http/webapp/public/icons/smartthings.png b/platypush/backend/http/webapp/public/icons/smartthings.png new file mode 100644 index 000000000..9110a99c5 Binary files /dev/null and b/platypush/backend/http/webapp/public/icons/smartthings.png differ diff --git a/platypush/backend/http/webapp/public/img/spinner.gif b/platypush/backend/http/webapp/public/img/spinner.gif new file mode 100644 index 000000000..0b3ba6284 Binary files /dev/null and b/platypush/backend/http/webapp/public/img/spinner.gif differ diff --git a/platypush/backend/http/webapp/src/Utils.vue b/platypush/backend/http/webapp/src/Utils.vue index 50ce3fe4b..455785424 100644 --- a/platypush/backend/http/webapp/src/Utils.vue +++ b/platypush/backend/http/webapp/src/Utils.vue @@ -5,10 +5,11 @@ import DateTime from "@/utils/DateTime"; import Events from "@/utils/Events"; import Notification from "@/utils/Notification"; import Screen from "@/utils/Screen"; +import Text from "@/utils/Text"; import Types from "@/utils/Types"; export default { name: "Utils", - mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Types], + mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Text, Types], } </script> diff --git a/platypush/backend/http/webapp/src/assets/icons.json b/platypush/backend/http/webapp/src/assets/icons.json index ee45a8013..93e8886c0 100644 --- a/platypush/backend/http/webapp/src/assets/icons.json +++ b/platypush/backend/http/webapp/src/assets/icons.json @@ -17,6 +17,9 @@ "camera.pi": { "class": "fas fa-camera" }, + "entities": { + "class": "fa fa-home" + }, "execute": { "class": "fa fa-play" }, @@ -59,9 +62,21 @@ "rtorrent": { "class": "fa fa-magnet" }, + "smartthings": { + "imgUrl": "/icons/smartthings.png" + }, "switches": { "class": "fas fa-toggle-on" }, + "switch.switchbot": { + "class": "fas fa-toggle-on" + }, + "switch.tplink": { + "class": "fas fa-toggle-on" + }, + "switchbot": { + "class": "fas fa-toggle-on" + }, "sound": { "class": "fa fa-microphone" }, diff --git a/platypush/backend/http/webapp/src/components/Nav.vue b/platypush/backend/http/webapp/src/components/Nav.vue index 649bf6ad1..92531e2f7 100644 --- a/platypush/backend/http/webapp/src/components/Nav.vue +++ b/platypush/backend/http/webapp/src/components/Nav.vue @@ -6,7 +6,7 @@ </div> <ul class="plugins"> - <li v-for="name in Object.keys(panels).sort()" :key="name" class="entry" :class="{selected: name === selectedPanel}" + <li v-for="name in panelNames" :key="name" class="entry" :class="{selected: name === selectedPanel}" :title="name" @click="onItemClick(name)"> <a :href="`/#${name}`"> <span class="icon"> @@ -14,7 +14,7 @@ <img :src="icons[name].imgUrl" v-else-if="icons[name]?.imgUrl" alt="name"/> <i class="fas fa-puzzle-piece" v-else /> </span> - <span class="name" v-if="!collapsed" v-text="name" /> + <span class="name" v-if="!collapsed" v-text="name == 'entities' ? 'Home' : name" /> </a> </li> </ul> @@ -66,6 +66,16 @@ export default { }, }, + computed: { + panelNames() { + let panelNames = Object.keys(this.panels) + const homeIdx = panelNames.indexOf('entities') + if (homeIdx >= 0) + return ['entities'].concat((panelNames.slice(0, homeIdx).concat(panelNames.slice(homeIdx+1))).sort()) + return panelNames.sort() + }, + }, + methods: { onItemClick(name) { this.$emit('select', name) @@ -80,11 +90,6 @@ export default { host: null, } }, - - mounted() { - if (this.isMobile() && !this.$root.$route.hash.length) - this.collapsed = false - }, } </script> diff --git a/platypush/backend/http/webapp/src/components/elements/ConfirmDialog.vue b/platypush/backend/http/webapp/src/components/elements/ConfirmDialog.vue new file mode 100644 index 000000000..54368aac7 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/elements/ConfirmDialog.vue @@ -0,0 +1,84 @@ +<template> + <Modal ref="modal" :title="title"> + <div class="dialog-content"> + <slot /> + </div> + + <form class="buttons" @submit.prevent="onConfirm"> + <button type="submit" class="ok-btn" @click="onConfirm" @touch="onConfirm"> + <i class="fas fa-check" /> {{ confirmText }} + </button> + <button type="button" class="cancel-btn" @click="close" @touch="close"> + <i class="fas fa-xmark" /> {{ cancelText }} + </button> + </form> + </Modal> +</template> + +<script> +import Modal from "@/components/Modal"; + +export default { + emits: ['input', 'click', 'touch'], + components: {Modal}, + props: { + title: { + type: String, + }, + + confirmText: { + type: String, + default: "OK", + }, + + cancelText: { + type: String, + default: "Cancel", + }, + }, + + methods: { + onConfirm() { + this.$emit('input') + this.close() + }, + + show() { + this.$refs.modal.show() + }, + + close() { + this.$refs.modal.hide() + }, + }, +} +</script> + +<style lang="scss" scoped> +:deep(.modal) { + .dialog-content { + padding: 1em; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: right; + padding: 1em 0 1em 1em; + border: 0; + border-radius: 0; + box-shadow: 0 -1px 2px 0 $default-shadow-color; + + button { + margin-right: 1em; + padding: 0.5em 1em; + border: 1px solid $border-color-2; + border-radius: 1em; + + &:hover { + background: $hover-bg; + } + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/elements/Dropdown.vue b/platypush/backend/http/webapp/src/components/elements/Dropdown.vue index 0ab113595..fd6de368b 100644 --- a/platypush/backend/http/webapp/src/components/elements/Dropdown.vue +++ b/platypush/backend/http/webapp/src/components/elements/Dropdown.vue @@ -37,6 +37,11 @@ export default { title: { type: String, }, + + keepOpenOnItemClick: { + type: Boolean, + default: false, + }, }, data() { diff --git a/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue b/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue index ac8c8f4d7..4698b7042 100644 --- a/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue +++ b/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue @@ -1,20 +1,27 @@ <template> <div class="row item" :class="itemClass" @click="clicked"> - <div class="col-1 icon" v-if="iconClass"> - <i :class="iconClass" /> + <div class="col-1 icon" v-if="iconClass?.length || iconUrl?.length"> + <Icon :class="iconClass" :url="iconUrl" /> </div> <div class="text" :class="{'col-11': iconClass != null}" v-text="text" /> </div> </template> <script> +import Icon from "@/components/elements/Icon"; + export default { name: "DropdownItem", + components: {Icon}, props: { iconClass: { type: String, }, + iconUrl: { + type: String, + }, + text: { type: String, }, @@ -31,8 +38,12 @@ export default { methods: { clicked(event) { + if (this.disabled) + return false + this.$parent.$emit('click', event) - this.$parent.visible = false + if (!this.$parent.keepOpenOnItemClick) + this.$parent.visible = false } } } @@ -55,7 +66,18 @@ export default { } .icon { - margin: 0 .5em; + display: inline-flex; + align-items: center; + } + + ::v-deep(.icon-container) { + width: 2em; + display: inline-flex; + align-items: center; + + .icon { + margin: 0 1.5em 0 .5em; + } } } </style> diff --git a/platypush/backend/http/webapp/src/components/elements/EditButton.vue b/platypush/backend/http/webapp/src/components/elements/EditButton.vue new file mode 100644 index 000000000..f24bdde68 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/elements/EditButton.vue @@ -0,0 +1,33 @@ +<template> + <button class="edit-btn" + @click="proxy($event)" @touch="proxy($event)" @input="proxy($event)" + > + <i class="fas fa-pen-to-square" /> + </button> +</template> + +<script> +export default { + emits: ['input', 'click', 'touch'], + methods: { + proxy(e) { + this.$emit(e.type, e) + }, + }, +} +</script> + +<style lang="scss" scoped> +.edit-btn { + border: 0; + background: none; + padding: 0 0.25em; + margin-left: 0.25em; + border: 1px solid rgba(0, 0, 0, 0); + + &:hover { + background: $hover-bg; + border: 1px solid $selected-fg; + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/elements/Icon.vue b/platypush/backend/http/webapp/src/components/elements/Icon.vue new file mode 100644 index 000000000..e61c7a7d9 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/elements/Icon.vue @@ -0,0 +1,48 @@ +<template> + <div class="icon-container"> + <img class="icon" :src="url" :alt="alt" v-if="url?.length"> + <i class="icon" :class="className" :style="{color: color}" + v-else-if="className?.length" /> + </div> +</template> + +<script> +export default { + props: { + class: { + type: String, + }, + url: { + type: String, + }, + color: { + type: String, + default: '', + }, + alt: { + type: String, + default: '', + }, + }, + + computed: { + className() { + return this.class + } + } +} +</script> + +<style lang="scss" scoped> +.icon-container { + display: inline-flex; + width: $icon-container-size; + justify-content: center; + text-align: center; + + .icon { + width: 1em; + height: 1em; + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/elements/NameEditor.vue b/platypush/backend/http/webapp/src/components/elements/NameEditor.vue new file mode 100644 index 000000000..73cf72899 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/elements/NameEditor.vue @@ -0,0 +1,75 @@ +<template> + <form @submit.prevent="submit" class="name-editor"> + <input type="text" v-model="text" :disabled="disabled"> + <button type="submit"> + <i class="fas fa-circle-check" /> + </button> + <button class="cancel" @click="$emit('cancel')" @touch="$emit('cancel')"> + <i class="fas fa-ban" /> + </button> + <slot /> + </form> +</template> + +<script> +export default { + emits: ['input', 'cancel'], + props: { + value: { + type: String, + }, + + disabled: { + type: Boolean, + deafult: false, + }, + }, + + data() { + return { + text: null, + } + }, + + methods: { + proxy(e) { + this.$emit(e.type, e) + }, + + submit() { + this.$emit('input', this.text) + return false + }, + }, + + mounted() { + this.text = this.value + }, +} +</script> + +<style lang="scss" scoped> +.name-editor { + background: #00000000; + display: inline-flex; + flex-direction: row; + padding: 0; + border: 0; + border-radius: 0; + box-shadow: none; + + button { + border: none; + background: none; + padding: 0 0.5em; + + &.confirm { + color: $selected-fg; + } + + &.cancel { + color: $error-fg; + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/elements/NoItems.vue b/platypush/backend/http/webapp/src/components/elements/NoItems.vue new file mode 100644 index 000000000..db0278cb8 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/elements/NoItems.vue @@ -0,0 +1,51 @@ +<template> +<div class="no-items-container"> + <div class="no-items fade-in"> + <slot /> + </div> +</div> +</template> + +<script> +export default { + name: "NoItems", +} +</script> + +<style lang="scss" scoped> +.no-items-container { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + + .no-items { + min-width: 100%; + max-width: 100%; + + @include from($tablet) { + min-width: 80%; + } + + @include from($desktop) { + min-width: 50%; + max-width: 35em; + } + + @include from($fullhd) { + min-width: 33%; + } + + background: $background-color; + margin: 1em; + padding: 1em; + font-size: 1.5em; + color: $no-items-color; + display: flex; + align-items: center; + justify-content: center; + border-radius: 1em; + box-shadow: $border-shadow-bottom; + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/elements/Slider.vue b/platypush/backend/http/webapp/src/components/elements/Slider.vue index 8cf7393e9..9969db470 100644 --- a/platypush/backend/http/webapp/src/components/elements/Slider.vue +++ b/platypush/backend/http/webapp/src/components/elements/Slider.vue @@ -62,7 +62,7 @@ export default { }, update(value) { - const percent = (value * 100) / (this.range[1] - this.range[0]) + const percent = ((value - this.range[0]) * 100) / (this.range[1] - this.range[0]) this.$refs.thumb.style.left = `${percent}%` this.$refs.thumb.style.transform = `translate(-${percent}%, -50%)` this.$refs.track.style.width = `${percent}%` diff --git a/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue b/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue index a751861d4..43be6765f 100644 --- a/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue +++ b/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue @@ -59,7 +59,7 @@ export default { display: none; & + label { border-radius: 1em; - display: block; + display: inline-flex; cursor: pointer; position: relative; transition: box-shadow .4s; diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue new file mode 100644 index 000000000..1e6eea805 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue @@ -0,0 +1,45 @@ +<template> + <div class="row item entity-container"> + <component :is="component" + :value="value" + :loading="loading" + :error="error || value?.reachable == false" + @input="$emit('input', $event)" + @loading="$emit('loading', $event)" + /> + </div> +</template> + +<script> +import { defineAsyncComponent } from 'vue' +import EntityMixin from "./EntityMixin" + +export default { + name: "Entity", + mixins: [EntityMixin], + emits: ['input', 'loading'], + + data() { + return { + component: null, + } + }, + + mounted() { + if (this.type !== 'Entity') + this.component = defineAsyncComponent( + () => import(`@/components/panels/Entities/${this.type}`) + ) + }, +} +</script> + +<style lang="scss" scoped> +@import "common"; + +.entity-container { + width: 100%; + position: relative; + padding: 0 !important; +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue b/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue new file mode 100644 index 000000000..e977be11a --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue @@ -0,0 +1,101 @@ +<template> + <div class="entity-icon-container" + :class="{'with-color-fill': !!colorFill}" + :style="colorFillStyle"> + <img src="@/assets/img/spinner.gif" class="loading" v-if="loading"> + <i class="fas fa-circle-exclamation error" v-else-if="error" /> + <Icon v-bind="computedIcon" v-else /> + </div> +</template> + +<script> +import Icon from "@/components/elements/Icon"; + +export default { + name: "EntityIcon", + components: {Icon}, + props: { + loading: { + type: Boolean, + default: false, + }, + + error: { + type: Boolean, + default: false, + }, + + icon: { + type: Object, + required: true, + }, + + hasColorFill: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + component: null, + modalVisible: false, + } + }, + + computed: { + colorFill() { + return (this.hasColorFill && this.icon.color) ? this.icon.color : null + }, + + colorFillStyle() { + return this.colorFill ? {'background': this.colorFill} : {} + }, + + computedIcon() { + const icon = {...this.icon} + if (this.colorFill) + delete icon.color + return icon + }, + + type() { + let entityType = (this.entity.type || '') + return entityType.charAt(0).toUpperCase() + entityType.slice(1) + }, + }, +} +</script> + +<style lang="scss" scoped> +@import "vars"; + +.entity-icon-container { + width: 1.625em; + height: 1.5em; + display: inline-flex; + margin-top: 0.25em; + margin-left: 0.25em; + position: relative; + text-align: center; + justify-content: center; + align-items: center; + + &.with-color-fill { + border-radius: 1em; + } + + .loading { + position: absolute; + bottom: 0; + transform: translate(0%, -50%); + width: 1em; + height: 1em; + } + + .error { + color: $error-fg; + margin-left: .5em; + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/EntityMixin.vue b/platypush/backend/http/webapp/src/components/panels/Entities/EntityMixin.vue new file mode 100644 index 000000000..74198d298 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/EntityMixin.vue @@ -0,0 +1,38 @@ +<script> +import Utils from "@/Utils" + +export default { + name: "EntityMixin", + mixins: [Utils], + emits: ['input'], + props: { + loading: { + type: Boolean, + default: false, + }, + + error: { + type: Boolean, + default: false, + }, + + value: { + type: Object, + required: true, + }, + }, + + data() { + return { + modalVisible: false, + } + }, + + computed: { + type() { + let entityType = (this.value.type || '') + return entityType.charAt(0).toUpperCase() + entityType.slice(1) + }, + }, +} +</script> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue new file mode 100644 index 000000000..9f1d03b56 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -0,0 +1,451 @@ +<template> + <div class="row plugin entities-container"> + <Loading v-if="loading" /> + + <header> + <div class="col-11 left"> + <Selector :entity-groups="entityGroups" :value="selector" @input="selector = $event" /> + </div> + + <div class="col-1 right"> + <button title="Refresh" @click="refresh(null)"> + <i class="fa fa-sync-alt" /> + </button> + </div> + </header> + + <div class="groups-canvas"> + <EntityModal :entity="entities[modalEntityId]" + :visible="modalVisible" @close="onEntityModal(null)" + v-if="modalEntityId" + /> + + <NoItems v-if="!Object.keys(displayGroups || {})?.length">No entities found</NoItems> + + <div class="groups-container" v-else> + <div class="group fade-in" v-for="group in displayGroups" :key="group.name"> + <div class="frame"> + <div class="header"> + <span class="section left"> + <Icon v-bind="entitiesMeta[group.name].icon || {}" + v-if="selector.grouping === 'type' && entitiesMeta[group.name]" /> + <Icon :class="pluginIcons[group.name]?.class" :url="pluginIcons[group.name]?.imgUrl" + v-else-if="selector.grouping === 'plugin' && pluginIcons[group.name]" /> + </span> + + <span class="section center"> + <div class="title" v-text="entitiesMeta[group.name].name_plural" + v-if="selector.grouping === 'type' && entitiesMeta[group.name]"/> + <div class="title" v-text="group.name" v-else-if="selector.grouping === 'plugin'"/> + </span> + + <span class="section right"> + <button title="Refresh" @click="refresh(group)"> + <i class="fa fa-sync-alt" /> + </button> + </span> + </div> + + <div class="body"> + <div class="entity-frame" @click="onEntityModal(entity.id)" + v-for="entity in group.entities" :key="entity.id"> + <Entity + :value="entity" + @input="onEntityInput" + :error="!!errorEntities[entity.id]" + :loading="!!loadingEntities[entity.id]" + @loading="loadingEntities[entity.id] = $event" + /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> +</template> + +<script> +import Utils from "@/Utils" +import Loading from "@/components/Loading"; +import Icon from "@/components/elements/Icon"; +import NoItems from "@/components/elements/NoItems"; +import Entity from "./Entity.vue"; +import Selector from "./Selector.vue"; +import EntityModal from "./Modal" +import icons from '@/assets/icons.json' +import meta from './meta.json' + +export default { + name: "Entities", + components: {Loading, Icon, Entity, Selector, NoItems, EntityModal}, + mixins: [Utils], + + props: { + // Entity scan timeout in seconds + entityScanTimeout: { + type: Number, + default: 30, + }, + }, + + data() { + return { + loading: false, + loadingEntities: {}, + errorEntities: {}, + entityTimeouts: {}, + entities: {}, + modalEntityId: null, + modalVisible: false, + selector: { + grouping: 'type', + selectedEntities: {}, + }, + } + }, + + computed: { + entitiesMeta() { + return meta + }, + + pluginIcons() { + return icons + }, + + entityGroups() { + return { + 'id': Object.entries(this.groupEntities('id')).reduce((obj, [id, entities]) => { + obj[id] = entities[0] + return obj + }, {}), + 'type': this.groupEntities('type'), + 'plugin': this.groupEntities('plugin'), + } + }, + + displayGroups() { + return Object.entries(this.entityGroups[this.selector.grouping]).filter( + (entry) => entry[1].filter( + (e) => !!this.selector.selectedEntities[e.id] + ).length > 0 + ).sort((a, b) => a[0].localeCompare(b[0])).map( + ([grouping, entities]) => { + return { + name: grouping, + entities: entities.filter( + (e) => e.id in this.selector.selectedEntities + ), + } + } + ) + }, + }, + + methods: { + groupEntities(attr) { + return Object.values(this.entities).reduce((obj, entity) => { + const entities = obj[entity[attr]] || {} + entities[entity.id] = entity + obj[entity[attr]] = Object.values(entities).sort((a, b) => { + return a.name.localeCompare(b.name) + }) + + return obj + }, {}) + }, + + async refresh(group) { + const entities = (group ? group.entities : this.entities) || {} + const args = {} + if (group) + args.plugins = Object.keys(entities.reduce((obj, entity) => { + obj[entity.plugin] = true + return obj + }, {})) + + this.loadingEntities = Object.values(entities).reduce((obj, entity) => { + const self = this + const id = entity.id + if (this.entityTimeouts[id]) + clearTimeout(this.entityTimeouts[id]) + + this.entityTimeouts[id] = setTimeout(() => { + if (self.loadingEntities[id]) + delete self.loadingEntities[id] + if (self.entityTimeouts[id]) + delete self.entityTimeouts[id] + + self.errorEntities[id] = entity + self.notify({ + error: true, + title: entity.plugin, + text: `Scan timeout for ${entity.name}`, + }) + }, this.entityScanTimeout * 1000) + + obj[id] = true + return obj + }, {}) + + await this.request('entities.scan', args) + }, + + async sync() { + this.loading = true + + try { + this.entities = (await this.request('entities.get')).reduce((obj, entity) => { + entity.name = entity?.meta?.name_override || entity.name + entity.meta = { + ...(meta[entity.type] || {}), + ...(entity.meta || {}), + } + + obj[entity.id] = entity + return obj + }, {}) + + this.selector.selectedEntities = this.entityGroups.id + } finally { + this.loading = false + } + }, + + clearEntityTimeouts(entityId) { + if (this.errorEntities[entityId]) + delete this.errorEntities[entityId] + if (this.loadingEntities[entityId]) + delete this.loadingEntities[entityId] + if (this.entityTimeouts[entityId]) { + clearTimeout(this.entityTimeouts[entityId]) + delete this.entityTimeouts[entityId] + } + }, + + onEntityInput(entity) { + this.entities[entity.id] = entity + this.clearEntityTimeouts(entity.id) + if (this.loadingEntities[entity.id]) + delete this.loadingEntities[entity.id] + }, + + onEntityUpdate(event) { + const entityId = event.entity.id + if (entityId == null) + return + + this.clearEntityTimeouts(entityId) + const entity = {...event.entity} + if (event.entity?.state == null) + entity.state = this.entities[entityId]?.state + if (entity.meta?.name_override?.length) + entity.name = entity.meta.name_override + else if (this.entities[entityId]?.meta?.name_override?.length) + entity.name = this.entities[entityId].meta.name_override + else + entity.name = event.entity?.name || this.entities[entityId]?.name + + entity.meta = { + ...(meta[event.entity.type] || {}), + ...(this.entities[entityId]?.meta || {}), + ...(event.entity?.meta || {}), + } + + this.entities[entityId] = entity + }, + + onEntityDelete(event) { + const entityId = event.entity?.id + if (entityId == null) + return + if (entityId === this.modalEntityId) + this.modalEntityId = null + if (this.entities[entityId]) + delete this.entities[entityId] + }, + + onEntityModal(entityId) { + if (entityId) { + this.modalEntityId = entityId + this.modalVisible = true + } else { + this.modalEntityId = null + this.modalVisible = false + } + }, + }, + + async mounted() { + this.subscribe( + this.onEntityUpdate, + 'on-entity-update', + 'platypush.message.event.entities.EntityUpdateEvent' + ) + + this.subscribe( + this.onEntityDelete, + 'on-entity-delete', + 'platypush.message.event.entities.EntityDeleteEvent' + ) + + await this.sync() + await this.refresh() + }, +} +</script> + +<style lang="scss" scoped> +@import "vars"; +@import "~@/style/items"; + +.entities-container { + --groups-per-row: 1; + + @include from($desktop) { + --groups-per-row: 2; + } + + @include from($fullhd) { + --groups-per-row: 3; + } + + width: 100%; + height: 100%; + overflow: auto; + color: $default-fg-2; + font-weight: 400; + + button { + background: #ffffff00; + border: 0; + + &:hover { + color: $default-hover-fg; + } + } + + header { + width: 100%; + height: $selector-height; + display: flex; + background: $default-bg-2; + box-shadow: $border-shadow-bottom; + position: relative; + + .right { + position: absolute; + right: 0; + text-align: right; + margin-right: 0.5em; + padding-right: 0.5em; + + button { + padding: 0.5em 0; + } + } + } + + .groups-canvas { + width: 100%; + height: calc(100% - #{$selector-height}); + overflow: auto; + } + + .groups-container { + @include from($desktop) { + column-count: var(--groups-per-row); + } + } + + .group { + width: 100%; + max-height: 100%; + position: relative; + padding: $main-margin 0; + display: flex; + break-inside: avoid; + + @include from ($tablet) { + padding: $main-margin; + } + + .frame { + @include from($desktop) { + max-height: calc(100vh - #{$header-height} - #{$main-margin}); + } + + display: flex; + flex-direction: column; + flex-grow: 1; + position: relative; + box-shadow: $group-shadow; + border-radius: 1em; + } + + .header { + width: 100%; + height: $header-height; + display: table; + background: $header-bg; + box-shadow: $header-shadow; + border-radius: 1em 1em 0 0; + + .section { + height: 100%; + display: table-cell; + vertical-align: middle; + + &.left, &.right { + width: 10%; + } + + &.right { + text-align: right; + } + + &.center { + width: 80%; + text-align: center; + } + } + } + + .body { + background: $default-bg-2; + max-height: calc(100% - #{$header-height}); + overflow: auto; + flex-grow: 1; + + .entity-frame:last-child { + border-radius: 0 0 1em 1em; + } + } + } + + :deep(.modal) { + @include until($tablet) { + width: 95%; + } + + .content { + @include until($tablet) { + width: 100%; + } + + @include from($tablet) { + min-width: 30em; + } + + .body { + padding: 0; + + .table-row { + padding: 0.5em; + } + } + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue new file mode 100644 index 000000000..ae85e59bc --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue @@ -0,0 +1,222 @@ +<template> + <div class="entity light-container"> + <div class="head" :class="{expanded: expanded}"> + <div class="col-1 icon"> + <EntityIcon :icon="icon" :hasColorFill="true" + :loading="loading" :error="error" /> + </div> + + <div class="col-s-8 col-m-9 label"> + <div class="name" v-text="value.name" /> + </div> + + <div class="col-s-3 col-m-2 buttons pull-right"> + <ToggleSwitch :value="value.on" @input="toggle" + @click.stop :disabled="loading || value.is_read_only" /> + + <button @click.stop="expanded = !expanded"> + <i class="fas" + :class="{'fa-angle-up': expanded, 'fa-angle-down': !expanded}" /> + </button> + </div> + </div> + + <div class="body" v-if="expanded" @click.stop="prevent"> + <div class="row" v-if="cssColor"> + <div class="icon"> + <i class="fas fa-palette" /> + </div> + <div class="input"> + <input type="color" :value="cssColor" @change="setLight({color: $event.target.value})" /> + </div> + </div> + + <div class="row" v-if="value.brightness"> + <div class="icon"> + <i class="fas fa-sun" /> + </div> + <div class="input"> + <Slider :range="[value.brightness_min, value.brightness_max]" + :value="value.brightness" @input="setLight({brightness: $event.target.value})" /> + </div> + </div> + + <div class="row" v-if="value.saturation"> + <div class="icon"> + <i class="fas fa-droplet" /> + </div> + <div class="input"> + <Slider :range="[value.saturation_min, value.saturation_max]" + :value="value.saturation" @input="setLight({saturation: $event.target.value})" /> + </div> + </div> + + <div class="row" v-if="value.temperature"> + <div class="icon"> + <i class="fas fa-temperature-half" /> + </div> + <div class="input"> + <Slider :range="[value.temperature_min, value.temperature_max]" + :value="value.temperature" @input="setLight({temperature: $event.target.value})"/> + </div> + </div> + </div> + </div> +</template> + +<script> +import Slider from "@/components/elements/Slider" +import ToggleSwitch from "@/components/elements/ToggleSwitch" +import EntityMixin from "./EntityMixin" +import EntityIcon from "./EntityIcon" +import {ColorConverter} from "@/components/panels/Light/color"; + +export default { + name: 'Light', + components: {ToggleSwitch, Slider, EntityIcon}, + mixins: [EntityMixin], + + data() { + return { + expanded: false, + colorConverter: null, + } + }, + + computed: { + rgbColor() { + if (this.value.meta?.icon?.color) + return this.value.meta.icon.color + + if ( + !this.colorConverter || ( + this.value.hue == null && + (this.value.x == null || this.value.y == null) + ) + ) + return + + if (this.value.x && this.value.y) + return this.colorConverter.xyToRgb( + this.value.x, + this.value.y, + this.value.brightness + ) + + return this.colorConverter.hslToRgb( + this.value.hue, + this.value.saturation, + this.value.brightness + ) + }, + + cssColor() { + const rgb = this.rgbColor + if (rgb) + return this.colorConverter.rgbToHex(rgb) + return null + }, + + icon() { + const icon = {...(this.value.meta?.icon || {})} + if (!icon.color && this.cssColor) + icon.color = this.cssColor + return icon + }, + }, + + methods: { + prevent(event) { + event.stopPropagation() + return false + }, + + async toggle(event) { + event.stopPropagation() + this.$emit('loading', true) + + try { + await this.request('entities.execute', { + id: this.value.id, + action: 'toggle', + }) + } finally { + this.$emit('loading', false) + } + }, + + async setLight(attrs) { + if (attrs.color) { + const rgb = this.colorConverter.hexToRgb(attrs.color) + if (this.value.x != null && this.value.y != null) { + attrs.xy = this.colorConverter.rgbToXY(...rgb) + delete attrs.color + } else if (this.value.hue != null) { + [attrs.hue, attrs.saturation, attrs.brightness] = this.colorConverter.rgbToHsl(...rgb) + delete attrs.color + } + } + + this.execute({ + type: 'request', + action: this.value.plugin + '.set_lights', + args: { + lights: [this.value.external_id], + ...attrs, + } + }) + }, + }, + + mounted() { + const ranges = {} + if (this.value.hue) + ranges.hue = [this.value.hue_min, this.value.hue_max] + if (this.value.saturation) + ranges.sat = [this.value.saturation_min, this.value.saturation_max] + if (this.value.brightness) + ranges.bri = [this.value.brightness_min, this.value.brightness_max] + if (this.value.temperature) + ranges.ct = [this.value.temperature_min, this.value.temperature_max] + + this.colorConverter = new ColorConverter(ranges) + }, +} +</script> + +<style lang="scss" scoped> +@import "common"; + +.light-container { + .head { + .buttons { + button { + margin-right: 0.5em; + } + } + } + + .body { + .row { + display: flex; + + .icon { + width: 2em; + text-align: center; + } + + .input { + width: calc(100% - 2em); + + [type=color] { + width: 100%; + } + + :deep(.slider) { + margin-top: 0.5em; + } + } + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue new file mode 100644 index 000000000..e130adc7b --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue @@ -0,0 +1,252 @@ +<template> + <Modal :visible="visible" :title="entity.name || entity.external_id"> + <ConfirmDialog ref="deleteConfirmDiag" title="Confirm entity deletion" @input="onDelete"> + Are you <b>sure</b> that you want to delete this entity? <br/><br/> + Note: you should only delete an entity if its plugin has been disabled + or the entity is no longer reachable.<br/><br/> + Otherwise, the entity will simply be created again upon the next scan. + </ConfirmDialog> + + <div class="table-row"> + <div class="title"> + Name + <EditButton @click="editName = true" v-if="!editName" /> + </div> + <div class="value"> + <NameEditor :value="entity.name" @input="onRename" + @cancel="editName = false" :disabled="loading" v-if="editName" /> + <span v-text="entity.name" v-else /> + </div> + </div> + + <div class="table-row"> + <div class="title"> + Icon + <EditButton @click="editIcon = true" v-if="!editIcon" /> + </div> + <div class="value icon-canvas"> + <span class="icon-editor" v-if="editIcon"> + <NameEditor :value="entity.meta?.icon?.class || entity.meta?.icon?.url" @input="onIconEdit" + @cancel="editIcon = false" :disabled="loading"> + <button type="button" title="Reset" @click="onIconEdit(null)" + @touch="onIconEdit(null)"> + <i class="fas fa-rotate-left" /> + </button> + </NameEditor> + <span class="help"> + Supported: image URLs or + <a href="https://fontawesome.com/icons" target="_blank">FontAwesome icon classes</a>. + </span> + </span> + + <Icon v-bind="entity?.meta?.icon || {}" v-else /> + </div> + </div> + + <div class="table-row"> + <div class="title"> + Icon color + </div> + <div class="value icon-color-picker"> + <input type="color" :value="entity.meta?.icon?.color" @change="onIconColorEdit"> + <button type="button" title="Reset" @click="onIconColorEdit(null)" + @touch="onIconColorEdit(null)"> + <i class="fas fa-rotate-left" /> + </button> + </div> + </div> + + <div class="table-row"> + <div class="title">Plugin</div> + <div class="value" v-text="entity.plugin" /> + </div> + + <div class="table-row"> + <div class="title">Internal ID</div> + <div class="value" v-text="entity.id" /> + </div> + + <div class="table-row" v-if="entity.external_id"> + <div class="title">External ID</div> + <div class="value" v-text="entity.external_id" /> + </div> + + <div class="table-row" v-if="entity.description"> + <div class="title">Description</div> + <div class="value" v-text="entity.description" /> + </div> + + <div v-for="value, attr in entity.data || {}" :key="attr"> + <div class="table-row" v-if="value != null"> + <div class="title" v-text="prettify(attr)" /> + <div class="value" v-text="'' + value" /> + </div> + </div> + + <div class="table-row" v-if="entity.created_at"> + <div class="title">Created at</div> + <div class="value" v-text="formatDateTime(entity.created_at)" /> + </div> + + <div class="table-row" v-if="entity.updated_at"> + <div class="title">Updated at</div> + <div class="value" v-text="formatDateTime(entity.updated_at)" /> + </div> + + <div class="table-row delete-entity-container"> + <div class="title">Delete Entity</div> + <div class="value"> + <button @click="$refs.deleteConfirmDiag.show()"> + <i class="fas fa-trash" /> + </button> + </div> + </div> + </Modal> +</template> + +<script> +import Modal from "@/components/Modal"; +import Icon from "@/components/elements/Icon"; +import ConfirmDialog from "@/components/elements/ConfirmDialog"; +import EditButton from "@/components/elements/EditButton"; +import NameEditor from "@/components/elements/NameEditor"; +import Utils from "@/Utils"; +import meta from './meta.json' + +export default { + name: "Entity", + components: {Modal, EditButton, NameEditor, Icon, ConfirmDialog}, + mixins: [Utils], + emits: ['input', 'loading'], + props: { + entity: { + type: Object, + required: true, + }, + + visible: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + loading: false, + editName: false, + editIcon: false, + } + }, + + methods: { + async onRename(newName) { + this.loading = true + + try { + const req = {} + req[this.entity.id] = newName + await this.request('entities.rename', req) + } finally { + this.loading = false + this.editName = false + } + }, + + async onDelete() { + this.loading = true + + try { + await this.request('entities.delete', [this.entity.id]) + } finally { + this.loading = false + } + }, + + async onIconEdit(newIcon) { + this.loading = true + + try { + const icon = {url: null, class: null} + if (newIcon?.length) { + if (newIcon.startsWith('http')) + icon.url = newIcon + else + icon.class = newIcon + } else { + icon.url = (meta[this.entity.type] || {})?.icon?.url + icon.class = (meta[this.entity.type] || {})?.icon?.['class'] + } + + const req = {} + req[this.entity.id] = {icon: icon} + await this.request('entities.set_meta', req) + } finally { + this.loading = false + this.editIcon = false + } + }, + + async onIconColorEdit(event) { + this.loading = true + + try { + const icon = this.entity.meta?.icon || {} + if (event) + icon.color = event.target.value + else + icon.color = null + + const req = {} + req[this.entity.id] = {icon: icon} + await this.request('entities.set_meta', req) + } finally { + this.loading = false + this.editIcon = false + } + }, + }, +} +</script> + +<style lang="scss" scoped> +:deep(.modal) { + .icon-canvas { + display: inline-flex; + align-items: center; + + @include until($tablet) { + .icon-container { + justify-content: left; + } + } + + @include from($tablet) { + .icon-container { + justify-content: right; + } + } + } + + .icon-editor { + display: flex; + flex-direction: column; + } + + button { + border: none; + background: none; + padding: 0 0.5em; + } + + .help { + font-size: 0.75em; + } + + .delete-entity-container { + color: $error-fg; + button { + color: $error-fg; + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue new file mode 100644 index 000000000..d8abd90b4 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue @@ -0,0 +1,233 @@ +<template> + <div class="entities-selectors-container"> + <div class="selector"> + <Dropdown title="Group by" icon-class="fas fa-eye" ref="groupingSelector"> + <DropdownItem v-for="g in visibleGroupings" :key="g" :text="prettifyGroupingName(g)" + :item-class="{selected: value?.grouping === g}" + @click="onGroupingChanged(g)" /> + </Dropdown> + </div> + + <div class="selector" :class="{active: isGroupFilterActive}" v-if="value?.grouping"> + <Dropdown title="Filter by" icon-class="fas fa-filter" ref="groupSelector" + keep-open-on-item-click> + <DropdownItem v-for="g in sortedGroups" :key="g" :text="g" + v-bind="iconForGroup(g)" :item-class="{selected: !!selectedGroups[g]}" + @click.stop="toggleGroup(g)" /> + </Dropdown> + </div> + + <div class="selector" v-if="Object.keys(entityGroups.id || {}).length"> + <input ref="search" type="text" class="search-bar" placeholder="🔎" v-model="searchTerm"> + </div> + </div> +</template> + +<script> +import Utils from '@/Utils' +import Dropdown from "@/components/elements/Dropdown"; +import DropdownItem from "@/components/elements/DropdownItem"; +import meta from './meta.json' +import pluginIcons from '@/assets/icons.json' + +export default { + name: "Selector", + emits: ['input'], + mixins: [Utils], + components: {Dropdown, DropdownItem}, + props: { + entityGroups: { + type: Object, + required: true, + }, + + value: { + type: Object, + required: true, + }, + }, + + data() { + return { + selectedGroups: {}, + searchTerm: '', + } + }, + + computed: { + visibleGroupings() { + return Object.keys(this.entityGroups).filter( + (grouping) => grouping !== 'id' + ) + }, + + sortedGroups() { + return Object.keys(this.entityGroups[this.value?.grouping] || {}).sort() + }, + + typesMeta() { + return meta + }, + + isGroupFilterActive() { + return Object.keys(this.selectedGroups).length !== this.sortedGroups.length + }, + + selectedEntities() { + return Object.values(this.entityGroups.id).filter((entity) => { + if (!this.selectedGroups[entity[this.value?.grouping]]) + return false + + if (this.searchTerm?.length) { + const searchTerm = this.searchTerm.toLowerCase() + return ( + ((entity.name || '').toLowerCase()).indexOf(searchTerm) >= 0 || + ((entity.plugin || '').toLowerCase()).indexOf(searchTerm) >= 0 || + ((entity.external_id || '').toLowerCase()).indexOf(searchTerm) >= 0 || + (entity.id || 0).toString() == searchTerm + ) + } + + return true + }).reduce((obj, entity) => { + obj[entity.id] = entity + return obj + }, {}) + }, + }, + + methods: { + prettifyGroupingName(name) { + return name ? this.prettify(name) + 's' : '' + }, + + iconForGroup(group) { + if (this.value.grouping === 'plugin' && pluginIcons[group]) { + const icon = pluginIcons[group] + return { + 'icon-class': icon['class']?.length || !icon.imgUrl?.length ? + icon['class'] : 'fas fa-gears', + 'icon-url': icon.imgUrl, + } + } + + return {} + }, + + synchronizeSelectedEntities() { + const value = {...this.value} + value.selectedEntities = this.selectedEntities + this.$emit('input', value) + }, + + updateSearchTerm() { + const value = {...this.value} + value.searchTerm = this.searchTerm + value.selectedEntities = this.selectedEntities + this.$emit('input', value) + }, + + refreshGroupFilter(reset) { + if (reset) + this.selectedGroups = Object.keys( + this.entityGroups[this.value?.grouping] || {} + ).reduce( + (obj, group) => { + obj[group] = true + return obj + }, {} + ) + else { + for (const group of Object.keys(this.entityGroups[this.value?.grouping])) + if (this.selectedGroups[group] == null) + this.selectedGroups[group] = true + } + + this.synchronizeSelectedEntities() + }, + + toggleGroup(group) { + this.selectedGroups[group] = !this.selectedGroups[group] + this.synchronizeSelectedEntities() + }, + + onGroupingChanged(grouping) { + if (!this.entityGroups[grouping] || grouping === this.value?.grouping) + return false + + const value = {...this.value} + value.grouping = grouping + this.$emit('input', value) + }, + }, + + mounted() { + this.refreshGroupFilter(true) + this.$watch(() => this.value?.grouping, () => { this.refreshGroupFilter(true) }) + this.$watch(() => this.searchTerm, this.updateSearchTerm) + this.$watch(() => this.entityGroups, () => { this.refreshGroupFilter(false) }) + }, +} +</script> + +<style lang="scss" scoped> +.entities-selectors-container { + width: 100%; + display: flex; + align-items: center; + + .selector { + height: 100%; + display: inline-flex; + + &.active { + :deep(.dropdown-container) { + button { + color: $default-hover-fg; + } + } + } + } + + @media (max-width: 330px) { + .search-bar { + display: none; + } + } + + :deep(.dropdown-container) { + height: 100%; + display: flex; + + button { + height: 100%; + background: $default-bg-2; + border: 0; + padding: 0.5em; + + &:hover { + color: $default-hover-fg; + } + } + + .item { + padding: 0.5em 4em 0.5em 0.5em; + border: 0; + box-shadow: none; + + .col-1.icon { + width: 1.5em; + } + + &.selected { + font-weight: bold; + background: #ffffff00; + } + + &:hover { + background: $hover-bg; + } + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue new file mode 100644 index 000000000..899436572 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue @@ -0,0 +1,57 @@ +<template> + <div class="entity switch-container"> + <div class="head"> + <div class="col-1 icon"> + <EntityIcon :icon="value.meta?.icon || {}" + :loading="loading" :error="error" /> + </div> + + <div class="col-9 label"> + <div class="name" v-text="value.name" /> + </div> + + <div class="col-2 switch pull-right"> + <ToggleSwitch :value="value.state" @input="toggle" + @click.stop :disabled="loading || value.is_read_only" /> + </div> + </div> + </div> +</template> + +<script> +import ToggleSwitch from "@/components/elements/ToggleSwitch" +import EntityIcon from "./EntityIcon" +import EntityMixin from "./EntityMixin" + +export default { + name: 'Switch', + components: {ToggleSwitch, EntityIcon}, + mixins: [EntityMixin], + + methods: { + async toggle(event) { + event.stopPropagation() + this.$emit('loading', true) + + try { + await this.request('entities.execute', { + id: this.value.id, + action: 'toggle', + }) + } finally { + this.$emit('loading', false) + } + }, + }, +} +</script> + +<style lang="scss" scoped> +@import "common"; + +.switch-container { + .switch { + direction: rtl; + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/common.scss b/platypush/backend/http/webapp/src/components/panels/Entities/common.scss new file mode 100644 index 000000000..289b1ec85 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/common.scss @@ -0,0 +1,55 @@ +@import "vars"; + +.entity { + width: 100%; + display: flex; + flex-direction: column; + + .head { + height: 100%; + display: flex; + align-items: center; + padding: 0.75em 0.25em; + + .label { + margin-top: 0.25em; + } + + &.expanded { + background: $selected-bg; + font-weight: bold; + } + + .pull-right { + display: inline-flex; + align-items: center; + direction: rtl; + padding-right: 0.5em; + + :deep(.power-switch) { + margin-top: 0.25em; + } + } + } + + .body { + @extend .fade-in; + display: flex; + flex-direction: column; + padding: 0.5em; + background: linear-gradient(0deg, $default-bg-5, $default-bg-2); + border-top: 1px solid $border-color-1; + box-shadow: $border-shadow-bottom; + } + + button { + height: 2em; + background: none; + border: none; + padding: 0 0 0 1em; + + &:hover { + color: $default-hover-fg; + } + } +} diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/meta.json b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json new file mode 100644 index 000000000..e4eafd8f0 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json @@ -0,0 +1,33 @@ +{ + "entity": { + "name": "Entity", + "name_plural": "Entities", + "icon": { + "class": "fas fa-circle-question" + } + }, + + "device": { + "name": "Device", + "name_plural": "Devices", + "icon": { + "class": "fas fa-gear" + } + }, + + "switch": { + "name": "Switch", + "name_plural": "Switches", + "icon": { + "class": "fas fa-toggle-on" + } + }, + + "light": { + "name": "Light", + "name_plural": "Lights", + "icon": { + "class": "fas fa-lightbulb" + } + } +} diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/vars.scss b/platypush/backend/http/webapp/src/components/panels/Entities/vars.scss new file mode 100644 index 000000000..388ce2595 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/vars.scss @@ -0,0 +1,2 @@ +$main-margin: 1em; +$selector-height: 2.5em; diff --git a/platypush/backend/http/webapp/src/components/panels/Light/color.js b/platypush/backend/http/webapp/src/components/panels/Light/color.js index f74464346..775df1b94 100644 --- a/platypush/backend/http/webapp/src/components/panels/Light/color.js +++ b/platypush/backend/http/webapp/src/components/panels/Light/color.js @@ -211,4 +211,21 @@ export class ColorConverter { console.debug('Could not determine color space') console.debug(color) } + + hexToRgb(hex) { + return [ + hex.slice(1, 3), + hex.slice(3, 5), + hex.slice(5, 7), + ].map(_ => parseInt(_, 16)) + } + + rgbToHex(rgb) { + return '#' + rgb.map((x) => { + let hex = x.toString(16) + if (hex.length < 2) + hex = '0' + hex + return hex + }).join('') + } } diff --git a/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue b/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue index e0466ba98..516e8687b 100644 --- a/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue +++ b/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue @@ -356,7 +356,7 @@ export default { async refreshStatus() { this.loading.status = true try { - this.status = await this.zrequest('status') + this.status = await this.zrequest('controller_status') } finally { this.loading.status = false } diff --git a/platypush/backend/http/webapp/src/style/icons.scss b/platypush/backend/http/webapp/src/style/icons.scss index fb599674e..a2cb7ef1a 100644 --- a/platypush/backend/http/webapp/src/style/icons.scss +++ b/platypush/backend/http/webapp/src/style/icons.scss @@ -1,3 +1,5 @@ +$icon-container-size: 3em; + @mixin icon { content: ' '; background-size: 1em 1em; diff --git a/platypush/backend/http/webapp/src/style/items.scss b/platypush/backend/http/webapp/src/style/items.scss index ae1adf67c..ba88c4ae5 100644 --- a/platypush/backend/http/webapp/src/style/items.scss +++ b/platypush/backend/http/webapp/src/style/items.scss @@ -1,3 +1,5 @@ +$header-height: 3.5em; + .item { display: flex; align-items: center; @@ -78,3 +80,43 @@ } } } + +:deep(.table-row) { + width: 100%; + display: flex; + flex-direction: column; + box-shadow: $row-shadow; + + &:hover { + background: $hover-bg; + } + + @include from($tablet) { + flex-direction: row; + align-items: center; + } + + .title, + .value { + width: 100%; + display: flex; + + @include from($tablet) { + display: inline-flex; + } + } + + .title { + font-weight: bold; + + @include from($tablet) { + width: 30%; + } + } + + .value { + @include from($tablet) { + justify-content: right; + } + } +} diff --git a/platypush/backend/http/webapp/src/style/themes/light.scss b/platypush/backend/http/webapp/src/style/themes/light.scss index c7f621765..c75b45050 100644 --- a/platypush/backend/http/webapp/src/style/themes/light.scss +++ b/platypush/backend/http/webapp/src/style/themes/light.scss @@ -6,10 +6,13 @@ $default-bg-4: #f1f3f2 !default; $default-bg-5: #edf0ee !default; $default-bg-6: #e4eae8 !default; $default-bg-7: #e4e4e4 !default; +$error-fg: #ad1717 !default; $default-fg: black !default; $default-fg-2: #23513a !default; $default-fg-3: #195331b3 !default; +$header-bg: linear-gradient(0deg, #c0e8e4, #e4f8f4) !default; +$no-items-color: #555555; //// Notifications $notification-bg: rgba(185, 255, 193, 0.9) !default; @@ -51,6 +54,8 @@ $border-shadow-bottom: 0 3px 2px -1px $default-shadow-color; $border-shadow-left: -2.5px 0 4px 0 $default-shadow-color; $border-shadow-right: 2.5px 0 4px 0 $default-shadow-color; $border-shadow-bottom-right: 2.5px 2.5px 3px 0 $default-shadow-color; +$header-shadow: 0px 1px 3px 1px #bbb !default; +$group-shadow: 3px -2px 6px 1px #98b0a0; //// Modals $modal-header-bg: #e0e0e0 !default; @@ -141,5 +146,7 @@ $dropdown-shadow: 1px 1px 1px #bbb !default; //// Scrollbars $scrollbar-track-bg: $slider-bg !default; $scrollbar-track-shadow: inset 1px 0px 3px 0 $slider-track-shadow !default; -$scrollbar-thumb-bg: #50ca80 !default; +$scrollbar-thumb-bg: #a5a2a2 !default; +//// Rows +$row-shadow: 0 0 1px 0.5px #cfcfcf !default; diff --git a/platypush/backend/http/webapp/src/utils/Text.vue b/platypush/backend/http/webapp/src/utils/Text.vue new file mode 100644 index 000000000..26aee37a7 --- /dev/null +++ b/platypush/backend/http/webapp/src/utils/Text.vue @@ -0,0 +1,17 @@ +<script> +export default { + name: "Text", + methods: { + capitalize(text) { + if (!text?.length) + return text + + return text.charAt(0).toUpperCase() + text.slice(1) + }, + + prettify(text) { + return text.split('_').map((t) => this.capitalize(t)).join(' ') + }, + }, +} +</script> diff --git a/platypush/backend/http/webapp/src/views/Panel.vue b/platypush/backend/http/webapp/src/views/Panel.vue index e143b04a9..56a72e7ff 100644 --- a/platypush/backend/http/webapp/src/views/Panel.vue +++ b/platypush/backend/http/webapp/src/views/Panel.vue @@ -45,10 +45,7 @@ export default { methods: { initSelectedPanel() { const match = this.$route.hash.match('#?([a-zA-Z0-9.]+)[?]?(.*)') - if (!match) - return - - const plugin = match[1] + const plugin = match ? match[1] : 'entities' if (plugin?.length) this.selectedPanel = plugin }, @@ -90,7 +87,7 @@ export default { initializeDefaultViews() { this.plugins.execute = {} - this.plugins.switches = {} + this.plugins.entities = {} }, }, @@ -113,7 +110,7 @@ main { height: 100%; display: flex; - @media screen and (max-width: $tablet) { + @include until($tablet) { flex-direction: column; } diff --git a/platypush/backend/light/hue/__init__.py b/platypush/backend/light/hue/__init__.py index 95cbed6ff..686e1a51b 100644 --- a/platypush/backend/light/hue/__init__.py +++ b/platypush/backend/light/hue/__init__.py @@ -1,100 +1,24 @@ -from threading import Thread +import warnings from platypush.backend import Backend -from platypush.context import get_plugin -from platypush.message.event.light import LightStatusChangeEvent class LightHueBackend(Backend): """ - This backend will periodically check for the status of your configured - Philips Hue light devices and trigger events when the status of a device - (power, saturation, brightness or hue) changes. + **DEPRECATED** - Triggers: - - * :class:`platypush.message.event.light.LightStatusChangeEvent` when the - status of a lightbulb changes - - Requires: - - * The :class:`platypush.plugins.light.hue.LightHuePlugin` plugin to be - active and configured. + The polling logic of this backend has been moved to the ``light.hue`` plugin itself. """ - _DEFAULT_POLL_SECONDS = 10 - - def __init__(self, poll_seconds=_DEFAULT_POLL_SECONDS, *args, **kwargs): - """ - :param poll_seconds: How often the backend will poll the Hue plugin for - status updates. Default: 10 seconds - :type poll_seconds: float - """ - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.poll_seconds = poll_seconds - - @staticmethod - def _get_lights(): - plugin = get_plugin('light.hue') - if not plugin: - plugin = get_plugin('light.hue', reload=True) - - return plugin.get_lights().output - - def _listener(self): - def _thread(): - lights = self._get_lights() - - while not self.should_stop(): - try: - lights_new = self._get_lights() - - for light_id, light in lights_new.items(): - event_args = {} - state = light.get('state') - prev_state = lights.get(light_id, {}).get('state', {}) - - if 'on' in state and state.get('on') != prev_state.get('on'): - event_args['on'] = state.get('on') - if 'bri' in state and state.get('bri') != prev_state.get('bri'): - event_args['bri'] = state.get('bri') - if 'sat' in state and state.get('sat') != prev_state.get('sat'): - event_args['sat'] = state.get('sat') - if 'hue' in state and state.get('hue') != prev_state.get('hue'): - event_args['hue'] = state.get('hue') - if 'ct' in state and state.get('ct') != prev_state.get('ct'): - event_args['ct'] = state.get('ct') - if 'xy' in state and state.get('xy') != prev_state.get('xy'): - event_args['xy'] = state.get('xy') - - if event_args: - event_args['plugin_name'] = 'light.hue' - event_args['light_id'] = light_id - event_args['light_name'] = light.get('name') - self.bus.post(LightStatusChangeEvent(**event_args)) - - lights = lights_new - except Exception as e: - self.logger.exception(e) - finally: - self.wait_stop(self.poll_seconds) - - return _thread + warnings.warn( + 'The light.hue backend is deprecated. All of its logic ' + 'has been moved to the light.hue plugin itself.' + ) def run(self): super().run() - self.logger.info('Starting Hue lights backend') - - while not self.should_stop(): - try: - poll_thread = Thread(target=self._listener()) - poll_thread.start() - poll_thread.join() - except Exception as e: - self.logger.exception(e) - self.wait_stop(self.poll_seconds) - self.logger.info('Stopped Hue lights backend') diff --git a/platypush/backend/light/hue/manifest.yaml b/platypush/backend/light/hue/manifest.yaml index 5d5e4ed03..8103a7866 100644 --- a/platypush/backend/light/hue/manifest.yaml +++ b/platypush/backend/light/hue/manifest.yaml @@ -1,7 +1,5 @@ manifest: events: - platypush.message.event.light.LightStatusChangeEvent: when thestatus of a lightbulb - changes install: pip: [] package: platypush.backend.light.hue diff --git a/platypush/backend/mail/__init__.py b/platypush/backend/mail/__init__.py index 686075286..84f1f8ab6 100644 --- a/platypush/backend/mail/__init__.py +++ b/platypush/backend/mail/__init__.py @@ -8,15 +8,18 @@ from queue import Queue, Empty from threading import Thread, RLock from typing import List, Dict, Any, Optional, Tuple -from sqlalchemy import create_engine, Column, Integer, String, DateTime -import sqlalchemy.engine as engine -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import engine, create_engine, Column, Integer, String, DateTime +from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from platypush.backend import Backend from platypush.config import Config from platypush.context import get_plugin -from platypush.message.event.mail import MailReceivedEvent, MailSeenEvent, MailFlaggedEvent, MailUnflaggedEvent +from platypush.message.event.mail import ( + MailReceivedEvent, + MailSeenEvent, + MailFlaggedEvent, + MailUnflaggedEvent, +) from platypush.plugins.mail import MailInPlugin, Mail # <editor-fold desc="Database tables"> @@ -25,7 +28,8 @@ Session = scoped_session(sessionmaker()) class MailboxStatus(Base): - """ Models the MailboxStatus table, containing information about the state of a monitored mailbox. """ + """Models the MailboxStatus table, containing information about the state of a monitored mailbox.""" + __tablename__ = 'MailboxStatus' mailbox_id = Column(Integer, primary_key=True) @@ -64,8 +68,13 @@ class MailBackend(Backend): """ - def __init__(self, mailboxes: List[Dict[str, Any]], timeout: Optional[int] = 60, poll_seconds: Optional[int] = 60, - **kwargs): + def __init__( + self, + mailboxes: List[Dict[str, Any]], + timeout: Optional[int] = 60, + poll_seconds: Optional[int] = 60, + **kwargs + ): """ :param mailboxes: List of mailboxes to be monitored. Each mailbox entry contains a ``plugin`` attribute to identify the :class:`platypush.plugins.mail.MailInPlugin` plugin that will be used (e.g. ``mail.imap``) @@ -128,9 +137,13 @@ class MailBackend(Backend): # Parse mailboxes for i, mbox in enumerate(mailboxes): - assert 'plugin' in mbox, 'No plugin attribute specified for mailbox n.{}'.format(i) + assert ( + 'plugin' in mbox + ), 'No plugin attribute specified for mailbox n.{}'.format(i) plugin = get_plugin(mbox.pop('plugin')) - assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format(plugin) + assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format( + plugin + ) name = mbox.pop('name') if 'name' in mbox else 'Mailbox #{}'.format(i + 1) self.mailboxes.append(Mailbox(plugin=plugin, name=name, args=mbox)) @@ -144,7 +157,10 @@ class MailBackend(Backend): # <editor-fold desc="Database methods"> def _db_get_engine(self) -> engine.Engine: - return create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False}) + return create_engine( + 'sqlite:///{}'.format(self.dbfile), + connect_args={'check_same_thread': False}, + ) def _db_load_mailboxes_status(self) -> None: mailbox_ids = list(range(len(self.mailboxes))) @@ -153,12 +169,18 @@ class MailBackend(Backend): session = Session() records = { record.mailbox_id: record - for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all() + for record in session.query(MailboxStatus) + .filter(MailboxStatus.mailbox_id.in_(mailbox_ids)) + .all() } - for mbox_id, mbox in enumerate(self.mailboxes): + for mbox_id, _ in enumerate(self.mailboxes): if mbox_id not in records: - record = MailboxStatus(mailbox_id=mbox_id, unseen_message_ids='[]', flagged_message_ids='[]') + record = MailboxStatus( + mailbox_id=mbox_id, + unseen_message_ids='[]', + flagged_message_ids='[]', + ) session.add(record) else: record = records[mbox_id] @@ -170,19 +192,25 @@ class MailBackend(Backend): session.commit() - def _db_get_mailbox_status(self, mailbox_ids: List[int]) -> Dict[int, MailboxStatus]: + def _db_get_mailbox_status( + self, mailbox_ids: List[int] + ) -> Dict[int, MailboxStatus]: with self._db_lock: session = Session() return { record.mailbox_id: record - for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all() + for record in session.query(MailboxStatus) + .filter(MailboxStatus.mailbox_id.in_(mailbox_ids)) + .all() } # </editor-fold> # <editor-fold desc="Parse unread messages logic"> @staticmethod - def _check_thread(unread_queue: Queue, flagged_queue: Queue, plugin: MailInPlugin, **args): + def _check_thread( + unread_queue: Queue, flagged_queue: Queue, plugin: MailInPlugin, **args + ): def thread(): # noinspection PyUnresolvedReferences unread = plugin.search_unseen_messages(**args).output @@ -194,8 +222,9 @@ class MailBackend(Backend): return thread - def _get_unread_seen_msgs(self, mailbox_idx: int, unread_msgs: Dict[int, Mail]) \ - -> Tuple[Dict[int, Mail], Dict[int, Mail]]: + def _get_unread_seen_msgs( + self, mailbox_idx: int, unread_msgs: Dict[int, Mail] + ) -> Tuple[Dict[int, Mail], Dict[int, Mail]]: prev_unread_msgs = self._unread_msgs[mailbox_idx] return { @@ -208,35 +237,51 @@ class MailBackend(Backend): if msg_id not in unread_msgs } - def _get_flagged_unflagged_msgs(self, mailbox_idx: int, flagged_msgs: Dict[int, Mail]) \ - -> Tuple[Dict[int, Mail], Dict[int, Mail]]: + def _get_flagged_unflagged_msgs( + self, mailbox_idx: int, flagged_msgs: Dict[int, Mail] + ) -> Tuple[Dict[int, Mail], Dict[int, Mail]]: prev_flagged_msgs = self._flagged_msgs[mailbox_idx] return { - msg_id: flagged_msgs[msg_id] - for msg_id in flagged_msgs - if msg_id not in prev_flagged_msgs - }, { - msg_id: prev_flagged_msgs[msg_id] - for msg_id in prev_flagged_msgs - if msg_id not in flagged_msgs - } + msg_id: flagged_msgs[msg_id] + for msg_id in flagged_msgs + if msg_id not in prev_flagged_msgs + }, { + msg_id: prev_flagged_msgs[msg_id] + for msg_id in prev_flagged_msgs + if msg_id not in flagged_msgs + } - def _process_msg_events(self, mailbox_id: int, unread: List[Mail], seen: List[Mail], - flagged: List[Mail], unflagged: List[Mail], last_checked_date: Optional[datetime] = None): + def _process_msg_events( + self, + mailbox_id: int, + unread: List[Mail], + seen: List[Mail], + flagged: List[Mail], + unflagged: List[Mail], + last_checked_date: Optional[datetime] = None, + ): for msg in unread: if msg.date and last_checked_date and msg.date < last_checked_date: continue - self.bus.post(MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)) + self.bus.post( + MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg) + ) for msg in seen: - self.bus.post(MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)) + self.bus.post( + MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg) + ) for msg in flagged: - self.bus.post(MailFlaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)) + self.bus.post( + MailFlaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg) + ) for msg in unflagged: - self.bus.post(MailUnflaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)) + self.bus.post( + MailUnflaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg) + ) def _check_mailboxes(self) -> List[Tuple[Dict[int, Mail], Dict[int, Mail]]]: workers = [] @@ -245,8 +290,14 @@ class MailBackend(Backend): for mbox in self.mailboxes: unread_queue, flagged_queue = [Queue()] * 2 - worker = Thread(target=self._check_thread(unread_queue=unread_queue, flagged_queue=flagged_queue, - plugin=mbox.plugin, **mbox.args)) + worker = Thread( + target=self._check_thread( + unread_queue=unread_queue, + flagged_queue=flagged_queue, + plugin=mbox.plugin, + **mbox.args + ) + ) worker.start() workers.append(worker) queues.append((unread_queue, flagged_queue)) @@ -260,7 +311,11 @@ class MailBackend(Backend): flagged = flagged_queue.get(timeout=self.timeout) results.append((unread, flagged)) except Empty: - self.logger.warning('Checks on mailbox #{} timed out after {} seconds'.format(i + 1, self.timeout)) + self.logger.warning( + 'Checks on mailbox #{} timed out after {} seconds'.format( + i + 1, self.timeout + ) + ) continue return results @@ -276,16 +331,25 @@ class MailBackend(Backend): for i, (unread, flagged) in enumerate(results): unread_msgs, seen_msgs = self._get_unread_seen_msgs(i, unread) flagged_msgs, unflagged_msgs = self._get_flagged_unflagged_msgs(i, flagged) - self._process_msg_events(i, unread=list(unread_msgs.values()), seen=list(seen_msgs.values()), - flagged=list(flagged_msgs.values()), unflagged=list(unflagged_msgs.values()), - last_checked_date=mailbox_statuses[i].last_checked_date) + self._process_msg_events( + i, + unread=list(unread_msgs.values()), + seen=list(seen_msgs.values()), + flagged=list(flagged_msgs.values()), + unflagged=list(unflagged_msgs.values()), + last_checked_date=mailbox_statuses[i].last_checked_date, + ) self._unread_msgs[i] = unread self._flagged_msgs[i] = flagged - records.append(MailboxStatus(mailbox_id=i, - unseen_message_ids=json.dumps([msg_id for msg_id in unread.keys()]), - flagged_message_ids=json.dumps([msg_id for msg_id in flagged.keys()]), - last_checked_date=datetime.now())) + records.append( + MailboxStatus( + mailbox_id=i, + unseen_message_ids=json.dumps(list(unread.keys())), + flagged_message_ids=json.dumps(list(flagged.keys())), + last_checked_date=datetime.now(), + ) + ) with self._db_lock: session = Session() diff --git a/platypush/backend/zigbee/mqtt/__init__.py b/platypush/backend/zigbee/mqtt/__init__.py index fd805596e..7fca6a676 100644 --- a/platypush/backend/zigbee/mqtt/__init__.py +++ b/platypush/backend/zigbee/mqtt/__init__.py @@ -1,21 +1,38 @@ +import contextlib import json -from typing import Optional +from typing import Optional, Mapping from platypush.backend.mqtt import MqttBackend from platypush.context import get_plugin -from platypush.message.event.zigbee.mqtt import ZigbeeMqttOnlineEvent, ZigbeeMqttOfflineEvent, \ - ZigbeeMqttDevicePropertySetEvent, ZigbeeMqttDevicePairingEvent, ZigbeeMqttDeviceConnectedEvent, \ - ZigbeeMqttDeviceBannedEvent, ZigbeeMqttDeviceRemovedEvent, ZigbeeMqttDeviceRemovedFailedEvent, \ - ZigbeeMqttDeviceWhitelistedEvent, ZigbeeMqttDeviceRenamedEvent, ZigbeeMqttDeviceBindEvent, \ - ZigbeeMqttDeviceUnbindEvent, ZigbeeMqttGroupAddedEvent, ZigbeeMqttGroupAddedFailedEvent, \ - ZigbeeMqttGroupRemovedEvent, ZigbeeMqttGroupRemovedFailedEvent, ZigbeeMqttGroupRemoveAllEvent, \ - ZigbeeMqttGroupRemoveAllFailedEvent, ZigbeeMqttErrorEvent +from platypush.message.event.zigbee.mqtt import ( + ZigbeeMqttOnlineEvent, + ZigbeeMqttOfflineEvent, + ZigbeeMqttDevicePropertySetEvent, + ZigbeeMqttDevicePairingEvent, + ZigbeeMqttDeviceConnectedEvent, + ZigbeeMqttDeviceBannedEvent, + ZigbeeMqttDeviceRemovedEvent, + ZigbeeMqttDeviceRemovedFailedEvent, + ZigbeeMqttDeviceWhitelistedEvent, + ZigbeeMqttDeviceRenamedEvent, + ZigbeeMqttDeviceBindEvent, + ZigbeeMqttDeviceUnbindEvent, + ZigbeeMqttGroupAddedEvent, + ZigbeeMqttGroupAddedFailedEvent, + ZigbeeMqttGroupRemovedEvent, + ZigbeeMqttGroupRemovedFailedEvent, + ZigbeeMqttGroupRemoveAllEvent, + ZigbeeMqttGroupRemoveAllFailedEvent, + ZigbeeMqttErrorEvent, +) class ZigbeeMqttBackend(MqttBackend): """ Listen for events on a zigbee2mqtt service. + For historical reasons, this backend should be enabled together with the `zigbee.mqtt` plugin. + Triggers: * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent` when the service comes online. @@ -59,11 +76,22 @@ class ZigbeeMqttBackend(MqttBackend): """ - def __init__(self, host: Optional[str] = None, port: Optional[int] = None, base_topic='zigbee2mqtt', - tls_cafile: Optional[str] = None, tls_certfile: Optional[str] = None, - tls_keyfile: Optional[str] = None, tls_version: Optional[str] = None, - tls_ciphers: Optional[str] = None, username: Optional[str] = None, - password: Optional[str] = None, client_id: Optional[str] = None, *args, **kwargs): + def __init__( + self, + host: Optional[str] = None, + port: Optional[int] = None, + base_topic='zigbee2mqtt', + tls_cafile: Optional[str] = None, + tls_certfile: Optional[str] = None, + tls_keyfile: Optional[str] = None, + tls_version: Optional[str] = None, + tls_ciphers: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + client_id: Optional[str] = None, + *args, + **kwargs + ): """ :param host: MQTT broker host (default: host configured on the ``zigbee.mqtt`` plugin). :param port: MQTT broker port (default: 1883). @@ -87,6 +115,7 @@ class ZigbeeMqttBackend(MqttBackend): plugin = get_plugin('zigbee.mqtt') self.base_topic = base_topic or plugin.base_topic self._devices = {} + self._devices_info = {} self._groups = {} self._last_state = None self.server_info = { @@ -106,17 +135,28 @@ class ZigbeeMqttBackend(MqttBackend): **self.server_info, } - listeners = [{ - **self.server_info, - 'topics': [ - self.base_topic + '/' + topic - for topic in ['bridge/state', 'bridge/log', 'bridge/logging', 'bridge/devices', 'bridge/groups'] - ], - }] + listeners = [ + { + **self.server_info, + 'topics': [ + self.base_topic + '/' + topic + for topic in [ + 'bridge/state', + 'bridge/log', + 'bridge/logging', + 'bridge/devices', + 'bridge/groups', + ] + ], + } + ] super().__init__( - *args, subscribe_default_topic=False, - listeners=listeners, client_id=client_id, **kwargs + *args, + subscribe_default_topic=False, + listeners=listeners, + client_id=client_id, + **kwargs ) if not client_id: @@ -146,7 +186,7 @@ class ZigbeeMqttBackend(MqttBackend): if msg_type == 'devices': devices = {} - for dev in (text or []): + for dev in text or []: devices[dev['friendly_name']] = dev client.subscribe(self.base_topic + '/' + dev['friendly_name']) elif msg_type == 'pairing': @@ -155,7 +195,9 @@ class ZigbeeMqttBackend(MqttBackend): self.bus.post(ZigbeeMqttDeviceBannedEvent(device=text, **args)) elif msg_type in ['device_removed_failed', 'device_force_removed_failed']: force = msg_type == 'device_force_removed_failed' - self.bus.post(ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args)) + self.bus.post( + ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args) + ) elif msg_type == 'device_whitelisted': self.bus.post(ZigbeeMqttDeviceWhitelistedEvent(device=text, **args)) elif msg_type == 'device_renamed': @@ -181,7 +223,11 @@ class ZigbeeMqttBackend(MqttBackend): self.bus.post(ZigbeeMqttErrorEvent(error=text, **args)) elif msg.get('level') in ['warning', 'error']: log = getattr(self.logger, msg['level']) - log('zigbee2mqtt {}: {}'.format(msg['level'], text or msg.get('error', msg.get('warning')))) + log( + 'zigbee2mqtt {}: {}'.format( + msg['level'], text or msg.get('error', msg.get('warning')) + ) + ) def _process_devices(self, client, msg): devices_info = { @@ -191,10 +237,9 @@ class ZigbeeMqttBackend(MqttBackend): # noinspection PyProtectedMember event_args = {'host': client._host, 'port': client._port} - client.subscribe(*[ - self.base_topic + '/' + device - for device in devices_info.keys() - ]) + client.subscribe( + *[self.base_topic + '/' + device for device in devices_info.keys()] + ) for name, device in devices_info.items(): if name not in self._devices: @@ -203,7 +248,7 @@ class ZigbeeMqttBackend(MqttBackend): exposes = (device.get('definition', {}) or {}).get('exposes', []) client.publish( self.base_topic + '/' + name + '/get', - json.dumps(get_plugin('zigbee.mqtt').build_device_get_request(exposes)) + json.dumps(self._plugin.build_device_get_request(exposes)), ) devices_copy = [*self._devices.keys()] @@ -213,13 +258,13 @@ class ZigbeeMqttBackend(MqttBackend): del self._devices[name] self._devices = {device: {} for device in devices_info.keys()} + self._devices_info = devices_info def _process_groups(self, client, msg): # noinspection PyProtectedMember event_args = {'host': client._host, 'port': client._port} groups_info = { - group.get('friendly_name', group.get('id')): group - for group in msg + group.get('friendly_name', group.get('id')): group for group in msg } for name in groups_info.keys(): @@ -236,15 +281,13 @@ class ZigbeeMqttBackend(MqttBackend): def on_mqtt_message(self): def handler(client, _, msg): - topic = msg.topic[len(self.base_topic)+1:] + topic = msg.topic[len(self.base_topic) + 1 :] data = msg.payload.decode() if not data: return - try: + with contextlib.suppress(ValueError, TypeError): data = json.loads(data) - except (ValueError, TypeError): - pass if topic == 'bridge/state': self._process_state_message(client, data) @@ -260,17 +303,45 @@ class ZigbeeMqttBackend(MqttBackend): return name = suffix - changed_props = {k: v for k, v in data.items() if v != self._devices[name].get(k)} + changed_props = { + k: v for k, v in data.items() if v != self._devices[name].get(k) + } if changed_props: - # noinspection PyProtectedMember - self.bus.post(ZigbeeMqttDevicePropertySetEvent(host=client._host, port=client._port, - device=name, properties=changed_props)) + self._process_property_update(name, data) + self.bus.post( + ZigbeeMqttDevicePropertySetEvent( + host=client._host, + port=client._port, + device=name, + properties=changed_props, + ) + ) self._devices[name].update(data) return handler + @property + def _plugin(self): + plugin = get_plugin('zigbee.mqtt') + assert plugin, 'The zigbee.mqtt plugin is not configured' + return plugin + + def _process_property_update(self, device_name: str, properties: Mapping): + device_info = self._devices_info.get(device_name) + if not (device_info and properties): + return + + self._plugin.publish_entities( + [ + { + **device_info, + 'state': properties, + } + ] + ) + def run(self): super().run() diff --git a/platypush/backend/zwave/mqtt/__init__.py b/platypush/backend/zwave/mqtt/__init__.py index 7d38c8e18..5a5d426c3 100644 --- a/platypush/backend/zwave/mqtt/__init__.py +++ b/platypush/backend/zwave/mqtt/__init__.py @@ -1,3 +1,4 @@ +import contextlib import json from queue import Queue, Empty from typing import Optional, Type @@ -5,14 +6,24 @@ from typing import Optional, Type from platypush.backend.mqtt import MqttBackend from platypush.context import get_plugin -from platypush.message.event.zwave import ZwaveEvent, ZwaveNodeAddedEvent, ZwaveValueChangedEvent, \ - ZwaveNodeRemovedEvent, ZwaveNodeRenamedEvent, ZwaveNodeReadyEvent, ZwaveNodeEvent, ZwaveNodeAsleepEvent, \ - ZwaveNodeAwakeEvent +from platypush.message.event.zwave import ( + ZwaveEvent, + ZwaveNodeAddedEvent, + ZwaveValueChangedEvent, + ZwaveNodeRemovedEvent, + ZwaveNodeRenamedEvent, + ZwaveNodeReadyEvent, + ZwaveNodeEvent, + ZwaveNodeAsleepEvent, + ZwaveNodeAwakeEvent, +) class ZwaveMqttBackend(MqttBackend): """ Listen for events on a `zwavejs2mqtt <https://github.com/zwave-js/zwavejs2mqtt>`_ 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. Triggers: @@ -41,6 +52,7 @@ class ZwaveMqttBackend(MqttBackend): """ from platypush.plugins.zwave.mqtt import ZwaveMqttPlugin + self.plugin: ZwaveMqttPlugin = get_plugin('zwave.mqtt') assert self.plugin, 'The zwave.mqtt plugin is not configured' @@ -61,27 +73,48 @@ class ZwaveMqttBackend(MqttBackend): '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'] - ], - }] + 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) + super().__init__( + *args, + subscribe_default_topic=False, + listeners=listeners, + client_id=client_id, + **kwargs, + ) if not client_id: self.client_id += '-zwavejs-mqtt' - def _dispatch_event(self, event_type: Type[ZwaveEvent], node: Optional[dict] = None, value: Optional[dict] = None, - **kwargs): + def _dispatch_event( + self, + event_type: Type[ZwaveEvent], + node: Optional[dict] = None, + value: Optional[dict] = None, + **kwargs, + ): if value and 'id' not in value: value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}" if 'propertyKey' in value: value_id += '-' + str(value['propertyKey']) if value_id not in node.get('values', {}): - self.logger.warning(f'value_id {value_id} not found on node {node["id"]}') + self.logger.warning( + f'value_id {value_id} not found on node {node["id"]}' + ) return value = node['values'][value_id] @@ -107,41 +140,47 @@ class ZwaveMqttBackend(MqttBackend): evt = event_type(**kwargs) self._events_queue.put(evt) - # zwavejs2mqtt currently treats some values (e.g. binary switches) in an inconsistent way, - # using two values - a read-only value called currentValue that gets updated on the - # node_value_updated topic, and a writable value called targetValue that doesn't get updated - # (see https://github.com/zwave-js/zwavejs2mqtt/blob/4a6a5c5f1274763fd3aced4cae2c72ea060716b5/docs/guide/migrating.md). - # To properly manage updates on writable values, propagate an event for both. - if event_type == ZwaveValueChangedEvent and kwargs.get('value', {}).get('property_id') == 'currentValue': - value = kwargs['value'].copy() - target_value_id = f'{kwargs["node"]["node_id"]}-{value["command_class"]}-{value.get("endpoint", 0)}' \ - f'-targetValue' - kwargs['value'] = kwargs['node'].get('values', {}).get(target_value_id) + if event_type == ZwaveValueChangedEvent: + # zwavejs2mqtt currently treats some values (e.g. binary switches) in an inconsistent way, + # using two values - a read-only value called currentValue that gets updated on the + # node_value_updated topic, and a writable value called targetValue that doesn't get updated + # (see https://github.com/zwave-js/zwavejs2mqtt/blob/4a6a5c5f1274763fd3aced4cae2c72ea060716b5 \ + # /docs/guide/migrating.md). + # To properly manage updates on writable values, propagate an event for both. + if kwargs.get('value', {}).get('property_id') == 'currentValue': + value = kwargs['value'].copy() + target_value_id = ( + f'{kwargs["node"]["node_id"]}-{value["command_class"]}-{value.get("endpoint", 0)}' + f'-targetValue' + ) + kwargs['value'] = kwargs['node'].get('values', {}).get(target_value_id) - if kwargs['value']: - kwargs['value']['data'] = value['data'] - kwargs['node']['values'][target_value_id] = kwargs['value'] - evt = event_type(**kwargs) - self._events_queue.put(evt) + if kwargs['value']: + kwargs['value']['data'] = value['data'] + kwargs['node']['values'][target_value_id] = kwargs['value'] + evt = event_type(**kwargs) + self._events_queue.put(evt) + + 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() + topic = msg.topic[len(self.events_topic) + 1 :].split('/').pop() data = msg.payload.decode() if not data: return - try: + with contextlib.suppress(ValueError, TypeError): data = json.loads(data)['data'] - except (ValueError, TypeError): - pass try: if topic == 'node_value_updated': - self._dispatch_event(ZwaveValueChangedEvent, node=data[0], value=data[1]) + 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': diff --git a/platypush/entities/__init__.py b/platypush/entities/__init__.py new file mode 100644 index 000000000..361d67e5e --- /dev/null +++ b/platypush/entities/__init__.py @@ -0,0 +1,38 @@ +import warnings +from typing import Collection, Optional + +from ._base import Entity, get_entities_registry +from ._engine import EntitiesEngine +from ._registry import manages, register_entity_plugin, get_plugin_entity_registry + +_engine: Optional[EntitiesEngine] = None + + +def init_entities_engine() -> EntitiesEngine: + from ._base import init_entities_db + + global _engine + init_entities_db() + _engine = EntitiesEngine() + _engine.start() + return _engine + + +def publish_entities(entities: Collection[Entity]): + if not _engine: + warnings.warn('No entities engine registered') + return + + _engine.post(*entities) + + +__all__ = ( + 'Entity', + 'EntitiesEngine', + 'init_entities_engine', + 'publish_entities', + 'register_entity_plugin', + 'get_plugin_entity_registry', + 'get_entities_registry', + 'manages', +) diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py new file mode 100644 index 000000000..ea6da9164 --- /dev/null +++ b/platypush/entities/_base.py @@ -0,0 +1,131 @@ +import inspect +import pathlib +from datetime import datetime +from typing import Mapping, Type, Tuple, Any + +import pkgutil +from sqlalchemy import ( + Boolean, + Column, + Index, + Integer, + String, + DateTime, + JSON, + UniqueConstraint, + inspect as schema_inspect, +) +from sqlalchemy.orm import declarative_base, ColumnProperty + +from platypush.message import JSONAble + +Base = declarative_base() +entities_registry: Mapping[Type['Entity'], Mapping] = {} + + +class Entity(Base): + """ + Model for a general-purpose platform entity. + """ + + __tablename__ = 'entity' + + id = Column(Integer, autoincrement=True, primary_key=True) + external_id = Column(String, nullable=True) + name = Column(String, nullable=False, index=True) + description = Column(String) + type = Column(String, nullable=False, index=True) + plugin = Column(String, nullable=False) + data = Column(JSON, default=dict) + meta = Column(JSON, default=dict) + is_read_only = Column(Boolean, default=False) + is_write_only = Column(Boolean, default=False) + created_at = Column( + DateTime(timezone=False), default=datetime.utcnow(), nullable=False + ) + updated_at = Column( + DateTime(timezone=False), default=datetime.utcnow(), onupdate=datetime.utcnow() + ) + + UniqueConstraint(external_id, plugin) + + __table_args__ = (Index(name, plugin), Index(name, type, plugin)) + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + 'polymorphic_on': type, + } + + @classmethod + @property + def columns(cls) -> Tuple[ColumnProperty]: + inspector = schema_inspect(cls) + return tuple(inspector.mapper.column_attrs) + + def _serialize_value(self, col: ColumnProperty) -> Any: + val = getattr(self, col.key) + if isinstance(val, datetime): + # All entity timestamps are in UTC + val = val.isoformat() + '+00:00' + + return val + + def to_json(self) -> dict: + return {col.key: self._serialize_value(col) for col in self.columns} + + def get_plugin(self): + from platypush.context import get_plugin + + plugin = get_plugin(self.plugin) + assert plugin, f'No such plugin: {plugin}' + return plugin + + def run(self, action: str, *args, **kwargs): + plugin = self.get_plugin() + method = getattr(plugin, action, None) + assert method, f'No such action: {self.plugin}.{action}' + return method(self.external_id or self.name, *args, **kwargs) + + +# Inject the JSONAble mixin (Python goes nuts if done through +# standard multiple inheritance with an SQLAlchemy ORM class) +Entity.__bases__ = Entity.__bases__ + (JSONAble,) + + +def _discover_entity_types(): + from platypush.context import get_plugin + + logger = get_plugin('logger') + assert logger + + for loader, modname, _ in pkgutil.walk_packages( + path=[str(pathlib.Path(__file__).parent.absolute())], + prefix=__package__ + '.', + onerror=lambda _: None, + ): + try: + mod_loader = loader.find_module(modname) # type: ignore + assert mod_loader + module = mod_loader.load_module() # type: ignore + except Exception as e: + logger.warning(f'Could not import module {modname}') + logger.exception(e) + continue + + for _, obj in inspect.getmembers(module): + if inspect.isclass(obj) and issubclass(obj, Entity): + entities_registry[obj] = {} + + +def get_entities_registry(): + return entities_registry.copy() + + +def init_entities_db(): + from platypush.context import get_plugin + + _discover_entity_types() + db = get_plugin('db') + assert db + engine = db.get_engine() + db.create_all(engine, Base) diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py new file mode 100644 index 000000000..fef07a054 --- /dev/null +++ b/platypush/entities/_engine.py @@ -0,0 +1,269 @@ +import json +from logging import getLogger +from queue import Queue, Empty +from threading import Thread, Event, RLock +from time import time +from typing import Iterable, List, Optional + +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session, make_transient + +from platypush.context import get_bus +from platypush.message.event.entities import EntityUpdateEvent + +from ._base import Entity + + +class EntitiesEngine(Thread): + # Processing queue timeout in seconds + _queue_timeout = 2.0 + + def __init__(self): + obj_name = self.__class__.__name__ + super().__init__(name=obj_name) + self.logger = getLogger(name=obj_name) + self._queue = Queue() + self._should_stop = Event() + self._entities_awaiting_flush = set() + self._entities_cache_lock = RLock() + self._entities_cache = { + 'by_id': {}, + 'by_external_id_and_plugin': {}, + 'by_name_and_plugin': {}, + } + + def _get_db(self): + from platypush.context import get_plugin + + db = get_plugin('db') + assert db + return db + + def _get_cached_entity(self, entity: Entity) -> Optional[dict]: + if entity.id: + e = self._entities_cache['by_id'].get(entity.id) + if e: + return e + + if entity.external_id and entity.plugin: + e = self._entities_cache['by_external_id_and_plugin'].get( + (entity.external_id, entity.plugin) + ) + if e: + return e + + if entity.name and entity.plugin: + e = self._entities_cache['by_name_and_plugin'].get( + (entity.name, entity.plugin) + ) + if e: + return e + + @staticmethod + def _cache_repr(entity: Entity) -> dict: + repr_ = entity.to_json() + repr_.pop('data', None) + repr_.pop('meta', None) + repr_.pop('created_at', None) + repr_.pop('updated_at', None) + return repr_ + + def _cache_entities(self, *entities: Entity, overwrite_cache=False): + for entity in entities: + e = self._cache_repr(entity) + if not overwrite_cache: + existing_entity = self._entities_cache['by_id'].get(entity.id) + if existing_entity: + for k, v in existing_entity.items(): + if e.get(k) is None: + e[k] = v + + if entity.id: + self._entities_cache['by_id'][entity.id] = e + if entity.external_id and entity.plugin: + self._entities_cache['by_external_id_and_plugin'][ + (entity.external_id, entity.plugin) + ] = e + if entity.name and entity.plugin: + self._entities_cache['by_name_and_plugin'][ + (entity.name, entity.plugin) + ] = e + + def _populate_entity_id_from_cache(self, new_entity: Entity): + with self._entities_cache_lock: + cached_entity = self._get_cached_entity(new_entity) + if cached_entity and cached_entity.get('id'): + new_entity.id = cached_entity['id'] + if new_entity.id: + self._cache_entities(new_entity) + + def _init_entities_cache(self): + with self._get_db().get_session() as session: + entities = session.query(Entity).all() + for entity in entities: + make_transient(entity) + + with self._entities_cache_lock: + self._cache_entities(*entities, overwrite_cache=True) + + self.logger.info('Entities cache initialized') + + def _process_event(self, entity: Entity): + self._populate_entity_id_from_cache(entity) + if entity.id: + get_bus().post(EntityUpdateEvent(entity=entity)) + else: + self._entities_awaiting_flush.add(self._to_entity_awaiting_flush(entity)) + + @staticmethod + def _to_entity_awaiting_flush(entity: Entity): + e = entity.to_json() + return json.dumps( + {k: v for k, v in e.items() if k in {'external_id', 'name', 'plugin'}}, + sort_keys=True, + ) + + def post(self, *entities: Entity): + for entity in entities: + self._queue.put(entity) + + @property + def should_stop(self) -> bool: + return self._should_stop.is_set() + + def stop(self): + self._should_stop.set() + + def run(self): + super().run() + self.logger.info('Started entities engine') + self._init_entities_cache() + + while not self.should_stop: + msgs = [] + last_poll_time = time() + + while not self.should_stop and ( + time() - last_poll_time < self._queue_timeout + ): + try: + msg = self._queue.get(block=True, timeout=0.5) + except Empty: + continue + + if msg: + msgs.append(msg) + # Trigger an EntityUpdateEvent if there has + # been a change on the entity state + self._process_event(msg) + + if not msgs or self.should_stop: + continue + + try: + self._process_entities(*msgs) + except Exception as e: + self.logger.error('Error while processing entity updates: ' + str(e)) + self.logger.exception(e) + + self.logger.info('Stopped entities engine') + + def _get_if_exist( + self, session: Session, entities: Iterable[Entity] + ) -> Iterable[Entity]: + existing_entities = { + ( + str(entity.external_id) + if entity.external_id is not None + else entity.name, + entity.plugin, + ): entity + for entity in session.query(Entity) + .filter( + or_( + *[ + and_( + Entity.external_id == entity.external_id, + Entity.plugin == entity.plugin, + ) + if entity.external_id is not None + else and_( + Entity.name == entity.name, + Entity.type == entity.type, + Entity.plugin == entity.plugin, + ) + for entity in entities + ] + ) + ) + .all() + } + + return [ + existing_entities.get( + ( + str(entity.external_id) + if entity.external_id is not None + else entity.name, + entity.plugin, + ), + None, + ) + for entity in entities + ] + + def _merge_entities( + self, entities: List[Entity], existing_entities: List[Entity] + ) -> List[Entity]: + def merge(entity: Entity, existing_entity: Entity) -> Entity: + columns = [col.key for col in entity.columns] + for col in columns: + if col == 'meta': + existing_entity.meta = { # type: ignore + **(existing_entity.meta or {}), # type: ignore + **(entity.meta or {}), # type: ignore + } + elif col not in ('id', 'created_at'): + setattr(existing_entity, col, getattr(entity, col)) + + return existing_entity + + new_entities = [] + entities_map = {} + + # Get the latest update for each ((id|name), plugin) record + for e in entities: + key = ((e.external_id or e.name), e.plugin) + entities_map[key] = e + + # Retrieve existing records and merge them + for i, entity in enumerate(entities): + existing_entity = existing_entities[i] + if existing_entity: + entity = merge(entity, existing_entity) + + new_entities.append(entity) + + return new_entities + + def _process_entities(self, *entities: Entity): + with self._get_db().get_session() as session: + # Ensure that the internal IDs are set to null before the merge + for e in entities: + e.id = None # type: ignore + + existing_entities = self._get_if_exist(session, entities) + entities = self._merge_entities(entities, existing_entities) # type: ignore + session.add_all(entities) + session.commit() + + with self._entities_cache_lock: + for entity in entities: + self._cache_entities(entity, overwrite_cache=True) + + entities_awaiting_flush = {*self._entities_awaiting_flush} + for entity in entities: + e = self._to_entity_awaiting_flush(entity) + if e in entities_awaiting_flush: + self._process_event(entity) + self._entities_awaiting_flush.remove(e) diff --git a/platypush/entities/_registry.py b/platypush/entities/_registry.py new file mode 100644 index 000000000..1bee049d4 --- /dev/null +++ b/platypush/entities/_registry.py @@ -0,0 +1,135 @@ +import json +from datetime import datetime +from typing import Optional, Dict, Collection, Type + +from platypush.config import Config +from platypush.plugins import Plugin +from platypush.utils import get_plugin_name_by_class, get_redis + +from ._base import Entity + +_entity_registry_varname = '_platypush/plugin_entity_registry' + + +def register_entity_plugin(entity_type: Type[Entity], plugin: Plugin): + """ + Associates a plugin as a manager for a certain entity type. + If you use the `@manages` decorator then you usually don't have + to call this method directly. + """ + plugin_name = get_plugin_name_by_class(plugin.__class__) or '' + entity_type_name = entity_type.__name__.lower() + redis = get_redis() + registry = get_plugin_entity_registry() + registry_by_plugin = set(registry['by_plugin'].get(plugin_name, [])) + + registry_by_entity_type = set(registry['by_entity_type'].get(entity_type_name, [])) + + registry_by_plugin.add(entity_type_name) + registry_by_entity_type.add(plugin_name) + registry['by_plugin'][plugin_name] = list(registry_by_plugin) + registry['by_entity_type'][entity_type_name] = list(registry_by_entity_type) + redis.mset({_entity_registry_varname: json.dumps(registry)}) + + +def get_plugin_entity_registry() -> Dict[str, Dict[str, Collection[str]]]: + """ + Get the `plugin->entity_types` and `entity_type->plugin` + mappings supported by the current configuration. + """ + redis = get_redis() + registry = redis.mget([_entity_registry_varname])[0] + try: + registry = json.loads((registry or b'').decode()) + except (TypeError, ValueError): + return {'by_plugin': {}, 'by_entity_type': {}} + + enabled_plugins = set(Config.get_plugins().keys()) + + return { + 'by_plugin': { + plugin_name: entity_types + for plugin_name, entity_types in registry['by_plugin'].items() + if plugin_name in enabled_plugins + }, + 'by_entity_type': { + entity_type: [p for p in plugins if p in enabled_plugins] + for entity_type, plugins in registry['by_entity_type'].items() + }, + } + + +class EntityManagerMixin: + """ + This mixin is injected on the fly into any plugin class declared with + the @manages decorator. The class will therefore implement the + `publish_entities` and `transform_entities` methods, which can be + overridden if required. + """ + + def transform_entities(self, entities): + """ + This method takes a list of entities in any (plugin-specific) + format and converts them into a standardized collection of + `Entity` objects. Since this method is called by + :meth:`.publish_entities` before entity updates are published, + you may usually want to extend it to pre-process the entities + managed by your extension into the standard format before they + are stored and published to all the consumers. + """ + entities = entities or [] + for entity in entities: + if entity.id: + # Entity IDs can only refer to the internal primary key + entity.external_id = entity.id + entity.id = None # type: ignore + + entity.plugin = get_plugin_name_by_class(self.__class__) # type: ignore + entity.updated_at = datetime.utcnow() + + return entities + + def publish_entities(self, entities: Optional[Collection[Entity]]): + """ + Publishes a list of entities. The downstream consumers include: + + - The entity persistence manager + - The web server + - Any consumer subscribed to + :class:`platypush.message.event.entities.EntityUpdateEvent` + events (e.g. web clients) + + If your extension class uses the `@manages` decorator then you usually + don't need to override this class (but you may want to extend + :meth:`.transform_entities` instead if your extension doesn't natively + handle `Entity` objects). + """ + from . import publish_entities + + entities = self.transform_entities(entities) + publish_entities(entities) + + +def manages(*entities: Type[Entity]): + """ + This decorator is used to register a plugin/backend class as a + manager of one or more types of entities. + """ + + def wrapper(plugin: Type[Plugin]): + init = plugin.__init__ + + def __init__(self, *args, **kwargs): + for entity_type in entities: + register_entity_plugin(entity_type, self) + + init(self, *args, **kwargs) + + plugin.__init__ = __init__ + # Inject the EntityManagerMixin + if EntityManagerMixin not in plugin.__bases__: + plugin.__bases__ = (EntityManagerMixin,) + plugin.__bases__ + + return plugin + + return wrapper diff --git a/platypush/entities/devices.py b/platypush/entities/devices.py new file mode 100644 index 000000000..8d3d45230 --- /dev/null +++ b/platypush/entities/devices.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, Boolean, ForeignKey + +from ._base import Entity + + +class Device(Entity): + __tablename__ = 'device' + + id = Column(Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True) + reachable = Column(Boolean, default=True) + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + } diff --git a/platypush/entities/lights.py b/platypush/entities/lights.py new file mode 100644 index 000000000..ae65eae6e --- /dev/null +++ b/platypush/entities/lights.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Float + +from .devices import Device + + +class Light(Device): + __tablename__ = 'light' + + id = Column(Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True) + on = Column(Boolean) + brightness = Column(Float) + saturation = Column(Float) + hue = Column(Float) + temperature = Column(Float) + x = Column(Float) + y = Column(Float) + colormode = Column(String) + effect = Column(String) + hue_min = Column(Float) + hue_max = Column(Float) + saturation_min = Column(Float) + saturation_max = Column(Float) + brightness_min = Column(Float) + brightness_max = Column(Float) + temperature_min = Column(Float) + temperature_max = Column(Float) + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + } diff --git a/platypush/entities/switches.py b/platypush/entities/switches.py new file mode 100644 index 000000000..3c7d6d100 --- /dev/null +++ b/platypush/entities/switches.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, ForeignKey, Boolean + +from .devices import Device + + +class Switch(Device): + __tablename__ = 'switch' + + id = Column(Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True) + state = Column(Boolean) + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + } diff --git a/platypush/message/event/entities.py b/platypush/message/event/entities.py new file mode 100644 index 000000000..6f82f092a --- /dev/null +++ b/platypush/message/event/entities.py @@ -0,0 +1,32 @@ +from abc import ABC +from typing import Union + +from platypush.entities import Entity +from platypush.message.event import Event + + +class EntityEvent(Event, ABC): + def __init__( + self, entity: Union[Entity, dict], *args, disable_logging=True, **kwargs + ): + if isinstance(entity, Entity): + entity = entity.to_json() + super().__init__( + entity=entity, *args, disable_logging=disable_logging, **kwargs + ) + + +class EntityUpdateEvent(EntityEvent): + """ + This event is triggered whenever an entity of any type (a switch, a light, + a sensor, a media player etc.) updates its state. + """ + + +class EntityDeleteEvent(EntityEvent): + """ + This event is triggered whenever an entity is deleted. + """ + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/__init__.py b/platypush/plugins/__init__.py index 8338620ec..ff69c263c 100644 --- a/platypush/plugins/__init__.py +++ b/platypush/plugins/__init__.py @@ -19,12 +19,12 @@ def action(f): result = f(*args, **kwargs) if result and isinstance(result, Response): - result.errors = result.errors \ - if isinstance(result.errors, list) else [result.errors] + result.errors = ( + result.errors if isinstance(result.errors, list) else [result.errors] + ) response = result elif isinstance(result, tuple) and len(result) == 2: - response.errors = result[1] \ - if isinstance(result[1], list) else [result[1]] + response.errors = result[1] if isinstance(result[1], list) else [result[1]] if len(response.errors) == 1 and response.errors[0] is None: response.errors = [] @@ -39,12 +39,14 @@ def action(f): return _execute_action -class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to-init] - """ Base plugin class """ +class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to-init] + """Base plugin class""" def __init__(self, **kwargs): super().__init__() - self.logger = logging.getLogger('platypush:plugin:' + get_plugin_name_by_class(self.__class__)) + self.logger = logging.getLogger( + 'platypush:plugin:' + get_plugin_name_by_class(self.__class__) + ) if 'logging' in kwargs: self.logger.setLevel(getattr(logging, kwargs['logging'].upper())) @@ -53,8 +55,9 @@ class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-t ) def run(self, method, *args, **kwargs): - assert method in self.registered_actions, '{} is not a registered action on {}'.\ - format(method, self.__class__.__name__) + assert ( + method in self.registered_actions + ), '{} is not a registered action on {}'.format(method, self.__class__.__name__) return getattr(self, method)(*args, **kwargs) @@ -62,6 +65,7 @@ class RunnablePlugin(Plugin): """ Class for runnable plugins - i.e. plugins that have a start/stop method and can be started. """ + def __init__(self, poll_interval: Optional[float] = None, **kwargs): """ :param poll_interval: How often the :meth:`.loop` function should be execute (default: None, no pause/interval). @@ -78,6 +82,9 @@ class RunnablePlugin(Plugin): def should_stop(self): return self._should_stop.is_set() + def wait_stop(self, timeout=None): + return self._should_stop.wait(timeout) + def start(self): set_thread_name(self.__class__.__name__) self._thread = threading.Thread(target=self._runner) diff --git a/platypush/plugins/chat/irc/__init__.py b/platypush/plugins/chat/irc/__init__.py index e7abdae21..681c59159 100644 --- a/platypush/plugins/chat/irc/__init__.py +++ b/platypush/plugins/chat/irc/__init__.py @@ -2,7 +2,11 @@ import os from typing import Sequence, Dict, Tuple, Union, Optional from platypush.plugins import RunnablePlugin, action -from platypush.schemas.irc import IRCServerSchema, IRCServerStatusSchema, IRCChannelSchema +from platypush.schemas.irc import ( + IRCServerSchema, + IRCServerStatusSchema, + IRCChannelSchema, +) from ._bot import IRCBot from .. import ChatPlugin @@ -59,29 +63,19 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @property def _bots_by_server(self) -> Dict[str, IRCBot]: - return { - bot.server: bot - for srv, bot in self._bots.items() - } + return {bot.server: bot for srv, bot in self._bots.items()} @property def _bots_by_server_and_port(self) -> Dict[Tuple[str, int], IRCBot]: - return { - (bot.server, bot.port): bot - for srv, bot in self._bots.items() - } + return {(bot.server, bot.port): bot for srv, bot in self._bots.items()} @property def _bots_by_alias(self) -> Dict[str, IRCBot]: - return { - bot.alias: bot - for srv, bot in self._bots.items() - if bot.alias - } + return {bot.alias: bot for srv, bot in self._bots.items() if bot.alias} def main(self): self._connect() - self._should_stop.wait() + self.wait_stop() def _connect(self): for srv, bot in self._bots.items(): @@ -109,7 +103,11 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @action def send_file( - self, file: str, server: Union[str, Tuple[str, int]], nick: str, bind_address: Optional[str] = None + self, + file: str, + server: Union[str, Tuple[str, int]], + nick: str, + bind_address: Optional[str] = None, ): """ Send a file to an IRC user over DCC connection. @@ -127,7 +125,10 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @action def send_message( - self, text: str, server: Union[str, Tuple[str, int]], target: Union[str, Sequence[str]] + self, + text: str, + server: Union[str, Tuple[str, int]], + target: Union[str, Sequence[str]], ): """ Send a message to a channel or a nick. @@ -139,15 +140,14 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): """ bot = self._get_bot(server) method = ( - bot.connection.privmsg if isinstance(target, str) + bot.connection.privmsg + if isinstance(target, str) else bot.connection.privmsg_many ) method(target, text) @action - def send_notice( - self, text: str, server: Union[str, Tuple[str, int]], target: str - ): + def send_notice(self, text: str, server: Union[str, Tuple[str, int]], target: str): """ Send a notice to a channel or a nick. @@ -192,22 +192,28 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): channel_name = channel channel = bot.channels.get(channel) assert channel, f'Not connected to channel {channel}' - return IRCChannelSchema().dump({ - 'is_invite_only': channel.is_invite_only(), - 'is_moderated': channel.is_moderated(), - 'is_protected': channel.is_protected(), - 'is_secret': channel.is_secret(), - 'name': channel_name, - 'modes': channel.modes, - 'opers': list(channel.opers()), - 'owners': channel.owners(), - 'users': list(channel.users()), - 'voiced': list(channel.voiced()), - }) + return IRCChannelSchema().dump( + { + 'is_invite_only': channel.is_invite_only(), + 'is_moderated': channel.is_moderated(), + 'is_protected': channel.is_protected(), + 'is_secret': channel.is_secret(), + 'name': channel_name, + 'modes': channel.modes, + 'opers': list(channel.opers()), + 'owners': channel.owners(), + 'users': list(channel.users()), + 'voiced': list(channel.voiced()), + } + ) @action def send_ctcp_message( - self, ctcp_type: str, body: str, server: Union[str, Tuple[str, int]], target: str + self, + ctcp_type: str, + body: str, + server: Union[str, Tuple[str, int]], + target: str, ): """ Send a CTCP message to a target. @@ -222,7 +228,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @action def send_ctcp_reply( - self, body: str, server: Union[str, Tuple[str, int]], target: str + self, body: str, server: Union[str, Tuple[str, int]], target: str ): """ Send a CTCP REPLY command. @@ -235,7 +241,9 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): bot.connection.ctcp_reply(target, body) @action - def disconnect(self, server: Union[str, Tuple[str, int]], message: Optional[str] = None): + def disconnect( + self, server: Union[str, Tuple[str, int]], message: Optional[str] = None + ): """ Disconnect from a server. @@ -246,9 +254,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): bot.connection.disconnect(message or bot.stop_message) @action - def invite( - self, nick: str, channel: str, server: Union[str, Tuple[str, int]] - ): + def invite(self, nick: str, channel: str, server: Union[str, Tuple[str, int]]): """ Invite a nick to a channel. @@ -272,7 +278,11 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @action def kick( - self, nick: str, channel: str, server: Union[str, Tuple[str, int]], reason: Optional[str] = None + self, + nick: str, + channel: str, + server: Union[str, Tuple[str, int]], + reason: Optional[str] = None, ): """ Kick a nick from a channel. @@ -286,9 +296,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): bot.connection.kick(channel, nick, reason) @action - def mode( - self, target: str, command: str, server: Union[str, Tuple[str, int]] - ): + def mode(self, target: str, command: str, server: Union[str, Tuple[str, int]]): """ Send a MODE command on the selected target. @@ -324,8 +332,10 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @action def part( - self, channel: Union[str, Sequence[str]], server: Union[str, Tuple[str, int]], - message: Optional[str] = None + self, + channel: Union[str, Sequence[str]], + server: Union[str, Tuple[str, int]], + message: Optional[str] = None, ): """ Parts/exits a channel. @@ -339,9 +349,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): bot.connection.part(channels=channels, message=message or bot.stop_message) @action - def quit( - self, server: Union[str, Tuple[str, int]], message: Optional[str] = None - ): + def quit(self, server: Union[str, Tuple[str, int]], message: Optional[str] = None): """ Send a QUIT command. @@ -363,7 +371,12 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): bot.connection.send_raw(message) @action - def topic(self, channel: str, server: Union[str, Tuple[str, int]], topic: Optional[str] = None) -> str: + def topic( + self, + channel: str, + server: Union[str, Tuple[str, int]], + topic: Optional[str] = None, + ) -> str: """ Get/set the topic of an IRC channel. diff --git a/platypush/plugins/db/__init__.py b/platypush/plugins/db/__init__.py index f4594f089..ab901b2e8 100644 --- a/platypush/plugins/db/__init__.py +++ b/platypush/plugins/db/__init__.py @@ -1,11 +1,11 @@ -""" -.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com> -""" - import time +from contextlib import contextmanager +from multiprocessing import RLock +from typing import Generator from sqlalchemy import create_engine, Table, MetaData from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker, scoped_session from platypush.plugins import Plugin, action @@ -30,22 +30,23 @@ class DbPlugin(Plugin): """ super().__init__() - self.engine = self._get_engine(engine, *args, **kwargs) + self.engine = self.get_engine(engine, *args, **kwargs) + self._session_locks = {} - def _get_engine(self, engine=None, *args, **kwargs): + def get_engine(self, engine=None, *args, **kwargs) -> Engine: if engine: if isinstance(engine, Engine): return engine if engine.startswith('sqlite://'): kwargs['connect_args'] = {'check_same_thread': False} - return create_engine(engine, *args, **kwargs) + return create_engine(engine, *args, **kwargs) # type: ignore + assert self.engine return self.engine - # noinspection PyUnusedLocal @staticmethod - def _build_condition(table, column, value): + def _build_condition(_, column, value): if isinstance(value, str): value = "'{}'".format(value) elif not isinstance(value, int) and not isinstance(value, float): @@ -73,14 +74,14 @@ class DbPlugin(Plugin): :param kwargs: Extra kwargs that will be passed to ``sqlalchemy.create_engine`` (seehttps:///docs.sqlalchemy.org/en/latest/core/engines.html) """ - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) with engine.connect() as connection: connection.execute(statement) def _get_table(self, table, engine=None, *args, **kwargs): if not engine: - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) db_ok = False n_tries = 0 @@ -98,7 +99,7 @@ class DbPlugin(Plugin): self.logger.exception(e) self.logger.info('Waiting {} seconds before retrying'.format(wait_time)) time.sleep(wait_time) - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) if not db_ok and last_error: raise last_error @@ -163,7 +164,7 @@ class DbPlugin(Plugin): ] """ - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) if table: table, engine = self._get_table(table, engine=engine, *args, **kwargs) @@ -234,7 +235,7 @@ class DbPlugin(Plugin): if key_columns is None: key_columns = [] - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) for record in records: table, engine = self._get_table(table, engine=engine, *args, **kwargs) @@ -293,7 +294,7 @@ class DbPlugin(Plugin): } """ - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) for record in records: table, engine = self._get_table(table, engine=engine, *args, **kwargs) @@ -341,7 +342,7 @@ class DbPlugin(Plugin): } """ - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) for record in records: table, engine = self._get_table(table, engine=engine, *args, **kwargs) @@ -352,5 +353,22 @@ class DbPlugin(Plugin): engine.execute(delete) + def create_all(self, engine, base): + self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock()) + with self._session_locks[engine.url]: + base.metadata.create_all(engine) + + @contextmanager + def get_session(self, engine=None, *args, **kwargs) -> Generator[Session, None, None]: + engine = self.get_engine(engine, *args, **kwargs) + self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock()) + with self._session_locks[engine.url]: + session = scoped_session(sessionmaker(expire_on_commit=False)) + session.configure(bind=engine) + s = session() + yield s + s.commit() + s.close() + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/entities/__init__.py b/platypush/plugins/entities/__init__.py new file mode 100644 index 000000000..e600a2d49 --- /dev/null +++ b/platypush/plugins/entities/__init__.py @@ -0,0 +1,251 @@ +from queue import Queue, Empty +from threading import Thread +from time import time +from typing import Optional, Any, Collection, Mapping + +from sqlalchemy import or_ +from sqlalchemy.orm import make_transient + +from platypush.config import Config +from platypush.context import get_plugin, get_bus +from platypush.entities import Entity, get_plugin_entity_registry, get_entities_registry +from platypush.message.event.entities import EntityUpdateEvent, EntityDeleteEvent +from platypush.plugins import Plugin, action + + +class EntitiesPlugin(Plugin): + """ + This plugin is used to interact with native platform entities (e.g. switches, lights, + sensors etc.) through a consistent interface, regardless of the integration type. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def _get_db(self): + db = get_plugin('db') + assert db + return db + + @action + def get( + self, + types: Optional[Collection[str]] = None, + plugins: Optional[Collection[str]] = None, + **filter, + ): + """ + Retrieve a list of entities. + + :param types: Entity types, as specified by the (lowercase) class name and table name. + Default: all entities. + :param plugins: Filter by plugin IDs (default: all plugins). + :param filter: Filter entities with these criteria (e.g. `name`, `id`, + `state`, `type`, `plugin` etc.) + """ + entity_registry = get_entities_registry() + selected_types = [] + all_types = {e.__tablename__.lower(): e for e in entity_registry} + + if types: + selected_types = {t.lower() for t in types} + entity_types = {t: et for t, et in all_types.items() if t in selected_types} + invalid_types = selected_types.difference(entity_types.keys()) + assert not invalid_types, ( + f'No such entity types: {invalid_types}. ' + f'Supported types: {list(all_types.keys())}' + ) + + selected_types = entity_types.keys() + + db = self._get_db() + enabled_plugins = list( + { + *Config.get_plugins().keys(), + *Config.get_backends().keys(), + } + ) + + with db.get_session() as session: + query = session.query(Entity).filter( + or_(Entity.plugin.in_(enabled_plugins), Entity.plugin.is_(None)) + ) + + if selected_types: + query = query.filter(Entity.type.in_(selected_types)) + if plugins: + query = query.filter(Entity.plugin.in_(plugins)) + if filter: + query = query.filter_by(**filter) + + return [e.to_json() for e in query.all()] + + @action + def scan( + self, + types: Optional[Collection[str]] = None, + plugins: Optional[Collection[str]] = None, + timeout: Optional[float] = 30.0, + ): + """ + (Re-)scan entities and return the updated results. + + :param types: Filter by entity types (e.g. `switch`, `light`, `sensor` etc.). + :param plugins: Filter by plugin names (e.g. `switch.tplink` or `light.hue`). + :param timeout: Scan timeout in seconds. Default: 30. + """ + filter = {} + plugin_registry = get_plugin_entity_registry() + + if plugins: + filter['plugins'] = plugins + plugin_registry['by_plugin'] = { + plugin: plugin_registry['by_plugin'][plugin] + for plugin in plugins + if plugin in plugin_registry['by_plugin'] + } + + if types: + filter['types'] = types + filter_entity_types = set(types) + plugin_registry['by_plugin'] = { + plugin_name: entity_types + for plugin_name, entity_types in plugin_registry['by_plugin'].items() + if any(t for t in entity_types if t in filter_entity_types) + } + + enabled_plugins = plugin_registry['by_plugin'].keys() + + def worker(plugin_name: str, q: Queue): + try: + plugin = get_plugin(plugin_name) + assert plugin, f'No such configured plugin: {plugin_name}' + # Force a plugin scan by calling the `status` action + response = plugin.status() + assert not response.errors, response.errors + q.put((plugin_name, response.output)) + except Exception as e: + q.put((plugin_name, e)) + + q = Queue() + start_time = time() + results = [] + workers = [ + Thread(target=worker, args=(plugin_name, q)) + for plugin_name in enabled_plugins + ] + + for w in workers: + w.start() + + while len(results) < len(workers) and ( + not timeout or (time() - start_time < timeout) + ): + try: + plugin_name, result = q.get(block=True, timeout=0.5) + if isinstance(result, Exception): + self.logger.warning( + f'Could not load results from plugin {plugin_name}: {result}' + ) + else: + results.append(result) + except Empty: + continue + + if len(results) < len(workers): + self.logger.warning('Scan timed out for some plugins') + + for w in workers: + w.join(timeout=max(0, timeout - (time() - start_time)) if timeout else None) + + return self.get(**filter) + + @action + def execute(self, id: Any, action: str, *args, **kwargs): + """ + Execute an action on an entity (for example `on`/`off` on a switch, or `get` + on a sensor). + + :param id: Entity ID (i.e. the entity's db primary key, not the plugin's external + or "logical" key) + :param action: Action that should be run. It should be a method implemented + by the entity's class. + :param args: Action's extra positional arguments. + :param kwargs: Action's extra named arguments. + """ + db = self._get_db() + with db.get_session() as session: + entity = session.query(Entity).filter_by(id=id).one_or_none() + + assert entity, f'No such entity ID: {id}' + return entity.run(action, *args, **kwargs) + + @action + def delete(self, *entities: int): # type: ignore + """ + Delete a set of entity IDs. + + Note: this should only be done if the entity is no longer available or + the associated plugin has been disabled, otherwise the entities will be + re-created by the plugins on the next scan. + + :param entities: IDs of the entities to be removed. + :return: The payload of the deleted entities. + """ + with self._get_db().get_session() as session: + entities: Collection[Entity] = ( + session.query(Entity).filter(Entity.id.in_(entities)).all() + ) + for entity in entities: + session.delete(entity) + session.commit() + + for entity in entities: + make_transient(entity) + get_bus().post(EntityDeleteEvent(entity)) + + return entities + + @action + def rename(self, **entities: Mapping[str, str]): + """ + Rename a sequence of entities. + Renaming, as of now, is actually done by setting the ``.meta.name_override`` + property of an entity rather than fully renaming the entity (which may be owned + by a plugin that doesn't support renaming, therefore the next entity update may + overwrite the name). + + :param entities: Entity `id` -> `new_name` mapping. + """ + return self.set_meta( + **{ + entity_id: {'name_override': name} + for entity_id, name in entities.items() + } + ) + + @action + def set_meta(self, **entities): + """ + Update the metadata of a set of entities. + + :param entities: Entity `id` -> `new_metadata_fields` mapping. + :return: The updated entities. + """ + entities = {str(k): v for k, v in entities.items()} + with self._get_db().get_session() as session: + objs = session.query(Entity).filter(Entity.id.in_(entities.keys())).all() + for obj in objs: + obj.meta = {**(obj.meta or {}), **(entities.get(str(obj.id), {}))} + session.add(obj) + + session.commit() + + for obj in objs: + make_transient(obj) + get_bus().post(EntityUpdateEvent(obj)) + + return [obj.to_json() for obj in objs] + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/entities/manifest.yaml b/platypush/plugins/entities/manifest.yaml new file mode 100644 index 000000000..83628bb5e --- /dev/null +++ b/platypush/plugins/entities/manifest.yaml @@ -0,0 +1,4 @@ +manifest: + events: {} + package: platypush.plugins.entities + type: plugin diff --git a/platypush/plugins/gpio/__init__.py b/platypush/plugins/gpio/__init__.py index 53f677a68..d312bfc02 100644 --- a/platypush/plugins/gpio/__init__.py +++ b/platypush/plugins/gpio/__init__.py @@ -27,11 +27,11 @@ class GpioPlugin(RunnablePlugin): """ def __init__( - self, - pins: Optional[Dict[str, int]] = None, - monitored_pins: Optional[Collection[Union[str, int]]] = None, - mode: str = 'board', - **kwargs + self, + pins: Optional[Dict[str, int]] = None, + monitored_pins: Optional[Collection[Union[str, int]]] = None, + mode: str = 'board', + **kwargs ): """ :param mode: Specify ``board`` if you want to use the board PIN numbers, @@ -64,8 +64,9 @@ class GpioPlugin(RunnablePlugin): self._initialized_pins = {} self._monitored_pins = monitored_pins or [] self.pins_by_name = pins if pins else {} - self.pins_by_number = {number: name - for (name, number) in self.pins_by_name.items()} + self.pins_by_number = { + number: name for (name, number) in self.pins_by_name.items() + } def _init_board(self): import RPi.GPIO as GPIO @@ -98,6 +99,7 @@ class GpioPlugin(RunnablePlugin): def on_gpio_event(self): def callback(pin: int): import RPi.GPIO as GPIO + value = GPIO.input(pin) pin = self.pins_by_number.get(pin, pin) get_bus().post(GPIOEvent(pin=pin, value=value)) @@ -106,23 +108,23 @@ class GpioPlugin(RunnablePlugin): def main(self): import RPi.GPIO as GPIO + if not self._monitored_pins: return # No need to start the monitor self._init_board() - monitored_pins = [ - self._get_pin_number(pin) for pin in self._monitored_pins - ] + monitored_pins = [self._get_pin_number(pin) for pin in self._monitored_pins] for pin in monitored_pins: GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) GPIO.add_event_detect(pin, GPIO.BOTH, callback=self.on_gpio_event()) - self._should_stop.wait() + self.wait_stop() @action - def write(self, pin: Union[int, str], value: Union[int, bool], - name: Optional[str] = None) -> Dict[str, Any]: + def write( + self, pin: Union[int, str], value: Union[int, bool], name: Optional[str] = None + ) -> Dict[str, Any]: """ Write a byte value to a pin. diff --git a/platypush/plugins/light/__init__.py b/platypush/plugins/light/__init__.py index 3943924d7..b683c884a 100644 --- a/platypush/plugins/light/__init__.py +++ b/platypush/plugins/light/__init__.py @@ -1,30 +1,52 @@ from abc import ABC, abstractmethod -from platypush.plugins import action -from platypush.plugins.switch import SwitchPlugin +from platypush.entities import manages +from platypush.entities.lights import Light +from platypush.plugins import Plugin, action -class LightPlugin(SwitchPlugin, ABC): +@manages(Light) +class LightPlugin(Plugin, ABC): """ Abstract plugin to interface your logic with lights/bulbs. """ @action @abstractmethod - def on(self): - """ Turn the light on """ + def on(self, lights=None, *args, **kwargs): + """Turn the light on""" raise NotImplementedError() @action @abstractmethod - def off(self): - """ Turn the light off """ + def off(self, lights=None, *args, **kwargs): + """Turn the light off""" raise NotImplementedError() @action @abstractmethod - def toggle(self): - """ Toggle the light status (on/off) """ + def toggle(self, lights=None, *args, **kwargs): + """Toggle the light status (on/off)""" + raise NotImplementedError() + + @action + @abstractmethod + def set_lights(self, lights=None, *args, **kwargs): + """ + Set a set of properties on a set of lights. + + :param light: List of lights to set. Each item can represent a light + name or ID. + :param kwargs: key-value list of the parameters to set. + """ + raise NotImplementedError() + + @action + @abstractmethod + def status(self, *args, **kwargs): + """ + Get the current status of the lights. + """ raise NotImplementedError() diff --git a/platypush/plugins/light/hue/__init__.py b/platypush/plugins/light/hue/__init__.py index 06ec97652..9a79c7e3a 100644 --- a/platypush/plugins/light/hue/__init__.py +++ b/platypush/plugins/light/hue/__init__.py @@ -4,16 +4,22 @@ import time from enum import Enum from threading import Thread, Event -from typing import List +from typing import Iterable, Union, Mapping, Any, Set from platypush.context import get_bus -from platypush.message.event.light import LightAnimationStartedEvent, LightAnimationStoppedEvent -from platypush.plugins import action +from platypush.entities import Entity +from platypush.entities.lights import Light as LightEntity +from platypush.message.event.light import ( + LightAnimationStartedEvent, + LightAnimationStoppedEvent, + LightStatusChangeEvent, +) +from platypush.plugins import action, RunnablePlugin from platypush.plugins.light import LightPlugin from platypush.utils import set_thread_name -class LightHuePlugin(LightPlugin): +class LightHuePlugin(RunnablePlugin, LightPlugin): """ Philips Hue lights plugin. @@ -25,15 +31,20 @@ class LightHuePlugin(LightPlugin): - :class:`platypush.message.event.light.LightAnimationStartedEvent` when an animation is started. - :class:`platypush.message.event.light.LightAnimationStoppedEvent` when an animation is stopped. + - :class:`platypush.message.event.light.LightStatusChangeEvent` when the status of a lightbulb + changes. """ MAX_BRI = 255 MAX_SAT = 255 MAX_HUE = 65535 + MIN_CT = 154 + MAX_CT = 500 ANIMATION_CTRL_QUEUE_NAME = 'platypush/light/hue/AnimationCtrl' _BRIDGE_RECONNECT_SECONDS = 5 _MAX_RECONNECT_TRIES = 5 + _UNINITIALIZED_BRIDGE_ERR = 'The Hue bridge is not initialized' class Animation(Enum): COLOR_TRANSITION = 'color_transition' @@ -45,7 +56,7 @@ class LightHuePlugin(LightPlugin): elif isinstance(other, self.__class__): return self == other - def __init__(self, bridge, lights=None, groups=None): + def __init__(self, bridge, lights=None, groups=None, poll_seconds: float = 20.0): """ :param bridge: Bridge address or hostname :type bridge: str @@ -55,38 +66,54 @@ class LightHuePlugin(LightPlugin): :param groups Default groups to be controlled (default: all) :type groups: list[str] + + :param poll_seconds: How often the plugin should check the bridge for light + updates (default: 20 seconds). """ super().__init__() self.bridge_address = bridge self.bridge = None - self.logger.info('Initializing Hue lights plugin - bridge: "{}"'.format(self.bridge_address)) + self.logger.info( + 'Initializing Hue lights plugin - bridge: "{}"'.format(self.bridge_address) + ) self.connect() - self.lights = [] - self.groups = [] + self.lights = set() + self.groups = set() + self.poll_seconds = poll_seconds + self._cached_lights = {} if lights: - self.lights = lights + self.lights = set(lights) elif groups: - self.groups = groups - self._expand_groups() + self.groups = set(groups) + self.lights.update(self._expand_groups(self.groups)) else: - # noinspection PyUnresolvedReferences - self.lights = [light.name for light in self.bridge.lights] + self.lights = {light['name'] for light in self._get_lights().values()} self.animation_thread = None self.animations = {} self._animation_stop = Event() self._init_animations() - self.logger.info('Configured lights: "{}"'.format(self.lights)) + self.logger.info(f'Configured lights: {self.lights}') - def _expand_groups(self): - groups = [g for g in self.bridge.groups if g.name in self.groups] - for group in groups: - for light in group.lights: - self.lights += [light.name] + def _expand_groups(self, groups: Iterable[str]) -> Set[str]: + lights = set() + light_id_to_name = { + light_id: light['name'] for light_id, light in self._get_lights().items() + } + + groups_ = [g for g in self._get_groups().values() if g.get('name') in groups] + + for group in groups_: + for light_id in group.get('lights', []): + light_name = light_id_to_name.get(light_id) + if light_name: + lights.add(light_name) + + return lights def _init_animations(self): self.animations = { @@ -94,10 +121,10 @@ class LightHuePlugin(LightPlugin): 'lights': {}, } - for group in self.bridge.groups: - self.animations['groups'][group.group_id] = None - for light in self.bridge.lights: - self.animations['lights'][light.light_id] = None + for group_id in self._get_groups(): + self.animations['groups'][group_id] = None + for light_id in self._get_lights(): + self.animations['lights'][light_id] = None @action def connect(self): @@ -110,6 +137,7 @@ class LightHuePlugin(LightPlugin): # Lazy init if not self.bridge: from phue import Bridge, PhueRegistrationException + success = False n_tries = 0 @@ -119,12 +147,14 @@ class LightHuePlugin(LightPlugin): self.bridge = Bridge(self.bridge_address) success = True except PhueRegistrationException as e: - self.logger.warning('Bridge registration error: {}'. - format(str(e))) + self.logger.warning('Bridge registration error: {}'.format(str(e))) if n_tries >= self._MAX_RECONNECT_TRIES: - self.logger.error(('Bridge registration failed after ' + - '{} attempts').format(n_tries)) + self.logger.error( + ( + 'Bridge registration failed after ' + '{} attempts' + ).format(n_tries) + ) break time.sleep(self._BRIDGE_RECONNECT_SECONDS) @@ -168,7 +198,7 @@ class LightHuePlugin(LightPlugin): 'id': id, **scene, } - for id, scene in self.bridge.get_scene().items() + for id, scene in self._get_scenes().items() } @action @@ -215,7 +245,7 @@ class LightHuePlugin(LightPlugin): 'id': id, **light, } - for id, light in self.bridge.get_light().items() + for id, light in self._get_lights().items() } @action @@ -273,7 +303,7 @@ class LightHuePlugin(LightPlugin): 'id': id, **group, } - for id, group in self.bridge.get_group().items() + for id, group in self._get_groups().items() } @action @@ -321,15 +351,22 @@ class LightHuePlugin(LightPlugin): self.bridge = None raise e + assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR lights = [] groups = [] if 'lights' in kwargs: - lights = kwargs.pop('lights').split(',').strip() \ - if isinstance(lights, str) else kwargs.pop('lights') + lights = ( + kwargs.pop('lights').split(',').strip() + if isinstance(lights, str) + else kwargs.pop('lights') + ) if 'groups' in kwargs: - groups = kwargs.pop('groups').split(',').strip() \ - if isinstance(groups, str) else kwargs.pop('groups') + groups = ( + kwargs.pop('groups').split(',').strip() + if isinstance(groups, str) + else kwargs.pop('groups') + ) if not lights and not groups: lights = self.lights @@ -340,34 +377,36 @@ class LightHuePlugin(LightPlugin): try: if attr == 'scene': - self.bridge.run_scene(groups[0], kwargs.pop('name')) + assert groups, 'No groups specified' + self.bridge.run_scene(list(groups)[0], kwargs.pop('name')) else: if groups: - self.bridge.set_group(groups, attr, *args, **kwargs) + self.bridge.set_group(list(groups), attr, *args, **kwargs) if lights: - self.bridge.set_light(lights, attr, *args, **kwargs) + self.bridge.set_light(list(lights), attr, *args, **kwargs) except Exception as e: # Reset bridge connection self.bridge = None raise e - @action - def set_light(self, light, **kwargs): - """ - Set a light (or lights) property. + return self._get_lights() - :param light: Light or lights to set. Can be a string representing the light name, - a light object, a list of string, or a list of light objects. - :param kwargs: key-value list of parameters to set. + @action + def set_lights(self, lights, **kwargs): + """ + Set a set of properties on a set of lights. + + :param light: List of lights to set. Each item can represent a light + name or ID. + :param kwargs: key-value list of the parameters to set. Example call:: { "type": "request", - "target": "hostname", "action": "light.hue.set_light", "args": { - "light": "Bulb 1", + "lights": ["Bulb 1", "Bulb 2"], "sat": 255 } } @@ -375,21 +414,42 @@ class LightHuePlugin(LightPlugin): """ self.connect() - self.bridge.set_light(light, **kwargs) + assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR + all_lights = self._get_lights() + + for i, l in enumerate(lights): + if str(l) in all_lights: + lights[i] = all_lights[str(l)]['name'] + + # Convert entity attributes to local attributes + if kwargs.get('saturation') is not None: + kwargs['sat'] = kwargs.pop('saturation') + if kwargs.get('brightness') is not None: + kwargs['bri'] = kwargs.pop('brightness') + if kwargs.get('temperature') is not None: + kwargs['ct'] = kwargs.pop('temperature') + + # "Unroll" the map + args = [] + for arg, value in kwargs.items(): + args += [arg, value] + + self.bridge.set_light(lights, *args) + return self._get_lights() @action def set_group(self, group, **kwargs): """ Set a group (or groups) property. - :param group: Group or groups to set. Can be a string representing the group name, a group object, a list of strings, or a list of group objects. + :param group: Group or groups to set. It can be a string representing the + group name, a group object, a list of strings, or a list of group objects. :param kwargs: key-value list of parameters to set. Example call:: { "type": "request", - "target": "hostname", "action": "light.hue.set_group", "args": { "light": "Living Room", @@ -400,6 +460,7 @@ class LightHuePlugin(LightPlugin): """ self.connect() + assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR self.bridge.set_group(group, **kwargs) @action @@ -451,15 +512,16 @@ class LightHuePlugin(LightPlugin): groups_off = [] if groups: - all_groups = self.bridge.get_group().values() - + all_groups = self._get_groups().values() groups_on = [ - group['name'] for group in all_groups + group['name'] + for group in all_groups if group['name'] in groups and group['state']['any_on'] is True ] groups_off = [ - group['name'] for group in all_groups + group['name'] + for group in all_groups if group['name'] in groups and group['state']['any_on'] is False ] @@ -467,16 +529,20 @@ class LightHuePlugin(LightPlugin): lights = self.lights if lights: - all_lights = self.bridge.get_light().values() + all_lights = self._get_lights() lights_on = [ - light['name'] for light in all_lights - if light['name'] in lights and light['state']['on'] is True + light['name'] + for light_id, light in all_lights.items() + if (light_id in lights or light['name'] in lights) + and light['state']['on'] is True ] lights_off = [ - light['name'] for light in all_lights - if light['name'] in lights and light['state']['on'] is False + light['name'] + for light_id, light in all_lights.items() + if (light_id in lights or light['name'] in lights) + and light['state']['on'] is False ] if lights_on or groups_on: @@ -499,8 +565,13 @@ class LightHuePlugin(LightPlugin): groups = [] if lights is None: lights = [] - return self._exec('bri', int(value) % (self.MAX_BRI + 1), - lights=lights, groups=groups, **kwargs) + return self._exec( + 'bri', + int(value) % (self.MAX_BRI + 1), + lights=lights, + groups=groups, + **kwargs, + ) @action def sat(self, value, lights=None, groups=None, **kwargs): @@ -516,8 +587,13 @@ class LightHuePlugin(LightPlugin): groups = [] if lights is None: lights = [] - return self._exec('sat', int(value) % (self.MAX_SAT + 1), - lights=lights, groups=groups, **kwargs) + return self._exec( + 'sat', + int(value) % (self.MAX_SAT + 1), + lights=lights, + groups=groups, + **kwargs, + ) @action def hue(self, value, lights=None, groups=None, **kwargs): @@ -533,8 +609,13 @@ class LightHuePlugin(LightPlugin): groups = [] if lights is None: lights = [] - return self._exec('hue', int(value) % (self.MAX_HUE + 1), - lights=lights, groups=groups, **kwargs) + return self._exec( + 'hue', + int(value) % (self.MAX_HUE + 1), + lights=lights, + groups=groups, + **kwargs, + ) @action def xy(self, value, lights=None, groups=None, **kwargs): @@ -557,7 +638,7 @@ class LightHuePlugin(LightPlugin): """ Set lights/groups color temperature. - :param value: Temperature value (range: 0-255) + :param value: Temperature value (range: 154-500) :type value: int :param lights: List of lights. :param groups: List of groups. @@ -584,25 +665,31 @@ class LightHuePlugin(LightPlugin): lights = [] if lights: - bri = statistics.mean([ - light['state']['bri'] - for light in self.bridge.get_light().values() - if light['name'] in lights - ]) + bri = statistics.mean( + [ + light['state']['bri'] + for light in self._get_lights().values() + if light['name'] in lights + ] + ) elif groups: - bri = statistics.mean([ - group['action']['bri'] - for group in self.bridge.get_group().values() - if group['name'] in groups - ]) + bri = statistics.mean( + [ + group['action']['bri'] + for group in self._get_groups().values() + if group['name'] in groups + ] + ) else: - bri = statistics.mean([ - light['state']['bri'] - for light in self.bridge.get_light().values() - if light['name'] in self.lights - ]) + bri = statistics.mean( + [ + light['state']['bri'] + for light in self._get_lights().values() + if light['name'] in self.lights + ] + ) - delta *= (self.MAX_BRI / 100) + delta *= self.MAX_BRI / 100 if bri + delta < 0: bri = 0 elif bri + delta > self.MAX_BRI: @@ -628,25 +715,31 @@ class LightHuePlugin(LightPlugin): lights = [] if lights: - sat = statistics.mean([ - light['state']['sat'] - for light in self.bridge.get_light().values() - if light['name'] in lights - ]) + sat = statistics.mean( + [ + light['state']['sat'] + for light in self._get_lights().values() + if light['name'] in lights + ] + ) elif groups: - sat = statistics.mean([ - group['action']['sat'] - for group in self.bridge.get_group().values() - if group['name'] in groups - ]) + sat = statistics.mean( + [ + group['action']['sat'] + for group in self._get_groups().values() + if group['name'] in groups + ] + ) else: - sat = statistics.mean([ - light['state']['sat'] - for light in self.bridge.get_light().values() - if light['name'] in self.lights - ]) + sat = statistics.mean( + [ + light['state']['sat'] + for light in self._get_lights().values() + if light['name'] in self.lights + ] + ) - delta *= (self.MAX_SAT / 100) + delta *= self.MAX_SAT / 100 if sat + delta < 0: sat = 0 elif sat + delta > self.MAX_SAT: @@ -672,25 +765,31 @@ class LightHuePlugin(LightPlugin): lights = [] if lights: - hue = statistics.mean([ - light['state']['hue'] - for light in self.bridge.get_light().values() - if light['name'] in lights - ]) + hue = statistics.mean( + [ + light['state']['hue'] + for light in self._get_lights().values() + if light['name'] in lights + ] + ) elif groups: - hue = statistics.mean([ - group['action']['hue'] - for group in self.bridge.get_group().values() - if group['name'] in groups - ]) + hue = statistics.mean( + [ + group['action']['hue'] + for group in self._get_groups().values() + if group['name'] in groups + ] + ) else: - hue = statistics.mean([ - light['state']['hue'] - for light in self.bridge.get_light().values() - if light['name'] in self.lights - ]) + hue = statistics.mean( + [ + light['state']['hue'] + for light in self._get_lights().values() + if light['name'] in self.lights + ] + ) - delta *= (self.MAX_HUE / 100) + delta *= self.MAX_HUE / 100 if hue + delta < 0: hue = 0 elif hue + delta > self.MAX_HUE: @@ -734,10 +833,20 @@ class LightHuePlugin(LightPlugin): self._init_animations() @action - def animate(self, animation, duration=None, - hue_range=None, sat_range=None, - bri_range=None, lights=None, groups=None, - hue_step=1000, sat_step=2, bri_step=1, transition_seconds=1.0): + def animate( + self, + animation, + duration=None, + hue_range=None, + sat_range=None, + bri_range=None, + lights=None, + groups=None, + hue_step=1000, + sat_step=2, + bri_step=1, + transition_seconds=1.0, + ): """ Run a lights animation. @@ -747,28 +856,33 @@ class LightHuePlugin(LightPlugin): :param duration: Animation duration in seconds (default: None, i.e. continue until stop) :type duration: float - :param hue_range: If you selected a ``color_transition``, this will specify the hue range of your color ``color_transition``. - Default: [0, 65535] + :param hue_range: If you selected a ``color_transition``, this will + specify the hue range of your color ``color_transition``. Default: [0, 65535] :type hue_range: list[int] - :param sat_range: If you selected a color ``color_transition``, this will specify the saturation range of your color - ``color_transition``. Default: [0, 255] + :param sat_range: If you selected a color ``color_transition``, this + will specify the saturation range of your color ``color_transition``. + Default: [0, 255] :type sat_range: list[int] - :param bri_range: If you selected a color ``color_transition``, this will specify the brightness range of your color - ``color_transition``. Default: [254, 255] :type bri_range: list[int] + :param bri_range: If you selected a color ``color_transition``, this + will specify the brightness range of your color ``color_transition``. + Default: [254, 255] :type bri_range: list[int] :param lights: Lights to control (names, IDs or light objects). Default: plugin default lights :param groups: Groups to control (names, IDs or group objects). Default: plugin default groups - :param hue_step: If you selected a color ``color_transition``, this will specify by how much the color hue will change - between iterations. Default: 1000 :type hue_step: int + :param hue_step: If you selected a color ``color_transition``, this + will specify by how much the color hue will change between iterations. + Default: 1000 :type hue_step: int - :param sat_step: If you selected a color ``color_transition``, this will specify by how much the saturation will change - between iterations. Default: 2 :type sat_step: int + :param sat_step: If you selected a color ``color_transition``, this + will specify by how much the saturation will change between iterations. + Default: 2 :type sat_step: int - :param bri_step: If you selected a color ``color_transition``, this will specify by how much the brightness will change - between iterations. Default: 1 :type bri_step: int + :param bri_step: If you selected a color ``color_transition``, this + will specify by how much the brightness will change between iterations. + Default: 1 :type bri_step: int :param transition_seconds: Time between two transitions or blinks in seconds. Default: 1.0 :type transition_seconds: float @@ -776,20 +890,26 @@ class LightHuePlugin(LightPlugin): self.stop_animation() self._animation_stop.clear() + all_lights = self._get_lights() + bri_range = bri_range or [self.MAX_BRI - 1, self.MAX_BRI] + sat_range = sat_range or [0, self.MAX_SAT] + hue_range = hue_range or [0, self.MAX_HUE] - if bri_range is None: - bri_range = [self.MAX_BRI - 1, self.MAX_BRI] - if sat_range is None: - sat_range = [0, self.MAX_SAT] - if hue_range is None: - hue_range = [0, self.MAX_HUE] if groups: - groups = [g for g in self.bridge.groups if g.name in groups or g.group_id in groups] - lights = lights or [] - for group in groups: - lights.extend([light.name for light in group.lights]) + groups = { + group_id: group + for group_id, group in self._get_groups().items() + if group.get('name') in groups or group_id in groups + } + + lights = set(lights or []) + lights.update(self._expand_groups([g['name'] for g in groups.values()])) elif lights: - lights = [light.name for light in self.bridge.lights if light.name in lights or light.light_id in lights] + lights = { + light['name'] + for light_id, light in all_lights.items() + if light['name'] in lights or int(light_id) in lights + } else: lights = self.lights @@ -806,26 +926,50 @@ class LightHuePlugin(LightPlugin): } if groups: - for group in groups: - self.animations['groups'][group.group_id] = info + for group_id in groups: + self.animations['groups'][group_id] = info - for light in self.bridge.lights: - if light.name in lights: - self.animations['lights'][light.light_id] = info + for light_id, light in all_lights.items(): + if light['name'] in lights: + self.animations['lights'][light_id] = info def _initialize_light_attrs(lights): + lights_by_name = { + light['name']: light for light in self._get_lights().values() + } + if animation == self.Animation.COLOR_TRANSITION: - return {light: { - 'hue': random.randint(hue_range[0], hue_range[1]), - 'sat': random.randint(sat_range[0], sat_range[1]), - 'bri': random.randint(bri_range[0], bri_range[1]), - } for light in lights} + return { + light: { + **( + {'hue': random.randint(hue_range[0], hue_range[1])} # type: ignore + if 'hue' in lights_by_name.get(light, {}).get('state', {}) + else {} + ), + **( + {'sat': random.randint(sat_range[0], sat_range[1])} # type: ignore + if 'sat' in lights_by_name.get(light, {}).get('state', {}) + else {} + ), + **( + {'bri': random.randint(bri_range[0], bri_range[1])} # type: ignore + if 'bri' in lights_by_name.get(light, {}).get('state', {}) + else {} + ), + } + for light in lights + } elif animation == self.Animation.BLINK: - return {light: { - 'on': True, - 'bri': self.MAX_BRI, - 'transitiontime': 0, - } for light in lights} + return { + light: { + 'on': True, + **({'bri': self.MAX_BRI} if 'bri' in light else {}), + 'transitiontime': 0, + } + for light in lights + } + + raise AssertionError(f'Unknown animation type: {animation}') def _next_light_attrs(lights): if animation == self.Animation.COLOR_TRANSITION: @@ -843,15 +987,19 @@ class LightHuePlugin(LightPlugin): else: continue - lights[light][attr] = ((value - attr_range[0] + attr_step) % - (attr_range[1] - attr_range[0] + 1)) + \ - attr_range[0] + lights[light][attr] = ( + (value - attr_range[0] + attr_step) + % (attr_range[1] - attr_range[0] + 1) + ) + attr_range[0] elif animation == self.Animation.BLINK: - lights = {light: { - 'on': False if attrs['on'] else True, - 'bri': self.MAX_BRI, - 'transitiontime': 0, - } for (light, attrs) in lights.items()} + lights = { + light: { + 'on': not attrs['on'], + 'bri': self.MAX_BRI, + 'transitiontime': 0, + } + for (light, attrs) in lights.items() + } return lights @@ -860,13 +1008,23 @@ class LightHuePlugin(LightPlugin): def _animate_thread(lights): set_thread_name('HueAnimate') - get_bus().post(LightAnimationStartedEvent(lights=lights, groups=groups, animation=animation)) + get_bus().post( + LightAnimationStartedEvent( + lights=lights, + groups=list((groups or {}).keys()), + animation=animation, + ) + ) lights = _initialize_light_attrs(lights) animation_start_time = time.time() stop_animation = False - while not stop_animation and not (duration and time.time() - animation_start_time > duration): + while not stop_animation and not ( + duration and time.time() - animation_start_time > duration + ): + assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR + try: if animation == self.Animation.COLOR_TRANSITION: for (light, attrs) in lights.items(): @@ -877,7 +1035,9 @@ class LightHuePlugin(LightPlugin): self.logger.debug('Setting lights to {}'.format(conf)) if groups: - self.bridge.set_group([g.name for g in groups], conf) + self.bridge.set_group( + [g['name'] for g in groups.values()], conf + ) else: self.bridge.set_light(lights.keys(), conf) @@ -891,57 +1051,164 @@ class LightHuePlugin(LightPlugin): lights = _next_light_attrs(lights) - get_bus().post(LightAnimationStoppedEvent(lights=lights, groups=groups, animation=animation)) + get_bus().post( + LightAnimationStoppedEvent( + lights=list(lights.keys()), + groups=list((groups or {}).keys()), + animation=animation, + ) + ) + self.animation_thread = None - self.animation_thread = Thread(target=_animate_thread, - name='HueAnimate', - args=(lights,)) + self.animation_thread = Thread( + target=_animate_thread, name='HueAnimate', args=(lights,) + ) self.animation_thread.start() - @property - def switches(self) -> List[dict]: - """ - :returns: Implements :meth:`platypush.plugins.switch.SwitchPlugin.switches` and returns the status of the - configured lights. Example: + def _get_light_attr(self, light, attr: str): + try: + return getattr(light, attr, None) + except KeyError: + return None - .. code-block:: json + def transform_entities( + self, entities: Union[Iterable[Union[dict, Entity]], Mapping[Any, dict]] + ) -> Iterable[Entity]: + new_entities = [] + if isinstance(entities, dict): + entities = [{'id': id, **e} for id, e in entities.items()] - [ - { - "id": "3", - "name": "Lightbulb 1", - "on": true, - "bri": 254, - "hue": 1532, - "sat": 215, - "effect": "none", - "xy": [ - 0.6163, - 0.3403 - ], - "ct": 153, - "alert": "none", - "colormode": "hs", - "reachable": true - "type": "Extended color light", - "modelid": "LCT001", - "manufacturername": "Philips", - "uniqueid": "00:11:22:33:44:55:66:77-88", - "swversion": "5.105.0.21169" - } - ] + for entity in entities: + if isinstance(entity, Entity): + new_entities.append(entity) + elif isinstance(entity, dict): + new_entities.append( + LightEntity( + id=entity['id'], + name=entity['name'], + description=entity.get('type'), + on=entity.get('state', {}).get('on', False), + brightness=entity.get('state', {}).get('bri'), + saturation=entity.get('state', {}).get('sat'), + hue=entity.get('state', {}).get('hue'), + temperature=entity.get('state', {}).get('ct'), + colormode=entity.get('colormode'), + reachable=entity.get('state', {}).get('reachable'), + x=entity['state']['xy'][0] + if entity.get('state', {}).get('xy') + else None, + y=entity['state']['xy'][1] + if entity.get('state', {}).get('xy') + else None, + effect=entity.get('state', {}).get('effect'), + **( + { + 'hue_min': 0, + 'hue_max': self.MAX_HUE, + } + if entity.get('state', {}).get('hue') is not None + else { + 'hue_min': None, + 'hue_max': None, + } + ), + **( + { + 'saturation_min': 0, + 'saturation_max': self.MAX_SAT, + } + if entity.get('state', {}).get('sat') is not None + else { + 'saturation_min': None, + 'saturation_max': None, + } + ), + **( + { + 'brightness_min': 0, + 'brightness_max': self.MAX_BRI, + } + if entity.get('state', {}).get('bri') is not None + else { + 'brightness_min': None, + 'brightness_max': None, + } + ), + **( + { + 'temperature_min': self.MIN_CT, + 'temperature_max': self.MAX_CT, + } + if entity.get('state', {}).get('ct') is not None + else { + 'temperature_min': None, + 'temperature_max': None, + } + ), + ) + ) - """ + return super().transform_entities(new_entities) # type: ignore - return [ - { - 'id': id, - **light.pop('state', {}), - **light, - } - for id, light in self.bridge.get_light().items() - ] + def _get_lights(self) -> dict: + assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR + lights = self.bridge.get_light() + lights = {id: light for id, light in lights.items() if not light.get('recycle')} + self._cached_lights = lights + self.publish_entities(lights) # type: ignore + return lights + + def _get_groups(self) -> dict: + assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR + groups = self.bridge.get_group() or {} + return {id: group for id, group in groups.items() if not group.get('recycle')} + + def _get_scenes(self) -> dict: + assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR + scenes = self.bridge.get_scene() or {} + return {id: scene for id, scene in scenes.items() if not scene.get('recycle')} + + @action + def status(self) -> Iterable[LightEntity]: + lights = self.transform_entities(self._get_lights()) + for light in lights: + light.id = light.external_id + for attr, value in (light.data or {}).items(): + setattr(light, attr, value) + + del light.external_id + del light.data + + return lights + + def main(self): + lights_prev = self._get_lights() # Initialize the lights + + while not self.should_stop(): + try: + lights_new = self._get_lights() + for light_id, light in lights_new.items(): + event_args = {} + new_state = light.get('state', {}) + prev_state = lights_prev.get(light_id, {}).get('state', {}) + + for attr in ['on', 'bri', 'sat', 'hue', 'ct', 'xy']: + if attr in new_state and new_state.get(attr) != prev_state.get( + attr + ): + event_args[attr] = new_state.get(attr) + + if event_args: + event_args['plugin_name'] = 'light.hue' + event_args['light_id'] = light_id + event_args['light_name'] = light.get('name') + get_bus().post(LightStatusChangeEvent(**event_args)) + self.publish_entities([{'id': light_id, **light}]) # type: ignore + + lights_prev = lights_new + finally: + self.wait_stop(self.poll_seconds) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/light/hue/manifest.yaml b/platypush/plugins/light/hue/manifest.yaml index cd18d5195..1a8581aa2 100644 --- a/platypush/plugins/light/hue/manifest.yaml +++ b/platypush/plugins/light/hue/manifest.yaml @@ -4,6 +4,8 @@ manifest: started. platypush.message.event.light.LightAnimationStoppedEvent: when an animation is stopped. + platypush.message.event.light.LightStatusChangeEvent: when the status of a + lightbulb changes. install: pip: - phue diff --git a/platypush/plugins/media/search/local.py b/platypush/plugins/media/search/local.py index 3298dfe17..76868aa4f 100644 --- a/platypush/plugins/media/search/local.py +++ b/platypush/plugins/media/search/local.py @@ -3,9 +3,16 @@ import os import re import time -from sqlalchemy import create_engine, Column, Integer, String, DateTime, PrimaryKeyConstraint, ForeignKey -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import ( + create_engine, + Column, + Integer, + String, + DateTime, + PrimaryKeyConstraint, + ForeignKey, +) +from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from sqlalchemy.sql.expression import func from platypush.config import Config @@ -38,7 +45,8 @@ class LocalMediaSearcher(MediaSearcher): if not self._db_engine: self._db_engine = create_engine( 'sqlite:///{}'.format(self.db_file), - connect_args={'check_same_thread': False}) + connect_args={'check_same_thread': False}, + ) Base.metadata.create_all(self._db_engine) Session.configure(bind=self._db_engine) @@ -57,27 +65,30 @@ class LocalMediaSearcher(MediaSearcher): @classmethod def _get_last_modify_time(cls, path, recursive=False): - return max([os.path.getmtime(p) for p, _, _ in os.walk(path)]) \ - if recursive else os.path.getmtime(path) + return ( + max([os.path.getmtime(p) for p, _, _ in os.walk(path)]) + if recursive + else os.path.getmtime(path) + ) @classmethod - def _has_directory_changed_since_last_indexing(self, dir_record): + def _has_directory_changed_since_last_indexing(cls, dir_record): if not dir_record.last_indexed_at: return True - return datetime.datetime.fromtimestamp( - self._get_last_modify_time(dir_record.path)) > dir_record.last_indexed_at + return ( + datetime.datetime.fromtimestamp(cls._get_last_modify_time(dir_record.path)) + > dir_record.last_indexed_at + ) @classmethod def _matches_query(cls, filename, query): filename = filename.lower() - query_tokens = [_.lower() for _ in re.split( - cls._filename_separators, query.strip())] + query_tokens = [ + _.lower() for _ in re.split(cls._filename_separators, query.strip()) + ] - for token in query_tokens: - if token not in filename: - return False - return True + return all(token in filename for token in query_tokens) @classmethod def _sync_token_records(cls, session, *tokens): @@ -85,9 +96,12 @@ class LocalMediaSearcher(MediaSearcher): if not tokens: return [] - records = {record.token: record for record in - session.query(MediaToken).filter( - MediaToken.token.in_(tokens)).all()} + records = { + record.token: record + for record in session.query(MediaToken) + .filter(MediaToken.token.in_(tokens)) + .all() + } for token in tokens: if token in records: @@ -97,13 +111,11 @@ class LocalMediaSearcher(MediaSearcher): records[token] = record session.commit() - return session.query(MediaToken).filter( - MediaToken.token.in_(tokens)).all() + return session.query(MediaToken).filter(MediaToken.token.in_(tokens)).all() @classmethod def _get_file_records(cls, dir_record, session): - return session.query(MediaFile).filter_by( - directory_id=dir_record.id).all() + return session.query(MediaFile).filter_by(directory_id=dir_record.id).all() def scan(self, media_dir, session=None, dir_record=None): """ @@ -121,17 +133,19 @@ class LocalMediaSearcher(MediaSearcher): dir_record = self._get_or_create_dir_entry(session, media_dir) if not os.path.isdir(media_dir): - self.logger.info('Directory {} is no longer accessible, removing it'. - format(media_dir)) - session.query(MediaDirectory) \ - .filter(MediaDirectory.path == media_dir) \ - .delete(synchronize_session='fetch') + self.logger.info( + 'Directory {} is no longer accessible, removing it'.format(media_dir) + ) + session.query(MediaDirectory).filter( + MediaDirectory.path == media_dir + ).delete(synchronize_session='fetch') return stored_file_records = { - f.path: f for f in self._get_file_records(dir_record, session)} + f.path: f for f in self._get_file_records(dir_record, session) + } - for path, dirs, files in os.walk(media_dir): + for path, _, files in os.walk(media_dir): for filename in files: filepath = os.path.join(path, filename) @@ -142,26 +156,32 @@ class LocalMediaSearcher(MediaSearcher): del stored_file_records[filepath] continue - if not MediaPlugin.is_video_file(filename) and \ - not MediaPlugin.is_audio_file(filename): + if not MediaPlugin.is_video_file( + filename + ) and not MediaPlugin.is_audio_file(filename): continue self.logger.debug('Syncing item {}'.format(filepath)) - tokens = [_.lower() for _ in re.split(self._filename_separators, - filename.strip())] + tokens = [ + _.lower() + for _ in re.split(self._filename_separators, filename.strip()) + ] token_records = self._sync_token_records(session, *tokens) - file_record = MediaFile.build(directory_id=dir_record.id, - path=filepath) + file_record = MediaFile.build(directory_id=dir_record.id, path=filepath) session.add(file_record) session.commit() - file_record = session.query(MediaFile).filter_by( - directory_id=dir_record.id, path=filepath).one() + file_record = ( + session.query(MediaFile) + .filter_by(directory_id=dir_record.id, path=filepath) + .one() + ) for token_record in token_records: - file_token = MediaFileToken.build(file_id=file_record.id, - token_id=token_record.id) + file_token = MediaFileToken.build( + file_id=file_record.id, token_id=token_record.id + ) session.add(file_token) # stored_file_records should now only contain the records of the files @@ -169,15 +189,20 @@ class LocalMediaSearcher(MediaSearcher): if stored_file_records: self.logger.info( 'Removing references to {} deleted media items from {}'.format( - len(stored_file_records), media_dir)) + len(stored_file_records), media_dir + ) + ) - session.query(MediaFile).filter(MediaFile.id.in_( - [record.id for record in stored_file_records.values()] - )).delete(synchronize_session='fetch') + session.query(MediaFile).filter( + MediaFile.id.in_([record.id for record in stored_file_records.values()]) + ).delete(synchronize_session='fetch') dir_record.last_indexed_at = datetime.datetime.now() - self.logger.info('Scanned {} in {} seconds'.format( - media_dir, int(time.time() - index_start_time))) + self.logger.info( + 'Scanned {} in {} seconds'.format( + media_dir, int(time.time() - index_start_time) + ) + ) session.commit() @@ -197,25 +222,30 @@ class LocalMediaSearcher(MediaSearcher): dir_record = self._get_or_create_dir_entry(session, media_dir) if self._has_directory_changed_since_last_indexing(dir_record): - self.logger.info('{} has changed since last indexing, '.format( - media_dir) + 're-indexing') + self.logger.info( + '{} has changed since last indexing, '.format(media_dir) + + 're-indexing' + ) self.scan(media_dir, session=session, dir_record=dir_record) - query_tokens = [_.lower() for _ in re.split( - self._filename_separators, query.strip())] + query_tokens = [ + _.lower() for _ in re.split(self._filename_separators, query.strip()) + ] - for file_record in session.query(MediaFile.path). \ - join(MediaFileToken). \ - join(MediaToken). \ - filter(MediaToken.token.in_(query_tokens)). \ - group_by(MediaFile.path). \ - having(func.count(MediaFileToken.token_id) >= len(query_tokens)): + for file_record in ( + session.query(MediaFile.path) + .join(MediaFileToken) + .join(MediaToken) + .filter(MediaToken.token.in_(query_tokens)) + .group_by(MediaFile.path) + .having(func.count(MediaFileToken.token_id) >= len(query_tokens)) + ): if os.path.isfile(file_record.path): results[file_record.path] = { 'url': 'file://' + file_record.path, 'title': os.path.basename(file_record.path), - 'size': os.path.getsize(file_record.path) + 'size': os.path.getsize(file_record.path), } return results.values() @@ -223,11 +253,12 @@ class LocalMediaSearcher(MediaSearcher): # --- Table definitions + class MediaDirectory(Base): - """ Models the MediaDirectory table """ + """Models the MediaDirectory table""" __tablename__ = 'MediaDirectory' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) path = Column(String) @@ -243,14 +274,15 @@ class MediaDirectory(Base): class MediaFile(Base): - """ Models the MediaFile table """ + """Models the MediaFile table""" __tablename__ = 'MediaFile' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) - directory_id = Column(Integer, ForeignKey( - 'MediaDirectory.id', ondelete='CASCADE'), nullable=False) + directory_id = Column( + Integer, ForeignKey('MediaDirectory.id', ondelete='CASCADE'), nullable=False + ) path = Column(String, nullable=False, unique=True) indexed_at = Column(DateTime) @@ -265,10 +297,10 @@ class MediaFile(Base): class MediaToken(Base): - """ Models the MediaToken table """ + """Models the MediaToken table""" __tablename__ = 'MediaToken' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) token = Column(String, nullable=False, unique=True) @@ -282,14 +314,16 @@ class MediaToken(Base): class MediaFileToken(Base): - """ Models the MediaFileToken table """ + """Models the MediaFileToken table""" __tablename__ = 'MediaFileToken' - file_id = Column(Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'), - nullable=False) - token_id = Column(Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'), - nullable=False) + file_id = Column( + Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'), nullable=False + ) + token_id = Column( + Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'), nullable=False + ) __table_args__ = (PrimaryKeyConstraint(file_id, token_id), {}) @@ -301,4 +335,5 @@ class MediaFileToken(Base): record.token_id = token_id return record + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/ntfy/__init__.py b/platypush/plugins/ntfy/__init__.py index 064275723..9684b2b15 100644 --- a/platypush/plugins/ntfy/__init__.py +++ b/platypush/plugins/ntfy/__init__.py @@ -241,7 +241,7 @@ class NtfyPlugin(RunnablePlugin): args['headers'] = { 'Filename': filename, **({'X-Title': title} if title else {}), - **({'X-Click': click_url} if click_url else {}), + **({'X-Click': url} if url else {}), **({'X-Email': email} if email else {}), **({'X-Priority': priority} if priority else {}), **({'X-Tags': ','.join(tags)} if tags else {}), @@ -256,7 +256,7 @@ class NtfyPlugin(RunnablePlugin): 'topic': topic, 'message': message, **({'title': title} if title else {}), - **({'click': click_url} if click_url else {}), + **({'click': url} if url else {}), **({'email': email} if email else {}), **({'priority': priority} if priority else {}), **({'tags': tags} if tags else {}), diff --git a/platypush/plugins/smartthings/__init__.py b/platypush/plugins/smartthings/__init__.py index 4853f2533..36df8ae67 100644 --- a/platypush/plugins/smartthings/__init__.py +++ b/platypush/plugins/smartthings/__init__.py @@ -2,13 +2,17 @@ import asyncio import aiohttp from threading import RLock -from typing import Optional, Dict, List, Union +from typing import Optional, Dict, List, Union, Iterable +from platypush.entities import manages +from platypush.entities.lights import Light +from platypush.entities.switches import Switch from platypush.plugins import action -from platypush.plugins.switch import SwitchPlugin +from platypush.plugins.switch import Plugin -class SmartthingsPlugin(SwitchPlugin): +@manages(Switch, Light) +class SmartthingsPlugin(Plugin): """ Plugin to interact with devices and locations registered to a Samsung SmartThings account. @@ -18,7 +22,7 @@ class SmartthingsPlugin(SwitchPlugin): """ - _timeout = aiohttp.ClientTimeout(total=20.) + _timeout = aiohttp.ClientTimeout(total=20.0) def __init__(self, access_token: str, **kwargs): """ @@ -43,46 +47,27 @@ class SmartthingsPlugin(SwitchPlugin): async def _refresh_locations(self, api): self._locations = await api.locations() - - self._locations_by_id = { - loc.location_id: loc - for loc in self._locations - } - - self._locations_by_name = { - loc.name: loc - for loc in self._locations - } + self._locations_by_id = {loc.location_id: loc for loc in self._locations} + self._locations_by_name = {loc.name: loc for loc in self._locations} async def _refresh_devices(self, api): self._devices = await api.devices() - - self._devices_by_id = { - dev.device_id: dev - for dev in self._devices - } - - self._devices_by_name = { - dev.label: dev - for dev in self._devices - } + self._devices_by_id = {dev.device_id: dev for dev in self._devices} + self._devices_by_name = {dev.label: dev for dev in self._devices} async def _refresh_rooms(self, api, location_id: str): self._rooms_by_location[location_id] = await api.rooms(location_id=location_id) - self._rooms_by_id.update(**{ - room.room_id: room - for room in self._rooms_by_location[location_id] - }) + self._rooms_by_id.update( + **{room.room_id: room for room in self._rooms_by_location[location_id]} + ) self._rooms_by_location_and_id[location_id] = { - room.room_id: room - for room in self._rooms_by_location[location_id] + room.room_id: room for room in self._rooms_by_location[location_id] } self._rooms_by_location_and_name[location_id] = { - room.name: room - for room in self._rooms_by_location[location_id] + room.name: room for room in self._rooms_by_location[location_id] } async def _refresh_info(self): @@ -127,7 +112,7 @@ class SmartthingsPlugin(SwitchPlugin): 'rooms': { room.room_id: self._room_to_dict(room) for room in self._rooms_by_location.get(location.location_id, {}) - } + }, } @staticmethod @@ -257,12 +242,18 @@ class SmartthingsPlugin(SwitchPlugin): """ self.refresh_info() return { - 'locations': {loc.location_id: self._location_to_dict(loc) for loc in self._locations}, - 'devices': {dev.device_id: self._device_to_dict(dev) for dev in self._devices}, + 'locations': { + loc.location_id: self._location_to_dict(loc) for loc in self._locations + }, + 'devices': { + dev.device_id: self._device_to_dict(dev) for dev in self._devices + }, } @action - def get_location(self, location_id: Optional[str] = None, name: Optional[str] = None) -> dict: + def get_location( + self, location_id: Optional[str] = None, name: Optional[str] = None + ) -> dict: """ Get the info of a location by ID or name. @@ -296,20 +287,37 @@ class SmartthingsPlugin(SwitchPlugin): """ assert location_id or name, 'Specify either location_id or name' - if location_id not in self._locations_by_id or name not in self._locations_by_name: + if ( + location_id not in self._locations_by_id + or name not in self._locations_by_name + ): self.refresh_info() - location = self._locations_by_id.get(location_id, self._locations_by_name.get(name)) + location = self._locations_by_id.get( + location_id, self._locations_by_name.get(name) + ) assert location, 'Location {} not found'.format(location_id or name) return self._location_to_dict(location) def _get_device(self, device: str): - if device not in self._devices_by_id or device not in self._devices_by_name: + return self._get_devices(device)[0] + + def _get_devices(self, *devices: str): + def get_found_and_missing_devs(): + found_devs = [ + self._devices_by_id.get(d, self._devices_by_name.get(d)) + for d in devices + ] + missing_devs = [d for i, d in enumerate(devices) if not found_devs[i]] + return found_devs, missing_devs + + devs, missing_devs = get_found_and_missing_devs() + if missing_devs: self.refresh_info() - device = self._devices_by_id.get(device, self._devices_by_name.get(device)) - assert device, 'Device {} not found'.format(device) - return device + devs, missing_devs = get_found_and_missing_devs() + assert not missing_devs, f'Devices not found: {missing_devs}' + return devs @action def get_device(self, device: str) -> dict: @@ -340,24 +348,41 @@ class SmartthingsPlugin(SwitchPlugin): device = self._get_device(device) return self._device_to_dict(device) - async def _execute(self, device_id: str, capability: str, command, component_id: str, args: Optional[list]): + async def _execute( + self, + device_id: str, + capability: str, + command, + component_id: str, + args: Optional[list], + ): import pysmartthings async with aiohttp.ClientSession(timeout=self._timeout) as session: api = pysmartthings.SmartThings(session, self._access_token) device = await api.device(device_id) - ret = await device.command(component_id=component_id, capability=capability, command=command, args=args) + ret = await device.command( + component_id=component_id, + capability=capability, + command=command, + args=args, + ) - assert ret, 'The command {capability}={command} failed on device {device}'.format( - capability=capability, command=command, device=device_id) + assert ( + ret + ), 'The command {capability}={command} failed on device {device}'.format( + capability=capability, command=command, device=device_id + ) @action - def execute(self, - device: str, - capability: str, - command, - component_id: str = 'main', - args: Optional[list] = None): + def execute( + self, + device: str, + capability: str, + command, + component_id: str = 'main', + args: Optional[list] = None, + ): """ Execute a command on a device. @@ -388,16 +413,89 @@ class SmartthingsPlugin(SwitchPlugin): loop = asyncio.new_event_loop() try: asyncio.set_event_loop(loop) - loop.run_until_complete(self._execute( - device_id=device.device_id, capability=capability, command=command, - component_id=component_id, args=args)) + loop.run_until_complete( + self._execute( + device_id=device.device_id, + capability=capability, + command=command, + component_id=component_id, + args=args, + ) + ) finally: loop.stop() @staticmethod - async def _get_device_status(api, device_id: str) -> dict: + def _is_light(device): + if isinstance(device, dict): + capabilities = device.get('capabilities', []) + else: + capabilities = device.capabilities + + return 'colorControl' in capabilities or 'colorTemperature' in capabilities + + def transform_entities(self, entities): + from platypush.entities.switches import Switch + + compatible_entities = [] + + for device in entities: + data = { + 'location_id': getattr(device, 'location_id', None), + 'room_id': getattr(device, 'room_id', None), + } + + if self._is_light(device): + light_attrs = { + 'id': device.device_id, + 'name': device.label, + 'data': data, + } + + if 'switch' in device.capabilities: + light_attrs['on'] = device.status.switch + if getattr(device.status, 'level', None) is not None: + light_attrs['brightness'] = device.status.level + light_attrs['brightness_min'] = 0 + light_attrs['brightness_max'] = 100 + if 'colorTemperature' in device.capabilities: + # Color temperature range on SmartThings is expressed in Kelvin + light_attrs['temperature_min'] = 2000 + light_attrs['temperature_max'] = 6500 + if ( + device.status.color_temperature + >= light_attrs['temperature_min'] + ): + light_attrs['temperature'] = ( + light_attrs['temperature_max'] + - light_attrs['temperature_min'] + ) / 2 + if getattr(device.status, 'hue', None) is not None: + light_attrs['hue'] = device.status.hue + light_attrs['hue_min'] = 0 + light_attrs['hue_max'] = 100 + if getattr(device.status, 'saturation', None) is not None: + light_attrs['saturation'] = device.status.saturation + light_attrs['saturation_min'] = 0 + light_attrs['saturation_max'] = 80 + + compatible_entities.append(Light(**light_attrs)) + elif 'switch' in device.capabilities: + compatible_entities.append( + Switch( + id=device.device_id, + name=device.label, + state=device.status.switch, + data=data, + ) + ) + + return super().transform_entities(compatible_entities) # type: ignore + + async def _get_device_status(self, api, device_id: str) -> dict: device = await api.device(device_id) await device.status.refresh() + self.publish_entities([device]) # type: ignore return { 'device_id': device_id, @@ -407,7 +505,7 @@ class SmartthingsPlugin(SwitchPlugin): for cap in device.capabilities if hasattr(device.status, cap) and not callable(getattr(device.status, cap)) - } + }, } async def _refresh_status(self, devices: List[str]) -> List[dict]: @@ -434,7 +532,9 @@ class SmartthingsPlugin(SwitchPlugin): parse_device_id(dev) # Fail if some devices haven't been found after refreshing - assert not missing_device_ids, 'Could not find the following devices: {}'.format(list(missing_device_ids)) + assert ( + not missing_device_ids + ), 'Could not find the following devices: {}'.format(list(missing_device_ids)) async with aiohttp.ClientSession(timeout=self._timeout) as session: api = pysmartthings.SmartThings(session, self._access_token) @@ -489,7 +589,7 @@ class SmartthingsPlugin(SwitchPlugin): loop.stop() @action - def on(self, device: str, *args, **kwargs) -> dict: + def on(self, device: str, *_, **__) -> dict: """ Turn on a device with ``switch`` capability. @@ -497,11 +597,10 @@ class SmartthingsPlugin(SwitchPlugin): :return: Device status """ self.execute(device, 'switch', 'on') - # noinspection PyUnresolvedReferences - return self.status(device).output[0] + return self.status(device).output[0] # type: ignore @action - def off(self, device: str, *args, **kwargs) -> dict: + def off(self, device: str, *_, **__) -> dict: """ Turn off a device with ``switch`` capability. @@ -509,11 +608,10 @@ class SmartthingsPlugin(SwitchPlugin): :return: Device status """ self.execute(device, 'switch', 'off') - # noinspection PyUnresolvedReferences - return self.status(device).output[0] + return self.status(device).output[0] # type: ignore @action - def toggle(self, device: str, *args, **kwargs) -> dict: + def toggle(self, device: str, *args, **__) -> dict: """ Toggle a device with ``switch`` capability. @@ -529,22 +627,28 @@ class SmartthingsPlugin(SwitchPlugin): async with aiohttp.ClientSession(timeout=self._timeout) as session: api = pysmartthings.SmartThings(session, self._access_token) dev = await api.device(device_id) - assert 'switch' in dev.capabilities, 'The device {} has no switch capability'.format(dev.label) + assert ( + 'switch' in dev.capabilities + ), 'The device {} has no switch capability'.format(dev.label) await dev.status.refresh() state = 'off' if dev.status.switch else 'on' - ret = await dev.command(component_id='main', capability='switch', command=state, args=args) + ret = await dev.command( + component_id='main', capability='switch', command=state, args=args + ) - assert ret, 'The command switch={state} failed on device {device}'.format(state=state, device=dev.label) - return not dev.status.switch + assert ret, 'The command switch={state} failed on device {device}'.format( + state=state, device=dev.label + ) with self._refresh_lock: loop = asyncio.new_event_loop() - state = loop.run_until_complete(_toggle()) + loop.run_until_complete(_toggle()) + device = loop.run_until_complete(self._refresh_status([device_id]))[0] # type: ignore return { 'id': device_id, - 'name': device.label, - 'on': state, + 'name': device['name'], + 'on': device['switch'], } @property @@ -569,8 +673,7 @@ class SmartthingsPlugin(SwitchPlugin): ] """ - # noinspection PyUnresolvedReferences - devices = self.status().output + devices = self.status().output # type: ignore return [ { 'name': device['name'], @@ -578,8 +681,65 @@ class SmartthingsPlugin(SwitchPlugin): 'on': device['switch'], } for device in devices - if 'switch' in device + if 'switch' in device and not self._is_light(device) ] + @action + def set_level(self, device: str, level: int, **kwargs): + """ + Set the level of a device with ``switchLevel`` capabilities (e.g. the + brightness of a lightbulb or the speed of a fan). + + :param device: Device ID or name. + :param level: Level, usually a percentage value between 0 and 1. + :param kwarsg: Extra arguments that should be passed to :meth:`.execute`. + """ + self.execute(device, 'switchLevel', 'setLevel', args=[int(level)], **kwargs) + + @action + def set_lights( + self, + lights: Iterable[str], + on: Optional[bool] = None, + brightness: Optional[int] = None, + hue: Optional[int] = None, + saturation: Optional[int] = None, + hex: Optional[str] = None, + temperature: Optional[int] = None, + **_, + ): + err = None + + with self._execute_lock: + for light in lights: + try: + if on is not None: + self.execute(light, 'switch', 'on' if on else 'off') + if brightness is not None: + self.execute( + light, 'switchLevel', 'setLevel', args=[brightness] + ) + if hue is not None: + self.execute(light, 'colorControl', 'setHue', args=[hue]) + if saturation is not None: + self.execute( + light, 'colorControl', 'setSaturation', args=[saturation] + ) + if temperature is not None: + self.execute( + light, + 'colorTemperature', + 'setColorTemperature', + args=[temperature], + ) + if hex is not None: + self.execute(light, 'colorControl', 'setColor', args=[hex]) + except Exception as e: + self.logger.error('Could not set attributes on %s: %s', light, e) + err = e + + if err: + raise err + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/switch/__init__.py b/platypush/plugins/switch/__init__.py index e73513d90..4b33f7e75 100644 --- a/platypush/plugins/switch/__init__.py +++ b/platypush/plugins/switch/__init__.py @@ -1,9 +1,12 @@ from abc import ABC, abstractmethod from typing import List, Union +from platypush.entities import manages +from platypush.entities.switches import Switch from platypush.plugins import Plugin, action +@manages(Switch) class SwitchPlugin(Plugin, ABC): """ Abstract class for interacting with switch devices @@ -46,7 +49,7 @@ class SwitchPlugin(Plugin, ABC): return devices @action - def status(self, device=None, *args, **kwargs) -> Union[dict, List[dict]]: + def status(self, device=None, *_, **__) -> Union[dict, List[dict]]: """ Get the status of all the devices, or filter by device name or ID (alias for :meth:`.switch_status`). diff --git a/platypush/plugins/switch/tplink/__init__.py b/platypush/plugins/switch/tplink/__init__.py index 47470d357..1b1c646e1 100644 --- a/platypush/plugins/switch/tplink/__init__.py +++ b/platypush/plugins/switch/tplink/__init__.py @@ -1,7 +1,15 @@ -from typing import Union, Dict, List +from typing import Union, Mapping, List, Collection, Optional -from pyHS100 import SmartDevice, SmartPlug, SmartBulb, SmartStrip, Discover, SmartDeviceException +from pyHS100 import ( + SmartDevice, + SmartPlug, + SmartBulb, + SmartStrip, + Discover, + SmartDeviceException, +) +from platypush.entities import Entity from platypush.plugins import action from platypush.plugins.switch import SwitchPlugin @@ -20,8 +28,13 @@ class SwitchTplinkPlugin(SwitchPlugin): _ip_to_dev = {} _alias_to_dev = {} - def __init__(self, plugs: Union[Dict[str, str], List[str]] = None, bulbs: Union[Dict[str, str], List[str]] = None, - strips: Union[Dict[str, str], List[str]] = None, **kwargs): + def __init__( + self, + plugs: Optional[Union[Mapping[str, str], List[str]]] = None, + bulbs: Optional[Union[Mapping[str, str], List[str]]] = None, + strips: Optional[Union[Mapping[str, str], List[str]]] = None, + **kwargs + ): """ :param plugs: Optional list of IP addresses or name->address mapping if you have a static list of TpLink plugs and you want to save on the scan time. @@ -62,19 +75,44 @@ class SwitchTplinkPlugin(SwitchPlugin): self._update_devices() - def _update_devices(self, devices: Dict[str, SmartDevice] = None): + def _update_devices(self, devices: Optional[Mapping[str, SmartDevice]] = None): for (addr, info) in self._static_devices.items(): try: dev = info['type'](addr) self._alias_to_dev[info.get('name', dev.alias)] = dev self._ip_to_dev[addr] = dev except SmartDeviceException as e: - self.logger.warning('Could not communicate with device {}: {}'.format(addr, str(e))) + self.logger.warning( + 'Could not communicate with device {}: {}'.format(addr, str(e)) + ) for (ip, dev) in (devices or {}).items(): self._ip_to_dev[ip] = dev self._alias_to_dev[dev.alias] = dev + if devices: + self.publish_entities(devices.values()) # type: ignore + + def transform_entities(self, devices: Collection[SmartDevice]): + from platypush.entities.switches import Switch + + return super().transform_entities( # type: ignore + [ + Switch( + id=dev.host, + name=dev.alias, + state=dev.is_on, + data={ + 'current_consumption': dev.current_consumption(), + 'ip': dev.host, + 'host': dev.host, + 'hw_info': dev.hw_info, + }, + ) + for dev in (devices or []) + ] + ) + def _scan(self): devices = Discover.discover() self._update_devices(devices) @@ -84,6 +122,9 @@ class SwitchTplinkPlugin(SwitchPlugin): if not use_cache: self._scan() + if isinstance(device, Entity): + device = device.external_id or device.name + if device in self._ip_to_dev: return self._ip_to_dev[device] @@ -95,8 +136,15 @@ class SwitchTplinkPlugin(SwitchPlugin): else: raise RuntimeError('Device {} not found'.format(device)) + def _set(self, device: SmartDevice, state: bool): + action_name = 'turn_on' if state else 'turn_off' + action = getattr(device, action_name) + action() + self.publish_entities([device]) # type: ignore + return self._serialize(device) + @action - def on(self, device, **kwargs): + def on(self, device, **_): """ Turn on a device @@ -105,11 +153,10 @@ class SwitchTplinkPlugin(SwitchPlugin): """ device = self._get_device(device) - device.turn_on() - return self.status(device) + return self._set(device, True) @action - def off(self, device, **kwargs): + def off(self, device, **_): """ Turn off a device @@ -118,11 +165,10 @@ class SwitchTplinkPlugin(SwitchPlugin): """ device = self._get_device(device) - device.turn_off() - return self.status(device) + return self._set(device, False) @action - def toggle(self, device, **kwargs): + def toggle(self, device, **_): """ Toggle the state of a device (on/off) @@ -131,12 +177,10 @@ class SwitchTplinkPlugin(SwitchPlugin): """ device = self._get_device(device) + return self._set(device, not device.is_on) - if device.is_on: - device.turn_off() - else: - device.turn_on() - + @staticmethod + def _serialize(device: SmartDevice) -> dict: return { 'current_consumption': device.current_consumption(), 'id': device.host, @@ -149,17 +193,7 @@ class SwitchTplinkPlugin(SwitchPlugin): @property def switches(self) -> List[dict]: - return [ - { - 'current_consumption': dev.current_consumption(), - 'id': ip, - 'ip': ip, - 'host': dev.host, - 'hw_info': dev.hw_info, - 'name': dev.alias, - 'on': dev.is_on, - } for (ip, dev) in self._scan().items() - ] + return [self._serialize(dev) for dev in self._scan().values()] # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/switch/wemo/__init__.py b/platypush/plugins/switch/wemo/__init__.py index 312c1c31f..e879c0466 100644 --- a/platypush/plugins/switch/wemo/__init__.py +++ b/platypush/plugins/switch/wemo/__init__.py @@ -1,9 +1,11 @@ +import contextlib import ipaddress -from typing import List +from typing import List, Optional from platypush.plugins import action from platypush.plugins.switch import SwitchPlugin from platypush.utils.workers import Workers + from .lib import WemoRunner from .scanner import Scanner @@ -16,7 +18,13 @@ class SwitchWemoPlugin(SwitchPlugin): _default_port = 49153 - def __init__(self, devices=None, netmask: str = None, port: int = _default_port, **kwargs): + def __init__( + self, + devices=None, + netmask: Optional[str] = None, + port: int = _default_port, + **kwargs + ): """ :param devices: List of IP addresses or name->address map containing the WeMo Switch devices to control. This plugin previously used ouimeaux for auto-discovery but it's been dropped because @@ -37,8 +45,11 @@ class SwitchWemoPlugin(SwitchPlugin): def _init_devices(self, devices): if devices: - self._devices.update(devices if isinstance(devices, dict) else - {addr: addr for addr in devices}) + self._devices.update( + devices + if isinstance(devices, dict) + else {addr: addr for addr in devices} + ) else: self._devices = {} @@ -68,37 +79,53 @@ class SwitchWemoPlugin(SwitchPlugin): """ return [ - self.status(device).output - for device in self._devices.values() + self.status(device).output # type: ignore + for device in self._devices.values() ] def _get_address(self, device: str) -> str: if device not in self._addresses: - try: + with contextlib.suppress(KeyError): return self._devices[device] - except KeyError: - pass return device @action - def status(self, device: str = None, *args, **kwargs): + def status(self, device: Optional[str] = None, *_, **__): devices = {device: device} if device else self._devices.copy() ret = [ { - 'id': addr, - 'ip': addr, - 'name': name if name != addr else WemoRunner.get_name(addr), - 'on': WemoRunner.get_state(addr), + "id": addr, + "ip": addr, + "name": name if name != addr else WemoRunner.get_name(addr), + "on": WemoRunner.get_state(addr), } for (name, addr) in devices.items() ] + self.publish_entities(ret) # type: ignore return ret[0] if device else ret + def transform_entities(self, devices: List[dict]): + from platypush.entities.switches import Switch + + return super().transform_entities( # type: ignore + [ + Switch( + id=dev["id"], + name=dev["name"], + state=dev["on"], + data={ + "ip": dev["ip"], + }, + ) + for dev in (devices or []) + ] + ) + @action - def on(self, device: str, **kwargs): + def on(self, device: str, **_): """ Turn a switch on @@ -109,7 +136,7 @@ class SwitchWemoPlugin(SwitchPlugin): return self.status(device) @action - def off(self, device: str, **kwargs): + def off(self, device: str, **_): """ Turn a switch off @@ -120,7 +147,7 @@ class SwitchWemoPlugin(SwitchPlugin): return self.status(device) @action - def toggle(self, device: str, *args, **kwargs): + def toggle(self, device: str, *_, **__): """ Toggle a device on/off state @@ -151,19 +178,16 @@ class SwitchWemoPlugin(SwitchPlugin): return WemoRunner.get_name(device) @action - def scan(self, netmask: str = None): + def scan(self, netmask: Optional[str] = None): netmask = netmask or self.netmask - assert netmask, 'Scan not supported: No netmask specified' + assert netmask, "Scan not supported: No netmask specified" workers = Workers(10, Scanner, port=self.port) with workers: for addr in ipaddress.IPv4Network(netmask): workers.put(addr.exploded) - devices = { - dev.name: dev.addr - for dev in workers.responses - } + devices = {dev.name: dev.addr for dev in workers.responses} self._init_devices(devices) return self.status() diff --git a/platypush/plugins/switchbot/__init__.py b/platypush/plugins/switchbot/__init__.py index 6545f8873..8d7b0e602 100644 --- a/platypush/plugins/switchbot/__init__.py +++ b/platypush/plugins/switchbot/__init__.py @@ -43,16 +43,21 @@ class SwitchbotPlugin(SwitchPlugin): return url def _run(self, method: str = 'get', *args, device=None, **kwargs): - response = getattr(requests, method)(self._url_for(*args, device=device), headers={ - 'Authorization': self._api_token, - 'Accept': 'application/json', - 'Content-Type': 'application/json; charset=utf-8', - }, **kwargs) + response = getattr(requests, method)( + self._url_for(*args, device=device), + headers={ + 'Authorization': self._api_token, + 'Accept': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + }, + **kwargs, + ) response.raise_for_status() response = response.json() - assert response.get('statusCode') == 100, \ - f'Switchbot API request failed: {response.get("statusCode")}: {response.get("message")}' + assert ( + response.get('statusCode') == 100 + ), f'Switchbot API request failed: {response.get("statusCode")}: {response.get("message")}' return response.get('body') @@ -77,16 +82,20 @@ class SwitchbotPlugin(SwitchPlugin): """ devices = self._run('get', 'devices') devices = [ - DeviceSchema().dump({ - **device, - 'is_virtual': False, - }) + DeviceSchema().dump( + { + **device, + 'is_virtual': False, + } + ) for device in devices.get('deviceList', []) ] + [ - DeviceSchema().dump({ - **device, - 'is_virtual': True, - }) + DeviceSchema().dump( + { + **device, + 'is_virtual': True, + } + ) for device in devices.get('infraredRemoteList', []) ] @@ -96,10 +105,44 @@ class SwitchbotPlugin(SwitchPlugin): return devices - def _worker(self, q: queue.Queue, method: str = 'get', *args, device: Optional[dict] = None, **kwargs): + def transform_entities(self, devices: List[dict]): + from platypush.entities.switches import Switch + + return super().transform_entities( # type: ignore + [ + Switch( + id=dev["id"], + name=dev["name"], + state=dev.get("on"), + is_write_only=True, + data={ + "device_type": dev.get("device_type"), + "is_virtual": dev.get("is_virtual", False), + "hub_id": dev.get("hub_id"), + }, + ) + for dev in (devices or []) + if dev.get('device_type') == 'Bot' + ] + ) + + def _worker( + self, + q: queue.Queue, + method: str = 'get', + *args, + device: Optional[dict] = None, + **kwargs, + ): schema = DeviceStatusSchema() try: - if method == 'get' and args and args[0] == 'status' and device and device.get('is_virtual'): + if ( + method == 'get' + and args + and args[0] == 'status' + and device + and device.get('is_virtual') + ): res = schema.load(device) else: res = self._run(method, *args, device=device, **kwargs) @@ -121,7 +164,11 @@ class SwitchbotPlugin(SwitchPlugin): devices = self.devices().output if device: device_info = self._get_device(device) - status = {} if device_info['is_virtual'] else self._run('get', 'status', device=device_info) + status = ( + {} + if device_info['is_virtual'] + else self._run('get', 'status', device=device_info) + ) return { **device_info, **status, @@ -133,7 +180,7 @@ class SwitchbotPlugin(SwitchPlugin): threading.Thread( target=self._worker, args=(queues[i], 'get', 'status'), - kwargs={'device': dev} + kwargs={'device': dev}, ) for i, dev in enumerate(devices) ] @@ -148,14 +195,17 @@ class SwitchbotPlugin(SwitchPlugin): continue assert not isinstance(response, Exception), str(response) - results.append({ - **devices_by_id.get(response.get('id'), {}), - **response, - }) + results.append( + { + **devices_by_id.get(response.get('id'), {}), + **response, + } + ) for worker in workers: worker.join() + self.publish_entities(results) # type: ignore return results @action @@ -200,9 +250,7 @@ class SwitchbotPlugin(SwitchPlugin): @property def switches(self) -> List[dict]: # noinspection PyUnresolvedReferences - return [ - dev for dev in self.status().output if 'on' in dev - ] + return [dev for dev in self.status().output if 'on' in dev] @action def set_curtain_position(self, device: str, position: int): @@ -213,11 +261,16 @@ class SwitchbotPlugin(SwitchPlugin): :param position: An integer between 0 (open) and 100 (closed). """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'setPosition', - 'commandType': 'command', - 'parameter': f'0,ff,{position}', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'setPosition', + 'commandType': 'command', + 'parameter': f'0,ff,{position}', + }, + ) @action def set_humidifier_efficiency(self, device: str, efficiency: Union[int, str]): @@ -228,11 +281,16 @@ class SwitchbotPlugin(SwitchPlugin): :param efficiency: An integer between 0 (open) and 100 (closed) or `auto`. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'setMode', - 'commandType': 'command', - 'parameter': efficiency, - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'setMode', + 'commandType': 'command', + 'parameter': efficiency, + }, + ) @action def set_fan_speed(self, device: str, speed: int): @@ -246,11 +304,16 @@ class SwitchbotPlugin(SwitchPlugin): status = self.status(device=device).output mode = status.get('mode') swing_range = status.get('swing_range') - return self._run('post', 'commands', device=device, json={ - 'command': 'set', - 'commandType': 'command', - 'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]), - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'set', + 'commandType': 'command', + 'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]), + }, + ) @action def set_fan_mode(self, device: str, mode: int): @@ -264,11 +327,16 @@ class SwitchbotPlugin(SwitchPlugin): status = self.status(device=device).output speed = status.get('speed') swing_range = status.get('swing_range') - return self._run('post', 'commands', device=device, json={ - 'command': 'set', - 'commandType': 'command', - 'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]), - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'set', + 'commandType': 'command', + 'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]), + }, + ) @action def set_swing_range(self, device: str, swing_range: int): @@ -282,11 +350,16 @@ class SwitchbotPlugin(SwitchPlugin): status = self.status(device=device).output speed = status.get('speed') mode = status.get('mode') - return self._run('post', 'commands', device=device, json={ - 'command': 'set', - 'commandType': 'command', - 'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]), - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'set', + 'commandType': 'command', + 'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]), + }, + ) @action def set_temperature(self, device: str, temperature: float): @@ -300,11 +373,18 @@ class SwitchbotPlugin(SwitchPlugin): status = self.status(device=device).output mode = status.get('mode') fan_speed = status.get('fan_speed') - return self._run('post', 'commands', device=device, json={ - 'command': 'setAll', - 'commandType': 'command', - 'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']), - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'setAll', + 'commandType': 'command', + 'parameter': ','.join( + [str(temperature), str(mode), str(fan_speed), 'on'] + ), + }, + ) @action def set_ac_mode(self, device: str, mode: int): @@ -325,11 +405,18 @@ class SwitchbotPlugin(SwitchPlugin): status = self.status(device=device).output temperature = status.get('temperature') fan_speed = status.get('fan_speed') - return self._run('post', 'commands', device=device, json={ - 'command': 'setAll', - 'commandType': 'command', - 'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']), - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'setAll', + 'commandType': 'command', + 'parameter': ','.join( + [str(temperature), str(mode), str(fan_speed), 'on'] + ), + }, + ) @action def set_ac_fan_speed(self, device: str, fan_speed: int): @@ -349,11 +436,18 @@ class SwitchbotPlugin(SwitchPlugin): status = self.status(device=device).output temperature = status.get('temperature') mode = status.get('mode') - return self._run('post', 'commands', device=device, json={ - 'command': 'setAll', - 'commandType': 'command', - 'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']), - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'setAll', + 'commandType': 'command', + 'parameter': ','.join( + [str(temperature), str(mode), str(fan_speed), 'on'] + ), + }, + ) @action def set_channel(self, device: str, channel: int): @@ -364,11 +458,16 @@ class SwitchbotPlugin(SwitchPlugin): :param channel: Channel number. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'SetChannel', - 'commandType': 'command', - 'parameter': [str(channel)], - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'SetChannel', + 'commandType': 'command', + 'parameter': [str(channel)], + }, + ) @action def volup(self, device: str): @@ -378,10 +477,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'volumeAdd', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'volumeAdd', + 'commandType': 'command', + }, + ) @action def voldown(self, device: str): @@ -391,10 +495,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'volumeSub', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'volumeSub', + 'commandType': 'command', + }, + ) @action def mute(self, device: str): @@ -404,10 +513,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'setMute', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'setMute', + 'commandType': 'command', + }, + ) @action def channel_next(self, device: str): @@ -417,10 +531,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'channelAdd', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'channelAdd', + 'commandType': 'command', + }, + ) @action def channel_prev(self, device: str): @@ -430,10 +549,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'channelSub', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'channelSub', + 'commandType': 'command', + }, + ) @action def play(self, device: str): @@ -443,10 +567,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'Play', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'Play', + 'commandType': 'command', + }, + ) @action def pause(self, device: str): @@ -456,10 +585,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'Pause', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'Pause', + 'commandType': 'command', + }, + ) @action def stop(self, device: str): @@ -469,10 +603,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'Stop', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'Stop', + 'commandType': 'command', + }, + ) @action def forward(self, device: str): @@ -482,10 +621,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'FastForward', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'FastForward', + 'commandType': 'command', + }, + ) @action def back(self, device: str): @@ -495,10 +639,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'Rewind', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'Rewind', + 'commandType': 'command', + }, + ) @action def next(self, device: str): @@ -508,10 +657,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'Next', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'Next', + 'commandType': 'command', + }, + ) @action def previous(self, device: str): @@ -521,10 +675,15 @@ class SwitchbotPlugin(SwitchPlugin): :param device: Device name or ID. """ device = self._get_device(device) - return self._run('post', 'commands', device=device, json={ - 'command': 'Previous', - 'commandType': 'command', - }) + return self._run( + 'post', + 'commands', + device=device, + json={ + 'command': 'Previous', + 'commandType': 'command', + }, + ) @action def scenes(self) -> List[dict]: @@ -544,7 +703,8 @@ class SwitchbotPlugin(SwitchPlugin): """ # noinspection PyUnresolvedReferences scenes = [ - s for s in self.scenes().output + s + for s in self.scenes().output if s.get('id') == scene or s.get('name') == scene ] diff --git a/platypush/plugins/switchbot/bluetooth/__init__.py b/platypush/plugins/switchbot/bluetooth/__init__.py index e441ef651..5da12b3d9 100644 --- a/platypush/plugins/switchbot/bluetooth/__init__.py +++ b/platypush/plugins/switchbot/bluetooth/__init__.py @@ -1,6 +1,6 @@ import enum import time -from typing import List +from typing import List, Optional from platypush.message.response.bluetooth import BluetoothScanResponse from platypush.plugins import action @@ -8,7 +8,9 @@ from platypush.plugins.bluetooth.ble import BluetoothBlePlugin from platypush.plugins.switch import SwitchPlugin -class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/missing-call-to-init] +class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init] + SwitchPlugin, BluetoothBlePlugin +): """ Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and programmatically control switches over a Bluetooth interface. @@ -31,6 +33,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m """ Base64 encoded commands """ + # \x57\x01\x00 PRESS = 'VwEA' # # \x57\x01\x01 @@ -38,8 +41,14 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m # # \x57\x01\x02 OFF = 'VwEC' - def __init__(self, interface=None, connect_timeout=None, - scan_timeout=2, devices=None, **kwargs): + def __init__( + self, + interface=None, + connect_timeout=None, + scan_timeout=2, + devices=None, + **kwargs + ): """ :param interface: Bluetooth interface to use (e.g. hci0) default: first available one :type interface: str @@ -59,17 +68,21 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m self.scan_timeout = scan_timeout if scan_timeout else 2 self.configured_devices = devices or {} self.configured_devices_by_name = { - name: addr - for addr, name in self.configured_devices.items() + name: addr for addr, name in self.configured_devices.items() } def _run(self, device: str, command: Command): - if device in self.configured_devices_by_name: - device = self.configured_devices_by_name[device] + device = self.configured_devices_by_name.get(device, '') n_tries = 1 try: - self.write(device, command.value, handle=self.handle, channel_type='random', binary=True) + self.write( + device, + command.value, + handle=self.handle, + channel_type='random', + binary=True, + ) except Exception as e: self.logger.exception(e) n_tries -= 1 @@ -78,7 +91,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m raise e time.sleep(5) - return self.status(device) + return self.status(device) # type: ignore @action def press(self, device): @@ -91,11 +104,11 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m return self._run(device, self.Command.PRESS) @action - def toggle(self, device, **kwargs): + def toggle(self, device, **_): return self.press(device) @action - def on(self, device, **kwargs): + def on(self, device, **_): """ Send a press-on button command to a device @@ -105,7 +118,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m return self._run(device, self.Command.ON) @action - def off(self, device, **kwargs): + def off(self, device, **_): """ Send a press-off button command to a device @@ -115,7 +128,9 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m return self._run(device, self.Command.OFF) @action - def scan(self, interface: str = None, duration: int = 10) -> BluetoothScanResponse: + def scan( + self, interface: Optional[str] = None, duration: int = 10 + ) -> BluetoothScanResponse: """ Scan for available Switchbot devices nearby. @@ -129,9 +144,13 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m for dev in devices: try: characteristics = [ - chrc for chrc in self.discover_characteristics( - dev['addr'], channel_type='random', wait=False, - timeout=self.scan_timeout).characteristics + chrc + for chrc in self.discover_characteristics( + dev['addr'], + channel_type='random', + wait=False, + timeout=self.scan_timeout, + ).characteristics if chrc.get('uuid') == self.uuid ] @@ -140,10 +159,12 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m except Exception as e: self.logger.warning('Device scan error', e) + self.publish_entities(compatible_devices) # type: ignore return BluetoothScanResponse(devices=compatible_devices) @property def switches(self) -> List[dict]: + self.publish_entities(self.configured_devices) # type: ignore return [ { 'address': addr, @@ -154,5 +175,20 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m for addr, name in self.configured_devices.items() ] + def transform_entities(self, devices: dict): + from platypush.entities.switches import Switch + + return super().transform_entities( # type: ignore + [ + Switch( + id=addr, + name=name, + state=False, + is_write_only=True, + ) + for addr, name in devices.items() + ] + ) + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py index 65cb2734b..b7a12cffc 100644 --- a/platypush/plugins/zigbee/mqtt/__init__.py +++ b/platypush/plugins/zigbee/mqtt/__init__.py @@ -4,12 +4,16 @@ import threading from queue import Queue from typing import Optional, List, Any, Dict, Union +from platypush.entities import manages +from platypush.entities.lights import Light +from platypush.entities.switches import Switch +from platypush.message import Mapping from platypush.message.response import Response from platypush.plugins.mqtt import MqttPlugin, action -from platypush.plugins.switch import SwitchPlugin -class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-init] +@manages(Light, Switch) +class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init] """ This plugin allows you to interact with Zigbee devices over MQTT through any Zigbee sniffer and `zigbee2mqtt <https://www.zigbee2mqtt.io/>`_. @@ -35,7 +39,8 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i .. code-block:: shell - wget https://github.com/Koenkk/Z-Stack-firmware/raw/master/coordinator/Z-Stack_Home_1.2/bin/default/CC2531_DEFAULT_20201127.zip + wget https://github.com/Koenkk/Z-Stack-firmware/raw/master\ + /coordinator/Z-Stack_Home_1.2/bin/default/CC2531_DEFAULT_20201127.zip unzip CC2531_DEFAULT_20201127.zip [sudo] cc-tool -e -w CC2531ZNP-Prod.hex @@ -78,7 +83,8 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i configured your network, to prevent accidental/malignant joins from outer Zigbee devices. - Start the ``zigbee2mqtt`` daemon on your device (the - `official documentation <https://www.zigbee2mqtt.io/getting_started/running_zigbee2mqtt.html#5-optional-running-as-a-daemon-with-systemctl>`_ + `official documentation <https://www.zigbee2mqtt.io/getting_started + /running_zigbee2mqtt.html#5-optional-running-as-a-daemon-with-systemctl>`_ also contains instructions on how to configure it as a ``systemd`` service: .. code-block:: shell @@ -103,10 +109,20 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i """ - def __init__(self, host: str = 'localhost', port: int = 1883, base_topic: str = 'zigbee2mqtt', timeout: int = 10, - tls_certfile: Optional[str] = None, tls_keyfile: Optional[str] = None, - tls_version: Optional[str] = None, tls_ciphers: Optional[str] = None, - username: Optional[str] = None, password: Optional[str] = None, **kwargs): + def __init__( + self, + host: str = 'localhost', + port: int = 1883, + base_topic: str = 'zigbee2mqtt', + timeout: int = 10, + tls_certfile: Optional[str] = None, + tls_keyfile: Optional[str] = None, + tls_version: Optional[str] = None, + tls_ciphers: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + **kwargs, + ): """ :param host: Default MQTT broker where ``zigbee2mqtt`` publishes its messages (default: ``localhost``). :param port: Broker listen port (default: 1883). @@ -124,17 +140,80 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i :param username: If the connection requires user authentication, specify the username (default: None) :param password: If the connection requires user authentication, specify the password (default: None) """ - super().__init__(host=host, port=port, tls_certfile=tls_certfile, tls_keyfile=tls_keyfile, - tls_version=tls_version, tls_ciphers=tls_ciphers, username=username, - password=password, **kwargs) + super().__init__( + host=host, + port=port, + tls_certfile=tls_certfile, + tls_keyfile=tls_keyfile, + tls_version=tls_version, + tls_ciphers=tls_ciphers, + username=username, + password=password, + **kwargs, + ) self.base_topic = base_topic self.timeout = timeout self._info = { 'devices': {}, 'groups': {}, + 'devices_by_addr': {}, } + def transform_entities(self, devices): + from platypush.entities.switches import Switch + + compatible_entities = [] + for dev in devices: + if not dev: + continue + + converted_entity = None + dev_def = dev.get("definition") or {} + dev_info = { + "type": dev.get("type"), + "date_code": dev.get("date_code"), + "ieee_address": dev.get("ieee_address"), + "network_address": dev.get("network_address"), + "power_source": dev.get("power_source"), + "software_build_id": dev.get("software_build_id"), + "model_id": dev.get("model_id"), + "model": dev_def.get("model"), + "vendor": dev_def.get("vendor"), + "supported": dev.get("supported"), + } + + light_info = self._get_light_meta(dev) + switch_info = self._get_switch_meta(dev) + + if light_info: + converted_entity = Light( + id=dev['ieee_address'], + name=dev.get('friendly_name'), + on=dev.get('state', {}).get('state') == switch_info.get('value_on'), + brightness=dev.get('state', {}).get('brightness'), + brightness_min=light_info.get('brightness_min'), + brightness_max=light_info.get('brightness_max'), + temperature=dev.get('state', {}).get('temperature'), + temperature_min=light_info.get('temperature_min'), + temperature_max=light_info.get('temperature_max'), + description=dev_def.get('description'), + data=dev_info, + ) + elif switch_info and dev.get('state', {}).get('state') is not None: + converted_entity = Switch( + id=dev['ieee_address'], + name=dev.get('friendly_name'), + state=dev.get('state', {}).get('state') == switch_info['value_on'], + description=dev_def.get("description"), + data=dev_info, + ) + + if converted_entity: + compatible_entities.append(converted_entity) + + return super().transform_entities(compatible_entities) # type: ignore + def _get_network_info(self, **kwargs): self.logger.info('Fetching Zigbee network information') client = None @@ -157,7 +236,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i 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[topic] = ( + msg.payload.decode() + if topic == 'state' + else json.loads(msg.payload.decode()) + ) info_ready_events[topic].set() return callback @@ -174,7 +257,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i 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') + raise TimeoutError( + 'A timeout occurred while fetching the Zigbee network information' + ) # Cache the new results self._info['devices'] = { @@ -182,9 +267,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i for device in info.get('devices', []) } + self._info['devices_by_addr'] = { + device['ieee_address']: device for device in info.get('devices', []) + } + self._info['groups'] = { - group.get('name'): group - for group in info.get('groups', []) + group.get('name'): group for group in info.get('groups', []) } self.logger.info('Zigbee network configuration updated') @@ -194,7 +282,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i client.loop_stop() client.disconnect() except Exception as e: - self.logger.warning('Error on MQTT client disconnection: {}'.format(str(e))) + self.logger.warning( + 'Error on MQTT client disconnection: {}'.format(str(e)) + ) def _topic(self, topic): return self.base_topic + '/' + topic @@ -204,7 +294,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i if isinstance(response, Response): response = response.output - assert response.get('status') != 'error', response.get('error', 'zigbee2mqtt error') + assert response.get('status') != 'error', response.get( + 'error', 'zigbee2mqtt error' + ) return response @action @@ -291,7 +383,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i "value_min": 150 }, { - "description": "Color of this light in the CIE 1931 color space (x/y)", + "description": "Color of this light in the XY space", "features": [ { "access": 7, @@ -315,7 +407,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i }, { "access": 2, - "description": "Triggers an effect on the light (e.g. make light blink for a few seconds)", + "description": "Triggers an effect on the light", "name": "effect", "property": "effect", "type": "enum", @@ -382,7 +474,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i return self._get_network_info(**kwargs).get('devices') @action - def permit_join(self, permit: bool = True, timeout: Optional[float] = None, **kwargs): + def permit_join( + self, permit: bool = True, timeout: Optional[float] = None, **kwargs + ): """ Enable/disable devices from joining the network. This is not persistent (will not be saved to ``configuration.yaml``). @@ -394,14 +488,19 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i """ if timeout: return self._parse_response( - self.publish(topic=self._topic('bridge/request/permit_join'), - msg={'value': permit, 'time': timeout}, - reply_topic=self._topic('bridge/response/permit_join'), - **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/permit_join'), + msg={'value': permit, 'time': timeout}, + reply_topic=self._topic('bridge/response/permit_join'), + **self._mqtt_args(**kwargs), + ) + ) - return self.publish(topic=self._topic('bridge/request/permit_join'), - msg={'value': permit}, - **self._mqtt_args(**kwargs)) + return self.publish( + topic=self._topic('bridge/request/permit_join'), + msg={'value': permit}, + **self._mqtt_args(**kwargs), + ) @action def factory_reset(self, **kwargs): @@ -413,7 +512,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - self.publish(topic=self._topic('bridge/request/touchlink/factory_reset'), msg='', **self._mqtt_args(**kwargs)) + self.publish( + topic=self._topic('bridge/request/touchlink/factory_reset'), + msg='', + **self._mqtt_args(**kwargs), + ) @action def log_level(self, level: str, **kwargs): @@ -425,9 +528,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i (default: query the default configured device). """ return self._parse_response( - self.publish(topic=self._topic('bridge/request/config/log_level'), msg={'value': level}, - reply_topic=self._topic('bridge/response/config/log_level'), - **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/config/log_level'), + msg={'value': level}, + reply_topic=self._topic('bridge/response/config/log_level'), + **self._mqtt_args(**kwargs), + ) + ) @action def device_set_option(self, device: str, option: str, value: Any, **kwargs): @@ -441,14 +548,18 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i (default: query the default configured device). """ return self._parse_response( - self.publish(topic=self._topic('bridge/request/device/options'), - reply_topic=self._topic('bridge/response/device/options'), - msg={ - 'id': device, - 'options': { - option: value, - } - }, **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/device/options'), + reply_topic=self._topic('bridge/response/device/options'), + msg={ + 'id': device, + 'options': { + option: value, + }, + }, + **self._mqtt_args(**kwargs), + ) + ) @action def device_remove(self, device: str, force: bool = False, **kwargs): @@ -463,10 +574,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i (default: query the default configured device). """ return self._parse_response( - self.publish(topic=self._topic('bridge/request/device/remove'), - msg={'id': device, 'force': force}, - reply_topic=self._topic('bridge/response/device/remove'), - **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/device/remove'), + msg={'id': device, 'force': force}, + reply_topic=self._topic('bridge/response/device/remove'), + **self._mqtt_args(**kwargs), + ) + ) @action def device_ban(self, device: str, **kwargs): @@ -478,10 +592,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i (default: query the default configured device). """ return self._parse_response( - self.publish(topic=self._topic('bridge/request/device/ban'), - reply_topic=self._topic('bridge/response/device/ban'), - msg={'id': device}, - **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/device/ban'), + reply_topic=self._topic('bridge/response/device/ban'), + msg={'id': device}, + **self._mqtt_args(**kwargs), + ) + ) @action def device_whitelist(self, device: str, **kwargs): @@ -494,10 +611,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i (default: query the default configured device). """ return self._parse_response( - self.publish(topic=self._topic('bridge/request/device/whitelist'), - reply_topic=self._topic('bridge/response/device/whitelist'), - msg={'id': device}, - **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/device/whitelist'), + reply_topic=self._topic('bridge/response/device/whitelist'), + msg={'id': device}, + **self._mqtt_args(**kwargs), + ) + ) @action def device_rename(self, name: str, device: Optional[str] = None, **kwargs): @@ -516,8 +636,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i # noinspection PyUnresolvedReferences devices = self.devices().output - assert not [dev for dev in devices if dev.get('friendly_name') == name], \ - 'A device named {} already exists on the network'.format(name) + assert not [ + dev for dev in devices if dev.get('friendly_name') == name + ], 'A device named {} already exists on the network'.format(name) if device: req = { @@ -531,10 +652,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i } return self._parse_response( - self.publish(topic=self._topic('bridge/request/device/rename'), - msg=req, - reply_topic=self._topic('bridge/response/device/rename'), - **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/device/rename'), + msg=req, + reply_topic=self._topic('bridge/response/device/rename'), + **self._mqtt_args(**kwargs), + ) + ) @staticmethod def build_device_get_request(values: List[Dict[str, Any]]) -> dict: @@ -561,9 +685,16 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i return ret + def _get_device_info(self, device: str) -> Mapping: + return self._info['devices'].get( + device, self._info['devices_by_addr'].get(device, {}) + ) + # noinspection PyShadowingBuiltins @action - def device_get(self, device: str, property: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def device_get( + self, device: str, property: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the properties of a device. The returned keys vary depending on the device. For example, a light bulb may have the "``state``" and "``brightness``" properties, while an environment sensor may have the @@ -576,28 +707,59 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i :return: Key->value map of the device properties. """ kwargs = self._mqtt_args(**kwargs) + device_info = self._get_device_info(device) + if device_info: + device = device_info.get('friendly_name') or device_info['ieee_address'] if property: - properties = self.publish(topic=self._topic(device) + '/get/' + property, reply_topic=self._topic(device), - msg={property: ''}, **kwargs).output + properties = self.publish( + topic=self._topic(device) + f'/get/{property}', + reply_topic=self._topic(device), + msg={property: ''}, + **kwargs, + ).output - assert property in properties, 'No such property: ' + property + assert property in properties, f'No such property: {property}' return {property: properties[property]} if device not in self._info.get('devices', {}): # Refresh devices info self._get_network_info(**kwargs) - assert self._info.get('devices', {}).get(device), 'No such device: ' + device - exposes = (self._info.get('devices', {}).get(device, {}).get('definition', {}) or {}).get('exposes', []) + dev = self._info.get('devices', {}).get( + device, self._info.get('devices_by_addr', {}).get(device) + ) + + assert dev, f'No such device: {device}' + exposes = ( + self._info.get('devices', {}).get(device, {}).get('definition', {}) or {} + ).get('exposes', []) if not exposes: return {} - return self.publish(topic=self._topic(device) + '/get', reply_topic=self._topic(device), - msg=self.build_device_get_request(exposes), **kwargs) + device_state = self.publish( + topic=self._topic(device) + '/get', + reply_topic=self._topic(device), + msg=self.build_device_get_request(exposes), + **kwargs, + ).output + + if device_info: + self.publish_entities( # type: ignore + [ + { + **device_info, + 'state': device_state, + } + ] + ) + + return device_state @action - def devices_get(self, devices: Optional[List[str]] = None, **kwargs) -> Dict[str, dict]: + def devices_get( + self, devices: Optional[List[str]] = None, **kwargs + ) -> Dict[str, dict]: """ Get the properties of the devices connected to the network. @@ -622,14 +784,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i kwargs = self._mqtt_args(**kwargs) if not devices: - # noinspection PyUnresolvedReferences - devices = set([ + devices = { device['friendly_name'] or device['ieee_address'] for device in self.devices(**kwargs).output - ]) + } def worker(device: str, q: Queue): - # noinspection PyUnresolvedReferences q.put(self.device_get(device, **kwargs).output) queues = {} @@ -638,7 +798,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i for device in devices: queues[device] = Queue() - workers[device] = threading.Thread(target=worker, args=(device, queues[device])) + workers[device] = threading.Thread( + target=worker, args=(device, queues[device]) + ) workers[device].start() for device in devices: @@ -646,8 +808,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i response[device] = queues[device].get(timeout=kwargs.get('timeout')) workers[device].join(timeout=kwargs.get('timeout')) except Exception as e: - self.logger.warning('An error while getting the status of the device {}: {}'.format( - device, str(e))) + self.logger.warning( + 'An error while getting the status of the device {}: {}'.format( + device, str(e) + ) + ) return response @@ -658,7 +823,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i :param device: Device friendly name (default: get all devices). """ - return self.devices_get([device], *args, **kwargs) + return self.devices_get([device] if device else None, *args, **kwargs) # noinspection PyShadowingBuiltins,DuplicatedCode @action @@ -674,9 +839,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - properties = self.publish(topic=self._topic(device + '/set'), - reply_topic=self._topic(device), - msg={property: value}, **self._mqtt_args(**kwargs)).output + properties = self.publish( + topic=self._topic(device + '/set'), + reply_topic=self._topic(device), + msg={property: value}, + **self._mqtt_args(**kwargs), + ).output if property: assert property in properties, 'No such property: ' + property @@ -705,9 +873,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i """ ret = self._parse_response( - self.publish(topic=self._topic('bridge/request/device/ota_update/check'), - reply_topic=self._topic('bridge/response/device/ota_update/check'), - msg={'id': device}, **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/device/ota_update/check'), + reply_topic=self._topic('bridge/response/device/ota_update/check'), + msg={'id': device}, + **self._mqtt_args(**kwargs), + ) + ) return { 'status': ret['status'], @@ -725,9 +897,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i (default: query the default configured device). """ return self._parse_response( - self.publish(topic=self._topic('bridge/request/device/ota_update/update'), - reply_topic=self._topic('bridge/response/device/ota_update/update'), - msg={'id': device}, **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/device/ota_update/update'), + reply_topic=self._topic('bridge/response/device/ota_update/update'), + msg={'id': device}, + **self._mqtt_args(**kwargs), + ) + ) @action def groups(self, **kwargs) -> List[dict]: @@ -883,16 +1059,22 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - payload = name if id is None else { - 'id': id, - 'friendly_name': name, - } + payload = ( + name + if id is None + else { + 'id': id, + 'friendly_name': name, + } + ) return self._parse_response( - self.publish(topic=self._topic('bridge/request/group/add'), - reply_topic=self._topic('bridge/response/group/add'), - msg=payload, - **self._mqtt_args(**kwargs)) + self.publish( + topic=self._topic('bridge/request/group/add'), + reply_topic=self._topic('bridge/response/group/add'), + msg=payload, + **self._mqtt_args(**kwargs), + ) ) @action @@ -911,9 +1093,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i if property: msg = {property: ''} - properties = self.publish(topic=self._topic(group + '/get'), - reply_topic=self._topic(group), - msg=msg, **self._mqtt_args(**kwargs)).output + properties = self.publish( + topic=self._topic(group + '/get'), + reply_topic=self._topic(group), + msg=msg, + **self._mqtt_args(**kwargs), + ).output if property: assert property in properties, 'No such property: ' + property @@ -935,9 +1120,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - properties = self.publish(topic=self._topic(group + '/set'), - reply_topic=self._topic(group), - msg={property: value}, **self._mqtt_args(**kwargs)).output + properties = self.publish( + topic=self._topic(group + '/set'), + reply_topic=self._topic(group), + msg={property: value}, + **self._mqtt_args(**kwargs), + ).output if property: assert property in properties, 'No such property: ' + property @@ -961,13 +1149,18 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i # noinspection PyUnresolvedReferences groups = {group.get('friendly_name'): group for group in self.groups().output} - assert name not in groups, 'A group named {} already exists on the network'.format(name) + assert ( + name not in groups + ), 'A group named {} already exists on the network'.format(name) return self._parse_response( - self.publish(topic=self._topic('bridge/request/group/rename'), - reply_topic=self._topic('bridge/response/group/rename'), - msg={'from': group, 'to': name} if group else name, - **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/group/rename'), + reply_topic=self._topic('bridge/response/group/rename'), + msg={'from': group, 'to': name} if group else name, + **self._mqtt_args(**kwargs), + ) + ) @action def group_remove(self, name: str, **kwargs): @@ -979,10 +1172,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i (default: query the default configured device). """ return self._parse_response( - self.publish(topic=self._topic('bridge/request/group/remove'), - reply_topic=self._topic('bridge/response/group/remove'), - msg=name, - **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/group/remove'), + reply_topic=self._topic('bridge/response/group/remove'), + msg=name, + **self._mqtt_args(**kwargs), + ) + ) @action def group_add_device(self, group: str, device: str, **kwargs): @@ -995,12 +1191,16 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i (default: query the default configured device). """ return self._parse_response( - self.publish(topic=self._topic('bridge/request/group/members/add'), - reply_topic=self._topic('bridge/response/group/members/add'), - msg={ - 'group': group, - 'device': device, - }, **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/group/members/add'), + reply_topic=self._topic('bridge/response/group/members/add'), + msg={ + 'group': group, + 'device': device, + }, + **self._mqtt_args(**kwargs), + ) + ) @action def group_remove_device(self, group: str, device: Optional[str] = None, **kwargs): @@ -1015,13 +1215,23 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i """ return self._parse_response( self.publish( - topic=self._topic('bridge/request/group/members/remove{}'.format('_all' if device is None else '')), + topic=self._topic( + 'bridge/request/group/members/remove{}'.format( + '_all' if device is None else '' + ) + ), reply_topic=self._topic( - 'bridge/response/group/members/remove{}'.format('_all' if device is None else '')), + 'bridge/response/group/members/remove{}'.format( + '_all' if device is None else '' + ) + ), msg={ 'group': group, 'device': device, - }, **self._mqtt_args(**kwargs))) + }, + **self._mqtt_args(**kwargs), + ) + ) @action def bind_devices(self, source: str, target: str, **kwargs): @@ -1040,9 +1250,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i (default: query the default configured device). """ return self._parse_response( - self.publish(topic=self._topic('bridge/request/device/bind'), - reply_topic=self._topic('bridge/response/device/bind'), - msg={'from': source, 'to': target}, **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/device/bind'), + reply_topic=self._topic('bridge/response/device/bind'), + msg={'from': source, 'to': target}, + **self._mqtt_args(**kwargs), + ) + ) @action def unbind_devices(self, source: str, target: str, **kwargs): @@ -1057,9 +1271,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i (default: query the default configured device). """ return self._parse_response( - self.publish(topic=self._topic('bridge/request/device/unbind'), - reply_topic=self._topic('bridge/response/device/unbind'), - msg={'from': source, 'to': target}, **self._mqtt_args(**kwargs))) + self.publish( + topic=self._topic('bridge/request/device/unbind'), + reply_topic=self._topic('bridge/response/device/unbind'), + msg={'from': source, 'to': target}, + **self._mqtt_args(**kwargs), + ) + ) @action def on(self, device, *args, **kwargs) -> dict: @@ -1067,10 +1285,15 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.on` and turns on a Zigbee device with a writable binary property. """ - switch_info = self._get_switches_info().get(device) + switch_info = self._get_switch_info(device) assert switch_info, '{} is not a valid switch'.format(device) - props = self.device_set(device, switch_info['property'], switch_info['value_on']).output - return self._properties_to_switch(device=device, props=props, switch_info=switch_info) + device = switch_info.get('friendly_name') or switch_info['ieee_address'] + props = self.device_set( + device, switch_info['property'], switch_info['value_on'] + ).output + return self._properties_to_switch( + device=device, props=props, switch_info=switch_info + ) @action def off(self, device, *args, **kwargs) -> dict: @@ -1078,10 +1301,15 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.off` and turns off a Zigbee device with a writable binary property. """ - switch_info = self._get_switches_info().get(device) + switch_info = self._get_switch_info(device) assert switch_info, '{} is not a valid switch'.format(device) - props = self.device_set(device, switch_info['property'], switch_info['value_off']).output - return self._properties_to_switch(device=device, props=props, switch_info=switch_info) + device = switch_info.get('friendly_name') or switch_info['ieee_address'] + props = self.device_set( + device, switch_info['property'], switch_info['value_off'] + ).output + return self._properties_to_switch( + device=device, props=props, switch_info=switch_info + ) @action def toggle(self, device, *args, **kwargs) -> dict: @@ -1089,10 +1317,26 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.toggle` and toggles a Zigbee device with a writable binary property. """ - switch_info = self._get_switches_info().get(device) + switch_info = self._get_switch_info(device) assert switch_info, '{} is not a valid switch'.format(device) - props = self.device_set(device, switch_info['property'], switch_info['value_toggle']).output - return self._properties_to_switch(device=device, props=props, switch_info=switch_info) + device = switch_info.get('friendly_name') or switch_info['ieee_address'] + props = self.device_set( + device, switch_info['property'], switch_info['value_toggle'] + ).output + return self._properties_to_switch( + device=device, props=props, switch_info=switch_info + ) + + def _get_switch_info(self, device: str): + switches_info = self._get_switches_info() + info = switches_info.get(device) + if info: + return info + + device_info = self._get_device_info(device) + if device_info: + device = device_info.get('friendly_name') or device_info['ieee_address'] + return switches_info.get(device) @staticmethod def _properties_to_switch(device: str, props: dict, switch_info: dict) -> dict: @@ -1103,32 +1347,105 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i **props, } - def _get_switches_info(self) -> dict: - def switch_info(device_info: dict) -> dict: - exposes = (device_info.get('definition', {}) or {}).get('exposes', []) - for exposed in exposes: - for feature in exposed.get('features', []): - if feature.get('type') == 'binary' and 'value_on' in feature and 'value_off' in feature and \ - feature.get('access', 0) & 2: - return { - 'property': feature['property'], + @staticmethod + def _get_switch_meta(device_info: dict) -> dict: + exposes = (device_info.get('definition', {}) or {}).get('exposes', []) + for exposed in exposes: + for feature in exposed.get('features', []): + if ( + feature.get('property') == 'state' + and feature.get('type') == 'binary' + and 'value_on' in feature + and 'value_off' in feature + ): + return { + 'friendly_name': device_info.get('friendly_name'), + 'ieee_address': device_info.get('friendly_name'), + 'property': feature['property'], + 'value_on': feature['value_on'], + 'value_off': feature['value_off'], + 'value_toggle': feature.get('value_toggle', None), + 'is_read_only': not bool(feature.get('access', 0) & 2), + 'is_write_only': not bool(feature.get('access', 0) & 1), + } + + return {} + + @staticmethod + def _get_light_meta(device_info: dict) -> dict: + exposes = (device_info.get('definition', {}) or {}).get('exposes', []) + for exposed in exposes: + if exposed.get('type') == 'light': + features = exposed.get('features', []) + switch = {} + brightness = {} + temperature = {} + + for feature in features: + if ( + feature.get('property') == 'state' + and feature.get('type') == 'binary' + and 'value_on' in feature + and 'value_off' in feature + ): + switch = { 'value_on': feature['value_on'], 'value_off': feature['value_off'], + 'state_name': feature['name'], 'value_toggle': feature.get('value_toggle', None), + 'is_read_only': not bool(feature.get('access', 0) & 2), + 'is_write_only': not bool(feature.get('access', 0) & 1), + } + elif ( + feature.get('property') == 'brightness' + and feature.get('type') == 'numeric' + and 'value_min' in feature + and 'value_max' in feature + ): + brightness = { + 'brightness_name': feature['name'], + 'brightness_min': feature['value_min'], + 'brightness_max': feature['value_max'], + 'is_read_only': not bool(feature.get('access', 0) & 2), + 'is_write_only': not bool(feature.get('access', 0) & 1), + } + elif ( + feature.get('property') == 'color_temp' + and feature.get('type') == 'numeric' + and 'value_min' in feature + and 'value_max' in feature + ): + temperature = { + 'temperature_name': feature['name'], + 'temperature_min': feature['value_min'], + 'temperature_max': feature['value_max'], + 'is_read_only': not bool(feature.get('access', 0) & 2), + 'is_write_only': not bool(feature.get('access', 0) & 1), } - return {} + return { + 'friendly_name': device_info.get('friendly_name'), + 'ieee_address': device_info.get('friendly_name'), + **switch, + **brightness, + **temperature, + } + return {} + + def _get_switches_info(self) -> dict: # noinspection PyUnresolvedReferences devices = self.devices().output switches_info = {} for device in devices: - info = switch_info(device) + info = self._get_switch_meta(device) if not info: continue - switches_info[device.get('friendly_name', device.get('ieee_address'))] = info + switches_info[ + device.get('friendly_name', device.get('ieee_address')) + ] = info return switches_info @@ -1142,9 +1459,37 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i switches_info = self._get_switches_info() # noinspection PyUnresolvedReferences return [ - self._properties_to_switch(device=name, props=switch, switch_info=switches_info[name]) - for name, switch in self.devices_get(list(switches_info.keys())).output.items() + self._properties_to_switch( + device=name, props=switch, switch_info=switches_info[name] + ) + for name, switch in self.devices_get( + list(switches_info.keys()) + ).output.items() ] + @action + def set_lights(self, lights, **kwargs): + devices = [ + dev + for dev in self._get_network_info().get('devices', []) + if dev.get('ieee_address') in lights or dev.get('friendly_name') in lights + ] + + for dev in devices: + light_meta = self._get_light_meta(dev) + assert light_meta, f'{dev["name"]} is not a light' + + for attr, value in kwargs.items(): + if attr == 'on': + attr = light_meta['state_name'] + elif attr in {'brightness', 'bri'}: + attr = light_meta['brightness_name'] + elif attr in {'temperature', 'ct'}: + attr = light_meta['temperature_name'] + + self.device_set( + dev.get('friendly_name', dev.get('ieee_address')), attr, value + ) + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/zwave/mqtt/__init__.py b/platypush/plugins/zwave/mqtt/__init__.py index 63279b900..c07449c6b 100644 --- a/platypush/plugins/zwave/mqtt/__init__.py +++ b/platypush/plugins/zwave/mqtt/__init__.py @@ -3,8 +3,9 @@ import queue from datetime import datetime from threading import Timer -from typing import Optional, List, Any, Dict, Union, Iterable, Callable +from typing import Optional, List, Any, Dict, Union, Iterable, Mapping, Callable +from platypush.entities.switches import Switch from platypush.message.event.zwave import ZwaveNodeRenamedEvent, ZwaveNodeEvent from platypush.context import get_backend, get_bus @@ -21,6 +22,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): This plugin allows you to manage a Z-Wave network over MQTT through `zwavejs2mqtt <https://github.com/zwave-js/zwavejs2mqtt>`_. + For historical reasons, it is advised to enabled this plugin together + with the ``zwave.mqtt`` backend, or you may lose the ability to listen + to asynchronous events. + Configuration required on the zwavejs2mqtt gateway: * Install the gateway following the instructions reported @@ -45,10 +50,21 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): """ - def __init__(self, name: str, host: str = 'localhost', port: int = 1883, topic_prefix: str = 'zwave', - timeout: int = 10, tls_certfile: Optional[str] = None, tls_keyfile: Optional[str] = None, - tls_version: Optional[str] = None, tls_ciphers: Optional[str] = None, username: Optional[str] = None, - password: Optional[str] = None, **kwargs): + def __init__( + self, + name: str, + host: str = 'localhost', + port: int = 1883, + topic_prefix: str = 'zwave', + timeout: int = 10, + tls_certfile: Optional[str] = None, + tls_keyfile: Optional[str] = None, + tls_version: Optional[str] = None, + tls_ciphers: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + **kwargs, + ): """ :param name: Gateway name, as configured from the zwavejs2mqtt web panel from Mqtt -> Name. :param host: MQTT broker host, as configured from the zwavejs2mqtt web panel from Mqtt -> Host @@ -70,9 +86,17 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param password: If the connection requires user authentication, specify the password (default: None) """ - super().__init__(host=host, port=port, tls_certfile=tls_certfile, tls_keyfile=tls_keyfile, - tls_version=tls_version, tls_ciphers=tls_ciphers, username=username, - password=password, **kwargs) + super().__init__( + host=host, + port=port, + tls_certfile=tls_certfile, + tls_keyfile=tls_keyfile, + tls_version=tls_version, + tls_ciphers=tls_ciphers, + username=username, + password=password, + **kwargs, + ) self.topic_prefix = topic_prefix self.base_topic = topic_prefix + '/{}/ZWAVE_GATEWAY-' + name @@ -118,14 +142,21 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): if isinstance(response, Response): response = response.output - assert response.get('success') is True, response.get('message', 'zwavejs2mqtt error') + assert response.get('success') is True, response.get( + 'message', 'zwavejs2mqtt error' + ) return response def _api_request(self, api: str, *args, **kwargs): payload = json.dumps({'args': args}) ret = self._parse_response( - self.publish(topic=self._api_topic(api) + '/set', msg=payload, reply_topic=self._api_topic(api), - **self._mqtt_args(**kwargs))) + self.publish( + topic=self._api_topic(api) + '/set', + msg=payload, + reply_topic=self._api_topic(api), + **self._mqtt_args(**kwargs), + ) + ) assert not ret or ret.get('success') is True, ret.get('message') return ret.get('result') @@ -135,7 +166,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): if t: return datetime.fromtimestamp(t / 1000) - def _get_scene(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs) -> dict: + def _get_scene( + self, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + **kwargs, + ) -> dict: assert scene_id or scene_label, 'No scene_id/scene_label specified' if scene_id in self._scenes_cache['by_id']: return self._scenes_cache['by_id'][scene_id] @@ -191,8 +227,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): 'id_on_network': value['id'], 'value_id': value['id'], 'data': value.get('value'), - 'data_items': [state for state in value['states']] if len(value.get('states', [])) > 1 else None, - 'label': value.get('label', value.get('propertyName', value.get('property'))), + 'data_items': value['states'] if len(value.get('states', [])) > 1 else None, + 'label': value.get( + 'label', value.get('propertyName', value.get('property')) + ), 'property_id': value.get('property'), 'help': value.get('description'), 'node_id': value.get('nodeId'), @@ -209,8 +247,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): 'is_read_only': value['readable'] and not value['writeable'], 'is_write_only': value['writeable'] and not value['readable'], 'last_update': cls._convert_timestamp(value.get('lastUpdate')), - **({'property_key': value['propertyKey']} if 'propertyKey' in value else {}), - **({'property_key_name': value['propertyKeyName']} if 'propertyKeyName' in value else {}), + **( + {'property_key': value['propertyKey']} if 'propertyKey' in value else {} + ), + **( + {'property_key_name': value['propertyKeyName']} + if 'propertyKeyName' in value + else {} + ), } @staticmethod @@ -250,7 +294,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): 'device_id': node['hexId'].replace('0x', ''), 'name': node.get('name'), 'capabilities': capabilities, - 'manufacturer_id': '0x{:04x}'.format(node['manufacturerId']) if node.get('manufacturerId') else None, + 'manufacturer_id': '0x{:04x}'.format(node['manufacturerId']) + if node.get('manufacturerId') + else None, 'manufacturer_name': node.get('manufacturer'), 'location': node.get('loc'), 'status': node.get('status'), @@ -268,9 +314,15 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): 'is_security_device': node.get('supportsSecurity'), 'is_sleeping': node.get('ready') and node.get('status') == 'Asleep', 'last_update': cls._convert_timestamp(node.get('lastActive')), - 'product_id': '0x{:04x}'.format(node['productId']) if node.get('productId') else None, - 'product_type': '0x{:04x}'.format(node['productType']) if node.get('productType') else None, - 'product_name': ' '.join([node.get('productLabel', ''), node.get('productDescription', '')]), + 'product_id': '0x{:04x}'.format(node['productId']) + if node.get('productId') + else None, + 'product_type': '0x{:04x}'.format(node['productType']) + if node.get('productType') + else None, + 'product_name': ' '.join( + [node.get('productLabel', ''), node.get('productDescription', '')] + ), 'baud_rate': node.get('dataRate'), 'max_baud_rate': node.get('maxBaudRate', node.get('dataRate')), 'device_class': node.get('deviceClass'), @@ -284,25 +336,28 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): 'zwave_plus_node_type': node.get('zwavePlusNodeType'), 'zwave_plus_role_type': node.get('zwavePlusRoleType'), 'neighbours': node.get('neighbors', []), - 'command_classes': { value['commandClassName'] - for value in node.get('values', {}).values() if value.get('commandClassName') + for value in node.get('values', {}).values() + if value.get('commandClassName') }, - 'groups': { group['value']: cls.group_to_dict(group, node_id=node['id']) for group in node.get('groups', []) }, - 'values': { value['id']: cls.value_to_dict(value) for value in node.get('values', {}).values() }, } - def _get_node(self, node_id: Optional[int] = None, node_name: Optional[str] = None, use_cache: bool = True, - **kwargs) -> Optional[dict]: + def _get_node( + self, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + use_cache: bool = True, + **kwargs, + ) -> Optional[dict]: assert node_id or node_name, 'Please provide either a node_id or node_name' if use_cache: if node_id and node_id in self._nodes_cache['by_id']: @@ -335,12 +390,21 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): return node - def _get_value(self, value_id: Optional[int] = None, id_on_network: Optional[str] = None, - value_label: Optional[str] = None, node_id: Optional[int] = None, node_name: Optional[str] = None, - use_cache: bool = True, **_) -> Dict[str, Any]: + def _get_value( + self, + value_id: Optional[int] = None, + id_on_network: Optional[str] = None, + value_label: Optional[str] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + use_cache: bool = True, + **_, + ) -> Dict[str, Any]: # Unlike python-openzwave, value_id and id_on_network are the same on zwavejs2mqtt value_id = value_id or id_on_network - assert value_id or value_label, 'Please provide either value_id, id_on_network or value_label' + assert ( + value_id or value_label + ), 'Please provide either value_id, id_on_network or value_label' if use_cache: if value_id and value_id in self._values_cache['by_id']: @@ -350,7 +414,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): nodes = [] if node_id or node_name: - nodes = [self._get_node(node_id=node_id, node_name=node_name, use_cache=False)] + nodes = [ + self._get_node(node_id=node_id, node_name=node_name, use_cache=False) + ] if not nodes: # noinspection PyUnresolvedReferences @@ -376,8 +442,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): value = values[0] if value.get('property_id') == 'targetValue': - cur_value_id = '-'.join(value['value_id'].split('-')[:-1] + ['currentValue']) - cur_value = self._nodes_cache['by_id'][value['node_id']].get('values', {}).get(cur_value_id) + cur_value_id = '-'.join( + value['value_id'].split('-')[:-1] + ['currentValue'] + ) + cur_value = ( + self._nodes_cache['by_id'][value['node_id']] + .get('values', {}) + .get(cur_value_id) + ) if cur_value: value['data'] = cur_value['data'] @@ -385,42 +457,96 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): if value['label']: self._values_cache['by_label'][value['label']] = value + self.publish_entities([value]) # type: ignore return value + @staticmethod + def _is_switch(value: Mapping): + return ( + value.get('command_class_name', '').endswith('Switch') if value else False + ) + + def transform_entities(self, values: Iterable[Mapping]): + entities = [] + + for value in values: + if self._is_switch(value): + entities.append( + Switch( + id=value['id'], + name='{node_name} [{value_name}]'.format( + node_name=self._nodes_cache['by_id'][value['node_id']].get( + 'name', f'[Node {value["node_id"]}]' + ), + value_name=value["label"], + ), + state=value['data'], + description=value.get('help'), + is_read_only=value.get('is_read_only'), + is_write_only=value.get('is_write_only'), + data={ + 'label': value.get('label'), + 'node_id': value.get('node_id'), + }, + ) + ) + + return super().transform_entities(entities) # type: ignore + def _topic_by_value_id(self, value_id: str) -> str: return self.topic_prefix + '/' + '/'.join(value_id.split('-')) - def _filter_values(self, command_classes: Iterable[str], filter_callback: Optional[Callable[[dict], bool]] = None, - node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: - # noinspection PyUnresolvedReferences - nodes = [self._get_node(node_name=node_name, use_cache=False, **kwargs)] if node_id or node_name else \ - self.get_nodes(**kwargs).output.values() + def _filter_values( + self, + command_classes: Optional[Iterable[str]] = None, + filter_callback: Optional[Callable[[dict], bool]] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ) -> Dict[str, Any]: + nodes = ( + [self._get_node(node_name=node_name, use_cache=False, **kwargs)] + if node_id or node_name + else self.get_nodes(**kwargs).output.values() + ) command_classes = { command_class_by_name[command_name] - for command_name in command_classes + for command_name in (command_classes or []) } values = {} for node in nodes: for value in node.get('values', {}).values(): - if value.get('command_class') not in command_classes or ( - filter_callback and not filter_callback(value)): + if ( + command_classes + and value.get('command_class') not in command_classes + ) or (filter_callback and not filter_callback(value)): continue value_id = value['id_on_network'] if value_id.split('-').pop() == 'targetValue': value_id = '-'.join(value_id.split('-')[:-1]) + '-currentValue' - cur_value = self._nodes_cache['by_id'][value['node_id']].get('values', {}).get(value_id) + cur_value = ( + self._nodes_cache['by_id'][value['node_id']] + .get('values', {}) + .get(value_id) + ) if cur_value: value['data'] = cur_value['data'] values[value['id_on_network']] = value + self.publish_entities(values.values()) # type: ignore return values - def _get_group(self, group_id: Optional[str] = None, group_index: Optional[int] = None, **kwargs) -> dict: + def _get_group( + self, + group_id: Optional[str] = None, + group_index: Optional[int] = None, + **kwargs, + ) -> dict: group_id = group_id or group_index assert group_id is not None, 'No group_id/group_index specified' group = self._groups_cache.get(group_id) @@ -448,15 +574,24 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): @action def status(self, **kwargs) -> Dict[str, Any]: + """ + Get the current status of the Z-Wave values. + + :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` + (default: query the default configured device). + """ + return self._filter_values(**kwargs) + + @action + def controller_status(self, **kwargs) -> Dict[str, Any]: """ Get the status of the controller. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). - :return: dict with the following fields: ``device`` and ``state``. """ msg_queue = queue.Queue() - topic = f'{self.topic_prefix}/Controller/status' + topic = f'{self.topic_prefix}/driver/status' client = self._get_client(**kwargs) def on_message(_, __, msg): @@ -465,23 +600,26 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): msg_queue.put(json.loads(msg.payload)) client.on_message = on_message - client.connect(kwargs.get('host', self.host), kwargs.get('port', self.port), - keepalive=kwargs.get('timeout', self.timeout)) + client.connect( + kwargs.get('host', self.host), + kwargs.get('port', self.port), + keepalive=kwargs.get('timeout', self.timeout), + ) client.subscribe(topic) client.loop_start() try: - status = msg_queue.get(block=True, timeout=kwargs.get('timeout', self.timeout)) + status = msg_queue.get( + block=True, timeout=kwargs.get('timeout', self.timeout) + ) except queue.Empty: raise TimeoutError('The request timed out') finally: client.loop_stop() return { - 'device': status['nodeId'], - 'state': status['status'], - 'stats': {}, + 'state': status, } @action @@ -510,7 +648,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def remove_failed_node(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def remove_failed_node( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Remove a failed node from the network. @@ -526,7 +666,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('removeFailedNode', node_id, **kwargs) @action - def replace_failed_node(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def replace_failed_node( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Replace a failed node on the network. @@ -549,7 +691,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def request_network_update(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def request_network_update( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Request a network update to a node. @@ -575,8 +719,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('refreshNeighbors', **kwargs) @action - def get_nodes(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) \ - -> Optional[Dict[str, Any]]: + def get_nodes( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Optional[Dict[str, Any]]: """ Get the nodes associated to the network. @@ -740,7 +885,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): (default: query the default configured device). """ if node_id or node_name: - return self._get_node(node_id=node_id, node_name=node_name, use_cache=False, **kwargs) + return self._get_node( + node_id=node_id, node_name=node_name, use_cache=False, **kwargs + ) nodes = { node['id']: self.node_to_dict(node) @@ -748,10 +895,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): } self._nodes_cache['by_id'] = nodes - self._nodes_cache['by_name'] = { - node['name']: node - for node in nodes.values() - } + self._nodes_cache['by_name'] = {node['name']: node for node in nodes.values()} return nodes @@ -763,7 +907,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def set_node_name(self, new_name: str, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def set_node_name( + self, + new_name: str, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Rename a node on the network. @@ -778,10 +928,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): assert node_id, f'No such node: {node_id}' self._api_request('setNodeName', node_id, new_name, **kwargs) - get_bus().post(ZwaveNodeRenamedEvent(node={ - **self._get_node(node_id=node_id), - 'name': new_name, - })) + get_bus().post( + ZwaveNodeRenamedEvent( + node={ + **self._get_node(node_id=node_id), + 'name': new_name, + } + ) + ) @action def set_node_product_name(self, **kwargs): @@ -798,8 +952,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def set_node_location(self, location: str, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs): + def set_node_location( + self, + location: str, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Set the location of a node. @@ -814,10 +973,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): assert node_id, f'No such node: {node_id}' self._api_request('setNodeLocation', node_id, location, **kwargs) - get_bus().post(ZwaveNodeEvent(node={ - **self._get_node(node_id=node_id), - 'location': location, - })) + get_bus().post( + ZwaveNodeEvent( + node={ + **self._get_node(node_id=node_id), + 'location': location, + } + ) + ) @action def cancel_command(self, **kwargs): @@ -877,7 +1040,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): """ self._api_request('beginHealingNetwork', **kwargs) if timeout: - Timer(timeout, lambda: self._api_request('stopHealingNetwork', **kwargs)).start() + Timer( + timeout, lambda: self._api_request('stopHealingNetwork', **kwargs) + ).start() @action def switch_all(self, **kwargs): @@ -894,9 +1059,15 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def get_value(self, value_id: Optional[int] = None, id_on_network: Optional[str] = None, - value_label: Optional[str] = None, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs) -> Dict[str, Any]: + def get_value( + self, + value_id: Optional[int] = None, + id_on_network: Optional[str] = None, + value_label: Optional[str] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ) -> Dict[str, Any]: """ Get a value on the network. @@ -908,13 +1079,27 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network, node_id=node_id, - node_name=node_name, use_cache=False, **kwargs) + return self._get_value( + value_id=value_id, + value_label=value_label, + id_on_network=id_on_network, + node_id=node_id, + node_name=node_name, + use_cache=False, + **kwargs, + ) @action - def set_value(self, data, value_id: Optional[int] = None, id_on_network: Optional[str] = None, - value_label: Optional[str] = None, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs): + def set_value( + self, + data, + value_id: Optional[int] = None, + id_on_network: Optional[str] = None, + value_label: Optional[str] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Set a value. @@ -927,40 +1112,57 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - value = self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network, - node_id=node_id, node_name=node_name, **kwargs) + value = self._get_value( + value_id=value_id, + value_label=value_label, + id_on_network=id_on_network, + node_id=node_id, + node_name=node_name, + **kwargs, + ) - self._api_request('writeValue', { - 'nodeId': value['node_id'], - 'commandClass': value['command_class'], - 'endpoint': value.get('endpoint', 0), - 'property': value['property_id'], - **({'propertyKey': value['property_key']} if 'property_key' in value else {}), - }, data, **kwargs) + self._api_request( + 'writeValue', + { + 'nodeId': value['node_id'], + 'commandClass': value['command_class'], + 'endpoint': value.get('endpoint', 0), + 'property': value['property_id'], + **( + {'propertyKey': value['property_key']} + if 'property_key' in value + else {} + ), + }, + data, + **kwargs, + ) @action - def set_value_label(self, **kwargs): + def set_value_label(self, **_): """ Change the label/name of a value (not implemented by zwavejs2mqtt). """ raise _NOT_IMPLEMENTED_ERR @action - def node_add_value(self, **kwargs): + def node_add_value(self, **_): """ Add a value to a node (not implemented by zwavejs2mqtt). """ raise _NOT_IMPLEMENTED_ERR @action - def node_remove_value(self, **kwargs): + def node_remove_value(self, **_): """ Remove a value from a node (not implemented by zwavejs2mqtt). """ raise _NOT_IMPLEMENTED_ERR @action - def node_heal(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def node_heal( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Heal network node by requesting the node to rediscover their neighbours. @@ -974,7 +1176,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('healNode', node_id, **kwargs) @action - def node_update_neighbours(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def node_update_neighbours( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Ask a node to update its neighbours table (same as :meth:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin.node_heal`). @@ -987,7 +1191,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self.node_heal(node_id=node_id, node_name=node_name, **kwargs) @action - def node_network_update(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def node_network_update( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Update the controller with network information (same as :meth:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin.node_heal`). @@ -1000,7 +1206,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self.node_heal(node_id=node_id, node_name=node_name, **kwargs) @action - def node_refresh_info(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def node_refresh_info( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Fetch up-to-date information about the node. @@ -1014,7 +1222,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('refreshInfo', node_id, **kwargs) @action - def get_dimmers(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_dimmers( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the dimmers on the network or associated to a node. @@ -1023,12 +1233,17 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['switch_multilevel', 'switch_toggle_multilevel'], node_id=node_id, - node_name=node_name, **kwargs) + return self._filter_values( + ['switch_multilevel', 'switch_toggle_multilevel'], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_node_config(self, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs) -> Dict[str, Any]: + def get_node_config( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the configuration values of a node or of all the nodes on the network. @@ -1037,12 +1252,22 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['configuration', 'ip_configuration', 'association_command_configuration', - 'sensor_configuration'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + [ + 'configuration', + 'ip_configuration', + 'association_command_configuration', + 'sensor_configuration', + ], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_battery_levels(self, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs) -> Dict[str, Any]: + def get_battery_levels( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the battery levels of a node or of all the nodes on the network. @@ -1051,11 +1276,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['battery'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['battery'], node_id=node_id, node_name=node_name, **kwargs + ) @action - def get_power_levels(self, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs) -> Dict[str, Any]: + def get_power_levels( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the power levels of this node. @@ -1064,10 +1292,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['powerlevel'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['powerlevel'], node_id=node_id, node_name=node_name, **kwargs + ) @action - def get_bulbs(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_bulbs( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the bulbs/LEDs on the network or associated to a node. @@ -1076,11 +1308,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['color'], filter_callback=lambda value: not value['is_read_only'], node_id=node_id, - node_name=node_name, **kwargs) + return self._filter_values( + ['color'], + filter_callback=lambda value: not value['is_read_only'], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_switches(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_switches( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the switches on the network or associated to a node. @@ -1089,12 +1328,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['switch_binary', 'switch_toggle_binary'], - filter_callback=lambda value: not value['is_read_only'], - node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['switch_binary', 'switch_toggle_binary'], + filter_callback=lambda value: not value['is_read_only'], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_sensors(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_sensors( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the sensors on the network or associated to a node. @@ -1103,12 +1348,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['sensor_binary', 'sensor_multilevel', 'sensor_alarm', 'meter'], - filter_callback=lambda value: not value['is_write_only'], - node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['sensor_binary', 'sensor_multilevel', 'sensor_alarm', 'meter'], + filter_callback=lambda value: not value['is_write_only'], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_doorlocks(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_doorlocks( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the doorlocks on the network or associated to a node. @@ -1117,10 +1368,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['door_lock'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['door_lock'], node_id=node_id, node_name=node_name, **kwargs + ) @action - def get_locks(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_locks( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the locks on the network or associated to a node. @@ -1129,10 +1384,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['lock'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['lock'], node_id=node_id, node_name=node_name, **kwargs + ) @action - def get_usercodes(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_usercodes( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the usercodes on the network or associated to a node. @@ -1141,11 +1400,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['user_code'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['user_code'], node_id=node_id, node_name=node_name, **kwargs + ) @action - def get_thermostats(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) \ - -> Dict[str, Any]: + def get_thermostats( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the thermostats on the network or associated to a node. @@ -1154,13 +1416,25 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['thermostat_heating', 'thermostat_mode', 'thermostat_operating_state', - 'thermostat_setpoint', 'thermostat_fan_mode', 'thermostat_fan_state', - 'thermostat_setback'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + [ + 'thermostat_heating', + 'thermostat_mode', + 'thermostat_operating_state', + 'thermostat_setpoint', + 'thermostat_fan_mode', + 'thermostat_fan_state', + 'thermostat_setback', + ], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_protections(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) \ - -> Dict[str, Any]: + def get_protections( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the protection-compatible devices on the network or associated to a node. @@ -1169,7 +1443,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['protection'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['protection'], node_id=node_id, node_name=node_name, **kwargs + ) @action def get_groups(self, **kwargs) -> Dict[str, dict]: @@ -1218,7 +1494,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): group['group_id']: { **group, 'associations': [ - assoc['nodeId'] for assoc in self._api_request('getAssociations', node_id, group['index']) + assoc['nodeId'] + for assoc in self._api_request( + 'getAssociations', node_id, group['index'] + ) ], } for node_id, node in nodes.items() @@ -1228,7 +1507,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): return self._groups_cache @action - def get_scenes(self, **kwargs) -> Dict[int, Dict[str, Any]]: + def get_scenes(self, **_) -> Dict[int, Dict[str, Any]]: """ Get the scenes configured on the network. @@ -1264,7 +1543,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): } @action - def create_scene(self, label: str, **kwargs): + def create_scene(self, label: str, **_): """ Create a new scene. @@ -1275,7 +1554,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('_createScene', label) @action - def remove_scene(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs): + def remove_scene( + self, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + **kwargs, + ): """ Remove a scene. @@ -1288,7 +1572,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('_removeScene', scene['scene_id']) @action - def activate_scene(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs): + def activate_scene( + self, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + **kwargs, + ): """ Activate a scene. @@ -1301,8 +1590,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('_activateScene', scene['scene_id']) @action - def set_scene_label(self, new_label: str, scene_id: Optional[int] = None, scene_label: Optional[str] = None, - **kwargs): + def set_scene_label( + self, + new_label: str, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + **kwargs, + ): """ Rename a scene/set the scene label. @@ -1315,10 +1609,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def scene_add_value(self, data: Optional[Any] = None, value_id: Optional[int] = None, - id_on_network: Optional[str] = None, value_label: Optional[str] = None, - scene_id: Optional[int] = None, scene_label: Optional[str] = None, - node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def scene_add_value( + self, + data: Optional[Any] = None, + value_id: Optional[int] = None, + id_on_network: Optional[str] = None, + value_label: Optional[str] = None, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Add a value to a scene. @@ -1333,22 +1635,41 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - value = self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network, - node_id=node_id, node_name=node_name, **kwargs) + value = self._get_value( + value_id=value_id, + value_label=value_label, + id_on_network=id_on_network, + node_id=node_id, + node_name=node_name, + **kwargs, + ) scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs) - self._api_request('_addSceneValue', scene['scene_id'], { - 'nodeId': value['node_id'], - 'commandClass': value['command_class'], - 'property': value['property_id'], - 'endpoint': value['endpoint'], - }, data, kwargs.get('timeout', self.timeout)) + self._api_request( + '_addSceneValue', + scene['scene_id'], + { + 'nodeId': value['node_id'], + 'commandClass': value['command_class'], + 'property': value['property_id'], + 'endpoint': value['endpoint'], + }, + data, + kwargs.get('timeout', self.timeout), + ) @action - def scene_remove_value(self, value_id: Optional[int] = None, id_on_network: Optional[str] = None, - value_label: Optional[str] = None, scene_id: Optional[int] = None, - scene_label: Optional[str] = None, node_id: Optional[int] = None, - node_name: Optional[str] = None, **kwargs): + def scene_remove_value( + self, + value_id: Optional[int] = None, + id_on_network: Optional[str] = None, + value_label: Optional[str] = None, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Remove a value from a scene. @@ -1362,13 +1683,24 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - value = self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network, - node_id=node_id, node_name=node_name, **kwargs) + value = self._get_value( + value_id=value_id, + value_label=value_label, + id_on_network=id_on_network, + node_id=node_id, + node_name=node_name, + **kwargs, + ) scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs) self._api_request('_removeSceneValue', scene['scene_id'], value['value_id']) @action - def get_scene_values(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs) -> dict: + def get_scene_values( + self, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + **kwargs, + ) -> dict: """ Get the values associated to a scene. @@ -1381,8 +1713,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): return scene.get('values', {}) @action - def create_button(self, button_id: Union[int, str], node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs): + def create_button( + self, + button_id: Union[int, str], + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Create a handheld button on a device. Only intended for bridge firmware controllers (not implemented by zwavejs2mqtt). @@ -1396,8 +1733,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def delete_button(self, button_id: Union[int, str], node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs): + def delete_button( + self, + button_id: Union[int, str], + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Delete a button association from a device. Only intended for bridge firmware controllers. (not implemented by zwavejs2mqtt). @@ -1411,8 +1753,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def add_node_to_group(self, group_id: Optional[str] = None, node_id: Optional[int] = None, - endpoint: Optional[int] = None, **kwargs): + def add_node_to_group( + self, + group_id: Optional[str] = None, + node_id: Optional[int] = None, + endpoint: Optional[int] = None, + **kwargs, + ): """ Add a node to a group. @@ -1430,8 +1777,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('addAssociations', group['node_id'], group['index'], [assoc]) @action - def remove_node_from_group(self, group_id: Optional[str] = None, node_id: Optional[int] = None, - endpoint: Optional[int] = None, **kwargs): + def remove_node_from_group( + self, + group_id: Optional[str] = None, + node_id: Optional[int] = None, + endpoint: Optional[int] = None, + **kwargs, + ): """ Remove a node from a group. @@ -1446,10 +1798,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): if endpoint is not None: assoc['endpoint'] = endpoint - self._api_request('removeAssociations', group['node_id'], group['index'], [assoc]) + self._api_request( + 'removeAssociations', group['node_id'], group['index'], [assoc] + ) @action - def create_new_primary(self, **kwargs): + def create_new_primary(self, **_): """ Create a new primary controller on the network when the previous primary fails (not implemented by zwavejs2mqtt). @@ -1460,7 +1814,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def hard_reset(self, **kwargs): + def hard_reset(self, **_): """ Perform a hard reset of the controller. It erases its network configuration settings. The controller becomes a primary controller ready to add devices to a new network. @@ -1471,7 +1825,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('hardReset') @action - def soft_reset(self, **kwargs): + def soft_reset(self, **_): """ Perform a soft reset of the controller. Resets a controller without erasing its network configuration settings (not implemented by zwavejs2). @@ -1482,7 +1836,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def write_config(self, **kwargs): + def write_config(self, **_): """ Store the current configuration of the network to the user directory (not implemented by zwavejs2mqtt). @@ -1492,7 +1846,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def on(self, device: str, *args, **kwargs): + def on(self, device: str, *_, **kwargs): """ Turn on a switch on a device. @@ -1503,7 +1857,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self.set_value(data=True, id_on_network=device, **kwargs) @action - def off(self, device: str, *args, **kwargs): + def off(self, device: str, *_, **kwargs): """ Turn off a switch on a device. @@ -1514,7 +1868,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self.set_value(data=False, id_on_network=device, **kwargs) @action - def toggle(self, device: str, *args, **kwargs) -> dict: + def toggle(self, device: str, *_, **kwargs) -> dict: """ Toggle a switch on a device. @@ -1527,8 +1881,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self.set_value(data=value['data'], id_on_network=device, **kwargs) return { - 'name': '{} - {}'.format(self._nodes_cache['by_id'][value['node_id']]['name'], - value.get('label', '[No Label]')), + 'name': '{} - {}'.format( + self._nodes_cache['by_id'][value['node_id']]['name'], + value.get('label', '[No Label]'), + ), 'on': value['data'], 'id': value['value_id'], } @@ -1541,11 +1897,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): devices = self.get_switches().output.values() return [ { - 'name': '{} - {}'.format(self._nodes_cache['by_id'][dev['node_id']]['name'], - dev.get('label', '[No Label]')), + 'name': '{} - {}'.format( + self._nodes_cache['by_id'][dev['node_id']]['name'], + dev.get('label', '[No Label]'), + ), 'on': dev['data'], 'id': dev['value_id'], - } for dev in devices + } + for dev in devices ] diff --git a/platypush/user/__init__.py b/platypush/user/__init__.py index ccc2040aa..c6c2bcd22 100644 --- a/platypush/user/__init__.py +++ b/platypush/user/__init__.py @@ -13,11 +13,13 @@ except ImportError: from jwt import PyJWTError, encode as jwt_encode, decode as jwt_decode from sqlalchemy import Column, Integer, String, DateTime, ForeignKey -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import make_transient, declarative_base from platypush.context import get_plugin -from platypush.exceptions.user import InvalidJWTTokenException, InvalidCredentialsException +from platypush.exceptions.user import ( + InvalidJWTTokenException, + InvalidCredentialsException, +) from platypush.utils import get_or_generate_jwt_rsa_key_pair Base = declarative_base() @@ -28,126 +30,144 @@ class UserManager: Main class for managing platform users """ - # noinspection PyProtectedMember def __init__(self): - db_plugin = get_plugin('db') - if not db_plugin: - raise ModuleNotFoundError('Please enable/configure the db plugin for multi-user support') + self.db = get_plugin('db') + assert self.db + self._engine = self.db.get_engine() + self.db.create_all(self._engine, Base) - self._engine = db_plugin._get_engine() - - def get_user(self, username): - session = self._get_db_session() - user = self._get_user(session, username) - if not user: - return None - - # Hide password + @staticmethod + def _mask_password(user): + make_transient(user) user.password = None return user + def get_user(self, username): + with self.db.get_session() as session: + user = self._get_user(session, username) + if not user: + return None + + session.expunge(user) + return self._mask_password(user) + def get_user_count(self): - session = self._get_db_session() - return session.query(User).count() + with self.db.get_session() as session: + return session.query(User).count() def get_users(self): - session = self._get_db_session() - return session.query(User) + with self.db.get_session() as session: + return session.query(User) def create_user(self, username, password, **kwargs): - session = self._get_db_session() if not username: raise ValueError('Invalid or empty username') if not password: raise ValueError('Please provide a password for the user') - user = self._get_user(session, username) - if user: - raise NameError('The user {} already exists'.format(username)) + with self.db.get_session() as session: + user = self._get_user(session, username) + if user: + raise NameError('The user {} already exists'.format(username)) - record = User(username=username, password=self._encrypt_password(password), - created_at=datetime.datetime.utcnow(), **kwargs) + record = User( + username=username, + password=self._encrypt_password(password), + created_at=datetime.datetime.utcnow(), + **kwargs + ) - session.add(record) - session.commit() - user = self._get_user(session, username) + session.add(record) + session.commit() + user = self._get_user(session, username) - # Hide password - user.password = None - return user + return self._mask_password(user) def update_password(self, username, old_password, new_password): - session = self._get_db_session() - if not self._authenticate_user(session, username, old_password): - return False + with self.db.get_session() as session: + if not self._authenticate_user(session, username, old_password): + return False - user = self._get_user(session, username) - user.password = self._encrypt_password(new_password) - session.commit() - return True + user = self._get_user(session, username) + user.password = self._encrypt_password(new_password) + session.commit() + return True def authenticate_user(self, username, password): - session = self._get_db_session() - return self._authenticate_user(session, username, password) + with self.db.get_session() as session: + return self._authenticate_user(session, username, password) def authenticate_user_session(self, session_token): - session = self._get_db_session() - user_session = session.query(UserSession).filter_by(session_token=session_token).first() + with self.db.get_session() as session: + user_session = ( + session.query(UserSession) + .filter_by(session_token=session_token) + .first() + ) - if not user_session or ( - user_session.expires_at and user_session.expires_at < datetime.datetime.utcnow()): - return None, None + if not user_session or ( + user_session.expires_at + and user_session.expires_at < datetime.datetime.utcnow() + ): + return None, None - user = session.query(User).filter_by(user_id=user_session.user_id).first() - - # Hide password - user.password = None - return user, session + user = session.query(User).filter_by(user_id=user_session.user_id).first() + return self._mask_password(user), user_session def delete_user(self, username): - session = self._get_db_session() - user = self._get_user(session, username) - if not user: - raise NameError('No such user: {}'.format(username)) + with self.db.get_session() as session: + user = self._get_user(session, username) + if not user: + raise NameError('No such user: {}'.format(username)) - user_sessions = session.query(UserSession).filter_by(user_id=user.user_id).all() - for user_session in user_sessions: - session.delete(user_session) + user_sessions = ( + session.query(UserSession).filter_by(user_id=user.user_id).all() + ) + for user_session in user_sessions: + session.delete(user_session) - session.delete(user) - session.commit() - return True + session.delete(user) + session.commit() + return True def delete_user_session(self, session_token): - session = self._get_db_session() - user_session = session.query(UserSession).filter_by(session_token=session_token).first() + with self.db.get_session() as session: + user_session = ( + session.query(UserSession) + .filter_by(session_token=session_token) + .first() + ) - if not user_session: - return False + if not user_session: + return False - session.delete(user_session) - session.commit() - return True + session.delete(user_session) + session.commit() + return True def create_user_session(self, username, password, expires_at=None): - session = self._get_db_session() - user = self._authenticate_user(session, username, password) - if not user: - return None + with self.db.get_session() as session: + user = self._authenticate_user(session, username, password) + if not user: + return None - if expires_at: - if isinstance(expires_at, int) or isinstance(expires_at, float): - expires_at = datetime.datetime.fromtimestamp(expires_at) - elif isinstance(expires_at, str): - expires_at = datetime.datetime.fromisoformat(expires_at) + if expires_at: + if isinstance(expires_at, (int, float)): + expires_at = datetime.datetime.fromtimestamp(expires_at) + elif isinstance(expires_at, str): + expires_at = datetime.datetime.fromisoformat(expires_at) - user_session = UserSession(user_id=user.user_id, session_token=self.generate_session_token(), - csrf_token=self.generate_session_token(), created_at=datetime.datetime.utcnow(), - expires_at=expires_at) + user_session = UserSession( + user_id=user.user_id, + session_token=self.generate_session_token(), + csrf_token=self.generate_session_token(), + created_at=datetime.datetime.utcnow(), + expires_at=expires_at, + ) - session.add(user_session) - session.commit() - return user_session + session.add(user_session) + session.commit() + return user_session @staticmethod def _get_user(session, username): @@ -180,10 +200,20 @@ class UserManager: :param session_token: Session token. """ - session = self._get_db_session() - return session.query(User).join(UserSession).filter_by(session_token=session_token).first() + with self.db.get_session() as session: + return ( + session.query(User) + .join(UserSession) + .filter_by(session_token=session_token) + .first() + ) - def generate_jwt_token(self, username: str, password: str, expires_at: Optional[datetime.datetime] = None) -> str: + def generate_jwt_token( + self, + username: str, + password: str, + expires_at: Optional[datetime.datetime] = None, + ) -> str: """ Create a user JWT token for API usage. @@ -240,12 +270,6 @@ class UserManager: return payload - def _get_db_session(self): - Base.metadata.create_all(self._engine) - session = scoped_session(sessionmaker(expire_on_commit=False)) - session.configure(bind=self._engine) - return session() - def _authenticate_user(self, session, username, password): """ :return: :class:`platypush.user.User` instance if the user exists and the password is valid, ``None`` otherwise. @@ -261,10 +285,10 @@ class UserManager: class User(Base): - """ Models the User table """ + """Models the User table""" __tablename__ = 'user' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} user_id = Column(Integer, primary_key=True) username = Column(String, unique=True, nullable=False) @@ -273,10 +297,10 @@ class User(Base): class UserSession(Base): - """ Models the UserSession table """ + """Models the UserSession table""" __tablename__ = 'user_session' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} session_id = Column(Integer, primary_key=True) session_token = Column(String, unique=True, nullable=False) diff --git a/platypush/utils/__init__.py b/platypush/utils/__init__.py index c75615c85..61f2fc23e 100644 --- a/platypush/utils/__init__.py +++ b/platypush/utils/__init__.py @@ -1,4 +1,5 @@ import ast +import contextlib import datetime import hashlib import importlib @@ -20,8 +21,8 @@ logger = logging.getLogger('utils') def get_module_and_method_from_action(action): - """ Input : action=music.mpd.play - Output : ('music.mpd', 'play') """ + """Input : action=music.mpd.play + Output : ('music.mpd', 'play')""" tokens = action.split('.') module_name = str.join('.', tokens[:-1]) @@ -30,7 +31,7 @@ def get_module_and_method_from_action(action): def get_message_class_by_type(msgtype): - """ Gets the class of a message type given as string """ + """Gets the class of a message type given as string""" try: module = importlib.import_module('platypush.message.' + msgtype) @@ -43,8 +44,7 @@ def get_message_class_by_type(msgtype): try: msgclass = getattr(module, cls_name) except AttributeError as e: - logger.warning('No such class in {}: {}'.format( - module.__name__, cls_name)) + logger.warning('No such class in {}: {}'.format(module.__name__, cls_name)) raise RuntimeError(e) return msgclass @@ -52,13 +52,13 @@ def get_message_class_by_type(msgtype): # noinspection PyShadowingBuiltins def get_event_class_by_type(type): - """ Gets an event class by type name """ + """Gets an event class by type name""" event_module = importlib.import_module('.'.join(type.split('.')[:-1])) return getattr(event_module, type.split('.')[-1]) def get_plugin_module_by_name(plugin_name): - """ Gets the module of a plugin by name (e.g. "music.mpd" or "media.vlc") """ + """Gets the module of a plugin by name (e.g. "music.mpd" or "media.vlc")""" module_name = 'platypush.plugins.' + plugin_name try: @@ -69,22 +69,26 @@ def get_plugin_module_by_name(plugin_name): def get_plugin_class_by_name(plugin_name): - """ Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc") """ + """Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc")""" module = get_plugin_module_by_name(plugin_name) if not module: return - class_name = getattr(module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin') + class_name = getattr( + module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin' + ) try: - return getattr(module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin') + return getattr( + module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin' + ) except Exception as e: logger.error('Cannot import class {}: {}'.format(class_name, str(e))) return None def get_plugin_name_by_class(plugin) -> Optional[str]: - """Gets the common name of a plugin (e.g. "music.mpd" or "media.vlc") given its class. """ + """Gets the common name of a plugin (e.g. "music.mpd" or "media.vlc") given its class.""" from platypush.plugins import Plugin @@ -93,7 +97,8 @@ def get_plugin_name_by_class(plugin) -> Optional[str]: class_name = plugin.__name__ class_tokens = [ - token.lower() for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ') + token.lower() + for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ') if token.strip() and token != 'Plugin' ] @@ -101,7 +106,7 @@ def get_plugin_name_by_class(plugin) -> Optional[str]: def get_backend_name_by_class(backend) -> Optional[str]: - """Gets the common name of a backend (e.g. "http" or "mqtt") given its class. """ + """Gets the common name of a backend (e.g. "http" or "mqtt") given its class.""" from platypush.backend import Backend @@ -110,7 +115,8 @@ def get_backend_name_by_class(backend) -> Optional[str]: class_name = backend.__name__ class_tokens = [ - token.lower() for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ') + token.lower() + for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ') if token.strip() and token != 'Backend' ] @@ -135,12 +141,12 @@ def set_timeout(seconds, on_timeout): def clear_timeout(): - """ Clear any previously set timeout """ + """Clear any previously set timeout""" signal.alarm(0) def get_hash(s): - """ Get the SHA256 hash hex digest of a string input """ + """Get the SHA256 hash hex digest of a string input""" return hashlib.sha256(s.encode('utf-8')).hexdigest() @@ -177,11 +183,8 @@ def get_decorators(cls, climb_class_hierarchy=False): node_iter.visit_FunctionDef = visit_FunctionDef for target in targets: - try: + with contextlib.suppress(TypeError): node_iter.visit(ast.parse(inspect.getsource(target))) - except TypeError: - # Ignore built-in classes - pass return decorators @@ -195,45 +198,57 @@ def get_redis_queue_name_by_message(msg): return 'platypush/responses/{}'.format(msg.id) if msg.id else None -def _get_ssl_context(context_type=None, ssl_cert=None, ssl_key=None, - ssl_cafile=None, ssl_capath=None): +def _get_ssl_context( + context_type=None, ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None +): if not context_type: - ssl_context = ssl.create_default_context(cafile=ssl_cafile, - capath=ssl_capath) + ssl_context = ssl.create_default_context(cafile=ssl_cafile, capath=ssl_capath) else: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) if ssl_cafile or ssl_capath: - ssl_context.load_verify_locations( - cafile=ssl_cafile, capath=ssl_capath) + ssl_context.load_verify_locations(cafile=ssl_cafile, capath=ssl_capath) ssl_context.load_cert_chain( certfile=os.path.abspath(os.path.expanduser(ssl_cert)), - keyfile=os.path.abspath(os.path.expanduser(ssl_key)) if ssl_key else None + keyfile=os.path.abspath(os.path.expanduser(ssl_key)) if ssl_key else None, ) return ssl_context -def get_ssl_context(ssl_cert=None, ssl_key=None, ssl_cafile=None, - ssl_capath=None): - return _get_ssl_context(context_type=None, - ssl_cert=ssl_cert, ssl_key=ssl_key, - ssl_cafile=ssl_cafile, ssl_capath=ssl_capath) +def get_ssl_context(ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None): + return _get_ssl_context( + context_type=None, + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) -def get_ssl_server_context(ssl_cert=None, ssl_key=None, ssl_cafile=None, - ssl_capath=None): - return _get_ssl_context(context_type=ssl.PROTOCOL_TLS_SERVER, - ssl_cert=ssl_cert, ssl_key=ssl_key, - ssl_cafile=ssl_cafile, ssl_capath=ssl_capath) +def get_ssl_server_context( + ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None +): + return _get_ssl_context( + context_type=ssl.PROTOCOL_TLS_SERVER, + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) -def get_ssl_client_context(ssl_cert=None, ssl_key=None, ssl_cafile=None, - ssl_capath=None): - return _get_ssl_context(context_type=ssl.PROTOCOL_TLS_CLIENT, - ssl_cert=ssl_cert, ssl_key=ssl_key, - ssl_cafile=ssl_cafile, ssl_capath=ssl_capath) +def get_ssl_client_context( + ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None +): + return _get_ssl_context( + context_type=ssl.PROTOCOL_TLS_CLIENT, + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) def set_thread_name(name): @@ -241,6 +256,7 @@ def set_thread_name(name): try: import prctl + # noinspection PyUnresolvedReferences prctl.set_name(name) except ImportError: @@ -251,9 +267,9 @@ def find_bins_in_path(bin_name): return [ os.path.join(p, bin_name) for p in os.environ.get('PATH', '').split(':') - if os.path.isfile(os.path.join(p, bin_name)) and ( - os.name == 'nt' or os.access(os.path.join(p, bin_name), os.X_OK) - )] + if os.path.isfile(os.path.join(p, bin_name)) + and (os.name == 'nt' or os.access(os.path.join(p, bin_name), os.X_OK)) + ] def find_files_by_ext(directory, *exts): @@ -271,7 +287,7 @@ def find_files_by_ext(directory, *exts): max_len = len(max(exts, key=len)) result = [] - for root, dirs, files in os.walk(directory): + for _, __, files in os.walk(directory): for i in range(min_len, max_len + 1): result += [f for f in files if f[-i:] in exts] @@ -302,8 +318,9 @@ def get_ip_or_hostname(): def get_mime_type(resource): import magic + if resource.startswith('file://'): - resource = resource[len('file://'):] + resource = resource[len('file://') :] # noinspection HttpUrlsUsage if resource.startswith('http://') or resource.startswith('https://'): @@ -315,7 +332,9 @@ def get_mime_type(resource): elif hasattr(magic, 'from_file'): mime = magic.from_file(resource, mime=True) else: - raise RuntimeError('The installed magic version provides neither detect_from_filename nor from_file') + raise RuntimeError( + 'The installed magic version provides neither detect_from_filename nor from_file' + ) if mime: return mime.mime_type if hasattr(mime, 'mime_type') else mime @@ -332,6 +351,7 @@ def grouper(n, iterable, fillvalue=None): grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx """ from itertools import zip_longest + args = [iter(iterable)] * n if fillvalue: @@ -355,6 +375,7 @@ def is_functional_cron(obj) -> bool: def run(action, *args, **kwargs): from platypush.context import get_plugin + (module_name, method_name) = get_module_and_method_from_action(action) plugin = get_plugin(module_name) method = getattr(plugin, method_name) @@ -366,7 +387,9 @@ def run(action, *args, **kwargs): return response.output -def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> Tuple[str, str]: +def generate_rsa_key_pair( + key_file: Optional[str] = None, size: int = 2048 +) -> Tuple[str, str]: """ Generate an RSA key pair. @@ -390,27 +413,30 @@ def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> T public_exp = 65537 private_key = rsa.generate_private_key( - public_exponent=public_exp, - key_size=size, - backend=default_backend() + public_exponent=public_exp, key_size=size, backend=default_backend() ) logger.info('Generating RSA {} key pair'.format(size)) private_key_str = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() + encryption_algorithm=serialization.NoEncryption(), ).decode() - public_key_str = private_key.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.PKCS1, - ).decode() + public_key_str = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.PKCS1, + ) + .decode() + ) if key_file: logger.info('Saving private key to {}'.format(key_file)) - with open(os.path.expanduser(key_file), 'w') as f1, \ - open(os.path.expanduser(key_file) + '.pub', 'w') as f2: + with open(os.path.expanduser(key_file), 'w') as f1, open( + os.path.expanduser(key_file) + '.pub', 'w' + ) as f2: f1.write(private_key_str) f2.write(public_key_str) os.chmod(key_file, 0o600) @@ -426,8 +452,7 @@ def get_or_generate_jwt_rsa_key_pair(): pub_key_file = priv_key_file + '.pub' if os.path.isfile(priv_key_file) and os.path.isfile(pub_key_file): - with open(pub_key_file, 'r') as f1, \ - open(priv_key_file, 'r') as f2: + with open(pub_key_file, 'r') as f1, open(priv_key_file, 'r') as f2: return f1.read(), f2.read() pathlib.Path(key_dir).mkdir(parents=True, exist_ok=True, mode=0o755) @@ -439,7 +464,7 @@ def get_enabled_plugins() -> dict: from platypush.context import get_plugin plugins = {} - for name, config in Config.get_plugins().items(): + for name in Config.get_plugins(): try: plugin = get_plugin(name) if plugin: @@ -453,11 +478,18 @@ def get_enabled_plugins() -> dict: def get_redis() -> Redis: from platypush.config import Config - return Redis(**(Config.get('backend.redis') or {}).get('redis_args', {})) + + return Redis( + **( + (Config.get('backend.redis') or {}).get('redis_args', {}) + or Config.get('redis') + or {} + ) + ) def to_datetime(t: Union[str, int, float, datetime.datetime]) -> datetime.datetime: - if isinstance(t, int) or isinstance(t, float): + if isinstance(t, (int, float)): return datetime.datetime.fromtimestamp(t, tz=tz.tzutc()) if isinstance(t, str): return parser.parse(t) diff --git a/setup.cfg b/setup.cfg index ad67bf2fb..9ad8fe286 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,6 +8,7 @@ description-file = README.md [flake8] max-line-length = 120 -ignore = - SIM105 +extend-ignore = + E203 W503 + SIM105