Compare commits

...

102 Commits

Author SHA1 Message Date
Fabio Manganiello 3513ee3e1c
Merge branch 'master' into 191-support-for-general-entities-backend-and-plugin 2022-07-08 23:13:36 +02:00
Fabio Manganiello 0d0995d71d
Merge branch 'master' into 191-support-for-general-entities-backend-and-plugin 2022-06-02 20:58:34 +02:00
Fabio Manganiello 2898a33752
s/click_url/url/g in ntfy message definitions 2022-06-02 00:36:14 +02:00
Fabio Manganiello 0919a0055d
Merge branch 'master' into 191-support-for-general-entities-backend-and-plugin 2022-06-02 00:13:43 +02:00
Fabio Manganiello 5b3e1317f4
Only refresh entities that are visible on the interface 2022-05-30 09:23:25 +02:00
Fabio Manganiello 1df71cb54a
Proper support for light entities on smartthings 2022-05-30 09:23:05 +02:00
Fabio Manganiello 0689e05e96
Apply the light color to the icon fill instead of the bulb icon itself 2022-05-30 09:18:19 +02:00
Fabio Manganiello 89560e7c38
Only include entities associated to enabled plugins or with no plugins in `entities.get` 2022-05-29 23:59:46 +02:00
Fabio Manganiello 30dfdeecb0
Merge branch 'master' into 191-support-for-general-entities-backend-and-plugin 2022-05-25 10:11:57 +02:00
Fabio Manganiello f57f940d57
Made _is_switch more resilient against rogue Z-Wave values 2022-05-01 22:18:46 +02:00
Fabio Manganiello 117f92e5b4
Deprecated the `light.hue` backend
The polling logic has been moved to the `light.hue` plugin itself
instead, so it's no longer required to have both a plugin and a backend
enabled in order to fully manage a Hue bridge.
2022-05-01 21:55:35 +02:00
Fabio Manganiello a5541c33b0
Added support for light entities in zigbee.mqtt
TODO: Support for colors (I don't have a color Zigbee bulb to test it on yet)
2022-05-01 21:10:54 +02:00
Fabio Manganiello b23f45f45e
Process a zigbee entity update event with all the properties, not only the ones that have been changed 2022-05-01 21:09:13 +02:00
Fabio Manganiello 088cf23958
Do not emit input event from the light component upon update
It may be an incomplete update that breaks the UI, and it will be
overwritten by the backend event anyway
2022-05-01 21:08:02 +02:00
Fabio Manganiello e8f4b7c10e
CSS adjustments 2022-05-01 15:44:57 +02:00
Fabio Manganiello dd12d57552
Added light UI entity component 2022-05-01 15:35:20 +02:00
Fabio Manganiello 5aa3750807
Re-sync the list of entities when the entities component is mounted 2022-05-01 15:34:45 +02:00
Fabio Manganiello f760d44224
Refactored/simplified UI code for entities management 2022-05-01 15:34:15 +02:00
Fabio Manganiello 8d91fec771
Better implementation for light.hue.set_lights 2022-05-01 15:33:12 +02:00
Fabio Manganiello c22c17a55d
More flexible implementation for LightPlugin abstract methods 2022-05-01 15:31:45 +02:00
Fabio Manganiello 46df3a6a98
FIX: `reachable` is an attribute of `state` 2022-05-01 01:58:05 +02:00
Fabio Manganiello 8e06b8c727
Fixed range scaling on Slider component 2022-04-30 23:40:14 +02:00
Fabio Manganiello 30a024befb
Manage hue/sat/bri/ct light ranges on the light entity object itself 2022-04-30 19:38:50 +02:00
Fabio Manganiello b16af0a97f
Include entity `data` attributes in the entity info modal 2022-04-30 16:39:37 +02:00
Fabio Manganiello c7970842d7
Disable logging by default for entity events (they can be quite spammy) 2022-04-30 02:13:20 +02:00
Fabio Manganiello 7df67aca82
updated_at should have utcnow() onupdate, not now() 2022-04-30 01:48:55 +02:00
Fabio Manganiello d29b377cf1
Exclude deleted lights/groups/scenes from the returned lists 2022-04-30 01:39:39 +02:00
Fabio Manganiello 8d57cf06c2
Major refactor for the `light.hue` plugin.
- Added support for lights as native platform entities.
- Improved performance by using the JSON API objects whenever possible
  to interact with the bridge instead of the native Python objects,
  which perform a bunch of lazy API calls under the hood resulting in
  degraded performance.
- Fixed lights animation attributes by setting only the ones actually
  supported by a light.
- Several LINT fixes.
2022-04-30 01:07:00 +02:00
Fabio Manganiello 975d37c562
Added relevant attributes to `light` entities 2022-04-29 23:29:04 +02:00
Fabio Manganiello 90f067de61
Added `reachable` flag to device entities 2022-04-29 23:27:35 +02:00
Fabio Manganiello f45df5d4d3
Explictly cast entity IDs to strings when coalescing entity updates
Some plugins may represent entity IDs as integers, while the database
maps external IDs to strings. This may result in entities being
incorrectly mapped during merging. Casting to string prevents these
type-related ambiguities.
2022-04-29 23:24:28 +02:00
Fabio Manganiello 975991ba69
Merge branch 'master' into 191-support-for-general-entities-backend-and-plugin 2022-04-29 16:53:41 +02:00
Fabio Manganiello d22fbcd9db
Merge branch 'master' into 191-support-for-general-entities-backend-and-plugin 2022-04-28 01:58:24 +02:00
Fabio Manganiello 47f8520f3b
Added support for description/read_only/write_only on entity level 2022-04-24 22:18:29 +02:00
Fabio Manganiello d261b9bb9b
Frontend support for entities deletion 2022-04-24 21:40:10 +02:00
Fabio Manganiello 9981cc4746
Backend support for entities deletion 2022-04-24 21:38:45 +02:00
Fabio Manganiello 3e4b13d20f
Added standard Vue component for confirm dialogs 2022-04-24 21:34:39 +02:00
Fabio Manganiello 321a61d06d
Align .section.right content to the right 2022-04-24 11:30:52 +02:00
Fabio Manganiello b22df768eb
Fixed entity icon alignment on mobile 2022-04-24 01:42:14 +02:00
Fabio Manganiello 8e2154f2b5
Do not overwrite an entity's state from an event if the state was not sampled 2022-04-24 01:41:45 +02:00
Fabio Manganiello a9751f21f1
`entities` should be the default view when the web panel is opened 2022-04-24 01:40:34 +02:00
Fabio Manganiello 135965176d
Support for entity icon color change 2022-04-23 17:52:21 +02:00
Fabio Manganiello ef6b57df31
Added entity info modal and (partial) support for renaming entities 2022-04-23 01:01:14 +02:00
Fabio Manganiello 7d4bd20df0
Support for individual entity group refresh 2022-04-19 23:56:49 +02:00
Fabio Manganiello e6bfa1c50f
Better dynamic entities discovery 2022-04-13 11:25:14 +02:00
Fabio Manganiello 332c91252c
zwave.mqtt.status renamed to controller_status, while status should return the current state of the values 2022-04-12 23:44:14 +02:00
Fabio Manganiello b35c761a43
Fixed entities panel mobile layout 2022-04-12 22:24:19 +02:00
Fabio Manganiello 08c0779347
<style> on entity components should be scoped 2022-04-12 16:00:31 +02:00
Fabio Manganiello 595ebe49ca
Support for entity scan timeout errors and visual error handling 2022-04-12 15:58:19 +02:00
Fabio Manganiello 548d487e73
Publish a switch entity from zigbee.mqtt only if the update includes its state 2022-04-12 14:41:21 +02:00
Fabio Manganiello 20530c2b6d
Loading events are now synchronized both ways upon entity action/refresh 2022-04-12 01:10:09 +02:00
Fabio Manganiello 9ddcf5eaeb
Implemented entities refresh on the UI 2022-04-12 00:43:22 +02:00
Fabio Manganiello 2aa8778078
Do not process EntityUpdateEvents only in case of payload changes
The UI relies on these events upon refresh to detect if a device is
still reacheable. Therefore, we shouldn't mask them if we don't detect
any changes with the current entity configuration/state.
2022-04-12 00:41:20 +02:00
Fabio Manganiello 72617b4b75
Handle EntityUpdateEvents on the UI 2022-04-11 23:16:29 +02:00
Fabio Manganiello be4d1e8e01
Proper support for native entities in zigbee.mqtt integration 2022-04-11 21:16:45 +02:00
Fabio Manganiello db4ad5825e
Fire an EntityUpdateEvent when the zwave.mqtt backend gets a value changed message 2022-04-11 01:40:49 +02:00
Fabio Manganiello 4471001110
smartthings.toggle should properly publish the updated entity 2022-04-11 00:43:31 +02:00
Fabio Manganiello f17245e8c7
Send an EntityUpdateEvent only if an entity has already been persisted
If an event comes from an entity that hasn't been persisted yet on the
internal storage then we wait for the entity record to be committed
before firing an event. It's better to wait a couple of seconds for the
database to synchronize rather than dealing with entity events with
incomplete objects.
2022-04-11 00:38:11 +02:00
Fabio Manganiello 67ff585f6c
Entities engine improvements
- Added cache support to prevent duplicate EntityUpdateEvents
- The cache is smartly pre-populated and kept up-to-date, so it's
  possible to trigger events as soon as the entities are published by
  the plugin (not only when the records are flushed to the internal db)
2022-04-11 00:01:21 +02:00
Fabio Manganiello 17615ff028
Support for multiple entity types/plugins filter on entities.get 2022-04-10 21:23:03 +02:00
Fabio Manganiello 532217be12
Support for filtering entities by search string 2022-04-10 17:57:51 +02:00
Fabio Manganiello f301fd7e69
Added standard NoItems component to handle visualization of no-results divs 2022-04-10 14:27:32 +02:00
Fabio Manganiello 58861afb1c
Added entities panel 2022-04-10 13:07:36 +02:00
Fabio Manganiello 8ec9c8f203
Added standard component for icons 2022-04-10 13:07:01 +02:00
Fabio Manganiello 3435f591eb
Support for keep-open-on-item-click and icon URLs on dropdown elements 2022-04-10 01:57:39 +02:00
Fabio Manganiello 19223bbbe1
Added SmartThings icon 2022-04-10 01:56:47 +02:00
Fabio Manganiello 453652ef76
Updated plugin icons 2022-04-10 01:50:45 +02:00
Fabio Manganiello b2ff66aa62
Added mixins to capitalize/prettify text 2022-04-10 01:50:13 +02:00
Fabio Manganiello 655d56f4da
Upgraded font-awesome to 6.x 2022-04-10 01:49:14 +02:00
Fabio Manganiello f52b556219
- icon_class should not be part of the backend model
- Interaction with entities should occur through the `entities.action`
  method, not by implementing native methods on each of the model
  objects
2022-04-08 16:49:47 +02:00
Fabio Manganiello 947b50b937
Added meta as a JSON field on the Entity table
Metadata attributes can now be defined and overridden on the object
itself, as well as on the database. Note that db settings will always
take priority in case of value conflicts.
2022-04-07 22:11:31 +02:00
Fabio Manganiello db7c2095ea
Implemented meta property for entities (for now it only include `icon_class`) 2022-04-07 18:09:25 +02:00
Fabio Manganiello e40b668380
Added missing docs 2022-04-07 01:49:13 +02:00
Fabio Manganiello d3dc86a5e2
Added documentation for plugin/entity type registry 2022-04-07 01:47:42 +02:00
Fabio Manganiello 28026b0428
Trigger an EntityUpdateEvent when an entity state changes 2022-04-07 01:46:37 +02:00
Fabio Manganiello 44707731a8
Normalize UTC timezone on all the entity timestamps 2022-04-07 01:13:29 +02:00
Fabio Manganiello 948f37afd4
Filter by configured/enabled plugins when returning the entity/plugin registry 2022-04-07 01:04:06 +02:00
Fabio Manganiello 3b4f7d3dad
Added entities plugin to query/action entities 2022-04-07 00:22:54 +02:00
Fabio Manganiello 2eeb1d4fea
Entity objects are now JSON-able 2022-04-07 00:21:54 +02:00
Fabio Manganiello 26ffc0b0e1
Use Redis instead of an in-process map to store the entity/plugin registry
This is particularly useful when we want to access the registry from
another process, like the web server or an external script.
2022-04-07 00:18:11 +02:00
Fabio Manganiello 7b1a63e287
Make sure that flake8 and black don't step on each other's toes 2022-04-07 00:17:39 +02:00
Fabio Manganiello 1c6ff2fa49
(actually, the other way around is better) 2022-04-06 23:56:10 +02:00
Fabio Manganiello d311629403
black validation should run before flake8 2022-04-06 23:48:27 +02:00
Fabio Manganiello d52ae2fb80
Implemented RunnablePlugin.wait_stop() utility method 2022-04-05 23:33:02 +02:00
Fabio Manganiello 061268cdaf
Support for direct actions on native entities [WIP] 2022-04-05 23:22:54 +02:00
Fabio Manganiello 91ff47167b
Don't terminate the entities engine thread if a batch of entity records fails 2022-04-05 23:04:57 +02:00
Fabio Manganiello fe0f3202fe
columns should be a property of the Entity object 2022-04-05 23:04:19 +02:00
Fabio Manganiello 8a70f1d38e
Replaced deprecated sqlalchemy.ext.declarative with sqlalchemy.orm 2022-04-05 22:47:44 +02:00
Fabio Manganiello 4b7eeaa4ed
Smarter merging of entities with the same key before they are committed 2022-04-05 21:17:58 +02:00
Fabio Manganiello b43ed169c7
Added support for switches as native entities to zwave.mqtt plugin 2022-04-05 20:22:47 +02:00
Fabio Manganiello 0dac2c0e92
Fixed handling of possible null device definition in zigbee.mqtt 2022-04-05 00:31:04 +02:00
Fabio Manganiello 28b3672432
Added native support for switch entities to the zigbee.mqtt plugin. 2022-04-05 00:07:55 +02:00
Fabio Manganiello 9f2793118b
black fix 2022-04-04 22:43:04 +02:00
Fabio Manganiello 9d9ec1dc59
Added native support for switch entities to the smartthings plugin 2022-04-04 22:41:04 +02:00
Fabio Manganiello b9c78ad913
Added native support for switch entities to switchbot.bluetooth plugin 2022-04-04 21:12:59 +02:00
Fabio Manganiello 91ff8d811f
Added native entities support in switchbot plugin 2022-04-04 20:56:28 +02:00
Fabio Manganiello 783238642d
Skip string and underscore normalization in black 2022-04-04 20:56:28 +02:00
Fabio Manganiello 53da19b638
Added entities engine support to WeMo switch plugin 2022-04-04 17:22:55 +02:00
Fabio Manganiello 7459f0115b
Added more pre-commit hooks 2022-04-04 17:22:54 +02:00
Fabio Manganiello 2c4c27855d
Added `.exception` action to logger plugin 2022-04-04 17:22:54 +02:00
Fabio Manganiello 9c25a131fa
get_bus() should return a default RedisBus() instance if the main bus is not registered 2022-04-04 17:22:54 +02:00
Fabio Manganiello 4ee7e4db29
Basic support for entities on the local db and implemented support for switch entities on the tplink plugin 2022-04-04 16:50:17 +02:00
77 changed files with 6127 additions and 1478 deletions

View File

@ -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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@
"name": "platypush",
"version": "0.1.0",
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.4",
"@fortawesome/fontawesome-free": "^6.1.1",
"axios": "^0.21.4",
"core-js": "^3.21.1",
"lato-font": "^3.0.0",
@ -1731,9 +1731,9 @@
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
"integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==",
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz",
"integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
@ -12917,9 +12917,9 @@
}
},
"@fortawesome/fontawesome-free": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
"integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg=="
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz",
"integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg=="
},
"@humanwhocodes/config-array": {
"version": "0.5.0",

View File

@ -8,7 +8,7 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.4",
"@fortawesome/fontawesome-free": "^6.1.1",
"axios": "^0.21.4",
"core-js": "^3.21.1",
"lato-font": "^3.0.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -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>

View File

@ -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"
},

View File

@ -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>

View File

@ -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" /> &nbsp; {{ confirmText }}
</button>
<button type="button" class="cancel-btn" @click="close" @touch="close">
<i class="fas fa-xmark" /> &nbsp; {{ cancelText }}
</button>
</form>
</Modal>
</template>
<script>
import Modal from "@/components/Modal";
export default {
emits: ['input', 'click', 'touch'],
components: {Modal},
props: {
title: {
type: String,
},
confirmText: {
type: String,
default: "OK",
},
cancelText: {
type: String,
default: "Cancel",
},
},
methods: {
onConfirm() {
this.$emit('input')
this.close()
},
show() {
this.$refs.modal.show()
},
close() {
this.$refs.modal.hide()
},
},
}
</script>
<style lang="scss" scoped>
:deep(.modal) {
.dialog-content {
padding: 1em;
}
.buttons {
display: flex;
flex-direction: row;
justify-content: right;
padding: 1em 0 1em 1em;
border: 0;
border-radius: 0;
box-shadow: 0 -1px 2px 0 $default-shadow-color;
button {
margin-right: 1em;
padding: 0.5em 1em;
border: 1px solid $border-color-2;
border-radius: 1em;
&:hover {
background: $hover-bg;
}
}
}
}
</style>

View File

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

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}%`

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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"
}
}
}

View File

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

View File

@ -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('')
}
}

View File

@ -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
}

View File

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

View File

@ -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;
}
}
}

View File

@ -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;

View 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>

View File

@ -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;
}

View File

@ -1,100 +1,24 @@
from threading import Thread
import warnings
from platypush.backend import Backend
from platypush.context import get_plugin
from platypush.message.event.light import LightStatusChangeEvent
class LightHueBackend(Backend):
"""
This backend will periodically check for the status of your configured
Philips Hue light devices and trigger events when the status of a device
(power, saturation, brightness or hue) changes.
**DEPRECATED**
Triggers:
* :class:`platypush.message.event.light.LightStatusChangeEvent` when the
status of a lightbulb changes
Requires:
* The :class:`platypush.plugins.light.hue.LightHuePlugin` plugin to be
active and configured.
The polling logic of this backend has been moved to the ``light.hue`` plugin itself.
"""
_DEFAULT_POLL_SECONDS = 10
def __init__(self, poll_seconds=_DEFAULT_POLL_SECONDS, *args, **kwargs):
"""
:param poll_seconds: How often the backend will poll the Hue plugin for
status updates. Default: 10 seconds
:type poll_seconds: float
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.poll_seconds = poll_seconds
@staticmethod
def _get_lights():
plugin = get_plugin('light.hue')
if not plugin:
plugin = get_plugin('light.hue', reload=True)
return plugin.get_lights().output
def _listener(self):
def _thread():
lights = self._get_lights()
while not self.should_stop():
try:
lights_new = self._get_lights()
for light_id, light in lights_new.items():
event_args = {}
state = light.get('state')
prev_state = lights.get(light_id, {}).get('state', {})
if 'on' in state and state.get('on') != prev_state.get('on'):
event_args['on'] = state.get('on')
if 'bri' in state and state.get('bri') != prev_state.get('bri'):
event_args['bri'] = state.get('bri')
if 'sat' in state and state.get('sat') != prev_state.get('sat'):
event_args['sat'] = state.get('sat')
if 'hue' in state and state.get('hue') != prev_state.get('hue'):
event_args['hue'] = state.get('hue')
if 'ct' in state and state.get('ct') != prev_state.get('ct'):
event_args['ct'] = state.get('ct')
if 'xy' in state and state.get('xy') != prev_state.get('xy'):
event_args['xy'] = state.get('xy')
if event_args:
event_args['plugin_name'] = 'light.hue'
event_args['light_id'] = light_id
event_args['light_name'] = light.get('name')
self.bus.post(LightStatusChangeEvent(**event_args))
lights = lights_new
except Exception as e:
self.logger.exception(e)
finally:
self.wait_stop(self.poll_seconds)
return _thread
warnings.warn(
'The light.hue backend is deprecated. All of its logic '
'has been moved to the light.hue plugin itself.'
)
def run(self):
super().run()
self.logger.info('Starting Hue lights backend')
while not self.should_stop():
try:
poll_thread = Thread(target=self._listener())
poll_thread.start()
poll_thread.join()
except Exception as e:
self.logger.exception(e)
self.wait_stop(self.poll_seconds)
self.logger.info('Stopped Hue lights backend')

View File

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

View File

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

View File

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

View File

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

View 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
View 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)

View 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)

View 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

View 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__,
}

View 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__,
}

View 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__,
}

View 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:

View File

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

View File

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

View File

@ -1,11 +1,11 @@
"""
.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com>
"""
import time
from contextlib import contextmanager
from multiprocessing import RLock
from typing import Generator
from sqlalchemy import create_engine, Table, MetaData
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker, scoped_session
from platypush.plugins import Plugin, action
@ -30,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:

View 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:

View File

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

View File

@ -27,11 +27,11 @@ class GpioPlugin(RunnablePlugin):
"""
def __init__(
self,
pins: Optional[Dict[str, int]] = None,
monitored_pins: Optional[Collection[Union[str, int]]] = None,
mode: str = 'board',
**kwargs
self,
pins: Optional[Dict[str, int]] = None,
monitored_pins: Optional[Collection[Union[str, int]]] = None,
mode: str = 'board',
**kwargs
):
"""
:param mode: Specify ``board`` if you want to use the board PIN numbers,
@ -64,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.

View File

@ -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()

View File

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

View File

@ -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

View File

@ -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:

View File

@ -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 {}),

View File

@ -2,13 +2,17 @@ import asyncio
import aiohttp
from threading import RLock
from typing import Optional, Dict, List, Union
from typing import Optional, Dict, List, Union, Iterable
from platypush.entities import manages
from platypush.entities.lights import Light
from platypush.entities.switches import Switch
from platypush.plugins import action
from platypush.plugins.switch import SwitchPlugin
from platypush.plugins.switch import Plugin
class SmartthingsPlugin(SwitchPlugin):
@manages(Switch, Light)
class SmartthingsPlugin(Plugin):
"""
Plugin to interact with devices and locations registered to a Samsung SmartThings account.
@ -18,7 +22,7 @@ class SmartthingsPlugin(SwitchPlugin):
"""
_timeout = aiohttp.ClientTimeout(total=20.)
_timeout = aiohttp.ClientTimeout(total=20.0)
def __init__(self, access_token: str, **kwargs):
"""
@ -43,46 +47,27 @@ class SmartthingsPlugin(SwitchPlugin):
async def _refresh_locations(self, api):
self._locations = await api.locations()
self._locations_by_id = {
loc.location_id: loc
for loc in self._locations
}
self._locations_by_name = {
loc.name: loc
for loc in self._locations
}
self._locations_by_id = {loc.location_id: loc for loc in self._locations}
self._locations_by_name = {loc.name: loc for loc in self._locations}
async def _refresh_devices(self, api):
self._devices = await api.devices()
self._devices_by_id = {
dev.device_id: dev
for dev in self._devices
}
self._devices_by_name = {
dev.label: dev
for dev in self._devices
}
self._devices_by_id = {dev.device_id: dev for dev in self._devices}
self._devices_by_name = {dev.label: dev for dev in self._devices}
async def _refresh_rooms(self, api, location_id: str):
self._rooms_by_location[location_id] = await api.rooms(location_id=location_id)
self._rooms_by_id.update(**{
room.room_id: room
for room in self._rooms_by_location[location_id]
})
self._rooms_by_id.update(
**{room.room_id: room for room in self._rooms_by_location[location_id]}
)
self._rooms_by_location_and_id[location_id] = {
room.room_id: room
for room in self._rooms_by_location[location_id]
room.room_id: room for room in self._rooms_by_location[location_id]
}
self._rooms_by_location_and_name[location_id] = {
room.name: room
for room in self._rooms_by_location[location_id]
room.name: room for room in self._rooms_by_location[location_id]
}
async def _refresh_info(self):
@ -127,7 +112,7 @@ class SmartthingsPlugin(SwitchPlugin):
'rooms': {
room.room_id: self._room_to_dict(room)
for room in self._rooms_by_location.get(location.location_id, {})
}
},
}
@staticmethod
@ -257,12 +242,18 @@ class SmartthingsPlugin(SwitchPlugin):
"""
self.refresh_info()
return {
'locations': {loc.location_id: self._location_to_dict(loc) for loc in self._locations},
'devices': {dev.device_id: self._device_to_dict(dev) for dev in self._devices},
'locations': {
loc.location_id: self._location_to_dict(loc) for loc in self._locations
},
'devices': {
dev.device_id: self._device_to_dict(dev) for dev in self._devices
},
}
@action
def get_location(self, location_id: Optional[str] = None, name: Optional[str] = None) -> dict:
def get_location(
self, location_id: Optional[str] = None, name: Optional[str] = None
) -> dict:
"""
Get the info of a location by ID or name.
@ -296,20 +287,37 @@ class SmartthingsPlugin(SwitchPlugin):
"""
assert location_id or name, 'Specify either location_id or name'
if location_id not in self._locations_by_id or name not in self._locations_by_name:
if (
location_id not in self._locations_by_id
or name not in self._locations_by_name
):
self.refresh_info()
location = self._locations_by_id.get(location_id, self._locations_by_name.get(name))
location = self._locations_by_id.get(
location_id, self._locations_by_name.get(name)
)
assert location, 'Location {} not found'.format(location_id or name)
return self._location_to_dict(location)
def _get_device(self, device: str):
if device not in self._devices_by_id or device not in self._devices_by_name:
return self._get_devices(device)[0]
def _get_devices(self, *devices: str):
def get_found_and_missing_devs():
found_devs = [
self._devices_by_id.get(d, self._devices_by_name.get(d))
for d in devices
]
missing_devs = [d for i, d in enumerate(devices) if not found_devs[i]]
return found_devs, missing_devs
devs, missing_devs = get_found_and_missing_devs()
if missing_devs:
self.refresh_info()
device = self._devices_by_id.get(device, self._devices_by_name.get(device))
assert device, 'Device {} not found'.format(device)
return device
devs, missing_devs = get_found_and_missing_devs()
assert not missing_devs, f'Devices not found: {missing_devs}'
return devs
@action
def get_device(self, device: str) -> dict:
@ -340,24 +348,41 @@ class SmartthingsPlugin(SwitchPlugin):
device = self._get_device(device)
return self._device_to_dict(device)
async def _execute(self, device_id: str, capability: str, command, component_id: str, args: Optional[list]):
async def _execute(
self,
device_id: str,
capability: str,
command,
component_id: str,
args: Optional[list],
):
import pysmartthings
async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = pysmartthings.SmartThings(session, self._access_token)
device = await api.device(device_id)
ret = await device.command(component_id=component_id, capability=capability, command=command, args=args)
ret = await device.command(
component_id=component_id,
capability=capability,
command=command,
args=args,
)
assert ret, 'The command {capability}={command} failed on device {device}'.format(
capability=capability, command=command, device=device_id)
assert (
ret
), 'The command {capability}={command} failed on device {device}'.format(
capability=capability, command=command, device=device_id
)
@action
def execute(self,
device: str,
capability: str,
command,
component_id: str = 'main',
args: Optional[list] = None):
def execute(
self,
device: str,
capability: str,
command,
component_id: str = 'main',
args: Optional[list] = None,
):
"""
Execute a command on a device.
@ -388,16 +413,89 @@ class SmartthingsPlugin(SwitchPlugin):
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
loop.run_until_complete(self._execute(
device_id=device.device_id, capability=capability, command=command,
component_id=component_id, args=args))
loop.run_until_complete(
self._execute(
device_id=device.device_id,
capability=capability,
command=command,
component_id=component_id,
args=args,
)
)
finally:
loop.stop()
@staticmethod
async def _get_device_status(api, device_id: str) -> dict:
def _is_light(device):
if isinstance(device, dict):
capabilities = device.get('capabilities', [])
else:
capabilities = device.capabilities
return 'colorControl' in capabilities or 'colorTemperature' in capabilities
def transform_entities(self, entities):
from platypush.entities.switches import Switch
compatible_entities = []
for device in entities:
data = {
'location_id': getattr(device, 'location_id', None),
'room_id': getattr(device, 'room_id', None),
}
if self._is_light(device):
light_attrs = {
'id': device.device_id,
'name': device.label,
'data': data,
}
if 'switch' in device.capabilities:
light_attrs['on'] = device.status.switch
if getattr(device.status, 'level', None) is not None:
light_attrs['brightness'] = device.status.level
light_attrs['brightness_min'] = 0
light_attrs['brightness_max'] = 100
if 'colorTemperature' in device.capabilities:
# Color temperature range on SmartThings is expressed in Kelvin
light_attrs['temperature_min'] = 2000
light_attrs['temperature_max'] = 6500
if (
device.status.color_temperature
>= light_attrs['temperature_min']
):
light_attrs['temperature'] = (
light_attrs['temperature_max']
- light_attrs['temperature_min']
) / 2
if getattr(device.status, 'hue', None) is not None:
light_attrs['hue'] = device.status.hue
light_attrs['hue_min'] = 0
light_attrs['hue_max'] = 100
if getattr(device.status, 'saturation', None) is not None:
light_attrs['saturation'] = device.status.saturation
light_attrs['saturation_min'] = 0
light_attrs['saturation_max'] = 80
compatible_entities.append(Light(**light_attrs))
elif 'switch' in device.capabilities:
compatible_entities.append(
Switch(
id=device.device_id,
name=device.label,
state=device.status.switch,
data=data,
)
)
return super().transform_entities(compatible_entities) # type: ignore
async def _get_device_status(self, api, device_id: str) -> dict:
device = await api.device(device_id)
await device.status.refresh()
self.publish_entities([device]) # type: ignore
return {
'device_id': device_id,
@ -407,7 +505,7 @@ class SmartthingsPlugin(SwitchPlugin):
for cap in device.capabilities
if hasattr(device.status, cap)
and not callable(getattr(device.status, cap))
}
},
}
async def _refresh_status(self, devices: List[str]) -> List[dict]:
@ -434,7 +532,9 @@ class SmartthingsPlugin(SwitchPlugin):
parse_device_id(dev)
# Fail if some devices haven't been found after refreshing
assert not missing_device_ids, 'Could not find the following devices: {}'.format(list(missing_device_ids))
assert (
not missing_device_ids
), 'Could not find the following devices: {}'.format(list(missing_device_ids))
async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = pysmartthings.SmartThings(session, self._access_token)
@ -489,7 +589,7 @@ class SmartthingsPlugin(SwitchPlugin):
loop.stop()
@action
def on(self, device: str, *args, **kwargs) -> dict:
def on(self, device: str, *_, **__) -> dict:
"""
Turn on a device with ``switch`` capability.
@ -497,11 +597,10 @@ class SmartthingsPlugin(SwitchPlugin):
:return: Device status
"""
self.execute(device, 'switch', 'on')
# noinspection PyUnresolvedReferences
return self.status(device).output[0]
return self.status(device).output[0] # type: ignore
@action
def off(self, device: str, *args, **kwargs) -> dict:
def off(self, device: str, *_, **__) -> dict:
"""
Turn off a device with ``switch`` capability.
@ -509,11 +608,10 @@ class SmartthingsPlugin(SwitchPlugin):
:return: Device status
"""
self.execute(device, 'switch', 'off')
# noinspection PyUnresolvedReferences
return self.status(device).output[0]
return self.status(device).output[0] # type: ignore
@action
def toggle(self, device: str, *args, **kwargs) -> dict:
def toggle(self, device: str, *args, **__) -> dict:
"""
Toggle a device with ``switch`` capability.
@ -529,22 +627,28 @@ class SmartthingsPlugin(SwitchPlugin):
async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = pysmartthings.SmartThings(session, self._access_token)
dev = await api.device(device_id)
assert 'switch' in dev.capabilities, 'The device {} has no switch capability'.format(dev.label)
assert (
'switch' in dev.capabilities
), 'The device {} has no switch capability'.format(dev.label)
await dev.status.refresh()
state = 'off' if dev.status.switch else 'on'
ret = await dev.command(component_id='main', capability='switch', command=state, args=args)
ret = await dev.command(
component_id='main', capability='switch', command=state, args=args
)
assert ret, 'The command switch={state} failed on device {device}'.format(state=state, device=dev.label)
return not dev.status.switch
assert ret, 'The command switch={state} failed on device {device}'.format(
state=state, device=dev.label
)
with self._refresh_lock:
loop = asyncio.new_event_loop()
state = loop.run_until_complete(_toggle())
loop.run_until_complete(_toggle())
device = loop.run_until_complete(self._refresh_status([device_id]))[0] # type: ignore
return {
'id': device_id,
'name': device.label,
'on': state,
'name': device['name'],
'on': device['switch'],
}
@property
@ -569,8 +673,7 @@ class SmartthingsPlugin(SwitchPlugin):
]
"""
# noinspection PyUnresolvedReferences
devices = self.status().output
devices = self.status().output # type: ignore
return [
{
'name': device['name'],
@ -578,8 +681,65 @@ class SmartthingsPlugin(SwitchPlugin):
'on': device['switch'],
}
for device in devices
if 'switch' in device
if 'switch' in device and not self._is_light(device)
]
@action
def set_level(self, device: str, level: int, **kwargs):
"""
Set the level of a device with ``switchLevel`` capabilities (e.g. the
brightness of a lightbulb or the speed of a fan).
:param device: Device ID or name.
:param level: Level, usually a percentage value between 0 and 1.
:param kwarsg: Extra arguments that should be passed to :meth:`.execute`.
"""
self.execute(device, 'switchLevel', 'setLevel', args=[int(level)], **kwargs)
@action
def set_lights(
self,
lights: Iterable[str],
on: Optional[bool] = None,
brightness: Optional[int] = None,
hue: Optional[int] = None,
saturation: Optional[int] = None,
hex: Optional[str] = None,
temperature: Optional[int] = None,
**_,
):
err = None
with self._execute_lock:
for light in lights:
try:
if on is not None:
self.execute(light, 'switch', 'on' if on else 'off')
if brightness is not None:
self.execute(
light, 'switchLevel', 'setLevel', args=[brightness]
)
if hue is not None:
self.execute(light, 'colorControl', 'setHue', args=[hue])
if saturation is not None:
self.execute(
light, 'colorControl', 'setSaturation', args=[saturation]
)
if temperature is not None:
self.execute(
light,
'colorTemperature',
'setColorTemperature',
args=[temperature],
)
if hex is not None:
self.execute(light, 'colorControl', 'setColor', args=[hex])
except Exception as e:
self.logger.error('Could not set attributes on %s: %s', light, e)
err = e
if err:
raise err
# vim:sw=4:ts=4:et:

View File

@ -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`).

View File

@ -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:

View File

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

View File

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

View File

@ -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:

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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