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/dbus.rst
platypush/events/distance.rst
platypush/events/entities.rst
platypush/events/file.rst
platypush/events/foursquare.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/dbus.rst
platypush/plugins/dropbox.rst
platypush/plugins/entities.rst
platypush/plugins/esp.rst
platypush/plugins/ffmpeg.rst
platypush/plugins/file.rst

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,15 +8,15 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.1.1",
"@fortawesome/fontawesome-free": "^5.15.4",
"axios": "^0.21.4",
"core-js": "^3.21.1",
"core-js": "^3.23.4",
"lato-font": "^3.0.0",
"mitt": "^2.1.0",
"sass": "^1.49.9",
"sass-loader": "^10.2.1",
"sass": "^1.53.0",
"sass-loader": "^10.3.1",
"vue": "^3.2.13",
"vue-router": "^4.0.14",
"vue-router": "^4.1.2",
"vue-skycons": "^4.2.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 Notification from "@/utils/Notification";
import Screen from "@/utils/Screen";
import Text from "@/utils/Text";
import Types from "@/utils/Types";
export default {
name: "Utils",
mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Text, Types],
mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Types],
}
</script>

View File

@ -17,9 +17,6 @@
"camera.pi": {
"class": "fas fa-camera"
},
"entities": {
"class": "fa fa-home"
},
"execute": {
"class": "fa fa-play"
},
@ -62,21 +59,9 @@
"rtorrent": {
"class": "fa fa-magnet"
},
"smartthings": {
"imgUrl": "/icons/smartthings.png"
},
"switches": {
"class": "fas fa-toggle-on"
},
"switch.switchbot": {
"class": "fas fa-toggle-on"
},
"switch.tplink": {
"class": "fas fa-toggle-on"
},
"switchbot": {
"class": "fas fa-toggle-on"
},
"sound": {
"class": "fa fa-microphone"
},

View File

@ -6,7 +6,7 @@
</div>
<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)">
<a :href="`/#${name}`">
<span class="icon">
@ -14,7 +14,7 @@
<img :src="icons[name].imgUrl" v-else-if="icons[name]?.imgUrl" alt="name"/>
<i class="fas fa-puzzle-piece" v-else />
</span>
<span class="name" v-if="!collapsed" v-text="name == 'entities' ? 'Home' : name" />
<span class="name" v-if="!collapsed" v-text="name" />
</a>
</li>
</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: {
onItemClick(name) {
this.$emit('select', name)
@ -90,6 +80,11 @@ export default {
host: null,
}
},
mounted() {
if (this.isMobile() && !this.$root.$route.hash.length)
this.collapsed = false
},
}
</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: {
type: String,
},
keepOpenOnItemClick: {
type: Boolean,
default: false,
},
},
data() {

View File

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

View File

@ -59,7 +59,7 @@ export default {
display: none;
& + label {
border-radius: 1em;
display: inline-flex;
display: block;
cursor: pointer;
position: relative;
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(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() {
this.loading.status = true
try {
this.status = await this.zrequest('controller_status')
this.status = await this.zrequest('status')
} finally {
this.loading.status = false
}

View File

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

View File

@ -1,5 +1,3 @@
$header-height: 3.5em;
.item {
display: flex;
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-6: #e4eae8 !default;
$default-bg-7: #e4e4e4 !default;
$error-fg: #ad1717 !default;
$default-fg: black !default;
$default-fg-2: #23513a !default;
$default-fg-3: #195331b3 !default;
$header-bg: linear-gradient(0deg, #c0e8e4, #e4f8f4) !default;
$no-items-color: #555555;
//// Notifications
$notification-bg: rgba(185, 255, 193, 0.9) !default;
@ -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-right: 2.5px 0 4px 0 $default-shadow-color;
$border-shadow-bottom-right: 2.5px 2.5px 3px 0 $default-shadow-color;
$header-shadow: 0px 1px 3px 1px #bbb !default;
$group-shadow: 3px -2px 6px 1px #98b0a0;
//// Modals
$modal-header-bg: #e0e0e0 !default;
@ -146,7 +141,5 @@ $dropdown-shadow: 1px 1px 1px #bbb !default;
//// Scrollbars
$scrollbar-track-bg: $slider-bg !default;
$scrollbar-track-shadow: inset 1px 0px 3px 0 $slider-track-shadow !default;
$scrollbar-thumb-bg: #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: {
initSelectedPanel() {
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)
this.selectedPanel = plugin
},
@ -87,7 +90,7 @@ export default {
initializeDefaultViews() {
this.plugins.execute = {}
this.plugins.entities = {}
this.plugins.switches = {}
},
},
@ -110,7 +113,7 @@ main {
height: 100%;
display: flex;
@include until($tablet) {
@media screen and (max-width: $tablet) {
flex-direction: column;
}

View File

@ -1,24 +1,100 @@
import warnings
from threading import Thread
from platypush.backend import Backend
from platypush.context import get_plugin
from platypush.message.event.light import LightStatusChangeEvent
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)
warnings.warn(
'The light.hue backend is deprecated. All of its logic '
'has been moved to the light.hue plugin itself.'
)
self.poll_seconds = poll_seconds
@staticmethod
def _get_lights():
plugin = get_plugin('light.hue')
if not plugin:
plugin = get_plugin('light.hue', reload=True)
return plugin.get_lights().output
def _listener(self):
def _thread():
lights = self._get_lights()
while not self.should_stop():
try:
lights_new = self._get_lights()
for light_id, light in lights_new.items():
event_args = {}
state = light.get('state')
prev_state = lights.get(light_id, {}).get('state', {})
if 'on' in state and state.get('on') != prev_state.get('on'):
event_args['on'] = state.get('on')
if 'bri' in state and state.get('bri') != prev_state.get('bri'):
event_args['bri'] = state.get('bri')
if 'sat' in state and state.get('sat') != prev_state.get('sat'):
event_args['sat'] = state.get('sat')
if 'hue' in state and state.get('hue') != prev_state.get('hue'):
event_args['hue'] = state.get('hue')
if 'ct' in state and state.get('ct') != prev_state.get('ct'):
event_args['ct'] = state.get('ct')
if 'xy' in state and state.get('xy') != prev_state.get('xy'):
event_args['xy'] = state.get('xy')
if event_args:
event_args['plugin_name'] = 'light.hue'
event_args['light_id'] = light_id
event_args['light_name'] = light.get('name')
self.bus.post(LightStatusChangeEvent(**event_args))
lights = lights_new
except Exception as e:
self.logger.exception(e)
finally:
self.wait_stop(self.poll_seconds)
return _thread
def run(self):
super().run()
self.logger.info('Starting Hue lights backend')
while not self.should_stop():
try:
poll_thread = Thread(target=self._listener())
poll_thread.start()
poll_thread.join()
except Exception as e:
self.logger.exception(e)
self.wait_stop(self.poll_seconds)
self.logger.info('Stopped Hue lights backend')

View File

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

View File

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

View File

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

View File

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

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 json
import re
import sys
import time
import uuid
@ -13,15 +14,23 @@ from platypush.utils import get_event_class_by_type
class Event(Message):
""" Event message class """
"""Event message class"""
# 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
# high frequency that would otherwise pollute the logs e.g. camera capture
# events
# pylint: disable=redefined-builtin
def __init__(self, target=None, origin=None, id=None, timestamp=None,
disable_logging=False, disable_web_clients_notification=False, **kwargs):
def __init__(
self,
target=None,
origin=None,
id=None,
timestamp=None,
disable_logging=False,
disable_web_clients_notification=False,
**kwargs
):
"""
Params:
target -- Target node [String]
@ -34,22 +43,27 @@ class Event(Message):
self.id = id if id else self._generate_id()
self.target = target if target else Config.get('device_id')
self.origin = origin if origin else Config.get('device_id')
self.type = '{}.{}'.format(self.__class__.__module__,
self.__class__.__name__)
self.type = '{}.{}'.format(self.__class__.__module__, self.__class__.__name__)
self.args = kwargs
self.disable_logging = disable_logging
self.disable_web_clients_notification = disable_web_clients_notification
for arg, value in self.args.items():
if arg not in [
'id', 'args', 'origin', 'target', 'type', 'timestamp', 'disable_logging'
'id',
'args',
'origin',
'target',
'type',
'timestamp',
'disable_logging',
] and not arg.startswith('_'):
self.__setattr__(arg, value)
@classmethod
def build(cls, msg):
""" Builds an event message from a JSON UTF-8 string/bytearray, a
dictionary, or another Event """
"""Builds an event message from a JSON UTF-8 string/bytearray, a
dictionary, or another Event"""
msg = super().parse(msg)
event_type = msg['args'].pop('type')
@ -64,8 +78,10 @@ class Event(Message):
@staticmethod
def _generate_id():
""" Generate a unique event ID """
return hashlib.md5(str(uuid.uuid1()).encode()).hexdigest() # lgtm [py/weak-sensitive-data-hashing]
"""Generate a unique event ID"""
return hashlib.md5(
str(uuid.uuid1()).encode()
).hexdigest() # lgtm [py/weak-sensitive-data-hashing]
def matches_condition(self, condition):
"""
@ -120,7 +136,13 @@ class Event(Message):
"""
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())
while event_tokens and condition_tokens:
@ -148,9 +170,11 @@ class Event(Message):
else:
result.parsed_args[argname] += ' ' + event_token
if (len(condition_tokens) == 1 and len(event_tokens) == 1) \
or (len(event_tokens) > 1 and len(condition_tokens) > 1
and event_tokens[1] == condition_tokens[1]):
if (len(condition_tokens) == 1 and len(event_tokens) == 1) or (
len(event_tokens) > 1
and len(condition_tokens) > 1
and event_tokens[1] == condition_tokens[1]
):
# Stop appending tokens to this argument, as the next
# condition will be satisfied as well
condition_tokens.pop(0)
@ -173,30 +197,30 @@ class Event(Message):
args = copy.deepcopy(self.args)
flatten(args)
return json.dumps({
'type': 'event',
'target': self.target,
'origin': self.origin if hasattr(self, 'origin') else None,
'id': self.id if hasattr(self, 'id') else None,
'_timestamp': self.timestamp,
'args': {
'type': self.type,
**args
return json.dumps(
{
'type': 'event',
'target': self.target,
'origin': self.origin if hasattr(self, 'origin') else None,
'id': self.id if hasattr(self, 'id') else None,
'_timestamp': self.timestamp,
'args': {'type': self.type, **args},
},
}, cls=self.Encoder)
cls=self.Encoder,
)
class EventMatchResult(object):
""" When comparing an event against an event condition, you want to
return this object. It contains the match status (True or False),
any parsed arguments, and a match_score that identifies how "strong"
the match is - in case of multiple event matches, the ones with the
highest score will win """
class EventMatchResult:
"""When comparing an event against an event condition, you want to
return this object. It contains the match status (True or False),
any parsed arguments, and a match_score that identifies how "strong"
the match is - in case of multiple event matches, the ones with the
highest score will win"""
def __init__(self, is_match, score=0, parsed_args=None):
self.is_match = is_match
self.score = score
self.parsed_args = {} if not parsed_args else parsed_args
self.parsed_args = parsed_args or {}
def flatten(args):
@ -213,4 +237,5 @@ def flatten(args):
elif isinstance(arg, (dict, list)):
flatten(args[i])
# 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)
if result and isinstance(result, Response):
result.errors = (
result.errors if isinstance(result.errors, list) else [result.errors]
)
result.errors = result.errors \
if isinstance(result.errors, list) else [result.errors]
response = result
elif isinstance(result, tuple) and len(result) == 2:
response.errors = result[1] if isinstance(result[1], list) else [result[1]]
response.errors = result[1] \
if isinstance(result[1], list) else [result[1]]
if len(response.errors) == 1 and response.errors[0] is None:
response.errors = []
@ -39,14 +39,12 @@ def action(f):
return _execute_action
class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to-init]
"""Base plugin class"""
class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to-init]
""" Base plugin class """
def __init__(self, **kwargs):
super().__init__()
self.logger = logging.getLogger(
'platypush:plugin:' + get_plugin_name_by_class(self.__class__)
)
self.logger = logging.getLogger('platypush:plugin:' + get_plugin_name_by_class(self.__class__))
if 'logging' in kwargs:
self.logger.setLevel(getattr(logging, kwargs['logging'].upper()))
@ -55,9 +53,8 @@ class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to
)
def run(self, method, *args, **kwargs):
assert (
method in self.registered_actions
), '{} is not a registered action on {}'.format(method, self.__class__.__name__)
assert method in self.registered_actions, '{} is not a registered action on {}'.\
format(method, self.__class__.__name__)
return getattr(self, method)(*args, **kwargs)
@ -65,7 +62,6 @@ class RunnablePlugin(Plugin):
"""
Class for runnable plugins - i.e. plugins that have a start/stop method and can be started.
"""
def __init__(self, poll_interval: Optional[float] = None, **kwargs):
"""
:param poll_interval: How often the :meth:`.loop` function should be execute (default: None, no pause/interval).
@ -82,9 +78,6 @@ class RunnablePlugin(Plugin):
def should_stop(self):
return self._should_stop.is_set()
def wait_stop(self, timeout=None):
return self._should_stop.wait(timeout)
def start(self):
set_thread_name(self.__class__.__name__)
self._thread = threading.Thread(target=self._runner)

View File

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

View File

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

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

View File

@ -1,52 +1,30 @@
from abc import ABC, abstractmethod
from platypush.entities import manages
from platypush.entities.lights import Light
from platypush.plugins import Plugin, action
from platypush.plugins import action
from platypush.plugins.switch import SwitchPlugin
@manages(Light)
class LightPlugin(Plugin, ABC):
class LightPlugin(SwitchPlugin, ABC):
"""
Abstract plugin to interface your logic with lights/bulbs.
"""
@action
@abstractmethod
def on(self, lights=None, *args, **kwargs):
"""Turn the light on"""
def on(self):
""" Turn the light on """
raise NotImplementedError()
@action
@abstractmethod
def off(self, lights=None, *args, **kwargs):
"""Turn the light off"""
def off(self):
""" Turn the light off """
raise NotImplementedError()
@action
@abstractmethod
def toggle(self, lights=None, *args, **kwargs):
"""Toggle the light status (on/off)"""
raise NotImplementedError()
@action
@abstractmethod
def set_lights(self, lights=None, *args, **kwargs):
"""
Set a set of properties on a set of lights.
:param light: List of lights to set. Each item can represent a light
name or ID.
:param kwargs: key-value list of the parameters to set.
"""
raise NotImplementedError()
@action
@abstractmethod
def status(self, *args, **kwargs):
"""
Get the current status of the lights.
"""
def toggle(self):
""" Toggle the light status (on/off) """
raise NotImplementedError()

View File

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

View File

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

View File

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

View File

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

View File

@ -2,17 +2,13 @@ import asyncio
import aiohttp
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.switch import Plugin
from platypush.plugins.switch import SwitchPlugin
@manages(Switch, Light)
class SmartthingsPlugin(Plugin):
class SmartthingsPlugin(SwitchPlugin):
"""
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):
"""
@ -47,27 +43,46 @@ class SmartthingsPlugin(Plugin):
async def _refresh_locations(self, api):
self._locations = await api.locations()
self._locations_by_id = {loc.location_id: loc for loc in self._locations}
self._locations_by_name = {loc.name: loc for loc in self._locations}
self._locations_by_id = {
loc.location_id: loc
for loc in self._locations
}
self._locations_by_name = {
loc.name: loc
for loc in self._locations
}
async def _refresh_devices(self, api):
self._devices = await api.devices()
self._devices_by_id = {dev.device_id: dev for dev in self._devices}
self._devices_by_name = {dev.label: dev for dev in self._devices}
self._devices_by_id = {
dev.device_id: dev
for dev in self._devices
}
self._devices_by_name = {
dev.label: dev
for dev in self._devices
}
async def _refresh_rooms(self, api, location_id: str):
self._rooms_by_location[location_id] = await api.rooms(location_id=location_id)
self._rooms_by_id.update(
**{room.room_id: room for room in self._rooms_by_location[location_id]}
)
self._rooms_by_id.update(**{
room.room_id: room
for room in self._rooms_by_location[location_id]
})
self._rooms_by_location_and_id[location_id] = {
room.room_id: room for room in self._rooms_by_location[location_id]
room.room_id: room
for room in self._rooms_by_location[location_id]
}
self._rooms_by_location_and_name[location_id] = {
room.name: room for room in self._rooms_by_location[location_id]
room.name: room
for room in self._rooms_by_location[location_id]
}
async def _refresh_info(self):
@ -112,7 +127,7 @@ class SmartthingsPlugin(Plugin):
'rooms': {
room.room_id: self._room_to_dict(room)
for room in self._rooms_by_location.get(location.location_id, {})
},
}
}
@staticmethod
@ -242,18 +257,12 @@ class SmartthingsPlugin(Plugin):
"""
self.refresh_info()
return {
'locations': {
loc.location_id: self._location_to_dict(loc) for loc in self._locations
},
'devices': {
dev.device_id: self._device_to_dict(dev) for dev in self._devices
},
'locations': {loc.location_id: self._location_to_dict(loc) for loc in self._locations},
'devices': {dev.device_id: self._device_to_dict(dev) for dev in self._devices},
}
@action
def get_location(
self, location_id: Optional[str] = None, name: Optional[str] = None
) -> dict:
def get_location(self, location_id: Optional[str] = None, name: Optional[str] = None) -> dict:
"""
Get the info of a location by ID or name.
@ -287,37 +296,20 @@ class SmartthingsPlugin(Plugin):
"""
assert location_id or name, 'Specify either location_id or name'
if (
location_id not in self._locations_by_id
or name not in self._locations_by_name
):
if location_id not in self._locations_by_id or name not in self._locations_by_name:
self.refresh_info()
location = self._locations_by_id.get(
location_id, self._locations_by_name.get(name)
)
location = self._locations_by_id.get(location_id, self._locations_by_name.get(name))
assert location, 'Location {} not found'.format(location_id or name)
return self._location_to_dict(location)
def _get_device(self, device: str):
return self._get_devices(device)[0]
def _get_devices(self, *devices: str):
def get_found_and_missing_devs():
found_devs = [
self._devices_by_id.get(d, self._devices_by_name.get(d))
for d in devices
]
missing_devs = [d for i, d in enumerate(devices) if not found_devs[i]]
return found_devs, missing_devs
devs, missing_devs = get_found_and_missing_devs()
if missing_devs:
if device not in self._devices_by_id or device not in self._devices_by_name:
self.refresh_info()
devs, missing_devs = get_found_and_missing_devs()
assert not missing_devs, f'Devices not found: {missing_devs}'
return devs
device = self._devices_by_id.get(device, self._devices_by_name.get(device))
assert device, 'Device {} not found'.format(device)
return device
@action
def get_device(self, device: str) -> dict:
@ -348,41 +340,24 @@ class SmartthingsPlugin(Plugin):
device = self._get_device(device)
return self._device_to_dict(device)
async def _execute(
self,
device_id: str,
capability: str,
command,
component_id: str,
args: Optional[list],
):
async def _execute(self, device_id: str, capability: str, command, component_id: str, args: Optional[list]):
import pysmartthings
async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = pysmartthings.SmartThings(session, self._access_token)
device = await api.device(device_id)
ret = await device.command(
component_id=component_id,
capability=capability,
command=command,
args=args,
)
ret = await device.command(component_id=component_id, capability=capability, command=command, args=args)
assert (
ret
), 'The command {capability}={command} failed on device {device}'.format(
capability=capability, command=command, device=device_id
)
assert ret, 'The command {capability}={command} failed on device {device}'.format(
capability=capability, command=command, device=device_id)
@action
def execute(
self,
device: str,
capability: str,
command,
component_id: str = 'main',
args: Optional[list] = None,
):
def execute(self,
device: str,
capability: str,
command,
component_id: str = 'main',
args: Optional[list] = None):
"""
Execute a command on a device.
@ -413,89 +388,16 @@ class SmartthingsPlugin(Plugin):
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
loop.run_until_complete(
self._execute(
device_id=device.device_id,
capability=capability,
command=command,
component_id=component_id,
args=args,
)
)
loop.run_until_complete(self._execute(
device_id=device.device_id, capability=capability, command=command,
component_id=component_id, args=args))
finally:
loop.stop()
@staticmethod
def _is_light(device):
if isinstance(device, dict):
capabilities = device.get('capabilities', [])
else:
capabilities = device.capabilities
return 'colorControl' in capabilities or 'colorTemperature' in capabilities
def transform_entities(self, entities):
from platypush.entities.switches import Switch
compatible_entities = []
for device in entities:
data = {
'location_id': getattr(device, 'location_id', None),
'room_id': getattr(device, 'room_id', None),
}
if self._is_light(device):
light_attrs = {
'id': device.device_id,
'name': device.label,
'data': data,
}
if 'switch' in device.capabilities:
light_attrs['on'] = device.status.switch
if getattr(device.status, 'level', None) is not None:
light_attrs['brightness'] = device.status.level
light_attrs['brightness_min'] = 0
light_attrs['brightness_max'] = 100
if 'colorTemperature' in device.capabilities:
# Color temperature range on SmartThings is expressed in Kelvin
light_attrs['temperature_min'] = 2000
light_attrs['temperature_max'] = 6500
if (
device.status.color_temperature
>= light_attrs['temperature_min']
):
light_attrs['temperature'] = (
light_attrs['temperature_max']
- light_attrs['temperature_min']
) / 2
if getattr(device.status, 'hue', None) is not None:
light_attrs['hue'] = device.status.hue
light_attrs['hue_min'] = 0
light_attrs['hue_max'] = 100
if getattr(device.status, 'saturation', None) is not None:
light_attrs['saturation'] = device.status.saturation
light_attrs['saturation_min'] = 0
light_attrs['saturation_max'] = 80
compatible_entities.append(Light(**light_attrs))
elif 'switch' in device.capabilities:
compatible_entities.append(
Switch(
id=device.device_id,
name=device.label,
state=device.status.switch,
data=data,
)
)
return super().transform_entities(compatible_entities) # type: ignore
async def _get_device_status(self, api, device_id: str) -> dict:
async def _get_device_status(api, device_id: str) -> dict:
device = await api.device(device_id)
await device.status.refresh()
self.publish_entities([device]) # type: ignore
return {
'device_id': device_id,
@ -505,7 +407,7 @@ class SmartthingsPlugin(Plugin):
for cap in device.capabilities
if hasattr(device.status, cap)
and not callable(getattr(device.status, cap))
},
}
}
async def _refresh_status(self, devices: List[str]) -> List[dict]:
@ -532,9 +434,7 @@ class SmartthingsPlugin(Plugin):
parse_device_id(dev)
# Fail if some devices haven't been found after refreshing
assert (
not missing_device_ids
), 'Could not find the following devices: {}'.format(list(missing_device_ids))
assert not missing_device_ids, 'Could not find the following devices: {}'.format(list(missing_device_ids))
async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = pysmartthings.SmartThings(session, self._access_token)
@ -589,7 +489,7 @@ class SmartthingsPlugin(Plugin):
loop.stop()
@action
def on(self, device: str, *_, **__) -> dict:
def on(self, device: str, *args, **kwargs) -> dict:
"""
Turn on a device with ``switch`` capability.
@ -597,10 +497,11 @@ class SmartthingsPlugin(Plugin):
:return: Device status
"""
self.execute(device, 'switch', 'on')
return self.status(device).output[0] # type: ignore
# noinspection PyUnresolvedReferences
return self.status(device).output[0]
@action
def off(self, device: str, *_, **__) -> dict:
def off(self, device: str, *args, **kwargs) -> dict:
"""
Turn off a device with ``switch`` capability.
@ -608,10 +509,11 @@ class SmartthingsPlugin(Plugin):
:return: Device status
"""
self.execute(device, 'switch', 'off')
return self.status(device).output[0] # type: ignore
# noinspection PyUnresolvedReferences
return self.status(device).output[0]
@action
def toggle(self, device: str, *args, **__) -> dict:
def toggle(self, device: str, *args, **kwargs) -> dict:
"""
Toggle a device with ``switch`` capability.
@ -627,28 +529,22 @@ class SmartthingsPlugin(Plugin):
async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = pysmartthings.SmartThings(session, self._access_token)
dev = await api.device(device_id)
assert (
'switch' in dev.capabilities
), 'The device {} has no switch capability'.format(dev.label)
assert 'switch' in dev.capabilities, 'The device {} has no switch capability'.format(dev.label)
await dev.status.refresh()
state = 'off' if dev.status.switch else 'on'
ret = await dev.command(
component_id='main', capability='switch', command=state, args=args
)
ret = await dev.command(component_id='main', capability='switch', command=state, args=args)
assert ret, 'The command switch={state} failed on device {device}'.format(
state=state, device=dev.label
)
assert ret, 'The command switch={state} failed on device {device}'.format(state=state, device=dev.label)
return not dev.status.switch
with self._refresh_lock:
loop = asyncio.new_event_loop()
loop.run_until_complete(_toggle())
device = loop.run_until_complete(self._refresh_status([device_id]))[0] # type: ignore
state = loop.run_until_complete(_toggle())
return {
'id': device_id,
'name': device['name'],
'on': device['switch'],
'name': device.label,
'on': state,
}
@property
@ -673,7 +569,8 @@ class SmartthingsPlugin(Plugin):
]
"""
devices = self.status().output # type: ignore
# noinspection PyUnresolvedReferences
devices = self.status().output
return [
{
'name': device['name'],
@ -681,65 +578,8 @@ class SmartthingsPlugin(Plugin):
'on': device['switch'],
}
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:

View File

@ -1,12 +1,9 @@
from abc import ABC, abstractmethod
from typing import List, Union
from platypush.entities import manages
from platypush.entities.switches import Switch
from platypush.plugins import Plugin, action
@manages(Switch)
class SwitchPlugin(Plugin, ABC):
"""
Abstract class for interacting with switch devices
@ -49,7 +46,7 @@ class SwitchPlugin(Plugin, ABC):
return devices
@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`).

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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