forked from platypush/platypush
Compare commits
102 commits
master
...
191-suppor
Author | SHA1 | Date | |
---|---|---|---|
3513ee3e1c | |||
0d0995d71d | |||
2898a33752 | |||
0919a0055d | |||
5b3e1317f4 | |||
1df71cb54a | |||
0689e05e96 | |||
89560e7c38 | |||
30dfdeecb0 | |||
f57f940d57 | |||
117f92e5b4 | |||
a5541c33b0 | |||
b23f45f45e | |||
088cf23958 | |||
e8f4b7c10e | |||
dd12d57552 | |||
5aa3750807 | |||
f760d44224 | |||
8d91fec771 | |||
c22c17a55d | |||
46df3a6a98 | |||
8e06b8c727 | |||
30a024befb | |||
b16af0a97f | |||
c7970842d7 | |||
7df67aca82 | |||
d29b377cf1 | |||
8d57cf06c2 | |||
975d37c562 | |||
90f067de61 | |||
f45df5d4d3 | |||
975991ba69 | |||
d22fbcd9db | |||
47f8520f3b | |||
d261b9bb9b | |||
9981cc4746 | |||
3e4b13d20f | |||
321a61d06d | |||
b22df768eb | |||
8e2154f2b5 | |||
a9751f21f1 | |||
135965176d | |||
ef6b57df31 | |||
7d4bd20df0 | |||
e6bfa1c50f | |||
332c91252c | |||
b35c761a43 | |||
08c0779347 | |||
595ebe49ca | |||
548d487e73 | |||
20530c2b6d | |||
9ddcf5eaeb | |||
2aa8778078 | |||
72617b4b75 | |||
be4d1e8e01 | |||
db4ad5825e | |||
4471001110 | |||
f17245e8c7 | |||
67ff585f6c | |||
17615ff028 | |||
532217be12 | |||
f301fd7e69 | |||
58861afb1c | |||
8ec9c8f203 | |||
3435f591eb | |||
19223bbbe1 | |||
453652ef76 | |||
b2ff66aa62 | |||
655d56f4da | |||
f52b556219 | |||
947b50b937 | |||
db7c2095ea | |||
e40b668380 | |||
d3dc86a5e2 | |||
28026b0428 | |||
44707731a8 | |||
948f37afd4 | |||
3b4f7d3dad | |||
2eeb1d4fea | |||
26ffc0b0e1 | |||
7b1a63e287 | |||
1c6ff2fa49 | |||
d311629403 | |||
d52ae2fb80 | |||
061268cdaf | |||
91ff47167b | |||
fe0f3202fe | |||
8a70f1d38e | |||
4b7eeaa4ed | |||
b43ed169c7 | |||
0dac2c0e92 | |||
28b3672432 | |||
9f2793118b | |||
9d9ec1dc59 | |||
b9c78ad913 | |||
91ff8d811f | |||
783238642d | |||
53da19b638 | |||
7459f0115b | |||
2c4c27855d | |||
9c25a131fa | |||
4ee7e4db29 |
78 changed files with 6199 additions and 1575 deletions
|
@ -20,6 +20,7 @@ Events
|
|||
platypush/events/custom.rst
|
||||
platypush/events/dbus.rst
|
||||
platypush/events/distance.rst
|
||||
platypush/events/entities.rst
|
||||
platypush/events/file.rst
|
||||
platypush/events/foursquare.rst
|
||||
platypush/events/geo.rst
|
||||
|
|
5
docs/source/platypush/events/entities.rst
Normal file
5
docs/source/platypush/events/entities.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
``entities``
|
||||
============
|
||||
|
||||
.. automodule:: platypush.message.event.entities
|
||||
:members:
|
5
docs/source/platypush/plugins/entities.rst
Normal file
5
docs/source/platypush/plugins/entities.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
``entities``
|
||||
============
|
||||
|
||||
.. automodule:: platypush.plugins.entities
|
||||
:members:
|
|
@ -32,6 +32,7 @@ Plugins
|
|||
platypush/plugins/db.rst
|
||||
platypush/plugins/dbus.rst
|
||||
platypush/plugins/dropbox.rst
|
||||
platypush/plugins/entities.rst
|
||||
platypush/plugins/esp.rst
|
||||
platypush/plugins/ffmpeg.rst
|
||||
platypush/plugins/file.rst
|
||||
|
|
|
@ -9,11 +9,13 @@ import argparse
|
|||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from .bus.redis import RedisBus
|
||||
from .config import Config
|
||||
from .context import register_backends, register_plugins
|
||||
from .cron.scheduler import CronScheduler
|
||||
from .entities import init_entities_engine, EntitiesEngine
|
||||
from .event.processor import EventProcessor
|
||||
from .logger import Logger
|
||||
from .message.event import Event
|
||||
|
@ -96,6 +98,7 @@ class Daemon:
|
|||
self.no_capture_stdout = no_capture_stdout
|
||||
self.no_capture_stderr = no_capture_stderr
|
||||
self.event_processor = EventProcessor()
|
||||
self.entities_engine: Optional[EntitiesEngine] = None
|
||||
self.requests_to_process = requests_to_process
|
||||
self.processed_requests = 0
|
||||
self.cron_scheduler = None
|
||||
|
@ -199,6 +202,7 @@ class Daemon:
|
|||
"""Stops the backends and the bus"""
|
||||
from .plugins import RunnablePlugin
|
||||
|
||||
if self.backends:
|
||||
for backend in self.backends.values():
|
||||
backend.stop()
|
||||
|
||||
|
@ -206,9 +210,17 @@ class Daemon:
|
|||
if isinstance(plugin, RunnablePlugin):
|
||||
plugin.stop()
|
||||
|
||||
if self.bus:
|
||||
self.bus.stop()
|
||||
self.bus = None
|
||||
|
||||
if self.cron_scheduler:
|
||||
self.cron_scheduler.stop()
|
||||
self.cron_scheduler = None
|
||||
|
||||
if self.entities_engine:
|
||||
self.entities_engine.stop()
|
||||
self.entities_engine = None
|
||||
|
||||
def run(self):
|
||||
"""Start the daemon"""
|
||||
|
@ -230,6 +242,9 @@ class Daemon:
|
|||
# Initialize the plugins
|
||||
register_plugins(bus=self.bus)
|
||||
|
||||
# Initialize the entities engine
|
||||
self.entities_engine = init_entities_engine()
|
||||
|
||||
# Start the cron scheduler
|
||||
if Config.get_cronjobs():
|
||||
self.cron_scheduler = CronScheduler(jobs=Config.get_cronjobs())
|
||||
|
|
|
@ -3,8 +3,7 @@ import os
|
|||
from typing import Optional, Union, List, Dict, Any
|
||||
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.config import Config
|
||||
|
@ -17,10 +16,10 @@ Session = scoped_session(sessionmaker())
|
|||
|
||||
|
||||
class Covid19Update(Base):
|
||||
""" Models the Covid19Data table """
|
||||
"""Models the Covid19Data table"""
|
||||
|
||||
__tablename__ = 'covid19data'
|
||||
__table_args__ = ({'sqlite_autoincrement': True})
|
||||
__table_args__ = {'sqlite_autoincrement': True}
|
||||
|
||||
country = Column(String, primary_key=True)
|
||||
confirmed = Column(Integer, nullable=False, default=0)
|
||||
|
@ -40,7 +39,12 @@ class Covid19Backend(Backend):
|
|||
"""
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
def __init__(self, country: Optional[Union[str, List[str]]], poll_seconds: Optional[float] = 3600.0, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
country: Optional[Union[str, List[str]]],
|
||||
poll_seconds: Optional[float] = 3600.0,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
:param country: Default country (or list of countries) to retrieve the stats for. It can either be the full
|
||||
country name or the country code. Special values:
|
||||
|
@ -56,7 +60,9 @@ class Covid19Backend(Backend):
|
|||
super().__init__(poll_seconds=poll_seconds, **kwargs)
|
||||
self._plugin: Covid19Plugin = get_plugin('covid19')
|
||||
self.country: List[str] = self._plugin._get_countries(country)
|
||||
self.workdir = os.path.join(os.path.expanduser(Config.get('workdir')), 'covid19')
|
||||
self.workdir = os.path.join(
|
||||
os.path.expanduser(Config.get('workdir')), 'covid19'
|
||||
)
|
||||
self.dbfile = os.path.join(self.workdir, 'data.db')
|
||||
os.makedirs(self.workdir, exist_ok=True)
|
||||
|
||||
|
@ -67,22 +73,30 @@ class Covid19Backend(Backend):
|
|||
self.logger.info('Stopped Covid19 backend')
|
||||
|
||||
def _process_update(self, summary: Dict[str, Any], session: Session):
|
||||
update_time = datetime.datetime.fromisoformat(summary['Date'].replace('Z', '+00:00'))
|
||||
update_time = datetime.datetime.fromisoformat(
|
||||
summary['Date'].replace('Z', '+00:00')
|
||||
)
|
||||
|
||||
self.bus.post(Covid19UpdateEvent(
|
||||
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'],
|
||||
session.merge(
|
||||
Covid19Update(
|
||||
country=summary['CountryCode'],
|
||||
confirmed=summary['TotalConfirmed'],
|
||||
deaths=summary['TotalDeaths'],
|
||||
recovered=summary['TotalRecovered'],
|
||||
last_updated_at=update_time))
|
||||
last_updated_at=update_time,
|
||||
)
|
||||
)
|
||||
|
||||
def loop(self):
|
||||
# noinspection PyUnresolvedReferences
|
||||
|
@ -90,23 +104,30 @@ class Covid19Backend(Backend):
|
|||
if not summaries:
|
||||
return
|
||||
|
||||
engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
|
||||
engine = create_engine(
|
||||
'sqlite:///{}'.format(self.dbfile),
|
||||
connect_args={'check_same_thread': False},
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session.configure(bind=engine)
|
||||
session = Session()
|
||||
|
||||
last_records = {
|
||||
record.country: record
|
||||
for record in session.query(Covid19Update).filter(Covid19Update.country.in_(self.country)).all()
|
||||
for record in session.query(Covid19Update)
|
||||
.filter(Covid19Update.country.in_(self.country))
|
||||
.all()
|
||||
}
|
||||
|
||||
for summary in summaries:
|
||||
country = summary['CountryCode']
|
||||
last_record = last_records.get(country)
|
||||
if not last_record or \
|
||||
summary['TotalConfirmed'] != last_record.confirmed or \
|
||||
summary['TotalDeaths'] != last_record.deaths or \
|
||||
summary['TotalRecovered'] != last_record.recovered:
|
||||
if (
|
||||
not last_record
|
||||
or summary['TotalConfirmed'] != last_record.confirmed
|
||||
or summary['TotalDeaths'] != last_record.deaths
|
||||
or summary['TotalRecovered'] != last_record.recovered
|
||||
):
|
||||
self._process_update(summary=summary, session=session)
|
||||
|
||||
session.commit()
|
||||
|
|
|
@ -6,15 +6,28 @@ from typing import Optional, List
|
|||
|
||||
import requests
|
||||
from sqlalchemy import create_engine, Column, String, DateTime
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.config import Config
|
||||
from platypush.message.event.github import GithubPushEvent, GithubCommitCommentEvent, GithubCreateEvent, \
|
||||
GithubDeleteEvent, GithubEvent, GithubForkEvent, GithubWikiEvent, GithubIssueCommentEvent, GithubIssueEvent, \
|
||||
GithubMemberEvent, GithubPublicEvent, GithubPullRequestEvent, GithubPullRequestReviewCommentEvent, \
|
||||
GithubReleaseEvent, GithubSponsorshipEvent, GithubWatchEvent
|
||||
from platypush.message.event.github import (
|
||||
GithubPushEvent,
|
||||
GithubCommitCommentEvent,
|
||||
GithubCreateEvent,
|
||||
GithubDeleteEvent,
|
||||
GithubEvent,
|
||||
GithubForkEvent,
|
||||
GithubWikiEvent,
|
||||
GithubIssueCommentEvent,
|
||||
GithubIssueEvent,
|
||||
GithubMemberEvent,
|
||||
GithubPublicEvent,
|
||||
GithubPullRequestEvent,
|
||||
GithubPullRequestReviewCommentEvent,
|
||||
GithubReleaseEvent,
|
||||
GithubSponsorshipEvent,
|
||||
GithubWatchEvent,
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
Session = scoped_session(sessionmaker())
|
||||
|
@ -71,8 +84,17 @@ class GithubBackend(Backend):
|
|||
|
||||
_base_url = 'https://api.github.com'
|
||||
|
||||
def __init__(self, user: str, user_token: str, repos: Optional[List[str]] = None, org: Optional[str] = None,
|
||||
poll_seconds: int = 60, max_events_per_scan: Optional[int] = 10, *args, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
user: str,
|
||||
user_token: str,
|
||||
repos: Optional[List[str]] = None,
|
||||
org: Optional[str] = None,
|
||||
poll_seconds: int = 60,
|
||||
max_events_per_scan: Optional[int] = 10,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
If neither ``repos`` nor ``org`` is specified then the backend will monitor all new events on user level.
|
||||
|
||||
|
@ -102,17 +124,23 @@ class GithubBackend(Backend):
|
|||
|
||||
def _request(self, uri: str, method: str = 'get') -> dict:
|
||||
method = getattr(requests, method.lower())
|
||||
return method(self._base_url + uri, auth=(self.user, self.user_token),
|
||||
headers={'Accept': 'application/vnd.github.v3+json'}).json()
|
||||
return method(
|
||||
self._base_url + uri,
|
||||
auth=(self.user, self.user_token),
|
||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
||||
).json()
|
||||
|
||||
def _init_db(self):
|
||||
engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
|
||||
engine = create_engine(
|
||||
'sqlite:///{}'.format(self.dbfile),
|
||||
connect_args={'check_same_thread': False},
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session.configure(bind=engine)
|
||||
|
||||
@staticmethod
|
||||
def _to_datetime(time_string: str) -> datetime.datetime:
|
||||
""" Convert ISO 8061 string format with leading 'Z' into something understandable by Python """
|
||||
"""Convert ISO 8061 string format with leading 'Z' into something understandable by Python"""
|
||||
return datetime.datetime.fromisoformat(time_string[:-1] + '+00:00')
|
||||
|
||||
@staticmethod
|
||||
|
@ -128,7 +156,11 @@ class GithubBackend(Backend):
|
|||
def _get_last_event_time(self, uri: str):
|
||||
with self.db_lock:
|
||||
record = self._get_or_create_resource(uri=uri, session=Session())
|
||||
return record.last_updated_at.replace(tzinfo=datetime.timezone.utc) if record.last_updated_at else None
|
||||
return (
|
||||
record.last_updated_at.replace(tzinfo=datetime.timezone.utc)
|
||||
if record.last_updated_at
|
||||
else None
|
||||
)
|
||||
|
||||
def _update_last_event_time(self, uri: str, last_updated_at: datetime.datetime):
|
||||
with self.db_lock:
|
||||
|
@ -158,9 +190,18 @@ class GithubBackend(Backend):
|
|||
'WatchEvent': GithubWatchEvent,
|
||||
}
|
||||
|
||||
event_type = event_mapping[event['type']] if event['type'] in event_mapping else GithubEvent
|
||||
return event_type(event_type=event['type'], actor=event['actor'], repo=event.get('repo', {}),
|
||||
payload=event['payload'], created_at=cls._to_datetime(event['created_at']))
|
||||
event_type = (
|
||||
event_mapping[event['type']]
|
||||
if event['type'] in event_mapping
|
||||
else GithubEvent
|
||||
)
|
||||
return event_type(
|
||||
event_type=event['type'],
|
||||
actor=event['actor'],
|
||||
repo=event.get('repo', {}),
|
||||
payload=event['payload'],
|
||||
created_at=cls._to_datetime(event['created_at']),
|
||||
)
|
||||
|
||||
def _events_monitor(self, uri: str, method: str = 'get'):
|
||||
def thread():
|
||||
|
@ -175,7 +216,10 @@ class GithubBackend(Backend):
|
|||
fired_events = []
|
||||
|
||||
for event in events:
|
||||
if self.max_events_per_scan and len(fired_events) >= self.max_events_per_scan:
|
||||
if (
|
||||
self.max_events_per_scan
|
||||
and len(fired_events) >= self.max_events_per_scan
|
||||
):
|
||||
break
|
||||
|
||||
event_time = self._to_datetime(event['created_at'])
|
||||
|
@ -189,12 +233,17 @@ class GithubBackend(Backend):
|
|||
for event in fired_events:
|
||||
self.bus.post(event)
|
||||
|
||||
self._update_last_event_time(uri=uri, last_updated_at=new_last_event_time)
|
||||
self._update_last_event_time(
|
||||
uri=uri, last_updated_at=new_last_event_time
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning('Encountered exception while fetching events from {}: {}'.format(
|
||||
uri, str(e)))
|
||||
self.logger.warning(
|
||||
'Encountered exception while fetching events from {}: {}'.format(
|
||||
uri, str(e)
|
||||
)
|
||||
)
|
||||
self.logger.exception(e)
|
||||
finally:
|
||||
|
||||
if self.wait_stop(timeout=self.poll_seconds):
|
||||
break
|
||||
|
||||
|
@ -206,12 +255,30 @@ class GithubBackend(Backend):
|
|||
|
||||
if self.repos:
|
||||
for repo in self.repos:
|
||||
monitors.append(threading.Thread(target=self._events_monitor('/networks/{repo}/events'.format(repo=repo))))
|
||||
monitors.append(
|
||||
threading.Thread(
|
||||
target=self._events_monitor(
|
||||
'/networks/{repo}/events'.format(repo=repo)
|
||||
)
|
||||
)
|
||||
)
|
||||
if self.org:
|
||||
monitors.append(threading.Thread(target=self._events_monitor('/orgs/{org}/events'.format(org=self.org))))
|
||||
monitors.append(
|
||||
threading.Thread(
|
||||
target=self._events_monitor(
|
||||
'/orgs/{org}/events'.format(org=self.org)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if not (self.repos or self.org):
|
||||
monitors.append(threading.Thread(target=self._events_monitor('/users/{user}/events'.format(user=self.user))))
|
||||
monitors.append(
|
||||
threading.Thread(
|
||||
target=self._events_monitor(
|
||||
'/users/{user}/events'.format(user=self.user)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for monitor in monitors:
|
||||
monitor.start()
|
||||
|
@ -222,4 +289,5 @@ class GithubBackend(Backend):
|
|||
|
||||
self.logger.info('Github backend terminated')
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -2,11 +2,17 @@ import datetime
|
|||
import enum
|
||||
import os
|
||||
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime, \
|
||||
Enum, ForeignKey
|
||||
from sqlalchemy import (
|
||||
create_engine,
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
)
|
||||
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from platypush.backend.http.request import HttpRequest
|
||||
|
@ -44,18 +50,31 @@ class RssUpdates(HttpRequest):
|
|||
|
||||
"""
|
||||
|
||||
user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + \
|
||||
'Chrome/62.0.3202.94 Safari/537.36'
|
||||
user_agent = (
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
+ 'Chrome/62.0.3202.94 Safari/537.36'
|
||||
)
|
||||
|
||||
def __init__(self, url, title=None, headers=None, params=None, max_entries=None,
|
||||
extract_content=False, digest_format=None, user_agent: str = user_agent,
|
||||
body_style: str = 'font-size: 22px; ' +
|
||||
'font-family: "Merriweather", Georgia, "Times New Roman", Times, serif;',
|
||||
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):
|
||||
article_content_style: str = '',
|
||||
*argv,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
:param url: URL to the RSS feed to be monitored.
|
||||
:param title: Optional title for the feed.
|
||||
|
@ -91,7 +110,9 @@ class RssUpdates(HttpRequest):
|
|||
# If true, then the http.webpage plugin will be used to parse the content
|
||||
self.extract_content = extract_content
|
||||
|
||||
self.digest_format = digest_format.lower() if digest_format else None # Supported formats: html, pdf
|
||||
self.digest_format = (
|
||||
digest_format.lower() if digest_format else None
|
||||
) # Supported formats: html, pdf
|
||||
|
||||
os.makedirs(os.path.expanduser(os.path.dirname(self.dbfile)), exist_ok=True)
|
||||
|
||||
|
@ -119,7 +140,11 @@ class RssUpdates(HttpRequest):
|
|||
|
||||
@staticmethod
|
||||
def _get_latest_update(session, source_id):
|
||||
return session.query(func.max(FeedEntry.published)).filter_by(source_id=source_id).scalar()
|
||||
return (
|
||||
session.query(func.max(FeedEntry.published))
|
||||
.filter_by(source_id=source_id)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
def _parse_entry_content(self, link):
|
||||
self.logger.info('Extracting content from {}'.format(link))
|
||||
|
@ -130,14 +155,20 @@ class RssUpdates(HttpRequest):
|
|||
errors = response.errors
|
||||
|
||||
if not output:
|
||||
self.logger.warning('Mercury parser error: {}'.format(errors or '[unknown error]'))
|
||||
self.logger.warning(
|
||||
'Mercury parser error: {}'.format(errors or '[unknown error]')
|
||||
)
|
||||
return
|
||||
|
||||
return output.get('content')
|
||||
|
||||
def get_new_items(self, response):
|
||||
import feedparser
|
||||
engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
|
||||
|
||||
engine = create_engine(
|
||||
'sqlite:///{}'.format(self.dbfile),
|
||||
connect_args={'check_same_thread': False},
|
||||
)
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
Session.configure(bind=engine)
|
||||
|
@ -157,12 +188,16 @@ class RssUpdates(HttpRequest):
|
|||
|
||||
content = u'''
|
||||
<h1 style="{title_style}">{title}</h1>
|
||||
<h2 style="{subtitle_style}">Feeds digest generated on {creation_date}</h2>'''.\
|
||||
format(title_style=self.title_style, title=self.title, subtitle_style=self.subtitle_style,
|
||||
creation_date=datetime.datetime.now().strftime('%d %B %Y, %H:%M'))
|
||||
<h2 style="{subtitle_style}">Feeds digest generated on {creation_date}</h2>'''.format(
|
||||
title_style=self.title_style,
|
||||
title=self.title,
|
||||
subtitle_style=self.subtitle_style,
|
||||
creation_date=datetime.datetime.now().strftime('%d %B %Y, %H:%M'),
|
||||
)
|
||||
|
||||
self.logger.info('Parsed {:d} items from RSS feed <{}>'
|
||||
.format(len(feed.entries), self.url))
|
||||
self.logger.info(
|
||||
'Parsed {:d} items from RSS feed <{}>'.format(len(feed.entries), self.url)
|
||||
)
|
||||
|
||||
for entry in feed.entries:
|
||||
if not entry.published_parsed:
|
||||
|
@ -171,9 +206,10 @@ class RssUpdates(HttpRequest):
|
|||
try:
|
||||
entry_timestamp = datetime.datetime(*entry.published_parsed[:6])
|
||||
|
||||
if latest_update is None \
|
||||
or entry_timestamp > latest_update:
|
||||
self.logger.info('Processed new item from RSS feed <{}>'.format(self.url))
|
||||
if latest_update is None or entry_timestamp > latest_update:
|
||||
self.logger.info(
|
||||
'Processed new item from RSS feed <{}>'.format(self.url)
|
||||
)
|
||||
entry.summary = entry.summary if hasattr(entry, 'summary') else None
|
||||
|
||||
if self.extract_content:
|
||||
|
@ -188,9 +224,13 @@ class RssUpdates(HttpRequest):
|
|||
<a href="{link}" target="_blank" style="{article_link_style}">{title}</a>
|
||||
</h1>
|
||||
<div class="_parsed-content" style="{article_content_style}">{content}</div>'''.format(
|
||||
article_title_style=self.article_title_style, article_link_style=self.article_link_style,
|
||||
article_content_style=self.article_content_style, link=entry.link, title=entry.title,
|
||||
content=entry.content)
|
||||
article_title_style=self.article_title_style,
|
||||
article_link_style=self.article_link_style,
|
||||
article_content_style=self.article_content_style,
|
||||
link=entry.link,
|
||||
title=entry.title,
|
||||
content=entry.content,
|
||||
)
|
||||
|
||||
e = {
|
||||
'entry_id': entry.id,
|
||||
|
@ -207,21 +247,32 @@ class RssUpdates(HttpRequest):
|
|||
if self.max_entries and len(entries) > self.max_entries:
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.warning('Exception encountered while parsing RSS ' +
|
||||
'RSS feed {}: {}'.format(entry.link, str(e)))
|
||||
self.logger.warning(
|
||||
'Exception encountered while parsing RSS '
|
||||
+ f'RSS feed {entry.link}: {e}'
|
||||
)
|
||||
self.logger.exception(e)
|
||||
|
||||
source_record.last_updated_at = parse_start_time
|
||||
digest_filename = None
|
||||
|
||||
if entries:
|
||||
self.logger.info('Parsed {} new entries from the RSS feed {}'.format(
|
||||
len(entries), self.title))
|
||||
self.logger.info(
|
||||
'Parsed {} new entries from the RSS feed {}'.format(
|
||||
len(entries), self.title
|
||||
)
|
||||
)
|
||||
|
||||
if self.digest_format:
|
||||
digest_filename = os.path.join(self.workdir, 'cache', '{}_{}.{}'.format(
|
||||
digest_filename = os.path.join(
|
||||
self.workdir,
|
||||
'cache',
|
||||
'{}_{}.{}'.format(
|
||||
datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
self.title, self.digest_format))
|
||||
self.title,
|
||||
self.digest_format,
|
||||
),
|
||||
)
|
||||
|
||||
os.makedirs(os.path.dirname(digest_filename), exist_ok=True)
|
||||
|
||||
|
@ -233,12 +284,15 @@ class RssUpdates(HttpRequest):
|
|||
</head>
|
||||
<body style="{body_style}">{content}</body>
|
||||
</html>
|
||||
'''.format(title=self.title, body_style=self.body_style, content=content)
|
||||
'''.format(
|
||||
title=self.title, body_style=self.body_style, content=content
|
||||
)
|
||||
|
||||
with open(digest_filename, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
elif self.digest_format == 'pdf':
|
||||
from weasyprint import HTML, CSS
|
||||
|
||||
try:
|
||||
from weasyprint.fonts import FontConfiguration
|
||||
except ImportError:
|
||||
|
@ -246,37 +300,47 @@ class RssUpdates(HttpRequest):
|
|||
|
||||
body_style = 'body { ' + self.body_style + ' }'
|
||||
font_config = FontConfiguration()
|
||||
css = [CSS('https://fonts.googleapis.com/css?family=Merriweather'),
|
||||
CSS(string=body_style, font_config=font_config)]
|
||||
css = [
|
||||
CSS('https://fonts.googleapis.com/css?family=Merriweather'),
|
||||
CSS(string=body_style, font_config=font_config),
|
||||
]
|
||||
|
||||
HTML(string=content).write_pdf(digest_filename, stylesheets=css)
|
||||
else:
|
||||
raise RuntimeError('Unsupported format: {}. Supported formats: ' +
|
||||
'html or pdf'.format(self.digest_format))
|
||||
raise RuntimeError(
|
||||
f'Unsupported format: {self.digest_format}. Supported formats: html, pdf'
|
||||
)
|
||||
|
||||
digest_entry = FeedDigest(source_id=source_record.id,
|
||||
digest_entry = FeedDigest(
|
||||
source_id=source_record.id,
|
||||
format=self.digest_format,
|
||||
filename=digest_filename)
|
||||
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,
|
||||
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)
|
||||
digest_filename=digest_filename,
|
||||
)
|
||||
|
||||
|
||||
class FeedSource(Base):
|
||||
""" Models the FeedSource table, containing RSS sources to be parsed """
|
||||
"""Models the FeedSource table, containing RSS sources to be parsed"""
|
||||
|
||||
__tablename__ = 'FeedSource'
|
||||
__table_args__ = ({'sqlite_autoincrement': True})
|
||||
__table_args__ = {'sqlite_autoincrement': True}
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
title = Column(String)
|
||||
|
@ -285,10 +349,10 @@ class FeedSource(Base):
|
|||
|
||||
|
||||
class FeedEntry(Base):
|
||||
""" Models the FeedEntry table, which contains RSS entries """
|
||||
"""Models the FeedEntry table, which contains RSS entries"""
|
||||
|
||||
__tablename__ = 'FeedEntry'
|
||||
__table_args__ = ({'sqlite_autoincrement': True})
|
||||
__table_args__ = {'sqlite_autoincrement': True}
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
entry_id = Column(String)
|
||||
|
@ -301,15 +365,15 @@ class FeedEntry(Base):
|
|||
|
||||
|
||||
class FeedDigest(Base):
|
||||
""" Models the FeedDigest table, containing feed digests either in HTML
|
||||
or PDF format """
|
||||
"""Models the FeedDigest table, containing feed digests either in HTML
|
||||
or PDF format"""
|
||||
|
||||
class DigestFormat(enum.Enum):
|
||||
html = 1
|
||||
pdf = 2
|
||||
|
||||
__tablename__ = 'FeedDigest'
|
||||
__table_args__ = ({'sqlite_autoincrement': True})
|
||||
__table_args__ = {'sqlite_autoincrement': True}
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
source_id = Column(Integer, ForeignKey('FeedSource.id'), nullable=False)
|
||||
|
@ -317,4 +381,5 @@ class FeedDigest(Base):
|
|||
filename = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
88
platypush/backend/http/webapp/package-lock.json
generated
88
platypush/backend/http/webapp/package-lock.json
generated
|
@ -8,15 +8,15 @@
|
|||
"name": "platypush",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.4",
|
||||
"@fortawesome/fontawesome-free": "^6.1.1",
|
||||
"axios": "^0.21.4",
|
||||
"core-js": "^3.23.4",
|
||||
"core-js": "^3.21.1",
|
||||
"lato-font": "^3.0.0",
|
||||
"mitt": "^2.1.0",
|
||||
"sass": "^1.53.0",
|
||||
"sass-loader": "^10.3.1",
|
||||
"sass": "^1.49.9",
|
||||
"sass-loader": "^10.2.1",
|
||||
"vue": "^3.2.13",
|
||||
"vue-router": "^4.1.2",
|
||||
"vue-router": "^4.0.14",
|
||||
"vue-skycons": "^4.2.0",
|
||||
"w3css": "^2.7.0"
|
||||
},
|
||||
|
@ -1731,9 +1731,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-free": {
|
||||
"version": "5.15.4",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
|
||||
"integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==",
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz",
|
||||
"integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg==",
|
||||
"hasInstallScript": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
@ -2768,9 +2768,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.2.1.tgz",
|
||||
"integrity": "sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ=="
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.1.3.tgz",
|
||||
"integrity": "sha512-79InfO2xHv+WHIrH1bHXQUiQD/wMls9qBk6WVwGCbdwP7/3zINtvqPNMtmSHXsIKjvUAHc8L0ouOj6ZQQRmcXg=="
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.2.31",
|
||||
|
@ -4205,9 +4205,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.23.4.tgz",
|
||||
"integrity": "sha512-vjsKqRc1RyAJC3Ye2kYqgfdThb3zYnx9CrqoCcjMOENMtQPC7ZViBvlDxwYU/2z2NI/IPuiXw5mT4hWhddqjzQ==",
|
||||
"version": "3.21.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz",
|
||||
"integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==",
|
||||
"hasInstallScript": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
@ -9402,9 +9402,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.53.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.53.0.tgz",
|
||||
"integrity": "sha512-zb/oMirbKhUgRQ0/GFz8TSAwRq2IlR29vOUJZOx0l8sV+CkHUfHa4u5nqrG+1VceZp7Jfj59SVW9ogdhTvJDcQ==",
|
||||
"version": "1.49.9",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz",
|
||||
"integrity": "sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==",
|
||||
"dependencies": {
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
|
@ -9418,9 +9418,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/sass-loader": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.3.1.tgz",
|
||||
"integrity": "sha512-y2aBdtYkbqorVavkC3fcJIUDGIegzDWPn3/LAFhsf3G+MzPKTJx37sROf5pXtUeggSVbNbmfj8TgRaSLMelXRA==",
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.2.1.tgz",
|
||||
"integrity": "sha512-RRvWl+3K2LSMezIsd008ErK4rk6CulIMSwrcc2aZvjymUgKo/vjXGp1rSWmfTUX7bblEOz8tst4wBwWtCGBqKA==",
|
||||
"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 || ^7.0.0",
|
||||
"node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0",
|
||||
"sass": "^1.3.0",
|
||||
"webpack": "^4.36.0 || ^5.0.0"
|
||||
},
|
||||
|
@ -10825,11 +10825,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.2.tgz",
|
||||
"integrity": "sha512-5BP1qXFncVRwgV/XnqzsKApdMjQPqWIpoUBdL1ynz8HyLxIX/UDAx7Ql2BjmA5CXT/p61JfZvkpiFWFpaqcfag==",
|
||||
"version": "4.0.14",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.14.tgz",
|
||||
"integrity": "sha512-wAO6zF9zxA3u+7AkMPqw9LjoUCjSxfFvINQj3E/DceTt6uEz1XZLraDhdg2EYmvVwTBSGlLYsUw8bDmx0754Mw==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.1.4"
|
||||
"@vue/devtools-api": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
|
@ -12917,9 +12917,9 @@
|
|||
}
|
||||
},
|
||||
"@fortawesome/fontawesome-free": {
|
||||
"version": "5.15.4",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
|
||||
"integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg=="
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz",
|
||||
"integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg=="
|
||||
},
|
||||
"@humanwhocodes/config-array": {
|
||||
"version": "0.5.0",
|
||||
|
@ -13770,9 +13770,9 @@
|
|||
}
|
||||
},
|
||||
"@vue/devtools-api": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.2.1.tgz",
|
||||
"integrity": "sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ=="
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.1.3.tgz",
|
||||
"integrity": "sha512-79InfO2xHv+WHIrH1bHXQUiQD/wMls9qBk6WVwGCbdwP7/3zINtvqPNMtmSHXsIKjvUAHc8L0ouOj6ZQQRmcXg=="
|
||||
},
|
||||
"@vue/reactivity": {
|
||||
"version": "3.2.31",
|
||||
|
@ -14868,9 +14868,9 @@
|
|||
}
|
||||
},
|
||||
"core-js": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.23.4.tgz",
|
||||
"integrity": "sha512-vjsKqRc1RyAJC3Ye2kYqgfdThb3zYnx9CrqoCcjMOENMtQPC7ZViBvlDxwYU/2z2NI/IPuiXw5mT4hWhddqjzQ=="
|
||||
"version": "3.21.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz",
|
||||
"integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig=="
|
||||
},
|
||||
"core-js-compat": {
|
||||
"version": "3.21.1",
|
||||
|
@ -18676,9 +18676,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"sass": {
|
||||
"version": "1.53.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.53.0.tgz",
|
||||
"integrity": "sha512-zb/oMirbKhUgRQ0/GFz8TSAwRq2IlR29vOUJZOx0l8sV+CkHUfHa4u5nqrG+1VceZp7Jfj59SVW9ogdhTvJDcQ==",
|
||||
"version": "1.49.9",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz",
|
||||
"integrity": "sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==",
|
||||
"requires": {
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
|
@ -18686,9 +18686,9 @@
|
|||
}
|
||||
},
|
||||
"sass-loader": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.3.1.tgz",
|
||||
"integrity": "sha512-y2aBdtYkbqorVavkC3fcJIUDGIegzDWPn3/LAFhsf3G+MzPKTJx37sROf5pXtUeggSVbNbmfj8TgRaSLMelXRA==",
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.2.1.tgz",
|
||||
"integrity": "sha512-RRvWl+3K2LSMezIsd008ErK4rk6CulIMSwrcc2aZvjymUgKo/vjXGp1rSWmfTUX7bblEOz8tst4wBwWtCGBqKA==",
|
||||
"requires": {
|
||||
"klona": "^2.0.4",
|
||||
"loader-utils": "^2.0.0",
|
||||
|
@ -19746,11 +19746,11 @@
|
|||
}
|
||||
},
|
||||
"vue-router": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.2.tgz",
|
||||
"integrity": "sha512-5BP1qXFncVRwgV/XnqzsKApdMjQPqWIpoUBdL1ynz8HyLxIX/UDAx7Ql2BjmA5CXT/p61JfZvkpiFWFpaqcfag==",
|
||||
"version": "4.0.14",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.14.tgz",
|
||||
"integrity": "sha512-wAO6zF9zxA3u+7AkMPqw9LjoUCjSxfFvINQj3E/DceTt6uEz1XZLraDhdg2EYmvVwTBSGlLYsUw8bDmx0754Mw==",
|
||||
"requires": {
|
||||
"@vue/devtools-api": "^6.1.4"
|
||||
"@vue/devtools-api": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"vue-skycons": {
|
||||
|
|
|
@ -8,15 +8,15 @@
|
|||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.4",
|
||||
"@fortawesome/fontawesome-free": "^6.1.1",
|
||||
"axios": "^0.21.4",
|
||||
"core-js": "^3.23.4",
|
||||
"core-js": "^3.21.1",
|
||||
"lato-font": "^3.0.0",
|
||||
"mitt": "^2.1.0",
|
||||
"sass": "^1.53.0",
|
||||
"sass-loader": "^10.3.1",
|
||||
"sass": "^1.49.9",
|
||||
"sass-loader": "^10.2.1",
|
||||
"vue": "^3.2.13",
|
||||
"vue-router": "^4.1.2",
|
||||
"vue-router": "^4.0.14",
|
||||
"vue-skycons": "^4.2.0",
|
||||
"w3css": "^2.7.0"
|
||||
},
|
||||
|
|
BIN
platypush/backend/http/webapp/public/icons/smartthings.png
Normal file
BIN
platypush/backend/http/webapp/public/icons/smartthings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4 KiB |
BIN
platypush/backend/http/webapp/public/img/spinner.gif
Normal file
BIN
platypush/backend/http/webapp/public/img/spinner.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
|
@ -5,10 +5,11 @@ import DateTime from "@/utils/DateTime";
|
|||
import Events from "@/utils/Events";
|
||||
import Notification from "@/utils/Notification";
|
||||
import Screen from "@/utils/Screen";
|
||||
import Text from "@/utils/Text";
|
||||
import Types from "@/utils/Types";
|
||||
|
||||
export default {
|
||||
name: "Utils",
|
||||
mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Types],
|
||||
mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Text, Types],
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -17,6 +17,9 @@
|
|||
"camera.pi": {
|
||||
"class": "fas fa-camera"
|
||||
},
|
||||
"entities": {
|
||||
"class": "fa fa-home"
|
||||
},
|
||||
"execute": {
|
||||
"class": "fa fa-play"
|
||||
},
|
||||
|
@ -59,9 +62,21 @@
|
|||
"rtorrent": {
|
||||
"class": "fa fa-magnet"
|
||||
},
|
||||
"smartthings": {
|
||||
"imgUrl": "/icons/smartthings.png"
|
||||
},
|
||||
"switches": {
|
||||
"class": "fas fa-toggle-on"
|
||||
},
|
||||
"switch.switchbot": {
|
||||
"class": "fas fa-toggle-on"
|
||||
},
|
||||
"switch.tplink": {
|
||||
"class": "fas fa-toggle-on"
|
||||
},
|
||||
"switchbot": {
|
||||
"class": "fas fa-toggle-on"
|
||||
},
|
||||
"sound": {
|
||||
"class": "fa fa-microphone"
|
||||
},
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
</div>
|
||||
|
||||
<ul class="plugins">
|
||||
<li v-for="name in Object.keys(panels).sort()" :key="name" class="entry" :class="{selected: name === selectedPanel}"
|
||||
<li v-for="name in panelNames" :key="name" class="entry" :class="{selected: name === selectedPanel}"
|
||||
:title="name" @click="onItemClick(name)">
|
||||
<a :href="`/#${name}`">
|
||||
<span class="icon">
|
||||
|
@ -14,7 +14,7 @@
|
|||
<img :src="icons[name].imgUrl" v-else-if="icons[name]?.imgUrl" alt="name"/>
|
||||
<i class="fas fa-puzzle-piece" v-else />
|
||||
</span>
|
||||
<span class="name" v-if="!collapsed" v-text="name" />
|
||||
<span class="name" v-if="!collapsed" v-text="name == 'entities' ? 'Home' : name" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -66,6 +66,16 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
panelNames() {
|
||||
let panelNames = Object.keys(this.panels)
|
||||
const homeIdx = panelNames.indexOf('entities')
|
||||
if (homeIdx >= 0)
|
||||
return ['entities'].concat((panelNames.slice(0, homeIdx).concat(panelNames.slice(homeIdx+1))).sort())
|
||||
return panelNames.sort()
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onItemClick(name) {
|
||||
this.$emit('select', name)
|
||||
|
@ -80,11 +90,6 @@ export default {
|
|||
host: null,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.isMobile() && !this.$root.$route.hash.length)
|
||||
this.collapsed = false
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<Modal ref="modal" :title="title">
|
||||
<div class="dialog-content">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<form class="buttons" @submit.prevent="onConfirm">
|
||||
<button type="submit" class="ok-btn" @click="onConfirm" @touch="onConfirm">
|
||||
<i class="fas fa-check" /> {{ confirmText }}
|
||||
</button>
|
||||
<button type="button" class="cancel-btn" @click="close" @touch="close">
|
||||
<i class="fas fa-xmark" /> {{ cancelText }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
export default {
|
||||
emits: ['input', 'click', 'touch'],
|
||||
components: {Modal},
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: "OK",
|
||||
},
|
||||
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: "Cancel",
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onConfirm() {
|
||||
this.$emit('input')
|
||||
this.close()
|
||||
},
|
||||
|
||||
show() {
|
||||
this.$refs.modal.show()
|
||||
},
|
||||
|
||||
close() {
|
||||
this.$refs.modal.hide()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.modal) {
|
||||
.dialog-content {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: right;
|
||||
padding: 1em 0 1em 1em;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 -1px 2px 0 $default-shadow-color;
|
||||
|
||||
button {
|
||||
margin-right: 1em;
|
||||
padding: 0.5em 1em;
|
||||
border: 1px solid $border-color-2;
|
||||
border-radius: 1em;
|
||||
|
||||
&:hover {
|
||||
background: $hover-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -37,6 +37,11 @@ export default {
|
|||
title: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
keepOpenOnItemClick: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
|
|
|
@ -1,20 +1,27 @@
|
|||
<template>
|
||||
<div class="row item" :class="itemClass" @click="clicked">
|
||||
<div class="col-1 icon" v-if="iconClass">
|
||||
<i :class="iconClass" />
|
||||
<div class="col-1 icon" v-if="iconClass?.length || iconUrl?.length">
|
||||
<Icon :class="iconClass" :url="iconUrl" />
|
||||
</div>
|
||||
<div class="text" :class="{'col-11': iconClass != null}" v-text="text" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Icon from "@/components/elements/Icon";
|
||||
|
||||
export default {
|
||||
name: "DropdownItem",
|
||||
components: {Icon},
|
||||
props: {
|
||||
iconClass: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
iconUrl: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
text: {
|
||||
type: String,
|
||||
},
|
||||
|
@ -31,7 +38,11 @@ export default {
|
|||
|
||||
methods: {
|
||||
clicked(event) {
|
||||
if (this.disabled)
|
||||
return false
|
||||
|
||||
this.$parent.$emit('click', event)
|
||||
if (!this.$parent.keepOpenOnItemClick)
|
||||
this.$parent.visible = false
|
||||
}
|
||||
}
|
||||
|
@ -55,7 +66,18 @@ export default {
|
|||
}
|
||||
|
||||
.icon {
|
||||
margin: 0 .5em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
::v-deep(.icon-container) {
|
||||
width: 2em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
margin: 0 1.5em 0 .5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<button class="edit-btn"
|
||||
@click="proxy($event)" @touch="proxy($event)" @input="proxy($event)"
|
||||
>
|
||||
<i class="fas fa-pen-to-square" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ['input', 'click', 'touch'],
|
||||
methods: {
|
||||
proxy(e) {
|
||||
this.$emit(e.type, e)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-btn {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0 0.25em;
|
||||
margin-left: 0.25em;
|
||||
border: 1px solid rgba(0, 0, 0, 0);
|
||||
|
||||
&:hover {
|
||||
background: $hover-bg;
|
||||
border: 1px solid $selected-fg;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div class="icon-container">
|
||||
<img class="icon" :src="url" :alt="alt" v-if="url?.length">
|
||||
<i class="icon" :class="className" :style="{color: color}"
|
||||
v-else-if="className?.length" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
class: {
|
||||
type: String,
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
className() {
|
||||
return this.class
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.icon-container {
|
||||
display: inline-flex;
|
||||
width: $icon-container-size;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<form @submit.prevent="submit" class="name-editor">
|
||||
<input type="text" v-model="text" :disabled="disabled">
|
||||
<button type="submit">
|
||||
<i class="fas fa-circle-check" />
|
||||
</button>
|
||||
<button class="cancel" @click="$emit('cancel')" @touch="$emit('cancel')">
|
||||
<i class="fas fa-ban" />
|
||||
</button>
|
||||
<slot />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ['input', 'cancel'],
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
deafult: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
text: null,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
proxy(e) {
|
||||
this.$emit(e.type, e)
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.$emit('input', this.text)
|
||||
return false
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.text = this.value
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.name-editor {
|
||||
background: #00000000;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0 0.5em;
|
||||
|
||||
&.confirm {
|
||||
color: $selected-fg;
|
||||
}
|
||||
|
||||
&.cancel {
|
||||
color: $error-fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,51 @@
|
|||
<template>
|
||||
<div class="no-items-container">
|
||||
<div class="no-items fade-in">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "NoItems",
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.no-items-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.no-items {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
@include from($tablet) {
|
||||
min-width: 80%;
|
||||
}
|
||||
|
||||
@include from($desktop) {
|
||||
min-width: 50%;
|
||||
max-width: 35em;
|
||||
}
|
||||
|
||||
@include from($fullhd) {
|
||||
min-width: 33%;
|
||||
}
|
||||
|
||||
background: $background-color;
|
||||
margin: 1em;
|
||||
padding: 1em;
|
||||
font-size: 1.5em;
|
||||
color: $no-items-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 1em;
|
||||
box-shadow: $border-shadow-bottom;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -62,7 +62,7 @@ export default {
|
|||
},
|
||||
|
||||
update(value) {
|
||||
const percent = (value * 100) / (this.range[1] - this.range[0])
|
||||
const percent = ((value - this.range[0]) * 100) / (this.range[1] - this.range[0])
|
||||
this.$refs.thumb.style.left = `${percent}%`
|
||||
this.$refs.thumb.style.transform = `translate(-${percent}%, -50%)`
|
||||
this.$refs.track.style.width = `${percent}%`
|
||||
|
|
|
@ -59,7 +59,7 @@ export default {
|
|||
display: none;
|
||||
& + label {
|
||||
border-radius: 1em;
|
||||
display: block;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: box-shadow .4s;
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<div class="row item entity-container">
|
||||
<component :is="component"
|
||||
:value="value"
|
||||
:loading="loading"
|
||||
:error="error || value?.reachable == false"
|
||||
@input="$emit('input', $event)"
|
||||
@loading="$emit('loading', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import EntityMixin from "./EntityMixin"
|
||||
|
||||
export default {
|
||||
name: "Entity",
|
||||
mixins: [EntityMixin],
|
||||
emits: ['input', 'loading'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
component: null,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.type !== 'Entity')
|
||||
this.component = defineAsyncComponent(
|
||||
() => import(`@/components/panels/Entities/${this.type}`)
|
||||
)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "common";
|
||||
|
||||
.entity-container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<div class="entity-icon-container"
|
||||
:class="{'with-color-fill': !!colorFill}"
|
||||
:style="colorFillStyle">
|
||||
<img src="@/assets/img/spinner.gif" class="loading" v-if="loading">
|
||||
<i class="fas fa-circle-exclamation error" v-else-if="error" />
|
||||
<Icon v-bind="computedIcon" v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Icon from "@/components/elements/Icon";
|
||||
|
||||
export default {
|
||||
name: "EntityIcon",
|
||||
components: {Icon},
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
error: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
icon: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
hasColorFill: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
component: null,
|
||||
modalVisible: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
colorFill() {
|
||||
return (this.hasColorFill && this.icon.color) ? this.icon.color : null
|
||||
},
|
||||
|
||||
colorFillStyle() {
|
||||
return this.colorFill ? {'background': this.colorFill} : {}
|
||||
},
|
||||
|
||||
computedIcon() {
|
||||
const icon = {...this.icon}
|
||||
if (this.colorFill)
|
||||
delete icon.color
|
||||
return icon
|
||||
},
|
||||
|
||||
type() {
|
||||
let entityType = (this.entity.type || '')
|
||||
return entityType.charAt(0).toUpperCase() + entityType.slice(1)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "vars";
|
||||
|
||||
.entity-icon-container {
|
||||
width: 1.625em;
|
||||
height: 1.5em;
|
||||
display: inline-flex;
|
||||
margin-top: 0.25em;
|
||||
margin-left: 0.25em;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&.with-color-fill {
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
transform: translate(0%, -50%);
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $error-fg;
|
||||
margin-left: .5em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,38 @@
|
|||
<script>
|
||||
import Utils from "@/Utils"
|
||||
|
||||
export default {
|
||||
name: "EntityMixin",
|
||||
mixins: [Utils],
|
||||
emits: ['input'],
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
error: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
modalVisible: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
type() {
|
||||
let entityType = (this.value.type || '')
|
||||
return entityType.charAt(0).toUpperCase() + entityType.slice(1)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,451 @@
|
|||
<template>
|
||||
<div class="row plugin entities-container">
|
||||
<Loading v-if="loading" />
|
||||
|
||||
<header>
|
||||
<div class="col-11 left">
|
||||
<Selector :entity-groups="entityGroups" :value="selector" @input="selector = $event" />
|
||||
</div>
|
||||
|
||||
<div class="col-1 right">
|
||||
<button title="Refresh" @click="refresh(null)">
|
||||
<i class="fa fa-sync-alt" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="groups-canvas">
|
||||
<EntityModal :entity="entities[modalEntityId]"
|
||||
:visible="modalVisible" @close="onEntityModal(null)"
|
||||
v-if="modalEntityId"
|
||||
/>
|
||||
|
||||
<NoItems v-if="!Object.keys(displayGroups || {})?.length">No entities found</NoItems>
|
||||
|
||||
<div class="groups-container" v-else>
|
||||
<div class="group fade-in" v-for="group in displayGroups" :key="group.name">
|
||||
<div class="frame">
|
||||
<div class="header">
|
||||
<span class="section left">
|
||||
<Icon v-bind="entitiesMeta[group.name].icon || {}"
|
||||
v-if="selector.grouping === 'type' && entitiesMeta[group.name]" />
|
||||
<Icon :class="pluginIcons[group.name]?.class" :url="pluginIcons[group.name]?.imgUrl"
|
||||
v-else-if="selector.grouping === 'plugin' && pluginIcons[group.name]" />
|
||||
</span>
|
||||
|
||||
<span class="section center">
|
||||
<div class="title" v-text="entitiesMeta[group.name].name_plural"
|
||||
v-if="selector.grouping === 'type' && entitiesMeta[group.name]"/>
|
||||
<div class="title" v-text="group.name" v-else-if="selector.grouping === 'plugin'"/>
|
||||
</span>
|
||||
|
||||
<span class="section right">
|
||||
<button title="Refresh" @click="refresh(group)">
|
||||
<i class="fa fa-sync-alt" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="entity-frame" @click="onEntityModal(entity.id)"
|
||||
v-for="entity in group.entities" :key="entity.id">
|
||||
<Entity
|
||||
:value="entity"
|
||||
@input="onEntityInput"
|
||||
:error="!!errorEntities[entity.id]"
|
||||
:loading="!!loadingEntities[entity.id]"
|
||||
@loading="loadingEntities[entity.id] = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Utils from "@/Utils"
|
||||
import Loading from "@/components/Loading";
|
||||
import Icon from "@/components/elements/Icon";
|
||||
import NoItems from "@/components/elements/NoItems";
|
||||
import Entity from "./Entity.vue";
|
||||
import Selector from "./Selector.vue";
|
||||
import EntityModal from "./Modal"
|
||||
import icons from '@/assets/icons.json'
|
||||
import meta from './meta.json'
|
||||
|
||||
export default {
|
||||
name: "Entities",
|
||||
components: {Loading, Icon, Entity, Selector, NoItems, EntityModal},
|
||||
mixins: [Utils],
|
||||
|
||||
props: {
|
||||
// Entity scan timeout in seconds
|
||||
entityScanTimeout: {
|
||||
type: Number,
|
||||
default: 30,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
loadingEntities: {},
|
||||
errorEntities: {},
|
||||
entityTimeouts: {},
|
||||
entities: {},
|
||||
modalEntityId: null,
|
||||
modalVisible: false,
|
||||
selector: {
|
||||
grouping: 'type',
|
||||
selectedEntities: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
entitiesMeta() {
|
||||
return meta
|
||||
},
|
||||
|
||||
pluginIcons() {
|
||||
return icons
|
||||
},
|
||||
|
||||
entityGroups() {
|
||||
return {
|
||||
'id': Object.entries(this.groupEntities('id')).reduce((obj, [id, entities]) => {
|
||||
obj[id] = entities[0]
|
||||
return obj
|
||||
}, {}),
|
||||
'type': this.groupEntities('type'),
|
||||
'plugin': this.groupEntities('plugin'),
|
||||
}
|
||||
},
|
||||
|
||||
displayGroups() {
|
||||
return Object.entries(this.entityGroups[this.selector.grouping]).filter(
|
||||
(entry) => entry[1].filter(
|
||||
(e) => !!this.selector.selectedEntities[e.id]
|
||||
).length > 0
|
||||
).sort((a, b) => a[0].localeCompare(b[0])).map(
|
||||
([grouping, entities]) => {
|
||||
return {
|
||||
name: grouping,
|
||||
entities: entities.filter(
|
||||
(e) => e.id in this.selector.selectedEntities
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
groupEntities(attr) {
|
||||
return Object.values(this.entities).reduce((obj, entity) => {
|
||||
const entities = obj[entity[attr]] || {}
|
||||
entities[entity.id] = entity
|
||||
obj[entity[attr]] = Object.values(entities).sort((a, b) => {
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return obj
|
||||
}, {})
|
||||
},
|
||||
|
||||
async refresh(group) {
|
||||
const entities = (group ? group.entities : this.entities) || {}
|
||||
const args = {}
|
||||
if (group)
|
||||
args.plugins = Object.keys(entities.reduce((obj, entity) => {
|
||||
obj[entity.plugin] = true
|
||||
return obj
|
||||
}, {}))
|
||||
|
||||
this.loadingEntities = Object.values(entities).reduce((obj, entity) => {
|
||||
const self = this
|
||||
const id = entity.id
|
||||
if (this.entityTimeouts[id])
|
||||
clearTimeout(this.entityTimeouts[id])
|
||||
|
||||
this.entityTimeouts[id] = setTimeout(() => {
|
||||
if (self.loadingEntities[id])
|
||||
delete self.loadingEntities[id]
|
||||
if (self.entityTimeouts[id])
|
||||
delete self.entityTimeouts[id]
|
||||
|
||||
self.errorEntities[id] = entity
|
||||
self.notify({
|
||||
error: true,
|
||||
title: entity.plugin,
|
||||
text: `Scan timeout for ${entity.name}`,
|
||||
})
|
||||
}, this.entityScanTimeout * 1000)
|
||||
|
||||
obj[id] = true
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
await this.request('entities.scan', args)
|
||||
},
|
||||
|
||||
async sync() {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
this.entities = (await this.request('entities.get')).reduce((obj, entity) => {
|
||||
entity.name = entity?.meta?.name_override || entity.name
|
||||
entity.meta = {
|
||||
...(meta[entity.type] || {}),
|
||||
...(entity.meta || {}),
|
||||
}
|
||||
|
||||
obj[entity.id] = entity
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
this.selector.selectedEntities = this.entityGroups.id
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
clearEntityTimeouts(entityId) {
|
||||
if (this.errorEntities[entityId])
|
||||
delete this.errorEntities[entityId]
|
||||
if (this.loadingEntities[entityId])
|
||||
delete this.loadingEntities[entityId]
|
||||
if (this.entityTimeouts[entityId]) {
|
||||
clearTimeout(this.entityTimeouts[entityId])
|
||||
delete this.entityTimeouts[entityId]
|
||||
}
|
||||
},
|
||||
|
||||
onEntityInput(entity) {
|
||||
this.entities[entity.id] = entity
|
||||
this.clearEntityTimeouts(entity.id)
|
||||
if (this.loadingEntities[entity.id])
|
||||
delete this.loadingEntities[entity.id]
|
||||
},
|
||||
|
||||
onEntityUpdate(event) {
|
||||
const entityId = event.entity.id
|
||||
if (entityId == null)
|
||||
return
|
||||
|
||||
this.clearEntityTimeouts(entityId)
|
||||
const entity = {...event.entity}
|
||||
if (event.entity?.state == null)
|
||||
entity.state = this.entities[entityId]?.state
|
||||
if (entity.meta?.name_override?.length)
|
||||
entity.name = entity.meta.name_override
|
||||
else if (this.entities[entityId]?.meta?.name_override?.length)
|
||||
entity.name = this.entities[entityId].meta.name_override
|
||||
else
|
||||
entity.name = event.entity?.name || this.entities[entityId]?.name
|
||||
|
||||
entity.meta = {
|
||||
...(meta[event.entity.type] || {}),
|
||||
...(this.entities[entityId]?.meta || {}),
|
||||
...(event.entity?.meta || {}),
|
||||
}
|
||||
|
||||
this.entities[entityId] = entity
|
||||
},
|
||||
|
||||
onEntityDelete(event) {
|
||||
const entityId = event.entity?.id
|
||||
if (entityId == null)
|
||||
return
|
||||
if (entityId === this.modalEntityId)
|
||||
this.modalEntityId = null
|
||||
if (this.entities[entityId])
|
||||
delete this.entities[entityId]
|
||||
},
|
||||
|
||||
onEntityModal(entityId) {
|
||||
if (entityId) {
|
||||
this.modalEntityId = entityId
|
||||
this.modalVisible = true
|
||||
} else {
|
||||
this.modalEntityId = null
|
||||
this.modalVisible = false
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
this.subscribe(
|
||||
this.onEntityUpdate,
|
||||
'on-entity-update',
|
||||
'platypush.message.event.entities.EntityUpdateEvent'
|
||||
)
|
||||
|
||||
this.subscribe(
|
||||
this.onEntityDelete,
|
||||
'on-entity-delete',
|
||||
'platypush.message.event.entities.EntityDeleteEvent'
|
||||
)
|
||||
|
||||
await this.sync()
|
||||
await this.refresh()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "vars";
|
||||
@import "~@/style/items";
|
||||
|
||||
.entities-container {
|
||||
--groups-per-row: 1;
|
||||
|
||||
@include from($desktop) {
|
||||
--groups-per-row: 2;
|
||||
}
|
||||
|
||||
@include from($fullhd) {
|
||||
--groups-per-row: 3;
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
color: $default-fg-2;
|
||||
font-weight: 400;
|
||||
|
||||
button {
|
||||
background: #ffffff00;
|
||||
border: 0;
|
||||
|
||||
&:hover {
|
||||
color: $default-hover-fg;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
width: 100%;
|
||||
height: $selector-height;
|
||||
display: flex;
|
||||
background: $default-bg-2;
|
||||
box-shadow: $border-shadow-bottom;
|
||||
position: relative;
|
||||
|
||||
.right {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
text-align: right;
|
||||
margin-right: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
|
||||
button {
|
||||
padding: 0.5em 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.groups-canvas {
|
||||
width: 100%;
|
||||
height: calc(100% - #{$selector-height});
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.groups-container {
|
||||
@include from($desktop) {
|
||||
column-count: var(--groups-per-row);
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
position: relative;
|
||||
padding: $main-margin 0;
|
||||
display: flex;
|
||||
break-inside: avoid;
|
||||
|
||||
@include from ($tablet) {
|
||||
padding: $main-margin;
|
||||
}
|
||||
|
||||
.frame {
|
||||
@include from($desktop) {
|
||||
max-height: calc(100vh - #{$header-height} - #{$main-margin});
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
box-shadow: $group-shadow;
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
height: $header-height;
|
||||
display: table;
|
||||
background: $header-bg;
|
||||
box-shadow: $header-shadow;
|
||||
border-radius: 1em 1em 0 0;
|
||||
|
||||
.section {
|
||||
height: 100%;
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
|
||||
&.left, &.right {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
&.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&.center {
|
||||
width: 80%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
background: $default-bg-2;
|
||||
max-height: calc(100% - #{$header-height});
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
|
||||
.entity-frame:last-child {
|
||||
border-radius: 0 0 1em 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.modal) {
|
||||
@include until($tablet) {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.content {
|
||||
@include until($tablet) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@include from($tablet) {
|
||||
min-width: 30em;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0;
|
||||
|
||||
.table-row {
|
||||
padding: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,222 @@
|
|||
<template>
|
||||
<div class="entity light-container">
|
||||
<div class="head" :class="{expanded: expanded}">
|
||||
<div class="col-1 icon">
|
||||
<EntityIcon :icon="icon" :hasColorFill="true"
|
||||
:loading="loading" :error="error" />
|
||||
</div>
|
||||
|
||||
<div class="col-s-8 col-m-9 label">
|
||||
<div class="name" v-text="value.name" />
|
||||
</div>
|
||||
|
||||
<div class="col-s-3 col-m-2 buttons pull-right">
|
||||
<ToggleSwitch :value="value.on" @input="toggle"
|
||||
@click.stop :disabled="loading || value.is_read_only" />
|
||||
|
||||
<button @click.stop="expanded = !expanded">
|
||||
<i class="fas"
|
||||
:class="{'fa-angle-up': expanded, 'fa-angle-down': !expanded}" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body" v-if="expanded" @click.stop="prevent">
|
||||
<div class="row" v-if="cssColor">
|
||||
<div class="icon">
|
||||
<i class="fas fa-palette" />
|
||||
</div>
|
||||
<div class="input">
|
||||
<input type="color" :value="cssColor" @change="setLight({color: $event.target.value})" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="value.brightness">
|
||||
<div class="icon">
|
||||
<i class="fas fa-sun" />
|
||||
</div>
|
||||
<div class="input">
|
||||
<Slider :range="[value.brightness_min, value.brightness_max]"
|
||||
:value="value.brightness" @input="setLight({brightness: $event.target.value})" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="value.saturation">
|
||||
<div class="icon">
|
||||
<i class="fas fa-droplet" />
|
||||
</div>
|
||||
<div class="input">
|
||||
<Slider :range="[value.saturation_min, value.saturation_max]"
|
||||
:value="value.saturation" @input="setLight({saturation: $event.target.value})" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="value.temperature">
|
||||
<div class="icon">
|
||||
<i class="fas fa-temperature-half" />
|
||||
</div>
|
||||
<div class="input">
|
||||
<Slider :range="[value.temperature_min, value.temperature_max]"
|
||||
:value="value.temperature" @input="setLight({temperature: $event.target.value})"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Slider from "@/components/elements/Slider"
|
||||
import ToggleSwitch from "@/components/elements/ToggleSwitch"
|
||||
import EntityMixin from "./EntityMixin"
|
||||
import EntityIcon from "./EntityIcon"
|
||||
import {ColorConverter} from "@/components/panels/Light/color";
|
||||
|
||||
export default {
|
||||
name: 'Light',
|
||||
components: {ToggleSwitch, Slider, EntityIcon},
|
||||
mixins: [EntityMixin],
|
||||
|
||||
data() {
|
||||
return {
|
||||
expanded: false,
|
||||
colorConverter: null,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
rgbColor() {
|
||||
if (this.value.meta?.icon?.color)
|
||||
return this.value.meta.icon.color
|
||||
|
||||
if (
|
||||
!this.colorConverter || (
|
||||
this.value.hue == null &&
|
||||
(this.value.x == null || this.value.y == null)
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if (this.value.x && this.value.y)
|
||||
return this.colorConverter.xyToRgb(
|
||||
this.value.x,
|
||||
this.value.y,
|
||||
this.value.brightness
|
||||
)
|
||||
|
||||
return this.colorConverter.hslToRgb(
|
||||
this.value.hue,
|
||||
this.value.saturation,
|
||||
this.value.brightness
|
||||
)
|
||||
},
|
||||
|
||||
cssColor() {
|
||||
const rgb = this.rgbColor
|
||||
if (rgb)
|
||||
return this.colorConverter.rgbToHex(rgb)
|
||||
return null
|
||||
},
|
||||
|
||||
icon() {
|
||||
const icon = {...(this.value.meta?.icon || {})}
|
||||
if (!icon.color && this.cssColor)
|
||||
icon.color = this.cssColor
|
||||
return icon
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
prevent(event) {
|
||||
event.stopPropagation()
|
||||
return false
|
||||
},
|
||||
|
||||
async toggle(event) {
|
||||
event.stopPropagation()
|
||||
this.$emit('loading', true)
|
||||
|
||||
try {
|
||||
await this.request('entities.execute', {
|
||||
id: this.value.id,
|
||||
action: 'toggle',
|
||||
})
|
||||
} finally {
|
||||
this.$emit('loading', false)
|
||||
}
|
||||
},
|
||||
|
||||
async setLight(attrs) {
|
||||
if (attrs.color) {
|
||||
const rgb = this.colorConverter.hexToRgb(attrs.color)
|
||||
if (this.value.x != null && this.value.y != null) {
|
||||
attrs.xy = this.colorConverter.rgbToXY(...rgb)
|
||||
delete attrs.color
|
||||
} else if (this.value.hue != null) {
|
||||
[attrs.hue, attrs.saturation, attrs.brightness] = this.colorConverter.rgbToHsl(...rgb)
|
||||
delete attrs.color
|
||||
}
|
||||
}
|
||||
|
||||
this.execute({
|
||||
type: 'request',
|
||||
action: this.value.plugin + '.set_lights',
|
||||
args: {
|
||||
lights: [this.value.external_id],
|
||||
...attrs,
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const ranges = {}
|
||||
if (this.value.hue)
|
||||
ranges.hue = [this.value.hue_min, this.value.hue_max]
|
||||
if (this.value.saturation)
|
||||
ranges.sat = [this.value.saturation_min, this.value.saturation_max]
|
||||
if (this.value.brightness)
|
||||
ranges.bri = [this.value.brightness_min, this.value.brightness_max]
|
||||
if (this.value.temperature)
|
||||
ranges.ct = [this.value.temperature_min, this.value.temperature_max]
|
||||
|
||||
this.colorConverter = new ColorConverter(ranges)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "common";
|
||||
|
||||
.light-container {
|
||||
.head {
|
||||
.buttons {
|
||||
button {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
.row {
|
||||
display: flex;
|
||||
|
||||
.icon {
|
||||
width: 2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: calc(100% - 2em);
|
||||
|
||||
[type=color] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.slider) {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,252 @@
|
|||
<template>
|
||||
<Modal :visible="visible" :title="entity.name || entity.external_id">
|
||||
<ConfirmDialog ref="deleteConfirmDiag" title="Confirm entity deletion" @input="onDelete">
|
||||
Are you <b>sure</b> that you want to delete this entity? <br/><br/>
|
||||
Note: you should only delete an entity if its plugin has been disabled
|
||||
or the entity is no longer reachable.<br/><br/>
|
||||
Otherwise, the entity will simply be created again upon the next scan.
|
||||
</ConfirmDialog>
|
||||
|
||||
<div class="table-row">
|
||||
<div class="title">
|
||||
Name
|
||||
<EditButton @click="editName = true" v-if="!editName" />
|
||||
</div>
|
||||
<div class="value">
|
||||
<NameEditor :value="entity.name" @input="onRename"
|
||||
@cancel="editName = false" :disabled="loading" v-if="editName" />
|
||||
<span v-text="entity.name" v-else />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row">
|
||||
<div class="title">
|
||||
Icon
|
||||
<EditButton @click="editIcon = true" v-if="!editIcon" />
|
||||
</div>
|
||||
<div class="value icon-canvas">
|
||||
<span class="icon-editor" v-if="editIcon">
|
||||
<NameEditor :value="entity.meta?.icon?.class || entity.meta?.icon?.url" @input="onIconEdit"
|
||||
@cancel="editIcon = false" :disabled="loading">
|
||||
<button type="button" title="Reset" @click="onIconEdit(null)"
|
||||
@touch="onIconEdit(null)">
|
||||
<i class="fas fa-rotate-left" />
|
||||
</button>
|
||||
</NameEditor>
|
||||
<span class="help">
|
||||
Supported: image URLs or
|
||||
<a href="https://fontawesome.com/icons" target="_blank">FontAwesome icon classes</a>.
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Icon v-bind="entity?.meta?.icon || {}" v-else />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row">
|
||||
<div class="title">
|
||||
Icon color
|
||||
</div>
|
||||
<div class="value icon-color-picker">
|
||||
<input type="color" :value="entity.meta?.icon?.color" @change="onIconColorEdit">
|
||||
<button type="button" title="Reset" @click="onIconColorEdit(null)"
|
||||
@touch="onIconColorEdit(null)">
|
||||
<i class="fas fa-rotate-left" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row">
|
||||
<div class="title">Plugin</div>
|
||||
<div class="value" v-text="entity.plugin" />
|
||||
</div>
|
||||
|
||||
<div class="table-row">
|
||||
<div class="title">Internal ID</div>
|
||||
<div class="value" v-text="entity.id" />
|
||||
</div>
|
||||
|
||||
<div class="table-row" v-if="entity.external_id">
|
||||
<div class="title">External ID</div>
|
||||
<div class="value" v-text="entity.external_id" />
|
||||
</div>
|
||||
|
||||
<div class="table-row" v-if="entity.description">
|
||||
<div class="title">Description</div>
|
||||
<div class="value" v-text="entity.description" />
|
||||
</div>
|
||||
|
||||
<div v-for="value, attr in entity.data || {}" :key="attr">
|
||||
<div class="table-row" v-if="value != null">
|
||||
<div class="title" v-text="prettify(attr)" />
|
||||
<div class="value" v-text="'' + value" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row" v-if="entity.created_at">
|
||||
<div class="title">Created at</div>
|
||||
<div class="value" v-text="formatDateTime(entity.created_at)" />
|
||||
</div>
|
||||
|
||||
<div class="table-row" v-if="entity.updated_at">
|
||||
<div class="title">Updated at</div>
|
||||
<div class="value" v-text="formatDateTime(entity.updated_at)" />
|
||||
</div>
|
||||
|
||||
<div class="table-row delete-entity-container">
|
||||
<div class="title">Delete Entity</div>
|
||||
<div class="value">
|
||||
<button @click="$refs.deleteConfirmDiag.show()">
|
||||
<i class="fas fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from "@/components/Modal";
|
||||
import Icon from "@/components/elements/Icon";
|
||||
import ConfirmDialog from "@/components/elements/ConfirmDialog";
|
||||
import EditButton from "@/components/elements/EditButton";
|
||||
import NameEditor from "@/components/elements/NameEditor";
|
||||
import Utils from "@/Utils";
|
||||
import meta from './meta.json'
|
||||
|
||||
export default {
|
||||
name: "Entity",
|
||||
components: {Modal, EditButton, NameEditor, Icon, ConfirmDialog},
|
||||
mixins: [Utils],
|
||||
emits: ['input', 'loading'],
|
||||
props: {
|
||||
entity: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
editName: false,
|
||||
editIcon: false,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async onRename(newName) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const req = {}
|
||||
req[this.entity.id] = newName
|
||||
await this.request('entities.rename', req)
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.editName = false
|
||||
}
|
||||
},
|
||||
|
||||
async onDelete() {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await this.request('entities.delete', [this.entity.id])
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async onIconEdit(newIcon) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const icon = {url: null, class: null}
|
||||
if (newIcon?.length) {
|
||||
if (newIcon.startsWith('http'))
|
||||
icon.url = newIcon
|
||||
else
|
||||
icon.class = newIcon
|
||||
} else {
|
||||
icon.url = (meta[this.entity.type] || {})?.icon?.url
|
||||
icon.class = (meta[this.entity.type] || {})?.icon?.['class']
|
||||
}
|
||||
|
||||
const req = {}
|
||||
req[this.entity.id] = {icon: icon}
|
||||
await this.request('entities.set_meta', req)
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.editIcon = false
|
||||
}
|
||||
},
|
||||
|
||||
async onIconColorEdit(event) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const icon = this.entity.meta?.icon || {}
|
||||
if (event)
|
||||
icon.color = event.target.value
|
||||
else
|
||||
icon.color = null
|
||||
|
||||
const req = {}
|
||||
req[this.entity.id] = {icon: icon}
|
||||
await this.request('entities.set_meta', req)
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.editIcon = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.modal) {
|
||||
.icon-canvas {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@include until($tablet) {
|
||||
.icon-container {
|
||||
justify-content: left;
|
||||
}
|
||||
}
|
||||
|
||||
@include from($tablet) {
|
||||
.icon-container {
|
||||
justify-content: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
|
||||
.help {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.delete-entity-container {
|
||||
color: $error-fg;
|
||||
button {
|
||||
color: $error-fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,233 @@
|
|||
<template>
|
||||
<div class="entities-selectors-container">
|
||||
<div class="selector">
|
||||
<Dropdown title="Group by" icon-class="fas fa-eye" ref="groupingSelector">
|
||||
<DropdownItem v-for="g in visibleGroupings" :key="g" :text="prettifyGroupingName(g)"
|
||||
:item-class="{selected: value?.grouping === g}"
|
||||
@click="onGroupingChanged(g)" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div class="selector" :class="{active: isGroupFilterActive}" v-if="value?.grouping">
|
||||
<Dropdown title="Filter by" icon-class="fas fa-filter" ref="groupSelector"
|
||||
keep-open-on-item-click>
|
||||
<DropdownItem v-for="g in sortedGroups" :key="g" :text="g"
|
||||
v-bind="iconForGroup(g)" :item-class="{selected: !!selectedGroups[g]}"
|
||||
@click.stop="toggleGroup(g)" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div class="selector" v-if="Object.keys(entityGroups.id || {}).length">
|
||||
<input ref="search" type="text" class="search-bar" placeholder="🔎" v-model="searchTerm">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Utils from '@/Utils'
|
||||
import Dropdown from "@/components/elements/Dropdown";
|
||||
import DropdownItem from "@/components/elements/DropdownItem";
|
||||
import meta from './meta.json'
|
||||
import pluginIcons from '@/assets/icons.json'
|
||||
|
||||
export default {
|
||||
name: "Selector",
|
||||
emits: ['input'],
|
||||
mixins: [Utils],
|
||||
components: {Dropdown, DropdownItem},
|
||||
props: {
|
||||
entityGroups: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedGroups: {},
|
||||
searchTerm: '',
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
visibleGroupings() {
|
||||
return Object.keys(this.entityGroups).filter(
|
||||
(grouping) => grouping !== 'id'
|
||||
)
|
||||
},
|
||||
|
||||
sortedGroups() {
|
||||
return Object.keys(this.entityGroups[this.value?.grouping] || {}).sort()
|
||||
},
|
||||
|
||||
typesMeta() {
|
||||
return meta
|
||||
},
|
||||
|
||||
isGroupFilterActive() {
|
||||
return Object.keys(this.selectedGroups).length !== this.sortedGroups.length
|
||||
},
|
||||
|
||||
selectedEntities() {
|
||||
return Object.values(this.entityGroups.id).filter((entity) => {
|
||||
if (!this.selectedGroups[entity[this.value?.grouping]])
|
||||
return false
|
||||
|
||||
if (this.searchTerm?.length) {
|
||||
const searchTerm = this.searchTerm.toLowerCase()
|
||||
return (
|
||||
((entity.name || '').toLowerCase()).indexOf(searchTerm) >= 0 ||
|
||||
((entity.plugin || '').toLowerCase()).indexOf(searchTerm) >= 0 ||
|
||||
((entity.external_id || '').toLowerCase()).indexOf(searchTerm) >= 0 ||
|
||||
(entity.id || 0).toString() == searchTerm
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}).reduce((obj, entity) => {
|
||||
obj[entity.id] = entity
|
||||
return obj
|
||||
}, {})
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
prettifyGroupingName(name) {
|
||||
return name ? this.prettify(name) + 's' : ''
|
||||
},
|
||||
|
||||
iconForGroup(group) {
|
||||
if (this.value.grouping === 'plugin' && pluginIcons[group]) {
|
||||
const icon = pluginIcons[group]
|
||||
return {
|
||||
'icon-class': icon['class']?.length || !icon.imgUrl?.length ?
|
||||
icon['class'] : 'fas fa-gears',
|
||||
'icon-url': icon.imgUrl,
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
},
|
||||
|
||||
synchronizeSelectedEntities() {
|
||||
const value = {...this.value}
|
||||
value.selectedEntities = this.selectedEntities
|
||||
this.$emit('input', value)
|
||||
},
|
||||
|
||||
updateSearchTerm() {
|
||||
const value = {...this.value}
|
||||
value.searchTerm = this.searchTerm
|
||||
value.selectedEntities = this.selectedEntities
|
||||
this.$emit('input', value)
|
||||
},
|
||||
|
||||
refreshGroupFilter(reset) {
|
||||
if (reset)
|
||||
this.selectedGroups = Object.keys(
|
||||
this.entityGroups[this.value?.grouping] || {}
|
||||
).reduce(
|
||||
(obj, group) => {
|
||||
obj[group] = true
|
||||
return obj
|
||||
}, {}
|
||||
)
|
||||
else {
|
||||
for (const group of Object.keys(this.entityGroups[this.value?.grouping]))
|
||||
if (this.selectedGroups[group] == null)
|
||||
this.selectedGroups[group] = true
|
||||
}
|
||||
|
||||
this.synchronizeSelectedEntities()
|
||||
},
|
||||
|
||||
toggleGroup(group) {
|
||||
this.selectedGroups[group] = !this.selectedGroups[group]
|
||||
this.synchronizeSelectedEntities()
|
||||
},
|
||||
|
||||
onGroupingChanged(grouping) {
|
||||
if (!this.entityGroups[grouping] || grouping === this.value?.grouping)
|
||||
return false
|
||||
|
||||
const value = {...this.value}
|
||||
value.grouping = grouping
|
||||
this.$emit('input', value)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.refreshGroupFilter(true)
|
||||
this.$watch(() => this.value?.grouping, () => { this.refreshGroupFilter(true) })
|
||||
this.$watch(() => this.searchTerm, this.updateSearchTerm)
|
||||
this.$watch(() => this.entityGroups, () => { this.refreshGroupFilter(false) })
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.entities-selectors-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.selector {
|
||||
height: 100%;
|
||||
display: inline-flex;
|
||||
|
||||
&.active {
|
||||
:deep(.dropdown-container) {
|
||||
button {
|
||||
color: $default-hover-fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 330px) {
|
||||
.search-bar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.dropdown-container) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
height: 100%;
|
||||
background: $default-bg-2;
|
||||
border: 0;
|
||||
padding: 0.5em;
|
||||
|
||||
&:hover {
|
||||
color: $default-hover-fg;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 0.5em 4em 0.5em 0.5em;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
|
||||
.col-1.icon {
|
||||
width: 1.5em;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
font-weight: bold;
|
||||
background: #ffffff00;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $hover-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,57 @@
|
|||
<template>
|
||||
<div class="entity switch-container">
|
||||
<div class="head">
|
||||
<div class="col-1 icon">
|
||||
<EntityIcon :icon="value.meta?.icon || {}"
|
||||
:loading="loading" :error="error" />
|
||||
</div>
|
||||
|
||||
<div class="col-9 label">
|
||||
<div class="name" v-text="value.name" />
|
||||
</div>
|
||||
|
||||
<div class="col-2 switch pull-right">
|
||||
<ToggleSwitch :value="value.state" @input="toggle"
|
||||
@click.stop :disabled="loading || value.is_read_only" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ToggleSwitch from "@/components/elements/ToggleSwitch"
|
||||
import EntityIcon from "./EntityIcon"
|
||||
import EntityMixin from "./EntityMixin"
|
||||
|
||||
export default {
|
||||
name: 'Switch',
|
||||
components: {ToggleSwitch, EntityIcon},
|
||||
mixins: [EntityMixin],
|
||||
|
||||
methods: {
|
||||
async toggle(event) {
|
||||
event.stopPropagation()
|
||||
this.$emit('loading', true)
|
||||
|
||||
try {
|
||||
await this.request('entities.execute', {
|
||||
id: this.value.id,
|
||||
action: 'toggle',
|
||||
})
|
||||
} finally {
|
||||
this.$emit('loading', false)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "common";
|
||||
|
||||
.switch-container {
|
||||
.switch {
|
||||
direction: rtl;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,55 @@
|
|||
@import "vars";
|
||||
|
||||
.entity {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.head {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75em 0.25em;
|
||||
|
||||
.label {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
background: $selected-bg;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pull-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
direction: rtl;
|
||||
padding-right: 0.5em;
|
||||
|
||||
:deep(.power-switch) {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
@extend .fade-in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5em;
|
||||
background: linear-gradient(0deg, $default-bg-5, $default-bg-2);
|
||||
border-top: 1px solid $border-color-1;
|
||||
box-shadow: $border-shadow-bottom;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 2em;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 0 0 1em;
|
||||
|
||||
&:hover {
|
||||
color: $default-hover-fg;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"entity": {
|
||||
"name": "Entity",
|
||||
"name_plural": "Entities",
|
||||
"icon": {
|
||||
"class": "fas fa-circle-question"
|
||||
}
|
||||
},
|
||||
|
||||
"device": {
|
||||
"name": "Device",
|
||||
"name_plural": "Devices",
|
||||
"icon": {
|
||||
"class": "fas fa-gear"
|
||||
}
|
||||
},
|
||||
|
||||
"switch": {
|
||||
"name": "Switch",
|
||||
"name_plural": "Switches",
|
||||
"icon": {
|
||||
"class": "fas fa-toggle-on"
|
||||
}
|
||||
},
|
||||
|
||||
"light": {
|
||||
"name": "Light",
|
||||
"name_plural": "Lights",
|
||||
"icon": {
|
||||
"class": "fas fa-lightbulb"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
$main-margin: 1em;
|
||||
$selector-height: 2.5em;
|
|
@ -211,4 +211,21 @@ export class ColorConverter {
|
|||
console.debug('Could not determine color space')
|
||||
console.debug(color)
|
||||
}
|
||||
|
||||
hexToRgb(hex) {
|
||||
return [
|
||||
hex.slice(1, 3),
|
||||
hex.slice(3, 5),
|
||||
hex.slice(5, 7),
|
||||
].map(_ => parseInt(_, 16))
|
||||
}
|
||||
|
||||
rgbToHex(rgb) {
|
||||
return '#' + rgb.map((x) => {
|
||||
let hex = x.toString(16)
|
||||
if (hex.length < 2)
|
||||
hex = '0' + hex
|
||||
return hex
|
||||
}).join('')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -356,7 +356,7 @@ export default {
|
|||
async refreshStatus() {
|
||||
this.loading.status = true
|
||||
try {
|
||||
this.status = await this.zrequest('status')
|
||||
this.status = await this.zrequest('controller_status')
|
||||
} finally {
|
||||
this.loading.status = false
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
$icon-container-size: 3em;
|
||||
|
||||
@mixin icon {
|
||||
content: ' ';
|
||||
background-size: 1em 1em;
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
$header-height: 3.5em;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -78,3 +80,43 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.table-row) {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: $row-shadow;
|
||||
|
||||
&:hover {
|
||||
background: $hover-bg;
|
||||
}
|
||||
|
||||
@include from($tablet) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title,
|
||||
.value {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@include from($tablet) {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
|
||||
@include from($tablet) {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
@include from($tablet) {
|
||||
justify-content: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,10 +6,13 @@ $default-bg-4: #f1f3f2 !default;
|
|||
$default-bg-5: #edf0ee !default;
|
||||
$default-bg-6: #e4eae8 !default;
|
||||
$default-bg-7: #e4e4e4 !default;
|
||||
$error-fg: #ad1717 !default;
|
||||
|
||||
$default-fg: black !default;
|
||||
$default-fg-2: #23513a !default;
|
||||
$default-fg-3: #195331b3 !default;
|
||||
$header-bg: linear-gradient(0deg, #c0e8e4, #e4f8f4) !default;
|
||||
$no-items-color: #555555;
|
||||
|
||||
//// Notifications
|
||||
$notification-bg: rgba(185, 255, 193, 0.9) !default;
|
||||
|
@ -51,6 +54,8 @@ $border-shadow-bottom: 0 3px 2px -1px $default-shadow-color;
|
|||
$border-shadow-left: -2.5px 0 4px 0 $default-shadow-color;
|
||||
$border-shadow-right: 2.5px 0 4px 0 $default-shadow-color;
|
||||
$border-shadow-bottom-right: 2.5px 2.5px 3px 0 $default-shadow-color;
|
||||
$header-shadow: 0px 1px 3px 1px #bbb !default;
|
||||
$group-shadow: 3px -2px 6px 1px #98b0a0;
|
||||
|
||||
//// Modals
|
||||
$modal-header-bg: #e0e0e0 !default;
|
||||
|
@ -141,5 +146,7 @@ $dropdown-shadow: 1px 1px 1px #bbb !default;
|
|||
//// Scrollbars
|
||||
$scrollbar-track-bg: $slider-bg !default;
|
||||
$scrollbar-track-shadow: inset 1px 0px 3px 0 $slider-track-shadow !default;
|
||||
$scrollbar-thumb-bg: #50ca80 !default;
|
||||
$scrollbar-thumb-bg: #a5a2a2 !default;
|
||||
|
||||
//// Rows
|
||||
$row-shadow: 0 0 1px 0.5px #cfcfcf !default;
|
||||
|
|
17
platypush/backend/http/webapp/src/utils/Text.vue
Normal file
17
platypush/backend/http/webapp/src/utils/Text.vue
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script>
|
||||
export default {
|
||||
name: "Text",
|
||||
methods: {
|
||||
capitalize(text) {
|
||||
if (!text?.length)
|
||||
return text
|
||||
|
||||
return text.charAt(0).toUpperCase() + text.slice(1)
|
||||
},
|
||||
|
||||
prettify(text) {
|
||||
return text.split('_').map((t) => this.capitalize(t)).join(' ')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -45,10 +45,7 @@ export default {
|
|||
methods: {
|
||||
initSelectedPanel() {
|
||||
const match = this.$route.hash.match('#?([a-zA-Z0-9.]+)[?]?(.*)')
|
||||
if (!match)
|
||||
return
|
||||
|
||||
const plugin = match[1]
|
||||
const plugin = match ? match[1] : 'entities'
|
||||
if (plugin?.length)
|
||||
this.selectedPanel = plugin
|
||||
},
|
||||
|
@ -90,7 +87,7 @@ export default {
|
|||
|
||||
initializeDefaultViews() {
|
||||
this.plugins.execute = {}
|
||||
this.plugins.switches = {}
|
||||
this.plugins.entities = {}
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -113,7 +110,7 @@ main {
|
|||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
@include until($tablet) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,100 +1,24 @@
|
|||
from threading import Thread
|
||||
import warnings
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.context import get_plugin
|
||||
from platypush.message.event.light import LightStatusChangeEvent
|
||||
|
||||
|
||||
class LightHueBackend(Backend):
|
||||
"""
|
||||
This backend will periodically check for the status of your configured
|
||||
Philips Hue light devices and trigger events when the status of a device
|
||||
(power, saturation, brightness or hue) changes.
|
||||
**DEPRECATED**
|
||||
|
||||
Triggers:
|
||||
|
||||
* :class:`platypush.message.event.light.LightStatusChangeEvent` when the
|
||||
status of a lightbulb changes
|
||||
|
||||
Requires:
|
||||
|
||||
* The :class:`platypush.plugins.light.hue.LightHuePlugin` plugin to be
|
||||
active and configured.
|
||||
"""
|
||||
|
||||
_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
|
||||
The polling logic of this backend has been moved to the ``light.hue`` plugin itself.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.poll_seconds = poll_seconds
|
||||
|
||||
@staticmethod
|
||||
def _get_lights():
|
||||
plugin = get_plugin('light.hue')
|
||||
if not plugin:
|
||||
plugin = get_plugin('light.hue', reload=True)
|
||||
|
||||
return plugin.get_lights().output
|
||||
|
||||
def _listener(self):
|
||||
def _thread():
|
||||
lights = self._get_lights()
|
||||
|
||||
while not self.should_stop():
|
||||
try:
|
||||
lights_new = self._get_lights()
|
||||
|
||||
for light_id, light in lights_new.items():
|
||||
event_args = {}
|
||||
state = light.get('state')
|
||||
prev_state = lights.get(light_id, {}).get('state', {})
|
||||
|
||||
if 'on' in state and state.get('on') != prev_state.get('on'):
|
||||
event_args['on'] = state.get('on')
|
||||
if 'bri' in state and state.get('bri') != prev_state.get('bri'):
|
||||
event_args['bri'] = state.get('bri')
|
||||
if 'sat' in state and state.get('sat') != prev_state.get('sat'):
|
||||
event_args['sat'] = state.get('sat')
|
||||
if 'hue' in state and state.get('hue') != prev_state.get('hue'):
|
||||
event_args['hue'] = state.get('hue')
|
||||
if 'ct' in state and state.get('ct') != prev_state.get('ct'):
|
||||
event_args['ct'] = state.get('ct')
|
||||
if 'xy' in state and state.get('xy') != prev_state.get('xy'):
|
||||
event_args['xy'] = state.get('xy')
|
||||
|
||||
if event_args:
|
||||
event_args['plugin_name'] = 'light.hue'
|
||||
event_args['light_id'] = light_id
|
||||
event_args['light_name'] = light.get('name')
|
||||
self.bus.post(LightStatusChangeEvent(**event_args))
|
||||
|
||||
lights = lights_new
|
||||
except Exception as e:
|
||||
self.logger.exception(e)
|
||||
finally:
|
||||
self.wait_stop(self.poll_seconds)
|
||||
|
||||
return _thread
|
||||
warnings.warn(
|
||||
'The light.hue backend is deprecated. All of its logic '
|
||||
'has been moved to the light.hue plugin itself.'
|
||||
)
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
self.logger.info('Starting Hue lights backend')
|
||||
|
||||
while not self.should_stop():
|
||||
try:
|
||||
poll_thread = Thread(target=self._listener())
|
||||
poll_thread.start()
|
||||
poll_thread.join()
|
||||
except Exception as e:
|
||||
self.logger.exception(e)
|
||||
self.wait_stop(self.poll_seconds)
|
||||
|
||||
self.logger.info('Stopped Hue lights backend')
|
||||
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
manifest:
|
||||
events:
|
||||
platypush.message.event.light.LightStatusChangeEvent: when thestatus of a lightbulb
|
||||
changes
|
||||
install:
|
||||
pip: []
|
||||
package: platypush.backend.light.hue
|
||||
|
|
|
@ -8,15 +8,18 @@ from queue import Queue, Empty
|
|||
from threading import Thread, RLock
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime
|
||||
import sqlalchemy.engine as engine
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import engine, create_engine, Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
|
||||
|
||||
from platypush.backend import Backend
|
||||
from platypush.config import Config
|
||||
from platypush.context import get_plugin
|
||||
from platypush.message.event.mail import MailReceivedEvent, MailSeenEvent, MailFlaggedEvent, MailUnflaggedEvent
|
||||
from platypush.message.event.mail import (
|
||||
MailReceivedEvent,
|
||||
MailSeenEvent,
|
||||
MailFlaggedEvent,
|
||||
MailUnflaggedEvent,
|
||||
)
|
||||
from platypush.plugins.mail import MailInPlugin, Mail
|
||||
|
||||
# <editor-fold desc="Database tables">
|
||||
|
@ -25,7 +28,8 @@ Session = scoped_session(sessionmaker())
|
|||
|
||||
|
||||
class MailboxStatus(Base):
|
||||
""" Models the MailboxStatus table, containing information about the state of a monitored mailbox. """
|
||||
"""Models the MailboxStatus table, containing information about the state of a monitored mailbox."""
|
||||
|
||||
__tablename__ = 'MailboxStatus'
|
||||
|
||||
mailbox_id = Column(Integer, primary_key=True)
|
||||
|
@ -64,8 +68,13 @@ class MailBackend(Backend):
|
|||
|
||||
"""
|
||||
|
||||
def __init__(self, mailboxes: List[Dict[str, Any]], timeout: Optional[int] = 60, poll_seconds: Optional[int] = 60,
|
||||
**kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
mailboxes: List[Dict[str, Any]],
|
||||
timeout: Optional[int] = 60,
|
||||
poll_seconds: Optional[int] = 60,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
:param mailboxes: List of mailboxes to be monitored. Each mailbox entry contains a ``plugin`` attribute to
|
||||
identify the :class:`platypush.plugins.mail.MailInPlugin` plugin that will be used (e.g. ``mail.imap``)
|
||||
|
@ -128,9 +137,13 @@ class MailBackend(Backend):
|
|||
|
||||
# Parse mailboxes
|
||||
for i, mbox in enumerate(mailboxes):
|
||||
assert 'plugin' in mbox, 'No plugin attribute specified for mailbox n.{}'.format(i)
|
||||
assert (
|
||||
'plugin' in mbox
|
||||
), 'No plugin attribute specified for mailbox n.{}'.format(i)
|
||||
plugin = get_plugin(mbox.pop('plugin'))
|
||||
assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format(plugin)
|
||||
assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format(
|
||||
plugin
|
||||
)
|
||||
name = mbox.pop('name') if 'name' in mbox else 'Mailbox #{}'.format(i + 1)
|
||||
self.mailboxes.append(Mailbox(plugin=plugin, name=name, args=mbox))
|
||||
|
||||
|
@ -144,7 +157,10 @@ class MailBackend(Backend):
|
|||
|
||||
# <editor-fold desc="Database methods">
|
||||
def _db_get_engine(self) -> engine.Engine:
|
||||
return create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
|
||||
return create_engine(
|
||||
'sqlite:///{}'.format(self.dbfile),
|
||||
connect_args={'check_same_thread': False},
|
||||
)
|
||||
|
||||
def _db_load_mailboxes_status(self) -> None:
|
||||
mailbox_ids = list(range(len(self.mailboxes)))
|
||||
|
@ -153,12 +169,18 @@ class MailBackend(Backend):
|
|||
session = Session()
|
||||
records = {
|
||||
record.mailbox_id: record
|
||||
for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all()
|
||||
for record in session.query(MailboxStatus)
|
||||
.filter(MailboxStatus.mailbox_id.in_(mailbox_ids))
|
||||
.all()
|
||||
}
|
||||
|
||||
for mbox_id, mbox in enumerate(self.mailboxes):
|
||||
for mbox_id, _ in enumerate(self.mailboxes):
|
||||
if mbox_id not in records:
|
||||
record = MailboxStatus(mailbox_id=mbox_id, unseen_message_ids='[]', flagged_message_ids='[]')
|
||||
record = MailboxStatus(
|
||||
mailbox_id=mbox_id,
|
||||
unseen_message_ids='[]',
|
||||
flagged_message_ids='[]',
|
||||
)
|
||||
session.add(record)
|
||||
else:
|
||||
record = records[mbox_id]
|
||||
|
@ -170,19 +192,25 @@ class MailBackend(Backend):
|
|||
|
||||
session.commit()
|
||||
|
||||
def _db_get_mailbox_status(self, mailbox_ids: List[int]) -> Dict[int, MailboxStatus]:
|
||||
def _db_get_mailbox_status(
|
||||
self, mailbox_ids: List[int]
|
||||
) -> Dict[int, MailboxStatus]:
|
||||
with self._db_lock:
|
||||
session = Session()
|
||||
return {
|
||||
record.mailbox_id: record
|
||||
for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all()
|
||||
for record in session.query(MailboxStatus)
|
||||
.filter(MailboxStatus.mailbox_id.in_(mailbox_ids))
|
||||
.all()
|
||||
}
|
||||
|
||||
# </editor-fold>
|
||||
|
||||
# <editor-fold desc="Parse unread messages logic">
|
||||
@staticmethod
|
||||
def _check_thread(unread_queue: Queue, flagged_queue: Queue, plugin: MailInPlugin, **args):
|
||||
def _check_thread(
|
||||
unread_queue: Queue, flagged_queue: Queue, plugin: MailInPlugin, **args
|
||||
):
|
||||
def thread():
|
||||
# noinspection PyUnresolvedReferences
|
||||
unread = plugin.search_unseen_messages(**args).output
|
||||
|
@ -194,8 +222,9 @@ class MailBackend(Backend):
|
|||
|
||||
return thread
|
||||
|
||||
def _get_unread_seen_msgs(self, mailbox_idx: int, unread_msgs: Dict[int, Mail]) \
|
||||
-> Tuple[Dict[int, Mail], Dict[int, Mail]]:
|
||||
def _get_unread_seen_msgs(
|
||||
self, mailbox_idx: int, unread_msgs: Dict[int, Mail]
|
||||
) -> Tuple[Dict[int, Mail], Dict[int, Mail]]:
|
||||
prev_unread_msgs = self._unread_msgs[mailbox_idx]
|
||||
|
||||
return {
|
||||
|
@ -208,8 +237,9 @@ 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 {
|
||||
|
@ -222,21 +252,36 @@ class MailBackend(Backend):
|
|||
if msg_id not in flagged_msgs
|
||||
}
|
||||
|
||||
def _process_msg_events(self, mailbox_id: int, unread: List[Mail], seen: List[Mail],
|
||||
flagged: List[Mail], unflagged: List[Mail], last_checked_date: Optional[datetime] = None):
|
||||
def _process_msg_events(
|
||||
self,
|
||||
mailbox_id: int,
|
||||
unread: List[Mail],
|
||||
seen: List[Mail],
|
||||
flagged: List[Mail],
|
||||
unflagged: List[Mail],
|
||||
last_checked_date: Optional[datetime] = None,
|
||||
):
|
||||
for msg in unread:
|
||||
if msg.date and last_checked_date and msg.date < last_checked_date:
|
||||
continue
|
||||
self.bus.post(MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
|
||||
self.bus.post(
|
||||
MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
|
||||
)
|
||||
|
||||
for msg in seen:
|
||||
self.bus.post(MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
|
||||
self.bus.post(
|
||||
MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
|
||||
)
|
||||
|
||||
for msg in flagged:
|
||||
self.bus.post(MailFlaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
|
||||
self.bus.post(
|
||||
MailFlaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
|
||||
)
|
||||
|
||||
for msg in unflagged:
|
||||
self.bus.post(MailUnflaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
|
||||
self.bus.post(
|
||||
MailUnflaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
|
||||
)
|
||||
|
||||
def _check_mailboxes(self) -> List[Tuple[Dict[int, Mail], Dict[int, Mail]]]:
|
||||
workers = []
|
||||
|
@ -245,8 +290,14 @@ class MailBackend(Backend):
|
|||
|
||||
for mbox in self.mailboxes:
|
||||
unread_queue, flagged_queue = [Queue()] * 2
|
||||
worker = Thread(target=self._check_thread(unread_queue=unread_queue, flagged_queue=flagged_queue,
|
||||
plugin=mbox.plugin, **mbox.args))
|
||||
worker = Thread(
|
||||
target=self._check_thread(
|
||||
unread_queue=unread_queue,
|
||||
flagged_queue=flagged_queue,
|
||||
plugin=mbox.plugin,
|
||||
**mbox.args
|
||||
)
|
||||
)
|
||||
worker.start()
|
||||
workers.append(worker)
|
||||
queues.append((unread_queue, flagged_queue))
|
||||
|
@ -260,7 +311,11 @@ class MailBackend(Backend):
|
|||
flagged = flagged_queue.get(timeout=self.timeout)
|
||||
results.append((unread, flagged))
|
||||
except Empty:
|
||||
self.logger.warning('Checks on mailbox #{} timed out after {} seconds'.format(i + 1, self.timeout))
|
||||
self.logger.warning(
|
||||
'Checks on mailbox #{} timed out after {} seconds'.format(
|
||||
i + 1, self.timeout
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
return results
|
||||
|
@ -276,16 +331,25 @@ class MailBackend(Backend):
|
|||
for i, (unread, flagged) in enumerate(results):
|
||||
unread_msgs, seen_msgs = self._get_unread_seen_msgs(i, unread)
|
||||
flagged_msgs, unflagged_msgs = self._get_flagged_unflagged_msgs(i, flagged)
|
||||
self._process_msg_events(i, unread=list(unread_msgs.values()), seen=list(seen_msgs.values()),
|
||||
flagged=list(flagged_msgs.values()), unflagged=list(unflagged_msgs.values()),
|
||||
last_checked_date=mailbox_statuses[i].last_checked_date)
|
||||
self._process_msg_events(
|
||||
i,
|
||||
unread=list(unread_msgs.values()),
|
||||
seen=list(seen_msgs.values()),
|
||||
flagged=list(flagged_msgs.values()),
|
||||
unflagged=list(unflagged_msgs.values()),
|
||||
last_checked_date=mailbox_statuses[i].last_checked_date,
|
||||
)
|
||||
|
||||
self._unread_msgs[i] = unread
|
||||
self._flagged_msgs[i] = flagged
|
||||
records.append(MailboxStatus(mailbox_id=i,
|
||||
unseen_message_ids=json.dumps([msg_id for msg_id in unread.keys()]),
|
||||
flagged_message_ids=json.dumps([msg_id for msg_id in flagged.keys()]),
|
||||
last_checked_date=datetime.now()))
|
||||
records.append(
|
||||
MailboxStatus(
|
||||
mailbox_id=i,
|
||||
unseen_message_ids=json.dumps(list(unread.keys())),
|
||||
flagged_message_ids=json.dumps(list(flagged.keys())),
|
||||
last_checked_date=datetime.now(),
|
||||
)
|
||||
)
|
||||
|
||||
with self._db_lock:
|
||||
session = Session()
|
||||
|
|
|
@ -1,21 +1,38 @@
|
|||
import contextlib
|
||||
import json
|
||||
from typing import Optional
|
||||
from typing import Optional, Mapping
|
||||
|
||||
from platypush.backend.mqtt import MqttBackend
|
||||
from platypush.context import get_plugin
|
||||
from platypush.message.event.zigbee.mqtt import ZigbeeMqttOnlineEvent, ZigbeeMqttOfflineEvent, \
|
||||
ZigbeeMqttDevicePropertySetEvent, ZigbeeMqttDevicePairingEvent, ZigbeeMqttDeviceConnectedEvent, \
|
||||
ZigbeeMqttDeviceBannedEvent, ZigbeeMqttDeviceRemovedEvent, ZigbeeMqttDeviceRemovedFailedEvent, \
|
||||
ZigbeeMqttDeviceWhitelistedEvent, ZigbeeMqttDeviceRenamedEvent, ZigbeeMqttDeviceBindEvent, \
|
||||
ZigbeeMqttDeviceUnbindEvent, ZigbeeMqttGroupAddedEvent, ZigbeeMqttGroupAddedFailedEvent, \
|
||||
ZigbeeMqttGroupRemovedEvent, ZigbeeMqttGroupRemovedFailedEvent, ZigbeeMqttGroupRemoveAllEvent, \
|
||||
ZigbeeMqttGroupRemoveAllFailedEvent, ZigbeeMqttErrorEvent
|
||||
from platypush.message.event.zigbee.mqtt import (
|
||||
ZigbeeMqttOnlineEvent,
|
||||
ZigbeeMqttOfflineEvent,
|
||||
ZigbeeMqttDevicePropertySetEvent,
|
||||
ZigbeeMqttDevicePairingEvent,
|
||||
ZigbeeMqttDeviceConnectedEvent,
|
||||
ZigbeeMqttDeviceBannedEvent,
|
||||
ZigbeeMqttDeviceRemovedEvent,
|
||||
ZigbeeMqttDeviceRemovedFailedEvent,
|
||||
ZigbeeMqttDeviceWhitelistedEvent,
|
||||
ZigbeeMqttDeviceRenamedEvent,
|
||||
ZigbeeMqttDeviceBindEvent,
|
||||
ZigbeeMqttDeviceUnbindEvent,
|
||||
ZigbeeMqttGroupAddedEvent,
|
||||
ZigbeeMqttGroupAddedFailedEvent,
|
||||
ZigbeeMqttGroupRemovedEvent,
|
||||
ZigbeeMqttGroupRemovedFailedEvent,
|
||||
ZigbeeMqttGroupRemoveAllEvent,
|
||||
ZigbeeMqttGroupRemoveAllFailedEvent,
|
||||
ZigbeeMqttErrorEvent,
|
||||
)
|
||||
|
||||
|
||||
class ZigbeeMqttBackend(MqttBackend):
|
||||
"""
|
||||
Listen for events on a zigbee2mqtt service.
|
||||
|
||||
For historical reasons, this backend should be enabled together with the `zigbee.mqtt` plugin.
|
||||
|
||||
Triggers:
|
||||
|
||||
* :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent` when the service comes online.
|
||||
|
@ -59,11 +76,22 @@ class ZigbeeMqttBackend(MqttBackend):
|
|||
|
||||
"""
|
||||
|
||||
def __init__(self, host: Optional[str] = None, port: Optional[int] = None, base_topic='zigbee2mqtt',
|
||||
tls_cafile: Optional[str] = None, tls_certfile: Optional[str] = None,
|
||||
tls_keyfile: Optional[str] = None, tls_version: Optional[str] = None,
|
||||
tls_ciphers: Optional[str] = None, username: Optional[str] = None,
|
||||
password: Optional[str] = None, client_id: Optional[str] = None, *args, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
host: Optional[str] = None,
|
||||
port: Optional[int] = None,
|
||||
base_topic='zigbee2mqtt',
|
||||
tls_cafile: Optional[str] = None,
|
||||
tls_certfile: Optional[str] = None,
|
||||
tls_keyfile: Optional[str] = None,
|
||||
tls_version: Optional[str] = None,
|
||||
tls_ciphers: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
:param host: MQTT broker host (default: host configured on the ``zigbee.mqtt`` plugin).
|
||||
:param port: MQTT broker port (default: 1883).
|
||||
|
@ -87,6 +115,7 @@ class ZigbeeMqttBackend(MqttBackend):
|
|||
plugin = get_plugin('zigbee.mqtt')
|
||||
self.base_topic = base_topic or plugin.base_topic
|
||||
self._devices = {}
|
||||
self._devices_info = {}
|
||||
self._groups = {}
|
||||
self._last_state = None
|
||||
self.server_info = {
|
||||
|
@ -106,17 +135,28 @@ class ZigbeeMqttBackend(MqttBackend):
|
|||
**self.server_info,
|
||||
}
|
||||
|
||||
listeners = [{
|
||||
listeners = [
|
||||
{
|
||||
**self.server_info,
|
||||
'topics': [
|
||||
self.base_topic + '/' + topic
|
||||
for topic in ['bridge/state', 'bridge/log', 'bridge/logging', 'bridge/devices', 'bridge/groups']
|
||||
for topic in [
|
||||
'bridge/state',
|
||||
'bridge/log',
|
||||
'bridge/logging',
|
||||
'bridge/devices',
|
||||
'bridge/groups',
|
||||
]
|
||||
],
|
||||
}]
|
||||
}
|
||||
]
|
||||
|
||||
super().__init__(
|
||||
*args, subscribe_default_topic=False,
|
||||
listeners=listeners, client_id=client_id, **kwargs
|
||||
*args,
|
||||
subscribe_default_topic=False,
|
||||
listeners=listeners,
|
||||
client_id=client_id,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if not client_id:
|
||||
|
@ -146,7 +186,7 @@ class ZigbeeMqttBackend(MqttBackend):
|
|||
|
||||
if msg_type == 'devices':
|
||||
devices = {}
|
||||
for dev in (text or []):
|
||||
for dev in text or []:
|
||||
devices[dev['friendly_name']] = dev
|
||||
client.subscribe(self.base_topic + '/' + dev['friendly_name'])
|
||||
elif msg_type == 'pairing':
|
||||
|
@ -155,7 +195,9 @@ class ZigbeeMqttBackend(MqttBackend):
|
|||
self.bus.post(ZigbeeMqttDeviceBannedEvent(device=text, **args))
|
||||
elif msg_type in ['device_removed_failed', 'device_force_removed_failed']:
|
||||
force = msg_type == 'device_force_removed_failed'
|
||||
self.bus.post(ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args))
|
||||
self.bus.post(
|
||||
ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args)
|
||||
)
|
||||
elif msg_type == 'device_whitelisted':
|
||||
self.bus.post(ZigbeeMqttDeviceWhitelistedEvent(device=text, **args))
|
||||
elif msg_type == 'device_renamed':
|
||||
|
@ -181,7 +223,11 @@ class ZigbeeMqttBackend(MqttBackend):
|
|||
self.bus.post(ZigbeeMqttErrorEvent(error=text, **args))
|
||||
elif msg.get('level') in ['warning', 'error']:
|
||||
log = getattr(self.logger, msg['level'])
|
||||
log('zigbee2mqtt {}: {}'.format(msg['level'], text or msg.get('error', msg.get('warning'))))
|
||||
log(
|
||||
'zigbee2mqtt {}: {}'.format(
|
||||
msg['level'], text or msg.get('error', msg.get('warning'))
|
||||
)
|
||||
)
|
||||
|
||||
def _process_devices(self, client, msg):
|
||||
devices_info = {
|
||||
|
@ -191,10 +237,9 @@ class ZigbeeMqttBackend(MqttBackend):
|
|||
|
||||
# noinspection PyProtectedMember
|
||||
event_args = {'host': client._host, 'port': client._port}
|
||||
client.subscribe(*[
|
||||
self.base_topic + '/' + device
|
||||
for device in devices_info.keys()
|
||||
])
|
||||
client.subscribe(
|
||||
*[self.base_topic + '/' + device for device in devices_info.keys()]
|
||||
)
|
||||
|
||||
for name, device in devices_info.items():
|
||||
if name not in self._devices:
|
||||
|
@ -203,7 +248,7 @@ class ZigbeeMqttBackend(MqttBackend):
|
|||
exposes = (device.get('definition', {}) or {}).get('exposes', [])
|
||||
client.publish(
|
||||
self.base_topic + '/' + name + '/get',
|
||||
json.dumps(get_plugin('zigbee.mqtt').build_device_get_request(exposes))
|
||||
json.dumps(self._plugin.build_device_get_request(exposes)),
|
||||
)
|
||||
|
||||
devices_copy = [*self._devices.keys()]
|
||||
|
@ -213,13 +258,13 @@ class ZigbeeMqttBackend(MqttBackend):
|
|||
del self._devices[name]
|
||||
|
||||
self._devices = {device: {} for device in devices_info.keys()}
|
||||
self._devices_info = devices_info
|
||||
|
||||
def _process_groups(self, client, msg):
|
||||
# noinspection PyProtectedMember
|
||||
event_args = {'host': client._host, 'port': client._port}
|
||||
groups_info = {
|
||||
group.get('friendly_name', group.get('id')): group
|
||||
for group in msg
|
||||
group.get('friendly_name', group.get('id')): group for group in msg
|
||||
}
|
||||
|
||||
for name in groups_info.keys():
|
||||
|
@ -236,15 +281,13 @@ class ZigbeeMqttBackend(MqttBackend):
|
|||
|
||||
def on_mqtt_message(self):
|
||||
def handler(client, _, msg):
|
||||
topic = msg.topic[len(self.base_topic)+1:]
|
||||
topic = msg.topic[len(self.base_topic) + 1 :]
|
||||
data = msg.payload.decode()
|
||||
if not data:
|
||||
return
|
||||
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
data = json.loads(data)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if topic == 'bridge/state':
|
||||
self._process_state_message(client, data)
|
||||
|
@ -260,17 +303,45 @@ class ZigbeeMqttBackend(MqttBackend):
|
|||
return
|
||||
|
||||
name = suffix
|
||||
changed_props = {k: v for k, v in data.items() if v != self._devices[name].get(k)}
|
||||
changed_props = {
|
||||
k: v for k, v in data.items() if v != self._devices[name].get(k)
|
||||
}
|
||||
|
||||
if changed_props:
|
||||
# noinspection PyProtectedMember
|
||||
self.bus.post(ZigbeeMqttDevicePropertySetEvent(host=client._host, port=client._port,
|
||||
device=name, properties=changed_props))
|
||||
self._process_property_update(name, data)
|
||||
self.bus.post(
|
||||
ZigbeeMqttDevicePropertySetEvent(
|
||||
host=client._host,
|
||||
port=client._port,
|
||||
device=name,
|
||||
properties=changed_props,
|
||||
)
|
||||
)
|
||||
|
||||
self._devices[name].update(data)
|
||||
|
||||
return handler
|
||||
|
||||
@property
|
||||
def _plugin(self):
|
||||
plugin = get_plugin('zigbee.mqtt')
|
||||
assert plugin, 'The zigbee.mqtt plugin is not configured'
|
||||
return plugin
|
||||
|
||||
def _process_property_update(self, device_name: str, properties: Mapping):
|
||||
device_info = self._devices_info.get(device_name)
|
||||
if not (device_info and properties):
|
||||
return
|
||||
|
||||
self._plugin.publish_entities(
|
||||
[
|
||||
{
|
||||
**device_info,
|
||||
'state': properties,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import contextlib
|
||||
import json
|
||||
from queue import Queue, Empty
|
||||
from typing import Optional, Type
|
||||
|
@ -5,14 +6,24 @@ from typing import Optional, Type
|
|||
from platypush.backend.mqtt import MqttBackend
|
||||
from platypush.context import get_plugin
|
||||
|
||||
from platypush.message.event.zwave import ZwaveEvent, ZwaveNodeAddedEvent, ZwaveValueChangedEvent, \
|
||||
ZwaveNodeRemovedEvent, ZwaveNodeRenamedEvent, ZwaveNodeReadyEvent, ZwaveNodeEvent, ZwaveNodeAsleepEvent, \
|
||||
ZwaveNodeAwakeEvent
|
||||
from platypush.message.event.zwave import (
|
||||
ZwaveEvent,
|
||||
ZwaveNodeAddedEvent,
|
||||
ZwaveValueChangedEvent,
|
||||
ZwaveNodeRemovedEvent,
|
||||
ZwaveNodeRenamedEvent,
|
||||
ZwaveNodeReadyEvent,
|
||||
ZwaveNodeEvent,
|
||||
ZwaveNodeAsleepEvent,
|
||||
ZwaveNodeAwakeEvent,
|
||||
)
|
||||
|
||||
|
||||
class ZwaveMqttBackend(MqttBackend):
|
||||
"""
|
||||
Listen for events on a `zwavejs2mqtt <https://github.com/zwave-js/zwavejs2mqtt>`_ service.
|
||||
For historical reasons, this should be enabled together with the ``zwave.mqtt`` plugin,
|
||||
even though the actual configuration is only specified on the plugin.
|
||||
|
||||
Triggers:
|
||||
|
||||
|
@ -41,6 +52,7 @@ class ZwaveMqttBackend(MqttBackend):
|
|||
"""
|
||||
|
||||
from platypush.plugins.zwave.mqtt import ZwaveMqttPlugin
|
||||
|
||||
self.plugin: ZwaveMqttPlugin = get_plugin('zwave.mqtt')
|
||||
assert self.plugin, 'The zwave.mqtt plugin is not configured'
|
||||
|
||||
|
@ -61,27 +73,48 @@ class ZwaveMqttBackend(MqttBackend):
|
|||
'password': self.plugin.password,
|
||||
}
|
||||
|
||||
listeners = [{
|
||||
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']
|
||||
for topic in [
|
||||
'node_ready',
|
||||
'node_sleep',
|
||||
'node_value_updated',
|
||||
'node_metadata_updated',
|
||||
'node_wakeup',
|
||||
]
|
||||
],
|
||||
}]
|
||||
}
|
||||
]
|
||||
|
||||
super().__init__(*args, subscribe_default_topic=False, listeners=listeners, client_id=client_id, **kwargs)
|
||||
super().__init__(
|
||||
*args,
|
||||
subscribe_default_topic=False,
|
||||
listeners=listeners,
|
||||
client_id=client_id,
|
||||
**kwargs,
|
||||
)
|
||||
if not client_id:
|
||||
self.client_id += '-zwavejs-mqtt'
|
||||
|
||||
def _dispatch_event(self, event_type: Type[ZwaveEvent], node: Optional[dict] = None, value: Optional[dict] = None,
|
||||
**kwargs):
|
||||
def _dispatch_event(
|
||||
self,
|
||||
event_type: Type[ZwaveEvent],
|
||||
node: Optional[dict] = None,
|
||||
value: Optional[dict] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if value and 'id' not in value:
|
||||
value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}"
|
||||
if 'propertyKey' in value:
|
||||
value_id += '-' + str(value['propertyKey'])
|
||||
|
||||
if value_id not in node.get('values', {}):
|
||||
self.logger.warning(f'value_id {value_id} not found on node {node["id"]}')
|
||||
self.logger.warning(
|
||||
f'value_id {value_id} not found on node {node["id"]}'
|
||||
)
|
||||
return
|
||||
|
||||
value = node['values'][value_id]
|
||||
|
@ -107,15 +140,19 @@ 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).
|
||||
# (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':
|
||||
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)}' \
|
||||
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']:
|
||||
|
@ -124,24 +161,26 @@ class ZwaveMqttBackend(MqttBackend):
|
|||
evt = event_type(**kwargs)
|
||||
self._events_queue.put(evt)
|
||||
|
||||
self.plugin.publish_entities([kwargs['value']]) # type: ignore
|
||||
|
||||
def on_mqtt_message(self):
|
||||
def handler(_, __, msg):
|
||||
if not msg.topic.startswith(self.events_topic):
|
||||
return
|
||||
|
||||
topic = msg.topic[len(self.events_topic) + 1:].split('/').pop()
|
||||
topic = msg.topic[len(self.events_topic) + 1 :].split('/').pop()
|
||||
data = msg.payload.decode()
|
||||
if not data:
|
||||
return
|
||||
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
data = json.loads(data)['data']
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
if topic == 'node_value_updated':
|
||||
self._dispatch_event(ZwaveValueChangedEvent, node=data[0], value=data[1])
|
||||
self._dispatch_event(
|
||||
ZwaveValueChangedEvent, node=data[0], value=data[1]
|
||||
)
|
||||
elif topic == 'node_metadata_updated':
|
||||
self._dispatch_event(ZwaveNodeEvent, node=data[0])
|
||||
elif topic == 'node_sleep':
|
||||
|
|
38
platypush/entities/__init__.py
Normal file
38
platypush/entities/__init__.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
import warnings
|
||||
from typing import Collection, Optional
|
||||
|
||||
from ._base import Entity, get_entities_registry
|
||||
from ._engine import EntitiesEngine
|
||||
from ._registry import manages, register_entity_plugin, get_plugin_entity_registry
|
||||
|
||||
_engine: Optional[EntitiesEngine] = None
|
||||
|
||||
|
||||
def init_entities_engine() -> EntitiesEngine:
|
||||
from ._base import init_entities_db
|
||||
|
||||
global _engine
|
||||
init_entities_db()
|
||||
_engine = EntitiesEngine()
|
||||
_engine.start()
|
||||
return _engine
|
||||
|
||||
|
||||
def publish_entities(entities: Collection[Entity]):
|
||||
if not _engine:
|
||||
warnings.warn('No entities engine registered')
|
||||
return
|
||||
|
||||
_engine.post(*entities)
|
||||
|
||||
|
||||
__all__ = (
|
||||
'Entity',
|
||||
'EntitiesEngine',
|
||||
'init_entities_engine',
|
||||
'publish_entities',
|
||||
'register_entity_plugin',
|
||||
'get_plugin_entity_registry',
|
||||
'get_entities_registry',
|
||||
'manages',
|
||||
)
|
131
platypush/entities/_base.py
Normal file
131
platypush/entities/_base.py
Normal file
|
@ -0,0 +1,131 @@
|
|||
import inspect
|
||||
import pathlib
|
||||
from datetime import datetime
|
||||
from typing import Mapping, Type, Tuple, Any
|
||||
|
||||
import pkgutil
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
JSON,
|
||||
UniqueConstraint,
|
||||
inspect as schema_inspect,
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base, ColumnProperty
|
||||
|
||||
from platypush.message import JSONAble
|
||||
|
||||
Base = declarative_base()
|
||||
entities_registry: Mapping[Type['Entity'], Mapping] = {}
|
||||
|
||||
|
||||
class Entity(Base):
|
||||
"""
|
||||
Model for a general-purpose platform entity.
|
||||
"""
|
||||
|
||||
__tablename__ = 'entity'
|
||||
|
||||
id = Column(Integer, autoincrement=True, primary_key=True)
|
||||
external_id = Column(String, nullable=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
description = Column(String)
|
||||
type = Column(String, nullable=False, index=True)
|
||||
plugin = Column(String, nullable=False)
|
||||
data = Column(JSON, default=dict)
|
||||
meta = Column(JSON, default=dict)
|
||||
is_read_only = Column(Boolean, default=False)
|
||||
is_write_only = Column(Boolean, default=False)
|
||||
created_at = Column(
|
||||
DateTime(timezone=False), default=datetime.utcnow(), nullable=False
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=False), default=datetime.utcnow(), onupdate=datetime.utcnow()
|
||||
)
|
||||
|
||||
UniqueConstraint(external_id, plugin)
|
||||
|
||||
__table_args__ = (Index(name, plugin), Index(name, type, plugin))
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': __tablename__,
|
||||
'polymorphic_on': type,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def columns(cls) -> Tuple[ColumnProperty]:
|
||||
inspector = schema_inspect(cls)
|
||||
return tuple(inspector.mapper.column_attrs)
|
||||
|
||||
def _serialize_value(self, col: ColumnProperty) -> Any:
|
||||
val = getattr(self, col.key)
|
||||
if isinstance(val, datetime):
|
||||
# All entity timestamps are in UTC
|
||||
val = val.isoformat() + '+00:00'
|
||||
|
||||
return val
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {col.key: self._serialize_value(col) for col in self.columns}
|
||||
|
||||
def get_plugin(self):
|
||||
from platypush.context import get_plugin
|
||||
|
||||
plugin = get_plugin(self.plugin)
|
||||
assert plugin, f'No such plugin: {plugin}'
|
||||
return plugin
|
||||
|
||||
def run(self, action: str, *args, **kwargs):
|
||||
plugin = self.get_plugin()
|
||||
method = getattr(plugin, action, None)
|
||||
assert method, f'No such action: {self.plugin}.{action}'
|
||||
return method(self.external_id or self.name, *args, **kwargs)
|
||||
|
||||
|
||||
# Inject the JSONAble mixin (Python goes nuts if done through
|
||||
# standard multiple inheritance with an SQLAlchemy ORM class)
|
||||
Entity.__bases__ = Entity.__bases__ + (JSONAble,)
|
||||
|
||||
|
||||
def _discover_entity_types():
|
||||
from platypush.context import get_plugin
|
||||
|
||||
logger = get_plugin('logger')
|
||||
assert logger
|
||||
|
||||
for loader, modname, _ in pkgutil.walk_packages(
|
||||
path=[str(pathlib.Path(__file__).parent.absolute())],
|
||||
prefix=__package__ + '.',
|
||||
onerror=lambda _: None,
|
||||
):
|
||||
try:
|
||||
mod_loader = loader.find_module(modname) # type: ignore
|
||||
assert mod_loader
|
||||
module = mod_loader.load_module() # type: ignore
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not import module {modname}')
|
||||
logger.exception(e)
|
||||
continue
|
||||
|
||||
for _, obj in inspect.getmembers(module):
|
||||
if inspect.isclass(obj) and issubclass(obj, Entity):
|
||||
entities_registry[obj] = {}
|
||||
|
||||
|
||||
def get_entities_registry():
|
||||
return entities_registry.copy()
|
||||
|
||||
|
||||
def init_entities_db():
|
||||
from platypush.context import get_plugin
|
||||
|
||||
_discover_entity_types()
|
||||
db = get_plugin('db')
|
||||
assert db
|
||||
engine = db.get_engine()
|
||||
db.create_all(engine, Base)
|
269
platypush/entities/_engine.py
Normal file
269
platypush/entities/_engine.py
Normal file
|
@ -0,0 +1,269 @@
|
|||
import json
|
||||
from logging import getLogger
|
||||
from queue import Queue, Empty
|
||||
from threading import Thread, Event, RLock
|
||||
from time import time
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session, make_transient
|
||||
|
||||
from platypush.context import get_bus
|
||||
from platypush.message.event.entities import EntityUpdateEvent
|
||||
|
||||
from ._base import Entity
|
||||
|
||||
|
||||
class EntitiesEngine(Thread):
|
||||
# Processing queue timeout in seconds
|
||||
_queue_timeout = 2.0
|
||||
|
||||
def __init__(self):
|
||||
obj_name = self.__class__.__name__
|
||||
super().__init__(name=obj_name)
|
||||
self.logger = getLogger(name=obj_name)
|
||||
self._queue = Queue()
|
||||
self._should_stop = Event()
|
||||
self._entities_awaiting_flush = set()
|
||||
self._entities_cache_lock = RLock()
|
||||
self._entities_cache = {
|
||||
'by_id': {},
|
||||
'by_external_id_and_plugin': {},
|
||||
'by_name_and_plugin': {},
|
||||
}
|
||||
|
||||
def _get_db(self):
|
||||
from platypush.context import get_plugin
|
||||
|
||||
db = get_plugin('db')
|
||||
assert db
|
||||
return db
|
||||
|
||||
def _get_cached_entity(self, entity: Entity) -> Optional[dict]:
|
||||
if entity.id:
|
||||
e = self._entities_cache['by_id'].get(entity.id)
|
||||
if e:
|
||||
return e
|
||||
|
||||
if entity.external_id and entity.plugin:
|
||||
e = self._entities_cache['by_external_id_and_plugin'].get(
|
||||
(entity.external_id, entity.plugin)
|
||||
)
|
||||
if e:
|
||||
return e
|
||||
|
||||
if entity.name and entity.plugin:
|
||||
e = self._entities_cache['by_name_and_plugin'].get(
|
||||
(entity.name, entity.plugin)
|
||||
)
|
||||
if e:
|
||||
return e
|
||||
|
||||
@staticmethod
|
||||
def _cache_repr(entity: Entity) -> dict:
|
||||
repr_ = entity.to_json()
|
||||
repr_.pop('data', None)
|
||||
repr_.pop('meta', None)
|
||||
repr_.pop('created_at', None)
|
||||
repr_.pop('updated_at', None)
|
||||
return repr_
|
||||
|
||||
def _cache_entities(self, *entities: Entity, overwrite_cache=False):
|
||||
for entity in entities:
|
||||
e = self._cache_repr(entity)
|
||||
if not overwrite_cache:
|
||||
existing_entity = self._entities_cache['by_id'].get(entity.id)
|
||||
if existing_entity:
|
||||
for k, v in existing_entity.items():
|
||||
if e.get(k) is None:
|
||||
e[k] = v
|
||||
|
||||
if entity.id:
|
||||
self._entities_cache['by_id'][entity.id] = e
|
||||
if entity.external_id and entity.plugin:
|
||||
self._entities_cache['by_external_id_and_plugin'][
|
||||
(entity.external_id, entity.plugin)
|
||||
] = e
|
||||
if entity.name and entity.plugin:
|
||||
self._entities_cache['by_name_and_plugin'][
|
||||
(entity.name, entity.plugin)
|
||||
] = e
|
||||
|
||||
def _populate_entity_id_from_cache(self, new_entity: Entity):
|
||||
with self._entities_cache_lock:
|
||||
cached_entity = self._get_cached_entity(new_entity)
|
||||
if cached_entity and cached_entity.get('id'):
|
||||
new_entity.id = cached_entity['id']
|
||||
if new_entity.id:
|
||||
self._cache_entities(new_entity)
|
||||
|
||||
def _init_entities_cache(self):
|
||||
with self._get_db().get_session() as session:
|
||||
entities = session.query(Entity).all()
|
||||
for entity in entities:
|
||||
make_transient(entity)
|
||||
|
||||
with self._entities_cache_lock:
|
||||
self._cache_entities(*entities, overwrite_cache=True)
|
||||
|
||||
self.logger.info('Entities cache initialized')
|
||||
|
||||
def _process_event(self, entity: Entity):
|
||||
self._populate_entity_id_from_cache(entity)
|
||||
if entity.id:
|
||||
get_bus().post(EntityUpdateEvent(entity=entity))
|
||||
else:
|
||||
self._entities_awaiting_flush.add(self._to_entity_awaiting_flush(entity))
|
||||
|
||||
@staticmethod
|
||||
def _to_entity_awaiting_flush(entity: Entity):
|
||||
e = entity.to_json()
|
||||
return json.dumps(
|
||||
{k: v for k, v in e.items() if k in {'external_id', 'name', 'plugin'}},
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
def post(self, *entities: Entity):
|
||||
for entity in entities:
|
||||
self._queue.put(entity)
|
||||
|
||||
@property
|
||||
def should_stop(self) -> bool:
|
||||
return self._should_stop.is_set()
|
||||
|
||||
def stop(self):
|
||||
self._should_stop.set()
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
self.logger.info('Started entities engine')
|
||||
self._init_entities_cache()
|
||||
|
||||
while not self.should_stop:
|
||||
msgs = []
|
||||
last_poll_time = time()
|
||||
|
||||
while not self.should_stop and (
|
||||
time() - last_poll_time < self._queue_timeout
|
||||
):
|
||||
try:
|
||||
msg = self._queue.get(block=True, timeout=0.5)
|
||||
except Empty:
|
||||
continue
|
||||
|
||||
if msg:
|
||||
msgs.append(msg)
|
||||
# Trigger an EntityUpdateEvent if there has
|
||||
# been a change on the entity state
|
||||
self._process_event(msg)
|
||||
|
||||
if not msgs or self.should_stop:
|
||||
continue
|
||||
|
||||
try:
|
||||
self._process_entities(*msgs)
|
||||
except Exception as e:
|
||||
self.logger.error('Error while processing entity updates: ' + str(e))
|
||||
self.logger.exception(e)
|
||||
|
||||
self.logger.info('Stopped entities engine')
|
||||
|
||||
def _get_if_exist(
|
||||
self, session: Session, entities: Iterable[Entity]
|
||||
) -> Iterable[Entity]:
|
||||
existing_entities = {
|
||||
(
|
||||
str(entity.external_id)
|
||||
if entity.external_id is not None
|
||||
else entity.name,
|
||||
entity.plugin,
|
||||
): entity
|
||||
for entity in session.query(Entity)
|
||||
.filter(
|
||||
or_(
|
||||
*[
|
||||
and_(
|
||||
Entity.external_id == entity.external_id,
|
||||
Entity.plugin == entity.plugin,
|
||||
)
|
||||
if entity.external_id is not None
|
||||
else and_(
|
||||
Entity.name == entity.name,
|
||||
Entity.type == entity.type,
|
||||
Entity.plugin == entity.plugin,
|
||||
)
|
||||
for entity in entities
|
||||
]
|
||||
)
|
||||
)
|
||||
.all()
|
||||
}
|
||||
|
||||
return [
|
||||
existing_entities.get(
|
||||
(
|
||||
str(entity.external_id)
|
||||
if entity.external_id is not None
|
||||
else entity.name,
|
||||
entity.plugin,
|
||||
),
|
||||
None,
|
||||
)
|
||||
for entity in entities
|
||||
]
|
||||
|
||||
def _merge_entities(
|
||||
self, entities: List[Entity], existing_entities: List[Entity]
|
||||
) -> List[Entity]:
|
||||
def merge(entity: Entity, existing_entity: Entity) -> Entity:
|
||||
columns = [col.key for col in entity.columns]
|
||||
for col in columns:
|
||||
if col == 'meta':
|
||||
existing_entity.meta = { # type: ignore
|
||||
**(existing_entity.meta or {}), # type: ignore
|
||||
**(entity.meta or {}), # type: ignore
|
||||
}
|
||||
elif col not in ('id', 'created_at'):
|
||||
setattr(existing_entity, col, getattr(entity, col))
|
||||
|
||||
return existing_entity
|
||||
|
||||
new_entities = []
|
||||
entities_map = {}
|
||||
|
||||
# Get the latest update for each ((id|name), plugin) record
|
||||
for e in entities:
|
||||
key = ((e.external_id or e.name), e.plugin)
|
||||
entities_map[key] = e
|
||||
|
||||
# Retrieve existing records and merge them
|
||||
for i, entity in enumerate(entities):
|
||||
existing_entity = existing_entities[i]
|
||||
if existing_entity:
|
||||
entity = merge(entity, existing_entity)
|
||||
|
||||
new_entities.append(entity)
|
||||
|
||||
return new_entities
|
||||
|
||||
def _process_entities(self, *entities: Entity):
|
||||
with self._get_db().get_session() as session:
|
||||
# Ensure that the internal IDs are set to null before the merge
|
||||
for e in entities:
|
||||
e.id = None # type: ignore
|
||||
|
||||
existing_entities = self._get_if_exist(session, entities)
|
||||
entities = self._merge_entities(entities, existing_entities) # type: ignore
|
||||
session.add_all(entities)
|
||||
session.commit()
|
||||
|
||||
with self._entities_cache_lock:
|
||||
for entity in entities:
|
||||
self._cache_entities(entity, overwrite_cache=True)
|
||||
|
||||
entities_awaiting_flush = {*self._entities_awaiting_flush}
|
||||
for entity in entities:
|
||||
e = self._to_entity_awaiting_flush(entity)
|
||||
if e in entities_awaiting_flush:
|
||||
self._process_event(entity)
|
||||
self._entities_awaiting_flush.remove(e)
|
135
platypush/entities/_registry.py
Normal file
135
platypush/entities/_registry.py
Normal file
|
@ -0,0 +1,135 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Collection, Type
|
||||
|
||||
from platypush.config import Config
|
||||
from platypush.plugins import Plugin
|
||||
from platypush.utils import get_plugin_name_by_class, get_redis
|
||||
|
||||
from ._base import Entity
|
||||
|
||||
_entity_registry_varname = '_platypush/plugin_entity_registry'
|
||||
|
||||
|
||||
def register_entity_plugin(entity_type: Type[Entity], plugin: Plugin):
|
||||
"""
|
||||
Associates a plugin as a manager for a certain entity type.
|
||||
If you use the `@manages` decorator then you usually don't have
|
||||
to call this method directly.
|
||||
"""
|
||||
plugin_name = get_plugin_name_by_class(plugin.__class__) or ''
|
||||
entity_type_name = entity_type.__name__.lower()
|
||||
redis = get_redis()
|
||||
registry = get_plugin_entity_registry()
|
||||
registry_by_plugin = set(registry['by_plugin'].get(plugin_name, []))
|
||||
|
||||
registry_by_entity_type = set(registry['by_entity_type'].get(entity_type_name, []))
|
||||
|
||||
registry_by_plugin.add(entity_type_name)
|
||||
registry_by_entity_type.add(plugin_name)
|
||||
registry['by_plugin'][plugin_name] = list(registry_by_plugin)
|
||||
registry['by_entity_type'][entity_type_name] = list(registry_by_entity_type)
|
||||
redis.mset({_entity_registry_varname: json.dumps(registry)})
|
||||
|
||||
|
||||
def get_plugin_entity_registry() -> Dict[str, Dict[str, Collection[str]]]:
|
||||
"""
|
||||
Get the `plugin->entity_types` and `entity_type->plugin`
|
||||
mappings supported by the current configuration.
|
||||
"""
|
||||
redis = get_redis()
|
||||
registry = redis.mget([_entity_registry_varname])[0]
|
||||
try:
|
||||
registry = json.loads((registry or b'').decode())
|
||||
except (TypeError, ValueError):
|
||||
return {'by_plugin': {}, 'by_entity_type': {}}
|
||||
|
||||
enabled_plugins = set(Config.get_plugins().keys())
|
||||
|
||||
return {
|
||||
'by_plugin': {
|
||||
plugin_name: entity_types
|
||||
for plugin_name, entity_types in registry['by_plugin'].items()
|
||||
if plugin_name in enabled_plugins
|
||||
},
|
||||
'by_entity_type': {
|
||||
entity_type: [p for p in plugins if p in enabled_plugins]
|
||||
for entity_type, plugins in registry['by_entity_type'].items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class EntityManagerMixin:
|
||||
"""
|
||||
This mixin is injected on the fly into any plugin class declared with
|
||||
the @manages decorator. The class will therefore implement the
|
||||
`publish_entities` and `transform_entities` methods, which can be
|
||||
overridden if required.
|
||||
"""
|
||||
|
||||
def transform_entities(self, entities):
|
||||
"""
|
||||
This method takes a list of entities in any (plugin-specific)
|
||||
format and converts them into a standardized collection of
|
||||
`Entity` objects. Since this method is called by
|
||||
:meth:`.publish_entities` before entity updates are published,
|
||||
you may usually want to extend it to pre-process the entities
|
||||
managed by your extension into the standard format before they
|
||||
are stored and published to all the consumers.
|
||||
"""
|
||||
entities = entities or []
|
||||
for entity in entities:
|
||||
if entity.id:
|
||||
# Entity IDs can only refer to the internal primary key
|
||||
entity.external_id = entity.id
|
||||
entity.id = None # type: ignore
|
||||
|
||||
entity.plugin = get_plugin_name_by_class(self.__class__) # type: ignore
|
||||
entity.updated_at = datetime.utcnow()
|
||||
|
||||
return entities
|
||||
|
||||
def publish_entities(self, entities: Optional[Collection[Entity]]):
|
||||
"""
|
||||
Publishes a list of entities. The downstream consumers include:
|
||||
|
||||
- The entity persistence manager
|
||||
- The web server
|
||||
- Any consumer subscribed to
|
||||
:class:`platypush.message.event.entities.EntityUpdateEvent`
|
||||
events (e.g. web clients)
|
||||
|
||||
If your extension class uses the `@manages` decorator then you usually
|
||||
don't need to override this class (but you may want to extend
|
||||
:meth:`.transform_entities` instead if your extension doesn't natively
|
||||
handle `Entity` objects).
|
||||
"""
|
||||
from . import publish_entities
|
||||
|
||||
entities = self.transform_entities(entities)
|
||||
publish_entities(entities)
|
||||
|
||||
|
||||
def manages(*entities: Type[Entity]):
|
||||
"""
|
||||
This decorator is used to register a plugin/backend class as a
|
||||
manager of one or more types of entities.
|
||||
"""
|
||||
|
||||
def wrapper(plugin: Type[Plugin]):
|
||||
init = plugin.__init__
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
for entity_type in entities:
|
||||
register_entity_plugin(entity_type, self)
|
||||
|
||||
init(self, *args, **kwargs)
|
||||
|
||||
plugin.__init__ = __init__
|
||||
# Inject the EntityManagerMixin
|
||||
if EntityManagerMixin not in plugin.__bases__:
|
||||
plugin.__bases__ = (EntityManagerMixin,) + plugin.__bases__
|
||||
|
||||
return plugin
|
||||
|
||||
return wrapper
|
14
platypush/entities/devices.py
Normal file
14
platypush/entities/devices.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
from sqlalchemy import Column, Integer, Boolean, ForeignKey
|
||||
|
||||
from ._base import Entity
|
||||
|
||||
|
||||
class Device(Entity):
|
||||
__tablename__ = 'device'
|
||||
|
||||
id = Column(Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True)
|
||||
reachable = Column(Boolean, default=True)
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': __tablename__,
|
||||
}
|
30
platypush/entities/lights.py
Normal file
30
platypush/entities/lights.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Float
|
||||
|
||||
from .devices import Device
|
||||
|
||||
|
||||
class Light(Device):
|
||||
__tablename__ = 'light'
|
||||
|
||||
id = Column(Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True)
|
||||
on = Column(Boolean)
|
||||
brightness = Column(Float)
|
||||
saturation = Column(Float)
|
||||
hue = Column(Float)
|
||||
temperature = Column(Float)
|
||||
x = Column(Float)
|
||||
y = Column(Float)
|
||||
colormode = Column(String)
|
||||
effect = Column(String)
|
||||
hue_min = Column(Float)
|
||||
hue_max = Column(Float)
|
||||
saturation_min = Column(Float)
|
||||
saturation_max = Column(Float)
|
||||
brightness_min = Column(Float)
|
||||
brightness_max = Column(Float)
|
||||
temperature_min = Column(Float)
|
||||
temperature_max = Column(Float)
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': __tablename__,
|
||||
}
|
14
platypush/entities/switches.py
Normal file
14
platypush/entities/switches.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
from sqlalchemy import Column, Integer, ForeignKey, Boolean
|
||||
|
||||
from .devices import Device
|
||||
|
||||
|
||||
class Switch(Device):
|
||||
__tablename__ = 'switch'
|
||||
|
||||
id = Column(Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True)
|
||||
state = Column(Boolean)
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': __tablename__,
|
||||
}
|
|
@ -2,7 +2,6 @@ import copy
|
|||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
@ -14,23 +13,15 @@ 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]
|
||||
|
@ -43,27 +34,22 @@ 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')
|
||||
|
@ -78,10 +64,8 @@ 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):
|
||||
"""
|
||||
|
@ -136,13 +120,7 @@ class Event(Message):
|
|||
"""
|
||||
|
||||
result = EventMatchResult(is_match=False)
|
||||
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())
|
||||
event_tokens = re.split(r'\s+', self.args[argname].strip().lower())
|
||||
condition_tokens = re.split(r'\s+', condition_value.strip().lower())
|
||||
|
||||
while event_tokens and condition_tokens:
|
||||
|
@ -170,11 +148,9 @@ 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)
|
||||
|
@ -197,30 +173,30 @@ class Event(Message):
|
|||
args = copy.deepcopy(self.args)
|
||||
flatten(args)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
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},
|
||||
'args': {
|
||||
'type': self.type,
|
||||
**args
|
||||
},
|
||||
cls=self.Encoder,
|
||||
)
|
||||
}, cls=self.Encoder)
|
||||
|
||||
|
||||
class EventMatchResult:
|
||||
"""When comparing an event against an event condition, you want to
|
||||
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"""
|
||||
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 = parsed_args or {}
|
||||
self.parsed_args = {} if not parsed_args else parsed_args
|
||||
|
||||
|
||||
def flatten(args):
|
||||
|
@ -237,5 +213,4 @@ def flatten(args):
|
|||
elif isinstance(arg, (dict, list)):
|
||||
flatten(args[i])
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
32
platypush/message/event/entities.py
Normal file
32
platypush/message/event/entities.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
from abc import ABC
|
||||
from typing import Union
|
||||
|
||||
from platypush.entities import Entity
|
||||
from platypush.message.event import Event
|
||||
|
||||
|
||||
class EntityEvent(Event, ABC):
|
||||
def __init__(
|
||||
self, entity: Union[Entity, dict], *args, disable_logging=True, **kwargs
|
||||
):
|
||||
if isinstance(entity, Entity):
|
||||
entity = entity.to_json()
|
||||
super().__init__(
|
||||
entity=entity, *args, disable_logging=disable_logging, **kwargs
|
||||
)
|
||||
|
||||
|
||||
class EntityUpdateEvent(EntityEvent):
|
||||
"""
|
||||
This event is triggered whenever an entity of any type (a switch, a light,
|
||||
a sensor, a media player etc.) updates its state.
|
||||
"""
|
||||
|
||||
|
||||
class EntityDeleteEvent(EntityEvent):
|
||||
"""
|
||||
This event is triggered whenever an entity is deleted.
|
||||
"""
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -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 = []
|
||||
|
@ -40,11 +40,13 @@ def action(f):
|
|||
|
||||
|
||||
class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to-init]
|
||||
""" Base plugin class """
|
||||
"""Base plugin class"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__()
|
||||
self.logger = logging.getLogger('platypush:plugin:' + get_plugin_name_by_class(self.__class__))
|
||||
self.logger = logging.getLogger(
|
||||
'platypush:plugin:' + get_plugin_name_by_class(self.__class__)
|
||||
)
|
||||
if 'logging' in kwargs:
|
||||
self.logger.setLevel(getattr(logging, kwargs['logging'].upper()))
|
||||
|
||||
|
@ -53,8 +55,9 @@ class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-t
|
|||
)
|
||||
|
||||
def run(self, method, *args, **kwargs):
|
||||
assert method in self.registered_actions, '{} is not a registered action on {}'.\
|
||||
format(method, self.__class__.__name__)
|
||||
assert (
|
||||
method in self.registered_actions
|
||||
), '{} is not a registered action on {}'.format(method, self.__class__.__name__)
|
||||
return getattr(self, method)(*args, **kwargs)
|
||||
|
||||
|
||||
|
@ -62,6 +65,7 @@ class RunnablePlugin(Plugin):
|
|||
"""
|
||||
Class for runnable plugins - i.e. plugins that have a start/stop method and can be started.
|
||||
"""
|
||||
|
||||
def __init__(self, poll_interval: Optional[float] = None, **kwargs):
|
||||
"""
|
||||
:param poll_interval: How often the :meth:`.loop` function should be execute (default: None, no pause/interval).
|
||||
|
@ -78,6 +82,9 @@ class RunnablePlugin(Plugin):
|
|||
def should_stop(self):
|
||||
return self._should_stop.is_set()
|
||||
|
||||
def wait_stop(self, timeout=None):
|
||||
return self._should_stop.wait(timeout)
|
||||
|
||||
def start(self):
|
||||
set_thread_name(self.__class__.__name__)
|
||||
self._thread = threading.Thread(target=self._runner)
|
||||
|
|
|
@ -2,7 +2,11 @@ import os
|
|||
from typing import Sequence, Dict, Tuple, Union, Optional
|
||||
|
||||
from platypush.plugins import RunnablePlugin, action
|
||||
from platypush.schemas.irc import IRCServerSchema, IRCServerStatusSchema, IRCChannelSchema
|
||||
from platypush.schemas.irc import (
|
||||
IRCServerSchema,
|
||||
IRCServerStatusSchema,
|
||||
IRCChannelSchema,
|
||||
)
|
||||
|
||||
from ._bot import IRCBot
|
||||
from .. import ChatPlugin
|
||||
|
@ -59,29 +63,19 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
|
||||
@property
|
||||
def _bots_by_server(self) -> Dict[str, IRCBot]:
|
||||
return {
|
||||
bot.server: bot
|
||||
for srv, bot in self._bots.items()
|
||||
}
|
||||
return {bot.server: bot for srv, bot in self._bots.items()}
|
||||
|
||||
@property
|
||||
def _bots_by_server_and_port(self) -> Dict[Tuple[str, int], IRCBot]:
|
||||
return {
|
||||
(bot.server, bot.port): bot
|
||||
for srv, bot in self._bots.items()
|
||||
}
|
||||
return {(bot.server, bot.port): bot for srv, bot in self._bots.items()}
|
||||
|
||||
@property
|
||||
def _bots_by_alias(self) -> Dict[str, IRCBot]:
|
||||
return {
|
||||
bot.alias: bot
|
||||
for srv, bot in self._bots.items()
|
||||
if bot.alias
|
||||
}
|
||||
return {bot.alias: bot for srv, bot in self._bots.items() if bot.alias}
|
||||
|
||||
def main(self):
|
||||
self._connect()
|
||||
self._should_stop.wait()
|
||||
self.wait_stop()
|
||||
|
||||
def _connect(self):
|
||||
for srv, bot in self._bots.items():
|
||||
|
@ -109,7 +103,11 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
|
||||
@action
|
||||
def send_file(
|
||||
self, file: str, server: Union[str, Tuple[str, int]], nick: str, bind_address: Optional[str] = None
|
||||
self,
|
||||
file: str,
|
||||
server: Union[str, Tuple[str, int]],
|
||||
nick: str,
|
||||
bind_address: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Send a file to an IRC user over DCC connection.
|
||||
|
@ -127,7 +125,10 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
|
||||
@action
|
||||
def send_message(
|
||||
self, text: str, server: Union[str, Tuple[str, int]], target: Union[str, Sequence[str]]
|
||||
self,
|
||||
text: str,
|
||||
server: Union[str, Tuple[str, int]],
|
||||
target: Union[str, Sequence[str]],
|
||||
):
|
||||
"""
|
||||
Send a message to a channel or a nick.
|
||||
|
@ -139,15 +140,14 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
"""
|
||||
bot = self._get_bot(server)
|
||||
method = (
|
||||
bot.connection.privmsg if isinstance(target, str)
|
||||
bot.connection.privmsg
|
||||
if isinstance(target, str)
|
||||
else bot.connection.privmsg_many
|
||||
)
|
||||
method(target, text)
|
||||
|
||||
@action
|
||||
def send_notice(
|
||||
self, text: str, server: Union[str, Tuple[str, int]], target: str
|
||||
):
|
||||
def send_notice(self, text: str, server: Union[str, Tuple[str, int]], target: str):
|
||||
"""
|
||||
Send a notice to a channel or a nick.
|
||||
|
||||
|
@ -192,7 +192,8 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
channel_name = channel
|
||||
channel = bot.channels.get(channel)
|
||||
assert channel, f'Not connected to channel {channel}'
|
||||
return IRCChannelSchema().dump({
|
||||
return IRCChannelSchema().dump(
|
||||
{
|
||||
'is_invite_only': channel.is_invite_only(),
|
||||
'is_moderated': channel.is_moderated(),
|
||||
'is_protected': channel.is_protected(),
|
||||
|
@ -203,11 +204,16 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
'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.
|
||||
|
@ -235,7 +241,9 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
bot.connection.ctcp_reply(target, body)
|
||||
|
||||
@action
|
||||
def disconnect(self, server: Union[str, Tuple[str, int]], message: Optional[str] = None):
|
||||
def disconnect(
|
||||
self, server: Union[str, Tuple[str, int]], message: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Disconnect from a server.
|
||||
|
||||
|
@ -246,9 +254,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
bot.connection.disconnect(message or bot.stop_message)
|
||||
|
||||
@action
|
||||
def invite(
|
||||
self, nick: str, channel: str, server: Union[str, Tuple[str, int]]
|
||||
):
|
||||
def invite(self, nick: str, channel: str, server: Union[str, Tuple[str, int]]):
|
||||
"""
|
||||
Invite a nick to a channel.
|
||||
|
||||
|
@ -272,7 +278,11 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
|
||||
@action
|
||||
def kick(
|
||||
self, nick: str, channel: str, server: Union[str, Tuple[str, int]], reason: Optional[str] = None
|
||||
self,
|
||||
nick: str,
|
||||
channel: str,
|
||||
server: Union[str, Tuple[str, int]],
|
||||
reason: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Kick a nick from a channel.
|
||||
|
@ -286,9 +296,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
bot.connection.kick(channel, nick, reason)
|
||||
|
||||
@action
|
||||
def mode(
|
||||
self, target: str, command: str, server: Union[str, Tuple[str, int]]
|
||||
):
|
||||
def mode(self, target: str, command: str, server: Union[str, Tuple[str, int]]):
|
||||
"""
|
||||
Send a MODE command on the selected target.
|
||||
|
||||
|
@ -324,8 +332,10 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
|
||||
@action
|
||||
def part(
|
||||
self, channel: Union[str, Sequence[str]], server: Union[str, Tuple[str, int]],
|
||||
message: Optional[str] = None
|
||||
self,
|
||||
channel: Union[str, Sequence[str]],
|
||||
server: Union[str, Tuple[str, int]],
|
||||
message: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Parts/exits a channel.
|
||||
|
@ -339,9 +349,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
bot.connection.part(channels=channels, message=message or bot.stop_message)
|
||||
|
||||
@action
|
||||
def quit(
|
||||
self, server: Union[str, Tuple[str, int]], message: Optional[str] = None
|
||||
):
|
||||
def quit(self, server: Union[str, Tuple[str, int]], message: Optional[str] = None):
|
||||
"""
|
||||
Send a QUIT command.
|
||||
|
||||
|
@ -363,7 +371,12 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
|
|||
bot.connection.send_raw(message)
|
||||
|
||||
@action
|
||||
def topic(self, channel: str, server: Union[str, Tuple[str, int]], topic: Optional[str] = None) -> str:
|
||||
def topic(
|
||||
self,
|
||||
channel: str,
|
||||
server: Union[str, Tuple[str, int]],
|
||||
topic: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get/set the topic of an IRC channel.
|
||||
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
"""
|
||||
.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com>
|
||||
"""
|
||||
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from multiprocessing import RLock
|
||||
from typing import Generator
|
||||
|
||||
from sqlalchemy import create_engine, Table, MetaData
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker, scoped_session
|
||||
|
||||
from platypush.plugins import Plugin, action
|
||||
|
||||
|
@ -30,22 +30,23 @@ class DbPlugin(Plugin):
|
|||
"""
|
||||
|
||||
super().__init__()
|
||||
self.engine = self._get_engine(engine, *args, **kwargs)
|
||||
self.engine = self.get_engine(engine, *args, **kwargs)
|
||||
self._session_locks = {}
|
||||
|
||||
def _get_engine(self, engine=None, *args, **kwargs):
|
||||
def get_engine(self, engine=None, *args, **kwargs) -> Engine:
|
||||
if engine:
|
||||
if isinstance(engine, Engine):
|
||||
return engine
|
||||
if engine.startswith('sqlite://'):
|
||||
kwargs['connect_args'] = {'check_same_thread': False}
|
||||
|
||||
return create_engine(engine, *args, **kwargs)
|
||||
return create_engine(engine, *args, **kwargs) # type: ignore
|
||||
|
||||
assert self.engine
|
||||
return self.engine
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
@staticmethod
|
||||
def _build_condition(table, column, value):
|
||||
def _build_condition(_, column, value):
|
||||
if isinstance(value, str):
|
||||
value = "'{}'".format(value)
|
||||
elif not isinstance(value, int) and not isinstance(value, float):
|
||||
|
@ -73,14 +74,14 @@ class DbPlugin(Plugin):
|
|||
:param kwargs: Extra kwargs that will be passed to ``sqlalchemy.create_engine`` (seehttps:///docs.sqlalchemy.org/en/latest/core/engines.html)
|
||||
"""
|
||||
|
||||
engine = self._get_engine(engine, *args, **kwargs)
|
||||
engine = self.get_engine(engine, *args, **kwargs)
|
||||
|
||||
with engine.connect() as connection:
|
||||
connection.execute(statement)
|
||||
|
||||
def _get_table(self, table, engine=None, *args, **kwargs):
|
||||
if not engine:
|
||||
engine = self._get_engine(engine, *args, **kwargs)
|
||||
engine = self.get_engine(engine, *args, **kwargs)
|
||||
|
||||
db_ok = False
|
||||
n_tries = 0
|
||||
|
@ -98,7 +99,7 @@ class DbPlugin(Plugin):
|
|||
self.logger.exception(e)
|
||||
self.logger.info('Waiting {} seconds before retrying'.format(wait_time))
|
||||
time.sleep(wait_time)
|
||||
engine = self._get_engine(engine, *args, **kwargs)
|
||||
engine = self.get_engine(engine, *args, **kwargs)
|
||||
|
||||
if not db_ok and last_error:
|
||||
raise last_error
|
||||
|
@ -163,7 +164,7 @@ class DbPlugin(Plugin):
|
|||
]
|
||||
"""
|
||||
|
||||
engine = self._get_engine(engine, *args, **kwargs)
|
||||
engine = self.get_engine(engine, *args, **kwargs)
|
||||
|
||||
if table:
|
||||
table, engine = self._get_table(table, engine=engine, *args, **kwargs)
|
||||
|
@ -234,7 +235,7 @@ class DbPlugin(Plugin):
|
|||
if key_columns is None:
|
||||
key_columns = []
|
||||
|
||||
engine = self._get_engine(engine, *args, **kwargs)
|
||||
engine = self.get_engine(engine, *args, **kwargs)
|
||||
|
||||
for record in records:
|
||||
table, engine = self._get_table(table, engine=engine, *args, **kwargs)
|
||||
|
@ -293,7 +294,7 @@ class DbPlugin(Plugin):
|
|||
}
|
||||
"""
|
||||
|
||||
engine = self._get_engine(engine, *args, **kwargs)
|
||||
engine = self.get_engine(engine, *args, **kwargs)
|
||||
|
||||
for record in records:
|
||||
table, engine = self._get_table(table, engine=engine, *args, **kwargs)
|
||||
|
@ -341,7 +342,7 @@ class DbPlugin(Plugin):
|
|||
}
|
||||
"""
|
||||
|
||||
engine = self._get_engine(engine, *args, **kwargs)
|
||||
engine = self.get_engine(engine, *args, **kwargs)
|
||||
|
||||
for record in records:
|
||||
table, engine = self._get_table(table, engine=engine, *args, **kwargs)
|
||||
|
@ -352,5 +353,22 @@ class DbPlugin(Plugin):
|
|||
|
||||
engine.execute(delete)
|
||||
|
||||
def create_all(self, engine, base):
|
||||
self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock())
|
||||
with self._session_locks[engine.url]:
|
||||
base.metadata.create_all(engine)
|
||||
|
||||
@contextmanager
|
||||
def get_session(self, engine=None, *args, **kwargs) -> Generator[Session, None, None]:
|
||||
engine = self.get_engine(engine, *args, **kwargs)
|
||||
self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock())
|
||||
with self._session_locks[engine.url]:
|
||||
session = scoped_session(sessionmaker(expire_on_commit=False))
|
||||
session.configure(bind=engine)
|
||||
s = session()
|
||||
yield s
|
||||
s.commit()
|
||||
s.close()
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
251
platypush/plugins/entities/__init__.py
Normal file
251
platypush/plugins/entities/__init__.py
Normal file
|
@ -0,0 +1,251 @@
|
|||
from queue import Queue, Empty
|
||||
from threading import Thread
|
||||
from time import time
|
||||
from typing import Optional, Any, Collection, Mapping
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import make_transient
|
||||
|
||||
from platypush.config import Config
|
||||
from platypush.context import get_plugin, get_bus
|
||||
from platypush.entities import Entity, get_plugin_entity_registry, get_entities_registry
|
||||
from platypush.message.event.entities import EntityUpdateEvent, EntityDeleteEvent
|
||||
from platypush.plugins import Plugin, action
|
||||
|
||||
|
||||
class EntitiesPlugin(Plugin):
|
||||
"""
|
||||
This plugin is used to interact with native platform entities (e.g. switches, lights,
|
||||
sensors etc.) through a consistent interface, regardless of the integration type.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def _get_db(self):
|
||||
db = get_plugin('db')
|
||||
assert db
|
||||
return db
|
||||
|
||||
@action
|
||||
def get(
|
||||
self,
|
||||
types: Optional[Collection[str]] = None,
|
||||
plugins: Optional[Collection[str]] = None,
|
||||
**filter,
|
||||
):
|
||||
"""
|
||||
Retrieve a list of entities.
|
||||
|
||||
:param types: Entity types, as specified by the (lowercase) class name and table name.
|
||||
Default: all entities.
|
||||
:param plugins: Filter by plugin IDs (default: all plugins).
|
||||
:param filter: Filter entities with these criteria (e.g. `name`, `id`,
|
||||
`state`, `type`, `plugin` etc.)
|
||||
"""
|
||||
entity_registry = get_entities_registry()
|
||||
selected_types = []
|
||||
all_types = {e.__tablename__.lower(): e for e in entity_registry}
|
||||
|
||||
if types:
|
||||
selected_types = {t.lower() for t in types}
|
||||
entity_types = {t: et for t, et in all_types.items() if t in selected_types}
|
||||
invalid_types = selected_types.difference(entity_types.keys())
|
||||
assert not invalid_types, (
|
||||
f'No such entity types: {invalid_types}. '
|
||||
f'Supported types: {list(all_types.keys())}'
|
||||
)
|
||||
|
||||
selected_types = entity_types.keys()
|
||||
|
||||
db = self._get_db()
|
||||
enabled_plugins = list(
|
||||
{
|
||||
*Config.get_plugins().keys(),
|
||||
*Config.get_backends().keys(),
|
||||
}
|
||||
)
|
||||
|
||||
with db.get_session() as session:
|
||||
query = session.query(Entity).filter(
|
||||
or_(Entity.plugin.in_(enabled_plugins), Entity.plugin.is_(None))
|
||||
)
|
||||
|
||||
if selected_types:
|
||||
query = query.filter(Entity.type.in_(selected_types))
|
||||
if plugins:
|
||||
query = query.filter(Entity.plugin.in_(plugins))
|
||||
if filter:
|
||||
query = query.filter_by(**filter)
|
||||
|
||||
return [e.to_json() for e in query.all()]
|
||||
|
||||
@action
|
||||
def scan(
|
||||
self,
|
||||
types: Optional[Collection[str]] = None,
|
||||
plugins: Optional[Collection[str]] = None,
|
||||
timeout: Optional[float] = 30.0,
|
||||
):
|
||||
"""
|
||||
(Re-)scan entities and return the updated results.
|
||||
|
||||
:param types: Filter by entity types (e.g. `switch`, `light`, `sensor` etc.).
|
||||
:param plugins: Filter by plugin names (e.g. `switch.tplink` or `light.hue`).
|
||||
:param timeout: Scan timeout in seconds. Default: 30.
|
||||
"""
|
||||
filter = {}
|
||||
plugin_registry = get_plugin_entity_registry()
|
||||
|
||||
if plugins:
|
||||
filter['plugins'] = plugins
|
||||
plugin_registry['by_plugin'] = {
|
||||
plugin: plugin_registry['by_plugin'][plugin]
|
||||
for plugin in plugins
|
||||
if plugin in plugin_registry['by_plugin']
|
||||
}
|
||||
|
||||
if types:
|
||||
filter['types'] = types
|
||||
filter_entity_types = set(types)
|
||||
plugin_registry['by_plugin'] = {
|
||||
plugin_name: entity_types
|
||||
for plugin_name, entity_types in plugin_registry['by_plugin'].items()
|
||||
if any(t for t in entity_types if t in filter_entity_types)
|
||||
}
|
||||
|
||||
enabled_plugins = plugin_registry['by_plugin'].keys()
|
||||
|
||||
def worker(plugin_name: str, q: Queue):
|
||||
try:
|
||||
plugin = get_plugin(plugin_name)
|
||||
assert plugin, f'No such configured plugin: {plugin_name}'
|
||||
# Force a plugin scan by calling the `status` action
|
||||
response = plugin.status()
|
||||
assert not response.errors, response.errors
|
||||
q.put((plugin_name, response.output))
|
||||
except Exception as e:
|
||||
q.put((plugin_name, e))
|
||||
|
||||
q = Queue()
|
||||
start_time = time()
|
||||
results = []
|
||||
workers = [
|
||||
Thread(target=worker, args=(plugin_name, q))
|
||||
for plugin_name in enabled_plugins
|
||||
]
|
||||
|
||||
for w in workers:
|
||||
w.start()
|
||||
|
||||
while len(results) < len(workers) and (
|
||||
not timeout or (time() - start_time < timeout)
|
||||
):
|
||||
try:
|
||||
plugin_name, result = q.get(block=True, timeout=0.5)
|
||||
if isinstance(result, Exception):
|
||||
self.logger.warning(
|
||||
f'Could not load results from plugin {plugin_name}: {result}'
|
||||
)
|
||||
else:
|
||||
results.append(result)
|
||||
except Empty:
|
||||
continue
|
||||
|
||||
if len(results) < len(workers):
|
||||
self.logger.warning('Scan timed out for some plugins')
|
||||
|
||||
for w in workers:
|
||||
w.join(timeout=max(0, timeout - (time() - start_time)) if timeout else None)
|
||||
|
||||
return self.get(**filter)
|
||||
|
||||
@action
|
||||
def execute(self, id: Any, action: str, *args, **kwargs):
|
||||
"""
|
||||
Execute an action on an entity (for example `on`/`off` on a switch, or `get`
|
||||
on a sensor).
|
||||
|
||||
:param id: Entity ID (i.e. the entity's db primary key, not the plugin's external
|
||||
or "logical" key)
|
||||
:param action: Action that should be run. It should be a method implemented
|
||||
by the entity's class.
|
||||
:param args: Action's extra positional arguments.
|
||||
:param kwargs: Action's extra named arguments.
|
||||
"""
|
||||
db = self._get_db()
|
||||
with db.get_session() as session:
|
||||
entity = session.query(Entity).filter_by(id=id).one_or_none()
|
||||
|
||||
assert entity, f'No such entity ID: {id}'
|
||||
return entity.run(action, *args, **kwargs)
|
||||
|
||||
@action
|
||||
def delete(self, *entities: int): # type: ignore
|
||||
"""
|
||||
Delete a set of entity IDs.
|
||||
|
||||
Note: this should only be done if the entity is no longer available or
|
||||
the associated plugin has been disabled, otherwise the entities will be
|
||||
re-created by the plugins on the next scan.
|
||||
|
||||
:param entities: IDs of the entities to be removed.
|
||||
:return: The payload of the deleted entities.
|
||||
"""
|
||||
with self._get_db().get_session() as session:
|
||||
entities: Collection[Entity] = (
|
||||
session.query(Entity).filter(Entity.id.in_(entities)).all()
|
||||
)
|
||||
for entity in entities:
|
||||
session.delete(entity)
|
||||
session.commit()
|
||||
|
||||
for entity in entities:
|
||||
make_transient(entity)
|
||||
get_bus().post(EntityDeleteEvent(entity))
|
||||
|
||||
return entities
|
||||
|
||||
@action
|
||||
def rename(self, **entities: Mapping[str, str]):
|
||||
"""
|
||||
Rename a sequence of entities.
|
||||
Renaming, as of now, is actually done by setting the ``.meta.name_override``
|
||||
property of an entity rather than fully renaming the entity (which may be owned
|
||||
by a plugin that doesn't support renaming, therefore the next entity update may
|
||||
overwrite the name).
|
||||
|
||||
:param entities: Entity `id` -> `new_name` mapping.
|
||||
"""
|
||||
return self.set_meta(
|
||||
**{
|
||||
entity_id: {'name_override': name}
|
||||
for entity_id, name in entities.items()
|
||||
}
|
||||
)
|
||||
|
||||
@action
|
||||
def set_meta(self, **entities):
|
||||
"""
|
||||
Update the metadata of a set of entities.
|
||||
|
||||
:param entities: Entity `id` -> `new_metadata_fields` mapping.
|
||||
:return: The updated entities.
|
||||
"""
|
||||
entities = {str(k): v for k, v in entities.items()}
|
||||
with self._get_db().get_session() as session:
|
||||
objs = session.query(Entity).filter(Entity.id.in_(entities.keys())).all()
|
||||
for obj in objs:
|
||||
obj.meta = {**(obj.meta or {}), **(entities.get(str(obj.id), {}))}
|
||||
session.add(obj)
|
||||
|
||||
session.commit()
|
||||
|
||||
for obj in objs:
|
||||
make_transient(obj)
|
||||
get_bus().post(EntityUpdateEvent(obj))
|
||||
|
||||
return [obj.to_json() for obj in objs]
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
4
platypush/plugins/entities/manifest.yaml
Normal file
4
platypush/plugins/entities/manifest.yaml
Normal file
|
@ -0,0 +1,4 @@
|
|||
manifest:
|
||||
events: {}
|
||||
package: platypush.plugins.entities
|
||||
type: plugin
|
|
@ -64,8 +64,9 @@ class GpioPlugin(RunnablePlugin):
|
|||
self._initialized_pins = {}
|
||||
self._monitored_pins = monitored_pins or []
|
||||
self.pins_by_name = pins if pins else {}
|
||||
self.pins_by_number = {number: name
|
||||
for (name, number) in self.pins_by_name.items()}
|
||||
self.pins_by_number = {
|
||||
number: name for (name, number) in self.pins_by_name.items()
|
||||
}
|
||||
|
||||
def _init_board(self):
|
||||
import RPi.GPIO as GPIO
|
||||
|
@ -98,6 +99,7 @@ class GpioPlugin(RunnablePlugin):
|
|||
def on_gpio_event(self):
|
||||
def callback(pin: int):
|
||||
import RPi.GPIO as GPIO
|
||||
|
||||
value = GPIO.input(pin)
|
||||
pin = self.pins_by_number.get(pin, pin)
|
||||
get_bus().post(GPIOEvent(pin=pin, value=value))
|
||||
|
@ -106,23 +108,23 @@ class GpioPlugin(RunnablePlugin):
|
|||
|
||||
def main(self):
|
||||
import RPi.GPIO as GPIO
|
||||
|
||||
if not self._monitored_pins:
|
||||
return # No need to start the monitor
|
||||
|
||||
self._init_board()
|
||||
monitored_pins = [
|
||||
self._get_pin_number(pin) for pin in self._monitored_pins
|
||||
]
|
||||
monitored_pins = [self._get_pin_number(pin) for pin in self._monitored_pins]
|
||||
|
||||
for pin in monitored_pins:
|
||||
GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
|
||||
GPIO.add_event_detect(pin, GPIO.BOTH, callback=self.on_gpio_event())
|
||||
|
||||
self._should_stop.wait()
|
||||
self.wait_stop()
|
||||
|
||||
@action
|
||||
def write(self, pin: Union[int, str], value: Union[int, bool],
|
||||
name: Optional[str] = None) -> Dict[str, Any]:
|
||||
def write(
|
||||
self, pin: Union[int, str], value: Union[int, bool], name: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Write a byte value to a pin.
|
||||
|
||||
|
|
|
@ -1,30 +1,52 @@
|
|||
from abc import ABC, abstractmethod
|
||||
|
||||
from platypush.plugins import action
|
||||
from platypush.plugins.switch import SwitchPlugin
|
||||
from platypush.entities import manages
|
||||
from platypush.entities.lights import Light
|
||||
from platypush.plugins import Plugin, action
|
||||
|
||||
|
||||
class LightPlugin(SwitchPlugin, ABC):
|
||||
@manages(Light)
|
||||
class LightPlugin(Plugin, ABC):
|
||||
"""
|
||||
Abstract plugin to interface your logic with lights/bulbs.
|
||||
"""
|
||||
|
||||
@action
|
||||
@abstractmethod
|
||||
def on(self):
|
||||
""" Turn the light on """
|
||||
def on(self, lights=None, *args, **kwargs):
|
||||
"""Turn the light on"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
@abstractmethod
|
||||
def off(self):
|
||||
""" Turn the light off """
|
||||
def off(self, lights=None, *args, **kwargs):
|
||||
"""Turn the light off"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
@abstractmethod
|
||||
def toggle(self):
|
||||
""" Toggle the light status (on/off) """
|
||||
def toggle(self, lights=None, *args, **kwargs):
|
||||
"""Toggle the light status (on/off)"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
@abstractmethod
|
||||
def set_lights(self, lights=None, *args, **kwargs):
|
||||
"""
|
||||
Set a set of properties on a set of lights.
|
||||
|
||||
:param light: List of lights to set. Each item can represent a light
|
||||
name or ID.
|
||||
:param kwargs: key-value list of the parameters to set.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
@abstractmethod
|
||||
def status(self, *args, **kwargs):
|
||||
"""
|
||||
Get the current status of the lights.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
|
|
|
@ -4,16 +4,22 @@ import time
|
|||
|
||||
from enum import Enum
|
||||
from threading import Thread, Event
|
||||
from typing import List
|
||||
from typing import Iterable, Union, Mapping, Any, Set
|
||||
|
||||
from platypush.context import get_bus
|
||||
from platypush.message.event.light import LightAnimationStartedEvent, LightAnimationStoppedEvent
|
||||
from platypush.plugins import action
|
||||
from platypush.entities import Entity
|
||||
from platypush.entities.lights import Light as LightEntity
|
||||
from platypush.message.event.light import (
|
||||
LightAnimationStartedEvent,
|
||||
LightAnimationStoppedEvent,
|
||||
LightStatusChangeEvent,
|
||||
)
|
||||
from platypush.plugins import action, RunnablePlugin
|
||||
from platypush.plugins.light import LightPlugin
|
||||
from platypush.utils import set_thread_name
|
||||
|
||||
|
||||
class LightHuePlugin(LightPlugin):
|
||||
class LightHuePlugin(RunnablePlugin, LightPlugin):
|
||||
"""
|
||||
Philips Hue lights plugin.
|
||||
|
||||
|
@ -25,15 +31,20 @@ class LightHuePlugin(LightPlugin):
|
|||
|
||||
- :class:`platypush.message.event.light.LightAnimationStartedEvent` when an animation is started.
|
||||
- :class:`platypush.message.event.light.LightAnimationStoppedEvent` when an animation is stopped.
|
||||
- :class:`platypush.message.event.light.LightStatusChangeEvent` when the status of a lightbulb
|
||||
changes.
|
||||
|
||||
"""
|
||||
|
||||
MAX_BRI = 255
|
||||
MAX_SAT = 255
|
||||
MAX_HUE = 65535
|
||||
MIN_CT = 154
|
||||
MAX_CT = 500
|
||||
ANIMATION_CTRL_QUEUE_NAME = 'platypush/light/hue/AnimationCtrl'
|
||||
_BRIDGE_RECONNECT_SECONDS = 5
|
||||
_MAX_RECONNECT_TRIES = 5
|
||||
_UNINITIALIZED_BRIDGE_ERR = 'The Hue bridge is not initialized'
|
||||
|
||||
class Animation(Enum):
|
||||
COLOR_TRANSITION = 'color_transition'
|
||||
|
@ -45,7 +56,7 @@ class LightHuePlugin(LightPlugin):
|
|||
elif isinstance(other, self.__class__):
|
||||
return self == other
|
||||
|
||||
def __init__(self, bridge, lights=None, groups=None):
|
||||
def __init__(self, bridge, lights=None, groups=None, poll_seconds: float = 20.0):
|
||||
"""
|
||||
:param bridge: Bridge address or hostname
|
||||
:type bridge: str
|
||||
|
@ -55,38 +66,54 @@ class LightHuePlugin(LightPlugin):
|
|||
|
||||
:param groups Default groups to be controlled (default: all)
|
||||
:type groups: list[str]
|
||||
|
||||
:param poll_seconds: How often the plugin should check the bridge for light
|
||||
updates (default: 20 seconds).
|
||||
"""
|
||||
|
||||
super().__init__()
|
||||
|
||||
self.bridge_address = bridge
|
||||
self.bridge = None
|
||||
self.logger.info('Initializing Hue lights plugin - bridge: "{}"'.format(self.bridge_address))
|
||||
self.logger.info(
|
||||
'Initializing Hue lights plugin - bridge: "{}"'.format(self.bridge_address)
|
||||
)
|
||||
|
||||
self.connect()
|
||||
self.lights = []
|
||||
self.groups = []
|
||||
self.lights = set()
|
||||
self.groups = set()
|
||||
self.poll_seconds = poll_seconds
|
||||
self._cached_lights = {}
|
||||
|
||||
if lights:
|
||||
self.lights = lights
|
||||
self.lights = set(lights)
|
||||
elif groups:
|
||||
self.groups = groups
|
||||
self._expand_groups()
|
||||
self.groups = set(groups)
|
||||
self.lights.update(self._expand_groups(self.groups))
|
||||
else:
|
||||
# noinspection PyUnresolvedReferences
|
||||
self.lights = [light.name for light in self.bridge.lights]
|
||||
self.lights = {light['name'] for light in self._get_lights().values()}
|
||||
|
||||
self.animation_thread = None
|
||||
self.animations = {}
|
||||
self._animation_stop = Event()
|
||||
self._init_animations()
|
||||
self.logger.info('Configured lights: "{}"'.format(self.lights))
|
||||
self.logger.info(f'Configured lights: {self.lights}')
|
||||
|
||||
def _expand_groups(self):
|
||||
groups = [g for g in self.bridge.groups if g.name in self.groups]
|
||||
for group in groups:
|
||||
for light in group.lights:
|
||||
self.lights += [light.name]
|
||||
def _expand_groups(self, groups: Iterable[str]) -> Set[str]:
|
||||
lights = set()
|
||||
light_id_to_name = {
|
||||
light_id: light['name'] for light_id, light in self._get_lights().items()
|
||||
}
|
||||
|
||||
groups_ = [g for g in self._get_groups().values() if g.get('name') in groups]
|
||||
|
||||
for group in groups_:
|
||||
for light_id in group.get('lights', []):
|
||||
light_name = light_id_to_name.get(light_id)
|
||||
if light_name:
|
||||
lights.add(light_name)
|
||||
|
||||
return lights
|
||||
|
||||
def _init_animations(self):
|
||||
self.animations = {
|
||||
|
@ -94,10 +121,10 @@ class LightHuePlugin(LightPlugin):
|
|||
'lights': {},
|
||||
}
|
||||
|
||||
for group in self.bridge.groups:
|
||||
self.animations['groups'][group.group_id] = None
|
||||
for light in self.bridge.lights:
|
||||
self.animations['lights'][light.light_id] = None
|
||||
for group_id in self._get_groups():
|
||||
self.animations['groups'][group_id] = None
|
||||
for light_id in self._get_lights():
|
||||
self.animations['lights'][light_id] = None
|
||||
|
||||
@action
|
||||
def connect(self):
|
||||
|
@ -110,6 +137,7 @@ class LightHuePlugin(LightPlugin):
|
|||
# Lazy init
|
||||
if not self.bridge:
|
||||
from phue import Bridge, PhueRegistrationException
|
||||
|
||||
success = False
|
||||
n_tries = 0
|
||||
|
||||
|
@ -119,12 +147,14 @@ class LightHuePlugin(LightPlugin):
|
|||
self.bridge = Bridge(self.bridge_address)
|
||||
success = True
|
||||
except PhueRegistrationException as e:
|
||||
self.logger.warning('Bridge registration error: {}'.
|
||||
format(str(e)))
|
||||
self.logger.warning('Bridge registration error: {}'.format(str(e)))
|
||||
|
||||
if n_tries >= self._MAX_RECONNECT_TRIES:
|
||||
self.logger.error(('Bridge registration failed after ' +
|
||||
'{} attempts').format(n_tries))
|
||||
self.logger.error(
|
||||
(
|
||||
'Bridge registration failed after ' + '{} attempts'
|
||||
).format(n_tries)
|
||||
)
|
||||
break
|
||||
|
||||
time.sleep(self._BRIDGE_RECONNECT_SECONDS)
|
||||
|
@ -168,7 +198,7 @@ class LightHuePlugin(LightPlugin):
|
|||
'id': id,
|
||||
**scene,
|
||||
}
|
||||
for id, scene in self.bridge.get_scene().items()
|
||||
for id, scene in self._get_scenes().items()
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -215,7 +245,7 @@ class LightHuePlugin(LightPlugin):
|
|||
'id': id,
|
||||
**light,
|
||||
}
|
||||
for id, light in self.bridge.get_light().items()
|
||||
for id, light in self._get_lights().items()
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -273,7 +303,7 @@ class LightHuePlugin(LightPlugin):
|
|||
'id': id,
|
||||
**group,
|
||||
}
|
||||
for id, group in self.bridge.get_group().items()
|
||||
for id, group in self._get_groups().items()
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -321,15 +351,22 @@ class LightHuePlugin(LightPlugin):
|
|||
self.bridge = None
|
||||
raise e
|
||||
|
||||
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
|
||||
lights = []
|
||||
groups = []
|
||||
|
||||
if 'lights' in kwargs:
|
||||
lights = kwargs.pop('lights').split(',').strip() \
|
||||
if isinstance(lights, str) else kwargs.pop('lights')
|
||||
lights = (
|
||||
kwargs.pop('lights').split(',').strip()
|
||||
if isinstance(lights, str)
|
||||
else kwargs.pop('lights')
|
||||
)
|
||||
if 'groups' in kwargs:
|
||||
groups = kwargs.pop('groups').split(',').strip() \
|
||||
if isinstance(groups, str) else kwargs.pop('groups')
|
||||
groups = (
|
||||
kwargs.pop('groups').split(',').strip()
|
||||
if isinstance(groups, str)
|
||||
else kwargs.pop('groups')
|
||||
)
|
||||
|
||||
if not lights and not groups:
|
||||
lights = self.lights
|
||||
|
@ -340,34 +377,36 @@ class LightHuePlugin(LightPlugin):
|
|||
|
||||
try:
|
||||
if attr == 'scene':
|
||||
self.bridge.run_scene(groups[0], kwargs.pop('name'))
|
||||
assert groups, 'No groups specified'
|
||||
self.bridge.run_scene(list(groups)[0], kwargs.pop('name'))
|
||||
else:
|
||||
if groups:
|
||||
self.bridge.set_group(groups, attr, *args, **kwargs)
|
||||
self.bridge.set_group(list(groups), attr, *args, **kwargs)
|
||||
if lights:
|
||||
self.bridge.set_light(lights, attr, *args, **kwargs)
|
||||
self.bridge.set_light(list(lights), attr, *args, **kwargs)
|
||||
except Exception as e:
|
||||
# Reset bridge connection
|
||||
self.bridge = None
|
||||
raise e
|
||||
|
||||
@action
|
||||
def set_light(self, light, **kwargs):
|
||||
"""
|
||||
Set a light (or lights) property.
|
||||
return self._get_lights()
|
||||
|
||||
:param light: Light or lights to set. Can be a string representing the light name,
|
||||
a light object, a list of string, or a list of light objects.
|
||||
:param kwargs: key-value list of parameters to set.
|
||||
@action
|
||||
def set_lights(self, lights, **kwargs):
|
||||
"""
|
||||
Set a set of properties on a set of lights.
|
||||
|
||||
:param light: List of lights to set. Each item can represent a light
|
||||
name or ID.
|
||||
:param kwargs: key-value list of the parameters to set.
|
||||
|
||||
Example call::
|
||||
|
||||
{
|
||||
"type": "request",
|
||||
"target": "hostname",
|
||||
"action": "light.hue.set_light",
|
||||
"args": {
|
||||
"light": "Bulb 1",
|
||||
"lights": ["Bulb 1", "Bulb 2"],
|
||||
"sat": 255
|
||||
}
|
||||
}
|
||||
|
@ -375,21 +414,42 @@ class LightHuePlugin(LightPlugin):
|
|||
"""
|
||||
|
||||
self.connect()
|
||||
self.bridge.set_light(light, **kwargs)
|
||||
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
|
||||
all_lights = self._get_lights()
|
||||
|
||||
for i, l in enumerate(lights):
|
||||
if str(l) in all_lights:
|
||||
lights[i] = all_lights[str(l)]['name']
|
||||
|
||||
# Convert entity attributes to local attributes
|
||||
if kwargs.get('saturation') is not None:
|
||||
kwargs['sat'] = kwargs.pop('saturation')
|
||||
if kwargs.get('brightness') is not None:
|
||||
kwargs['bri'] = kwargs.pop('brightness')
|
||||
if kwargs.get('temperature') is not None:
|
||||
kwargs['ct'] = kwargs.pop('temperature')
|
||||
|
||||
# "Unroll" the map
|
||||
args = []
|
||||
for arg, value in kwargs.items():
|
||||
args += [arg, value]
|
||||
|
||||
self.bridge.set_light(lights, *args)
|
||||
return self._get_lights()
|
||||
|
||||
@action
|
||||
def set_group(self, group, **kwargs):
|
||||
"""
|
||||
Set a group (or groups) property.
|
||||
|
||||
:param group: Group or groups to set. Can be a string representing the group name, a group object, a list of strings, or a list of group objects.
|
||||
:param group: Group or groups to set. It can be a string representing the
|
||||
group name, a group object, a list of strings, or a list of group objects.
|
||||
:param kwargs: key-value list of parameters to set.
|
||||
|
||||
Example call::
|
||||
|
||||
{
|
||||
"type": "request",
|
||||
"target": "hostname",
|
||||
"action": "light.hue.set_group",
|
||||
"args": {
|
||||
"light": "Living Room",
|
||||
|
@ -400,6 +460,7 @@ class LightHuePlugin(LightPlugin):
|
|||
"""
|
||||
|
||||
self.connect()
|
||||
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
|
||||
self.bridge.set_group(group, **kwargs)
|
||||
|
||||
@action
|
||||
|
@ -451,15 +512,16 @@ class LightHuePlugin(LightPlugin):
|
|||
groups_off = []
|
||||
|
||||
if groups:
|
||||
all_groups = self.bridge.get_group().values()
|
||||
|
||||
all_groups = self._get_groups().values()
|
||||
groups_on = [
|
||||
group['name'] for group in all_groups
|
||||
group['name']
|
||||
for group in all_groups
|
||||
if group['name'] in groups and group['state']['any_on'] is True
|
||||
]
|
||||
|
||||
groups_off = [
|
||||
group['name'] for group in all_groups
|
||||
group['name']
|
||||
for group in all_groups
|
||||
if group['name'] in groups and group['state']['any_on'] is False
|
||||
]
|
||||
|
||||
|
@ -467,16 +529,20 @@ class LightHuePlugin(LightPlugin):
|
|||
lights = self.lights
|
||||
|
||||
if lights:
|
||||
all_lights = self.bridge.get_light().values()
|
||||
all_lights = self._get_lights()
|
||||
|
||||
lights_on = [
|
||||
light['name'] for light in all_lights
|
||||
if light['name'] in lights and light['state']['on'] is True
|
||||
light['name']
|
||||
for light_id, light in all_lights.items()
|
||||
if (light_id in lights or light['name'] in lights)
|
||||
and light['state']['on'] is True
|
||||
]
|
||||
|
||||
lights_off = [
|
||||
light['name'] for light in all_lights
|
||||
if light['name'] in lights and light['state']['on'] is False
|
||||
light['name']
|
||||
for light_id, light in all_lights.items()
|
||||
if (light_id in lights or light['name'] in lights)
|
||||
and light['state']['on'] is False
|
||||
]
|
||||
|
||||
if lights_on or groups_on:
|
||||
|
@ -499,8 +565,13 @@ class LightHuePlugin(LightPlugin):
|
|||
groups = []
|
||||
if lights is None:
|
||||
lights = []
|
||||
return self._exec('bri', int(value) % (self.MAX_BRI + 1),
|
||||
lights=lights, groups=groups, **kwargs)
|
||||
return self._exec(
|
||||
'bri',
|
||||
int(value) % (self.MAX_BRI + 1),
|
||||
lights=lights,
|
||||
groups=groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@action
|
||||
def sat(self, value, lights=None, groups=None, **kwargs):
|
||||
|
@ -516,8 +587,13 @@ class LightHuePlugin(LightPlugin):
|
|||
groups = []
|
||||
if lights is None:
|
||||
lights = []
|
||||
return self._exec('sat', int(value) % (self.MAX_SAT + 1),
|
||||
lights=lights, groups=groups, **kwargs)
|
||||
return self._exec(
|
||||
'sat',
|
||||
int(value) % (self.MAX_SAT + 1),
|
||||
lights=lights,
|
||||
groups=groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@action
|
||||
def hue(self, value, lights=None, groups=None, **kwargs):
|
||||
|
@ -533,8 +609,13 @@ class LightHuePlugin(LightPlugin):
|
|||
groups = []
|
||||
if lights is None:
|
||||
lights = []
|
||||
return self._exec('hue', int(value) % (self.MAX_HUE + 1),
|
||||
lights=lights, groups=groups, **kwargs)
|
||||
return self._exec(
|
||||
'hue',
|
||||
int(value) % (self.MAX_HUE + 1),
|
||||
lights=lights,
|
||||
groups=groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@action
|
||||
def xy(self, value, lights=None, groups=None, **kwargs):
|
||||
|
@ -557,7 +638,7 @@ class LightHuePlugin(LightPlugin):
|
|||
"""
|
||||
Set lights/groups color temperature.
|
||||
|
||||
:param value: Temperature value (range: 0-255)
|
||||
:param value: Temperature value (range: 154-500)
|
||||
:type value: int
|
||||
:param lights: List of lights.
|
||||
:param groups: List of groups.
|
||||
|
@ -584,25 +665,31 @@ class LightHuePlugin(LightPlugin):
|
|||
lights = []
|
||||
|
||||
if lights:
|
||||
bri = statistics.mean([
|
||||
bri = statistics.mean(
|
||||
[
|
||||
light['state']['bri']
|
||||
for light in self.bridge.get_light().values()
|
||||
for light in self._get_lights().values()
|
||||
if light['name'] in lights
|
||||
])
|
||||
]
|
||||
)
|
||||
elif groups:
|
||||
bri = statistics.mean([
|
||||
bri = statistics.mean(
|
||||
[
|
||||
group['action']['bri']
|
||||
for group in self.bridge.get_group().values()
|
||||
for group in self._get_groups().values()
|
||||
if group['name'] in groups
|
||||
])
|
||||
]
|
||||
)
|
||||
else:
|
||||
bri = statistics.mean([
|
||||
bri = statistics.mean(
|
||||
[
|
||||
light['state']['bri']
|
||||
for light in self.bridge.get_light().values()
|
||||
for light in self._get_lights().values()
|
||||
if light['name'] in self.lights
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
delta *= (self.MAX_BRI / 100)
|
||||
delta *= self.MAX_BRI / 100
|
||||
if bri + delta < 0:
|
||||
bri = 0
|
||||
elif bri + delta > self.MAX_BRI:
|
||||
|
@ -628,25 +715,31 @@ class LightHuePlugin(LightPlugin):
|
|||
lights = []
|
||||
|
||||
if lights:
|
||||
sat = statistics.mean([
|
||||
sat = statistics.mean(
|
||||
[
|
||||
light['state']['sat']
|
||||
for light in self.bridge.get_light().values()
|
||||
for light in self._get_lights().values()
|
||||
if light['name'] in lights
|
||||
])
|
||||
]
|
||||
)
|
||||
elif groups:
|
||||
sat = statistics.mean([
|
||||
sat = statistics.mean(
|
||||
[
|
||||
group['action']['sat']
|
||||
for group in self.bridge.get_group().values()
|
||||
for group in self._get_groups().values()
|
||||
if group['name'] in groups
|
||||
])
|
||||
]
|
||||
)
|
||||
else:
|
||||
sat = statistics.mean([
|
||||
sat = statistics.mean(
|
||||
[
|
||||
light['state']['sat']
|
||||
for light in self.bridge.get_light().values()
|
||||
for light in self._get_lights().values()
|
||||
if light['name'] in self.lights
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
delta *= (self.MAX_SAT / 100)
|
||||
delta *= self.MAX_SAT / 100
|
||||
if sat + delta < 0:
|
||||
sat = 0
|
||||
elif sat + delta > self.MAX_SAT:
|
||||
|
@ -672,25 +765,31 @@ class LightHuePlugin(LightPlugin):
|
|||
lights = []
|
||||
|
||||
if lights:
|
||||
hue = statistics.mean([
|
||||
hue = statistics.mean(
|
||||
[
|
||||
light['state']['hue']
|
||||
for light in self.bridge.get_light().values()
|
||||
for light in self._get_lights().values()
|
||||
if light['name'] in lights
|
||||
])
|
||||
]
|
||||
)
|
||||
elif groups:
|
||||
hue = statistics.mean([
|
||||
hue = statistics.mean(
|
||||
[
|
||||
group['action']['hue']
|
||||
for group in self.bridge.get_group().values()
|
||||
for group in self._get_groups().values()
|
||||
if group['name'] in groups
|
||||
])
|
||||
]
|
||||
)
|
||||
else:
|
||||
hue = statistics.mean([
|
||||
hue = statistics.mean(
|
||||
[
|
||||
light['state']['hue']
|
||||
for light in self.bridge.get_light().values()
|
||||
for light in self._get_lights().values()
|
||||
if light['name'] in self.lights
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
delta *= (self.MAX_HUE / 100)
|
||||
delta *= self.MAX_HUE / 100
|
||||
if hue + delta < 0:
|
||||
hue = 0
|
||||
elif hue + delta > self.MAX_HUE:
|
||||
|
@ -734,10 +833,20 @@ class LightHuePlugin(LightPlugin):
|
|||
self._init_animations()
|
||||
|
||||
@action
|
||||
def animate(self, animation, duration=None,
|
||||
hue_range=None, sat_range=None,
|
||||
bri_range=None, lights=None, groups=None,
|
||||
hue_step=1000, sat_step=2, bri_step=1, transition_seconds=1.0):
|
||||
def animate(
|
||||
self,
|
||||
animation,
|
||||
duration=None,
|
||||
hue_range=None,
|
||||
sat_range=None,
|
||||
bri_range=None,
|
||||
lights=None,
|
||||
groups=None,
|
||||
hue_step=1000,
|
||||
sat_step=2,
|
||||
bri_step=1,
|
||||
transition_seconds=1.0,
|
||||
):
|
||||
"""
|
||||
Run a lights animation.
|
||||
|
||||
|
@ -747,28 +856,33 @@ class LightHuePlugin(LightPlugin):
|
|||
:param duration: Animation duration in seconds (default: None, i.e. continue until stop)
|
||||
:type duration: float
|
||||
|
||||
:param hue_range: If you selected a ``color_transition``, this will specify the hue range of your color ``color_transition``.
|
||||
Default: [0, 65535]
|
||||
:param hue_range: If you selected a ``color_transition``, this will
|
||||
specify the hue range of your color ``color_transition``. Default: [0, 65535]
|
||||
:type hue_range: list[int]
|
||||
|
||||
:param sat_range: If you selected a color ``color_transition``, this will specify the saturation range of your color
|
||||
``color_transition``. Default: [0, 255]
|
||||
:param sat_range: If you selected a color ``color_transition``, this
|
||||
will specify the saturation range of your color ``color_transition``.
|
||||
Default: [0, 255]
|
||||
:type sat_range: list[int]
|
||||
|
||||
:param bri_range: If you selected a color ``color_transition``, this will specify the brightness range of your color
|
||||
``color_transition``. Default: [254, 255] :type bri_range: list[int]
|
||||
:param bri_range: If you selected a color ``color_transition``, this
|
||||
will specify the brightness range of your color ``color_transition``.
|
||||
Default: [254, 255] :type bri_range: list[int]
|
||||
|
||||
:param lights: Lights to control (names, IDs or light objects). Default: plugin default lights
|
||||
:param groups: Groups to control (names, IDs or group objects). Default: plugin default groups
|
||||
|
||||
:param hue_step: If you selected a color ``color_transition``, this will specify by how much the color hue will change
|
||||
between iterations. Default: 1000 :type hue_step: int
|
||||
:param hue_step: If you selected a color ``color_transition``, this
|
||||
will specify by how much the color hue will change between iterations.
|
||||
Default: 1000 :type hue_step: int
|
||||
|
||||
:param sat_step: If you selected a color ``color_transition``, this will specify by how much the saturation will change
|
||||
between iterations. Default: 2 :type sat_step: int
|
||||
:param sat_step: If you selected a color ``color_transition``, this
|
||||
will specify by how much the saturation will change between iterations.
|
||||
Default: 2 :type sat_step: int
|
||||
|
||||
:param bri_step: If you selected a color ``color_transition``, this will specify by how much the brightness will change
|
||||
between iterations. Default: 1 :type bri_step: int
|
||||
:param bri_step: If you selected a color ``color_transition``, this
|
||||
will specify by how much the brightness will change between iterations.
|
||||
Default: 1 :type bri_step: int
|
||||
|
||||
:param transition_seconds: Time between two transitions or blinks in seconds. Default: 1.0
|
||||
:type transition_seconds: float
|
||||
|
@ -776,20 +890,26 @@ class LightHuePlugin(LightPlugin):
|
|||
|
||||
self.stop_animation()
|
||||
self._animation_stop.clear()
|
||||
all_lights = self._get_lights()
|
||||
bri_range = bri_range or [self.MAX_BRI - 1, self.MAX_BRI]
|
||||
sat_range = sat_range or [0, self.MAX_SAT]
|
||||
hue_range = hue_range or [0, self.MAX_HUE]
|
||||
|
||||
if bri_range is None:
|
||||
bri_range = [self.MAX_BRI - 1, self.MAX_BRI]
|
||||
if sat_range is None:
|
||||
sat_range = [0, self.MAX_SAT]
|
||||
if hue_range is None:
|
||||
hue_range = [0, self.MAX_HUE]
|
||||
if groups:
|
||||
groups = [g for g in self.bridge.groups if g.name in groups or g.group_id in groups]
|
||||
lights = lights or []
|
||||
for group in groups:
|
||||
lights.extend([light.name for light in group.lights])
|
||||
groups = {
|
||||
group_id: group
|
||||
for group_id, group in self._get_groups().items()
|
||||
if group.get('name') in groups or group_id in groups
|
||||
}
|
||||
|
||||
lights = set(lights or [])
|
||||
lights.update(self._expand_groups([g['name'] for g in groups.values()]))
|
||||
elif lights:
|
||||
lights = [light.name for light in self.bridge.lights if light.name in lights or light.light_id in lights]
|
||||
lights = {
|
||||
light['name']
|
||||
for light_id, light in all_lights.items()
|
||||
if light['name'] in lights or int(light_id) in lights
|
||||
}
|
||||
else:
|
||||
lights = self.lights
|
||||
|
||||
|
@ -806,26 +926,50 @@ class LightHuePlugin(LightPlugin):
|
|||
}
|
||||
|
||||
if groups:
|
||||
for group in groups:
|
||||
self.animations['groups'][group.group_id] = info
|
||||
for group_id in groups:
|
||||
self.animations['groups'][group_id] = info
|
||||
|
||||
for light in self.bridge.lights:
|
||||
if light.name in lights:
|
||||
self.animations['lights'][light.light_id] = info
|
||||
for light_id, light in all_lights.items():
|
||||
if light['name'] in lights:
|
||||
self.animations['lights'][light_id] = info
|
||||
|
||||
def _initialize_light_attrs(lights):
|
||||
lights_by_name = {
|
||||
light['name']: light for light in self._get_lights().values()
|
||||
}
|
||||
|
||||
if animation == self.Animation.COLOR_TRANSITION:
|
||||
return {light: {
|
||||
'hue': random.randint(hue_range[0], hue_range[1]),
|
||||
'sat': random.randint(sat_range[0], sat_range[1]),
|
||||
'bri': random.randint(bri_range[0], bri_range[1]),
|
||||
} for light in lights}
|
||||
return {
|
||||
light: {
|
||||
**(
|
||||
{'hue': random.randint(hue_range[0], hue_range[1])} # type: ignore
|
||||
if 'hue' in lights_by_name.get(light, {}).get('state', {})
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{'sat': random.randint(sat_range[0], sat_range[1])} # type: ignore
|
||||
if 'sat' in lights_by_name.get(light, {}).get('state', {})
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{'bri': random.randint(bri_range[0], bri_range[1])} # type: ignore
|
||||
if 'bri' in lights_by_name.get(light, {}).get('state', {})
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for light in lights
|
||||
}
|
||||
elif animation == self.Animation.BLINK:
|
||||
return {light: {
|
||||
return {
|
||||
light: {
|
||||
'on': True,
|
||||
'bri': self.MAX_BRI,
|
||||
**({'bri': self.MAX_BRI} if 'bri' in light else {}),
|
||||
'transitiontime': 0,
|
||||
} for light in lights}
|
||||
}
|
||||
for light in lights
|
||||
}
|
||||
|
||||
raise AssertionError(f'Unknown animation type: {animation}')
|
||||
|
||||
def _next_light_attrs(lights):
|
||||
if animation == self.Animation.COLOR_TRANSITION:
|
||||
|
@ -843,15 +987,19 @@ class LightHuePlugin(LightPlugin):
|
|||
else:
|
||||
continue
|
||||
|
||||
lights[light][attr] = ((value - attr_range[0] + attr_step) %
|
||||
(attr_range[1] - attr_range[0] + 1)) + \
|
||||
attr_range[0]
|
||||
lights[light][attr] = (
|
||||
(value - attr_range[0] + attr_step)
|
||||
% (attr_range[1] - attr_range[0] + 1)
|
||||
) + attr_range[0]
|
||||
elif animation == self.Animation.BLINK:
|
||||
lights = {light: {
|
||||
'on': False if attrs['on'] else True,
|
||||
lights = {
|
||||
light: {
|
||||
'on': not attrs['on'],
|
||||
'bri': self.MAX_BRI,
|
||||
'transitiontime': 0,
|
||||
} for (light, attrs) in lights.items()}
|
||||
}
|
||||
for (light, attrs) in lights.items()
|
||||
}
|
||||
|
||||
return lights
|
||||
|
||||
|
@ -860,13 +1008,23 @@ class LightHuePlugin(LightPlugin):
|
|||
|
||||
def _animate_thread(lights):
|
||||
set_thread_name('HueAnimate')
|
||||
get_bus().post(LightAnimationStartedEvent(lights=lights, groups=groups, animation=animation))
|
||||
get_bus().post(
|
||||
LightAnimationStartedEvent(
|
||||
lights=lights,
|
||||
groups=list((groups or {}).keys()),
|
||||
animation=animation,
|
||||
)
|
||||
)
|
||||
|
||||
lights = _initialize_light_attrs(lights)
|
||||
animation_start_time = time.time()
|
||||
stop_animation = False
|
||||
|
||||
while not stop_animation and not (duration and time.time() - animation_start_time > duration):
|
||||
while not stop_animation and not (
|
||||
duration and time.time() - animation_start_time > duration
|
||||
):
|
||||
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
|
||||
|
||||
try:
|
||||
if animation == self.Animation.COLOR_TRANSITION:
|
||||
for (light, attrs) in lights.items():
|
||||
|
@ -877,7 +1035,9 @@ class LightHuePlugin(LightPlugin):
|
|||
self.logger.debug('Setting lights to {}'.format(conf))
|
||||
|
||||
if groups:
|
||||
self.bridge.set_group([g.name for g in groups], conf)
|
||||
self.bridge.set_group(
|
||||
[g['name'] for g in groups.values()], conf
|
||||
)
|
||||
else:
|
||||
self.bridge.set_light(lights.keys(), conf)
|
||||
|
||||
|
@ -891,57 +1051,164 @@ class LightHuePlugin(LightPlugin):
|
|||
|
||||
lights = _next_light_attrs(lights)
|
||||
|
||||
get_bus().post(LightAnimationStoppedEvent(lights=lights, groups=groups, animation=animation))
|
||||
get_bus().post(
|
||||
LightAnimationStoppedEvent(
|
||||
lights=list(lights.keys()),
|
||||
groups=list((groups or {}).keys()),
|
||||
animation=animation,
|
||||
)
|
||||
)
|
||||
|
||||
self.animation_thread = None
|
||||
|
||||
self.animation_thread = Thread(target=_animate_thread,
|
||||
name='HueAnimate',
|
||||
args=(lights,))
|
||||
self.animation_thread = Thread(
|
||||
target=_animate_thread, name='HueAnimate', args=(lights,)
|
||||
)
|
||||
self.animation_thread.start()
|
||||
|
||||
@property
|
||||
def switches(self) -> List[dict]:
|
||||
"""
|
||||
:returns: Implements :meth:`platypush.plugins.switch.SwitchPlugin.switches` and returns the status of the
|
||||
configured lights. Example:
|
||||
def _get_light_attr(self, light, attr: str):
|
||||
try:
|
||||
return getattr(light, attr, None)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
.. code-block:: json
|
||||
def transform_entities(
|
||||
self, entities: Union[Iterable[Union[dict, Entity]], Mapping[Any, dict]]
|
||||
) -> Iterable[Entity]:
|
||||
new_entities = []
|
||||
if isinstance(entities, dict):
|
||||
entities = [{'id': id, **e} for id, e in entities.items()]
|
||||
|
||||
[
|
||||
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'),
|
||||
**(
|
||||
{
|
||||
"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"
|
||||
'hue_min': 0,
|
||||
'hue_max': self.MAX_HUE,
|
||||
}
|
||||
]
|
||||
|
||||
"""
|
||||
|
||||
return [
|
||||
if entity.get('state', {}).get('hue') is not None
|
||||
else {
|
||||
'hue_min': None,
|
||||
'hue_max': None,
|
||||
}
|
||||
),
|
||||
**(
|
||||
{
|
||||
'id': id,
|
||||
**light.pop('state', {}),
|
||||
**light,
|
||||
'saturation_min': 0,
|
||||
'saturation_max': self.MAX_SAT,
|
||||
}
|
||||
for id, light in self.bridge.get_light().items()
|
||||
]
|
||||
if entity.get('state', {}).get('sat') is not None
|
||||
else {
|
||||
'saturation_min': None,
|
||||
'saturation_max': None,
|
||||
}
|
||||
),
|
||||
**(
|
||||
{
|
||||
'brightness_min': 0,
|
||||
'brightness_max': self.MAX_BRI,
|
||||
}
|
||||
if entity.get('state', {}).get('bri') is not None
|
||||
else {
|
||||
'brightness_min': None,
|
||||
'brightness_max': None,
|
||||
}
|
||||
),
|
||||
**(
|
||||
{
|
||||
'temperature_min': self.MIN_CT,
|
||||
'temperature_max': self.MAX_CT,
|
||||
}
|
||||
if entity.get('state', {}).get('ct') is not None
|
||||
else {
|
||||
'temperature_min': None,
|
||||
'temperature_max': None,
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return super().transform_entities(new_entities) # type: ignore
|
||||
|
||||
def _get_lights(self) -> dict:
|
||||
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
|
||||
lights = self.bridge.get_light()
|
||||
lights = {id: light for id, light in lights.items() if not light.get('recycle')}
|
||||
self._cached_lights = lights
|
||||
self.publish_entities(lights) # type: ignore
|
||||
return lights
|
||||
|
||||
def _get_groups(self) -> dict:
|
||||
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
|
||||
groups = self.bridge.get_group() or {}
|
||||
return {id: group for id, group in groups.items() if not group.get('recycle')}
|
||||
|
||||
def _get_scenes(self) -> dict:
|
||||
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
|
||||
scenes = self.bridge.get_scene() or {}
|
||||
return {id: scene for id, scene in scenes.items() if not scene.get('recycle')}
|
||||
|
||||
@action
|
||||
def status(self) -> Iterable[LightEntity]:
|
||||
lights = self.transform_entities(self._get_lights())
|
||||
for light in lights:
|
||||
light.id = light.external_id
|
||||
for attr, value in (light.data or {}).items():
|
||||
setattr(light, attr, value)
|
||||
|
||||
del light.external_id
|
||||
del light.data
|
||||
|
||||
return lights
|
||||
|
||||
def main(self):
|
||||
lights_prev = self._get_lights() # Initialize the lights
|
||||
|
||||
while not self.should_stop():
|
||||
try:
|
||||
lights_new = self._get_lights()
|
||||
for light_id, light in lights_new.items():
|
||||
event_args = {}
|
||||
new_state = light.get('state', {})
|
||||
prev_state = lights_prev.get(light_id, {}).get('state', {})
|
||||
|
||||
for attr in ['on', 'bri', 'sat', 'hue', 'ct', 'xy']:
|
||||
if attr in new_state and new_state.get(attr) != prev_state.get(
|
||||
attr
|
||||
):
|
||||
event_args[attr] = new_state.get(attr)
|
||||
|
||||
if event_args:
|
||||
event_args['plugin_name'] = 'light.hue'
|
||||
event_args['light_id'] = light_id
|
||||
event_args['light_name'] = light.get('name')
|
||||
get_bus().post(LightStatusChangeEvent(**event_args))
|
||||
self.publish_entities([{'id': light_id, **light}]) # type: ignore
|
||||
|
||||
lights_prev = lights_new
|
||||
finally:
|
||||
self.wait_stop(self.poll_seconds)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -4,6 +4,8 @@ manifest:
|
|||
started.
|
||||
platypush.message.event.light.LightAnimationStoppedEvent: when an animation is
|
||||
stopped.
|
||||
platypush.message.event.light.LightStatusChangeEvent: when the status of a
|
||||
lightbulb changes.
|
||||
install:
|
||||
pip:
|
||||
- phue
|
||||
|
|
|
@ -3,9 +3,16 @@ import os
|
|||
import re
|
||||
import time
|
||||
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime, PrimaryKeyConstraint, ForeignKey
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import (
|
||||
create_engine,
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
PrimaryKeyConstraint,
|
||||
ForeignKey,
|
||||
)
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from platypush.config import Config
|
||||
|
@ -38,7 +45,8 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
if not self._db_engine:
|
||||
self._db_engine = create_engine(
|
||||
'sqlite:///{}'.format(self.db_file),
|
||||
connect_args={'check_same_thread': False})
|
||||
connect_args={'check_same_thread': False},
|
||||
)
|
||||
|
||||
Base.metadata.create_all(self._db_engine)
|
||||
Session.configure(bind=self._db_engine)
|
||||
|
@ -57,27 +65,30 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
|
||||
@classmethod
|
||||
def _get_last_modify_time(cls, path, recursive=False):
|
||||
return max([os.path.getmtime(p) for p, _, _ in os.walk(path)]) \
|
||||
if recursive else os.path.getmtime(path)
|
||||
return (
|
||||
max([os.path.getmtime(p) for p, _, _ in os.walk(path)])
|
||||
if recursive
|
||||
else os.path.getmtime(path)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _has_directory_changed_since_last_indexing(self, dir_record):
|
||||
def _has_directory_changed_since_last_indexing(cls, dir_record):
|
||||
if not dir_record.last_indexed_at:
|
||||
return True
|
||||
|
||||
return datetime.datetime.fromtimestamp(
|
||||
self._get_last_modify_time(dir_record.path)) > dir_record.last_indexed_at
|
||||
return (
|
||||
datetime.datetime.fromtimestamp(cls._get_last_modify_time(dir_record.path))
|
||||
> dir_record.last_indexed_at
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _matches_query(cls, filename, query):
|
||||
filename = filename.lower()
|
||||
query_tokens = [_.lower() for _ in re.split(
|
||||
cls._filename_separators, query.strip())]
|
||||
query_tokens = [
|
||||
_.lower() for _ in re.split(cls._filename_separators, query.strip())
|
||||
]
|
||||
|
||||
for token in query_tokens:
|
||||
if token not in filename:
|
||||
return False
|
||||
return True
|
||||
return all(token in filename for token in query_tokens)
|
||||
|
||||
@classmethod
|
||||
def _sync_token_records(cls, session, *tokens):
|
||||
|
@ -85,9 +96,12 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
if not tokens:
|
||||
return []
|
||||
|
||||
records = {record.token: record for record in
|
||||
session.query(MediaToken).filter(
|
||||
MediaToken.token.in_(tokens)).all()}
|
||||
records = {
|
||||
record.token: record
|
||||
for record in session.query(MediaToken)
|
||||
.filter(MediaToken.token.in_(tokens))
|
||||
.all()
|
||||
}
|
||||
|
||||
for token in tokens:
|
||||
if token in records:
|
||||
|
@ -97,13 +111,11 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
records[token] = record
|
||||
|
||||
session.commit()
|
||||
return session.query(MediaToken).filter(
|
||||
MediaToken.token.in_(tokens)).all()
|
||||
return session.query(MediaToken).filter(MediaToken.token.in_(tokens)).all()
|
||||
|
||||
@classmethod
|
||||
def _get_file_records(cls, dir_record, session):
|
||||
return session.query(MediaFile).filter_by(
|
||||
directory_id=dir_record.id).all()
|
||||
return session.query(MediaFile).filter_by(directory_id=dir_record.id).all()
|
||||
|
||||
def scan(self, media_dir, session=None, dir_record=None):
|
||||
"""
|
||||
|
@ -121,17 +133,19 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
dir_record = self._get_or_create_dir_entry(session, media_dir)
|
||||
|
||||
if not os.path.isdir(media_dir):
|
||||
self.logger.info('Directory {} is no longer accessible, removing it'.
|
||||
format(media_dir))
|
||||
session.query(MediaDirectory) \
|
||||
.filter(MediaDirectory.path == media_dir) \
|
||||
.delete(synchronize_session='fetch')
|
||||
self.logger.info(
|
||||
'Directory {} is no longer accessible, removing it'.format(media_dir)
|
||||
)
|
||||
session.query(MediaDirectory).filter(
|
||||
MediaDirectory.path == media_dir
|
||||
).delete(synchronize_session='fetch')
|
||||
return
|
||||
|
||||
stored_file_records = {
|
||||
f.path: f for f in self._get_file_records(dir_record, session)}
|
||||
f.path: f for f in self._get_file_records(dir_record, session)
|
||||
}
|
||||
|
||||
for path, dirs, files in os.walk(media_dir):
|
||||
for path, _, files in os.walk(media_dir):
|
||||
for filename in files:
|
||||
filepath = os.path.join(path, filename)
|
||||
|
||||
|
@ -142,26 +156,32 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
del stored_file_records[filepath]
|
||||
continue
|
||||
|
||||
if not MediaPlugin.is_video_file(filename) and \
|
||||
not MediaPlugin.is_audio_file(filename):
|
||||
if not MediaPlugin.is_video_file(
|
||||
filename
|
||||
) and not MediaPlugin.is_audio_file(filename):
|
||||
continue
|
||||
|
||||
self.logger.debug('Syncing item {}'.format(filepath))
|
||||
tokens = [_.lower() for _ in re.split(self._filename_separators,
|
||||
filename.strip())]
|
||||
tokens = [
|
||||
_.lower()
|
||||
for _ in re.split(self._filename_separators, filename.strip())
|
||||
]
|
||||
|
||||
token_records = self._sync_token_records(session, *tokens)
|
||||
file_record = MediaFile.build(directory_id=dir_record.id,
|
||||
path=filepath)
|
||||
file_record = MediaFile.build(directory_id=dir_record.id, path=filepath)
|
||||
|
||||
session.add(file_record)
|
||||
session.commit()
|
||||
file_record = session.query(MediaFile).filter_by(
|
||||
directory_id=dir_record.id, path=filepath).one()
|
||||
file_record = (
|
||||
session.query(MediaFile)
|
||||
.filter_by(directory_id=dir_record.id, path=filepath)
|
||||
.one()
|
||||
)
|
||||
|
||||
for token_record in token_records:
|
||||
file_token = MediaFileToken.build(file_id=file_record.id,
|
||||
token_id=token_record.id)
|
||||
file_token = MediaFileToken.build(
|
||||
file_id=file_record.id, token_id=token_record.id
|
||||
)
|
||||
session.add(file_token)
|
||||
|
||||
# stored_file_records should now only contain the records of the files
|
||||
|
@ -169,15 +189,20 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
if stored_file_records:
|
||||
self.logger.info(
|
||||
'Removing references to {} deleted media items from {}'.format(
|
||||
len(stored_file_records), media_dir))
|
||||
len(stored_file_records), media_dir
|
||||
)
|
||||
)
|
||||
|
||||
session.query(MediaFile).filter(MediaFile.id.in_(
|
||||
[record.id for record in stored_file_records.values()]
|
||||
)).delete(synchronize_session='fetch')
|
||||
session.query(MediaFile).filter(
|
||||
MediaFile.id.in_([record.id for record in stored_file_records.values()])
|
||||
).delete(synchronize_session='fetch')
|
||||
|
||||
dir_record.last_indexed_at = datetime.datetime.now()
|
||||
self.logger.info('Scanned {} in {} seconds'.format(
|
||||
media_dir, int(time.time() - index_start_time)))
|
||||
self.logger.info(
|
||||
'Scanned {} in {} seconds'.format(
|
||||
media_dir, int(time.time() - index_start_time)
|
||||
)
|
||||
)
|
||||
|
||||
session.commit()
|
||||
|
||||
|
@ -197,25 +222,30 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
dir_record = self._get_or_create_dir_entry(session, media_dir)
|
||||
|
||||
if self._has_directory_changed_since_last_indexing(dir_record):
|
||||
self.logger.info('{} has changed since last indexing, '.format(
|
||||
media_dir) + 're-indexing')
|
||||
self.logger.info(
|
||||
'{} has changed since last indexing, '.format(media_dir)
|
||||
+ 're-indexing'
|
||||
)
|
||||
|
||||
self.scan(media_dir, session=session, dir_record=dir_record)
|
||||
|
||||
query_tokens = [_.lower() for _ in re.split(
|
||||
self._filename_separators, query.strip())]
|
||||
query_tokens = [
|
||||
_.lower() for _ in re.split(self._filename_separators, query.strip())
|
||||
]
|
||||
|
||||
for file_record in session.query(MediaFile.path). \
|
||||
join(MediaFileToken). \
|
||||
join(MediaToken). \
|
||||
filter(MediaToken.token.in_(query_tokens)). \
|
||||
group_by(MediaFile.path). \
|
||||
having(func.count(MediaFileToken.token_id) >= len(query_tokens)):
|
||||
for file_record in (
|
||||
session.query(MediaFile.path)
|
||||
.join(MediaFileToken)
|
||||
.join(MediaToken)
|
||||
.filter(MediaToken.token.in_(query_tokens))
|
||||
.group_by(MediaFile.path)
|
||||
.having(func.count(MediaFileToken.token_id) >= len(query_tokens))
|
||||
):
|
||||
if os.path.isfile(file_record.path):
|
||||
results[file_record.path] = {
|
||||
'url': 'file://' + file_record.path,
|
||||
'title': os.path.basename(file_record.path),
|
||||
'size': os.path.getsize(file_record.path)
|
||||
'size': os.path.getsize(file_record.path),
|
||||
}
|
||||
|
||||
return results.values()
|
||||
|
@ -223,11 +253,12 @@ class LocalMediaSearcher(MediaSearcher):
|
|||
|
||||
# --- Table definitions
|
||||
|
||||
|
||||
class MediaDirectory(Base):
|
||||
""" Models the MediaDirectory table """
|
||||
"""Models the MediaDirectory table"""
|
||||
|
||||
__tablename__ = 'MediaDirectory'
|
||||
__table_args__ = ({'sqlite_autoincrement': True})
|
||||
__table_args__ = {'sqlite_autoincrement': True}
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
path = Column(String)
|
||||
|
@ -243,14 +274,15 @@ class MediaDirectory(Base):
|
|||
|
||||
|
||||
class MediaFile(Base):
|
||||
""" Models the MediaFile table """
|
||||
"""Models the MediaFile table"""
|
||||
|
||||
__tablename__ = 'MediaFile'
|
||||
__table_args__ = ({'sqlite_autoincrement': True})
|
||||
__table_args__ = {'sqlite_autoincrement': True}
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
directory_id = Column(Integer, ForeignKey(
|
||||
'MediaDirectory.id', ondelete='CASCADE'), nullable=False)
|
||||
directory_id = Column(
|
||||
Integer, ForeignKey('MediaDirectory.id', ondelete='CASCADE'), nullable=False
|
||||
)
|
||||
path = Column(String, nullable=False, unique=True)
|
||||
indexed_at = Column(DateTime)
|
||||
|
||||
|
@ -265,10 +297,10 @@ class MediaFile(Base):
|
|||
|
||||
|
||||
class MediaToken(Base):
|
||||
""" Models the MediaToken table """
|
||||
"""Models the MediaToken table"""
|
||||
|
||||
__tablename__ = 'MediaToken'
|
||||
__table_args__ = ({'sqlite_autoincrement': True})
|
||||
__table_args__ = {'sqlite_autoincrement': True}
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
token = Column(String, nullable=False, unique=True)
|
||||
|
@ -282,14 +314,16 @@ class MediaToken(Base):
|
|||
|
||||
|
||||
class MediaFileToken(Base):
|
||||
""" Models the MediaFileToken table """
|
||||
"""Models the MediaFileToken table"""
|
||||
|
||||
__tablename__ = 'MediaFileToken'
|
||||
|
||||
file_id = Column(Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'),
|
||||
nullable=False)
|
||||
token_id = Column(Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'),
|
||||
nullable=False)
|
||||
file_id = Column(
|
||||
Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'), nullable=False
|
||||
)
|
||||
token_id = Column(
|
||||
Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (PrimaryKeyConstraint(file_id, token_id), {})
|
||||
|
||||
|
@ -301,4 +335,5 @@ class MediaFileToken(Base):
|
|||
record.token_id = token_id
|
||||
return record
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -241,7 +241,7 @@ class NtfyPlugin(RunnablePlugin):
|
|||
args['headers'] = {
|
||||
'Filename': filename,
|
||||
**({'X-Title': title} if title else {}),
|
||||
**({'X-Click': click_url} if click_url else {}),
|
||||
**({'X-Click': url} if url else {}),
|
||||
**({'X-Email': email} if email else {}),
|
||||
**({'X-Priority': priority} if priority else {}),
|
||||
**({'X-Tags': ','.join(tags)} if tags else {}),
|
||||
|
@ -256,7 +256,7 @@ class NtfyPlugin(RunnablePlugin):
|
|||
'topic': topic,
|
||||
'message': message,
|
||||
**({'title': title} if title else {}),
|
||||
**({'click': click_url} if click_url else {}),
|
||||
**({'click': url} if url else {}),
|
||||
**({'email': email} if email else {}),
|
||||
**({'priority': priority} if priority else {}),
|
||||
**({'tags': tags} if tags else {}),
|
||||
|
|
|
@ -2,13 +2,17 @@ import asyncio
|
|||
import aiohttp
|
||||
|
||||
from threading import RLock
|
||||
from typing import Optional, Dict, List, Union
|
||||
from typing import Optional, Dict, List, Union, Iterable
|
||||
|
||||
from platypush.entities import manages
|
||||
from platypush.entities.lights import Light
|
||||
from platypush.entities.switches import Switch
|
||||
from platypush.plugins import action
|
||||
from platypush.plugins.switch import SwitchPlugin
|
||||
from platypush.plugins.switch import Plugin
|
||||
|
||||
|
||||
class SmartthingsPlugin(SwitchPlugin):
|
||||
@manages(Switch, Light)
|
||||
class SmartthingsPlugin(Plugin):
|
||||
"""
|
||||
Plugin to interact with devices and locations registered to a Samsung SmartThings account.
|
||||
|
||||
|
@ -18,7 +22,7 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
|
||||
"""
|
||||
|
||||
_timeout = aiohttp.ClientTimeout(total=20.)
|
||||
_timeout = aiohttp.ClientTimeout(total=20.0)
|
||||
|
||||
def __init__(self, access_token: str, **kwargs):
|
||||
"""
|
||||
|
@ -43,46 +47,27 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
|
||||
async def _refresh_locations(self, api):
|
||||
self._locations = await api.locations()
|
||||
|
||||
self._locations_by_id = {
|
||||
loc.location_id: loc
|
||||
for loc in self._locations
|
||||
}
|
||||
|
||||
self._locations_by_name = {
|
||||
loc.name: loc
|
||||
for loc in self._locations
|
||||
}
|
||||
self._locations_by_id = {loc.location_id: loc for loc in self._locations}
|
||||
self._locations_by_name = {loc.name: loc for loc in self._locations}
|
||||
|
||||
async def _refresh_devices(self, api):
|
||||
self._devices = await api.devices()
|
||||
|
||||
self._devices_by_id = {
|
||||
dev.device_id: dev
|
||||
for dev in self._devices
|
||||
}
|
||||
|
||||
self._devices_by_name = {
|
||||
dev.label: dev
|
||||
for dev in self._devices
|
||||
}
|
||||
self._devices_by_id = {dev.device_id: dev for dev in self._devices}
|
||||
self._devices_by_name = {dev.label: dev for dev in self._devices}
|
||||
|
||||
async def _refresh_rooms(self, api, location_id: str):
|
||||
self._rooms_by_location[location_id] = await api.rooms(location_id=location_id)
|
||||
|
||||
self._rooms_by_id.update(**{
|
||||
room.room_id: room
|
||||
for room in self._rooms_by_location[location_id]
|
||||
})
|
||||
self._rooms_by_id.update(
|
||||
**{room.room_id: room for room in self._rooms_by_location[location_id]}
|
||||
)
|
||||
|
||||
self._rooms_by_location_and_id[location_id] = {
|
||||
room.room_id: room
|
||||
for room in self._rooms_by_location[location_id]
|
||||
room.room_id: room for room in self._rooms_by_location[location_id]
|
||||
}
|
||||
|
||||
self._rooms_by_location_and_name[location_id] = {
|
||||
room.name: room
|
||||
for room in self._rooms_by_location[location_id]
|
||||
room.name: room for room in self._rooms_by_location[location_id]
|
||||
}
|
||||
|
||||
async def _refresh_info(self):
|
||||
|
@ -127,7 +112,7 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
'rooms': {
|
||||
room.room_id: self._room_to_dict(room)
|
||||
for room in self._rooms_by_location.get(location.location_id, {})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
@ -257,12 +242,18 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
"""
|
||||
self.refresh_info()
|
||||
return {
|
||||
'locations': {loc.location_id: self._location_to_dict(loc) for loc in self._locations},
|
||||
'devices': {dev.device_id: self._device_to_dict(dev) for dev in self._devices},
|
||||
'locations': {
|
||||
loc.location_id: self._location_to_dict(loc) for loc in self._locations
|
||||
},
|
||||
'devices': {
|
||||
dev.device_id: self._device_to_dict(dev) for dev in self._devices
|
||||
},
|
||||
}
|
||||
|
||||
@action
|
||||
def get_location(self, location_id: Optional[str] = None, name: Optional[str] = None) -> dict:
|
||||
def get_location(
|
||||
self, location_id: Optional[str] = None, name: Optional[str] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Get the info of a location by ID or name.
|
||||
|
||||
|
@ -296,20 +287,37 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
|
||||
"""
|
||||
assert location_id or name, 'Specify either location_id or name'
|
||||
if location_id not in self._locations_by_id or name not in self._locations_by_name:
|
||||
if (
|
||||
location_id not in self._locations_by_id
|
||||
or name not in self._locations_by_name
|
||||
):
|
||||
self.refresh_info()
|
||||
|
||||
location = self._locations_by_id.get(location_id, self._locations_by_name.get(name))
|
||||
location = self._locations_by_id.get(
|
||||
location_id, self._locations_by_name.get(name)
|
||||
)
|
||||
assert location, 'Location {} not found'.format(location_id or name)
|
||||
return self._location_to_dict(location)
|
||||
|
||||
def _get_device(self, device: str):
|
||||
if device not in self._devices_by_id or device not in self._devices_by_name:
|
||||
return self._get_devices(device)[0]
|
||||
|
||||
def _get_devices(self, *devices: str):
|
||||
def get_found_and_missing_devs():
|
||||
found_devs = [
|
||||
self._devices_by_id.get(d, self._devices_by_name.get(d))
|
||||
for d in devices
|
||||
]
|
||||
missing_devs = [d for i, d in enumerate(devices) if not found_devs[i]]
|
||||
return found_devs, missing_devs
|
||||
|
||||
devs, missing_devs = get_found_and_missing_devs()
|
||||
if missing_devs:
|
||||
self.refresh_info()
|
||||
|
||||
device = self._devices_by_id.get(device, self._devices_by_name.get(device))
|
||||
assert device, 'Device {} not found'.format(device)
|
||||
return device
|
||||
devs, missing_devs = get_found_and_missing_devs()
|
||||
assert not missing_devs, f'Devices not found: {missing_devs}'
|
||||
return devs
|
||||
|
||||
@action
|
||||
def get_device(self, device: str) -> dict:
|
||||
|
@ -340,24 +348,41 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
device = self._get_device(device)
|
||||
return self._device_to_dict(device)
|
||||
|
||||
async def _execute(self, device_id: str, capability: str, command, component_id: str, args: Optional[list]):
|
||||
async def _execute(
|
||||
self,
|
||||
device_id: str,
|
||||
capability: str,
|
||||
command,
|
||||
component_id: str,
|
||||
args: Optional[list],
|
||||
):
|
||||
import pysmartthings
|
||||
|
||||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
api = pysmartthings.SmartThings(session, self._access_token)
|
||||
device = await api.device(device_id)
|
||||
ret = await device.command(component_id=component_id, capability=capability, command=command, args=args)
|
||||
ret = await device.command(
|
||||
component_id=component_id,
|
||||
capability=capability,
|
||||
command=command,
|
||||
args=args,
|
||||
)
|
||||
|
||||
assert ret, 'The command {capability}={command} failed on device {device}'.format(
|
||||
capability=capability, command=command, device=device_id)
|
||||
assert (
|
||||
ret
|
||||
), 'The command {capability}={command} failed on device {device}'.format(
|
||||
capability=capability, command=command, device=device_id
|
||||
)
|
||||
|
||||
@action
|
||||
def execute(self,
|
||||
def execute(
|
||||
self,
|
||||
device: str,
|
||||
capability: str,
|
||||
command,
|
||||
component_id: str = 'main',
|
||||
args: Optional[list] = None):
|
||||
args: Optional[list] = None,
|
||||
):
|
||||
"""
|
||||
Execute a command on a device.
|
||||
|
||||
|
@ -388,16 +413,89 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(self._execute(
|
||||
device_id=device.device_id, capability=capability, command=command,
|
||||
component_id=component_id, args=args))
|
||||
loop.run_until_complete(
|
||||
self._execute(
|
||||
device_id=device.device_id,
|
||||
capability=capability,
|
||||
command=command,
|
||||
component_id=component_id,
|
||||
args=args,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
loop.stop()
|
||||
|
||||
@staticmethod
|
||||
async def _get_device_status(api, device_id: str) -> dict:
|
||||
def _is_light(device):
|
||||
if isinstance(device, dict):
|
||||
capabilities = device.get('capabilities', [])
|
||||
else:
|
||||
capabilities = device.capabilities
|
||||
|
||||
return 'colorControl' in capabilities or 'colorTemperature' in capabilities
|
||||
|
||||
def transform_entities(self, entities):
|
||||
from platypush.entities.switches import Switch
|
||||
|
||||
compatible_entities = []
|
||||
|
||||
for device in entities:
|
||||
data = {
|
||||
'location_id': getattr(device, 'location_id', None),
|
||||
'room_id': getattr(device, 'room_id', None),
|
||||
}
|
||||
|
||||
if self._is_light(device):
|
||||
light_attrs = {
|
||||
'id': device.device_id,
|
||||
'name': device.label,
|
||||
'data': data,
|
||||
}
|
||||
|
||||
if 'switch' in device.capabilities:
|
||||
light_attrs['on'] = device.status.switch
|
||||
if getattr(device.status, 'level', None) is not None:
|
||||
light_attrs['brightness'] = device.status.level
|
||||
light_attrs['brightness_min'] = 0
|
||||
light_attrs['brightness_max'] = 100
|
||||
if 'colorTemperature' in device.capabilities:
|
||||
# Color temperature range on SmartThings is expressed in Kelvin
|
||||
light_attrs['temperature_min'] = 2000
|
||||
light_attrs['temperature_max'] = 6500
|
||||
if (
|
||||
device.status.color_temperature
|
||||
>= light_attrs['temperature_min']
|
||||
):
|
||||
light_attrs['temperature'] = (
|
||||
light_attrs['temperature_max']
|
||||
- light_attrs['temperature_min']
|
||||
) / 2
|
||||
if getattr(device.status, 'hue', None) is not None:
|
||||
light_attrs['hue'] = device.status.hue
|
||||
light_attrs['hue_min'] = 0
|
||||
light_attrs['hue_max'] = 100
|
||||
if getattr(device.status, 'saturation', None) is not None:
|
||||
light_attrs['saturation'] = device.status.saturation
|
||||
light_attrs['saturation_min'] = 0
|
||||
light_attrs['saturation_max'] = 80
|
||||
|
||||
compatible_entities.append(Light(**light_attrs))
|
||||
elif 'switch' in device.capabilities:
|
||||
compatible_entities.append(
|
||||
Switch(
|
||||
id=device.device_id,
|
||||
name=device.label,
|
||||
state=device.status.switch,
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
|
||||
return super().transform_entities(compatible_entities) # type: ignore
|
||||
|
||||
async def _get_device_status(self, api, device_id: str) -> dict:
|
||||
device = await api.device(device_id)
|
||||
await device.status.refresh()
|
||||
self.publish_entities([device]) # type: ignore
|
||||
|
||||
return {
|
||||
'device_id': device_id,
|
||||
|
@ -407,7 +505,7 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
for cap in device.capabilities
|
||||
if hasattr(device.status, cap)
|
||||
and not callable(getattr(device.status, cap))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async def _refresh_status(self, devices: List[str]) -> List[dict]:
|
||||
|
@ -434,7 +532,9 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
parse_device_id(dev)
|
||||
|
||||
# Fail if some devices haven't been found after refreshing
|
||||
assert not missing_device_ids, 'Could not find the following devices: {}'.format(list(missing_device_ids))
|
||||
assert (
|
||||
not missing_device_ids
|
||||
), 'Could not find the following devices: {}'.format(list(missing_device_ids))
|
||||
|
||||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
api = pysmartthings.SmartThings(session, self._access_token)
|
||||
|
@ -489,7 +589,7 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
loop.stop()
|
||||
|
||||
@action
|
||||
def on(self, device: str, *args, **kwargs) -> dict:
|
||||
def on(self, device: str, *_, **__) -> dict:
|
||||
"""
|
||||
Turn on a device with ``switch`` capability.
|
||||
|
||||
|
@ -497,11 +597,10 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
:return: Device status
|
||||
"""
|
||||
self.execute(device, 'switch', 'on')
|
||||
# noinspection PyUnresolvedReferences
|
||||
return self.status(device).output[0]
|
||||
return self.status(device).output[0] # type: ignore
|
||||
|
||||
@action
|
||||
def off(self, device: str, *args, **kwargs) -> dict:
|
||||
def off(self, device: str, *_, **__) -> dict:
|
||||
"""
|
||||
Turn off a device with ``switch`` capability.
|
||||
|
||||
|
@ -509,11 +608,10 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
:return: Device status
|
||||
"""
|
||||
self.execute(device, 'switch', 'off')
|
||||
# noinspection PyUnresolvedReferences
|
||||
return self.status(device).output[0]
|
||||
return self.status(device).output[0] # type: ignore
|
||||
|
||||
@action
|
||||
def toggle(self, device: str, *args, **kwargs) -> dict:
|
||||
def toggle(self, device: str, *args, **__) -> dict:
|
||||
"""
|
||||
Toggle a device with ``switch`` capability.
|
||||
|
||||
|
@ -529,22 +627,28 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
api = pysmartthings.SmartThings(session, self._access_token)
|
||||
dev = await api.device(device_id)
|
||||
assert 'switch' in dev.capabilities, 'The device {} has no switch capability'.format(dev.label)
|
||||
assert (
|
||||
'switch' in dev.capabilities
|
||||
), 'The device {} has no switch capability'.format(dev.label)
|
||||
|
||||
await dev.status.refresh()
|
||||
state = 'off' if dev.status.switch else 'on'
|
||||
ret = await dev.command(component_id='main', capability='switch', command=state, args=args)
|
||||
ret = await dev.command(
|
||||
component_id='main', capability='switch', command=state, args=args
|
||||
)
|
||||
|
||||
assert ret, 'The command switch={state} failed on device {device}'.format(state=state, device=dev.label)
|
||||
return not dev.status.switch
|
||||
assert ret, 'The command switch={state} failed on device {device}'.format(
|
||||
state=state, device=dev.label
|
||||
)
|
||||
|
||||
with self._refresh_lock:
|
||||
loop = asyncio.new_event_loop()
|
||||
state = loop.run_until_complete(_toggle())
|
||||
loop.run_until_complete(_toggle())
|
||||
device = loop.run_until_complete(self._refresh_status([device_id]))[0] # type: ignore
|
||||
return {
|
||||
'id': device_id,
|
||||
'name': device.label,
|
||||
'on': state,
|
||||
'name': device['name'],
|
||||
'on': device['switch'],
|
||||
}
|
||||
|
||||
@property
|
||||
|
@ -569,8 +673,7 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
]
|
||||
|
||||
"""
|
||||
# noinspection PyUnresolvedReferences
|
||||
devices = self.status().output
|
||||
devices = self.status().output # type: ignore
|
||||
return [
|
||||
{
|
||||
'name': device['name'],
|
||||
|
@ -578,8 +681,65 @@ class SmartthingsPlugin(SwitchPlugin):
|
|||
'on': device['switch'],
|
||||
}
|
||||
for device in devices
|
||||
if 'switch' in device
|
||||
if 'switch' in device and not self._is_light(device)
|
||||
]
|
||||
|
||||
@action
|
||||
def set_level(self, device: str, level: int, **kwargs):
|
||||
"""
|
||||
Set the level of a device with ``switchLevel`` capabilities (e.g. the
|
||||
brightness of a lightbulb or the speed of a fan).
|
||||
|
||||
:param device: Device ID or name.
|
||||
:param level: Level, usually a percentage value between 0 and 1.
|
||||
:param kwarsg: Extra arguments that should be passed to :meth:`.execute`.
|
||||
"""
|
||||
self.execute(device, 'switchLevel', 'setLevel', args=[int(level)], **kwargs)
|
||||
|
||||
@action
|
||||
def set_lights(
|
||||
self,
|
||||
lights: Iterable[str],
|
||||
on: Optional[bool] = None,
|
||||
brightness: Optional[int] = None,
|
||||
hue: Optional[int] = None,
|
||||
saturation: Optional[int] = None,
|
||||
hex: Optional[str] = None,
|
||||
temperature: Optional[int] = None,
|
||||
**_,
|
||||
):
|
||||
err = None
|
||||
|
||||
with self._execute_lock:
|
||||
for light in lights:
|
||||
try:
|
||||
if on is not None:
|
||||
self.execute(light, 'switch', 'on' if on else 'off')
|
||||
if brightness is not None:
|
||||
self.execute(
|
||||
light, 'switchLevel', 'setLevel', args=[brightness]
|
||||
)
|
||||
if hue is not None:
|
||||
self.execute(light, 'colorControl', 'setHue', args=[hue])
|
||||
if saturation is not None:
|
||||
self.execute(
|
||||
light, 'colorControl', 'setSaturation', args=[saturation]
|
||||
)
|
||||
if temperature is not None:
|
||||
self.execute(
|
||||
light,
|
||||
'colorTemperature',
|
||||
'setColorTemperature',
|
||||
args=[temperature],
|
||||
)
|
||||
if hex is not None:
|
||||
self.execute(light, 'colorControl', 'setColor', args=[hex])
|
||||
except Exception as e:
|
||||
self.logger.error('Could not set attributes on %s: %s', light, e)
|
||||
err = e
|
||||
|
||||
if err:
|
||||
raise err
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from typing import List, Union
|
||||
|
||||
from platypush.entities import manages
|
||||
from platypush.entities.switches import Switch
|
||||
from platypush.plugins import Plugin, action
|
||||
|
||||
|
||||
@manages(Switch)
|
||||
class SwitchPlugin(Plugin, ABC):
|
||||
"""
|
||||
Abstract class for interacting with switch devices
|
||||
|
@ -46,7 +49,7 @@ class SwitchPlugin(Plugin, ABC):
|
|||
return devices
|
||||
|
||||
@action
|
||||
def status(self, device=None, *args, **kwargs) -> Union[dict, List[dict]]:
|
||||
def status(self, device=None, *_, **__) -> Union[dict, List[dict]]:
|
||||
"""
|
||||
Get the status of all the devices, or filter by device name or ID (alias for :meth:`.switch_status`).
|
||||
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
from typing import Union, Dict, List
|
||||
from typing import Union, Mapping, List, Collection, Optional
|
||||
|
||||
from pyHS100 import SmartDevice, SmartPlug, SmartBulb, SmartStrip, Discover, SmartDeviceException
|
||||
from pyHS100 import (
|
||||
SmartDevice,
|
||||
SmartPlug,
|
||||
SmartBulb,
|
||||
SmartStrip,
|
||||
Discover,
|
||||
SmartDeviceException,
|
||||
)
|
||||
|
||||
from platypush.entities import Entity
|
||||
from platypush.plugins import action
|
||||
from platypush.plugins.switch import SwitchPlugin
|
||||
|
||||
|
@ -20,8 +28,13 @@ class SwitchTplinkPlugin(SwitchPlugin):
|
|||
_ip_to_dev = {}
|
||||
_alias_to_dev = {}
|
||||
|
||||
def __init__(self, plugs: Union[Dict[str, str], List[str]] = None, bulbs: Union[Dict[str, str], List[str]] = None,
|
||||
strips: Union[Dict[str, str], List[str]] = None, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
plugs: Optional[Union[Mapping[str, str], List[str]]] = None,
|
||||
bulbs: Optional[Union[Mapping[str, str], List[str]]] = None,
|
||||
strips: Optional[Union[Mapping[str, str], List[str]]] = None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
:param plugs: Optional list of IP addresses or name->address mapping if you have a static list of
|
||||
TpLink plugs and you want to save on the scan time.
|
||||
|
@ -62,19 +75,44 @@ class SwitchTplinkPlugin(SwitchPlugin):
|
|||
|
||||
self._update_devices()
|
||||
|
||||
def _update_devices(self, devices: Dict[str, SmartDevice] = None):
|
||||
def _update_devices(self, devices: Optional[Mapping[str, SmartDevice]] = None):
|
||||
for (addr, info) in self._static_devices.items():
|
||||
try:
|
||||
dev = info['type'](addr)
|
||||
self._alias_to_dev[info.get('name', dev.alias)] = dev
|
||||
self._ip_to_dev[addr] = dev
|
||||
except SmartDeviceException as e:
|
||||
self.logger.warning('Could not communicate with device {}: {}'.format(addr, str(e)))
|
||||
self.logger.warning(
|
||||
'Could not communicate with device {}: {}'.format(addr, str(e))
|
||||
)
|
||||
|
||||
for (ip, dev) in (devices or {}).items():
|
||||
self._ip_to_dev[ip] = dev
|
||||
self._alias_to_dev[dev.alias] = dev
|
||||
|
||||
if devices:
|
||||
self.publish_entities(devices.values()) # type: ignore
|
||||
|
||||
def transform_entities(self, devices: Collection[SmartDevice]):
|
||||
from platypush.entities.switches import Switch
|
||||
|
||||
return super().transform_entities( # type: ignore
|
||||
[
|
||||
Switch(
|
||||
id=dev.host,
|
||||
name=dev.alias,
|
||||
state=dev.is_on,
|
||||
data={
|
||||
'current_consumption': dev.current_consumption(),
|
||||
'ip': dev.host,
|
||||
'host': dev.host,
|
||||
'hw_info': dev.hw_info,
|
||||
},
|
||||
)
|
||||
for dev in (devices or [])
|
||||
]
|
||||
)
|
||||
|
||||
def _scan(self):
|
||||
devices = Discover.discover()
|
||||
self._update_devices(devices)
|
||||
|
@ -84,6 +122,9 @@ class SwitchTplinkPlugin(SwitchPlugin):
|
|||
if not use_cache:
|
||||
self._scan()
|
||||
|
||||
if isinstance(device, Entity):
|
||||
device = device.external_id or device.name
|
||||
|
||||
if device in self._ip_to_dev:
|
||||
return self._ip_to_dev[device]
|
||||
|
||||
|
@ -95,8 +136,15 @@ class SwitchTplinkPlugin(SwitchPlugin):
|
|||
else:
|
||||
raise RuntimeError('Device {} not found'.format(device))
|
||||
|
||||
def _set(self, device: SmartDevice, state: bool):
|
||||
action_name = 'turn_on' if state else 'turn_off'
|
||||
action = getattr(device, action_name)
|
||||
action()
|
||||
self.publish_entities([device]) # type: ignore
|
||||
return self._serialize(device)
|
||||
|
||||
@action
|
||||
def on(self, device, **kwargs):
|
||||
def on(self, device, **_):
|
||||
"""
|
||||
Turn on a device
|
||||
|
||||
|
@ -105,11 +153,10 @@ class SwitchTplinkPlugin(SwitchPlugin):
|
|||
"""
|
||||
|
||||
device = self._get_device(device)
|
||||
device.turn_on()
|
||||
return self.status(device)
|
||||
return self._set(device, True)
|
||||
|
||||
@action
|
||||
def off(self, device, **kwargs):
|
||||
def off(self, device, **_):
|
||||
"""
|
||||
Turn off a device
|
||||
|
||||
|
@ -118,11 +165,10 @@ class SwitchTplinkPlugin(SwitchPlugin):
|
|||
"""
|
||||
|
||||
device = self._get_device(device)
|
||||
device.turn_off()
|
||||
return self.status(device)
|
||||
return self._set(device, False)
|
||||
|
||||
@action
|
||||
def toggle(self, device, **kwargs):
|
||||
def toggle(self, device, **_):
|
||||
"""
|
||||
Toggle the state of a device (on/off)
|
||||
|
||||
|
@ -131,12 +177,10 @@ class SwitchTplinkPlugin(SwitchPlugin):
|
|||
"""
|
||||
|
||||
device = self._get_device(device)
|
||||
return self._set(device, not device.is_on)
|
||||
|
||||
if device.is_on:
|
||||
device.turn_off()
|
||||
else:
|
||||
device.turn_on()
|
||||
|
||||
@staticmethod
|
||||
def _serialize(device: SmartDevice) -> dict:
|
||||
return {
|
||||
'current_consumption': device.current_consumption(),
|
||||
'id': device.host,
|
||||
|
@ -149,17 +193,7 @@ class SwitchTplinkPlugin(SwitchPlugin):
|
|||
|
||||
@property
|
||||
def switches(self) -> List[dict]:
|
||||
return [
|
||||
{
|
||||
'current_consumption': dev.current_consumption(),
|
||||
'id': ip,
|
||||
'ip': ip,
|
||||
'host': dev.host,
|
||||
'hw_info': dev.hw_info,
|
||||
'name': dev.alias,
|
||||
'on': dev.is_on,
|
||||
} for (ip, dev) in self._scan().items()
|
||||
]
|
||||
return [self._serialize(dev) for dev in self._scan().values()]
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import contextlib
|
||||
import ipaddress
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from platypush.plugins import action
|
||||
from platypush.plugins.switch import SwitchPlugin
|
||||
from platypush.utils.workers import Workers
|
||||
|
||||
from .lib import WemoRunner
|
||||
from .scanner import Scanner
|
||||
|
||||
|
@ -16,7 +18,13 @@ class SwitchWemoPlugin(SwitchPlugin):
|
|||
|
||||
_default_port = 49153
|
||||
|
||||
def __init__(self, devices=None, netmask: str = None, port: int = _default_port, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
devices=None,
|
||||
netmask: Optional[str] = None,
|
||||
port: int = _default_port,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
:param devices: List of IP addresses or name->address map containing the WeMo Switch devices to control.
|
||||
This plugin previously used ouimeaux for auto-discovery but it's been dropped because
|
||||
|
@ -37,8 +45,11 @@ class SwitchWemoPlugin(SwitchPlugin):
|
|||
|
||||
def _init_devices(self, devices):
|
||||
if devices:
|
||||
self._devices.update(devices if isinstance(devices, dict) else
|
||||
{addr: addr for addr in devices})
|
||||
self._devices.update(
|
||||
devices
|
||||
if isinstance(devices, dict)
|
||||
else {addr: addr for addr in devices}
|
||||
)
|
||||
else:
|
||||
self._devices = {}
|
||||
|
||||
|
@ -68,37 +79,53 @@ class SwitchWemoPlugin(SwitchPlugin):
|
|||
"""
|
||||
|
||||
return [
|
||||
self.status(device).output
|
||||
self.status(device).output # type: ignore
|
||||
for device in self._devices.values()
|
||||
]
|
||||
|
||||
def _get_address(self, device: str) -> str:
|
||||
if device not in self._addresses:
|
||||
try:
|
||||
with contextlib.suppress(KeyError):
|
||||
return self._devices[device]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return device
|
||||
|
||||
@action
|
||||
def status(self, device: str = None, *args, **kwargs):
|
||||
def status(self, device: Optional[str] = None, *_, **__):
|
||||
devices = {device: device} if device else self._devices.copy()
|
||||
|
||||
ret = [
|
||||
{
|
||||
'id': addr,
|
||||
'ip': addr,
|
||||
'name': name if name != addr else WemoRunner.get_name(addr),
|
||||
'on': WemoRunner.get_state(addr),
|
||||
"id": addr,
|
||||
"ip": addr,
|
||||
"name": name if name != addr else WemoRunner.get_name(addr),
|
||||
"on": WemoRunner.get_state(addr),
|
||||
}
|
||||
for (name, addr) in devices.items()
|
||||
]
|
||||
|
||||
self.publish_entities(ret) # type: ignore
|
||||
return ret[0] if device else ret
|
||||
|
||||
def transform_entities(self, devices: List[dict]):
|
||||
from platypush.entities.switches import Switch
|
||||
|
||||
return super().transform_entities( # type: ignore
|
||||
[
|
||||
Switch(
|
||||
id=dev["id"],
|
||||
name=dev["name"],
|
||||
state=dev["on"],
|
||||
data={
|
||||
"ip": dev["ip"],
|
||||
},
|
||||
)
|
||||
for dev in (devices or [])
|
||||
]
|
||||
)
|
||||
|
||||
@action
|
||||
def on(self, device: str, **kwargs):
|
||||
def on(self, device: str, **_):
|
||||
"""
|
||||
Turn a switch on
|
||||
|
||||
|
@ -109,7 +136,7 @@ class SwitchWemoPlugin(SwitchPlugin):
|
|||
return self.status(device)
|
||||
|
||||
@action
|
||||
def off(self, device: str, **kwargs):
|
||||
def off(self, device: str, **_):
|
||||
"""
|
||||
Turn a switch off
|
||||
|
||||
|
@ -120,7 +147,7 @@ class SwitchWemoPlugin(SwitchPlugin):
|
|||
return self.status(device)
|
||||
|
||||
@action
|
||||
def toggle(self, device: str, *args, **kwargs):
|
||||
def toggle(self, device: str, *_, **__):
|
||||
"""
|
||||
Toggle a device on/off state
|
||||
|
||||
|
@ -151,19 +178,16 @@ class SwitchWemoPlugin(SwitchPlugin):
|
|||
return WemoRunner.get_name(device)
|
||||
|
||||
@action
|
||||
def scan(self, netmask: str = None):
|
||||
def scan(self, netmask: Optional[str] = None):
|
||||
netmask = netmask or self.netmask
|
||||
assert netmask, 'Scan not supported: No netmask specified'
|
||||
assert netmask, "Scan not supported: No netmask specified"
|
||||
|
||||
workers = Workers(10, Scanner, port=self.port)
|
||||
with workers:
|
||||
for addr in ipaddress.IPv4Network(netmask):
|
||||
workers.put(addr.exploded)
|
||||
|
||||
devices = {
|
||||
dev.name: dev.addr
|
||||
for dev in workers.responses
|
||||
}
|
||||
devices = {dev.name: dev.addr for dev in workers.responses}
|
||||
|
||||
self._init_devices(devices)
|
||||
return self.status()
|
||||
|
|
|
@ -43,16 +43,21 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
return url
|
||||
|
||||
def _run(self, method: str = 'get', *args, device=None, **kwargs):
|
||||
response = getattr(requests, method)(self._url_for(*args, device=device), headers={
|
||||
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)
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
response = response.json()
|
||||
assert response.get('statusCode') == 100, \
|
||||
f'Switchbot API request failed: {response.get("statusCode")}: {response.get("message")}'
|
||||
assert (
|
||||
response.get('statusCode') == 100
|
||||
), f'Switchbot API request failed: {response.get("statusCode")}: {response.get("message")}'
|
||||
|
||||
return response.get('body')
|
||||
|
||||
|
@ -77,16 +82,20 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
"""
|
||||
devices = self._run('get', 'devices')
|
||||
devices = [
|
||||
DeviceSchema().dump({
|
||||
DeviceSchema().dump(
|
||||
{
|
||||
**device,
|
||||
'is_virtual': False,
|
||||
})
|
||||
}
|
||||
)
|
||||
for device in devices.get('deviceList', [])
|
||||
] + [
|
||||
DeviceSchema().dump({
|
||||
DeviceSchema().dump(
|
||||
{
|
||||
**device,
|
||||
'is_virtual': True,
|
||||
})
|
||||
}
|
||||
)
|
||||
for device in devices.get('infraredRemoteList', [])
|
||||
]
|
||||
|
||||
|
@ -96,10 +105,44 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
|
||||
return devices
|
||||
|
||||
def _worker(self, q: queue.Queue, method: str = 'get', *args, device: Optional[dict] = None, **kwargs):
|
||||
def transform_entities(self, devices: List[dict]):
|
||||
from platypush.entities.switches import Switch
|
||||
|
||||
return super().transform_entities( # type: ignore
|
||||
[
|
||||
Switch(
|
||||
id=dev["id"],
|
||||
name=dev["name"],
|
||||
state=dev.get("on"),
|
||||
is_write_only=True,
|
||||
data={
|
||||
"device_type": dev.get("device_type"),
|
||||
"is_virtual": dev.get("is_virtual", False),
|
||||
"hub_id": dev.get("hub_id"),
|
||||
},
|
||||
)
|
||||
for dev in (devices or [])
|
||||
if dev.get('device_type') == 'Bot'
|
||||
]
|
||||
)
|
||||
|
||||
def _worker(
|
||||
self,
|
||||
q: queue.Queue,
|
||||
method: str = 'get',
|
||||
*args,
|
||||
device: Optional[dict] = None,
|
||||
**kwargs,
|
||||
):
|
||||
schema = DeviceStatusSchema()
|
||||
try:
|
||||
if method == 'get' and args and args[0] == 'status' and device and device.get('is_virtual'):
|
||||
if (
|
||||
method == 'get'
|
||||
and args
|
||||
and args[0] == 'status'
|
||||
and device
|
||||
and device.get('is_virtual')
|
||||
):
|
||||
res = schema.load(device)
|
||||
else:
|
||||
res = self._run(method, *args, device=device, **kwargs)
|
||||
|
@ -121,7 +164,11 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
devices = self.devices().output
|
||||
if device:
|
||||
device_info = self._get_device(device)
|
||||
status = {} if device_info['is_virtual'] else self._run('get', 'status', device=device_info)
|
||||
status = (
|
||||
{}
|
||||
if device_info['is_virtual']
|
||||
else self._run('get', 'status', device=device_info)
|
||||
)
|
||||
return {
|
||||
**device_info,
|
||||
**status,
|
||||
|
@ -133,7 +180,7 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
threading.Thread(
|
||||
target=self._worker,
|
||||
args=(queues[i], 'get', 'status'),
|
||||
kwargs={'device': dev}
|
||||
kwargs={'device': dev},
|
||||
)
|
||||
for i, dev in enumerate(devices)
|
||||
]
|
||||
|
@ -148,14 +195,17 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
continue
|
||||
|
||||
assert not isinstance(response, Exception), str(response)
|
||||
results.append({
|
||||
results.append(
|
||||
{
|
||||
**devices_by_id.get(response.get('id'), {}),
|
||||
**response,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
for worker in workers:
|
||||
worker.join()
|
||||
|
||||
self.publish_entities(results) # type: ignore
|
||||
return results
|
||||
|
||||
@action
|
||||
|
@ -200,9 +250,7 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
@property
|
||||
def switches(self) -> List[dict]:
|
||||
# noinspection PyUnresolvedReferences
|
||||
return [
|
||||
dev for dev in self.status().output if 'on' in dev
|
||||
]
|
||||
return [dev for dev in self.status().output if 'on' in dev]
|
||||
|
||||
@action
|
||||
def set_curtain_position(self, device: str, position: int):
|
||||
|
@ -213,11 +261,16 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param position: An integer between 0 (open) and 100 (closed).
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'setPosition',
|
||||
'commandType': 'command',
|
||||
'parameter': f'0,ff,{position}',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def set_humidifier_efficiency(self, device: str, efficiency: Union[int, str]):
|
||||
|
@ -228,11 +281,16 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param efficiency: An integer between 0 (open) and 100 (closed) or `auto`.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'setMode',
|
||||
'commandType': 'command',
|
||||
'parameter': efficiency,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def set_fan_speed(self, device: str, speed: int):
|
||||
|
@ -246,11 +304,16 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
status = self.status(device=device).output
|
||||
mode = status.get('mode')
|
||||
swing_range = status.get('swing_range')
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'set',
|
||||
'commandType': 'command',
|
||||
'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def set_fan_mode(self, device: str, mode: int):
|
||||
|
@ -264,11 +327,16 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
status = self.status(device=device).output
|
||||
speed = status.get('speed')
|
||||
swing_range = status.get('swing_range')
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'set',
|
||||
'commandType': 'command',
|
||||
'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def set_swing_range(self, device: str, swing_range: int):
|
||||
|
@ -282,11 +350,16 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
status = self.status(device=device).output
|
||||
speed = status.get('speed')
|
||||
mode = status.get('mode')
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'set',
|
||||
'commandType': 'command',
|
||||
'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def set_temperature(self, device: str, temperature: float):
|
||||
|
@ -300,11 +373,18 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
status = self.status(device=device).output
|
||||
mode = status.get('mode')
|
||||
fan_speed = status.get('fan_speed')
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'setAll',
|
||||
'commandType': 'command',
|
||||
'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']),
|
||||
})
|
||||
'parameter': ','.join(
|
||||
[str(temperature), str(mode), str(fan_speed), 'on']
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def set_ac_mode(self, device: str, mode: int):
|
||||
|
@ -325,11 +405,18 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
status = self.status(device=device).output
|
||||
temperature = status.get('temperature')
|
||||
fan_speed = status.get('fan_speed')
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'setAll',
|
||||
'commandType': 'command',
|
||||
'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']),
|
||||
})
|
||||
'parameter': ','.join(
|
||||
[str(temperature), str(mode), str(fan_speed), 'on']
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def set_ac_fan_speed(self, device: str, fan_speed: int):
|
||||
|
@ -349,11 +436,18 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
status = self.status(device=device).output
|
||||
temperature = status.get('temperature')
|
||||
mode = status.get('mode')
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'setAll',
|
||||
'commandType': 'command',
|
||||
'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']),
|
||||
})
|
||||
'parameter': ','.join(
|
||||
[str(temperature), str(mode), str(fan_speed), 'on']
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def set_channel(self, device: str, channel: int):
|
||||
|
@ -364,11 +458,16 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param channel: Channel number.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'SetChannel',
|
||||
'commandType': 'command',
|
||||
'parameter': [str(channel)],
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def volup(self, device: str):
|
||||
|
@ -378,10 +477,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'volumeAdd',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def voldown(self, device: str):
|
||||
|
@ -391,10 +495,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'volumeSub',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def mute(self, device: str):
|
||||
|
@ -404,10 +513,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'setMute',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def channel_next(self, device: str):
|
||||
|
@ -417,10 +531,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'channelAdd',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def channel_prev(self, device: str):
|
||||
|
@ -430,10 +549,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'channelSub',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def play(self, device: str):
|
||||
|
@ -443,10 +567,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'Play',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def pause(self, device: str):
|
||||
|
@ -456,10 +585,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'Pause',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def stop(self, device: str):
|
||||
|
@ -469,10 +603,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'Stop',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def forward(self, device: str):
|
||||
|
@ -482,10 +621,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'FastForward',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def back(self, device: str):
|
||||
|
@ -495,10 +639,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'Rewind',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def next(self, device: str):
|
||||
|
@ -508,10 +657,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'Next',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def previous(self, device: str):
|
||||
|
@ -521,10 +675,15 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
:param device: Device name or ID.
|
||||
"""
|
||||
device = self._get_device(device)
|
||||
return self._run('post', 'commands', device=device, json={
|
||||
return self._run(
|
||||
'post',
|
||||
'commands',
|
||||
device=device,
|
||||
json={
|
||||
'command': 'Previous',
|
||||
'commandType': 'command',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@action
|
||||
def scenes(self) -> List[dict]:
|
||||
|
@ -544,7 +703,8 @@ class SwitchbotPlugin(SwitchPlugin):
|
|||
"""
|
||||
# noinspection PyUnresolvedReferences
|
||||
scenes = [
|
||||
s for s in self.scenes().output
|
||||
s
|
||||
for s in self.scenes().output
|
||||
if s.get('id') == scene or s.get('name') == scene
|
||||
]
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import enum
|
||||
import time
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from platypush.message.response.bluetooth import BluetoothScanResponse
|
||||
from platypush.plugins import action
|
||||
|
@ -8,7 +8,9 @@ from platypush.plugins.bluetooth.ble import BluetoothBlePlugin
|
|||
from platypush.plugins.switch import SwitchPlugin
|
||||
|
||||
|
||||
class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/missing-call-to-init]
|
||||
class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init]
|
||||
SwitchPlugin, BluetoothBlePlugin
|
||||
):
|
||||
"""
|
||||
Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and
|
||||
programmatically control switches over a Bluetooth interface.
|
||||
|
@ -31,6 +33,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m
|
|||
"""
|
||||
Base64 encoded commands
|
||||
"""
|
||||
|
||||
# \x57\x01\x00
|
||||
PRESS = 'VwEA'
|
||||
# # \x57\x01\x01
|
||||
|
@ -38,8 +41,14 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m
|
|||
# # \x57\x01\x02
|
||||
OFF = 'VwEC'
|
||||
|
||||
def __init__(self, interface=None, connect_timeout=None,
|
||||
scan_timeout=2, devices=None, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
interface=None,
|
||||
connect_timeout=None,
|
||||
scan_timeout=2,
|
||||
devices=None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
:param interface: Bluetooth interface to use (e.g. hci0) default: first available one
|
||||
:type interface: str
|
||||
|
@ -59,17 +68,21 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m
|
|||
self.scan_timeout = scan_timeout if scan_timeout else 2
|
||||
self.configured_devices = devices or {}
|
||||
self.configured_devices_by_name = {
|
||||
name: addr
|
||||
for addr, name in self.configured_devices.items()
|
||||
name: addr for addr, name in self.configured_devices.items()
|
||||
}
|
||||
|
||||
def _run(self, device: str, command: Command):
|
||||
if device in self.configured_devices_by_name:
|
||||
device = self.configured_devices_by_name[device]
|
||||
device = self.configured_devices_by_name.get(device, '')
|
||||
n_tries = 1
|
||||
|
||||
try:
|
||||
self.write(device, command.value, handle=self.handle, channel_type='random', binary=True)
|
||||
self.write(
|
||||
device,
|
||||
command.value,
|
||||
handle=self.handle,
|
||||
channel_type='random',
|
||||
binary=True,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(e)
|
||||
n_tries -= 1
|
||||
|
@ -78,7 +91,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m
|
|||
raise e
|
||||
time.sleep(5)
|
||||
|
||||
return self.status(device)
|
||||
return self.status(device) # type: ignore
|
||||
|
||||
@action
|
||||
def press(self, device):
|
||||
|
@ -91,11 +104,11 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m
|
|||
return self._run(device, self.Command.PRESS)
|
||||
|
||||
@action
|
||||
def toggle(self, device, **kwargs):
|
||||
def toggle(self, device, **_):
|
||||
return self.press(device)
|
||||
|
||||
@action
|
||||
def on(self, device, **kwargs):
|
||||
def on(self, device, **_):
|
||||
"""
|
||||
Send a press-on button command to a device
|
||||
|
||||
|
@ -105,7 +118,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m
|
|||
return self._run(device, self.Command.ON)
|
||||
|
||||
@action
|
||||
def off(self, device, **kwargs):
|
||||
def off(self, device, **_):
|
||||
"""
|
||||
Send a press-off button command to a device
|
||||
|
||||
|
@ -115,7 +128,9 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m
|
|||
return self._run(device, self.Command.OFF)
|
||||
|
||||
@action
|
||||
def scan(self, interface: str = None, duration: int = 10) -> BluetoothScanResponse:
|
||||
def scan(
|
||||
self, interface: Optional[str] = None, duration: int = 10
|
||||
) -> BluetoothScanResponse:
|
||||
"""
|
||||
Scan for available Switchbot devices nearby.
|
||||
|
||||
|
@ -129,9 +144,13 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m
|
|||
for dev in devices:
|
||||
try:
|
||||
characteristics = [
|
||||
chrc for chrc in self.discover_characteristics(
|
||||
dev['addr'], channel_type='random', wait=False,
|
||||
timeout=self.scan_timeout).characteristics
|
||||
chrc
|
||||
for chrc in self.discover_characteristics(
|
||||
dev['addr'],
|
||||
channel_type='random',
|
||||
wait=False,
|
||||
timeout=self.scan_timeout,
|
||||
).characteristics
|
||||
if chrc.get('uuid') == self.uuid
|
||||
]
|
||||
|
||||
|
@ -140,10 +159,12 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m
|
|||
except Exception as e:
|
||||
self.logger.warning('Device scan error', e)
|
||||
|
||||
self.publish_entities(compatible_devices) # type: ignore
|
||||
return BluetoothScanResponse(devices=compatible_devices)
|
||||
|
||||
@property
|
||||
def switches(self) -> List[dict]:
|
||||
self.publish_entities(self.configured_devices) # type: ignore
|
||||
return [
|
||||
{
|
||||
'address': addr,
|
||||
|
@ -154,5 +175,20 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m
|
|||
for addr, name in self.configured_devices.items()
|
||||
]
|
||||
|
||||
def transform_entities(self, devices: dict):
|
||||
from platypush.entities.switches import Switch
|
||||
|
||||
return super().transform_entities( # type: ignore
|
||||
[
|
||||
Switch(
|
||||
id=addr,
|
||||
name=name,
|
||||
state=False,
|
||||
is_write_only=True,
|
||||
)
|
||||
for addr, name in devices.items()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -4,12 +4,16 @@ import threading
|
|||
from queue import Queue
|
||||
from typing import Optional, List, Any, Dict, Union
|
||||
|
||||
from platypush.entities import manages
|
||||
from platypush.entities.lights import Light
|
||||
from platypush.entities.switches import Switch
|
||||
from platypush.message import Mapping
|
||||
from platypush.message.response import Response
|
||||
from platypush.plugins.mqtt import MqttPlugin, action
|
||||
from platypush.plugins.switch import SwitchPlugin
|
||||
|
||||
|
||||
class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-init]
|
||||
@manages(Light, Switch)
|
||||
class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
|
||||
"""
|
||||
This plugin allows you to interact with Zigbee devices over MQTT through any Zigbee sniffer and
|
||||
`zigbee2mqtt <https://www.zigbee2mqtt.io/>`_.
|
||||
|
@ -35,7 +39,8 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
|
||||
.. code-block:: shell
|
||||
|
||||
wget https://github.com/Koenkk/Z-Stack-firmware/raw/master/coordinator/Z-Stack_Home_1.2/bin/default/CC2531_DEFAULT_20201127.zip
|
||||
wget https://github.com/Koenkk/Z-Stack-firmware/raw/master\
|
||||
/coordinator/Z-Stack_Home_1.2/bin/default/CC2531_DEFAULT_20201127.zip
|
||||
unzip CC2531_DEFAULT_20201127.zip
|
||||
[sudo] cc-tool -e -w CC2531ZNP-Prod.hex
|
||||
|
||||
|
@ -78,7 +83,8 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
configured your network, to prevent accidental/malignant joins from outer Zigbee devices.
|
||||
|
||||
- Start the ``zigbee2mqtt`` daemon on your device (the
|
||||
`official documentation <https://www.zigbee2mqtt.io/getting_started/running_zigbee2mqtt.html#5-optional-running-as-a-daemon-with-systemctl>`_
|
||||
`official documentation <https://www.zigbee2mqtt.io/getting_started
|
||||
/running_zigbee2mqtt.html#5-optional-running-as-a-daemon-with-systemctl>`_
|
||||
also contains instructions on how to configure it as a ``systemd`` service:
|
||||
|
||||
.. code-block:: shell
|
||||
|
@ -103,10 +109,20 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = 'localhost', port: int = 1883, base_topic: str = 'zigbee2mqtt', timeout: int = 10,
|
||||
tls_certfile: Optional[str] = None, tls_keyfile: Optional[str] = None,
|
||||
tls_version: Optional[str] = None, tls_ciphers: Optional[str] = None,
|
||||
username: Optional[str] = None, password: Optional[str] = None, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
host: str = 'localhost',
|
||||
port: int = 1883,
|
||||
base_topic: str = 'zigbee2mqtt',
|
||||
timeout: int = 10,
|
||||
tls_certfile: Optional[str] = None,
|
||||
tls_keyfile: Optional[str] = None,
|
||||
tls_version: Optional[str] = None,
|
||||
tls_ciphers: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
:param host: Default MQTT broker where ``zigbee2mqtt`` publishes its messages (default: ``localhost``).
|
||||
:param port: Broker listen port (default: 1883).
|
||||
|
@ -124,17 +140,80 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
:param username: If the connection requires user authentication, specify the username (default: None)
|
||||
:param password: If the connection requires user authentication, specify the password (default: None)
|
||||
"""
|
||||
super().__init__(host=host, port=port, tls_certfile=tls_certfile, tls_keyfile=tls_keyfile,
|
||||
tls_version=tls_version, tls_ciphers=tls_ciphers, username=username,
|
||||
password=password, **kwargs)
|
||||
super().__init__(
|
||||
host=host,
|
||||
port=port,
|
||||
tls_certfile=tls_certfile,
|
||||
tls_keyfile=tls_keyfile,
|
||||
tls_version=tls_version,
|
||||
tls_ciphers=tls_ciphers,
|
||||
username=username,
|
||||
password=password,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.base_topic = base_topic
|
||||
self.timeout = timeout
|
||||
self._info = {
|
||||
'devices': {},
|
||||
'groups': {},
|
||||
'devices_by_addr': {},
|
||||
}
|
||||
|
||||
def transform_entities(self, devices):
|
||||
from platypush.entities.switches import Switch
|
||||
|
||||
compatible_entities = []
|
||||
for dev in devices:
|
||||
if not dev:
|
||||
continue
|
||||
|
||||
converted_entity = None
|
||||
dev_def = dev.get("definition") or {}
|
||||
dev_info = {
|
||||
"type": dev.get("type"),
|
||||
"date_code": dev.get("date_code"),
|
||||
"ieee_address": dev.get("ieee_address"),
|
||||
"network_address": dev.get("network_address"),
|
||||
"power_source": dev.get("power_source"),
|
||||
"software_build_id": dev.get("software_build_id"),
|
||||
"model_id": dev.get("model_id"),
|
||||
"model": dev_def.get("model"),
|
||||
"vendor": dev_def.get("vendor"),
|
||||
"supported": dev.get("supported"),
|
||||
}
|
||||
|
||||
light_info = self._get_light_meta(dev)
|
||||
switch_info = self._get_switch_meta(dev)
|
||||
|
||||
if light_info:
|
||||
converted_entity = Light(
|
||||
id=dev['ieee_address'],
|
||||
name=dev.get('friendly_name'),
|
||||
on=dev.get('state', {}).get('state') == switch_info.get('value_on'),
|
||||
brightness=dev.get('state', {}).get('brightness'),
|
||||
brightness_min=light_info.get('brightness_min'),
|
||||
brightness_max=light_info.get('brightness_max'),
|
||||
temperature=dev.get('state', {}).get('temperature'),
|
||||
temperature_min=light_info.get('temperature_min'),
|
||||
temperature_max=light_info.get('temperature_max'),
|
||||
description=dev_def.get('description'),
|
||||
data=dev_info,
|
||||
)
|
||||
elif switch_info and dev.get('state', {}).get('state') is not None:
|
||||
converted_entity = Switch(
|
||||
id=dev['ieee_address'],
|
||||
name=dev.get('friendly_name'),
|
||||
state=dev.get('state', {}).get('state') == switch_info['value_on'],
|
||||
description=dev_def.get("description"),
|
||||
data=dev_info,
|
||||
)
|
||||
|
||||
if converted_entity:
|
||||
compatible_entities.append(converted_entity)
|
||||
|
||||
return super().transform_entities(compatible_entities) # type: ignore
|
||||
|
||||
def _get_network_info(self, **kwargs):
|
||||
self.logger.info('Fetching Zigbee network information')
|
||||
client = None
|
||||
|
@ -157,7 +236,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
def callback(_, __, msg):
|
||||
topic = msg.topic.split('/')[-1]
|
||||
if topic in info:
|
||||
info[topic] = msg.payload.decode() if topic == 'state' else json.loads(msg.payload.decode())
|
||||
info[topic] = (
|
||||
msg.payload.decode()
|
||||
if topic == 'state'
|
||||
else json.loads(msg.payload.decode())
|
||||
)
|
||||
info_ready_events[topic].set()
|
||||
|
||||
return callback
|
||||
|
@ -174,7 +257,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
for event in info_ready_events.values():
|
||||
info_ready = event.wait(timeout=timeout)
|
||||
if not info_ready:
|
||||
raise TimeoutError('A timeout occurred while fetching the Zigbee network information')
|
||||
raise TimeoutError(
|
||||
'A timeout occurred while fetching the Zigbee network information'
|
||||
)
|
||||
|
||||
# Cache the new results
|
||||
self._info['devices'] = {
|
||||
|
@ -182,9 +267,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
for device in info.get('devices', [])
|
||||
}
|
||||
|
||||
self._info['devices_by_addr'] = {
|
||||
device['ieee_address']: device for device in info.get('devices', [])
|
||||
}
|
||||
|
||||
self._info['groups'] = {
|
||||
group.get('name'): group
|
||||
for group in info.get('groups', [])
|
||||
group.get('name'): group for group in info.get('groups', [])
|
||||
}
|
||||
|
||||
self.logger.info('Zigbee network configuration updated')
|
||||
|
@ -194,7 +282,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
client.loop_stop()
|
||||
client.disconnect()
|
||||
except Exception as e:
|
||||
self.logger.warning('Error on MQTT client disconnection: {}'.format(str(e)))
|
||||
self.logger.warning(
|
||||
'Error on MQTT client disconnection: {}'.format(str(e))
|
||||
)
|
||||
|
||||
def _topic(self, topic):
|
||||
return self.base_topic + '/' + topic
|
||||
|
@ -204,7 +294,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
if isinstance(response, Response):
|
||||
response = response.output
|
||||
|
||||
assert response.get('status') != 'error', response.get('error', 'zigbee2mqtt error')
|
||||
assert response.get('status') != 'error', response.get(
|
||||
'error', 'zigbee2mqtt error'
|
||||
)
|
||||
return response
|
||||
|
||||
@action
|
||||
|
@ -291,7 +383,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
"value_min": 150
|
||||
},
|
||||
{
|
||||
"description": "Color of this light in the CIE 1931 color space (x/y)",
|
||||
"description": "Color of this light in the XY space",
|
||||
"features": [
|
||||
{
|
||||
"access": 7,
|
||||
|
@ -315,7 +407,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
},
|
||||
{
|
||||
"access": 2,
|
||||
"description": "Triggers an effect on the light (e.g. make light blink for a few seconds)",
|
||||
"description": "Triggers an effect on the light",
|
||||
"name": "effect",
|
||||
"property": "effect",
|
||||
"type": "enum",
|
||||
|
@ -382,7 +474,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
return self._get_network_info(**kwargs).get('devices')
|
||||
|
||||
@action
|
||||
def permit_join(self, permit: bool = True, timeout: Optional[float] = None, **kwargs):
|
||||
def permit_join(
|
||||
self, permit: bool = True, timeout: Optional[float] = None, **kwargs
|
||||
):
|
||||
"""
|
||||
Enable/disable devices from joining the network. This is not persistent (will not be saved to
|
||||
``configuration.yaml``).
|
||||
|
@ -394,14 +488,19 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
"""
|
||||
if timeout:
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/permit_join'),
|
||||
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._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
return self.publish(topic=self._topic('bridge/request/permit_join'),
|
||||
return self.publish(
|
||||
topic=self._topic('bridge/request/permit_join'),
|
||||
msg={'value': permit},
|
||||
**self._mqtt_args(**kwargs))
|
||||
**self._mqtt_args(**kwargs),
|
||||
)
|
||||
|
||||
@action
|
||||
def factory_reset(self, **kwargs):
|
||||
|
@ -413,7 +512,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
|
||||
(default: query the default configured device).
|
||||
"""
|
||||
self.publish(topic=self._topic('bridge/request/touchlink/factory_reset'), msg='', **self._mqtt_args(**kwargs))
|
||||
self.publish(
|
||||
topic=self._topic('bridge/request/touchlink/factory_reset'),
|
||||
msg='',
|
||||
**self._mqtt_args(**kwargs),
|
||||
)
|
||||
|
||||
@action
|
||||
def log_level(self, level: str, **kwargs):
|
||||
|
@ -425,9 +528,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
(default: query the default configured device).
|
||||
"""
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/config/log_level'), msg={'value': level},
|
||||
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._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def device_set_option(self, device: str, option: str, value: Any, **kwargs):
|
||||
|
@ -441,14 +548,18 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
(default: query the default configured device).
|
||||
"""
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/device/options'),
|
||||
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._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def device_remove(self, device: str, force: bool = False, **kwargs):
|
||||
|
@ -463,10 +574,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
(default: query the default configured device).
|
||||
"""
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/device/remove'),
|
||||
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._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def device_ban(self, device: str, **kwargs):
|
||||
|
@ -478,10 +592,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
(default: query the default configured device).
|
||||
"""
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/device/ban'),
|
||||
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._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def device_whitelist(self, device: str, **kwargs):
|
||||
|
@ -494,10 +611,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
(default: query the default configured device).
|
||||
"""
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/device/whitelist'),
|
||||
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._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def device_rename(self, name: str, device: Optional[str] = None, **kwargs):
|
||||
|
@ -516,8 +636,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
|
||||
# noinspection PyUnresolvedReferences
|
||||
devices = self.devices().output
|
||||
assert not [dev for dev in devices if dev.get('friendly_name') == name], \
|
||||
'A device named {} already exists on the network'.format(name)
|
||||
assert not [
|
||||
dev for dev in devices if dev.get('friendly_name') == name
|
||||
], 'A device named {} already exists on the network'.format(name)
|
||||
|
||||
if device:
|
||||
req = {
|
||||
|
@ -531,10 +652,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
}
|
||||
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/device/rename'),
|
||||
self.publish(
|
||||
topic=self._topic('bridge/request/device/rename'),
|
||||
msg=req,
|
||||
reply_topic=self._topic('bridge/response/device/rename'),
|
||||
**self._mqtt_args(**kwargs)))
|
||||
**self._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_device_get_request(values: List[Dict[str, Any]]) -> dict:
|
||||
|
@ -561,9 +685,16 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
|
||||
return ret
|
||||
|
||||
def _get_device_info(self, device: str) -> Mapping:
|
||||
return self._info['devices'].get(
|
||||
device, self._info['devices_by_addr'].get(device, {})
|
||||
)
|
||||
|
||||
# noinspection PyShadowingBuiltins
|
||||
@action
|
||||
def device_get(self, device: str, property: Optional[str] = None, **kwargs) -> Dict[str, Any]:
|
||||
def device_get(
|
||||
self, device: str, property: Optional[str] = None, **kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the properties of a device. The returned keys vary depending on the device. For example, a light bulb
|
||||
may have the "``state``" and "``brightness``" properties, while an environment sensor may have the
|
||||
|
@ -576,28 +707,59 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
:return: Key->value map of the device properties.
|
||||
"""
|
||||
kwargs = self._mqtt_args(**kwargs)
|
||||
device_info = self._get_device_info(device)
|
||||
if device_info:
|
||||
device = device_info.get('friendly_name') or device_info['ieee_address']
|
||||
|
||||
if property:
|
||||
properties = self.publish(topic=self._topic(device) + '/get/' + property, reply_topic=self._topic(device),
|
||||
msg={property: ''}, **kwargs).output
|
||||
properties = self.publish(
|
||||
topic=self._topic(device) + f'/get/{property}',
|
||||
reply_topic=self._topic(device),
|
||||
msg={property: ''},
|
||||
**kwargs,
|
||||
).output
|
||||
|
||||
assert property in properties, 'No such property: ' + property
|
||||
assert property in properties, f'No such property: {property}'
|
||||
return {property: properties[property]}
|
||||
|
||||
if device not in self._info.get('devices', {}):
|
||||
# Refresh devices info
|
||||
self._get_network_info(**kwargs)
|
||||
|
||||
assert self._info.get('devices', {}).get(device), 'No such device: ' + device
|
||||
exposes = (self._info.get('devices', {}).get(device, {}).get('definition', {}) or {}).get('exposes', [])
|
||||
dev = self._info.get('devices', {}).get(
|
||||
device, self._info.get('devices_by_addr', {}).get(device)
|
||||
)
|
||||
|
||||
assert dev, f'No such device: {device}'
|
||||
exposes = (
|
||||
self._info.get('devices', {}).get(device, {}).get('definition', {}) or {}
|
||||
).get('exposes', [])
|
||||
if not exposes:
|
||||
return {}
|
||||
|
||||
return self.publish(topic=self._topic(device) + '/get', reply_topic=self._topic(device),
|
||||
msg=self.build_device_get_request(exposes), **kwargs)
|
||||
device_state = self.publish(
|
||||
topic=self._topic(device) + '/get',
|
||||
reply_topic=self._topic(device),
|
||||
msg=self.build_device_get_request(exposes),
|
||||
**kwargs,
|
||||
).output
|
||||
|
||||
if device_info:
|
||||
self.publish_entities( # type: ignore
|
||||
[
|
||||
{
|
||||
**device_info,
|
||||
'state': device_state,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
return device_state
|
||||
|
||||
@action
|
||||
def devices_get(self, devices: Optional[List[str]] = None, **kwargs) -> Dict[str, dict]:
|
||||
def devices_get(
|
||||
self, devices: Optional[List[str]] = None, **kwargs
|
||||
) -> Dict[str, dict]:
|
||||
"""
|
||||
Get the properties of the devices connected to the network.
|
||||
|
||||
|
@ -622,14 +784,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
kwargs = self._mqtt_args(**kwargs)
|
||||
|
||||
if not devices:
|
||||
# noinspection PyUnresolvedReferences
|
||||
devices = set([
|
||||
devices = {
|
||||
device['friendly_name'] or device['ieee_address']
|
||||
for device in self.devices(**kwargs).output
|
||||
])
|
||||
}
|
||||
|
||||
def worker(device: str, q: Queue):
|
||||
# noinspection PyUnresolvedReferences
|
||||
q.put(self.device_get(device, **kwargs).output)
|
||||
|
||||
queues = {}
|
||||
|
@ -638,7 +798,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
|
||||
for device in devices:
|
||||
queues[device] = Queue()
|
||||
workers[device] = threading.Thread(target=worker, args=(device, queues[device]))
|
||||
workers[device] = threading.Thread(
|
||||
target=worker, args=(device, queues[device])
|
||||
)
|
||||
workers[device].start()
|
||||
|
||||
for device in devices:
|
||||
|
@ -646,8 +808,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
response[device] = queues[device].get(timeout=kwargs.get('timeout'))
|
||||
workers[device].join(timeout=kwargs.get('timeout'))
|
||||
except Exception as e:
|
||||
self.logger.warning('An error while getting the status of the device {}: {}'.format(
|
||||
device, str(e)))
|
||||
self.logger.warning(
|
||||
'An error while getting the status of the device {}: {}'.format(
|
||||
device, str(e)
|
||||
)
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
@ -658,7 +823,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
|
||||
:param device: Device friendly name (default: get all devices).
|
||||
"""
|
||||
return self.devices_get([device], *args, **kwargs)
|
||||
return self.devices_get([device] if device else None, *args, **kwargs)
|
||||
|
||||
# noinspection PyShadowingBuiltins,DuplicatedCode
|
||||
@action
|
||||
|
@ -674,9 +839,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
|
||||
(default: query the default configured device).
|
||||
"""
|
||||
properties = self.publish(topic=self._topic(device + '/set'),
|
||||
properties = self.publish(
|
||||
topic=self._topic(device + '/set'),
|
||||
reply_topic=self._topic(device),
|
||||
msg={property: value}, **self._mqtt_args(**kwargs)).output
|
||||
msg={property: value},
|
||||
**self._mqtt_args(**kwargs),
|
||||
).output
|
||||
|
||||
if property:
|
||||
assert property in properties, 'No such property: ' + property
|
||||
|
@ -705,9 +873,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
|
||||
"""
|
||||
ret = self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/device/ota_update/check'),
|
||||
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)))
|
||||
msg={'id': device},
|
||||
**self._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
'status': ret['status'],
|
||||
|
@ -725,9 +897,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
(default: query the default configured device).
|
||||
"""
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/device/ota_update/update'),
|
||||
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)))
|
||||
msg={'id': device},
|
||||
**self._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def groups(self, **kwargs) -> List[dict]:
|
||||
|
@ -883,16 +1059,22 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
|
||||
(default: query the default configured device).
|
||||
"""
|
||||
payload = name if id is None else {
|
||||
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'),
|
||||
self.publish(
|
||||
topic=self._topic('bridge/request/group/add'),
|
||||
reply_topic=self._topic('bridge/response/group/add'),
|
||||
msg=payload,
|
||||
**self._mqtt_args(**kwargs))
|
||||
**self._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
|
@ -911,9 +1093,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
if property:
|
||||
msg = {property: ''}
|
||||
|
||||
properties = self.publish(topic=self._topic(group + '/get'),
|
||||
properties = self.publish(
|
||||
topic=self._topic(group + '/get'),
|
||||
reply_topic=self._topic(group),
|
||||
msg=msg, **self._mqtt_args(**kwargs)).output
|
||||
msg=msg,
|
||||
**self._mqtt_args(**kwargs),
|
||||
).output
|
||||
|
||||
if property:
|
||||
assert property in properties, 'No such property: ' + property
|
||||
|
@ -935,9 +1120,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
|
||||
(default: query the default configured device).
|
||||
"""
|
||||
properties = self.publish(topic=self._topic(group + '/set'),
|
||||
properties = self.publish(
|
||||
topic=self._topic(group + '/set'),
|
||||
reply_topic=self._topic(group),
|
||||
msg={property: value}, **self._mqtt_args(**kwargs)).output
|
||||
msg={property: value},
|
||||
**self._mqtt_args(**kwargs),
|
||||
).output
|
||||
|
||||
if property:
|
||||
assert property in properties, 'No such property: ' + property
|
||||
|
@ -961,13 +1149,18 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
|
||||
# noinspection PyUnresolvedReferences
|
||||
groups = {group.get('friendly_name'): group for group in self.groups().output}
|
||||
assert name not in groups, 'A group named {} already exists on the network'.format(name)
|
||||
assert (
|
||||
name not in groups
|
||||
), 'A group named {} already exists on the network'.format(name)
|
||||
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/group/rename'),
|
||||
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._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def group_remove(self, name: str, **kwargs):
|
||||
|
@ -979,10 +1172,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
(default: query the default configured device).
|
||||
"""
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/group/remove'),
|
||||
self.publish(
|
||||
topic=self._topic('bridge/request/group/remove'),
|
||||
reply_topic=self._topic('bridge/response/group/remove'),
|
||||
msg=name,
|
||||
**self._mqtt_args(**kwargs)))
|
||||
**self._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def group_add_device(self, group: str, device: str, **kwargs):
|
||||
|
@ -995,12 +1191,16 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
(default: query the default configured device).
|
||||
"""
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/group/members/add'),
|
||||
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._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def group_remove_device(self, group: str, device: Optional[str] = None, **kwargs):
|
||||
|
@ -1015,13 +1215,23 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
"""
|
||||
return self._parse_response(
|
||||
self.publish(
|
||||
topic=self._topic('bridge/request/group/members/remove{}'.format('_all' if device is None else '')),
|
||||
topic=self._topic(
|
||||
'bridge/request/group/members/remove{}'.format(
|
||||
'_all' if device is None else ''
|
||||
)
|
||||
),
|
||||
reply_topic=self._topic(
|
||||
'bridge/response/group/members/remove{}'.format('_all' if device is None else '')),
|
||||
'bridge/response/group/members/remove{}'.format(
|
||||
'_all' if device is None else ''
|
||||
)
|
||||
),
|
||||
msg={
|
||||
'group': group,
|
||||
'device': device,
|
||||
}, **self._mqtt_args(**kwargs)))
|
||||
},
|
||||
**self._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def bind_devices(self, source: str, target: str, **kwargs):
|
||||
|
@ -1040,9 +1250,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
(default: query the default configured device).
|
||||
"""
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/device/bind'),
|
||||
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)))
|
||||
msg={'from': source, 'to': target},
|
||||
**self._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def unbind_devices(self, source: str, target: str, **kwargs):
|
||||
|
@ -1057,9 +1271,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
(default: query the default configured device).
|
||||
"""
|
||||
return self._parse_response(
|
||||
self.publish(topic=self._topic('bridge/request/device/unbind'),
|
||||
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)))
|
||||
msg={'from': source, 'to': target},
|
||||
**self._mqtt_args(**kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
@action
|
||||
def on(self, device, *args, **kwargs) -> dict:
|
||||
|
@ -1067,10 +1285,15 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.on` and turns on a Zigbee device with a writable
|
||||
binary property.
|
||||
"""
|
||||
switch_info = self._get_switches_info().get(device)
|
||||
switch_info = self._get_switch_info(device)
|
||||
assert switch_info, '{} is not a valid switch'.format(device)
|
||||
props = self.device_set(device, switch_info['property'], switch_info['value_on']).output
|
||||
return self._properties_to_switch(device=device, props=props, switch_info=switch_info)
|
||||
device = switch_info.get('friendly_name') or switch_info['ieee_address']
|
||||
props = self.device_set(
|
||||
device, switch_info['property'], switch_info['value_on']
|
||||
).output
|
||||
return self._properties_to_switch(
|
||||
device=device, props=props, switch_info=switch_info
|
||||
)
|
||||
|
||||
@action
|
||||
def off(self, device, *args, **kwargs) -> dict:
|
||||
|
@ -1078,10 +1301,15 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.off` and turns off a Zigbee device with a
|
||||
writable binary property.
|
||||
"""
|
||||
switch_info = self._get_switches_info().get(device)
|
||||
switch_info = self._get_switch_info(device)
|
||||
assert switch_info, '{} is not a valid switch'.format(device)
|
||||
props = self.device_set(device, switch_info['property'], switch_info['value_off']).output
|
||||
return self._properties_to_switch(device=device, props=props, switch_info=switch_info)
|
||||
device = switch_info.get('friendly_name') or switch_info['ieee_address']
|
||||
props = self.device_set(
|
||||
device, switch_info['property'], switch_info['value_off']
|
||||
).output
|
||||
return self._properties_to_switch(
|
||||
device=device, props=props, switch_info=switch_info
|
||||
)
|
||||
|
||||
@action
|
||||
def toggle(self, device, *args, **kwargs) -> dict:
|
||||
|
@ -1089,10 +1317,26 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.toggle` and toggles a Zigbee device with a
|
||||
writable binary property.
|
||||
"""
|
||||
switch_info = self._get_switches_info().get(device)
|
||||
switch_info = self._get_switch_info(device)
|
||||
assert switch_info, '{} is not a valid switch'.format(device)
|
||||
props = self.device_set(device, switch_info['property'], switch_info['value_toggle']).output
|
||||
return self._properties_to_switch(device=device, props=props, switch_info=switch_info)
|
||||
device = switch_info.get('friendly_name') or switch_info['ieee_address']
|
||||
props = self.device_set(
|
||||
device, switch_info['property'], switch_info['value_toggle']
|
||||
).output
|
||||
return self._properties_to_switch(
|
||||
device=device, props=props, switch_info=switch_info
|
||||
)
|
||||
|
||||
def _get_switch_info(self, device: str):
|
||||
switches_info = self._get_switches_info()
|
||||
info = switches_info.get(device)
|
||||
if info:
|
||||
return info
|
||||
|
||||
device_info = self._get_device_info(device)
|
||||
if device_info:
|
||||
device = device_info.get('friendly_name') or device_info['ieee_address']
|
||||
return switches_info.get(device)
|
||||
|
||||
@staticmethod
|
||||
def _properties_to_switch(device: str, props: dict, switch_info: dict) -> dict:
|
||||
|
@ -1103,32 +1347,105 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
**props,
|
||||
}
|
||||
|
||||
def _get_switches_info(self) -> dict:
|
||||
def switch_info(device_info: dict) -> dict:
|
||||
@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('type') == 'binary' and 'value_on' in feature and 'value_off' in feature and \
|
||||
feature.get('access', 0) & 2:
|
||||
if (
|
||||
feature.get('property') == 'state'
|
||||
and feature.get('type') == 'binary'
|
||||
and 'value_on' in feature
|
||||
and 'value_off' in feature
|
||||
):
|
||||
return {
|
||||
'friendly_name': device_info.get('friendly_name'),
|
||||
'ieee_address': device_info.get('friendly_name'),
|
||||
'property': feature['property'],
|
||||
'value_on': feature['value_on'],
|
||||
'value_off': feature['value_off'],
|
||||
'value_toggle': feature.get('value_toggle', None),
|
||||
'is_read_only': not bool(feature.get('access', 0) & 2),
|
||||
'is_write_only': not bool(feature.get('access', 0) & 1),
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _get_light_meta(device_info: dict) -> dict:
|
||||
exposes = (device_info.get('definition', {}) or {}).get('exposes', [])
|
||||
for exposed in exposes:
|
||||
if exposed.get('type') == 'light':
|
||||
features = exposed.get('features', [])
|
||||
switch = {}
|
||||
brightness = {}
|
||||
temperature = {}
|
||||
|
||||
for feature in features:
|
||||
if (
|
||||
feature.get('property') == 'state'
|
||||
and feature.get('type') == 'binary'
|
||||
and 'value_on' in feature
|
||||
and 'value_off' in feature
|
||||
):
|
||||
switch = {
|
||||
'value_on': feature['value_on'],
|
||||
'value_off': feature['value_off'],
|
||||
'state_name': feature['name'],
|
||||
'value_toggle': feature.get('value_toggle', None),
|
||||
'is_read_only': not bool(feature.get('access', 0) & 2),
|
||||
'is_write_only': not bool(feature.get('access', 0) & 1),
|
||||
}
|
||||
elif (
|
||||
feature.get('property') == 'brightness'
|
||||
and feature.get('type') == 'numeric'
|
||||
and 'value_min' in feature
|
||||
and 'value_max' in feature
|
||||
):
|
||||
brightness = {
|
||||
'brightness_name': feature['name'],
|
||||
'brightness_min': feature['value_min'],
|
||||
'brightness_max': feature['value_max'],
|
||||
'is_read_only': not bool(feature.get('access', 0) & 2),
|
||||
'is_write_only': not bool(feature.get('access', 0) & 1),
|
||||
}
|
||||
elif (
|
||||
feature.get('property') == 'color_temp'
|
||||
and feature.get('type') == 'numeric'
|
||||
and 'value_min' in feature
|
||||
and 'value_max' in feature
|
||||
):
|
||||
temperature = {
|
||||
'temperature_name': feature['name'],
|
||||
'temperature_min': feature['value_min'],
|
||||
'temperature_max': feature['value_max'],
|
||||
'is_read_only': not bool(feature.get('access', 0) & 2),
|
||||
'is_write_only': not bool(feature.get('access', 0) & 1),
|
||||
}
|
||||
|
||||
return {
|
||||
'friendly_name': device_info.get('friendly_name'),
|
||||
'ieee_address': device_info.get('friendly_name'),
|
||||
**switch,
|
||||
**brightness,
|
||||
**temperature,
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def _get_switches_info(self) -> dict:
|
||||
# noinspection PyUnresolvedReferences
|
||||
devices = self.devices().output
|
||||
switches_info = {}
|
||||
|
||||
for device in devices:
|
||||
info = switch_info(device)
|
||||
info = self._get_switch_meta(device)
|
||||
if not info:
|
||||
continue
|
||||
|
||||
switches_info[device.get('friendly_name', device.get('ieee_address'))] = info
|
||||
switches_info[
|
||||
device.get('friendly_name', device.get('ieee_address'))
|
||||
] = info
|
||||
|
||||
return switches_info
|
||||
|
||||
|
@ -1142,9 +1459,37 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i
|
|||
switches_info = self._get_switches_info()
|
||||
# noinspection PyUnresolvedReferences
|
||||
return [
|
||||
self._properties_to_switch(device=name, props=switch, switch_info=switches_info[name])
|
||||
for name, switch in self.devices_get(list(switches_info.keys())).output.items()
|
||||
self._properties_to_switch(
|
||||
device=name, props=switch, switch_info=switches_info[name]
|
||||
)
|
||||
for name, switch in self.devices_get(
|
||||
list(switches_info.keys())
|
||||
).output.items()
|
||||
]
|
||||
|
||||
@action
|
||||
def set_lights(self, lights, **kwargs):
|
||||
devices = [
|
||||
dev
|
||||
for dev in self._get_network_info().get('devices', [])
|
||||
if dev.get('ieee_address') in lights or dev.get('friendly_name') in lights
|
||||
]
|
||||
|
||||
for dev in devices:
|
||||
light_meta = self._get_light_meta(dev)
|
||||
assert light_meta, f'{dev["name"]} is not a light'
|
||||
|
||||
for attr, value in kwargs.items():
|
||||
if attr == 'on':
|
||||
attr = light_meta['state_name']
|
||||
elif attr in {'brightness', 'bri'}:
|
||||
attr = light_meta['brightness_name']
|
||||
elif attr in {'temperature', 'ct'}:
|
||||
attr = light_meta['temperature_name']
|
||||
|
||||
self.device_set(
|
||||
dev.get('friendly_name', dev.get('ieee_address')), attr, value
|
||||
)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -13,11 +13,13 @@ except ImportError:
|
|||
from jwt import PyJWTError, encode as jwt_encode, decode as jwt_decode
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import make_transient, declarative_base
|
||||
|
||||
from platypush.context import get_plugin
|
||||
from platypush.exceptions.user import InvalidJWTTokenException, InvalidCredentialsException
|
||||
from platypush.exceptions.user import (
|
||||
InvalidJWTTokenException,
|
||||
InvalidCredentialsException,
|
||||
)
|
||||
from platypush.utils import get_or_generate_jwt_rsa_key_pair
|
||||
|
||||
Base = declarative_base()
|
||||
|
@ -28,56 +30,61 @@ class UserManager:
|
|||
Main class for managing platform users
|
||||
"""
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
def __init__(self):
|
||||
db_plugin = get_plugin('db')
|
||||
if not db_plugin:
|
||||
raise ModuleNotFoundError('Please enable/configure the db plugin for multi-user support')
|
||||
self.db = get_plugin('db')
|
||||
assert self.db
|
||||
self._engine = self.db.get_engine()
|
||||
self.db.create_all(self._engine, Base)
|
||||
|
||||
self._engine = db_plugin._get_engine()
|
||||
@staticmethod
|
||||
def _mask_password(user):
|
||||
make_transient(user)
|
||||
user.password = None
|
||||
return user
|
||||
|
||||
def get_user(self, username):
|
||||
session = self._get_db_session()
|
||||
with self.db.get_session() as session:
|
||||
user = self._get_user(session, username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Hide password
|
||||
user.password = None
|
||||
return user
|
||||
session.expunge(user)
|
||||
return self._mask_password(user)
|
||||
|
||||
def get_user_count(self):
|
||||
session = self._get_db_session()
|
||||
with self.db.get_session() as session:
|
||||
return session.query(User).count()
|
||||
|
||||
def get_users(self):
|
||||
session = self._get_db_session()
|
||||
with self.db.get_session() as session:
|
||||
return session.query(User)
|
||||
|
||||
def create_user(self, username, password, **kwargs):
|
||||
session = self._get_db_session()
|
||||
if not username:
|
||||
raise ValueError('Invalid or empty username')
|
||||
if not password:
|
||||
raise ValueError('Please provide a password for the user')
|
||||
|
||||
with self.db.get_session() as session:
|
||||
user = self._get_user(session, username)
|
||||
if user:
|
||||
raise NameError('The user {} already exists'.format(username))
|
||||
|
||||
record = User(username=username, password=self._encrypt_password(password),
|
||||
created_at=datetime.datetime.utcnow(), **kwargs)
|
||||
record = User(
|
||||
username=username,
|
||||
password=self._encrypt_password(password),
|
||||
created_at=datetime.datetime.utcnow(),
|
||||
**kwargs
|
||||
)
|
||||
|
||||
session.add(record)
|
||||
session.commit()
|
||||
user = self._get_user(session, username)
|
||||
|
||||
# Hide password
|
||||
user.password = None
|
||||
return user
|
||||
return self._mask_password(user)
|
||||
|
||||
def update_password(self, username, old_password, new_password):
|
||||
session = self._get_db_session()
|
||||
with self.db.get_session() as session:
|
||||
if not self._authenticate_user(session, username, old_password):
|
||||
return False
|
||||
|
||||
|
@ -87,30 +94,35 @@ class UserManager:
|
|||
return True
|
||||
|
||||
def authenticate_user(self, username, password):
|
||||
session = self._get_db_session()
|
||||
with self.db.get_session() as session:
|
||||
return self._authenticate_user(session, username, password)
|
||||
|
||||
def authenticate_user_session(self, session_token):
|
||||
session = self._get_db_session()
|
||||
user_session = session.query(UserSession).filter_by(session_token=session_token).first()
|
||||
with self.db.get_session() as session:
|
||||
user_session = (
|
||||
session.query(UserSession)
|
||||
.filter_by(session_token=session_token)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_session or (
|
||||
user_session.expires_at and user_session.expires_at < datetime.datetime.utcnow()):
|
||||
user_session.expires_at
|
||||
and user_session.expires_at < datetime.datetime.utcnow()
|
||||
):
|
||||
return None, None
|
||||
|
||||
user = session.query(User).filter_by(user_id=user_session.user_id).first()
|
||||
|
||||
# Hide password
|
||||
user.password = None
|
||||
return user, session
|
||||
return self._mask_password(user), user_session
|
||||
|
||||
def delete_user(self, username):
|
||||
session = self._get_db_session()
|
||||
with self.db.get_session() as session:
|
||||
user = self._get_user(session, username)
|
||||
if not user:
|
||||
raise NameError('No such user: {}'.format(username))
|
||||
|
||||
user_sessions = session.query(UserSession).filter_by(user_id=user.user_id).all()
|
||||
user_sessions = (
|
||||
session.query(UserSession).filter_by(user_id=user.user_id).all()
|
||||
)
|
||||
for user_session in user_sessions:
|
||||
session.delete(user_session)
|
||||
|
||||
|
@ -119,8 +131,12 @@ class UserManager:
|
|||
return True
|
||||
|
||||
def delete_user_session(self, session_token):
|
||||
session = self._get_db_session()
|
||||
user_session = session.query(UserSession).filter_by(session_token=session_token).first()
|
||||
with self.db.get_session() as session:
|
||||
user_session = (
|
||||
session.query(UserSession)
|
||||
.filter_by(session_token=session_token)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_session:
|
||||
return False
|
||||
|
@ -130,20 +146,24 @@ class UserManager:
|
|||
return True
|
||||
|
||||
def create_user_session(self, username, password, expires_at=None):
|
||||
session = self._get_db_session()
|
||||
with self.db.get_session() as session:
|
||||
user = self._authenticate_user(session, username, password)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if expires_at:
|
||||
if isinstance(expires_at, int) or isinstance(expires_at, float):
|
||||
if isinstance(expires_at, (int, float)):
|
||||
expires_at = datetime.datetime.fromtimestamp(expires_at)
|
||||
elif isinstance(expires_at, str):
|
||||
expires_at = datetime.datetime.fromisoformat(expires_at)
|
||||
|
||||
user_session = UserSession(user_id=user.user_id, session_token=self.generate_session_token(),
|
||||
csrf_token=self.generate_session_token(), created_at=datetime.datetime.utcnow(),
|
||||
expires_at=expires_at)
|
||||
user_session = UserSession(
|
||||
user_id=user.user_id,
|
||||
session_token=self.generate_session_token(),
|
||||
csrf_token=self.generate_session_token(),
|
||||
created_at=datetime.datetime.utcnow(),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
session.add(user_session)
|
||||
session.commit()
|
||||
|
@ -180,10 +200,20 @@ class UserManager:
|
|||
|
||||
:param session_token: Session token.
|
||||
"""
|
||||
session = self._get_db_session()
|
||||
return session.query(User).join(UserSession).filter_by(session_token=session_token).first()
|
||||
with self.db.get_session() as session:
|
||||
return (
|
||||
session.query(User)
|
||||
.join(UserSession)
|
||||
.filter_by(session_token=session_token)
|
||||
.first()
|
||||
)
|
||||
|
||||
def generate_jwt_token(self, username: str, password: str, expires_at: Optional[datetime.datetime] = None) -> str:
|
||||
def generate_jwt_token(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
expires_at: Optional[datetime.datetime] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a user JWT token for API usage.
|
||||
|
||||
|
@ -240,12 +270,6 @@ class UserManager:
|
|||
|
||||
return payload
|
||||
|
||||
def _get_db_session(self):
|
||||
Base.metadata.create_all(self._engine)
|
||||
session = scoped_session(sessionmaker(expire_on_commit=False))
|
||||
session.configure(bind=self._engine)
|
||||
return session()
|
||||
|
||||
def _authenticate_user(self, session, username, password):
|
||||
"""
|
||||
:return: :class:`platypush.user.User` instance if the user exists and the password is valid, ``None`` otherwise.
|
||||
|
@ -261,10 +285,10 @@ class UserManager:
|
|||
|
||||
|
||||
class User(Base):
|
||||
""" Models the User table """
|
||||
"""Models the User table"""
|
||||
|
||||
__tablename__ = 'user'
|
||||
__table_args__ = ({'sqlite_autoincrement': True})
|
||||
__table_args__ = {'sqlite_autoincrement': True}
|
||||
|
||||
user_id = Column(Integer, primary_key=True)
|
||||
username = Column(String, unique=True, nullable=False)
|
||||
|
@ -273,10 +297,10 @@ class User(Base):
|
|||
|
||||
|
||||
class UserSession(Base):
|
||||
""" Models the UserSession table """
|
||||
"""Models the UserSession table"""
|
||||
|
||||
__tablename__ = 'user_session'
|
||||
__table_args__ = ({'sqlite_autoincrement': True})
|
||||
__table_args__ = {'sqlite_autoincrement': True}
|
||||
|
||||
session_id = Column(Integer, primary_key=True)
|
||||
session_token = Column(String, unique=True, nullable=False)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import ast
|
||||
import contextlib
|
||||
import datetime
|
||||
import hashlib
|
||||
import importlib
|
||||
|
@ -20,8 +21,8 @@ logger = logging.getLogger('utils')
|
|||
|
||||
|
||||
def get_module_and_method_from_action(action):
|
||||
""" Input : action=music.mpd.play
|
||||
Output : ('music.mpd', 'play') """
|
||||
"""Input : action=music.mpd.play
|
||||
Output : ('music.mpd', 'play')"""
|
||||
|
||||
tokens = action.split('.')
|
||||
module_name = str.join('.', tokens[:-1])
|
||||
|
@ -30,7 +31,7 @@ def get_module_and_method_from_action(action):
|
|||
|
||||
|
||||
def get_message_class_by_type(msgtype):
|
||||
""" Gets the class of a message type given as string """
|
||||
"""Gets the class of a message type given as string"""
|
||||
|
||||
try:
|
||||
module = importlib.import_module('platypush.message.' + msgtype)
|
||||
|
@ -43,8 +44,7 @@ def get_message_class_by_type(msgtype):
|
|||
try:
|
||||
msgclass = getattr(module, cls_name)
|
||||
except AttributeError as e:
|
||||
logger.warning('No such class in {}: {}'.format(
|
||||
module.__name__, cls_name))
|
||||
logger.warning('No such class in {}: {}'.format(module.__name__, cls_name))
|
||||
raise RuntimeError(e)
|
||||
|
||||
return msgclass
|
||||
|
@ -52,13 +52,13 @@ def get_message_class_by_type(msgtype):
|
|||
|
||||
# noinspection PyShadowingBuiltins
|
||||
def get_event_class_by_type(type):
|
||||
""" Gets an event class by type name """
|
||||
"""Gets an event class by type name"""
|
||||
event_module = importlib.import_module('.'.join(type.split('.')[:-1]))
|
||||
return getattr(event_module, type.split('.')[-1])
|
||||
|
||||
|
||||
def get_plugin_module_by_name(plugin_name):
|
||||
""" Gets the module of a plugin by name (e.g. "music.mpd" or "media.vlc") """
|
||||
"""Gets the module of a plugin by name (e.g. "music.mpd" or "media.vlc")"""
|
||||
|
||||
module_name = 'platypush.plugins.' + plugin_name
|
||||
try:
|
||||
|
@ -69,22 +69,26 @@ def get_plugin_module_by_name(plugin_name):
|
|||
|
||||
|
||||
def get_plugin_class_by_name(plugin_name):
|
||||
""" Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc") """
|
||||
"""Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc")"""
|
||||
|
||||
module = get_plugin_module_by_name(plugin_name)
|
||||
if not module:
|
||||
return
|
||||
|
||||
class_name = getattr(module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin')
|
||||
class_name = getattr(
|
||||
module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin'
|
||||
)
|
||||
try:
|
||||
return getattr(module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin')
|
||||
return getattr(
|
||||
module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Cannot import class {}: {}'.format(class_name, str(e)))
|
||||
return None
|
||||
|
||||
|
||||
def get_plugin_name_by_class(plugin) -> Optional[str]:
|
||||
"""Gets the common name of a plugin (e.g. "music.mpd" or "media.vlc") given its class. """
|
||||
"""Gets the common name of a plugin (e.g. "music.mpd" or "media.vlc") given its class."""
|
||||
|
||||
from platypush.plugins import Plugin
|
||||
|
||||
|
@ -93,7 +97,8 @@ def get_plugin_name_by_class(plugin) -> Optional[str]:
|
|||
|
||||
class_name = plugin.__name__
|
||||
class_tokens = [
|
||||
token.lower() for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
|
||||
token.lower()
|
||||
for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
|
||||
if token.strip() and token != 'Plugin'
|
||||
]
|
||||
|
||||
|
@ -101,7 +106,7 @@ def get_plugin_name_by_class(plugin) -> Optional[str]:
|
|||
|
||||
|
||||
def get_backend_name_by_class(backend) -> Optional[str]:
|
||||
"""Gets the common name of a backend (e.g. "http" or "mqtt") given its class. """
|
||||
"""Gets the common name of a backend (e.g. "http" or "mqtt") given its class."""
|
||||
|
||||
from platypush.backend import Backend
|
||||
|
||||
|
@ -110,7 +115,8 @@ def get_backend_name_by_class(backend) -> Optional[str]:
|
|||
|
||||
class_name = backend.__name__
|
||||
class_tokens = [
|
||||
token.lower() for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
|
||||
token.lower()
|
||||
for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
|
||||
if token.strip() and token != 'Backend'
|
||||
]
|
||||
|
||||
|
@ -135,12 +141,12 @@ def set_timeout(seconds, on_timeout):
|
|||
|
||||
|
||||
def clear_timeout():
|
||||
""" Clear any previously set timeout """
|
||||
"""Clear any previously set timeout"""
|
||||
signal.alarm(0)
|
||||
|
||||
|
||||
def get_hash(s):
|
||||
""" Get the SHA256 hash hex digest of a string input """
|
||||
"""Get the SHA256 hash hex digest of a string input"""
|
||||
return hashlib.sha256(s.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
|
@ -177,11 +183,8 @@ def get_decorators(cls, climb_class_hierarchy=False):
|
|||
node_iter.visit_FunctionDef = visit_FunctionDef
|
||||
|
||||
for target in targets:
|
||||
try:
|
||||
with contextlib.suppress(TypeError):
|
||||
node_iter.visit(ast.parse(inspect.getsource(target)))
|
||||
except TypeError:
|
||||
# Ignore built-in classes
|
||||
pass
|
||||
|
||||
return decorators
|
||||
|
||||
|
@ -195,45 +198,57 @@ def get_redis_queue_name_by_message(msg):
|
|||
return 'platypush/responses/{}'.format(msg.id) if msg.id else None
|
||||
|
||||
|
||||
def _get_ssl_context(context_type=None, ssl_cert=None, ssl_key=None,
|
||||
ssl_cafile=None, ssl_capath=None):
|
||||
def _get_ssl_context(
|
||||
context_type=None, ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None
|
||||
):
|
||||
if not context_type:
|
||||
ssl_context = ssl.create_default_context(cafile=ssl_cafile,
|
||||
capath=ssl_capath)
|
||||
ssl_context = ssl.create_default_context(cafile=ssl_cafile, capath=ssl_capath)
|
||||
else:
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
|
||||
if ssl_cafile or ssl_capath:
|
||||
ssl_context.load_verify_locations(
|
||||
cafile=ssl_cafile, capath=ssl_capath)
|
||||
ssl_context.load_verify_locations(cafile=ssl_cafile, capath=ssl_capath)
|
||||
|
||||
ssl_context.load_cert_chain(
|
||||
certfile=os.path.abspath(os.path.expanduser(ssl_cert)),
|
||||
keyfile=os.path.abspath(os.path.expanduser(ssl_key)) if ssl_key else None
|
||||
keyfile=os.path.abspath(os.path.expanduser(ssl_key)) if ssl_key else None,
|
||||
)
|
||||
|
||||
return ssl_context
|
||||
|
||||
|
||||
def get_ssl_context(ssl_cert=None, ssl_key=None, ssl_cafile=None,
|
||||
ssl_capath=None):
|
||||
return _get_ssl_context(context_type=None,
|
||||
ssl_cert=ssl_cert, ssl_key=ssl_key,
|
||||
ssl_cafile=ssl_cafile, ssl_capath=ssl_capath)
|
||||
def get_ssl_context(ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None):
|
||||
return _get_ssl_context(
|
||||
context_type=None,
|
||||
ssl_cert=ssl_cert,
|
||||
ssl_key=ssl_key,
|
||||
ssl_cafile=ssl_cafile,
|
||||
ssl_capath=ssl_capath,
|
||||
)
|
||||
|
||||
|
||||
def get_ssl_server_context(ssl_cert=None, ssl_key=None, ssl_cafile=None,
|
||||
ssl_capath=None):
|
||||
return _get_ssl_context(context_type=ssl.PROTOCOL_TLS_SERVER,
|
||||
ssl_cert=ssl_cert, ssl_key=ssl_key,
|
||||
ssl_cafile=ssl_cafile, ssl_capath=ssl_capath)
|
||||
def get_ssl_server_context(
|
||||
ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None
|
||||
):
|
||||
return _get_ssl_context(
|
||||
context_type=ssl.PROTOCOL_TLS_SERVER,
|
||||
ssl_cert=ssl_cert,
|
||||
ssl_key=ssl_key,
|
||||
ssl_cafile=ssl_cafile,
|
||||
ssl_capath=ssl_capath,
|
||||
)
|
||||
|
||||
|
||||
def get_ssl_client_context(ssl_cert=None, ssl_key=None, ssl_cafile=None,
|
||||
ssl_capath=None):
|
||||
return _get_ssl_context(context_type=ssl.PROTOCOL_TLS_CLIENT,
|
||||
ssl_cert=ssl_cert, ssl_key=ssl_key,
|
||||
ssl_cafile=ssl_cafile, ssl_capath=ssl_capath)
|
||||
def get_ssl_client_context(
|
||||
ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None
|
||||
):
|
||||
return _get_ssl_context(
|
||||
context_type=ssl.PROTOCOL_TLS_CLIENT,
|
||||
ssl_cert=ssl_cert,
|
||||
ssl_key=ssl_key,
|
||||
ssl_cafile=ssl_cafile,
|
||||
ssl_capath=ssl_capath,
|
||||
)
|
||||
|
||||
|
||||
def set_thread_name(name):
|
||||
|
@ -241,6 +256,7 @@ def set_thread_name(name):
|
|||
|
||||
try:
|
||||
import prctl
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
prctl.set_name(name)
|
||||
except ImportError:
|
||||
|
@ -251,9 +267,9 @@ def find_bins_in_path(bin_name):
|
|||
return [
|
||||
os.path.join(p, bin_name)
|
||||
for p in os.environ.get('PATH', '').split(':')
|
||||
if os.path.isfile(os.path.join(p, bin_name)) and (
|
||||
os.name == 'nt' or os.access(os.path.join(p, bin_name), os.X_OK)
|
||||
)]
|
||||
if os.path.isfile(os.path.join(p, bin_name))
|
||||
and (os.name == 'nt' or os.access(os.path.join(p, bin_name), os.X_OK))
|
||||
]
|
||||
|
||||
|
||||
def find_files_by_ext(directory, *exts):
|
||||
|
@ -271,7 +287,7 @@ def find_files_by_ext(directory, *exts):
|
|||
max_len = len(max(exts, key=len))
|
||||
result = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for _, __, files in os.walk(directory):
|
||||
for i in range(min_len, max_len + 1):
|
||||
result += [f for f in files if f[-i:] in exts]
|
||||
|
||||
|
@ -302,8 +318,9 @@ def get_ip_or_hostname():
|
|||
|
||||
def get_mime_type(resource):
|
||||
import magic
|
||||
|
||||
if resource.startswith('file://'):
|
||||
resource = resource[len('file://'):]
|
||||
resource = resource[len('file://') :]
|
||||
|
||||
# noinspection HttpUrlsUsage
|
||||
if resource.startswith('http://') or resource.startswith('https://'):
|
||||
|
@ -315,7 +332,9 @@ def get_mime_type(resource):
|
|||
elif hasattr(magic, 'from_file'):
|
||||
mime = magic.from_file(resource, mime=True)
|
||||
else:
|
||||
raise RuntimeError('The installed magic version provides neither detect_from_filename nor from_file')
|
||||
raise RuntimeError(
|
||||
'The installed magic version provides neither detect_from_filename nor from_file'
|
||||
)
|
||||
|
||||
if mime:
|
||||
return mime.mime_type if hasattr(mime, 'mime_type') else mime
|
||||
|
@ -332,6 +351,7 @@ def grouper(n, iterable, fillvalue=None):
|
|||
grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx
|
||||
"""
|
||||
from itertools import zip_longest
|
||||
|
||||
args = [iter(iterable)] * n
|
||||
|
||||
if fillvalue:
|
||||
|
@ -355,6 +375,7 @@ def is_functional_cron(obj) -> bool:
|
|||
|
||||
def run(action, *args, **kwargs):
|
||||
from platypush.context import get_plugin
|
||||
|
||||
(module_name, method_name) = get_module_and_method_from_action(action)
|
||||
plugin = get_plugin(module_name)
|
||||
method = getattr(plugin, method_name)
|
||||
|
@ -366,7 +387,9 @@ def run(action, *args, **kwargs):
|
|||
return response.output
|
||||
|
||||
|
||||
def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> Tuple[str, str]:
|
||||
def generate_rsa_key_pair(
|
||||
key_file: Optional[str] = None, size: int = 2048
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Generate an RSA key pair.
|
||||
|
||||
|
@ -390,27 +413,30 @@ def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> T
|
|||
|
||||
public_exp = 65537
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=public_exp,
|
||||
key_size=size,
|
||||
backend=default_backend()
|
||||
public_exponent=public_exp, key_size=size, backend=default_backend()
|
||||
)
|
||||
|
||||
logger.info('Generating RSA {} key pair'.format(size))
|
||||
private_key_str = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
|
||||
public_key_str = private_key.public_key().public_bytes(
|
||||
public_key_str = (
|
||||
private_key.public_key()
|
||||
.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.PKCS1,
|
||||
).decode()
|
||||
)
|
||||
.decode()
|
||||
)
|
||||
|
||||
if key_file:
|
||||
logger.info('Saving private key to {}'.format(key_file))
|
||||
with open(os.path.expanduser(key_file), 'w') as f1, \
|
||||
open(os.path.expanduser(key_file) + '.pub', 'w') as f2:
|
||||
with open(os.path.expanduser(key_file), 'w') as f1, open(
|
||||
os.path.expanduser(key_file) + '.pub', 'w'
|
||||
) as f2:
|
||||
f1.write(private_key_str)
|
||||
f2.write(public_key_str)
|
||||
os.chmod(key_file, 0o600)
|
||||
|
@ -426,8 +452,7 @@ def get_or_generate_jwt_rsa_key_pair():
|
|||
pub_key_file = priv_key_file + '.pub'
|
||||
|
||||
if os.path.isfile(priv_key_file) and os.path.isfile(pub_key_file):
|
||||
with open(pub_key_file, 'r') as f1, \
|
||||
open(priv_key_file, 'r') as f2:
|
||||
with open(pub_key_file, 'r') as f1, open(priv_key_file, 'r') as f2:
|
||||
return f1.read(), f2.read()
|
||||
|
||||
pathlib.Path(key_dir).mkdir(parents=True, exist_ok=True, mode=0o755)
|
||||
|
@ -439,7 +464,7 @@ def get_enabled_plugins() -> dict:
|
|||
from platypush.context import get_plugin
|
||||
|
||||
plugins = {}
|
||||
for name, config in Config.get_plugins().items():
|
||||
for name in Config.get_plugins():
|
||||
try:
|
||||
plugin = get_plugin(name)
|
||||
if plugin:
|
||||
|
@ -453,11 +478,18 @@ def get_enabled_plugins() -> dict:
|
|||
|
||||
def get_redis() -> Redis:
|
||||
from platypush.config import Config
|
||||
return Redis(**(Config.get('backend.redis') or {}).get('redis_args', {}))
|
||||
|
||||
return Redis(
|
||||
**(
|
||||
(Config.get('backend.redis') or {}).get('redis_args', {})
|
||||
or Config.get('redis')
|
||||
or {}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def to_datetime(t: Union[str, int, float, datetime.datetime]) -> datetime.datetime:
|
||||
if isinstance(t, int) or isinstance(t, float):
|
||||
if isinstance(t, (int, float)):
|
||||
return datetime.datetime.fromtimestamp(t, tz=tz.tzutc())
|
||||
if isinstance(t, str):
|
||||
return parser.parse(t)
|
||||
|
|
|
@ -8,6 +8,7 @@ description-file = README.md
|
|||
|
||||
[flake8]
|
||||
max-line-length = 120
|
||||
ignore =
|
||||
SIM105
|
||||
extend-ignore =
|
||||
E203
|
||||
W503
|
||||
SIM105
|
||||
|
|
Loading…
Reference in a new issue