Compare commits

..

10 Commits

Author SHA1 Message Date
Fabio Manganiello 99de5318ff
Merge pull request #313 from BlackLight/snyk-upgrade-58f5a7acf019c661bec911d06f0bf10a
[Snyk] Upgrade core-js from 3.21.1 to 3.23.4
2022-08-05 13:26:25 +02:00
Fabio Manganiello b3bab9b1d8
Merge pull request #314 from BlackLight/snyk-upgrade-9823d0f9eee2d94f4547598322ba6a48
[Snyk] Upgrade vue-router from 4.0.14 to 4.1.2
2022-08-05 13:26:07 +02:00
Fabio Manganiello 7e4877c793
Merge pull request #315 from BlackLight/snyk-upgrade-30cde2b595c9da96da481c691c0964d5
[Snyk] Upgrade sass from 1.49.9 to 1.53.0
2022-08-05 13:23:38 +02:00
Fabio Manganiello 55602cc282
Merge branch 'master' into snyk-upgrade-30cde2b595c9da96da481c691c0964d5 2022-08-05 13:05:25 +02:00
Fabio Manganiello d2053a012a
Merge pull request #316 from BlackLight/snyk-upgrade-ba00badb7e42a7b25417256efb18f67b
[Snyk] Upgrade sass-loader from 10.2.1 to 10.3.1
2022-08-05 13:03:46 +02:00
snyk-bot 3d5fc9a10b
fix: upgrade sass-loader from 10.2.1 to 10.3.1
Snyk has created this PR to upgrade sass-loader from 10.2.1 to 10.3.1.

See this package in npm:
https://www.npmjs.com/package/sass-loader

See this project in Snyk:
https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-08-04 20:31:51 +00:00
snyk-bot be4dd48d76
fix: upgrade sass from 1.49.9 to 1.53.0
Snyk has created this PR to upgrade sass from 1.49.9 to 1.53.0.

See this package in npm:
https://www.npmjs.com/package/sass

See this project in Snyk:
https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-08-04 20:31:45 +00:00
snyk-bot bd21779a17
fix: upgrade vue-router from 4.0.14 to 4.1.2
Snyk has created this PR to upgrade vue-router from 4.0.14 to 4.1.2.

See this package in npm:
https://www.npmjs.com/package/vue-router

See this project in Snyk:
https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-08-04 20:31:33 +00:00
snyk-bot 58afc1090c
fix: upgrade core-js from 3.21.1 to 3.23.4
Snyk has created this PR to upgrade core-js from 3.21.1 to 3.23.4.

See this package in npm:
https://www.npmjs.com/package/core-js

See this project in Snyk:
https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-08-04 20:31:28 +00:00
Fabio Manganiello 7c87238fec
match_condition should return immediately (no score-based fuzzy search) if an event condition is an exact match 2022-08-04 01:04:00 +02:00
78 changed files with 1573 additions and 6197 deletions

View File

@ -20,7 +20,6 @@ Events
platypush/events/custom.rst platypush/events/custom.rst
platypush/events/dbus.rst platypush/events/dbus.rst
platypush/events/distance.rst platypush/events/distance.rst
platypush/events/entities.rst
platypush/events/file.rst platypush/events/file.rst
platypush/events/foursquare.rst platypush/events/foursquare.rst
platypush/events/geo.rst platypush/events/geo.rst

View File

@ -1,5 +0,0 @@
``entities``
============
.. automodule:: platypush.message.event.entities
:members:

View File

@ -1,5 +0,0 @@
``entities``
============
.. automodule:: platypush.plugins.entities
:members:

View File

@ -32,7 +32,6 @@ Plugins
platypush/plugins/db.rst platypush/plugins/db.rst
platypush/plugins/dbus.rst platypush/plugins/dbus.rst
platypush/plugins/dropbox.rst platypush/plugins/dropbox.rst
platypush/plugins/entities.rst
platypush/plugins/esp.rst platypush/plugins/esp.rst
platypush/plugins/ffmpeg.rst platypush/plugins/ffmpeg.rst
platypush/plugins/file.rst platypush/plugins/file.rst

View File

@ -9,13 +9,11 @@ import argparse
import logging import logging
import os import os
import sys import sys
from typing import Optional
from .bus.redis import RedisBus from .bus.redis import RedisBus
from .config import Config from .config import Config
from .context import register_backends, register_plugins from .context import register_backends, register_plugins
from .cron.scheduler import CronScheduler from .cron.scheduler import CronScheduler
from .entities import init_entities_engine, EntitiesEngine
from .event.processor import EventProcessor from .event.processor import EventProcessor
from .logger import Logger from .logger import Logger
from .message.event import Event from .message.event import Event
@ -98,7 +96,6 @@ class Daemon:
self.no_capture_stdout = no_capture_stdout self.no_capture_stdout = no_capture_stdout
self.no_capture_stderr = no_capture_stderr self.no_capture_stderr = no_capture_stderr
self.event_processor = EventProcessor() self.event_processor = EventProcessor()
self.entities_engine: Optional[EntitiesEngine] = None
self.requests_to_process = requests_to_process self.requests_to_process = requests_to_process
self.processed_requests = 0 self.processed_requests = 0
self.cron_scheduler = None self.cron_scheduler = None
@ -202,25 +199,16 @@ class Daemon:
"""Stops the backends and the bus""" """Stops the backends and the bus"""
from .plugins import RunnablePlugin from .plugins import RunnablePlugin
if self.backends: for backend in self.backends.values():
for backend in self.backends.values(): backend.stop()
backend.stop()
for plugin in get_enabled_plugins().values(): for plugin in get_enabled_plugins().values():
if isinstance(plugin, RunnablePlugin): if isinstance(plugin, RunnablePlugin):
plugin.stop() plugin.stop()
if self.bus: self.bus.stop()
self.bus.stop()
self.bus = None
if self.cron_scheduler: if self.cron_scheduler:
self.cron_scheduler.stop() self.cron_scheduler.stop()
self.cron_scheduler = None
if self.entities_engine:
self.entities_engine.stop()
self.entities_engine = None
def run(self): def run(self):
"""Start the daemon""" """Start the daemon"""
@ -242,9 +230,6 @@ class Daemon:
# Initialize the plugins # Initialize the plugins
register_plugins(bus=self.bus) register_plugins(bus=self.bus)
# Initialize the entities engine
self.entities_engine = init_entities_engine()
# Start the cron scheduler # Start the cron scheduler
if Config.get_cronjobs(): if Config.get_cronjobs():
self.cron_scheduler = CronScheduler(jobs=Config.get_cronjobs()) self.cron_scheduler = CronScheduler(jobs=Config.get_cronjobs())

View File

@ -3,7 +3,8 @@ import os
from typing import Optional, Union, List, Dict, Any from typing import Optional, Union, List, Dict, Any
from sqlalchemy import create_engine, Column, Integer, String, DateTime from sqlalchemy import create_engine, Column, Integer, String, DateTime
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base
from platypush.backend import Backend from platypush.backend import Backend
from platypush.config import Config from platypush.config import Config
@ -16,10 +17,10 @@ Session = scoped_session(sessionmaker())
class Covid19Update(Base): class Covid19Update(Base):
"""Models the Covid19Data table""" """ Models the Covid19Data table """
__tablename__ = 'covid19data' __tablename__ = 'covid19data'
__table_args__ = {'sqlite_autoincrement': True} __table_args__ = ({'sqlite_autoincrement': True})
country = Column(String, primary_key=True) country = Column(String, primary_key=True)
confirmed = Column(Integer, nullable=False, default=0) confirmed = Column(Integer, nullable=False, default=0)
@ -39,12 +40,7 @@ class Covid19Backend(Backend):
""" """
# noinspection PyProtectedMember # noinspection PyProtectedMember
def __init__( def __init__(self, country: Optional[Union[str, List[str]]], poll_seconds: Optional[float] = 3600.0, **kwargs):
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 :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: country name or the country code. Special values:
@ -60,9 +56,7 @@ class Covid19Backend(Backend):
super().__init__(poll_seconds=poll_seconds, **kwargs) super().__init__(poll_seconds=poll_seconds, **kwargs)
self._plugin: Covid19Plugin = get_plugin('covid19') self._plugin: Covid19Plugin = get_plugin('covid19')
self.country: List[str] = self._plugin._get_countries(country) self.country: List[str] = self._plugin._get_countries(country)
self.workdir = os.path.join( self.workdir = os.path.join(os.path.expanduser(Config.get('workdir')), 'covid19')
os.path.expanduser(Config.get('workdir')), 'covid19'
)
self.dbfile = os.path.join(self.workdir, 'data.db') self.dbfile = os.path.join(self.workdir, 'data.db')
os.makedirs(self.workdir, exist_ok=True) os.makedirs(self.workdir, exist_ok=True)
@ -73,30 +67,22 @@ class Covid19Backend(Backend):
self.logger.info('Stopped Covid19 backend') self.logger.info('Stopped Covid19 backend')
def _process_update(self, summary: Dict[str, Any], session: Session): def _process_update(self, summary: Dict[str, Any], session: Session):
update_time = datetime.datetime.fromisoformat( update_time = datetime.datetime.fromisoformat(summary['Date'].replace('Z', '+00:00'))
summary['Date'].replace('Z', '+00:00')
)
self.bus.post( self.bus.post(Covid19UpdateEvent(
Covid19UpdateEvent( country=summary['Country'],
country=summary['Country'], country_code=summary['CountryCode'],
country_code=summary['CountryCode'], confirmed=summary['TotalConfirmed'],
confirmed=summary['TotalConfirmed'], deaths=summary['TotalDeaths'],
deaths=summary['TotalDeaths'], recovered=summary['TotalRecovered'],
recovered=summary['TotalRecovered'], update_time=update_time,
update_time=update_time, ))
)
)
session.merge( session.merge(Covid19Update(country=summary['CountryCode'],
Covid19Update( confirmed=summary['TotalConfirmed'],
country=summary['CountryCode'], deaths=summary['TotalDeaths'],
confirmed=summary['TotalConfirmed'], recovered=summary['TotalRecovered'],
deaths=summary['TotalDeaths'], last_updated_at=update_time))
recovered=summary['TotalRecovered'],
last_updated_at=update_time,
)
)
def loop(self): def loop(self):
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
@ -104,30 +90,23 @@ class Covid19Backend(Backend):
if not summaries: if not summaries:
return return
engine = create_engine( engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
'sqlite:///{}'.format(self.dbfile),
connect_args={'check_same_thread': False},
)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
Session.configure(bind=engine) Session.configure(bind=engine)
session = Session() session = Session()
last_records = { last_records = {
record.country: record record.country: record
for record in session.query(Covid19Update) for record in session.query(Covid19Update).filter(Covid19Update.country.in_(self.country)).all()
.filter(Covid19Update.country.in_(self.country))
.all()
} }
for summary in summaries: for summary in summaries:
country = summary['CountryCode'] country = summary['CountryCode']
last_record = last_records.get(country) last_record = last_records.get(country)
if ( if not last_record or \
not last_record summary['TotalConfirmed'] != last_record.confirmed or \
or summary['TotalConfirmed'] != last_record.confirmed summary['TotalDeaths'] != last_record.deaths or \
or summary['TotalDeaths'] != last_record.deaths summary['TotalRecovered'] != last_record.recovered:
or summary['TotalRecovered'] != last_record.recovered
):
self._process_update(summary=summary, session=session) self._process_update(summary=summary, session=session)
session.commit() session.commit()

View File

@ -6,28 +6,15 @@ from typing import Optional, List
import requests import requests
from sqlalchemy import create_engine, Column, String, DateTime from sqlalchemy import create_engine, Column, String, DateTime
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, scoped_session
from platypush.backend import Backend from platypush.backend import Backend
from platypush.config import Config from platypush.config import Config
from platypush.message.event.github import ( from platypush.message.event.github import GithubPushEvent, GithubCommitCommentEvent, GithubCreateEvent, \
GithubPushEvent, GithubDeleteEvent, GithubEvent, GithubForkEvent, GithubWikiEvent, GithubIssueCommentEvent, GithubIssueEvent, \
GithubCommitCommentEvent, GithubMemberEvent, GithubPublicEvent, GithubPullRequestEvent, GithubPullRequestReviewCommentEvent, \
GithubCreateEvent, GithubReleaseEvent, GithubSponsorshipEvent, GithubWatchEvent
GithubDeleteEvent,
GithubEvent,
GithubForkEvent,
GithubWikiEvent,
GithubIssueCommentEvent,
GithubIssueEvent,
GithubMemberEvent,
GithubPublicEvent,
GithubPullRequestEvent,
GithubPullRequestReviewCommentEvent,
GithubReleaseEvent,
GithubSponsorshipEvent,
GithubWatchEvent,
)
Base = declarative_base() Base = declarative_base()
Session = scoped_session(sessionmaker()) Session = scoped_session(sessionmaker())
@ -84,17 +71,8 @@ class GithubBackend(Backend):
_base_url = 'https://api.github.com' _base_url = 'https://api.github.com'
def __init__( def __init__(self, user: str, user_token: str, repos: Optional[List[str]] = None, org: Optional[str] = None,
self, poll_seconds: int = 60, max_events_per_scan: Optional[int] = 10, *args, **kwargs):
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. If neither ``repos`` nor ``org`` is specified then the backend will monitor all new events on user level.
@ -124,23 +102,17 @@ class GithubBackend(Backend):
def _request(self, uri: str, method: str = 'get') -> dict: def _request(self, uri: str, method: str = 'get') -> dict:
method = getattr(requests, method.lower()) method = getattr(requests, method.lower())
return method( return method(self._base_url + uri, auth=(self.user, self.user_token),
self._base_url + uri, headers={'Accept': 'application/vnd.github.v3+json'}).json()
auth=(self.user, self.user_token),
headers={'Accept': 'application/vnd.github.v3+json'},
).json()
def _init_db(self): def _init_db(self):
engine = create_engine( engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
'sqlite:///{}'.format(self.dbfile),
connect_args={'check_same_thread': False},
)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
Session.configure(bind=engine) Session.configure(bind=engine)
@staticmethod @staticmethod
def _to_datetime(time_string: str) -> datetime.datetime: 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') return datetime.datetime.fromisoformat(time_string[:-1] + '+00:00')
@staticmethod @staticmethod
@ -156,11 +128,7 @@ class GithubBackend(Backend):
def _get_last_event_time(self, uri: str): def _get_last_event_time(self, uri: str):
with self.db_lock: with self.db_lock:
record = self._get_or_create_resource(uri=uri, session=Session()) record = self._get_or_create_resource(uri=uri, session=Session())
return ( return record.last_updated_at.replace(tzinfo=datetime.timezone.utc) if record.last_updated_at else None
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): def _update_last_event_time(self, uri: str, last_updated_at: datetime.datetime):
with self.db_lock: with self.db_lock:
@ -190,18 +158,9 @@ class GithubBackend(Backend):
'WatchEvent': GithubWatchEvent, 'WatchEvent': GithubWatchEvent,
} }
event_type = ( event_type = event_mapping[event['type']] if event['type'] in event_mapping else GithubEvent
event_mapping[event['type']] return event_type(event_type=event['type'], actor=event['actor'], repo=event.get('repo', {}),
if event['type'] in event_mapping payload=event['payload'], created_at=cls._to_datetime(event['created_at']))
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 _events_monitor(self, uri: str, method: str = 'get'):
def thread(): def thread():
@ -216,10 +175,7 @@ class GithubBackend(Backend):
fired_events = [] fired_events = []
for event in events: for event in events:
if ( if self.max_events_per_scan and len(fired_events) >= self.max_events_per_scan:
self.max_events_per_scan
and len(fired_events) >= self.max_events_per_scan
):
break break
event_time = self._to_datetime(event['created_at']) event_time = self._to_datetime(event['created_at'])
@ -233,19 +189,14 @@ class GithubBackend(Backend):
for event in fired_events: for event in fired_events:
self.bus.post(event) self.bus.post(event)
self._update_last_event_time( self._update_last_event_time(uri=uri, last_updated_at=new_last_event_time)
uri=uri, last_updated_at=new_last_event_time
)
except Exception as e: except Exception as e:
self.logger.warning( self.logger.warning('Encountered exception while fetching events from {}: {}'.format(
'Encountered exception while fetching events from {}: {}'.format( uri, str(e)))
uri, str(e)
)
)
self.logger.exception(e) self.logger.exception(e)
finally:
if self.wait_stop(timeout=self.poll_seconds): if self.wait_stop(timeout=self.poll_seconds):
break break
return thread return thread
@ -255,30 +206,12 @@ class GithubBackend(Backend):
if self.repos: if self.repos:
for repo in self.repos: for repo in self.repos:
monitors.append( monitors.append(threading.Thread(target=self._events_monitor('/networks/{repo}/events'.format(repo=repo))))
threading.Thread(
target=self._events_monitor(
'/networks/{repo}/events'.format(repo=repo)
)
)
)
if self.org: if self.org:
monitors.append( monitors.append(threading.Thread(target=self._events_monitor('/orgs/{org}/events'.format(org=self.org))))
threading.Thread(
target=self._events_monitor(
'/orgs/{org}/events'.format(org=self.org)
)
)
)
if not (self.repos or self.org): if not (self.repos or self.org):
monitors.append( monitors.append(threading.Thread(target=self._events_monitor('/users/{user}/events'.format(user=self.user))))
threading.Thread(
target=self._events_monitor(
'/users/{user}/events'.format(user=self.user)
)
)
)
for monitor in monitors: for monitor in monitors:
monitor.start() monitor.start()
@ -289,5 +222,4 @@ class GithubBackend(Backend):
self.logger.info('Github backend terminated') self.logger.info('Github backend terminated')
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -2,17 +2,11 @@ import datetime
import enum import enum
import os import os
from sqlalchemy import ( from sqlalchemy import create_engine, Column, Integer, String, DateTime, \
create_engine, Enum, ForeignKey
Column,
Integer,
String,
DateTime,
Enum,
ForeignKey,
)
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from platypush.backend.http.request import HttpRequest from platypush.backend.http.request import HttpRequest
@ -50,31 +44,18 @@ class RssUpdates(HttpRequest):
""" """
user_agent = ( user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + \
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' 'Chrome/62.0.3202.94 Safari/537.36'
+ 'Chrome/62.0.3202.94 Safari/537.36'
)
def __init__( def __init__(self, url, title=None, headers=None, params=None, max_entries=None,
self, extract_content=False, digest_format=None, user_agent: str = user_agent,
url, body_style: str = 'font-size: 22px; ' +
title=None, 'font-family: "Merriweather", Georgia, "Times New Roman", Times, serif;',
headers=None, title_style: str = 'margin-top: 30px',
params=None, subtitle_style: str = 'margin-top: 10px; page-break-after: always',
max_entries=None, article_title_style: str = 'page-break-before: always',
extract_content=False, article_link_style: str = 'color: #555; text-decoration: none; border-bottom: 1px dotted',
digest_format=None, article_content_style: str = '', *argv, **kwargs):
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 url: URL to the RSS feed to be monitored.
:param title: Optional title for the feed. :param title: Optional title for the feed.
@ -110,9 +91,7 @@ class RssUpdates(HttpRequest):
# If true, then the http.webpage plugin will be used to parse the content # If true, then the http.webpage plugin will be used to parse the content
self.extract_content = extract_content self.extract_content = extract_content
self.digest_format = ( self.digest_format = digest_format.lower() if digest_format else None # Supported formats: html, pdf
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) os.makedirs(os.path.expanduser(os.path.dirname(self.dbfile)), exist_ok=True)
@ -140,11 +119,7 @@ class RssUpdates(HttpRequest):
@staticmethod @staticmethod
def _get_latest_update(session, source_id): def _get_latest_update(session, source_id):
return ( return session.query(func.max(FeedEntry.published)).filter_by(source_id=source_id).scalar()
session.query(func.max(FeedEntry.published))
.filter_by(source_id=source_id)
.scalar()
)
def _parse_entry_content(self, link): def _parse_entry_content(self, link):
self.logger.info('Extracting content from {}'.format(link)) self.logger.info('Extracting content from {}'.format(link))
@ -155,20 +130,14 @@ class RssUpdates(HttpRequest):
errors = response.errors errors = response.errors
if not output: if not output:
self.logger.warning( self.logger.warning('Mercury parser error: {}'.format(errors or '[unknown error]'))
'Mercury parser error: {}'.format(errors or '[unknown error]')
)
return return
return output.get('content') return output.get('content')
def get_new_items(self, response): def get_new_items(self, response):
import feedparser 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) Base.metadata.create_all(engine)
Session.configure(bind=engine) Session.configure(bind=engine)
@ -188,16 +157,12 @@ class RssUpdates(HttpRequest):
content = u''' content = u'''
<h1 style="{title_style}">{title}</h1> <h1 style="{title_style}">{title}</h1>
<h2 style="{subtitle_style}">Feeds digest generated on {creation_date}</h2>'''.format( <h2 style="{subtitle_style}">Feeds digest generated on {creation_date}</h2>'''.\
title_style=self.title_style, format(title_style=self.title_style, title=self.title, subtitle_style=self.subtitle_style,
title=self.title, creation_date=datetime.datetime.now().strftime('%d %B %Y, %H:%M'))
subtitle_style=self.subtitle_style,
creation_date=datetime.datetime.now().strftime('%d %B %Y, %H:%M'),
)
self.logger.info( self.logger.info('Parsed {:d} items from RSS feed <{}>'
'Parsed {:d} items from RSS feed <{}>'.format(len(feed.entries), self.url) .format(len(feed.entries), self.url))
)
for entry in feed.entries: for entry in feed.entries:
if not entry.published_parsed: if not entry.published_parsed:
@ -206,10 +171,9 @@ class RssUpdates(HttpRequest):
try: try:
entry_timestamp = datetime.datetime(*entry.published_parsed[:6]) entry_timestamp = datetime.datetime(*entry.published_parsed[:6])
if latest_update is None or entry_timestamp > latest_update: if latest_update is None \
self.logger.info( or entry_timestamp > latest_update:
'Processed new item from RSS feed <{}>'.format(self.url) self.logger.info('Processed new item from RSS feed <{}>'.format(self.url))
)
entry.summary = entry.summary if hasattr(entry, 'summary') else None entry.summary = entry.summary if hasattr(entry, 'summary') else None
if self.extract_content: if self.extract_content:
@ -224,13 +188,9 @@ class RssUpdates(HttpRequest):
<a href="{link}" target="_blank" style="{article_link_style}">{title}</a> <a href="{link}" target="_blank" style="{article_link_style}">{title}</a>
</h1> </h1>
<div class="_parsed-content" style="{article_content_style}">{content}</div>'''.format( <div class="_parsed-content" style="{article_content_style}">{content}</div>'''.format(
article_title_style=self.article_title_style, article_title_style=self.article_title_style, article_link_style=self.article_link_style,
article_link_style=self.article_link_style, article_content_style=self.article_content_style, link=entry.link, title=entry.title,
article_content_style=self.article_content_style, content=entry.content)
link=entry.link,
title=entry.title,
content=entry.content,
)
e = { e = {
'entry_id': entry.id, 'entry_id': entry.id,
@ -247,32 +207,21 @@ class RssUpdates(HttpRequest):
if self.max_entries and len(entries) > self.max_entries: if self.max_entries and len(entries) > self.max_entries:
break break
except Exception as e: except Exception as e:
self.logger.warning( self.logger.warning('Exception encountered while parsing RSS ' +
'Exception encountered while parsing RSS ' 'RSS feed {}: {}'.format(entry.link, str(e)))
+ f'RSS feed {entry.link}: {e}'
)
self.logger.exception(e) self.logger.exception(e)
source_record.last_updated_at = parse_start_time source_record.last_updated_at = parse_start_time
digest_filename = None digest_filename = None
if entries: if entries:
self.logger.info( self.logger.info('Parsed {} new entries from the RSS feed {}'.format(
'Parsed {} new entries from the RSS feed {}'.format( len(entries), self.title))
len(entries), self.title
)
)
if self.digest_format: if self.digest_format:
digest_filename = os.path.join( digest_filename = os.path.join(self.workdir, 'cache', '{}_{}.{}'.format(
self.workdir, datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
'cache', self.title, self.digest_format))
'{}_{}.{}'.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) os.makedirs(os.path.dirname(digest_filename), exist_ok=True)
@ -284,15 +233,12 @@ class RssUpdates(HttpRequest):
</head> </head>
<body style="{body_style}">{content}</body> <body style="{body_style}">{content}</body>
</html> </html>
'''.format( '''.format(title=self.title, body_style=self.body_style, content=content)
title=self.title, body_style=self.body_style, content=content
)
with open(digest_filename, 'w', encoding='utf-8') as f: with open(digest_filename, 'w', encoding='utf-8') as f:
f.write(content) f.write(content)
elif self.digest_format == 'pdf': elif self.digest_format == 'pdf':
from weasyprint import HTML, CSS from weasyprint import HTML, CSS
try: try:
from weasyprint.fonts import FontConfiguration from weasyprint.fonts import FontConfiguration
except ImportError: except ImportError:
@ -300,47 +246,37 @@ class RssUpdates(HttpRequest):
body_style = 'body { ' + self.body_style + ' }' body_style = 'body { ' + self.body_style + ' }'
font_config = FontConfiguration() font_config = FontConfiguration()
css = [ css = [CSS('https://fonts.googleapis.com/css?family=Merriweather'),
CSS('https://fonts.googleapis.com/css?family=Merriweather'), CSS(string=body_style, font_config=font_config)]
CSS(string=body_style, font_config=font_config),
]
HTML(string=content).write_pdf(digest_filename, stylesheets=css) HTML(string=content).write_pdf(digest_filename, stylesheets=css)
else: else:
raise RuntimeError( raise RuntimeError('Unsupported format: {}. Supported formats: ' +
f'Unsupported format: {self.digest_format}. Supported formats: html, pdf' 'html or pdf'.format(self.digest_format))
)
digest_entry = FeedDigest( digest_entry = FeedDigest(source_id=source_record.id,
source_id=source_record.id, format=self.digest_format,
format=self.digest_format, filename=digest_filename)
filename=digest_filename,
)
session.add(digest_entry) session.add(digest_entry)
self.logger.info( self.logger.info('{} digest ready: {}'.format(self.digest_format, digest_filename))
'{} digest ready: {}'.format(self.digest_format, digest_filename)
)
session.commit() session.commit()
self.logger.info('Parsing RSS feed {}: completed'.format(self.title)) self.logger.info('Parsing RSS feed {}: completed'.format(self.title))
return NewFeedEvent( return NewFeedEvent(request=dict(self), response=entries,
request=dict(self), source_id=source_record.id,
response=entries, source_title=source_record.title,
source_id=source_record.id, title=self.title,
source_title=source_record.title, digest_format=self.digest_format,
title=self.title, digest_filename=digest_filename)
digest_format=self.digest_format,
digest_filename=digest_filename,
)
class FeedSource(Base): 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' __tablename__ = 'FeedSource'
__table_args__ = {'sqlite_autoincrement': True} __table_args__ = ({'sqlite_autoincrement': True})
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
title = Column(String) title = Column(String)
@ -349,10 +285,10 @@ class FeedSource(Base):
class FeedEntry(Base): class FeedEntry(Base):
"""Models the FeedEntry table, which contains RSS entries""" """ Models the FeedEntry table, which contains RSS entries """
__tablename__ = 'FeedEntry' __tablename__ = 'FeedEntry'
__table_args__ = {'sqlite_autoincrement': True} __table_args__ = ({'sqlite_autoincrement': True})
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
entry_id = Column(String) entry_id = Column(String)
@ -365,15 +301,15 @@ class FeedEntry(Base):
class FeedDigest(Base): class FeedDigest(Base):
"""Models the FeedDigest table, containing feed digests either in HTML """ Models the FeedDigest table, containing feed digests either in HTML
or PDF format""" or PDF format """
class DigestFormat(enum.Enum): class DigestFormat(enum.Enum):
html = 1 html = 1
pdf = 2 pdf = 2
__tablename__ = 'FeedDigest' __tablename__ = 'FeedDigest'
__table_args__ = {'sqlite_autoincrement': True} __table_args__ = ({'sqlite_autoincrement': True})
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
source_id = Column(Integer, ForeignKey('FeedSource.id'), nullable=False) source_id = Column(Integer, ForeignKey('FeedSource.id'), nullable=False)
@ -381,5 +317,4 @@ class FeedDigest(Base):
filename = Column(String, nullable=False) filename = Column(String, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.datetime.utcnow) created_at = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -8,15 +8,15 @@
"name": "platypush", "name": "platypush",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.1.1", "@fortawesome/fontawesome-free": "^5.15.4",
"axios": "^0.21.4", "axios": "^0.21.4",
"core-js": "^3.21.1", "core-js": "^3.23.4",
"lato-font": "^3.0.0", "lato-font": "^3.0.0",
"mitt": "^2.1.0", "mitt": "^2.1.0",
"sass": "^1.49.9", "sass": "^1.53.0",
"sass-loader": "^10.2.1", "sass-loader": "^10.3.1",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-router": "^4.0.14", "vue-router": "^4.1.2",
"vue-skycons": "^4.2.0", "vue-skycons": "^4.2.0",
"w3css": "^2.7.0" "w3css": "^2.7.0"
}, },
@ -1731,9 +1731,9 @@
} }
}, },
"node_modules/@fortawesome/fontawesome-free": { "node_modules/@fortawesome/fontawesome-free": {
"version": "6.1.1", "version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
"integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg==", "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==",
"hasInstallScript": true, "hasInstallScript": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -2768,9 +2768,9 @@
"dev": true "dev": true
}, },
"node_modules/@vue/devtools-api": { "node_modules/@vue/devtools-api": {
"version": "6.1.3", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.1.3.tgz", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.2.1.tgz",
"integrity": "sha512-79InfO2xHv+WHIrH1bHXQUiQD/wMls9qBk6WVwGCbdwP7/3zINtvqPNMtmSHXsIKjvUAHc8L0ouOj6ZQQRmcXg==" "integrity": "sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ=="
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.2.31", "version": "3.2.31",
@ -4205,9 +4205,9 @@
} }
}, },
"node_modules/core-js": { "node_modules/core-js": {
"version": "3.21.1", "version": "3.23.4",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.23.4.tgz",
"integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==", "integrity": "sha512-vjsKqRc1RyAJC3Ye2kYqgfdThb3zYnx9CrqoCcjMOENMtQPC7ZViBvlDxwYU/2z2NI/IPuiXw5mT4hWhddqjzQ==",
"hasInstallScript": true, "hasInstallScript": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@ -9402,9 +9402,9 @@
"dev": true "dev": true
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.49.9", "version": "1.53.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.53.0.tgz",
"integrity": "sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==", "integrity": "sha512-zb/oMirbKhUgRQ0/GFz8TSAwRq2IlR29vOUJZOx0l8sV+CkHUfHa4u5nqrG+1VceZp7Jfj59SVW9ogdhTvJDcQ==",
"dependencies": { "dependencies": {
"chokidar": ">=3.0.0 <4.0.0", "chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0", "immutable": "^4.0.0",
@ -9418,9 +9418,9 @@
} }
}, },
"node_modules/sass-loader": { "node_modules/sass-loader": {
"version": "10.2.1", "version": "10.3.1",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.2.1.tgz", "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.3.1.tgz",
"integrity": "sha512-RRvWl+3K2LSMezIsd008ErK4rk6CulIMSwrcc2aZvjymUgKo/vjXGp1rSWmfTUX7bblEOz8tst4wBwWtCGBqKA==", "integrity": "sha512-y2aBdtYkbqorVavkC3fcJIUDGIegzDWPn3/LAFhsf3G+MzPKTJx37sROf5pXtUeggSVbNbmfj8TgRaSLMelXRA==",
"dependencies": { "dependencies": {
"klona": "^2.0.4", "klona": "^2.0.4",
"loader-utils": "^2.0.0", "loader-utils": "^2.0.0",
@ -9437,7 +9437,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"fibers": ">= 3.1.0", "fibers": ">= 3.1.0",
"node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0", "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
"sass": "^1.3.0", "sass": "^1.3.0",
"webpack": "^4.36.0 || ^5.0.0" "webpack": "^4.36.0 || ^5.0.0"
}, },
@ -10825,11 +10825,11 @@
} }
}, },
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "4.0.14", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.14.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.2.tgz",
"integrity": "sha512-wAO6zF9zxA3u+7AkMPqw9LjoUCjSxfFvINQj3E/DceTt6uEz1XZLraDhdg2EYmvVwTBSGlLYsUw8bDmx0754Mw==", "integrity": "sha512-5BP1qXFncVRwgV/XnqzsKApdMjQPqWIpoUBdL1ynz8HyLxIX/UDAx7Ql2BjmA5CXT/p61JfZvkpiFWFpaqcfag==",
"dependencies": { "dependencies": {
"@vue/devtools-api": "^6.0.0" "@vue/devtools-api": "^6.1.4"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/posva" "url": "https://github.com/sponsors/posva"
@ -12917,9 +12917,9 @@
} }
}, },
"@fortawesome/fontawesome-free": { "@fortawesome/fontawesome-free": {
"version": "6.1.1", "version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
"integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg==" "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg=="
}, },
"@humanwhocodes/config-array": { "@humanwhocodes/config-array": {
"version": "0.5.0", "version": "0.5.0",
@ -13770,9 +13770,9 @@
} }
}, },
"@vue/devtools-api": { "@vue/devtools-api": {
"version": "6.1.3", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.1.3.tgz", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.2.1.tgz",
"integrity": "sha512-79InfO2xHv+WHIrH1bHXQUiQD/wMls9qBk6WVwGCbdwP7/3zINtvqPNMtmSHXsIKjvUAHc8L0ouOj6ZQQRmcXg==" "integrity": "sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ=="
}, },
"@vue/reactivity": { "@vue/reactivity": {
"version": "3.2.31", "version": "3.2.31",
@ -14868,9 +14868,9 @@
} }
}, },
"core-js": { "core-js": {
"version": "3.21.1", "version": "3.23.4",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.23.4.tgz",
"integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==" "integrity": "sha512-vjsKqRc1RyAJC3Ye2kYqgfdThb3zYnx9CrqoCcjMOENMtQPC7ZViBvlDxwYU/2z2NI/IPuiXw5mT4hWhddqjzQ=="
}, },
"core-js-compat": { "core-js-compat": {
"version": "3.21.1", "version": "3.21.1",
@ -18676,9 +18676,9 @@
"dev": true "dev": true
}, },
"sass": { "sass": {
"version": "1.49.9", "version": "1.53.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.53.0.tgz",
"integrity": "sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==", "integrity": "sha512-zb/oMirbKhUgRQ0/GFz8TSAwRq2IlR29vOUJZOx0l8sV+CkHUfHa4u5nqrG+1VceZp7Jfj59SVW9ogdhTvJDcQ==",
"requires": { "requires": {
"chokidar": ">=3.0.0 <4.0.0", "chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0", "immutable": "^4.0.0",
@ -18686,9 +18686,9 @@
} }
}, },
"sass-loader": { "sass-loader": {
"version": "10.2.1", "version": "10.3.1",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.2.1.tgz", "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.3.1.tgz",
"integrity": "sha512-RRvWl+3K2LSMezIsd008ErK4rk6CulIMSwrcc2aZvjymUgKo/vjXGp1rSWmfTUX7bblEOz8tst4wBwWtCGBqKA==", "integrity": "sha512-y2aBdtYkbqorVavkC3fcJIUDGIegzDWPn3/LAFhsf3G+MzPKTJx37sROf5pXtUeggSVbNbmfj8TgRaSLMelXRA==",
"requires": { "requires": {
"klona": "^2.0.4", "klona": "^2.0.4",
"loader-utils": "^2.0.0", "loader-utils": "^2.0.0",
@ -19746,11 +19746,11 @@
} }
}, },
"vue-router": { "vue-router": {
"version": "4.0.14", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.14.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.2.tgz",
"integrity": "sha512-wAO6zF9zxA3u+7AkMPqw9LjoUCjSxfFvINQj3E/DceTt6uEz1XZLraDhdg2EYmvVwTBSGlLYsUw8bDmx0754Mw==", "integrity": "sha512-5BP1qXFncVRwgV/XnqzsKApdMjQPqWIpoUBdL1ynz8HyLxIX/UDAx7Ql2BjmA5CXT/p61JfZvkpiFWFpaqcfag==",
"requires": { "requires": {
"@vue/devtools-api": "^6.0.0" "@vue/devtools-api": "^6.1.4"
} }
}, },
"vue-skycons": { "vue-skycons": {

View File

@ -8,15 +8,15 @@
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.1.1", "@fortawesome/fontawesome-free": "^5.15.4",
"axios": "^0.21.4", "axios": "^0.21.4",
"core-js": "^3.21.1", "core-js": "^3.23.4",
"lato-font": "^3.0.0", "lato-font": "^3.0.0",
"mitt": "^2.1.0", "mitt": "^2.1.0",
"sass": "^1.49.9", "sass": "^1.53.0",
"sass-loader": "^10.2.1", "sass-loader": "^10.3.1",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-router": "^4.0.14", "vue-router": "^4.1.2",
"vue-skycons": "^4.2.0", "vue-skycons": "^4.2.0",
"w3css": "^2.7.0" "w3css": "^2.7.0"
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

View File

@ -5,11 +5,10 @@ import DateTime from "@/utils/DateTime";
import Events from "@/utils/Events"; import Events from "@/utils/Events";
import Notification from "@/utils/Notification"; import Notification from "@/utils/Notification";
import Screen from "@/utils/Screen"; import Screen from "@/utils/Screen";
import Text from "@/utils/Text";
import Types from "@/utils/Types"; import Types from "@/utils/Types";
export default { export default {
name: "Utils", name: "Utils",
mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Text, Types], mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Types],
} }
</script> </script>

View File

@ -17,9 +17,6 @@
"camera.pi": { "camera.pi": {
"class": "fas fa-camera" "class": "fas fa-camera"
}, },
"entities": {
"class": "fa fa-home"
},
"execute": { "execute": {
"class": "fa fa-play" "class": "fa fa-play"
}, },
@ -62,21 +59,9 @@
"rtorrent": { "rtorrent": {
"class": "fa fa-magnet" "class": "fa fa-magnet"
}, },
"smartthings": {
"imgUrl": "/icons/smartthings.png"
},
"switches": { "switches": {
"class": "fas fa-toggle-on" "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": { "sound": {
"class": "fa fa-microphone" "class": "fa fa-microphone"
}, },

View File

@ -6,7 +6,7 @@
</div> </div>
<ul class="plugins"> <ul class="plugins">
<li v-for="name in panelNames" :key="name" class="entry" :class="{selected: name === selectedPanel}" <li v-for="name in Object.keys(panels).sort()" :key="name" class="entry" :class="{selected: name === selectedPanel}"
:title="name" @click="onItemClick(name)"> :title="name" @click="onItemClick(name)">
<a :href="`/#${name}`"> <a :href="`/#${name}`">
<span class="icon"> <span class="icon">
@ -14,7 +14,7 @@
<img :src="icons[name].imgUrl" v-else-if="icons[name]?.imgUrl" alt="name"/> <img :src="icons[name].imgUrl" v-else-if="icons[name]?.imgUrl" alt="name"/>
<i class="fas fa-puzzle-piece" v-else /> <i class="fas fa-puzzle-piece" v-else />
</span> </span>
<span class="name" v-if="!collapsed" v-text="name == 'entities' ? 'Home' : name" /> <span class="name" v-if="!collapsed" v-text="name" />
</a> </a>
</li> </li>
</ul> </ul>
@ -66,16 +66,6 @@ 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: { methods: {
onItemClick(name) { onItemClick(name) {
this.$emit('select', name) this.$emit('select', name)
@ -90,6 +80,11 @@ export default {
host: null, host: null,
} }
}, },
mounted() {
if (this.isMobile() && !this.$root.$route.hash.length)
this.collapsed = false
},
} }
</script> </script>

View File

@ -1,84 +0,0 @@
<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" /> &nbsp; {{ confirmText }}
</button>
<button type="button" class="cancel-btn" @click="close" @touch="close">
<i class="fas fa-xmark" /> &nbsp; {{ 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>

View File

@ -37,11 +37,6 @@ export default {
title: { title: {
type: String, type: String,
}, },
keepOpenOnItemClick: {
type: Boolean,
default: false,
},
}, },
data() { data() {

View File

@ -1,27 +1,20 @@
<template> <template>
<div class="row item" :class="itemClass" @click="clicked"> <div class="row item" :class="itemClass" @click="clicked">
<div class="col-1 icon" v-if="iconClass?.length || iconUrl?.length"> <div class="col-1 icon" v-if="iconClass">
<Icon :class="iconClass" :url="iconUrl" /> <i :class="iconClass" />
</div> </div>
<div class="text" :class="{'col-11': iconClass != null}" v-text="text" /> <div class="text" :class="{'col-11': iconClass != null}" v-text="text" />
</div> </div>
</template> </template>
<script> <script>
import Icon from "@/components/elements/Icon";
export default { export default {
name: "DropdownItem", name: "DropdownItem",
components: {Icon},
props: { props: {
iconClass: { iconClass: {
type: String, type: String,
}, },
iconUrl: {
type: String,
},
text: { text: {
type: String, type: String,
}, },
@ -38,12 +31,8 @@ export default {
methods: { methods: {
clicked(event) { clicked(event) {
if (this.disabled)
return false
this.$parent.$emit('click', event) this.$parent.$emit('click', event)
if (!this.$parent.keepOpenOnItemClick) this.$parent.visible = false
this.$parent.visible = false
} }
} }
} }
@ -66,18 +55,7 @@ export default {
} }
.icon { .icon {
display: inline-flex; margin: 0 .5em;
align-items: center;
}
::v-deep(.icon-container) {
width: 2em;
display: inline-flex;
align-items: center;
.icon {
margin: 0 1.5em 0 .5em;
}
} }
} }
</style> </style>

View File

@ -1,33 +0,0 @@
<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>

View File

@ -1,48 +0,0 @@
<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>

View File

@ -1,75 +0,0 @@
<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>

View File

@ -1,51 +0,0 @@
<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>

View File

@ -62,7 +62,7 @@ export default {
}, },
update(value) { update(value) {
const percent = ((value - this.range[0]) * 100) / (this.range[1] - this.range[0]) const percent = (value * 100) / (this.range[1] - this.range[0])
this.$refs.thumb.style.left = `${percent}%` this.$refs.thumb.style.left = `${percent}%`
this.$refs.thumb.style.transform = `translate(-${percent}%, -50%)` this.$refs.thumb.style.transform = `translate(-${percent}%, -50%)`
this.$refs.track.style.width = `${percent}%` this.$refs.track.style.width = `${percent}%`

View File

@ -59,7 +59,7 @@ export default {
display: none; display: none;
& + label { & + label {
border-radius: 1em; border-radius: 1em;
display: inline-flex; display: block;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
transition: box-shadow .4s; transition: box-shadow .4s;

View File

@ -1,45 +0,0 @@
<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>

View File

@ -1,101 +0,0 @@
<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>

View File

@ -1,38 +0,0 @@
<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>

View File

@ -1,451 +0,0 @@
<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>

View File

@ -1,222 +0,0 @@
<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>

View File

@ -1,252 +0,0 @@
<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>

View File

@ -1,233 +0,0 @@
<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>

View File

@ -1,57 +0,0 @@
<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>

View File

@ -1,55 +0,0 @@
@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;
}
}
}

View File

@ -1,33 +0,0 @@
{
"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"
}
}
}

View File

@ -1,2 +0,0 @@
$main-margin: 1em;
$selector-height: 2.5em;

View File

@ -211,21 +211,4 @@ export class ColorConverter {
console.debug('Could not determine color space') console.debug('Could not determine color space')
console.debug(color) 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('')
}
} }

View File

@ -356,7 +356,7 @@ export default {
async refreshStatus() { async refreshStatus() {
this.loading.status = true this.loading.status = true
try { try {
this.status = await this.zrequest('controller_status') this.status = await this.zrequest('status')
} finally { } finally {
this.loading.status = false this.loading.status = false
} }

View File

@ -1,5 +1,3 @@
$icon-container-size: 3em;
@mixin icon { @mixin icon {
content: ' '; content: ' ';
background-size: 1em 1em; background-size: 1em 1em;

View File

@ -1,5 +1,3 @@
$header-height: 3.5em;
.item { .item {
display: flex; display: flex;
align-items: center; align-items: center;
@ -80,43 +78,3 @@ $header-height: 3.5em;
} }
} }
} }
: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;
}
}
}

View File

@ -6,13 +6,10 @@ $default-bg-4: #f1f3f2 !default;
$default-bg-5: #edf0ee !default; $default-bg-5: #edf0ee !default;
$default-bg-6: #e4eae8 !default; $default-bg-6: #e4eae8 !default;
$default-bg-7: #e4e4e4 !default; $default-bg-7: #e4e4e4 !default;
$error-fg: #ad1717 !default;
$default-fg: black !default; $default-fg: black !default;
$default-fg-2: #23513a !default; $default-fg-2: #23513a !default;
$default-fg-3: #195331b3 !default; $default-fg-3: #195331b3 !default;
$header-bg: linear-gradient(0deg, #c0e8e4, #e4f8f4) !default;
$no-items-color: #555555;
//// Notifications //// Notifications
$notification-bg: rgba(185, 255, 193, 0.9) !default; $notification-bg: rgba(185, 255, 193, 0.9) !default;
@ -54,8 +51,6 @@ $border-shadow-bottom: 0 3px 2px -1px $default-shadow-color;
$border-shadow-left: -2.5px 0 4px 0 $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-right: 2.5px 0 4px 0 $default-shadow-color;
$border-shadow-bottom-right: 2.5px 2.5px 3px 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 //// Modals
$modal-header-bg: #e0e0e0 !default; $modal-header-bg: #e0e0e0 !default;
@ -146,7 +141,5 @@ $dropdown-shadow: 1px 1px 1px #bbb !default;
//// Scrollbars //// Scrollbars
$scrollbar-track-bg: $slider-bg !default; $scrollbar-track-bg: $slider-bg !default;
$scrollbar-track-shadow: inset 1px 0px 3px 0 $slider-track-shadow !default; $scrollbar-track-shadow: inset 1px 0px 3px 0 $slider-track-shadow !default;
$scrollbar-thumb-bg: #a5a2a2 !default; $scrollbar-thumb-bg: #50ca80 !default;
//// Rows
$row-shadow: 0 0 1px 0.5px #cfcfcf !default;

View File

@ -1,17 +0,0 @@
<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>

View File

@ -45,7 +45,10 @@ export default {
methods: { methods: {
initSelectedPanel() { initSelectedPanel() {
const match = this.$route.hash.match('#?([a-zA-Z0-9.]+)[?]?(.*)') const match = this.$route.hash.match('#?([a-zA-Z0-9.]+)[?]?(.*)')
const plugin = match ? match[1] : 'entities' if (!match)
return
const plugin = match[1]
if (plugin?.length) if (plugin?.length)
this.selectedPanel = plugin this.selectedPanel = plugin
}, },
@ -87,7 +90,7 @@ export default {
initializeDefaultViews() { initializeDefaultViews() {
this.plugins.execute = {} this.plugins.execute = {}
this.plugins.entities = {} this.plugins.switches = {}
}, },
}, },
@ -110,7 +113,7 @@ main {
height: 100%; height: 100%;
display: flex; display: flex;
@include until($tablet) { @media screen and (max-width: $tablet) {
flex-direction: column; flex-direction: column;
} }

View File

@ -1,24 +1,100 @@
import warnings from threading import Thread
from platypush.backend import Backend from platypush.backend import Backend
from platypush.context import get_plugin
from platypush.message.event.light import LightStatusChangeEvent
class LightHueBackend(Backend): class LightHueBackend(Backend):
""" """
**DEPRECATED** 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.
The polling logic of this backend has been moved to the ``light.hue`` plugin itself. 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.
""" """
def __init__(self, *args, **kwargs): _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
"""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
warnings.warn( self.poll_seconds = poll_seconds
'The light.hue backend is deprecated. All of its logic '
'has been moved to the light.hue plugin itself.' @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
def run(self): def run(self):
super().run() 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') self.logger.info('Stopped Hue lights backend')

View File

@ -1,5 +1,7 @@
manifest: manifest:
events: events:
platypush.message.event.light.LightStatusChangeEvent: when thestatus of a lightbulb
changes
install: install:
pip: [] pip: []
package: platypush.backend.light.hue package: platypush.backend.light.hue

View File

@ -8,18 +8,15 @@ from queue import Queue, Empty
from threading import Thread, RLock from threading import Thread, RLock
from typing import List, Dict, Any, Optional, Tuple from typing import List, Dict, Any, Optional, Tuple
from sqlalchemy import engine, create_engine, Column, Integer, String, DateTime from sqlalchemy import create_engine, Column, Integer, String, DateTime
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base import sqlalchemy.engine as engine
from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base
from platypush.backend import Backend from platypush.backend import Backend
from platypush.config import Config from platypush.config import Config
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.message.event.mail import ( from platypush.message.event.mail import MailReceivedEvent, MailSeenEvent, MailFlaggedEvent, MailUnflaggedEvent
MailReceivedEvent,
MailSeenEvent,
MailFlaggedEvent,
MailUnflaggedEvent,
)
from platypush.plugins.mail import MailInPlugin, Mail from platypush.plugins.mail import MailInPlugin, Mail
# <editor-fold desc="Database tables"> # <editor-fold desc="Database tables">
@ -28,8 +25,7 @@ Session = scoped_session(sessionmaker())
class MailboxStatus(Base): 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' __tablename__ = 'MailboxStatus'
mailbox_id = Column(Integer, primary_key=True) mailbox_id = Column(Integer, primary_key=True)
@ -68,13 +64,8 @@ class MailBackend(Backend):
""" """
def __init__( def __init__(self, mailboxes: List[Dict[str, Any]], timeout: Optional[int] = 60, poll_seconds: Optional[int] = 60,
self, **kwargs):
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 :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``) identify the :class:`platypush.plugins.mail.MailInPlugin` plugin that will be used (e.g. ``mail.imap``)
@ -137,13 +128,9 @@ class MailBackend(Backend):
# Parse mailboxes # Parse mailboxes
for i, mbox in enumerate(mailboxes): for i, mbox in enumerate(mailboxes):
assert ( assert 'plugin' in mbox, 'No plugin attribute specified for mailbox n.{}'.format(i)
'plugin' in mbox
), 'No plugin attribute specified for mailbox n.{}'.format(i)
plugin = get_plugin(mbox.pop('plugin')) plugin = get_plugin(mbox.pop('plugin'))
assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format( assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format(plugin)
plugin
)
name = mbox.pop('name') if 'name' in mbox else 'Mailbox #{}'.format(i + 1) name = mbox.pop('name') if 'name' in mbox else 'Mailbox #{}'.format(i + 1)
self.mailboxes.append(Mailbox(plugin=plugin, name=name, args=mbox)) self.mailboxes.append(Mailbox(plugin=plugin, name=name, args=mbox))
@ -157,10 +144,7 @@ class MailBackend(Backend):
# <editor-fold desc="Database methods"> # <editor-fold desc="Database methods">
def _db_get_engine(self) -> engine.Engine: def _db_get_engine(self) -> engine.Engine:
return create_engine( return create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
'sqlite:///{}'.format(self.dbfile),
connect_args={'check_same_thread': False},
)
def _db_load_mailboxes_status(self) -> None: def _db_load_mailboxes_status(self) -> None:
mailbox_ids = list(range(len(self.mailboxes))) mailbox_ids = list(range(len(self.mailboxes)))
@ -169,18 +153,12 @@ class MailBackend(Backend):
session = Session() session = Session()
records = { records = {
record.mailbox_id: record record.mailbox_id: record
for record in session.query(MailboxStatus) for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all()
.filter(MailboxStatus.mailbox_id.in_(mailbox_ids))
.all()
} }
for mbox_id, _ in enumerate(self.mailboxes): for mbox_id, mbox in enumerate(self.mailboxes):
if mbox_id not in records: if mbox_id not in records:
record = MailboxStatus( record = MailboxStatus(mailbox_id=mbox_id, unseen_message_ids='[]', flagged_message_ids='[]')
mailbox_id=mbox_id,
unseen_message_ids='[]',
flagged_message_ids='[]',
)
session.add(record) session.add(record)
else: else:
record = records[mbox_id] record = records[mbox_id]
@ -192,25 +170,19 @@ class MailBackend(Backend):
session.commit() session.commit()
def _db_get_mailbox_status( def _db_get_mailbox_status(self, mailbox_ids: List[int]) -> Dict[int, MailboxStatus]:
self, mailbox_ids: List[int]
) -> Dict[int, MailboxStatus]:
with self._db_lock: with self._db_lock:
session = Session() session = Session()
return { return {
record.mailbox_id: record record.mailbox_id: record
for record in session.query(MailboxStatus) for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all()
.filter(MailboxStatus.mailbox_id.in_(mailbox_ids))
.all()
} }
# </editor-fold> # </editor-fold>
# <editor-fold desc="Parse unread messages logic"> # <editor-fold desc="Parse unread messages logic">
@staticmethod @staticmethod
def _check_thread( def _check_thread(unread_queue: Queue, flagged_queue: Queue, plugin: MailInPlugin, **args):
unread_queue: Queue, flagged_queue: Queue, plugin: MailInPlugin, **args
):
def thread(): def thread():
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
unread = plugin.search_unseen_messages(**args).output unread = plugin.search_unseen_messages(**args).output
@ -222,9 +194,8 @@ class MailBackend(Backend):
return thread return thread
def _get_unread_seen_msgs( def _get_unread_seen_msgs(self, mailbox_idx: int, unread_msgs: Dict[int, Mail]) \
self, mailbox_idx: int, unread_msgs: Dict[int, Mail] -> Tuple[Dict[int, Mail], Dict[int, Mail]]:
) -> Tuple[Dict[int, Mail], Dict[int, Mail]]:
prev_unread_msgs = self._unread_msgs[mailbox_idx] prev_unread_msgs = self._unread_msgs[mailbox_idx]
return { return {
@ -237,51 +208,35 @@ class MailBackend(Backend):
if msg_id not in unread_msgs if msg_id not in unread_msgs
} }
def _get_flagged_unflagged_msgs( def _get_flagged_unflagged_msgs(self, mailbox_idx: int, flagged_msgs: Dict[int, Mail]) \
self, mailbox_idx: int, flagged_msgs: Dict[int, Mail] -> Tuple[Dict[int, Mail], Dict[int, Mail]]:
) -> Tuple[Dict[int, Mail], Dict[int, Mail]]:
prev_flagged_msgs = self._flagged_msgs[mailbox_idx] prev_flagged_msgs = self._flagged_msgs[mailbox_idx]
return { return {
msg_id: flagged_msgs[msg_id] msg_id: flagged_msgs[msg_id]
for msg_id in flagged_msgs for msg_id in flagged_msgs
if msg_id not in prev_flagged_msgs if msg_id not in prev_flagged_msgs
}, { }, {
msg_id: prev_flagged_msgs[msg_id] msg_id: prev_flagged_msgs[msg_id]
for msg_id in prev_flagged_msgs for msg_id in prev_flagged_msgs
if msg_id not in flagged_msgs if msg_id not in flagged_msgs
} }
def _process_msg_events( def _process_msg_events(self, mailbox_id: int, unread: List[Mail], seen: List[Mail],
self, flagged: List[Mail], unflagged: List[Mail], last_checked_date: Optional[datetime] = None):
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: for msg in unread:
if msg.date and last_checked_date and msg.date < last_checked_date: if msg.date and last_checked_date and msg.date < last_checked_date:
continue continue
self.bus.post( self.bus.post(MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
)
for msg in seen: for msg in seen:
self.bus.post( self.bus.post(MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
)
for msg in flagged: for msg in flagged:
self.bus.post( self.bus.post(MailFlaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
MailFlaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
)
for msg in unflagged: for msg in unflagged:
self.bus.post( self.bus.post(MailUnflaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
MailUnflaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
)
def _check_mailboxes(self) -> List[Tuple[Dict[int, Mail], Dict[int, Mail]]]: def _check_mailboxes(self) -> List[Tuple[Dict[int, Mail], Dict[int, Mail]]]:
workers = [] workers = []
@ -290,14 +245,8 @@ class MailBackend(Backend):
for mbox in self.mailboxes: for mbox in self.mailboxes:
unread_queue, flagged_queue = [Queue()] * 2 unread_queue, flagged_queue = [Queue()] * 2
worker = Thread( worker = Thread(target=self._check_thread(unread_queue=unread_queue, flagged_queue=flagged_queue,
target=self._check_thread( plugin=mbox.plugin, **mbox.args))
unread_queue=unread_queue,
flagged_queue=flagged_queue,
plugin=mbox.plugin,
**mbox.args
)
)
worker.start() worker.start()
workers.append(worker) workers.append(worker)
queues.append((unread_queue, flagged_queue)) queues.append((unread_queue, flagged_queue))
@ -311,11 +260,7 @@ class MailBackend(Backend):
flagged = flagged_queue.get(timeout=self.timeout) flagged = flagged_queue.get(timeout=self.timeout)
results.append((unread, flagged)) results.append((unread, flagged))
except Empty: except Empty:
self.logger.warning( self.logger.warning('Checks on mailbox #{} timed out after {} seconds'.format(i + 1, self.timeout))
'Checks on mailbox #{} timed out after {} seconds'.format(
i + 1, self.timeout
)
)
continue continue
return results return results
@ -331,25 +276,16 @@ class MailBackend(Backend):
for i, (unread, flagged) in enumerate(results): for i, (unread, flagged) in enumerate(results):
unread_msgs, seen_msgs = self._get_unread_seen_msgs(i, unread) unread_msgs, seen_msgs = self._get_unread_seen_msgs(i, unread)
flagged_msgs, unflagged_msgs = self._get_flagged_unflagged_msgs(i, flagged) flagged_msgs, unflagged_msgs = self._get_flagged_unflagged_msgs(i, flagged)
self._process_msg_events( self._process_msg_events(i, unread=list(unread_msgs.values()), seen=list(seen_msgs.values()),
i, flagged=list(flagged_msgs.values()), unflagged=list(unflagged_msgs.values()),
unread=list(unread_msgs.values()), last_checked_date=mailbox_statuses[i].last_checked_date)
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._unread_msgs[i] = unread
self._flagged_msgs[i] = flagged self._flagged_msgs[i] = flagged
records.append( records.append(MailboxStatus(mailbox_id=i,
MailboxStatus( unseen_message_ids=json.dumps([msg_id for msg_id in unread.keys()]),
mailbox_id=i, flagged_message_ids=json.dumps([msg_id for msg_id in flagged.keys()]),
unseen_message_ids=json.dumps(list(unread.keys())), last_checked_date=datetime.now()))
flagged_message_ids=json.dumps(list(flagged.keys())),
last_checked_date=datetime.now(),
)
)
with self._db_lock: with self._db_lock:
session = Session() session = Session()

View File

@ -1,38 +1,21 @@
import contextlib
import json import json
from typing import Optional, Mapping from typing import Optional
from platypush.backend.mqtt import MqttBackend from platypush.backend.mqtt import MqttBackend
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.message.event.zigbee.mqtt import ( from platypush.message.event.zigbee.mqtt import ZigbeeMqttOnlineEvent, ZigbeeMqttOfflineEvent, \
ZigbeeMqttOnlineEvent, ZigbeeMqttDevicePropertySetEvent, ZigbeeMqttDevicePairingEvent, ZigbeeMqttDeviceConnectedEvent, \
ZigbeeMqttOfflineEvent, ZigbeeMqttDeviceBannedEvent, ZigbeeMqttDeviceRemovedEvent, ZigbeeMqttDeviceRemovedFailedEvent, \
ZigbeeMqttDevicePropertySetEvent, ZigbeeMqttDeviceWhitelistedEvent, ZigbeeMqttDeviceRenamedEvent, ZigbeeMqttDeviceBindEvent, \
ZigbeeMqttDevicePairingEvent, ZigbeeMqttDeviceUnbindEvent, ZigbeeMqttGroupAddedEvent, ZigbeeMqttGroupAddedFailedEvent, \
ZigbeeMqttDeviceConnectedEvent, ZigbeeMqttGroupRemovedEvent, ZigbeeMqttGroupRemovedFailedEvent, ZigbeeMqttGroupRemoveAllEvent, \
ZigbeeMqttDeviceBannedEvent, ZigbeeMqttGroupRemoveAllFailedEvent, ZigbeeMqttErrorEvent
ZigbeeMqttDeviceRemovedEvent,
ZigbeeMqttDeviceRemovedFailedEvent,
ZigbeeMqttDeviceWhitelistedEvent,
ZigbeeMqttDeviceRenamedEvent,
ZigbeeMqttDeviceBindEvent,
ZigbeeMqttDeviceUnbindEvent,
ZigbeeMqttGroupAddedEvent,
ZigbeeMqttGroupAddedFailedEvent,
ZigbeeMqttGroupRemovedEvent,
ZigbeeMqttGroupRemovedFailedEvent,
ZigbeeMqttGroupRemoveAllEvent,
ZigbeeMqttGroupRemoveAllFailedEvent,
ZigbeeMqttErrorEvent,
)
class ZigbeeMqttBackend(MqttBackend): class ZigbeeMqttBackend(MqttBackend):
""" """
Listen for events on a zigbee2mqtt service. Listen for events on a zigbee2mqtt service.
For historical reasons, this backend should be enabled together with the `zigbee.mqtt` plugin.
Triggers: Triggers:
* :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent` when the service comes online. * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent` when the service comes online.
@ -76,22 +59,11 @@ class ZigbeeMqttBackend(MqttBackend):
""" """
def __init__( def __init__(self, host: Optional[str] = None, port: Optional[int] = None, base_topic='zigbee2mqtt',
self, tls_cafile: Optional[str] = None, tls_certfile: Optional[str] = None,
host: Optional[str] = None, tls_keyfile: Optional[str] = None, tls_version: Optional[str] = None,
port: Optional[int] = None, tls_ciphers: Optional[str] = None, username: Optional[str] = None,
base_topic='zigbee2mqtt', password: Optional[str] = None, client_id: Optional[str] = None, *args, **kwargs):
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 host: MQTT broker host (default: host configured on the ``zigbee.mqtt`` plugin).
:param port: MQTT broker port (default: 1883). :param port: MQTT broker port (default: 1883).
@ -115,7 +87,6 @@ class ZigbeeMqttBackend(MqttBackend):
plugin = get_plugin('zigbee.mqtt') plugin = get_plugin('zigbee.mqtt')
self.base_topic = base_topic or plugin.base_topic self.base_topic = base_topic or plugin.base_topic
self._devices = {} self._devices = {}
self._devices_info = {}
self._groups = {} self._groups = {}
self._last_state = None self._last_state = None
self.server_info = { self.server_info = {
@ -135,28 +106,17 @@ class ZigbeeMqttBackend(MqttBackend):
**self.server_info, **self.server_info,
} }
listeners = [ listeners = [{
{ **self.server_info,
**self.server_info, 'topics': [
'topics': [ self.base_topic + '/' + topic
self.base_topic + '/' + topic for topic in ['bridge/state', 'bridge/log', 'bridge/logging', 'bridge/devices', 'bridge/groups']
for topic in [ ],
'bridge/state', }]
'bridge/log',
'bridge/logging',
'bridge/devices',
'bridge/groups',
]
],
}
]
super().__init__( super().__init__(
*args, *args, subscribe_default_topic=False,
subscribe_default_topic=False, listeners=listeners, client_id=client_id, **kwargs
listeners=listeners,
client_id=client_id,
**kwargs
) )
if not client_id: if not client_id:
@ -186,7 +146,7 @@ class ZigbeeMqttBackend(MqttBackend):
if msg_type == 'devices': if msg_type == 'devices':
devices = {} devices = {}
for dev in text or []: for dev in (text or []):
devices[dev['friendly_name']] = dev devices[dev['friendly_name']] = dev
client.subscribe(self.base_topic + '/' + dev['friendly_name']) client.subscribe(self.base_topic + '/' + dev['friendly_name'])
elif msg_type == 'pairing': elif msg_type == 'pairing':
@ -195,9 +155,7 @@ class ZigbeeMqttBackend(MqttBackend):
self.bus.post(ZigbeeMqttDeviceBannedEvent(device=text, **args)) self.bus.post(ZigbeeMqttDeviceBannedEvent(device=text, **args))
elif msg_type in ['device_removed_failed', 'device_force_removed_failed']: elif msg_type in ['device_removed_failed', 'device_force_removed_failed']:
force = msg_type == 'device_force_removed_failed' force = msg_type == 'device_force_removed_failed'
self.bus.post( self.bus.post(ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args))
ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args)
)
elif msg_type == 'device_whitelisted': elif msg_type == 'device_whitelisted':
self.bus.post(ZigbeeMqttDeviceWhitelistedEvent(device=text, **args)) self.bus.post(ZigbeeMqttDeviceWhitelistedEvent(device=text, **args))
elif msg_type == 'device_renamed': elif msg_type == 'device_renamed':
@ -223,11 +181,7 @@ class ZigbeeMqttBackend(MqttBackend):
self.bus.post(ZigbeeMqttErrorEvent(error=text, **args)) self.bus.post(ZigbeeMqttErrorEvent(error=text, **args))
elif msg.get('level') in ['warning', 'error']: elif msg.get('level') in ['warning', 'error']:
log = getattr(self.logger, msg['level']) log = getattr(self.logger, msg['level'])
log( log('zigbee2mqtt {}: {}'.format(msg['level'], text or msg.get('error', msg.get('warning'))))
'zigbee2mqtt {}: {}'.format(
msg['level'], text or msg.get('error', msg.get('warning'))
)
)
def _process_devices(self, client, msg): def _process_devices(self, client, msg):
devices_info = { devices_info = {
@ -237,9 +191,10 @@ class ZigbeeMqttBackend(MqttBackend):
# noinspection PyProtectedMember # noinspection PyProtectedMember
event_args = {'host': client._host, 'port': client._port} event_args = {'host': client._host, 'port': client._port}
client.subscribe( client.subscribe(*[
*[self.base_topic + '/' + device for device in devices_info.keys()] self.base_topic + '/' + device
) for device in devices_info.keys()
])
for name, device in devices_info.items(): for name, device in devices_info.items():
if name not in self._devices: if name not in self._devices:
@ -248,7 +203,7 @@ class ZigbeeMqttBackend(MqttBackend):
exposes = (device.get('definition', {}) or {}).get('exposes', []) exposes = (device.get('definition', {}) or {}).get('exposes', [])
client.publish( client.publish(
self.base_topic + '/' + name + '/get', self.base_topic + '/' + name + '/get',
json.dumps(self._plugin.build_device_get_request(exposes)), json.dumps(get_plugin('zigbee.mqtt').build_device_get_request(exposes))
) )
devices_copy = [*self._devices.keys()] devices_copy = [*self._devices.keys()]
@ -258,13 +213,13 @@ class ZigbeeMqttBackend(MqttBackend):
del self._devices[name] del self._devices[name]
self._devices = {device: {} for device in devices_info.keys()} self._devices = {device: {} for device in devices_info.keys()}
self._devices_info = devices_info
def _process_groups(self, client, msg): def _process_groups(self, client, msg):
# noinspection PyProtectedMember # noinspection PyProtectedMember
event_args = {'host': client._host, 'port': client._port} event_args = {'host': client._host, 'port': client._port}
groups_info = { 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(): for name in groups_info.keys():
@ -281,13 +236,15 @@ class ZigbeeMqttBackend(MqttBackend):
def on_mqtt_message(self): def on_mqtt_message(self):
def handler(client, _, msg): def handler(client, _, msg):
topic = msg.topic[len(self.base_topic) + 1 :] topic = msg.topic[len(self.base_topic)+1:]
data = msg.payload.decode() data = msg.payload.decode()
if not data: if not data:
return return
with contextlib.suppress(ValueError, TypeError): try:
data = json.loads(data) data = json.loads(data)
except (ValueError, TypeError):
pass
if topic == 'bridge/state': if topic == 'bridge/state':
self._process_state_message(client, data) self._process_state_message(client, data)
@ -303,45 +260,17 @@ class ZigbeeMqttBackend(MqttBackend):
return return
name = suffix name = suffix
changed_props = { changed_props = {k: v for k, v in data.items() if v != self._devices[name].get(k)}
k: v for k, v in data.items() if v != self._devices[name].get(k)
}
if changed_props: if changed_props:
self._process_property_update(name, data) # noinspection PyProtectedMember
self.bus.post( self.bus.post(ZigbeeMqttDevicePropertySetEvent(host=client._host, port=client._port,
ZigbeeMqttDevicePropertySetEvent( device=name, properties=changed_props))
host=client._host,
port=client._port,
device=name,
properties=changed_props,
)
)
self._devices[name].update(data) self._devices[name].update(data)
return handler 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): def run(self):
super().run() super().run()

View File

@ -1,4 +1,3 @@
import contextlib
import json import json
from queue import Queue, Empty from queue import Queue, Empty
from typing import Optional, Type from typing import Optional, Type
@ -6,24 +5,14 @@ from typing import Optional, Type
from platypush.backend.mqtt import MqttBackend from platypush.backend.mqtt import MqttBackend
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.message.event.zwave import ( from platypush.message.event.zwave import ZwaveEvent, ZwaveNodeAddedEvent, ZwaveValueChangedEvent, \
ZwaveEvent, ZwaveNodeRemovedEvent, ZwaveNodeRenamedEvent, ZwaveNodeReadyEvent, ZwaveNodeEvent, ZwaveNodeAsleepEvent, \
ZwaveNodeAddedEvent, ZwaveNodeAwakeEvent
ZwaveValueChangedEvent,
ZwaveNodeRemovedEvent,
ZwaveNodeRenamedEvent,
ZwaveNodeReadyEvent,
ZwaveNodeEvent,
ZwaveNodeAsleepEvent,
ZwaveNodeAwakeEvent,
)
class ZwaveMqttBackend(MqttBackend): class ZwaveMqttBackend(MqttBackend):
""" """
Listen for events on a `zwavejs2mqtt <https://github.com/zwave-js/zwavejs2mqtt>`_ service. 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: Triggers:
@ -52,7 +41,6 @@ class ZwaveMqttBackend(MqttBackend):
""" """
from platypush.plugins.zwave.mqtt import ZwaveMqttPlugin from platypush.plugins.zwave.mqtt import ZwaveMqttPlugin
self.plugin: ZwaveMqttPlugin = get_plugin('zwave.mqtt') self.plugin: ZwaveMqttPlugin = get_plugin('zwave.mqtt')
assert self.plugin, 'The zwave.mqtt plugin is not configured' assert self.plugin, 'The zwave.mqtt plugin is not configured'
@ -73,48 +61,27 @@ class ZwaveMqttBackend(MqttBackend):
'password': self.plugin.password, 'password': self.plugin.password,
} }
listeners = [ listeners = [{
{ **self.server_info,
**self.server_info, 'topics': [
'topics': [ self.plugin.events_topic + '/node/' + topic
self.plugin.events_topic + '/node/' + topic for topic in ['node_ready', 'node_sleep', 'node_value_updated', 'node_metadata_updated', 'node_wakeup']
for topic in [ ],
'node_ready', }]
'node_sleep',
'node_value_updated',
'node_metadata_updated',
'node_wakeup',
]
],
}
]
super().__init__( 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: if not client_id:
self.client_id += '-zwavejs-mqtt' self.client_id += '-zwavejs-mqtt'
def _dispatch_event( def _dispatch_event(self, event_type: Type[ZwaveEvent], node: Optional[dict] = None, value: Optional[dict] = None,
self, **kwargs):
event_type: Type[ZwaveEvent],
node: Optional[dict] = None,
value: Optional[dict] = None,
**kwargs,
):
if value and 'id' not in value: if value and 'id' not in value:
value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}" value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}"
if 'propertyKey' in value: if 'propertyKey' in value:
value_id += '-' + str(value['propertyKey']) value_id += '-' + str(value['propertyKey'])
if value_id not in node.get('values', {}): if value_id not in node.get('values', {}):
self.logger.warning( self.logger.warning(f'value_id {value_id} not found on node {node["id"]}')
f'value_id {value_id} not found on node {node["id"]}'
)
return return
value = node['values'][value_id] value = node['values'][value_id]
@ -140,47 +107,41 @@ class ZwaveMqttBackend(MqttBackend):
evt = event_type(**kwargs) evt = event_type(**kwargs)
self._events_queue.put(evt) self._events_queue.put(evt)
if event_type == ZwaveValueChangedEvent: # zwavejs2mqtt currently treats some values (e.g. binary switches) in an inconsistent way,
# 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
# 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
# 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).
# (see https://github.com/zwave-js/zwavejs2mqtt/blob/4a6a5c5f1274763fd3aced4cae2c72ea060716b5 \ # To properly manage updates on writable values, propagate an event for both.
# /docs/guide/migrating.md). if event_type == ZwaveValueChangedEvent and kwargs.get('value', {}).get('property_id') == 'currentValue':
# To properly manage updates on writable values, propagate an event for both. value = kwargs['value'].copy()
if kwargs.get('value', {}).get('property_id') == 'currentValue': target_value_id = f'{kwargs["node"]["node_id"]}-{value["command_class"]}-{value.get("endpoint", 0)}' \
value = kwargs['value'].copy() f'-targetValue'
target_value_id = ( kwargs['value'] = kwargs['node'].get('values', {}).get(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']: if kwargs['value']:
kwargs['value']['data'] = value['data'] kwargs['value']['data'] = value['data']
kwargs['node']['values'][target_value_id] = kwargs['value'] kwargs['node']['values'][target_value_id] = kwargs['value']
evt = event_type(**kwargs) evt = event_type(**kwargs)
self._events_queue.put(evt) self._events_queue.put(evt)
self.plugin.publish_entities([kwargs['value']]) # type: ignore
def on_mqtt_message(self): def on_mqtt_message(self):
def handler(_, __, msg): def handler(_, __, msg):
if not msg.topic.startswith(self.events_topic): if not msg.topic.startswith(self.events_topic):
return 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() data = msg.payload.decode()
if not data: if not data:
return return
with contextlib.suppress(ValueError, TypeError): try:
data = json.loads(data)['data'] data = json.loads(data)['data']
except (ValueError, TypeError):
pass
try: try:
if topic == 'node_value_updated': if topic == 'node_value_updated':
self._dispatch_event( self._dispatch_event(ZwaveValueChangedEvent, node=data[0], value=data[1])
ZwaveValueChangedEvent, node=data[0], value=data[1]
)
elif topic == 'node_metadata_updated': elif topic == 'node_metadata_updated':
self._dispatch_event(ZwaveNodeEvent, node=data[0]) self._dispatch_event(ZwaveNodeEvent, node=data[0])
elif topic == 'node_sleep': elif topic == 'node_sleep':

View File

@ -1,38 +0,0 @@
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',
)

View File

@ -1,131 +0,0 @@
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)

View File

@ -1,269 +0,0 @@
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)

View File

@ -1,135 +0,0 @@
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

View File

@ -1,14 +0,0 @@
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__,
}

View File

@ -1,30 +0,0 @@
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__,
}

View File

@ -1,14 +0,0 @@
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__,
}

View File

@ -2,6 +2,7 @@ import copy
import hashlib import hashlib
import json import json
import re import re
import sys
import time import time
import uuid import uuid
@ -13,15 +14,23 @@ from platypush.utils import get_event_class_by_type
class Event(Message): class Event(Message):
""" Event message class """ """Event message class"""
# If this class property is set to false then the logging of these events # If this class property is set to false then the logging of these events
# will be disabled. Logging is usually disabled for events with a very # will be disabled. Logging is usually disabled for events with a very
# high frequency that would otherwise pollute the logs e.g. camera capture # high frequency that would otherwise pollute the logs e.g. camera capture
# events # events
# pylint: disable=redefined-builtin # pylint: disable=redefined-builtin
def __init__(self, target=None, origin=None, id=None, timestamp=None, def __init__(
disable_logging=False, disable_web_clients_notification=False, **kwargs): self,
target=None,
origin=None,
id=None,
timestamp=None,
disable_logging=False,
disable_web_clients_notification=False,
**kwargs
):
""" """
Params: Params:
target -- Target node [String] target -- Target node [String]
@ -34,22 +43,27 @@ class Event(Message):
self.id = id if id else self._generate_id() self.id = id if id else self._generate_id()
self.target = target if target else Config.get('device_id') self.target = target if target else Config.get('device_id')
self.origin = origin if origin else Config.get('device_id') self.origin = origin if origin else Config.get('device_id')
self.type = '{}.{}'.format(self.__class__.__module__, self.type = '{}.{}'.format(self.__class__.__module__, self.__class__.__name__)
self.__class__.__name__)
self.args = kwargs self.args = kwargs
self.disable_logging = disable_logging self.disable_logging = disable_logging
self.disable_web_clients_notification = disable_web_clients_notification self.disable_web_clients_notification = disable_web_clients_notification
for arg, value in self.args.items(): for arg, value in self.args.items():
if arg not in [ if arg not in [
'id', 'args', 'origin', 'target', 'type', 'timestamp', 'disable_logging' 'id',
'args',
'origin',
'target',
'type',
'timestamp',
'disable_logging',
] and not arg.startswith('_'): ] and not arg.startswith('_'):
self.__setattr__(arg, value) self.__setattr__(arg, value)
@classmethod @classmethod
def build(cls, msg): def build(cls, msg):
""" Builds an event message from a JSON UTF-8 string/bytearray, a """Builds an event message from a JSON UTF-8 string/bytearray, a
dictionary, or another Event """ dictionary, or another Event"""
msg = super().parse(msg) msg = super().parse(msg)
event_type = msg['args'].pop('type') event_type = msg['args'].pop('type')
@ -64,8 +78,10 @@ class Event(Message):
@staticmethod @staticmethod
def _generate_id(): def _generate_id():
""" Generate a unique event ID """ """Generate a unique event ID"""
return hashlib.md5(str(uuid.uuid1()).encode()).hexdigest() # lgtm [py/weak-sensitive-data-hashing] return hashlib.md5(
str(uuid.uuid1()).encode()
).hexdigest() # lgtm [py/weak-sensitive-data-hashing]
def matches_condition(self, condition): def matches_condition(self, condition):
""" """
@ -120,7 +136,13 @@ class Event(Message):
""" """
result = EventMatchResult(is_match=False) result = EventMatchResult(is_match=False)
event_tokens = re.split(r'\s+', self.args[argname].strip().lower()) if self.args.get(argname) == condition_value:
# In case of an exact match, return immediately
result.is_match = True
result.score = sys.maxsize
return result
event_tokens = re.split(r'\s+', self.args.get(argname, '').strip().lower())
condition_tokens = re.split(r'\s+', condition_value.strip().lower()) condition_tokens = re.split(r'\s+', condition_value.strip().lower())
while event_tokens and condition_tokens: while event_tokens and condition_tokens:
@ -148,9 +170,11 @@ class Event(Message):
else: else:
result.parsed_args[argname] += ' ' + event_token result.parsed_args[argname] += ' ' + event_token
if (len(condition_tokens) == 1 and len(event_tokens) == 1) \ if (len(condition_tokens) == 1 and len(event_tokens) == 1) or (
or (len(event_tokens) > 1 and len(condition_tokens) > 1 len(event_tokens) > 1
and event_tokens[1] == condition_tokens[1]): and len(condition_tokens) > 1
and event_tokens[1] == condition_tokens[1]
):
# Stop appending tokens to this argument, as the next # Stop appending tokens to this argument, as the next
# condition will be satisfied as well # condition will be satisfied as well
condition_tokens.pop(0) condition_tokens.pop(0)
@ -173,30 +197,30 @@ class Event(Message):
args = copy.deepcopy(self.args) args = copy.deepcopy(self.args)
flatten(args) flatten(args)
return json.dumps({ return json.dumps(
'type': 'event', {
'target': self.target, 'type': 'event',
'origin': self.origin if hasattr(self, 'origin') else None, 'target': self.target,
'id': self.id if hasattr(self, 'id') else None, 'origin': self.origin if hasattr(self, 'origin') else None,
'_timestamp': self.timestamp, 'id': self.id if hasattr(self, 'id') else None,
'args': { '_timestamp': self.timestamp,
'type': self.type, 'args': {'type': self.type, **args},
**args
}, },
}, cls=self.Encoder) cls=self.Encoder,
)
class EventMatchResult(object): class EventMatchResult:
""" When comparing an event against an event condition, you want to """When comparing an event against an event condition, you want to
return this object. It contains the match status (True or False), return this object. It contains the match status (True or False),
any parsed arguments, and a match_score that identifies how "strong" any parsed arguments, and a match_score that identifies how "strong"
the match is - in case of multiple event matches, the ones with the the match is - in case of multiple event matches, the ones with the
highest score will win """ highest score will win"""
def __init__(self, is_match, score=0, parsed_args=None): def __init__(self, is_match, score=0, parsed_args=None):
self.is_match = is_match self.is_match = is_match
self.score = score self.score = score
self.parsed_args = {} if not parsed_args else parsed_args self.parsed_args = parsed_args or {}
def flatten(args): def flatten(args):
@ -213,4 +237,5 @@ def flatten(args):
elif isinstance(arg, (dict, list)): elif isinstance(arg, (dict, list)):
flatten(args[i]) flatten(args[i])
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -1,32 +0,0 @@
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:

View File

@ -19,12 +19,12 @@ def action(f):
result = f(*args, **kwargs) result = f(*args, **kwargs)
if result and isinstance(result, Response): if result and isinstance(result, Response):
result.errors = ( result.errors = result.errors \
result.errors if isinstance(result.errors, list) else [result.errors] if isinstance(result.errors, list) else [result.errors]
)
response = result response = result
elif isinstance(result, tuple) and len(result) == 2: 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: if len(response.errors) == 1 and response.errors[0] is None:
response.errors = [] response.errors = []
@ -39,14 +39,12 @@ def action(f):
return _execute_action return _execute_action
class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to-init] class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to-init]
"""Base plugin class""" """ Base plugin class """
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__() super().__init__()
self.logger = logging.getLogger( self.logger = logging.getLogger('platypush:plugin:' + get_plugin_name_by_class(self.__class__))
'platypush:plugin:' + get_plugin_name_by_class(self.__class__)
)
if 'logging' in kwargs: if 'logging' in kwargs:
self.logger.setLevel(getattr(logging, kwargs['logging'].upper())) self.logger.setLevel(getattr(logging, kwargs['logging'].upper()))
@ -55,9 +53,8 @@ class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to
) )
def run(self, method, *args, **kwargs): def run(self, method, *args, **kwargs):
assert ( assert method in self.registered_actions, '{} is not a registered action on {}'.\
method in self.registered_actions format(method, self.__class__.__name__)
), '{} is not a registered action on {}'.format(method, self.__class__.__name__)
return getattr(self, method)(*args, **kwargs) return getattr(self, method)(*args, **kwargs)
@ -65,7 +62,6 @@ class RunnablePlugin(Plugin):
""" """
Class for runnable plugins - i.e. plugins that have a start/stop method and can be started. 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): 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). :param poll_interval: How often the :meth:`.loop` function should be execute (default: None, no pause/interval).
@ -82,9 +78,6 @@ class RunnablePlugin(Plugin):
def should_stop(self): def should_stop(self):
return self._should_stop.is_set() return self._should_stop.is_set()
def wait_stop(self, timeout=None):
return self._should_stop.wait(timeout)
def start(self): def start(self):
set_thread_name(self.__class__.__name__) set_thread_name(self.__class__.__name__)
self._thread = threading.Thread(target=self._runner) self._thread = threading.Thread(target=self._runner)

View File

@ -2,11 +2,7 @@ import os
from typing import Sequence, Dict, Tuple, Union, Optional from typing import Sequence, Dict, Tuple, Union, Optional
from platypush.plugins import RunnablePlugin, action from platypush.plugins import RunnablePlugin, action
from platypush.schemas.irc import ( from platypush.schemas.irc import IRCServerSchema, IRCServerStatusSchema, IRCChannelSchema
IRCServerSchema,
IRCServerStatusSchema,
IRCChannelSchema,
)
from ._bot import IRCBot from ._bot import IRCBot
from .. import ChatPlugin from .. import ChatPlugin
@ -63,19 +59,29 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
@property @property
def _bots_by_server(self) -> Dict[str, IRCBot]: 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 @property
def _bots_by_server_and_port(self) -> Dict[Tuple[str, int], IRCBot]: 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 @property
def _bots_by_alias(self) -> Dict[str, IRCBot]: 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): def main(self):
self._connect() self._connect()
self.wait_stop() self._should_stop.wait()
def _connect(self): def _connect(self):
for srv, bot in self._bots.items(): for srv, bot in self._bots.items():
@ -103,11 +109,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
@action @action
def send_file( def send_file(
self, self, file: str, server: Union[str, Tuple[str, int]], nick: str, bind_address: Optional[str] = None
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. Send a file to an IRC user over DCC connection.
@ -125,10 +127,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
@action @action
def send_message( def send_message(
self, self, text: str, server: Union[str, Tuple[str, int]], target: Union[str, Sequence[str]]
text: str,
server: Union[str, Tuple[str, int]],
target: Union[str, Sequence[str]],
): ):
""" """
Send a message to a channel or a nick. Send a message to a channel or a nick.
@ -140,14 +139,15 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
""" """
bot = self._get_bot(server) bot = self._get_bot(server)
method = ( method = (
bot.connection.privmsg bot.connection.privmsg if isinstance(target, str)
if isinstance(target, str)
else bot.connection.privmsg_many else bot.connection.privmsg_many
) )
method(target, text) method(target, text)
@action @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. Send a notice to a channel or a nick.
@ -192,28 +192,22 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
channel_name = channel channel_name = channel
channel = bot.channels.get(channel) channel = bot.channels.get(channel)
assert channel, f'Not connected to channel {channel}' assert channel, f'Not connected to channel {channel}'
return IRCChannelSchema().dump( return IRCChannelSchema().dump({
{ 'is_invite_only': channel.is_invite_only(),
'is_invite_only': channel.is_invite_only(), 'is_moderated': channel.is_moderated(),
'is_moderated': channel.is_moderated(), 'is_protected': channel.is_protected(),
'is_protected': channel.is_protected(), 'is_secret': channel.is_secret(),
'is_secret': channel.is_secret(), 'name': channel_name,
'name': channel_name, 'modes': channel.modes,
'modes': channel.modes, 'opers': list(channel.opers()),
'opers': list(channel.opers()), 'owners': channel.owners(),
'owners': channel.owners(), 'users': list(channel.users()),
'users': list(channel.users()), 'voiced': list(channel.voiced()),
'voiced': list(channel.voiced()), })
}
)
@action @action
def send_ctcp_message( def send_ctcp_message(
self, self, ctcp_type: str, body: str, server: Union[str, Tuple[str, int]], target: str
ctcp_type: str,
body: str,
server: Union[str, Tuple[str, int]],
target: str,
): ):
""" """
Send a CTCP message to a target. Send a CTCP message to a target.
@ -228,7 +222,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
@action @action
def send_ctcp_reply( 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. Send a CTCP REPLY command.
@ -241,9 +235,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
bot.connection.ctcp_reply(target, body) bot.connection.ctcp_reply(target, body)
@action @action
def disconnect( def disconnect(self, server: Union[str, Tuple[str, int]], message: Optional[str] = None):
self, server: Union[str, Tuple[str, int]], message: Optional[str] = None
):
""" """
Disconnect from a server. Disconnect from a server.
@ -254,7 +246,9 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
bot.connection.disconnect(message or bot.stop_message) bot.connection.disconnect(message or bot.stop_message)
@action @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. Invite a nick to a channel.
@ -278,11 +272,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
@action @action
def kick( def kick(
self, self, nick: str, channel: str, server: Union[str, Tuple[str, int]], reason: Optional[str] = None
nick: str,
channel: str,
server: Union[str, Tuple[str, int]],
reason: Optional[str] = None,
): ):
""" """
Kick a nick from a channel. Kick a nick from a channel.
@ -296,7 +286,9 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
bot.connection.kick(channel, nick, reason) bot.connection.kick(channel, nick, reason)
@action @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. Send a MODE command on the selected target.
@ -332,10 +324,8 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
@action @action
def part( def part(
self, self, channel: Union[str, Sequence[str]], server: Union[str, Tuple[str, int]],
channel: Union[str, Sequence[str]], message: Optional[str] = None
server: Union[str, Tuple[str, int]],
message: Optional[str] = None,
): ):
""" """
Parts/exits a channel. Parts/exits a channel.
@ -349,7 +339,9 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
bot.connection.part(channels=channels, message=message or bot.stop_message) bot.connection.part(channels=channels, message=message or bot.stop_message)
@action @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. Send a QUIT command.
@ -371,12 +363,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
bot.connection.send_raw(message) bot.connection.send_raw(message)
@action @action
def topic( def topic(self, channel: str, server: Union[str, Tuple[str, int]], topic: Optional[str] = None) -> str:
self,
channel: str,
server: Union[str, Tuple[str, int]],
topic: Optional[str] = None,
) -> str:
""" """
Get/set the topic of an IRC channel. Get/set the topic of an IRC channel.

View File

@ -1,11 +1,11 @@
"""
.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com>
"""
import time import time
from contextlib import contextmanager
from multiprocessing import RLock
from typing import Generator
from sqlalchemy import create_engine, Table, MetaData from sqlalchemy import create_engine, Table, MetaData
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker, scoped_session
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
@ -30,23 +30,22 @@ class DbPlugin(Plugin):
""" """
super().__init__() 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) -> Engine: def _get_engine(self, engine=None, *args, **kwargs):
if engine: if engine:
if isinstance(engine, Engine): if isinstance(engine, Engine):
return engine return engine
if engine.startswith('sqlite://'): if engine.startswith('sqlite://'):
kwargs['connect_args'] = {'check_same_thread': False} kwargs['connect_args'] = {'check_same_thread': False}
return create_engine(engine, *args, **kwargs) # type: ignore return create_engine(engine, *args, **kwargs)
assert self.engine
return self.engine return self.engine
# noinspection PyUnusedLocal
@staticmethod @staticmethod
def _build_condition(_, column, value): def _build_condition(table, column, value):
if isinstance(value, str): if isinstance(value, str):
value = "'{}'".format(value) value = "'{}'".format(value)
elif not isinstance(value, int) and not isinstance(value, float): elif not isinstance(value, int) and not isinstance(value, float):
@ -74,14 +73,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) :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: with engine.connect() as connection:
connection.execute(statement) connection.execute(statement)
def _get_table(self, table, engine=None, *args, **kwargs): def _get_table(self, table, engine=None, *args, **kwargs):
if not engine: if not engine:
engine = self.get_engine(engine, *args, **kwargs) engine = self._get_engine(engine, *args, **kwargs)
db_ok = False db_ok = False
n_tries = 0 n_tries = 0
@ -99,7 +98,7 @@ class DbPlugin(Plugin):
self.logger.exception(e) self.logger.exception(e)
self.logger.info('Waiting {} seconds before retrying'.format(wait_time)) self.logger.info('Waiting {} seconds before retrying'.format(wait_time))
time.sleep(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: if not db_ok and last_error:
raise last_error raise last_error
@ -164,7 +163,7 @@ class DbPlugin(Plugin):
] ]
""" """
engine = self.get_engine(engine, *args, **kwargs) engine = self._get_engine(engine, *args, **kwargs)
if table: if table:
table, engine = self._get_table(table, engine=engine, *args, **kwargs) table, engine = self._get_table(table, engine=engine, *args, **kwargs)
@ -235,7 +234,7 @@ class DbPlugin(Plugin):
if key_columns is None: if key_columns is None:
key_columns = [] key_columns = []
engine = self.get_engine(engine, *args, **kwargs) engine = self._get_engine(engine, *args, **kwargs)
for record in records: for record in records:
table, engine = self._get_table(table, engine=engine, *args, **kwargs) table, engine = self._get_table(table, engine=engine, *args, **kwargs)
@ -294,7 +293,7 @@ class DbPlugin(Plugin):
} }
""" """
engine = self.get_engine(engine, *args, **kwargs) engine = self._get_engine(engine, *args, **kwargs)
for record in records: for record in records:
table, engine = self._get_table(table, engine=engine, *args, **kwargs) table, engine = self._get_table(table, engine=engine, *args, **kwargs)
@ -342,7 +341,7 @@ class DbPlugin(Plugin):
} }
""" """
engine = self.get_engine(engine, *args, **kwargs) engine = self._get_engine(engine, *args, **kwargs)
for record in records: for record in records:
table, engine = self._get_table(table, engine=engine, *args, **kwargs) table, engine = self._get_table(table, engine=engine, *args, **kwargs)
@ -353,22 +352,5 @@ class DbPlugin(Plugin):
engine.execute(delete) 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: # vim:sw=4:ts=4:et:

View File

@ -1,251 +0,0 @@
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:

View File

@ -1,4 +0,0 @@
manifest:
events: {}
package: platypush.plugins.entities
type: plugin

View File

@ -27,11 +27,11 @@ class GpioPlugin(RunnablePlugin):
""" """
def __init__( def __init__(
self, self,
pins: Optional[Dict[str, int]] = None, pins: Optional[Dict[str, int]] = None,
monitored_pins: Optional[Collection[Union[str, int]]] = None, monitored_pins: Optional[Collection[Union[str, int]]] = None,
mode: str = 'board', mode: str = 'board',
**kwargs **kwargs
): ):
""" """
:param mode: Specify ``board`` if you want to use the board PIN numbers, :param mode: Specify ``board`` if you want to use the board PIN numbers,
@ -64,9 +64,8 @@ class GpioPlugin(RunnablePlugin):
self._initialized_pins = {} self._initialized_pins = {}
self._monitored_pins = monitored_pins or [] self._monitored_pins = monitored_pins or []
self.pins_by_name = pins if pins else {} self.pins_by_name = pins if pins else {}
self.pins_by_number = { self.pins_by_number = {number: name
number: name for (name, number) in self.pins_by_name.items() for (name, number) in self.pins_by_name.items()}
}
def _init_board(self): def _init_board(self):
import RPi.GPIO as GPIO import RPi.GPIO as GPIO
@ -99,7 +98,6 @@ class GpioPlugin(RunnablePlugin):
def on_gpio_event(self): def on_gpio_event(self):
def callback(pin: int): def callback(pin: int):
import RPi.GPIO as GPIO import RPi.GPIO as GPIO
value = GPIO.input(pin) value = GPIO.input(pin)
pin = self.pins_by_number.get(pin, pin) pin = self.pins_by_number.get(pin, pin)
get_bus().post(GPIOEvent(pin=pin, value=value)) get_bus().post(GPIOEvent(pin=pin, value=value))
@ -108,23 +106,23 @@ class GpioPlugin(RunnablePlugin):
def main(self): def main(self):
import RPi.GPIO as GPIO import RPi.GPIO as GPIO
if not self._monitored_pins: if not self._monitored_pins:
return # No need to start the monitor return # No need to start the monitor
self._init_board() 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: for pin in monitored_pins:
GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
GPIO.add_event_detect(pin, GPIO.BOTH, callback=self.on_gpio_event()) GPIO.add_event_detect(pin, GPIO.BOTH, callback=self.on_gpio_event())
self.wait_stop() self._should_stop.wait()
@action @action
def write( def write(self, pin: Union[int, str], value: Union[int, bool],
self, pin: Union[int, str], value: Union[int, bool], name: Optional[str] = None name: Optional[str] = None) -> Dict[str, Any]:
) -> Dict[str, Any]:
""" """
Write a byte value to a pin. Write a byte value to a pin.

View File

@ -1,52 +1,30 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from platypush.entities import manages from platypush.plugins import action
from platypush.entities.lights import Light from platypush.plugins.switch import SwitchPlugin
from platypush.plugins import Plugin, action
@manages(Light) class LightPlugin(SwitchPlugin, ABC):
class LightPlugin(Plugin, ABC):
""" """
Abstract plugin to interface your logic with lights/bulbs. Abstract plugin to interface your logic with lights/bulbs.
""" """
@action @action
@abstractmethod @abstractmethod
def on(self, lights=None, *args, **kwargs): def on(self):
"""Turn the light on""" """ Turn the light on """
raise NotImplementedError() raise NotImplementedError()
@action @action
@abstractmethod @abstractmethod
def off(self, lights=None, *args, **kwargs): def off(self):
"""Turn the light off""" """ Turn the light off """
raise NotImplementedError() raise NotImplementedError()
@action @action
@abstractmethod @abstractmethod
def toggle(self, lights=None, *args, **kwargs): def toggle(self):
"""Toggle the light status (on/off)""" """ 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() raise NotImplementedError()

View File

@ -4,22 +4,16 @@ import time
from enum import Enum from enum import Enum
from threading import Thread, Event from threading import Thread, Event
from typing import Iterable, Union, Mapping, Any, Set from typing import List
from platypush.context import get_bus from platypush.context import get_bus
from platypush.entities import Entity from platypush.message.event.light import LightAnimationStartedEvent, LightAnimationStoppedEvent
from platypush.entities.lights import Light as LightEntity from platypush.plugins import action
from platypush.message.event.light import (
LightAnimationStartedEvent,
LightAnimationStoppedEvent,
LightStatusChangeEvent,
)
from platypush.plugins import action, RunnablePlugin
from platypush.plugins.light import LightPlugin from platypush.plugins.light import LightPlugin
from platypush.utils import set_thread_name from platypush.utils import set_thread_name
class LightHuePlugin(RunnablePlugin, LightPlugin): class LightHuePlugin(LightPlugin):
""" """
Philips Hue lights plugin. Philips Hue lights plugin.
@ -31,20 +25,15 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
- :class:`platypush.message.event.light.LightAnimationStartedEvent` when an animation is started. - :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.LightAnimationStoppedEvent` when an animation is stopped.
- :class:`platypush.message.event.light.LightStatusChangeEvent` when the status of a lightbulb
changes.
""" """
MAX_BRI = 255 MAX_BRI = 255
MAX_SAT = 255 MAX_SAT = 255
MAX_HUE = 65535 MAX_HUE = 65535
MIN_CT = 154
MAX_CT = 500
ANIMATION_CTRL_QUEUE_NAME = 'platypush/light/hue/AnimationCtrl' ANIMATION_CTRL_QUEUE_NAME = 'platypush/light/hue/AnimationCtrl'
_BRIDGE_RECONNECT_SECONDS = 5 _BRIDGE_RECONNECT_SECONDS = 5
_MAX_RECONNECT_TRIES = 5 _MAX_RECONNECT_TRIES = 5
_UNINITIALIZED_BRIDGE_ERR = 'The Hue bridge is not initialized'
class Animation(Enum): class Animation(Enum):
COLOR_TRANSITION = 'color_transition' COLOR_TRANSITION = 'color_transition'
@ -56,7 +45,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
elif isinstance(other, self.__class__): elif isinstance(other, self.__class__):
return self == other return self == other
def __init__(self, bridge, lights=None, groups=None, poll_seconds: float = 20.0): def __init__(self, bridge, lights=None, groups=None):
""" """
:param bridge: Bridge address or hostname :param bridge: Bridge address or hostname
:type bridge: str :type bridge: str
@ -66,54 +55,38 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
:param groups Default groups to be controlled (default: all) :param groups Default groups to be controlled (default: all)
:type groups: list[str] :type groups: list[str]
:param poll_seconds: How often the plugin should check the bridge for light
updates (default: 20 seconds).
""" """
super().__init__() super().__init__()
self.bridge_address = bridge self.bridge_address = bridge
self.bridge = None self.bridge = None
self.logger.info( self.logger.info('Initializing Hue lights plugin - bridge: "{}"'.format(self.bridge_address))
'Initializing Hue lights plugin - bridge: "{}"'.format(self.bridge_address)
)
self.connect() self.connect()
self.lights = set() self.lights = []
self.groups = set() self.groups = []
self.poll_seconds = poll_seconds
self._cached_lights = {}
if lights: if lights:
self.lights = set(lights) self.lights = lights
elif groups: elif groups:
self.groups = set(groups) self.groups = groups
self.lights.update(self._expand_groups(self.groups)) self._expand_groups()
else: else:
self.lights = {light['name'] for light in self._get_lights().values()} # noinspection PyUnresolvedReferences
self.lights = [light.name for light in self.bridge.lights]
self.animation_thread = None self.animation_thread = None
self.animations = {} self.animations = {}
self._animation_stop = Event() self._animation_stop = Event()
self._init_animations() self._init_animations()
self.logger.info(f'Configured lights: {self.lights}') self.logger.info('Configured lights: "{}"'.format(self.lights))
def _expand_groups(self, groups: Iterable[str]) -> Set[str]: def _expand_groups(self):
lights = set() groups = [g for g in self.bridge.groups if g.name in self.groups]
light_id_to_name = { for group in groups:
light_id: light['name'] for light_id, light in self._get_lights().items() for light in group.lights:
} self.lights += [light.name]
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): def _init_animations(self):
self.animations = { self.animations = {
@ -121,10 +94,10 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
'lights': {}, 'lights': {},
} }
for group_id in self._get_groups(): for group in self.bridge.groups:
self.animations['groups'][group_id] = None self.animations['groups'][group.group_id] = None
for light_id in self._get_lights(): for light in self.bridge.lights:
self.animations['lights'][light_id] = None self.animations['lights'][light.light_id] = None
@action @action
def connect(self): def connect(self):
@ -137,7 +110,6 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
# Lazy init # Lazy init
if not self.bridge: if not self.bridge:
from phue import Bridge, PhueRegistrationException from phue import Bridge, PhueRegistrationException
success = False success = False
n_tries = 0 n_tries = 0
@ -147,14 +119,12 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
self.bridge = Bridge(self.bridge_address) self.bridge = Bridge(self.bridge_address)
success = True success = True
except PhueRegistrationException as e: 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: if n_tries >= self._MAX_RECONNECT_TRIES:
self.logger.error( self.logger.error(('Bridge registration failed after ' +
( '{} attempts').format(n_tries))
'Bridge registration failed after ' + '{} attempts'
).format(n_tries)
)
break break
time.sleep(self._BRIDGE_RECONNECT_SECONDS) time.sleep(self._BRIDGE_RECONNECT_SECONDS)
@ -198,7 +168,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
'id': id, 'id': id,
**scene, **scene,
} }
for id, scene in self._get_scenes().items() for id, scene in self.bridge.get_scene().items()
} }
@action @action
@ -245,7 +215,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
'id': id, 'id': id,
**light, **light,
} }
for id, light in self._get_lights().items() for id, light in self.bridge.get_light().items()
} }
@action @action
@ -303,7 +273,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
'id': id, 'id': id,
**group, **group,
} }
for id, group in self._get_groups().items() for id, group in self.bridge.get_group().items()
} }
@action @action
@ -351,22 +321,15 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
self.bridge = None self.bridge = None
raise e raise e
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
lights = [] lights = []
groups = [] groups = []
if 'lights' in kwargs: if 'lights' in kwargs:
lights = ( lights = kwargs.pop('lights').split(',').strip() \
kwargs.pop('lights').split(',').strip() if isinstance(lights, str) else kwargs.pop('lights')
if isinstance(lights, str)
else kwargs.pop('lights')
)
if 'groups' in kwargs: if 'groups' in kwargs:
groups = ( groups = kwargs.pop('groups').split(',').strip() \
kwargs.pop('groups').split(',').strip() if isinstance(groups, str) else kwargs.pop('groups')
if isinstance(groups, str)
else kwargs.pop('groups')
)
if not lights and not groups: if not lights and not groups:
lights = self.lights lights = self.lights
@ -377,36 +340,34 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
try: try:
if attr == 'scene': if attr == 'scene':
assert groups, 'No groups specified' self.bridge.run_scene(groups[0], kwargs.pop('name'))
self.bridge.run_scene(list(groups)[0], kwargs.pop('name'))
else: else:
if groups: if groups:
self.bridge.set_group(list(groups), attr, *args, **kwargs) self.bridge.set_group(groups, attr, *args, **kwargs)
if lights: if lights:
self.bridge.set_light(list(lights), attr, *args, **kwargs) self.bridge.set_light(lights, attr, *args, **kwargs)
except Exception as e: except Exception as e:
# Reset bridge connection # Reset bridge connection
self.bridge = None self.bridge = None
raise e raise e
return self._get_lights()
@action @action
def set_lights(self, lights, **kwargs): def set_light(self, light, **kwargs):
""" """
Set a set of properties on a set of lights. Set a light (or lights) property.
:param light: List of lights to set. Each item can represent a light :param light: Light or lights to set. Can be a string representing the light name,
name or ID. a light object, a list of string, or a list of light objects.
:param kwargs: key-value list of the parameters to set. :param kwargs: key-value list of parameters to set.
Example call:: Example call::
{ {
"type": "request", "type": "request",
"target": "hostname",
"action": "light.hue.set_light", "action": "light.hue.set_light",
"args": { "args": {
"lights": ["Bulb 1", "Bulb 2"], "light": "Bulb 1",
"sat": 255 "sat": 255
} }
} }
@ -414,42 +375,21 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
""" """
self.connect() self.connect()
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR self.bridge.set_light(light, **kwargs)
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 @action
def set_group(self, group, **kwargs): def set_group(self, group, **kwargs):
""" """
Set a group (or groups) property. Set a group (or groups) property.
:param group: Group or groups to set. It can be a string representing the :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.
group name, a group object, a list of strings, or a list of group objects.
:param kwargs: key-value list of parameters to set. :param kwargs: key-value list of parameters to set.
Example call:: Example call::
{ {
"type": "request", "type": "request",
"target": "hostname",
"action": "light.hue.set_group", "action": "light.hue.set_group",
"args": { "args": {
"light": "Living Room", "light": "Living Room",
@ -460,7 +400,6 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
""" """
self.connect() self.connect()
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
self.bridge.set_group(group, **kwargs) self.bridge.set_group(group, **kwargs)
@action @action
@ -512,16 +451,15 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
groups_off = [] groups_off = []
if groups: if groups:
all_groups = self._get_groups().values() all_groups = self.bridge.get_group().values()
groups_on = [ groups_on = [
group['name'] group['name'] for group in all_groups
for group in all_groups
if group['name'] in groups and group['state']['any_on'] is True if group['name'] in groups and group['state']['any_on'] is True
] ]
groups_off = [ groups_off = [
group['name'] group['name'] for group in all_groups
for group in all_groups
if group['name'] in groups and group['state']['any_on'] is False if group['name'] in groups and group['state']['any_on'] is False
] ]
@ -529,20 +467,16 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
lights = self.lights lights = self.lights
if lights: if lights:
all_lights = self._get_lights() all_lights = self.bridge.get_light().values()
lights_on = [ lights_on = [
light['name'] light['name'] for light in all_lights
for light_id, light in all_lights.items() if light['name'] in lights and light['state']['on'] is True
if (light_id in lights or light['name'] in lights)
and light['state']['on'] is True
] ]
lights_off = [ lights_off = [
light['name'] light['name'] for light in all_lights
for light_id, light in all_lights.items() if light['name'] in lights and light['state']['on'] is False
if (light_id in lights or light['name'] in lights)
and light['state']['on'] is False
] ]
if lights_on or groups_on: if lights_on or groups_on:
@ -565,13 +499,8 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
groups = [] groups = []
if lights is None: if lights is None:
lights = [] lights = []
return self._exec( return self._exec('bri', int(value) % (self.MAX_BRI + 1),
'bri', lights=lights, groups=groups, **kwargs)
int(value) % (self.MAX_BRI + 1),
lights=lights,
groups=groups,
**kwargs,
)
@action @action
def sat(self, value, lights=None, groups=None, **kwargs): def sat(self, value, lights=None, groups=None, **kwargs):
@ -587,13 +516,8 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
groups = [] groups = []
if lights is None: if lights is None:
lights = [] lights = []
return self._exec( return self._exec('sat', int(value) % (self.MAX_SAT + 1),
'sat', lights=lights, groups=groups, **kwargs)
int(value) % (self.MAX_SAT + 1),
lights=lights,
groups=groups,
**kwargs,
)
@action @action
def hue(self, value, lights=None, groups=None, **kwargs): def hue(self, value, lights=None, groups=None, **kwargs):
@ -609,13 +533,8 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
groups = [] groups = []
if lights is None: if lights is None:
lights = [] lights = []
return self._exec( return self._exec('hue', int(value) % (self.MAX_HUE + 1),
'hue', lights=lights, groups=groups, **kwargs)
int(value) % (self.MAX_HUE + 1),
lights=lights,
groups=groups,
**kwargs,
)
@action @action
def xy(self, value, lights=None, groups=None, **kwargs): def xy(self, value, lights=None, groups=None, **kwargs):
@ -638,7 +557,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
""" """
Set lights/groups color temperature. Set lights/groups color temperature.
:param value: Temperature value (range: 154-500) :param value: Temperature value (range: 0-255)
:type value: int :type value: int
:param lights: List of lights. :param lights: List of lights.
:param groups: List of groups. :param groups: List of groups.
@ -665,31 +584,25 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
lights = [] lights = []
if lights: if lights:
bri = statistics.mean( bri = statistics.mean([
[ light['state']['bri']
light['state']['bri'] for light in self.bridge.get_light().values()
for light in self._get_lights().values() if light['name'] in lights
if light['name'] in lights ])
]
)
elif groups: elif groups:
bri = statistics.mean( bri = statistics.mean([
[ group['action']['bri']
group['action']['bri'] for group in self.bridge.get_group().values()
for group in self._get_groups().values() if group['name'] in groups
if group['name'] in groups ])
]
)
else: else:
bri = statistics.mean( bri = statistics.mean([
[ light['state']['bri']
light['state']['bri'] for light in self.bridge.get_light().values()
for light in self._get_lights().values() if light['name'] in self.lights
if light['name'] in self.lights ])
]
)
delta *= self.MAX_BRI / 100 delta *= (self.MAX_BRI / 100)
if bri + delta < 0: if bri + delta < 0:
bri = 0 bri = 0
elif bri + delta > self.MAX_BRI: elif bri + delta > self.MAX_BRI:
@ -715,31 +628,25 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
lights = [] lights = []
if lights: if lights:
sat = statistics.mean( sat = statistics.mean([
[ light['state']['sat']
light['state']['sat'] for light in self.bridge.get_light().values()
for light in self._get_lights().values() if light['name'] in lights
if light['name'] in lights ])
]
)
elif groups: elif groups:
sat = statistics.mean( sat = statistics.mean([
[ group['action']['sat']
group['action']['sat'] for group in self.bridge.get_group().values()
for group in self._get_groups().values() if group['name'] in groups
if group['name'] in groups ])
]
)
else: else:
sat = statistics.mean( sat = statistics.mean([
[ light['state']['sat']
light['state']['sat'] for light in self.bridge.get_light().values()
for light in self._get_lights().values() if light['name'] in self.lights
if light['name'] in self.lights ])
]
)
delta *= self.MAX_SAT / 100 delta *= (self.MAX_SAT / 100)
if sat + delta < 0: if sat + delta < 0:
sat = 0 sat = 0
elif sat + delta > self.MAX_SAT: elif sat + delta > self.MAX_SAT:
@ -765,31 +672,25 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
lights = [] lights = []
if lights: if lights:
hue = statistics.mean( hue = statistics.mean([
[ light['state']['hue']
light['state']['hue'] for light in self.bridge.get_light().values()
for light in self._get_lights().values() if light['name'] in lights
if light['name'] in lights ])
]
)
elif groups: elif groups:
hue = statistics.mean( hue = statistics.mean([
[ group['action']['hue']
group['action']['hue'] for group in self.bridge.get_group().values()
for group in self._get_groups().values() if group['name'] in groups
if group['name'] in groups ])
]
)
else: else:
hue = statistics.mean( hue = statistics.mean([
[ light['state']['hue']
light['state']['hue'] for light in self.bridge.get_light().values()
for light in self._get_lights().values() if light['name'] in self.lights
if light['name'] in self.lights ])
]
)
delta *= self.MAX_HUE / 100 delta *= (self.MAX_HUE / 100)
if hue + delta < 0: if hue + delta < 0:
hue = 0 hue = 0
elif hue + delta > self.MAX_HUE: elif hue + delta > self.MAX_HUE:
@ -833,20 +734,10 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
self._init_animations() self._init_animations()
@action @action
def animate( def animate(self, animation, duration=None,
self, hue_range=None, sat_range=None,
animation, bri_range=None, lights=None, groups=None,
duration=None, hue_step=1000, sat_step=2, bri_step=1, transition_seconds=1.0):
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. Run a lights animation.
@ -856,33 +747,28 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
:param duration: Animation duration in seconds (default: None, i.e. continue until stop) :param duration: Animation duration in seconds (default: None, i.e. continue until stop)
:type duration: float :type duration: float
:param hue_range: If you selected a ``color_transition``, this will :param hue_range: If you selected a ``color_transition``, this will specify the hue range of your color ``color_transition``.
specify the hue range of your color ``color_transition``. Default: [0, 65535] Default: [0, 65535]
:type hue_range: list[int] :type hue_range: list[int]
:param sat_range: If you selected a color ``color_transition``, this :param sat_range: If you selected a color ``color_transition``, this will specify the saturation range of your color
will specify the saturation range of your color ``color_transition``. ``color_transition``. Default: [0, 255]
Default: [0, 255]
:type sat_range: list[int] :type sat_range: list[int]
:param bri_range: If you selected a color ``color_transition``, this :param bri_range: If you selected a color ``color_transition``, this will specify the brightness range of your color
will specify the brightness range of your color ``color_transition``. ``color_transition``. Default: [254, 255] :type bri_range: list[int]
Default: [254, 255] :type bri_range: list[int]
:param lights: Lights to control (names, IDs or light objects). Default: plugin default lights :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 groups: Groups to control (names, IDs or group objects). Default: plugin default groups
:param hue_step: If you selected a color ``color_transition``, this :param hue_step: If you selected a color ``color_transition``, this will specify by how much the color hue will change
will specify by how much the color hue will change between iterations. between iterations. Default: 1000 :type hue_step: int
Default: 1000 :type hue_step: int
:param sat_step: If you selected a color ``color_transition``, this :param sat_step: If you selected a color ``color_transition``, this will specify by how much the saturation will change
will specify by how much the saturation will change between iterations. between iterations. Default: 2 :type sat_step: int
Default: 2 :type sat_step: int
:param bri_step: If you selected a color ``color_transition``, this :param bri_step: If you selected a color ``color_transition``, this will specify by how much the brightness will change
will specify by how much the brightness will change between iterations. between iterations. Default: 1 :type bri_step: int
Default: 1 :type bri_step: int
:param transition_seconds: Time between two transitions or blinks in seconds. Default: 1.0 :param transition_seconds: Time between two transitions or blinks in seconds. Default: 1.0
:type transition_seconds: float :type transition_seconds: float
@ -890,26 +776,20 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
self.stop_animation() self.stop_animation()
self._animation_stop.clear() 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: if groups:
groups = { groups = [g for g in self.bridge.groups if g.name in groups or g.group_id in groups]
group_id: group lights = lights or []
for group_id, group in self._get_groups().items() for group in groups:
if group.get('name') in groups or group_id in groups lights.extend([light.name for light in group.lights])
}
lights = set(lights or [])
lights.update(self._expand_groups([g['name'] for g in groups.values()]))
elif lights: elif lights:
lights = { lights = [light.name for light in self.bridge.lights if light.name in lights or light.light_id in lights]
light['name']
for light_id, light in all_lights.items()
if light['name'] in lights or int(light_id) in lights
}
else: else:
lights = self.lights lights = self.lights
@ -926,50 +806,26 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
} }
if groups: if groups:
for group_id in groups: for group in groups:
self.animations['groups'][group_id] = info self.animations['groups'][group.group_id] = info
for light_id, light in all_lights.items(): for light in self.bridge.lights:
if light['name'] in lights: if light.name in lights:
self.animations['lights'][light_id] = info self.animations['lights'][light.light_id] = info
def _initialize_light_attrs(lights): def _initialize_light_attrs(lights):
lights_by_name = {
light['name']: light for light in self._get_lights().values()
}
if animation == self.Animation.COLOR_TRANSITION: if animation == self.Animation.COLOR_TRANSITION:
return { return {light: {
light: { 'hue': random.randint(hue_range[0], hue_range[1]),
**( 'sat': random.randint(sat_range[0], sat_range[1]),
{'hue': random.randint(hue_range[0], hue_range[1])} # type: ignore 'bri': random.randint(bri_range[0], bri_range[1]),
if 'hue' in lights_by_name.get(light, {}).get('state', {}) } for light in lights}
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: elif animation == self.Animation.BLINK:
return { return {light: {
light: { 'on': True,
'on': True, 'bri': self.MAX_BRI,
**({'bri': self.MAX_BRI} if 'bri' in light else {}), 'transitiontime': 0,
'transitiontime': 0, } for light in lights}
}
for light in lights
}
raise AssertionError(f'Unknown animation type: {animation}')
def _next_light_attrs(lights): def _next_light_attrs(lights):
if animation == self.Animation.COLOR_TRANSITION: if animation == self.Animation.COLOR_TRANSITION:
@ -987,19 +843,15 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
else: else:
continue continue
lights[light][attr] = ( lights[light][attr] = ((value - attr_range[0] + attr_step) %
(value - attr_range[0] + attr_step) (attr_range[1] - attr_range[0] + 1)) + \
% (attr_range[1] - attr_range[0] + 1) attr_range[0]
) + attr_range[0]
elif animation == self.Animation.BLINK: elif animation == self.Animation.BLINK:
lights = { lights = {light: {
light: { 'on': False if attrs['on'] else True,
'on': not attrs['on'], 'bri': self.MAX_BRI,
'bri': self.MAX_BRI, 'transitiontime': 0,
'transitiontime': 0, } for (light, attrs) in lights.items()}
}
for (light, attrs) in lights.items()
}
return lights return lights
@ -1008,23 +860,13 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
def _animate_thread(lights): def _animate_thread(lights):
set_thread_name('HueAnimate') set_thread_name('HueAnimate')
get_bus().post( get_bus().post(LightAnimationStartedEvent(lights=lights, groups=groups, animation=animation))
LightAnimationStartedEvent(
lights=lights,
groups=list((groups or {}).keys()),
animation=animation,
)
)
lights = _initialize_light_attrs(lights) lights = _initialize_light_attrs(lights)
animation_start_time = time.time() animation_start_time = time.time()
stop_animation = False stop_animation = False
while not stop_animation and not ( while not stop_animation and not (duration and time.time() - animation_start_time > duration):
duration and time.time() - animation_start_time > duration
):
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
try: try:
if animation == self.Animation.COLOR_TRANSITION: if animation == self.Animation.COLOR_TRANSITION:
for (light, attrs) in lights.items(): for (light, attrs) in lights.items():
@ -1035,9 +877,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
self.logger.debug('Setting lights to {}'.format(conf)) self.logger.debug('Setting lights to {}'.format(conf))
if groups: if groups:
self.bridge.set_group( self.bridge.set_group([g.name for g in groups], conf)
[g['name'] for g in groups.values()], conf
)
else: else:
self.bridge.set_light(lights.keys(), conf) self.bridge.set_light(lights.keys(), conf)
@ -1051,164 +891,57 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
lights = _next_light_attrs(lights) lights = _next_light_attrs(lights)
get_bus().post( get_bus().post(LightAnimationStoppedEvent(lights=lights, groups=groups, animation=animation))
LightAnimationStoppedEvent(
lights=list(lights.keys()),
groups=list((groups or {}).keys()),
animation=animation,
)
)
self.animation_thread = None self.animation_thread = None
self.animation_thread = Thread( self.animation_thread = Thread(target=_animate_thread,
target=_animate_thread, name='HueAnimate', args=(lights,) name='HueAnimate',
) args=(lights,))
self.animation_thread.start() self.animation_thread.start()
def _get_light_attr(self, light, attr: str): @property
try: def switches(self) -> List[dict]:
return getattr(light, attr, None) """
except KeyError: :returns: Implements :meth:`platypush.plugins.switch.SwitchPlugin.switches` and returns the status of the
return None configured lights. Example:
def transform_entities( .. code-block:: json
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()]
for entity in entities: [
if isinstance(entity, Entity): {
new_entities.append(entity) "id": "3",
elif isinstance(entity, dict): "name": "Lightbulb 1",
new_entities.append( "on": true,
LightEntity( "bri": 254,
id=entity['id'], "hue": 1532,
name=entity['name'], "sat": 215,
description=entity.get('type'), "effect": "none",
on=entity.get('state', {}).get('on', False), "xy": [
brightness=entity.get('state', {}).get('bri'), 0.6163,
saturation=entity.get('state', {}).get('sat'), 0.3403
hue=entity.get('state', {}).get('hue'), ],
temperature=entity.get('state', {}).get('ct'), "ct": 153,
colormode=entity.get('colormode'), "alert": "none",
reachable=entity.get('state', {}).get('reachable'), "colormode": "hs",
x=entity['state']['xy'][0] "reachable": true
if entity.get('state', {}).get('xy') "type": "Extended color light",
else None, "modelid": "LCT001",
y=entity['state']['xy'][1] "manufacturername": "Philips",
if entity.get('state', {}).get('xy') "uniqueid": "00:11:22:33:44:55:66:77-88",
else None, "swversion": "5.105.0.21169"
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 """
def _get_lights(self) -> dict: return [
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR {
lights = self.bridge.get_light() 'id': id,
lights = {id: light for id, light in lights.items() if not light.get('recycle')} **light.pop('state', {}),
self._cached_lights = lights **light,
self.publish_entities(lights) # type: ignore }
return lights for id, light in self.bridge.get_light().items()
]
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: # vim:sw=4:ts=4:et:

View File

@ -4,8 +4,6 @@ manifest:
started. started.
platypush.message.event.light.LightAnimationStoppedEvent: when an animation is platypush.message.event.light.LightAnimationStoppedEvent: when an animation is
stopped. stopped.
platypush.message.event.light.LightStatusChangeEvent: when the status of a
lightbulb changes.
install: install:
pip: pip:
- phue - phue

View File

@ -3,16 +3,9 @@ import os
import re import re
import time import time
from sqlalchemy import ( from sqlalchemy import create_engine, Column, Integer, String, DateTime, PrimaryKeyConstraint, ForeignKey
create_engine, from sqlalchemy.orm import sessionmaker, scoped_session
Column, from sqlalchemy.ext.declarative import declarative_base
Integer,
String,
DateTime,
PrimaryKeyConstraint,
ForeignKey,
)
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from platypush.config import Config from platypush.config import Config
@ -45,8 +38,7 @@ class LocalMediaSearcher(MediaSearcher):
if not self._db_engine: if not self._db_engine:
self._db_engine = create_engine( self._db_engine = create_engine(
'sqlite:///{}'.format(self.db_file), 'sqlite:///{}'.format(self.db_file),
connect_args={'check_same_thread': False}, connect_args={'check_same_thread': False})
)
Base.metadata.create_all(self._db_engine) Base.metadata.create_all(self._db_engine)
Session.configure(bind=self._db_engine) Session.configure(bind=self._db_engine)
@ -65,30 +57,27 @@ class LocalMediaSearcher(MediaSearcher):
@classmethod @classmethod
def _get_last_modify_time(cls, path, recursive=False): def _get_last_modify_time(cls, path, recursive=False):
return ( return max([os.path.getmtime(p) for p, _, _ in os.walk(path)]) \
max([os.path.getmtime(p) for p, _, _ in os.walk(path)]) if recursive else os.path.getmtime(path)
if recursive
else os.path.getmtime(path)
)
@classmethod @classmethod
def _has_directory_changed_since_last_indexing(cls, dir_record): def _has_directory_changed_since_last_indexing(self, dir_record):
if not dir_record.last_indexed_at: if not dir_record.last_indexed_at:
return True return True
return ( return datetime.datetime.fromtimestamp(
datetime.datetime.fromtimestamp(cls._get_last_modify_time(dir_record.path)) self._get_last_modify_time(dir_record.path)) > dir_record.last_indexed_at
> dir_record.last_indexed_at
)
@classmethod @classmethod
def _matches_query(cls, filename, query): def _matches_query(cls, filename, query):
filename = filename.lower() filename = filename.lower()
query_tokens = [ query_tokens = [_.lower() for _ in re.split(
_.lower() for _ in re.split(cls._filename_separators, query.strip()) cls._filename_separators, query.strip())]
]
return all(token in filename for token in query_tokens) for token in query_tokens:
if token not in filename:
return False
return True
@classmethod @classmethod
def _sync_token_records(cls, session, *tokens): def _sync_token_records(cls, session, *tokens):
@ -96,12 +85,9 @@ class LocalMediaSearcher(MediaSearcher):
if not tokens: if not tokens:
return [] return []
records = { records = {record.token: record for record in
record.token: record session.query(MediaToken).filter(
for record in session.query(MediaToken) MediaToken.token.in_(tokens)).all()}
.filter(MediaToken.token.in_(tokens))
.all()
}
for token in tokens: for token in tokens:
if token in records: if token in records:
@ -111,11 +97,13 @@ class LocalMediaSearcher(MediaSearcher):
records[token] = record records[token] = record
session.commit() session.commit()
return session.query(MediaToken).filter(MediaToken.token.in_(tokens)).all() return session.query(MediaToken).filter(
MediaToken.token.in_(tokens)).all()
@classmethod @classmethod
def _get_file_records(cls, dir_record, session): 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): def scan(self, media_dir, session=None, dir_record=None):
""" """
@ -133,19 +121,17 @@ class LocalMediaSearcher(MediaSearcher):
dir_record = self._get_or_create_dir_entry(session, media_dir) dir_record = self._get_or_create_dir_entry(session, media_dir)
if not os.path.isdir(media_dir): if not os.path.isdir(media_dir):
self.logger.info( self.logger.info('Directory {} is no longer accessible, removing it'.
'Directory {} is no longer accessible, removing it'.format(media_dir) format(media_dir))
) session.query(MediaDirectory) \
session.query(MediaDirectory).filter( .filter(MediaDirectory.path == media_dir) \
MediaDirectory.path == media_dir .delete(synchronize_session='fetch')
).delete(synchronize_session='fetch')
return return
stored_file_records = { 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, _, files in os.walk(media_dir): for path, dirs, files in os.walk(media_dir):
for filename in files: for filename in files:
filepath = os.path.join(path, filename) filepath = os.path.join(path, filename)
@ -156,32 +142,26 @@ class LocalMediaSearcher(MediaSearcher):
del stored_file_records[filepath] del stored_file_records[filepath]
continue continue
if not MediaPlugin.is_video_file( if not MediaPlugin.is_video_file(filename) and \
filename not MediaPlugin.is_audio_file(filename):
) and not MediaPlugin.is_audio_file(filename):
continue continue
self.logger.debug('Syncing item {}'.format(filepath)) self.logger.debug('Syncing item {}'.format(filepath))
tokens = [ tokens = [_.lower() for _ in re.split(self._filename_separators,
_.lower() filename.strip())]
for _ in re.split(self._filename_separators, filename.strip())
]
token_records = self._sync_token_records(session, *tokens) 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.add(file_record)
session.commit() session.commit()
file_record = ( file_record = session.query(MediaFile).filter_by(
session.query(MediaFile) directory_id=dir_record.id, path=filepath).one()
.filter_by(directory_id=dir_record.id, path=filepath)
.one()
)
for token_record in token_records: for token_record in token_records:
file_token = MediaFileToken.build( file_token = MediaFileToken.build(file_id=file_record.id,
file_id=file_record.id, token_id=token_record.id token_id=token_record.id)
)
session.add(file_token) session.add(file_token)
# stored_file_records should now only contain the records of the files # stored_file_records should now only contain the records of the files
@ -189,20 +169,15 @@ class LocalMediaSearcher(MediaSearcher):
if stored_file_records: if stored_file_records:
self.logger.info( self.logger.info(
'Removing references to {} deleted media items from {}'.format( 'Removing references to {} deleted media items from {}'.format(
len(stored_file_records), media_dir len(stored_file_records), media_dir))
)
)
session.query(MediaFile).filter( session.query(MediaFile).filter(MediaFile.id.in_(
MediaFile.id.in_([record.id for record in stored_file_records.values()]) [record.id for record in stored_file_records.values()]
).delete(synchronize_session='fetch') )).delete(synchronize_session='fetch')
dir_record.last_indexed_at = datetime.datetime.now() dir_record.last_indexed_at = datetime.datetime.now()
self.logger.info( self.logger.info('Scanned {} in {} seconds'.format(
'Scanned {} in {} seconds'.format( media_dir, int(time.time() - index_start_time)))
media_dir, int(time.time() - index_start_time)
)
)
session.commit() session.commit()
@ -222,30 +197,25 @@ class LocalMediaSearcher(MediaSearcher):
dir_record = self._get_or_create_dir_entry(session, media_dir) dir_record = self._get_or_create_dir_entry(session, media_dir)
if self._has_directory_changed_since_last_indexing(dir_record): if self._has_directory_changed_since_last_indexing(dir_record):
self.logger.info( self.logger.info('{} has changed since last indexing, '.format(
'{} has changed since last indexing, '.format(media_dir) media_dir) + 're-indexing')
+ 're-indexing'
)
self.scan(media_dir, session=session, dir_record=dir_record) self.scan(media_dir, session=session, dir_record=dir_record)
query_tokens = [ query_tokens = [_.lower() for _ in re.split(
_.lower() for _ in re.split(self._filename_separators, query.strip()) self._filename_separators, query.strip())]
]
for file_record in ( for file_record in session.query(MediaFile.path). \
session.query(MediaFile.path) join(MediaFileToken). \
.join(MediaFileToken) join(MediaToken). \
.join(MediaToken) filter(MediaToken.token.in_(query_tokens)). \
.filter(MediaToken.token.in_(query_tokens)) group_by(MediaFile.path). \
.group_by(MediaFile.path) having(func.count(MediaFileToken.token_id) >= len(query_tokens)):
.having(func.count(MediaFileToken.token_id) >= len(query_tokens))
):
if os.path.isfile(file_record.path): if os.path.isfile(file_record.path):
results[file_record.path] = { results[file_record.path] = {
'url': 'file://' + file_record.path, 'url': 'file://' + file_record.path,
'title': os.path.basename(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() return results.values()
@ -253,12 +223,11 @@ class LocalMediaSearcher(MediaSearcher):
# --- Table definitions # --- Table definitions
class MediaDirectory(Base): class MediaDirectory(Base):
"""Models the MediaDirectory table""" """ Models the MediaDirectory table """
__tablename__ = 'MediaDirectory' __tablename__ = 'MediaDirectory'
__table_args__ = {'sqlite_autoincrement': True} __table_args__ = ({'sqlite_autoincrement': True})
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
path = Column(String) path = Column(String)
@ -274,15 +243,14 @@ class MediaDirectory(Base):
class MediaFile(Base): class MediaFile(Base):
"""Models the MediaFile table""" """ Models the MediaFile table """
__tablename__ = 'MediaFile' __tablename__ = 'MediaFile'
__table_args__ = {'sqlite_autoincrement': True} __table_args__ = ({'sqlite_autoincrement': True})
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
directory_id = Column( directory_id = Column(Integer, ForeignKey(
Integer, ForeignKey('MediaDirectory.id', ondelete='CASCADE'), nullable=False 'MediaDirectory.id', ondelete='CASCADE'), nullable=False)
)
path = Column(String, nullable=False, unique=True) path = Column(String, nullable=False, unique=True)
indexed_at = Column(DateTime) indexed_at = Column(DateTime)
@ -297,10 +265,10 @@ class MediaFile(Base):
class MediaToken(Base): class MediaToken(Base):
"""Models the MediaToken table""" """ Models the MediaToken table """
__tablename__ = 'MediaToken' __tablename__ = 'MediaToken'
__table_args__ = {'sqlite_autoincrement': True} __table_args__ = ({'sqlite_autoincrement': True})
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
token = Column(String, nullable=False, unique=True) token = Column(String, nullable=False, unique=True)
@ -314,16 +282,14 @@ class MediaToken(Base):
class MediaFileToken(Base): class MediaFileToken(Base):
"""Models the MediaFileToken table""" """ Models the MediaFileToken table """
__tablename__ = 'MediaFileToken' __tablename__ = 'MediaFileToken'
file_id = Column( file_id = Column(Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'),
Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'), nullable=False nullable=False)
) token_id = Column(Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'),
token_id = Column( nullable=False)
Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'), nullable=False
)
__table_args__ = (PrimaryKeyConstraint(file_id, token_id), {}) __table_args__ = (PrimaryKeyConstraint(file_id, token_id), {})
@ -335,5 +301,4 @@ class MediaFileToken(Base):
record.token_id = token_id record.token_id = token_id
return record return record
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -241,7 +241,7 @@ class NtfyPlugin(RunnablePlugin):
args['headers'] = { args['headers'] = {
'Filename': filename, 'Filename': filename,
**({'X-Title': title} if title else {}), **({'X-Title': title} if title else {}),
**({'X-Click': url} if url else {}), **({'X-Click': click_url} if click_url else {}),
**({'X-Email': email} if email else {}), **({'X-Email': email} if email else {}),
**({'X-Priority': priority} if priority else {}), **({'X-Priority': priority} if priority else {}),
**({'X-Tags': ','.join(tags)} if tags else {}), **({'X-Tags': ','.join(tags)} if tags else {}),
@ -256,7 +256,7 @@ class NtfyPlugin(RunnablePlugin):
'topic': topic, 'topic': topic,
'message': message, 'message': message,
**({'title': title} if title else {}), **({'title': title} if title else {}),
**({'click': url} if url else {}), **({'click': click_url} if click_url else {}),
**({'email': email} if email else {}), **({'email': email} if email else {}),
**({'priority': priority} if priority else {}), **({'priority': priority} if priority else {}),
**({'tags': tags} if tags else {}), **({'tags': tags} if tags else {}),

View File

@ -2,17 +2,13 @@ import asyncio
import aiohttp import aiohttp
from threading import RLock from threading import RLock
from typing import Optional, Dict, List, Union, Iterable from typing import Optional, Dict, List, Union
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 import action
from platypush.plugins.switch import Plugin from platypush.plugins.switch import SwitchPlugin
@manages(Switch, Light) class SmartthingsPlugin(SwitchPlugin):
class SmartthingsPlugin(Plugin):
""" """
Plugin to interact with devices and locations registered to a Samsung SmartThings account. Plugin to interact with devices and locations registered to a Samsung SmartThings account.
@ -22,7 +18,7 @@ class SmartthingsPlugin(Plugin):
""" """
_timeout = aiohttp.ClientTimeout(total=20.0) _timeout = aiohttp.ClientTimeout(total=20.)
def __init__(self, access_token: str, **kwargs): def __init__(self, access_token: str, **kwargs):
""" """
@ -47,27 +43,46 @@ class SmartthingsPlugin(Plugin):
async def _refresh_locations(self, api): async def _refresh_locations(self, api):
self._locations = await api.locations() 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): async def _refresh_devices(self, api):
self._devices = await api.devices() 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): 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_location[location_id] = await api.rooms(location_id=location_id)
self._rooms_by_id.update( self._rooms_by_id.update(**{
**{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_id[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] = { 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): async def _refresh_info(self):
@ -112,7 +127,7 @@ class SmartthingsPlugin(Plugin):
'rooms': { 'rooms': {
room.room_id: self._room_to_dict(room) room.room_id: self._room_to_dict(room)
for room in self._rooms_by_location.get(location.location_id, {}) for room in self._rooms_by_location.get(location.location_id, {})
}, }
} }
@staticmethod @staticmethod
@ -242,18 +257,12 @@ class SmartthingsPlugin(Plugin):
""" """
self.refresh_info() self.refresh_info()
return { return {
'locations': { 'locations': {loc.location_id: self._location_to_dict(loc) for loc in self._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},
},
'devices': {
dev.device_id: self._device_to_dict(dev) for dev in self._devices
},
} }
@action @action
def get_location( def get_location(self, location_id: Optional[str] = None, name: Optional[str] = None) -> dict:
self, location_id: Optional[str] = None, name: Optional[str] = None
) -> dict:
""" """
Get the info of a location by ID or name. Get the info of a location by ID or name.
@ -287,37 +296,20 @@ class SmartthingsPlugin(Plugin):
""" """
assert location_id or name, 'Specify either location_id or name' assert location_id or name, 'Specify either location_id or name'
if ( if location_id not in self._locations_by_id or name not in self._locations_by_name:
location_id not in self._locations_by_id
or name not in self._locations_by_name
):
self.refresh_info() self.refresh_info()
location = self._locations_by_id.get( location = self._locations_by_id.get(location_id, self._locations_by_name.get(name))
location_id, self._locations_by_name.get(name)
)
assert location, 'Location {} not found'.format(location_id or name) assert location, 'Location {} not found'.format(location_id or name)
return self._location_to_dict(location) return self._location_to_dict(location)
def _get_device(self, device: str): def _get_device(self, device: str):
return self._get_devices(device)[0] if device not in self._devices_by_id or device not in self._devices_by_name:
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() self.refresh_info()
devs, missing_devs = get_found_and_missing_devs() device = self._devices_by_id.get(device, self._devices_by_name.get(device))
assert not missing_devs, f'Devices not found: {missing_devs}' assert device, 'Device {} not found'.format(device)
return devs return device
@action @action
def get_device(self, device: str) -> dict: def get_device(self, device: str) -> dict:
@ -348,41 +340,24 @@ class SmartthingsPlugin(Plugin):
device = self._get_device(device) device = self._get_device(device)
return self._device_to_dict(device) return self._device_to_dict(device)
async def _execute( async def _execute(self, device_id: str, capability: str, command, component_id: str, args: Optional[list]):
self,
device_id: str,
capability: str,
command,
component_id: str,
args: Optional[list],
):
import pysmartthings import pysmartthings
async with aiohttp.ClientSession(timeout=self._timeout) as session: async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = pysmartthings.SmartThings(session, self._access_token) api = pysmartthings.SmartThings(session, self._access_token)
device = await api.device(device_id) device = await api.device(device_id)
ret = await device.command( ret = await device.command(component_id=component_id, capability=capability, command=command, args=args)
component_id=component_id,
capability=capability,
command=command,
args=args,
)
assert ( assert ret, 'The command {capability}={command} failed on device {device}'.format(
ret capability=capability, command=command, device=device_id)
), 'The command {capability}={command} failed on device {device}'.format(
capability=capability, command=command, device=device_id
)
@action @action
def execute( def execute(self,
self, device: str,
device: str, capability: str,
capability: str, command,
command, component_id: str = 'main',
component_id: str = 'main', args: Optional[list] = None):
args: Optional[list] = None,
):
""" """
Execute a command on a device. Execute a command on a device.
@ -413,89 +388,16 @@ class SmartthingsPlugin(Plugin):
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
try: try:
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
loop.run_until_complete( loop.run_until_complete(self._execute(
self._execute( device_id=device.device_id, capability=capability, command=command,
device_id=device.device_id, component_id=component_id, args=args))
capability=capability,
command=command,
component_id=component_id,
args=args,
)
)
finally: finally:
loop.stop() loop.stop()
@staticmethod @staticmethod
def _is_light(device): async def _get_device_status(api, device_id: str) -> dict:
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) device = await api.device(device_id)
await device.status.refresh() await device.status.refresh()
self.publish_entities([device]) # type: ignore
return { return {
'device_id': device_id, 'device_id': device_id,
@ -505,7 +407,7 @@ class SmartthingsPlugin(Plugin):
for cap in device.capabilities for cap in device.capabilities
if hasattr(device.status, cap) if hasattr(device.status, cap)
and not callable(getattr(device.status, cap)) and not callable(getattr(device.status, cap))
}, }
} }
async def _refresh_status(self, devices: List[str]) -> List[dict]: async def _refresh_status(self, devices: List[str]) -> List[dict]:
@ -532,9 +434,7 @@ class SmartthingsPlugin(Plugin):
parse_device_id(dev) parse_device_id(dev)
# Fail if some devices haven't been found after refreshing # Fail if some devices haven't been found after refreshing
assert ( assert not missing_device_ids, 'Could not find the following devices: {}'.format(list(missing_device_ids))
not missing_device_ids
), 'Could not find the following devices: {}'.format(list(missing_device_ids))
async with aiohttp.ClientSession(timeout=self._timeout) as session: async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = pysmartthings.SmartThings(session, self._access_token) api = pysmartthings.SmartThings(session, self._access_token)
@ -589,7 +489,7 @@ class SmartthingsPlugin(Plugin):
loop.stop() loop.stop()
@action @action
def on(self, device: str, *_, **__) -> dict: def on(self, device: str, *args, **kwargs) -> dict:
""" """
Turn on a device with ``switch`` capability. Turn on a device with ``switch`` capability.
@ -597,10 +497,11 @@ class SmartthingsPlugin(Plugin):
:return: Device status :return: Device status
""" """
self.execute(device, 'switch', 'on') self.execute(device, 'switch', 'on')
return self.status(device).output[0] # type: ignore # noinspection PyUnresolvedReferences
return self.status(device).output[0]
@action @action
def off(self, device: str, *_, **__) -> dict: def off(self, device: str, *args, **kwargs) -> dict:
""" """
Turn off a device with ``switch`` capability. Turn off a device with ``switch`` capability.
@ -608,10 +509,11 @@ class SmartthingsPlugin(Plugin):
:return: Device status :return: Device status
""" """
self.execute(device, 'switch', 'off') self.execute(device, 'switch', 'off')
return self.status(device).output[0] # type: ignore # noinspection PyUnresolvedReferences
return self.status(device).output[0]
@action @action
def toggle(self, device: str, *args, **__) -> dict: def toggle(self, device: str, *args, **kwargs) -> dict:
""" """
Toggle a device with ``switch`` capability. Toggle a device with ``switch`` capability.
@ -627,28 +529,22 @@ class SmartthingsPlugin(Plugin):
async with aiohttp.ClientSession(timeout=self._timeout) as session: async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = pysmartthings.SmartThings(session, self._access_token) api = pysmartthings.SmartThings(session, self._access_token)
dev = await api.device(device_id) dev = await api.device(device_id)
assert ( assert 'switch' in dev.capabilities, 'The device {} has no switch capability'.format(dev.label)
'switch' in dev.capabilities
), 'The device {} has no switch capability'.format(dev.label)
await dev.status.refresh() await dev.status.refresh()
state = 'off' if dev.status.switch else 'on' state = 'off' if dev.status.switch else 'on'
ret = await dev.command( ret = await dev.command(component_id='main', capability='switch', command=state, args=args)
component_id='main', capability='switch', command=state, args=args
)
assert ret, 'The command switch={state} failed on device {device}'.format( assert ret, 'The command switch={state} failed on device {device}'.format(state=state, device=dev.label)
state=state, device=dev.label return not dev.status.switch
)
with self._refresh_lock: with self._refresh_lock:
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
loop.run_until_complete(_toggle()) state = loop.run_until_complete(_toggle())
device = loop.run_until_complete(self._refresh_status([device_id]))[0] # type: ignore
return { return {
'id': device_id, 'id': device_id,
'name': device['name'], 'name': device.label,
'on': device['switch'], 'on': state,
} }
@property @property
@ -673,7 +569,8 @@ class SmartthingsPlugin(Plugin):
] ]
""" """
devices = self.status().output # type: ignore # noinspection PyUnresolvedReferences
devices = self.status().output
return [ return [
{ {
'name': device['name'], 'name': device['name'],
@ -681,65 +578,8 @@ class SmartthingsPlugin(Plugin):
'on': device['switch'], 'on': device['switch'],
} }
for device in devices for device in devices
if 'switch' in device and not self._is_light(device) if 'switch' in 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: # vim:sw=4:ts=4:et:

View File

@ -1,12 +1,9 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List, Union from typing import List, Union
from platypush.entities import manages
from platypush.entities.switches import Switch
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
@manages(Switch)
class SwitchPlugin(Plugin, ABC): class SwitchPlugin(Plugin, ABC):
""" """
Abstract class for interacting with switch devices Abstract class for interacting with switch devices
@ -49,7 +46,7 @@ class SwitchPlugin(Plugin, ABC):
return devices return devices
@action @action
def status(self, device=None, *_, **__) -> Union[dict, List[dict]]: def status(self, device=None, *args, **kwargs) -> Union[dict, List[dict]]:
""" """
Get the status of all the devices, or filter by device name or ID (alias for :meth:`.switch_status`). Get the status of all the devices, or filter by device name or ID (alias for :meth:`.switch_status`).

View File

@ -1,15 +1,7 @@
from typing import Union, Mapping, List, Collection, Optional from typing import Union, Dict, List
from pyHS100 import ( from pyHS100 import SmartDevice, SmartPlug, SmartBulb, SmartStrip, Discover, SmartDeviceException
SmartDevice,
SmartPlug,
SmartBulb,
SmartStrip,
Discover,
SmartDeviceException,
)
from platypush.entities import Entity
from platypush.plugins import action from platypush.plugins import action
from platypush.plugins.switch import SwitchPlugin from platypush.plugins.switch import SwitchPlugin
@ -28,13 +20,8 @@ class SwitchTplinkPlugin(SwitchPlugin):
_ip_to_dev = {} _ip_to_dev = {}
_alias_to_dev = {} _alias_to_dev = {}
def __init__( def __init__(self, plugs: Union[Dict[str, str], List[str]] = None, bulbs: Union[Dict[str, str], List[str]] = None,
self, strips: Union[Dict[str, str], List[str]] = None, **kwargs):
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 :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. TpLink plugs and you want to save on the scan time.
@ -75,44 +62,19 @@ class SwitchTplinkPlugin(SwitchPlugin):
self._update_devices() self._update_devices()
def _update_devices(self, devices: Optional[Mapping[str, SmartDevice]] = None): def _update_devices(self, devices: Dict[str, SmartDevice] = None):
for (addr, info) in self._static_devices.items(): for (addr, info) in self._static_devices.items():
try: try:
dev = info['type'](addr) dev = info['type'](addr)
self._alias_to_dev[info.get('name', dev.alias)] = dev self._alias_to_dev[info.get('name', dev.alias)] = dev
self._ip_to_dev[addr] = dev self._ip_to_dev[addr] = dev
except SmartDeviceException as e: except SmartDeviceException as e:
self.logger.warning( self.logger.warning('Could not communicate with device {}: {}'.format(addr, str(e)))
'Could not communicate with device {}: {}'.format(addr, str(e))
)
for (ip, dev) in (devices or {}).items(): for (ip, dev) in (devices or {}).items():
self._ip_to_dev[ip] = dev self._ip_to_dev[ip] = dev
self._alias_to_dev[dev.alias] = 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): def _scan(self):
devices = Discover.discover() devices = Discover.discover()
self._update_devices(devices) self._update_devices(devices)
@ -122,9 +84,6 @@ class SwitchTplinkPlugin(SwitchPlugin):
if not use_cache: if not use_cache:
self._scan() self._scan()
if isinstance(device, Entity):
device = device.external_id or device.name
if device in self._ip_to_dev: if device in self._ip_to_dev:
return self._ip_to_dev[device] return self._ip_to_dev[device]
@ -136,15 +95,8 @@ class SwitchTplinkPlugin(SwitchPlugin):
else: else:
raise RuntimeError('Device {} not found'.format(device)) 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 @action
def on(self, device, **_): def on(self, device, **kwargs):
""" """
Turn on a device Turn on a device
@ -153,10 +105,11 @@ class SwitchTplinkPlugin(SwitchPlugin):
""" """
device = self._get_device(device) device = self._get_device(device)
return self._set(device, True) device.turn_on()
return self.status(device)
@action @action
def off(self, device, **_): def off(self, device, **kwargs):
""" """
Turn off a device Turn off a device
@ -165,10 +118,11 @@ class SwitchTplinkPlugin(SwitchPlugin):
""" """
device = self._get_device(device) device = self._get_device(device)
return self._set(device, False) device.turn_off()
return self.status(device)
@action @action
def toggle(self, device, **_): def toggle(self, device, **kwargs):
""" """
Toggle the state of a device (on/off) Toggle the state of a device (on/off)
@ -177,10 +131,12 @@ class SwitchTplinkPlugin(SwitchPlugin):
""" """
device = self._get_device(device) device = self._get_device(device)
return self._set(device, not device.is_on)
@staticmethod if device.is_on:
def _serialize(device: SmartDevice) -> dict: device.turn_off()
else:
device.turn_on()
return { return {
'current_consumption': device.current_consumption(), 'current_consumption': device.current_consumption(),
'id': device.host, 'id': device.host,
@ -193,7 +149,17 @@ class SwitchTplinkPlugin(SwitchPlugin):
@property @property
def switches(self) -> List[dict]: def switches(self) -> List[dict]:
return [self._serialize(dev) for dev in self._scan().values()] 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()
]
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -1,11 +1,9 @@
import contextlib
import ipaddress import ipaddress
from typing import List, Optional from typing import List
from platypush.plugins import action from platypush.plugins import action
from platypush.plugins.switch import SwitchPlugin from platypush.plugins.switch import SwitchPlugin
from platypush.utils.workers import Workers from platypush.utils.workers import Workers
from .lib import WemoRunner from .lib import WemoRunner
from .scanner import Scanner from .scanner import Scanner
@ -18,13 +16,7 @@ class SwitchWemoPlugin(SwitchPlugin):
_default_port = 49153 _default_port = 49153
def __init__( def __init__(self, devices=None, netmask: str = None, port: int = _default_port, **kwargs):
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. :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 This plugin previously used ouimeaux for auto-discovery but it's been dropped because
@ -45,11 +37,8 @@ class SwitchWemoPlugin(SwitchPlugin):
def _init_devices(self, devices): def _init_devices(self, devices):
if devices: if devices:
self._devices.update( self._devices.update(devices if isinstance(devices, dict) else
devices {addr: addr for addr in devices})
if isinstance(devices, dict)
else {addr: addr for addr in devices}
)
else: else:
self._devices = {} self._devices = {}
@ -79,53 +68,37 @@ class SwitchWemoPlugin(SwitchPlugin):
""" """
return [ return [
self.status(device).output # type: ignore self.status(device).output
for device in self._devices.values() for device in self._devices.values()
] ]
def _get_address(self, device: str) -> str: def _get_address(self, device: str) -> str:
if device not in self._addresses: if device not in self._addresses:
with contextlib.suppress(KeyError): try:
return self._devices[device] return self._devices[device]
except KeyError:
pass
return device return device
@action @action
def status(self, device: Optional[str] = None, *_, **__): def status(self, device: str = None, *args, **kwargs):
devices = {device: device} if device else self._devices.copy() devices = {device: device} if device else self._devices.copy()
ret = [ ret = [
{ {
"id": addr, 'id': addr,
"ip": addr, 'ip': addr,
"name": name if name != addr else WemoRunner.get_name(addr), 'name': name if name != addr else WemoRunner.get_name(addr),
"on": WemoRunner.get_state(addr), 'on': WemoRunner.get_state(addr),
} }
for (name, addr) in devices.items() for (name, addr) in devices.items()
] ]
self.publish_entities(ret) # type: ignore
return ret[0] if device else ret 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 @action
def on(self, device: str, **_): def on(self, device: str, **kwargs):
""" """
Turn a switch on Turn a switch on
@ -136,7 +109,7 @@ class SwitchWemoPlugin(SwitchPlugin):
return self.status(device) return self.status(device)
@action @action
def off(self, device: str, **_): def off(self, device: str, **kwargs):
""" """
Turn a switch off Turn a switch off
@ -147,7 +120,7 @@ class SwitchWemoPlugin(SwitchPlugin):
return self.status(device) return self.status(device)
@action @action
def toggle(self, device: str, *_, **__): def toggle(self, device: str, *args, **kwargs):
""" """
Toggle a device on/off state Toggle a device on/off state
@ -178,16 +151,19 @@ class SwitchWemoPlugin(SwitchPlugin):
return WemoRunner.get_name(device) return WemoRunner.get_name(device)
@action @action
def scan(self, netmask: Optional[str] = None): def scan(self, netmask: str = None):
netmask = netmask or self.netmask 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) workers = Workers(10, Scanner, port=self.port)
with workers: with workers:
for addr in ipaddress.IPv4Network(netmask): for addr in ipaddress.IPv4Network(netmask):
workers.put(addr.exploded) 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) self._init_devices(devices)
return self.status() return self.status()

View File

@ -43,21 +43,16 @@ class SwitchbotPlugin(SwitchPlugin):
return url return url
def _run(self, method: str = 'get', *args, device=None, **kwargs): def _run(self, method: str = 'get', *args, device=None, **kwargs):
response = getattr(requests, method)( response = getattr(requests, method)(self._url_for(*args, device=device), headers={
self._url_for(*args, device=device), 'Authorization': self._api_token,
headers={ 'Accept': 'application/json',
'Authorization': self._api_token, 'Content-Type': 'application/json; charset=utf-8',
'Accept': 'application/json', }, **kwargs)
'Content-Type': 'application/json; charset=utf-8',
},
**kwargs,
)
response.raise_for_status() response.raise_for_status()
response = response.json() response = response.json()
assert ( assert response.get('statusCode') == 100, \
response.get('statusCode') == 100 f'Switchbot API request failed: {response.get("statusCode")}: {response.get("message")}'
), f'Switchbot API request failed: {response.get("statusCode")}: {response.get("message")}'
return response.get('body') return response.get('body')
@ -82,20 +77,16 @@ class SwitchbotPlugin(SwitchPlugin):
""" """
devices = self._run('get', 'devices') devices = self._run('get', 'devices')
devices = [ devices = [
DeviceSchema().dump( DeviceSchema().dump({
{ **device,
**device, 'is_virtual': False,
'is_virtual': False, })
}
)
for device in devices.get('deviceList', []) for device in devices.get('deviceList', [])
] + [ ] + [
DeviceSchema().dump( DeviceSchema().dump({
{ **device,
**device, 'is_virtual': True,
'is_virtual': True, })
}
)
for device in devices.get('infraredRemoteList', []) for device in devices.get('infraredRemoteList', [])
] ]
@ -105,44 +96,10 @@ class SwitchbotPlugin(SwitchPlugin):
return devices return devices
def transform_entities(self, devices: List[dict]): def _worker(self, q: queue.Queue, method: str = 'get', *args, device: Optional[dict] = None, **kwargs):
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() schema = DeviceStatusSchema()
try: try:
if ( if method == 'get' and args and args[0] == 'status' and device and device.get('is_virtual'):
method == 'get'
and args
and args[0] == 'status'
and device
and device.get('is_virtual')
):
res = schema.load(device) res = schema.load(device)
else: else:
res = self._run(method, *args, device=device, **kwargs) res = self._run(method, *args, device=device, **kwargs)
@ -164,11 +121,7 @@ class SwitchbotPlugin(SwitchPlugin):
devices = self.devices().output devices = self.devices().output
if device: if device:
device_info = self._get_device(device) device_info = self._get_device(device)
status = ( status = {} if device_info['is_virtual'] else self._run('get', 'status', device=device_info)
{}
if device_info['is_virtual']
else self._run('get', 'status', device=device_info)
)
return { return {
**device_info, **device_info,
**status, **status,
@ -180,7 +133,7 @@ class SwitchbotPlugin(SwitchPlugin):
threading.Thread( threading.Thread(
target=self._worker, target=self._worker,
args=(queues[i], 'get', 'status'), args=(queues[i], 'get', 'status'),
kwargs={'device': dev}, kwargs={'device': dev}
) )
for i, dev in enumerate(devices) for i, dev in enumerate(devices)
] ]
@ -195,17 +148,14 @@ class SwitchbotPlugin(SwitchPlugin):
continue continue
assert not isinstance(response, Exception), str(response) assert not isinstance(response, Exception), str(response)
results.append( results.append({
{ **devices_by_id.get(response.get('id'), {}),
**devices_by_id.get(response.get('id'), {}), **response,
**response, })
}
)
for worker in workers: for worker in workers:
worker.join() worker.join()
self.publish_entities(results) # type: ignore
return results return results
@action @action
@ -250,7 +200,9 @@ class SwitchbotPlugin(SwitchPlugin):
@property @property
def switches(self) -> List[dict]: def switches(self) -> List[dict]:
# noinspection PyUnresolvedReferences # 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 @action
def set_curtain_position(self, device: str, position: int): def set_curtain_position(self, device: str, position: int):
@ -261,16 +213,11 @@ class SwitchbotPlugin(SwitchPlugin):
:param position: An integer between 0 (open) and 100 (closed). :param position: An integer between 0 (open) and 100 (closed).
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'setPosition',
'commands', 'commandType': 'command',
device=device, 'parameter': f'0,ff,{position}',
json={ })
'command': 'setPosition',
'commandType': 'command',
'parameter': f'0,ff,{position}',
},
)
@action @action
def set_humidifier_efficiency(self, device: str, efficiency: Union[int, str]): def set_humidifier_efficiency(self, device: str, efficiency: Union[int, str]):
@ -281,16 +228,11 @@ class SwitchbotPlugin(SwitchPlugin):
:param efficiency: An integer between 0 (open) and 100 (closed) or `auto`. :param efficiency: An integer between 0 (open) and 100 (closed) or `auto`.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'setMode',
'commands', 'commandType': 'command',
device=device, 'parameter': efficiency,
json={ })
'command': 'setMode',
'commandType': 'command',
'parameter': efficiency,
},
)
@action @action
def set_fan_speed(self, device: str, speed: int): def set_fan_speed(self, device: str, speed: int):
@ -304,16 +246,11 @@ class SwitchbotPlugin(SwitchPlugin):
status = self.status(device=device).output status = self.status(device=device).output
mode = status.get('mode') mode = status.get('mode')
swing_range = status.get('swing_range') swing_range = status.get('swing_range')
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'set',
'commands', 'commandType': 'command',
device=device, 'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
json={ })
'command': 'set',
'commandType': 'command',
'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
},
)
@action @action
def set_fan_mode(self, device: str, mode: int): def set_fan_mode(self, device: str, mode: int):
@ -327,16 +264,11 @@ class SwitchbotPlugin(SwitchPlugin):
status = self.status(device=device).output status = self.status(device=device).output
speed = status.get('speed') speed = status.get('speed')
swing_range = status.get('swing_range') swing_range = status.get('swing_range')
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'set',
'commands', 'commandType': 'command',
device=device, 'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
json={ })
'command': 'set',
'commandType': 'command',
'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
},
)
@action @action
def set_swing_range(self, device: str, swing_range: int): def set_swing_range(self, device: str, swing_range: int):
@ -350,16 +282,11 @@ class SwitchbotPlugin(SwitchPlugin):
status = self.status(device=device).output status = self.status(device=device).output
speed = status.get('speed') speed = status.get('speed')
mode = status.get('mode') mode = status.get('mode')
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'set',
'commands', 'commandType': 'command',
device=device, 'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
json={ })
'command': 'set',
'commandType': 'command',
'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
},
)
@action @action
def set_temperature(self, device: str, temperature: float): def set_temperature(self, device: str, temperature: float):
@ -373,18 +300,11 @@ class SwitchbotPlugin(SwitchPlugin):
status = self.status(device=device).output status = self.status(device=device).output
mode = status.get('mode') mode = status.get('mode')
fan_speed = status.get('fan_speed') fan_speed = status.get('fan_speed')
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'setAll',
'commands', 'commandType': 'command',
device=device, 'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']),
json={ })
'command': 'setAll',
'commandType': 'command',
'parameter': ','.join(
[str(temperature), str(mode), str(fan_speed), 'on']
),
},
)
@action @action
def set_ac_mode(self, device: str, mode: int): def set_ac_mode(self, device: str, mode: int):
@ -405,18 +325,11 @@ class SwitchbotPlugin(SwitchPlugin):
status = self.status(device=device).output status = self.status(device=device).output
temperature = status.get('temperature') temperature = status.get('temperature')
fan_speed = status.get('fan_speed') fan_speed = status.get('fan_speed')
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'setAll',
'commands', 'commandType': 'command',
device=device, 'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']),
json={ })
'command': 'setAll',
'commandType': 'command',
'parameter': ','.join(
[str(temperature), str(mode), str(fan_speed), 'on']
),
},
)
@action @action
def set_ac_fan_speed(self, device: str, fan_speed: int): def set_ac_fan_speed(self, device: str, fan_speed: int):
@ -436,18 +349,11 @@ class SwitchbotPlugin(SwitchPlugin):
status = self.status(device=device).output status = self.status(device=device).output
temperature = status.get('temperature') temperature = status.get('temperature')
mode = status.get('mode') mode = status.get('mode')
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'setAll',
'commands', 'commandType': 'command',
device=device, 'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']),
json={ })
'command': 'setAll',
'commandType': 'command',
'parameter': ','.join(
[str(temperature), str(mode), str(fan_speed), 'on']
),
},
)
@action @action
def set_channel(self, device: str, channel: int): def set_channel(self, device: str, channel: int):
@ -458,16 +364,11 @@ class SwitchbotPlugin(SwitchPlugin):
:param channel: Channel number. :param channel: Channel number.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'SetChannel',
'commands', 'commandType': 'command',
device=device, 'parameter': [str(channel)],
json={ })
'command': 'SetChannel',
'commandType': 'command',
'parameter': [str(channel)],
},
)
@action @action
def volup(self, device: str): def volup(self, device: str):
@ -477,15 +378,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'volumeAdd',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'volumeAdd',
'commandType': 'command',
},
)
@action @action
def voldown(self, device: str): def voldown(self, device: str):
@ -495,15 +391,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'volumeSub',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'volumeSub',
'commandType': 'command',
},
)
@action @action
def mute(self, device: str): def mute(self, device: str):
@ -513,15 +404,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'setMute',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'setMute',
'commandType': 'command',
},
)
@action @action
def channel_next(self, device: str): def channel_next(self, device: str):
@ -531,15 +417,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'channelAdd',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'channelAdd',
'commandType': 'command',
},
)
@action @action
def channel_prev(self, device: str): def channel_prev(self, device: str):
@ -549,15 +430,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'channelSub',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'channelSub',
'commandType': 'command',
},
)
@action @action
def play(self, device: str): def play(self, device: str):
@ -567,15 +443,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'Play',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'Play',
'commandType': 'command',
},
)
@action @action
def pause(self, device: str): def pause(self, device: str):
@ -585,15 +456,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'Pause',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'Pause',
'commandType': 'command',
},
)
@action @action
def stop(self, device: str): def stop(self, device: str):
@ -603,15 +469,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'Stop',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'Stop',
'commandType': 'command',
},
)
@action @action
def forward(self, device: str): def forward(self, device: str):
@ -621,15 +482,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'FastForward',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'FastForward',
'commandType': 'command',
},
)
@action @action
def back(self, device: str): def back(self, device: str):
@ -639,15 +495,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'Rewind',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'Rewind',
'commandType': 'command',
},
)
@action @action
def next(self, device: str): def next(self, device: str):
@ -657,15 +508,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'Next',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'Next',
'commandType': 'command',
},
)
@action @action
def previous(self, device: str): def previous(self, device: str):
@ -675,15 +521,10 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run('post', 'commands', device=device, json={
'post', 'command': 'Previous',
'commands', 'commandType': 'command',
device=device, })
json={
'command': 'Previous',
'commandType': 'command',
},
)
@action @action
def scenes(self) -> List[dict]: def scenes(self) -> List[dict]:
@ -703,8 +544,7 @@ class SwitchbotPlugin(SwitchPlugin):
""" """
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
scenes = [ scenes = [
s s for s in self.scenes().output
for s in self.scenes().output
if s.get('id') == scene or s.get('name') == scene if s.get('id') == scene or s.get('name') == scene
] ]

View File

@ -1,6 +1,6 @@
import enum import enum
import time import time
from typing import List, Optional from typing import List
from platypush.message.response.bluetooth import BluetoothScanResponse from platypush.message.response.bluetooth import BluetoothScanResponse
from platypush.plugins import action from platypush.plugins import action
@ -8,9 +8,7 @@ from platypush.plugins.bluetooth.ble import BluetoothBlePlugin
from platypush.plugins.switch import SwitchPlugin from platypush.plugins.switch import SwitchPlugin
class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init] class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/missing-call-to-init]
SwitchPlugin, BluetoothBlePlugin
):
""" """
Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and
programmatically control switches over a Bluetooth interface. programmatically control switches over a Bluetooth interface.
@ -33,7 +31,6 @@ class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init]
""" """
Base64 encoded commands Base64 encoded commands
""" """
# \x57\x01\x00 # \x57\x01\x00
PRESS = 'VwEA' PRESS = 'VwEA'
# # \x57\x01\x01 # # \x57\x01\x01
@ -41,14 +38,8 @@ class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init]
# # \x57\x01\x02 # # \x57\x01\x02
OFF = 'VwEC' OFF = 'VwEC'
def __init__( def __init__(self, interface=None, connect_timeout=None,
self, scan_timeout=2, devices=None, **kwargs):
interface=None,
connect_timeout=None,
scan_timeout=2,
devices=None,
**kwargs
):
""" """
:param interface: Bluetooth interface to use (e.g. hci0) default: first available one :param interface: Bluetooth interface to use (e.g. hci0) default: first available one
:type interface: str :type interface: str
@ -68,21 +59,17 @@ class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init]
self.scan_timeout = scan_timeout if scan_timeout else 2 self.scan_timeout = scan_timeout if scan_timeout else 2
self.configured_devices = devices or {} self.configured_devices = devices or {}
self.configured_devices_by_name = { 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): def _run(self, device: str, command: Command):
device = self.configured_devices_by_name.get(device, '') if device in self.configured_devices_by_name:
device = self.configured_devices_by_name[device]
n_tries = 1 n_tries = 1
try: try:
self.write( self.write(device, command.value, handle=self.handle, channel_type='random', binary=True)
device,
command.value,
handle=self.handle,
channel_type='random',
binary=True,
)
except Exception as e: except Exception as e:
self.logger.exception(e) self.logger.exception(e)
n_tries -= 1 n_tries -= 1
@ -91,7 +78,7 @@ class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init]
raise e raise e
time.sleep(5) time.sleep(5)
return self.status(device) # type: ignore return self.status(device)
@action @action
def press(self, device): def press(self, device):
@ -104,11 +91,11 @@ class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init]
return self._run(device, self.Command.PRESS) return self._run(device, self.Command.PRESS)
@action @action
def toggle(self, device, **_): def toggle(self, device, **kwargs):
return self.press(device) return self.press(device)
@action @action
def on(self, device, **_): def on(self, device, **kwargs):
""" """
Send a press-on button command to a device Send a press-on button command to a device
@ -118,7 +105,7 @@ class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init]
return self._run(device, self.Command.ON) return self._run(device, self.Command.ON)
@action @action
def off(self, device, **_): def off(self, device, **kwargs):
""" """
Send a press-off button command to a device Send a press-off button command to a device
@ -128,9 +115,7 @@ class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init]
return self._run(device, self.Command.OFF) return self._run(device, self.Command.OFF)
@action @action
def scan( def scan(self, interface: str = None, duration: int = 10) -> BluetoothScanResponse:
self, interface: Optional[str] = None, duration: int = 10
) -> BluetoothScanResponse:
""" """
Scan for available Switchbot devices nearby. Scan for available Switchbot devices nearby.
@ -144,13 +129,9 @@ class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init]
for dev in devices: for dev in devices:
try: try:
characteristics = [ characteristics = [
chrc chrc for chrc in self.discover_characteristics(
for chrc in self.discover_characteristics( dev['addr'], channel_type='random', wait=False,
dev['addr'], timeout=self.scan_timeout).characteristics
channel_type='random',
wait=False,
timeout=self.scan_timeout,
).characteristics
if chrc.get('uuid') == self.uuid if chrc.get('uuid') == self.uuid
] ]
@ -159,12 +140,10 @@ class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init]
except Exception as e: except Exception as e:
self.logger.warning('Device scan error', e) self.logger.warning('Device scan error', e)
self.publish_entities(compatible_devices) # type: ignore
return BluetoothScanResponse(devices=compatible_devices) return BluetoothScanResponse(devices=compatible_devices)
@property @property
def switches(self) -> List[dict]: def switches(self) -> List[dict]:
self.publish_entities(self.configured_devices) # type: ignore
return [ return [
{ {
'address': addr, 'address': addr,
@ -175,20 +154,5 @@ class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init]
for addr, name in self.configured_devices.items() 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: # vim:sw=4:ts=4:et:

View File

@ -4,16 +4,12 @@ import threading
from queue import Queue from queue import Queue
from typing import Optional, List, Any, Dict, Union 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.message.response import Response
from platypush.plugins.mqtt import MqttPlugin, action from platypush.plugins.mqtt import MqttPlugin, action
from platypush.plugins.switch import SwitchPlugin
@manages(Light, Switch) class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-init]
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 This plugin allows you to interact with Zigbee devices over MQTT through any Zigbee sniffer and
`zigbee2mqtt <https://www.zigbee2mqtt.io/>`_. `zigbee2mqtt <https://www.zigbee2mqtt.io/>`_.
@ -39,8 +35,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
.. code-block:: shell .. code-block:: shell
wget https://github.com/Koenkk/Z-Stack-firmware/raw/master\ wget https://github.com/Koenkk/Z-Stack-firmware/raw/master/coordinator/Z-Stack_Home_1.2/bin/default/CC2531_DEFAULT_20201127.zip
/coordinator/Z-Stack_Home_1.2/bin/default/CC2531_DEFAULT_20201127.zip
unzip CC2531_DEFAULT_20201127.zip unzip CC2531_DEFAULT_20201127.zip
[sudo] cc-tool -e -w CC2531ZNP-Prod.hex [sudo] cc-tool -e -w CC2531ZNP-Prod.hex
@ -83,8 +78,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
configured your network, to prevent accidental/malignant joins from outer Zigbee devices. configured your network, to prevent accidental/malignant joins from outer Zigbee devices.
- Start the ``zigbee2mqtt`` daemon on your device (the - Start the ``zigbee2mqtt`` daemon on your device (the
`official documentation <https://www.zigbee2mqtt.io/getting_started `official documentation <https://www.zigbee2mqtt.io/getting_started/running_zigbee2mqtt.html#5-optional-running-as-a-daemon-with-systemctl>`_
/running_zigbee2mqtt.html#5-optional-running-as-a-daemon-with-systemctl>`_
also contains instructions on how to configure it as a ``systemd`` service: also contains instructions on how to configure it as a ``systemd`` service:
.. code-block:: shell .. code-block:: shell
@ -109,20 +103,10 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
""" """
def __init__( def __init__(self, host: str = 'localhost', port: int = 1883, base_topic: str = 'zigbee2mqtt', timeout: int = 10,
self, tls_certfile: Optional[str] = None, tls_keyfile: Optional[str] = None,
host: str = 'localhost', tls_version: Optional[str] = None, tls_ciphers: Optional[str] = None,
port: int = 1883, username: Optional[str] = None, password: Optional[str] = None, **kwargs):
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 host: Default MQTT broker where ``zigbee2mqtt`` publishes its messages (default: ``localhost``).
:param port: Broker listen port (default: 1883). :param port: Broker listen port (default: 1883).
@ -140,80 +124,17 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
:param username: If the connection requires user authentication, specify the username (default: None) :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) :param password: If the connection requires user authentication, specify the password (default: None)
""" """
super().__init__( super().__init__(host=host, port=port, tls_certfile=tls_certfile, tls_keyfile=tls_keyfile,
host=host, tls_version=tls_version, tls_ciphers=tls_ciphers, username=username,
port=port, password=password, **kwargs)
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.base_topic = base_topic
self.timeout = timeout self.timeout = timeout
self._info = { self._info = {
'devices': {}, 'devices': {},
'groups': {}, '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): def _get_network_info(self, **kwargs):
self.logger.info('Fetching Zigbee network information') self.logger.info('Fetching Zigbee network information')
client = None client = None
@ -236,11 +157,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
def callback(_, __, msg): def callback(_, __, msg):
topic = msg.topic.split('/')[-1] topic = msg.topic.split('/')[-1]
if topic in info: if topic in info:
info[topic] = ( info[topic] = msg.payload.decode() if topic == 'state' else json.loads(msg.payload.decode())
msg.payload.decode()
if topic == 'state'
else json.loads(msg.payload.decode())
)
info_ready_events[topic].set() info_ready_events[topic].set()
return callback return callback
@ -257,9 +174,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
for event in info_ready_events.values(): for event in info_ready_events.values():
info_ready = event.wait(timeout=timeout) info_ready = event.wait(timeout=timeout)
if not info_ready: if not info_ready:
raise TimeoutError( raise TimeoutError('A timeout occurred while fetching the Zigbee network information')
'A timeout occurred while fetching the Zigbee network information'
)
# Cache the new results # Cache the new results
self._info['devices'] = { self._info['devices'] = {
@ -267,12 +182,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
for device in info.get('devices', []) for device in info.get('devices', [])
} }
self._info['devices_by_addr'] = {
device['ieee_address']: device for device in info.get('devices', [])
}
self._info['groups'] = { 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') self.logger.info('Zigbee network configuration updated')
@ -282,9 +194,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
client.loop_stop() client.loop_stop()
client.disconnect() client.disconnect()
except Exception as e: except Exception as e:
self.logger.warning( self.logger.warning('Error on MQTT client disconnection: {}'.format(str(e)))
'Error on MQTT client disconnection: {}'.format(str(e))
)
def _topic(self, topic): def _topic(self, topic):
return self.base_topic + '/' + topic return self.base_topic + '/' + topic
@ -294,9 +204,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
if isinstance(response, Response): if isinstance(response, Response):
response = response.output response = response.output
assert response.get('status') != 'error', response.get( assert response.get('status') != 'error', response.get('error', 'zigbee2mqtt error')
'error', 'zigbee2mqtt error'
)
return response return response
@action @action
@ -383,7 +291,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
"value_min": 150 "value_min": 150
}, },
{ {
"description": "Color of this light in the XY space", "description": "Color of this light in the CIE 1931 color space (x/y)",
"features": [ "features": [
{ {
"access": 7, "access": 7,
@ -407,7 +315,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
}, },
{ {
"access": 2, "access": 2,
"description": "Triggers an effect on the light", "description": "Triggers an effect on the light (e.g. make light blink for a few seconds)",
"name": "effect", "name": "effect",
"property": "effect", "property": "effect",
"type": "enum", "type": "enum",
@ -474,9 +382,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
return self._get_network_info(**kwargs).get('devices') return self._get_network_info(**kwargs).get('devices')
@action @action
def permit_join( def permit_join(self, permit: bool = True, timeout: Optional[float] = None, **kwargs):
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 Enable/disable devices from joining the network. This is not persistent (will not be saved to
``configuration.yaml``). ``configuration.yaml``).
@ -488,19 +394,14 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
""" """
if timeout: if timeout:
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/permit_join'),
topic=self._topic('bridge/request/permit_join'), msg={'value': permit, 'time': timeout},
msg={'value': permit, 'time': timeout}, reply_topic=self._topic('bridge/response/permit_join'),
reply_topic=self._topic('bridge/response/permit_join'), **self._mqtt_args(**kwargs)))
**self._mqtt_args(**kwargs),
)
)
return self.publish( return self.publish(topic=self._topic('bridge/request/permit_join'),
topic=self._topic('bridge/request/permit_join'), msg={'value': permit},
msg={'value': permit}, **self._mqtt_args(**kwargs))
**self._mqtt_args(**kwargs),
)
@action @action
def factory_reset(self, **kwargs): def factory_reset(self, **kwargs):
@ -512,11 +413,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
(default: query the default configured device). (default: query the default configured device).
""" """
self.publish( self.publish(topic=self._topic('bridge/request/touchlink/factory_reset'), msg='', **self._mqtt_args(**kwargs))
topic=self._topic('bridge/request/touchlink/factory_reset'),
msg='',
**self._mqtt_args(**kwargs),
)
@action @action
def log_level(self, level: str, **kwargs): def log_level(self, level: str, **kwargs):
@ -528,13 +425,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device). (default: query the default configured device).
""" """
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/config/log_level'), msg={'value': level},
topic=self._topic('bridge/request/config/log_level'), reply_topic=self._topic('bridge/response/config/log_level'),
msg={'value': level}, **self._mqtt_args(**kwargs)))
reply_topic=self._topic('bridge/response/config/log_level'),
**self._mqtt_args(**kwargs),
)
)
@action @action
def device_set_option(self, device: str, option: str, value: Any, **kwargs): def device_set_option(self, device: str, option: str, value: Any, **kwargs):
@ -548,18 +441,14 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device). (default: query the default configured device).
""" """
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/device/options'),
topic=self._topic('bridge/request/device/options'), reply_topic=self._topic('bridge/response/device/options'),
reply_topic=self._topic('bridge/response/device/options'), msg={
msg={ 'id': device,
'id': device, 'options': {
'options': { option: value,
option: value, }
}, }, **self._mqtt_args(**kwargs)))
},
**self._mqtt_args(**kwargs),
)
)
@action @action
def device_remove(self, device: str, force: bool = False, **kwargs): def device_remove(self, device: str, force: bool = False, **kwargs):
@ -574,13 +463,10 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device). (default: query the default configured device).
""" """
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/device/remove'),
topic=self._topic('bridge/request/device/remove'), msg={'id': device, 'force': force},
msg={'id': device, 'force': force}, reply_topic=self._topic('bridge/response/device/remove'),
reply_topic=self._topic('bridge/response/device/remove'), **self._mqtt_args(**kwargs)))
**self._mqtt_args(**kwargs),
)
)
@action @action
def device_ban(self, device: str, **kwargs): def device_ban(self, device: str, **kwargs):
@ -592,13 +478,10 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device). (default: query the default configured device).
""" """
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/device/ban'),
topic=self._topic('bridge/request/device/ban'), reply_topic=self._topic('bridge/response/device/ban'),
reply_topic=self._topic('bridge/response/device/ban'), msg={'id': device},
msg={'id': device}, **self._mqtt_args(**kwargs)))
**self._mqtt_args(**kwargs),
)
)
@action @action
def device_whitelist(self, device: str, **kwargs): def device_whitelist(self, device: str, **kwargs):
@ -611,13 +494,10 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device). (default: query the default configured device).
""" """
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/device/whitelist'),
topic=self._topic('bridge/request/device/whitelist'), reply_topic=self._topic('bridge/response/device/whitelist'),
reply_topic=self._topic('bridge/response/device/whitelist'), msg={'id': device},
msg={'id': device}, **self._mqtt_args(**kwargs)))
**self._mqtt_args(**kwargs),
)
)
@action @action
def device_rename(self, name: str, device: Optional[str] = None, **kwargs): def device_rename(self, name: str, device: Optional[str] = None, **kwargs):
@ -636,9 +516,8 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
devices = self.devices().output devices = self.devices().output
assert not [ assert not [dev for dev in devices if dev.get('friendly_name') == name], \
dev for dev in devices if dev.get('friendly_name') == name 'A device named {} already exists on the network'.format(name)
], 'A device named {} already exists on the network'.format(name)
if device: if device:
req = { req = {
@ -652,13 +531,10 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
} }
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/device/rename'),
topic=self._topic('bridge/request/device/rename'), msg=req,
msg=req, reply_topic=self._topic('bridge/response/device/rename'),
reply_topic=self._topic('bridge/response/device/rename'), **self._mqtt_args(**kwargs)))
**self._mqtt_args(**kwargs),
)
)
@staticmethod @staticmethod
def build_device_get_request(values: List[Dict[str, Any]]) -> dict: def build_device_get_request(values: List[Dict[str, Any]]) -> dict:
@ -685,16 +561,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
return ret 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 # noinspection PyShadowingBuiltins
@action @action
def device_get( def device_get(self, device: str, property: Optional[str] = None, **kwargs) -> Dict[str, Any]:
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 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 may have the "``state``" and "``brightness``" properties, while an environment sensor may have the
@ -707,59 +576,28 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
:return: Key->value map of the device properties. :return: Key->value map of the device properties.
""" """
kwargs = self._mqtt_args(**kwargs) 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: if property:
properties = self.publish( properties = self.publish(topic=self._topic(device) + '/get/' + property, reply_topic=self._topic(device),
topic=self._topic(device) + f'/get/{property}', msg={property: ''}, **kwargs).output
reply_topic=self._topic(device),
msg={property: ''},
**kwargs,
).output
assert property in properties, f'No such property: {property}' assert property in properties, 'No such property: ' + property
return {property: properties[property]} return {property: properties[property]}
if device not in self._info.get('devices', {}): if device not in self._info.get('devices', {}):
# Refresh devices info # Refresh devices info
self._get_network_info(**kwargs) self._get_network_info(**kwargs)
dev = self._info.get('devices', {}).get( assert self._info.get('devices', {}).get(device), 'No such device: ' + device
device, self._info.get('devices_by_addr', {}).get(device) exposes = (self._info.get('devices', {}).get(device, {}).get('definition', {}) or {}).get('exposes', [])
)
assert dev, f'No such device: {device}'
exposes = (
self._info.get('devices', {}).get(device, {}).get('definition', {}) or {}
).get('exposes', [])
if not exposes: if not exposes:
return {} return {}
device_state = self.publish( return self.publish(topic=self._topic(device) + '/get', reply_topic=self._topic(device),
topic=self._topic(device) + '/get', msg=self.build_device_get_request(exposes), **kwargs)
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 @action
def devices_get( def devices_get(self, devices: Optional[List[str]] = None, **kwargs) -> Dict[str, dict]:
self, devices: Optional[List[str]] = None, **kwargs
) -> Dict[str, dict]:
""" """
Get the properties of the devices connected to the network. Get the properties of the devices connected to the network.
@ -784,12 +622,14 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
kwargs = self._mqtt_args(**kwargs) kwargs = self._mqtt_args(**kwargs)
if not devices: if not devices:
devices = { # noinspection PyUnresolvedReferences
devices = set([
device['friendly_name'] or device['ieee_address'] device['friendly_name'] or device['ieee_address']
for device in self.devices(**kwargs).output for device in self.devices(**kwargs).output
} ])
def worker(device: str, q: Queue): def worker(device: str, q: Queue):
# noinspection PyUnresolvedReferences
q.put(self.device_get(device, **kwargs).output) q.put(self.device_get(device, **kwargs).output)
queues = {} queues = {}
@ -798,9 +638,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
for device in devices: for device in devices:
queues[device] = Queue() queues[device] = Queue()
workers[device] = threading.Thread( workers[device] = threading.Thread(target=worker, args=(device, queues[device]))
target=worker, args=(device, queues[device])
)
workers[device].start() workers[device].start()
for device in devices: for device in devices:
@ -808,11 +646,8 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
response[device] = queues[device].get(timeout=kwargs.get('timeout')) response[device] = queues[device].get(timeout=kwargs.get('timeout'))
workers[device].join(timeout=kwargs.get('timeout')) workers[device].join(timeout=kwargs.get('timeout'))
except Exception as e: except Exception as e:
self.logger.warning( self.logger.warning('An error while getting the status of the device {}: {}'.format(
'An error while getting the status of the device {}: {}'.format( device, str(e)))
device, str(e)
)
)
return response return response
@ -823,7 +658,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
:param device: Device friendly name (default: get all devices). :param device: Device friendly name (default: get all devices).
""" """
return self.devices_get([device] if device else None, *args, **kwargs) return self.devices_get([device], *args, **kwargs)
# noinspection PyShadowingBuiltins,DuplicatedCode # noinspection PyShadowingBuiltins,DuplicatedCode
@action @action
@ -839,12 +674,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
(default: query the default configured device). (default: query the default configured device).
""" """
properties = self.publish( properties = self.publish(topic=self._topic(device + '/set'),
topic=self._topic(device + '/set'), reply_topic=self._topic(device),
reply_topic=self._topic(device), msg={property: value}, **self._mqtt_args(**kwargs)).output
msg={property: value},
**self._mqtt_args(**kwargs),
).output
if property: if property:
assert property in properties, 'No such property: ' + property assert property in properties, 'No such property: ' + property
@ -873,13 +705,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
""" """
ret = self._parse_response( ret = self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/device/ota_update/check'),
topic=self._topic('bridge/request/device/ota_update/check'), reply_topic=self._topic('bridge/response/device/ota_update/check'),
reply_topic=self._topic('bridge/response/device/ota_update/check'), msg={'id': device}, **self._mqtt_args(**kwargs)))
msg={'id': device},
**self._mqtt_args(**kwargs),
)
)
return { return {
'status': ret['status'], 'status': ret['status'],
@ -897,13 +725,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device). (default: query the default configured device).
""" """
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/device/ota_update/update'),
topic=self._topic('bridge/request/device/ota_update/update'), reply_topic=self._topic('bridge/response/device/ota_update/update'),
reply_topic=self._topic('bridge/response/device/ota_update/update'), msg={'id': device}, **self._mqtt_args(**kwargs)))
msg={'id': device},
**self._mqtt_args(**kwargs),
)
)
@action @action
def groups(self, **kwargs) -> List[dict]: def groups(self, **kwargs) -> List[dict]:
@ -1059,22 +883,16 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
(default: query the default configured device). (default: query the default configured device).
""" """
payload = ( payload = name if id is None else {
name 'id': id,
if id is None 'friendly_name': name,
else { }
'id': id,
'friendly_name': name,
}
)
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/group/add'),
topic=self._topic('bridge/request/group/add'), reply_topic=self._topic('bridge/response/group/add'),
reply_topic=self._topic('bridge/response/group/add'), msg=payload,
msg=payload, **self._mqtt_args(**kwargs))
**self._mqtt_args(**kwargs),
)
) )
@action @action
@ -1093,12 +911,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
if property: if property:
msg = {property: ''} msg = {property: ''}
properties = self.publish( properties = self.publish(topic=self._topic(group + '/get'),
topic=self._topic(group + '/get'), reply_topic=self._topic(group),
reply_topic=self._topic(group), msg=msg, **self._mqtt_args(**kwargs)).output
msg=msg,
**self._mqtt_args(**kwargs),
).output
if property: if property:
assert property in properties, 'No such property: ' + property assert property in properties, 'No such property: ' + property
@ -1120,12 +935,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
(default: query the default configured device). (default: query the default configured device).
""" """
properties = self.publish( properties = self.publish(topic=self._topic(group + '/set'),
topic=self._topic(group + '/set'), reply_topic=self._topic(group),
reply_topic=self._topic(group), msg={property: value}, **self._mqtt_args(**kwargs)).output
msg={property: value},
**self._mqtt_args(**kwargs),
).output
if property: if property:
assert property in properties, 'No such property: ' + property assert property in properties, 'No such property: ' + property
@ -1149,18 +961,13 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
groups = {group.get('friendly_name'): group for group in self.groups().output} groups = {group.get('friendly_name'): group for group in self.groups().output}
assert ( assert name not in groups, 'A group named {} already exists on the network'.format(name)
name not in groups
), 'A group named {} already exists on the network'.format(name)
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/group/rename'),
topic=self._topic('bridge/request/group/rename'), reply_topic=self._topic('bridge/response/group/rename'),
reply_topic=self._topic('bridge/response/group/rename'), msg={'from': group, 'to': name} if group else name,
msg={'from': group, 'to': name} if group else name, **self._mqtt_args(**kwargs)))
**self._mqtt_args(**kwargs),
)
)
@action @action
def group_remove(self, name: str, **kwargs): def group_remove(self, name: str, **kwargs):
@ -1172,13 +979,10 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device). (default: query the default configured device).
""" """
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/group/remove'),
topic=self._topic('bridge/request/group/remove'), reply_topic=self._topic('bridge/response/group/remove'),
reply_topic=self._topic('bridge/response/group/remove'), msg=name,
msg=name, **self._mqtt_args(**kwargs)))
**self._mqtt_args(**kwargs),
)
)
@action @action
def group_add_device(self, group: str, device: str, **kwargs): def group_add_device(self, group: str, device: str, **kwargs):
@ -1191,16 +995,12 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device). (default: query the default configured device).
""" """
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/group/members/add'),
topic=self._topic('bridge/request/group/members/add'), reply_topic=self._topic('bridge/response/group/members/add'),
reply_topic=self._topic('bridge/response/group/members/add'), msg={
msg={ 'group': group,
'group': group, 'device': device,
'device': device, }, **self._mqtt_args(**kwargs)))
},
**self._mqtt_args(**kwargs),
)
)
@action @action
def group_remove_device(self, group: str, device: Optional[str] = None, **kwargs): def group_remove_device(self, group: str, device: Optional[str] = None, **kwargs):
@ -1215,23 +1015,13 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
""" """
return self._parse_response( return self._parse_response(
self.publish( self.publish(
topic=self._topic( topic=self._topic('bridge/request/group/members/remove{}'.format('_all' if device is None else '')),
'bridge/request/group/members/remove{}'.format(
'_all' if device is None else ''
)
),
reply_topic=self._topic( reply_topic=self._topic(
'bridge/response/group/members/remove{}'.format( 'bridge/response/group/members/remove{}'.format('_all' if device is None else '')),
'_all' if device is None else ''
)
),
msg={ msg={
'group': group, 'group': group,
'device': device, 'device': device,
}, }, **self._mqtt_args(**kwargs)))
**self._mqtt_args(**kwargs),
)
)
@action @action
def bind_devices(self, source: str, target: str, **kwargs): def bind_devices(self, source: str, target: str, **kwargs):
@ -1250,13 +1040,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device). (default: query the default configured device).
""" """
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/device/bind'),
topic=self._topic('bridge/request/device/bind'), reply_topic=self._topic('bridge/response/device/bind'),
reply_topic=self._topic('bridge/response/device/bind'), msg={'from': source, 'to': target}, **self._mqtt_args(**kwargs)))
msg={'from': source, 'to': target},
**self._mqtt_args(**kwargs),
)
)
@action @action
def unbind_devices(self, source: str, target: str, **kwargs): def unbind_devices(self, source: str, target: str, **kwargs):
@ -1271,13 +1057,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device). (default: query the default configured device).
""" """
return self._parse_response( return self._parse_response(
self.publish( self.publish(topic=self._topic('bridge/request/device/unbind'),
topic=self._topic('bridge/request/device/unbind'), reply_topic=self._topic('bridge/response/device/unbind'),
reply_topic=self._topic('bridge/response/device/unbind'), msg={'from': source, 'to': target}, **self._mqtt_args(**kwargs)))
msg={'from': source, 'to': target},
**self._mqtt_args(**kwargs),
)
)
@action @action
def on(self, device, *args, **kwargs) -> dict: def on(self, device, *args, **kwargs) -> dict:
@ -1285,15 +1067,10 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.on` and turns on a Zigbee device with a writable Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.on` and turns on a Zigbee device with a writable
binary property. binary property.
""" """
switch_info = self._get_switch_info(device) switch_info = self._get_switches_info().get(device)
assert switch_info, '{} is not a valid switch'.format(device) assert switch_info, '{} is not a valid switch'.format(device)
device = switch_info.get('friendly_name') or switch_info['ieee_address'] props = self.device_set(device, switch_info['property'], switch_info['value_on']).output
props = self.device_set( return self._properties_to_switch(device=device, props=props, switch_info=switch_info)
device, switch_info['property'], switch_info['value_on']
).output
return self._properties_to_switch(
device=device, props=props, switch_info=switch_info
)
@action @action
def off(self, device, *args, **kwargs) -> dict: def off(self, device, *args, **kwargs) -> dict:
@ -1301,15 +1078,10 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.off` and turns off a Zigbee device with a Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.off` and turns off a Zigbee device with a
writable binary property. writable binary property.
""" """
switch_info = self._get_switch_info(device) switch_info = self._get_switches_info().get(device)
assert switch_info, '{} is not a valid switch'.format(device) assert switch_info, '{} is not a valid switch'.format(device)
device = switch_info.get('friendly_name') or switch_info['ieee_address'] props = self.device_set(device, switch_info['property'], switch_info['value_off']).output
props = self.device_set( return self._properties_to_switch(device=device, props=props, switch_info=switch_info)
device, switch_info['property'], switch_info['value_off']
).output
return self._properties_to_switch(
device=device, props=props, switch_info=switch_info
)
@action @action
def toggle(self, device, *args, **kwargs) -> dict: def toggle(self, device, *args, **kwargs) -> dict:
@ -1317,26 +1089,10 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.toggle` and toggles a Zigbee device with a Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.toggle` and toggles a Zigbee device with a
writable binary property. writable binary property.
""" """
switch_info = self._get_switch_info(device) switch_info = self._get_switches_info().get(device)
assert switch_info, '{} is not a valid switch'.format(device) assert switch_info, '{} is not a valid switch'.format(device)
device = switch_info.get('friendly_name') or switch_info['ieee_address'] props = self.device_set(device, switch_info['property'], switch_info['value_toggle']).output
props = self.device_set( return self._properties_to_switch(device=device, props=props, switch_info=switch_info)
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 @staticmethod
def _properties_to_switch(device: str, props: dict, switch_info: dict) -> dict: def _properties_to_switch(device: str, props: dict, switch_info: dict) -> dict:
@ -1347,105 +1103,32 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
**props, **props,
} }
@staticmethod def _get_switches_info(self) -> dict:
def _get_switch_meta(device_info: dict) -> dict: def switch_info(device_info: dict) -> dict:
exposes = (device_info.get('definition', {}) or {}).get('exposes', []) exposes = (device_info.get('definition', {}) or {}).get('exposes', [])
for exposed in exposes: for exposed in exposes:
for feature in exposed.get('features', []): for feature in exposed.get('features', []):
if ( if feature.get('type') == 'binary' and 'value_on' in feature and 'value_off' in feature and \
feature.get('property') == 'state' feature.get('access', 0) & 2:
and feature.get('type') == 'binary' return {
and 'value_on' in feature 'property': feature['property'],
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_on': feature['value_on'],
'value_off': feature['value_off'], 'value_off': feature['value_off'],
'state_name': feature['name'],
'value_toggle': feature.get('value_toggle', None), '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 # noinspection PyUnresolvedReferences
devices = self.devices().output devices = self.devices().output
switches_info = {} switches_info = {}
for device in devices: for device in devices:
info = self._get_switch_meta(device) info = switch_info(device)
if not info: if not info:
continue continue
switches_info[ switches_info[device.get('friendly_name', device.get('ieee_address'))] = info
device.get('friendly_name', device.get('ieee_address'))
] = info
return switches_info return switches_info
@ -1459,37 +1142,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
switches_info = self._get_switches_info() switches_info = self._get_switches_info()
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
return [ return [
self._properties_to_switch( self._properties_to_switch(device=name, props=switch, switch_info=switches_info[name])
device=name, props=switch, switch_info=switches_info[name] for name, switch in self.devices_get(list(switches_info.keys())).output.items()
)
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: # vim:sw=4:ts=4:et:

File diff suppressed because it is too large Load Diff

View File

@ -13,13 +13,11 @@ except ImportError:
from jwt import PyJWTError, encode as jwt_encode, decode as jwt_decode from jwt import PyJWTError, encode as jwt_encode, decode as jwt_decode
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import make_transient, declarative_base from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.exceptions.user import ( from platypush.exceptions.user import InvalidJWTTokenException, InvalidCredentialsException
InvalidJWTTokenException,
InvalidCredentialsException,
)
from platypush.utils import get_or_generate_jwt_rsa_key_pair from platypush.utils import get_or_generate_jwt_rsa_key_pair
Base = declarative_base() Base = declarative_base()
@ -30,144 +28,126 @@ class UserManager:
Main class for managing platform users Main class for managing platform users
""" """
# noinspection PyProtectedMember
def __init__(self): def __init__(self):
self.db = get_plugin('db') db_plugin = get_plugin('db')
assert self.db if not db_plugin:
self._engine = self.db.get_engine() raise ModuleNotFoundError('Please enable/configure the db plugin for multi-user support')
self.db.create_all(self._engine, Base)
@staticmethod self._engine = db_plugin._get_engine()
def _mask_password(user):
make_transient(user) def get_user(self, username):
session = self._get_db_session()
user = self._get_user(session, username)
if not user:
return None
# Hide password
user.password = None user.password = None
return user 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): def get_user_count(self):
with self.db.get_session() as session: session = self._get_db_session()
return session.query(User).count() return session.query(User).count()
def get_users(self): def get_users(self):
with self.db.get_session() as session: session = self._get_db_session()
return session.query(User) return session.query(User)
def create_user(self, username, password, **kwargs): def create_user(self, username, password, **kwargs):
session = self._get_db_session()
if not username: if not username:
raise ValueError('Invalid or empty username') raise ValueError('Invalid or empty username')
if not password: if not password:
raise ValueError('Please provide a password for the user') raise ValueError('Please provide a password for the user')
with self.db.get_session() as session: user = self._get_user(session, username)
user = self._get_user(session, username) if user:
if user: raise NameError('The user {} already exists'.format(username))
raise NameError('The user {} already exists'.format(username))
record = User( record = User(username=username, password=self._encrypt_password(password),
username=username, created_at=datetime.datetime.utcnow(), **kwargs)
password=self._encrypt_password(password),
created_at=datetime.datetime.utcnow(),
**kwargs
)
session.add(record) session.add(record)
session.commit() session.commit()
user = self._get_user(session, username) user = self._get_user(session, username)
return self._mask_password(user) # Hide password
user.password = None
return user
def update_password(self, username, old_password, new_password): def update_password(self, username, old_password, new_password):
with self.db.get_session() as session: session = self._get_db_session()
if not self._authenticate_user(session, username, old_password): if not self._authenticate_user(session, username, old_password):
return False return False
user = self._get_user(session, username) user = self._get_user(session, username)
user.password = self._encrypt_password(new_password) user.password = self._encrypt_password(new_password)
session.commit() session.commit()
return True return True
def authenticate_user(self, username, password): def authenticate_user(self, username, password):
with self.db.get_session() as session: session = self._get_db_session()
return self._authenticate_user(session, username, password) return self._authenticate_user(session, username, password)
def authenticate_user_session(self, session_token): def authenticate_user_session(self, session_token):
with self.db.get_session() as session: session = self._get_db_session()
user_session = ( user_session = session.query(UserSession).filter_by(session_token=session_token).first()
session.query(UserSession)
.filter_by(session_token=session_token)
.first()
)
if not user_session or ( if not user_session or (
user_session.expires_at user_session.expires_at and user_session.expires_at < datetime.datetime.utcnow()):
and user_session.expires_at < datetime.datetime.utcnow() return None, None
):
return None, None
user = session.query(User).filter_by(user_id=user_session.user_id).first() user = session.query(User).filter_by(user_id=user_session.user_id).first()
return self._mask_password(user), user_session
# Hide password
user.password = None
return user, session
def delete_user(self, username): def delete_user(self, username):
with self.db.get_session() as session: session = self._get_db_session()
user = self._get_user(session, username) user = self._get_user(session, username)
if not user: if not user:
raise NameError('No such user: {}'.format(username)) raise NameError('No such user: {}'.format(username))
user_sessions = ( user_sessions = session.query(UserSession).filter_by(user_id=user.user_id).all()
session.query(UserSession).filter_by(user_id=user.user_id).all() for user_session in user_sessions:
) session.delete(user_session)
for user_session in user_sessions:
session.delete(user_session)
session.delete(user) session.delete(user)
session.commit() session.commit()
return True return True
def delete_user_session(self, session_token): def delete_user_session(self, session_token):
with self.db.get_session() as session: session = self._get_db_session()
user_session = ( user_session = session.query(UserSession).filter_by(session_token=session_token).first()
session.query(UserSession)
.filter_by(session_token=session_token)
.first()
)
if not user_session: if not user_session:
return False return False
session.delete(user_session) session.delete(user_session)
session.commit() session.commit()
return True return True
def create_user_session(self, username, password, expires_at=None): def create_user_session(self, username, password, expires_at=None):
with self.db.get_session() as session: session = self._get_db_session()
user = self._authenticate_user(session, username, password) user = self._authenticate_user(session, username, password)
if not user: if not user:
return None return None
if expires_at: if expires_at:
if isinstance(expires_at, (int, float)): if isinstance(expires_at, int) or isinstance(expires_at, float):
expires_at = datetime.datetime.fromtimestamp(expires_at) expires_at = datetime.datetime.fromtimestamp(expires_at)
elif isinstance(expires_at, str): elif isinstance(expires_at, str):
expires_at = datetime.datetime.fromisoformat(expires_at) expires_at = datetime.datetime.fromisoformat(expires_at)
user_session = UserSession( user_session = UserSession(user_id=user.user_id, session_token=self.generate_session_token(),
user_id=user.user_id, csrf_token=self.generate_session_token(), created_at=datetime.datetime.utcnow(),
session_token=self.generate_session_token(), expires_at=expires_at)
csrf_token=self.generate_session_token(),
created_at=datetime.datetime.utcnow(),
expires_at=expires_at,
)
session.add(user_session) session.add(user_session)
session.commit() session.commit()
return user_session return user_session
@staticmethod @staticmethod
def _get_user(session, username): def _get_user(session, username):
@ -200,20 +180,10 @@ class UserManager:
:param session_token: Session token. :param session_token: Session token.
""" """
with self.db.get_session() as session: session = self._get_db_session()
return ( return session.query(User).join(UserSession).filter_by(session_token=session_token).first()
session.query(User)
.join(UserSession)
.filter_by(session_token=session_token)
.first()
)
def generate_jwt_token( def generate_jwt_token(self, username: str, password: str, expires_at: Optional[datetime.datetime] = None) -> str:
self,
username: str,
password: str,
expires_at: Optional[datetime.datetime] = None,
) -> str:
""" """
Create a user JWT token for API usage. Create a user JWT token for API usage.
@ -270,6 +240,12 @@ class UserManager:
return payload 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): def _authenticate_user(self, session, username, password):
""" """
:return: :class:`platypush.user.User` instance if the user exists and the password is valid, ``None`` otherwise. :return: :class:`platypush.user.User` instance if the user exists and the password is valid, ``None`` otherwise.
@ -285,10 +261,10 @@ class UserManager:
class User(Base): class User(Base):
"""Models the User table""" """ Models the User table """
__tablename__ = 'user' __tablename__ = 'user'
__table_args__ = {'sqlite_autoincrement': True} __table_args__ = ({'sqlite_autoincrement': True})
user_id = Column(Integer, primary_key=True) user_id = Column(Integer, primary_key=True)
username = Column(String, unique=True, nullable=False) username = Column(String, unique=True, nullable=False)
@ -297,10 +273,10 @@ class User(Base):
class UserSession(Base): class UserSession(Base):
"""Models the UserSession table""" """ Models the UserSession table """
__tablename__ = 'user_session' __tablename__ = 'user_session'
__table_args__ = {'sqlite_autoincrement': True} __table_args__ = ({'sqlite_autoincrement': True})
session_id = Column(Integer, primary_key=True) session_id = Column(Integer, primary_key=True)
session_token = Column(String, unique=True, nullable=False) session_token = Column(String, unique=True, nullable=False)

View File

@ -1,5 +1,4 @@
import ast import ast
import contextlib
import datetime import datetime
import hashlib import hashlib
import importlib import importlib
@ -21,8 +20,8 @@ logger = logging.getLogger('utils')
def get_module_and_method_from_action(action): def get_module_and_method_from_action(action):
"""Input : action=music.mpd.play """ Input : action=music.mpd.play
Output : ('music.mpd', 'play')""" Output : ('music.mpd', 'play') """
tokens = action.split('.') tokens = action.split('.')
module_name = str.join('.', tokens[:-1]) module_name = str.join('.', tokens[:-1])
@ -31,7 +30,7 @@ def get_module_and_method_from_action(action):
def get_message_class_by_type(msgtype): 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: try:
module = importlib.import_module('platypush.message.' + msgtype) module = importlib.import_module('platypush.message.' + msgtype)
@ -44,7 +43,8 @@ def get_message_class_by_type(msgtype):
try: try:
msgclass = getattr(module, cls_name) msgclass = getattr(module, cls_name)
except AttributeError as e: 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) raise RuntimeError(e)
return msgclass return msgclass
@ -52,13 +52,13 @@ def get_message_class_by_type(msgtype):
# noinspection PyShadowingBuiltins # noinspection PyShadowingBuiltins
def get_event_class_by_type(type): 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])) event_module = importlib.import_module('.'.join(type.split('.')[:-1]))
return getattr(event_module, type.split('.')[-1]) return getattr(event_module, type.split('.')[-1])
def get_plugin_module_by_name(plugin_name): 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 module_name = 'platypush.plugins.' + plugin_name
try: try:
@ -69,26 +69,22 @@ def get_plugin_module_by_name(plugin_name):
def get_plugin_class_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) module = get_plugin_module_by_name(plugin_name)
if not module: if not module:
return return
class_name = getattr( class_name = getattr(module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin')
module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin'
)
try: try:
return getattr( return getattr(module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin')
module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin'
)
except Exception as e: except Exception as e:
logger.error('Cannot import class {}: {}'.format(class_name, str(e))) logger.error('Cannot import class {}: {}'.format(class_name, str(e)))
return None return None
def get_plugin_name_by_class(plugin) -> Optional[str]: 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 from platypush.plugins import Plugin
@ -97,8 +93,7 @@ def get_plugin_name_by_class(plugin) -> Optional[str]:
class_name = plugin.__name__ class_name = plugin.__name__
class_tokens = [ class_tokens = [
token.lower() token.lower() for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
if token.strip() and token != 'Plugin' if token.strip() and token != 'Plugin'
] ]
@ -106,7 +101,7 @@ def get_plugin_name_by_class(plugin) -> Optional[str]:
def get_backend_name_by_class(backend) -> 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 from platypush.backend import Backend
@ -115,8 +110,7 @@ def get_backend_name_by_class(backend) -> Optional[str]:
class_name = backend.__name__ class_name = backend.__name__
class_tokens = [ class_tokens = [
token.lower() token.lower() for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
if token.strip() and token != 'Backend' if token.strip() and token != 'Backend'
] ]
@ -141,12 +135,12 @@ def set_timeout(seconds, on_timeout):
def clear_timeout(): def clear_timeout():
"""Clear any previously set timeout""" """ Clear any previously set timeout """
signal.alarm(0) signal.alarm(0)
def get_hash(s): 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() return hashlib.sha256(s.encode('utf-8')).hexdigest()
@ -183,8 +177,11 @@ def get_decorators(cls, climb_class_hierarchy=False):
node_iter.visit_FunctionDef = visit_FunctionDef node_iter.visit_FunctionDef = visit_FunctionDef
for target in targets: for target in targets:
with contextlib.suppress(TypeError): try:
node_iter.visit(ast.parse(inspect.getsource(target))) node_iter.visit(ast.parse(inspect.getsource(target)))
except TypeError:
# Ignore built-in classes
pass
return decorators return decorators
@ -198,57 +195,45 @@ def get_redis_queue_name_by_message(msg):
return 'platypush/responses/{}'.format(msg.id) if msg.id else None return 'platypush/responses/{}'.format(msg.id) if msg.id else None
def _get_ssl_context( def _get_ssl_context(context_type=None, ssl_cert=None, ssl_key=None,
context_type=None, ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None ssl_cafile=None, ssl_capath=None):
):
if not context_type: 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: else:
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
if ssl_cafile or ssl_capath: 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( ssl_context.load_cert_chain(
certfile=os.path.abspath(os.path.expanduser(ssl_cert)), 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 return ssl_context
def get_ssl_context(ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None): def get_ssl_context(ssl_cert=None, ssl_key=None, ssl_cafile=None,
return _get_ssl_context( ssl_capath=None):
context_type=None, return _get_ssl_context(context_type=None,
ssl_cert=ssl_cert, ssl_cert=ssl_cert, ssl_key=ssl_key,
ssl_key=ssl_key, ssl_cafile=ssl_cafile, ssl_capath=ssl_capath)
ssl_cafile=ssl_cafile,
ssl_capath=ssl_capath,
)
def get_ssl_server_context( def get_ssl_server_context(ssl_cert=None, ssl_key=None, ssl_cafile=None,
ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None ssl_capath=None):
): return _get_ssl_context(context_type=ssl.PROTOCOL_TLS_SERVER,
return _get_ssl_context( ssl_cert=ssl_cert, ssl_key=ssl_key,
context_type=ssl.PROTOCOL_TLS_SERVER, ssl_cafile=ssl_cafile, ssl_capath=ssl_capath)
ssl_cert=ssl_cert,
ssl_key=ssl_key,
ssl_cafile=ssl_cafile,
ssl_capath=ssl_capath,
)
def get_ssl_client_context( def get_ssl_client_context(ssl_cert=None, ssl_key=None, ssl_cafile=None,
ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None ssl_capath=None):
): return _get_ssl_context(context_type=ssl.PROTOCOL_TLS_CLIENT,
return _get_ssl_context( ssl_cert=ssl_cert, ssl_key=ssl_key,
context_type=ssl.PROTOCOL_TLS_CLIENT, ssl_cafile=ssl_cafile, ssl_capath=ssl_capath)
ssl_cert=ssl_cert,
ssl_key=ssl_key,
ssl_cafile=ssl_cafile,
ssl_capath=ssl_capath,
)
def set_thread_name(name): def set_thread_name(name):
@ -256,7 +241,6 @@ def set_thread_name(name):
try: try:
import prctl import prctl
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
prctl.set_name(name) prctl.set_name(name)
except ImportError: except ImportError:
@ -267,9 +251,9 @@ def find_bins_in_path(bin_name):
return [ return [
os.path.join(p, bin_name) os.path.join(p, bin_name)
for p in os.environ.get('PATH', '').split(':') for p in os.environ.get('PATH', '').split(':')
if os.path.isfile(os.path.join(p, bin_name)) if os.path.isfile(os.path.join(p, bin_name)) and (
and (os.name == 'nt' or os.access(os.path.join(p, bin_name), os.X_OK)) os.name == 'nt' or os.access(os.path.join(p, bin_name), os.X_OK)
] )]
def find_files_by_ext(directory, *exts): def find_files_by_ext(directory, *exts):
@ -287,7 +271,7 @@ def find_files_by_ext(directory, *exts):
max_len = len(max(exts, key=len)) max_len = len(max(exts, key=len))
result = [] result = []
for _, __, files in os.walk(directory): for root, dirs, files in os.walk(directory):
for i in range(min_len, max_len + 1): for i in range(min_len, max_len + 1):
result += [f for f in files if f[-i:] in exts] result += [f for f in files if f[-i:] in exts]
@ -318,9 +302,8 @@ def get_ip_or_hostname():
def get_mime_type(resource): def get_mime_type(resource):
import magic import magic
if resource.startswith('file://'): if resource.startswith('file://'):
resource = resource[len('file://') :] resource = resource[len('file://'):]
# noinspection HttpUrlsUsage # noinspection HttpUrlsUsage
if resource.startswith('http://') or resource.startswith('https://'): if resource.startswith('http://') or resource.startswith('https://'):
@ -332,9 +315,7 @@ def get_mime_type(resource):
elif hasattr(magic, 'from_file'): elif hasattr(magic, 'from_file'):
mime = magic.from_file(resource, mime=True) mime = magic.from_file(resource, mime=True)
else: else:
raise RuntimeError( raise RuntimeError('The installed magic version provides neither detect_from_filename nor from_file')
'The installed magic version provides neither detect_from_filename nor from_file'
)
if mime: if mime:
return mime.mime_type if hasattr(mime, 'mime_type') else mime return mime.mime_type if hasattr(mime, 'mime_type') else mime
@ -351,7 +332,6 @@ def grouper(n, iterable, fillvalue=None):
grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx
""" """
from itertools import zip_longest from itertools import zip_longest
args = [iter(iterable)] * n args = [iter(iterable)] * n
if fillvalue: if fillvalue:
@ -375,7 +355,6 @@ def is_functional_cron(obj) -> bool:
def run(action, *args, **kwargs): def run(action, *args, **kwargs):
from platypush.context import get_plugin from platypush.context import get_plugin
(module_name, method_name) = get_module_and_method_from_action(action) (module_name, method_name) = get_module_and_method_from_action(action)
plugin = get_plugin(module_name) plugin = get_plugin(module_name)
method = getattr(plugin, method_name) method = getattr(plugin, method_name)
@ -387,9 +366,7 @@ def run(action, *args, **kwargs):
return response.output return response.output
def generate_rsa_key_pair( def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> Tuple[str, str]:
key_file: Optional[str] = None, size: int = 2048
) -> Tuple[str, str]:
""" """
Generate an RSA key pair. Generate an RSA key pair.
@ -413,30 +390,27 @@ def generate_rsa_key_pair(
public_exp = 65537 public_exp = 65537
private_key = rsa.generate_private_key( 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)) logger.info('Generating RSA {} key pair'.format(size))
private_key_str = private_key.private_bytes( private_key_str = private_key.private_bytes(
encoding=serialization.Encoding.PEM, encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL, format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(), encryption_algorithm=serialization.NoEncryption()
).decode() ).decode()
public_key_str = ( public_key_str = private_key.public_key().public_bytes(
private_key.public_key() encoding=serialization.Encoding.PEM,
.public_bytes( format=serialization.PublicFormat.PKCS1,
encoding=serialization.Encoding.PEM, ).decode()
format=serialization.PublicFormat.PKCS1,
)
.decode()
)
if key_file: if key_file:
logger.info('Saving private key to {}'.format(key_file)) logger.info('Saving private key to {}'.format(key_file))
with open(os.path.expanduser(key_file), 'w') as f1, open( with open(os.path.expanduser(key_file), 'w') as f1, \
os.path.expanduser(key_file) + '.pub', 'w' open(os.path.expanduser(key_file) + '.pub', 'w') as f2:
) as f2:
f1.write(private_key_str) f1.write(private_key_str)
f2.write(public_key_str) f2.write(public_key_str)
os.chmod(key_file, 0o600) os.chmod(key_file, 0o600)
@ -452,7 +426,8 @@ def get_or_generate_jwt_rsa_key_pair():
pub_key_file = priv_key_file + '.pub' pub_key_file = priv_key_file + '.pub'
if os.path.isfile(priv_key_file) and os.path.isfile(pub_key_file): 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() return f1.read(), f2.read()
pathlib.Path(key_dir).mkdir(parents=True, exist_ok=True, mode=0o755) pathlib.Path(key_dir).mkdir(parents=True, exist_ok=True, mode=0o755)
@ -464,7 +439,7 @@ def get_enabled_plugins() -> dict:
from platypush.context import get_plugin from platypush.context import get_plugin
plugins = {} plugins = {}
for name in Config.get_plugins(): for name, config in Config.get_plugins().items():
try: try:
plugin = get_plugin(name) plugin = get_plugin(name)
if plugin: if plugin:
@ -478,18 +453,11 @@ def get_enabled_plugins() -> dict:
def get_redis() -> Redis: def get_redis() -> Redis:
from platypush.config import Config 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: def to_datetime(t: Union[str, int, float, datetime.datetime]) -> datetime.datetime:
if isinstance(t, (int, float)): if isinstance(t, int) or isinstance(t, float):
return datetime.datetime.fromtimestamp(t, tz=tz.tzutc()) return datetime.datetime.fromtimestamp(t, tz=tz.tzutc())
if isinstance(t, str): if isinstance(t, str):
return parser.parse(t) return parser.parse(t)

View File

@ -8,7 +8,6 @@ description-file = README.md
[flake8] [flake8]
max-line-length = 120 max-line-length = 120
extend-ignore = ignore =
E203
W503
SIM105 SIM105
W503