From 4ee7e4db296b9397f859559017c7e3f3aab25021 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 4 Apr 2022 16:50:17 +0200 Subject: [PATCH 01/96] Basic support for entities on the local db and implemented support for switch entities on the tplink plugin --- platypush/__init__.py | 21 ++- platypush/entities/__init__.py | 36 +++++ platypush/entities/_base.py | 73 +++++++++ platypush/entities/_engine.py | 110 +++++++++++++ platypush/entities/_registry.py | 62 +++++++ platypush/entities/devices.py | 14 ++ platypush/entities/lights.py | 14 ++ platypush/entities/switches.py | 15 ++ platypush/plugins/db/__init__.py | 50 ++++-- platypush/plugins/switch/__init__.py | 5 +- platypush/plugins/switch/tplink/__init__.py | 70 +++++--- platypush/user/__init__.py | 170 ++++++++++---------- 12 files changed, 506 insertions(+), 134 deletions(-) create mode 100644 platypush/entities/__init__.py create mode 100644 platypush/entities/_base.py create mode 100644 platypush/entities/_engine.py create mode 100644 platypush/entities/_registry.py create mode 100644 platypush/entities/devices.py create mode 100644 platypush/entities/lights.py create mode 100644 platypush/entities/switches.py diff --git a/platypush/__init__.py b/platypush/__init__.py index 4c1d2cf18..e9f6640c0 100644 --- a/platypush/__init__.py +++ b/platypush/__init__.py @@ -9,11 +9,13 @@ import argparse import logging import os import sys +from typing import Optional from .bus.redis import RedisBus from .config import Config from .context import register_backends, register_plugins from .cron.scheduler import CronScheduler +from .entities import init_entities_engine, EntitiesEngine from .event.processor import EventProcessor from .logger import Logger from .message.event import Event @@ -86,6 +88,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 @@ -161,16 +164,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 """ @@ -192,6 +204,9 @@ class Daemon: # Initialize the plugins register_plugins(bus=self.bus) + # Initialize the entities engine + self.entities_engine = init_entities_engine() + # Start the cron scheduler if Config.get_cronjobs(): self.cron_scheduler = CronScheduler(jobs=Config.get_cronjobs()) diff --git a/platypush/entities/__init__.py b/platypush/entities/__init__.py new file mode 100644 index 000000000..f59ab240b --- /dev/null +++ b/platypush/entities/__init__.py @@ -0,0 +1,36 @@ +import warnings +from typing import Collection, Optional + +from ._base import Entity +from ._engine import EntitiesEngine +from ._registry import manages, register_entity_plugin, get_plugin_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_registry', + 'manages', +) + diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py new file mode 100644 index 000000000..80be23b83 --- /dev/null +++ b/platypush/entities/_base.py @@ -0,0 +1,73 @@ +import inspect +import pathlib +from datetime import datetime +from typing import Mapping, Type + +import pkgutil +from sqlalchemy import Column, Index, Integer, String, DateTime, JSON, UniqueConstraint +from sqlalchemy.ext.declarative import declarative_base + +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) + type = Column(String, nullable=False, index=True) + plugin = Column(String, nullable=False) + data = Column(JSON, default=dict) + created_at = Column(DateTime(timezone=False), default=datetime.utcnow(), nullable=False) + updated_at = Column(DateTime(timezone=False), default=datetime.utcnow(), onupdate=datetime.now()) + + UniqueConstraint(external_id, plugin) + + __table_args__ = ( + Index(name, plugin), + ) + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + 'polymorphic_on': type, + } + + +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 init_entities_db(): + from platypush.context import get_plugin + _discover_entity_types() + db = get_plugin('db') + assert db + engine = db.get_engine() + db.create_all(engine, Base) + diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py new file mode 100644 index 000000000..f99a4725c --- /dev/null +++ b/platypush/entities/_engine.py @@ -0,0 +1,110 @@ +from logging import getLogger +from queue import Queue, Empty +from threading import Thread, Event +from time import time +from typing import Iterable, List + +from sqlalchemy import and_, or_, inspect as schema_inspect +from sqlalchemy.orm import Session +from sqlalchemy.sql.elements import Null + +from ._base import Entity + + +class EntitiesEngine(Thread): + # Processing queue timeout in seconds + _queue_timeout = 5. + + 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() + + 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') + + 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) + + if not msgs or self.should_stop: + continue + + self._process_entities(*msgs) + + self.logger.info('Stopped entities engine') + + def _get_if_exist(self, session: Session, entities: Iterable[Entity]) -> Iterable[Entity]: + existing_entities = { + (entity.external_id or 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.plugin == entity.plugin) + for entity in entities + ]) + ).all() + } + + return [ + existing_entities.get( + (entity.external_id or entity.name, entity.plugin), None + ) for entity in entities + ] + + def _merge_entities( + self, entities: List[Entity], + existing_entities: List[Entity] + ) -> List[Entity]: + new_entities = [] + + for i, entity in enumerate(entities): + existing_entity = existing_entities[i] + if existing_entity: + inspector = schema_inspect(entity.__class__) + columns = [col.key for col in inspector.mapper.column_attrs] + for col in columns: + new_value = getattr(entity, col) + if new_value is not None and new_value.__class__ != Null: + setattr(existing_entity, col, getattr(entity, col)) + + new_entities.append(existing_entity) + else: + new_entities.append(entity) + + return new_entities + + def _process_entities(self, *entities: Entity): + from platypush.context import get_plugin + + with get_plugin('db').get_session() as session: # 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() + diff --git a/platypush/entities/_registry.py b/platypush/entities/_registry.py new file mode 100644 index 000000000..b8644808f --- /dev/null +++ b/platypush/entities/_registry.py @@ -0,0 +1,62 @@ +from datetime import datetime +from typing import Optional, Mapping, Dict, Collection, Type + +from platypush.plugins import Plugin +from platypush.utils import get_plugin_name_by_class + +from ._base import Entity + +_entity_plugin_registry: Mapping[Type[Entity], Dict[str, Plugin]] = {} + + +def register_entity_plugin(entity_type: Type[Entity], plugin: Plugin): + plugins = _entity_plugin_registry.get(entity_type, {}) + plugin_name = get_plugin_name_by_class(plugin.__class__) + assert plugin_name + plugins[plugin_name] = plugin + _entity_plugin_registry[entity_type] = plugins + + +def get_plugin_registry(): + return _entity_plugin_registry.copy() + + +class EntityManagerMixin: + def transform_entities(self, entities): + 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]]): + from . import publish_entities + entities = self.transform_entities(entities) + publish_entities(entities) + + +def manages(*entities: Type[Entity]): + def wrapper(plugin: Type[Plugin]): + init = plugin.__init__ + + def __init__(self, *args, **kwargs): + for entity_type in entities: + register_entity_plugin(entity_type, self) + + init(self, *args, **kwargs) + + plugin.__init__ = __init__ + # Inject the EntityManagerMixin + if EntityManagerMixin not in plugin.__bases__: + plugin.__bases__ = (EntityManagerMixin,) + plugin.__bases__ + + return plugin + + return wrapper + diff --git a/platypush/entities/devices.py b/platypush/entities/devices.py new file mode 100644 index 000000000..dfc64f01f --- /dev/null +++ b/platypush/entities/devices.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, ForeignKey + +from ._base import Entity + + +class Device(Entity): + __tablename__ = 'device' + + id = Column(Integer, ForeignKey(Entity.id), primary_key=True) + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + } + diff --git a/platypush/entities/lights.py b/platypush/entities/lights.py new file mode 100644 index 000000000..95f303f90 --- /dev/null +++ b/platypush/entities/lights.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, ForeignKey + +from .devices import Device + + +class Light(Device): + __tablename__ = 'light' + + id = Column(Integer, ForeignKey(Device.id), primary_key=True) + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + } + diff --git a/platypush/entities/switches.py b/platypush/entities/switches.py new file mode 100644 index 000000000..4af4ba189 --- /dev/null +++ b/platypush/entities/switches.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, ForeignKey, Boolean + +from .devices import Device + + +class Switch(Device): + __tablename__ = 'switch' + + id = Column(Integer, ForeignKey(Device.id), primary_key=True) + state = Column(Boolean) + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + } + diff --git a/platypush/plugins/db/__init__.py b/platypush/plugins/db/__init__.py index f4594f089..ab901b2e8 100644 --- a/platypush/plugins/db/__init__.py +++ b/platypush/plugins/db/__init__.py @@ -1,11 +1,11 @@ -""" -.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com> -""" - import time +from contextlib import contextmanager +from multiprocessing import RLock +from typing import Generator from sqlalchemy import create_engine, Table, MetaData from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker, scoped_session from platypush.plugins import Plugin, action @@ -30,22 +30,23 @@ class DbPlugin(Plugin): """ super().__init__() - self.engine = self._get_engine(engine, *args, **kwargs) + self.engine = self.get_engine(engine, *args, **kwargs) + self._session_locks = {} - def _get_engine(self, engine=None, *args, **kwargs): + def get_engine(self, engine=None, *args, **kwargs) -> Engine: if engine: if isinstance(engine, Engine): return engine if engine.startswith('sqlite://'): kwargs['connect_args'] = {'check_same_thread': False} - return create_engine(engine, *args, **kwargs) + return create_engine(engine, *args, **kwargs) # type: ignore + assert self.engine return self.engine - # noinspection PyUnusedLocal @staticmethod - def _build_condition(table, column, value): + def _build_condition(_, column, value): if isinstance(value, str): value = "'{}'".format(value) elif not isinstance(value, int) and not isinstance(value, float): @@ -73,14 +74,14 @@ class DbPlugin(Plugin): :param kwargs: Extra kwargs that will be passed to ``sqlalchemy.create_engine`` (seehttps:///docs.sqlalchemy.org/en/latest/core/engines.html) """ - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) with engine.connect() as connection: connection.execute(statement) def _get_table(self, table, engine=None, *args, **kwargs): if not engine: - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) db_ok = False n_tries = 0 @@ -98,7 +99,7 @@ class DbPlugin(Plugin): self.logger.exception(e) self.logger.info('Waiting {} seconds before retrying'.format(wait_time)) time.sleep(wait_time) - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) if not db_ok and last_error: raise last_error @@ -163,7 +164,7 @@ class DbPlugin(Plugin): ] """ - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) if table: table, engine = self._get_table(table, engine=engine, *args, **kwargs) @@ -234,7 +235,7 @@ class DbPlugin(Plugin): if key_columns is None: key_columns = [] - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) for record in records: table, engine = self._get_table(table, engine=engine, *args, **kwargs) @@ -293,7 +294,7 @@ class DbPlugin(Plugin): } """ - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) for record in records: table, engine = self._get_table(table, engine=engine, *args, **kwargs) @@ -341,7 +342,7 @@ class DbPlugin(Plugin): } """ - engine = self._get_engine(engine, *args, **kwargs) + engine = self.get_engine(engine, *args, **kwargs) for record in records: table, engine = self._get_table(table, engine=engine, *args, **kwargs) @@ -352,5 +353,22 @@ class DbPlugin(Plugin): engine.execute(delete) + def create_all(self, engine, base): + self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock()) + with self._session_locks[engine.url]: + base.metadata.create_all(engine) + + @contextmanager + def get_session(self, engine=None, *args, **kwargs) -> Generator[Session, None, None]: + engine = self.get_engine(engine, *args, **kwargs) + self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock()) + with self._session_locks[engine.url]: + session = scoped_session(sessionmaker(expire_on_commit=False)) + session.configure(bind=engine) + s = session() + yield s + s.commit() + s.close() + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/switch/__init__.py b/platypush/plugins/switch/__init__.py index e73513d90..4b33f7e75 100644 --- a/platypush/plugins/switch/__init__.py +++ b/platypush/plugins/switch/__init__.py @@ -1,9 +1,12 @@ from abc import ABC, abstractmethod from typing import List, Union +from platypush.entities import manages +from platypush.entities.switches import Switch from platypush.plugins import Plugin, action +@manages(Switch) class SwitchPlugin(Plugin, ABC): """ Abstract class for interacting with switch devices @@ -46,7 +49,7 @@ class SwitchPlugin(Plugin, ABC): return devices @action - def status(self, device=None, *args, **kwargs) -> Union[dict, List[dict]]: + def status(self, device=None, *_, **__) -> Union[dict, List[dict]]: """ Get the status of all the devices, or filter by device name or ID (alias for :meth:`.switch_status`). diff --git a/platypush/plugins/switch/tplink/__init__.py b/platypush/plugins/switch/tplink/__init__.py index 47470d357..cab5a6c13 100644 --- a/platypush/plugins/switch/tplink/__init__.py +++ b/platypush/plugins/switch/tplink/__init__.py @@ -1,4 +1,4 @@ -from typing import Union, Dict, List +from typing import Union, Mapping, List, Collection, Optional from pyHS100 import SmartDevice, SmartPlug, SmartBulb, SmartStrip, Discover, SmartDeviceException @@ -20,8 +20,12 @@ 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,7 +66,7 @@ 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) @@ -75,6 +79,26 @@ class SwitchTplinkPlugin(SwitchPlugin): 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) @@ -95,8 +119,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 +136,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 +148,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 +160,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, @@ -150,15 +177,8 @@ 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() + self._serialize(dev) + for dev in self._scan().values() ] diff --git a/platypush/user/__init__.py b/platypush/user/__init__.py index ccc2040aa..673497b7f 100644 --- a/platypush/user/__init__.py +++ b/platypush/user/__init__.py @@ -13,7 +13,7 @@ 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.orm import make_transient from sqlalchemy.ext.declarative import declarative_base from platypush.context import get_plugin @@ -28,126 +28,124 @@ 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) or isinstance(expires_at, float): + expires_at = datetime.datetime.fromtimestamp(expires_at) + elif isinstance(expires_at, str): + expires_at = datetime.datetime.fromisoformat(expires_at) - user_session = UserSession(user_id=user.user_id, session_token=self.generate_session_token(), - csrf_token=self.generate_session_token(), created_at=datetime.datetime.utcnow(), - expires_at=expires_at) + user_session = UserSession(user_id=user.user_id, session_token=self.generate_session_token(), + csrf_token=self.generate_session_token(), created_at=datetime.datetime.utcnow(), + expires_at=expires_at) - session.add(user_session) - session.commit() - return user_session + session.add(user_session) + session.commit() + return user_session @staticmethod def _get_user(session, username): @@ -180,8 +178,8 @@ 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: """ @@ -240,12 +238,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. From 9c25a131fa25b6db8482d8ede0f80b8ba10e0448 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sat, 2 Apr 2022 22:47:23 +0200 Subject: [PATCH 02/96] get_bus() should return a default RedisBus() instance if the main bus is not registered --- platypush/context/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/platypush/context/__init__.py b/platypush/context/__init__.py index 43f764d73..bcadf289a 100644 --- a/platypush/context/__init__.py +++ b/platypush/context/__init__.py @@ -133,8 +133,11 @@ def get_plugin(plugin_name, reload=False): def get_bus() -> Bus: global main_bus - assert main_bus, 'The bus is not registered' - return main_bus + if main_bus: + return main_bus + + from platypush.bus.redis import RedisBus + return RedisBus() def get_or_create_event_loop(): From 2c4c27855db7584c045fa02b54e4d554e32a4202 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 3 Apr 2022 00:26:39 +0200 Subject: [PATCH 03/96] Added `.exception` action to logger plugin --- platypush/plugins/logger/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/platypush/plugins/logger/__init__.py b/platypush/plugins/logger/__init__.py index efac544d6..73de5fb73 100644 --- a/platypush/plugins/logger/__init__.py +++ b/platypush/plugins/logger/__init__.py @@ -41,6 +41,13 @@ class LoggerPlugin(Plugin): """ self.logger.error(msg, *args, **kwargs) + @action + def exception(self, exception, *args, **kwargs): + """ + logger.exception wrapper + """ + self.logger.exception(exception, *args, **kwargs) + # vim:sw=4:ts=4:et: From 7459f0115baa31cfb98e73a7075978eb53050ed4 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 4 Apr 2022 17:21:47 +0200 Subject: [PATCH 04/96] Added more pre-commit hooks --- .pre-commit-config.yaml | 21 +++++++++++++++++++-- setup.cfg | 3 +++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c2f163aa7..18c7457a1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,14 +2,31 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v4.1.0 hooks: # - id: trailing-whitespace # - id: end-of-file-fixer - id: check-yaml + - id: check-json + - id: check-xml + - id: check-symlinks - id: check-added-large-files - repo: https://github.com/Lucas-C/pre-commit-hooks-nodejs - rev: v1.1.1 + rev: v1.1.2 hooks: - id: markdown-toc + +- repo: https://github.com/pycqa/flake8 + rev: 4.0.1 + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear + - flake8-comprehensions + - flake8-simplify + +- repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black diff --git a/setup.cfg b/setup.cfg index 862dce588..1dd336c25 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,3 +5,6 @@ tag = True [metadata] description-file = README.md + +[flake8] +max-line-length = 120 From 53da19b638e1c60e81de9743c02367e8af90ada6 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 4 Apr 2022 17:22:31 +0200 Subject: [PATCH 05/96] Added entities engine support to WeMo switch plugin --- platypush/plugins/switch/wemo/__init__.py | 70 +++++++++++++++-------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/platypush/plugins/switch/wemo/__init__.py b/platypush/plugins/switch/wemo/__init__.py index 312c1c31f..e879c0466 100644 --- a/platypush/plugins/switch/wemo/__init__.py +++ b/platypush/plugins/switch/wemo/__init__.py @@ -1,9 +1,11 @@ +import contextlib import ipaddress -from typing import List +from typing import List, Optional from platypush.plugins import action from platypush.plugins.switch import SwitchPlugin from platypush.utils.workers import Workers + from .lib import WemoRunner from .scanner import Scanner @@ -16,7 +18,13 @@ class SwitchWemoPlugin(SwitchPlugin): _default_port = 49153 - def __init__(self, devices=None, netmask: str = None, port: int = _default_port, **kwargs): + def __init__( + self, + devices=None, + netmask: Optional[str] = None, + port: int = _default_port, + **kwargs + ): """ :param devices: List of IP addresses or name->address map containing the WeMo Switch devices to control. This plugin previously used ouimeaux for auto-discovery but it's been dropped because @@ -37,8 +45,11 @@ class SwitchWemoPlugin(SwitchPlugin): def _init_devices(self, devices): if devices: - self._devices.update(devices if isinstance(devices, dict) else - {addr: addr for addr in devices}) + self._devices.update( + devices + if isinstance(devices, dict) + else {addr: addr for addr in devices} + ) else: self._devices = {} @@ -68,37 +79,53 @@ class SwitchWemoPlugin(SwitchPlugin): """ return [ - self.status(device).output - for device in self._devices.values() + self.status(device).output # type: ignore + for device in self._devices.values() ] def _get_address(self, device: str) -> str: if device not in self._addresses: - try: + with contextlib.suppress(KeyError): return self._devices[device] - except KeyError: - pass return device @action - def status(self, device: str = None, *args, **kwargs): + def status(self, device: Optional[str] = None, *_, **__): devices = {device: device} if device else self._devices.copy() ret = [ { - 'id': addr, - 'ip': addr, - 'name': name if name != addr else WemoRunner.get_name(addr), - 'on': WemoRunner.get_state(addr), + "id": addr, + "ip": addr, + "name": name if name != addr else WemoRunner.get_name(addr), + "on": WemoRunner.get_state(addr), } for (name, addr) in devices.items() ] + self.publish_entities(ret) # type: ignore return ret[0] if device else ret + def transform_entities(self, devices: List[dict]): + from platypush.entities.switches import Switch + + return super().transform_entities( # type: ignore + [ + Switch( + id=dev["id"], + name=dev["name"], + state=dev["on"], + data={ + "ip": dev["ip"], + }, + ) + for dev in (devices or []) + ] + ) + @action - def on(self, device: str, **kwargs): + def on(self, device: str, **_): """ Turn a switch on @@ -109,7 +136,7 @@ class SwitchWemoPlugin(SwitchPlugin): return self.status(device) @action - def off(self, device: str, **kwargs): + def off(self, device: str, **_): """ Turn a switch off @@ -120,7 +147,7 @@ class SwitchWemoPlugin(SwitchPlugin): return self.status(device) @action - def toggle(self, device: str, *args, **kwargs): + def toggle(self, device: str, *_, **__): """ Toggle a device on/off state @@ -151,19 +178,16 @@ class SwitchWemoPlugin(SwitchPlugin): return WemoRunner.get_name(device) @action - def scan(self, netmask: str = None): + def scan(self, netmask: Optional[str] = None): netmask = netmask or self.netmask - assert netmask, 'Scan not supported: No netmask specified' + assert netmask, "Scan not supported: No netmask specified" workers = Workers(10, Scanner, port=self.port) with workers: for addr in ipaddress.IPv4Network(netmask): workers.put(addr.exploded) - devices = { - dev.name: dev.addr - for dev in workers.responses - } + devices = {dev.name: dev.addr for dev in workers.responses} self._init_devices(devices) return self.status() From 783238642d36b0d27d1e1cf6b5a1d03a2a54f7da Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 4 Apr 2022 20:55:10 +0200 Subject: [PATCH 06/96] Skip string and underscore normalization in black --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..e7c6caf61 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.black] +skip-string-normalization = true +skip-numeric-underscore-normalization = true + From 91ff8d811f6a59d43ae03c2ab3c95f390c570aaa Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 4 Apr 2022 20:56:19 +0200 Subject: [PATCH 07/96] Added native entities support in switchbot plugin --- platypush/plugins/switchbot/__init__.py | 399 +++++++++++++++++------- 1 file changed, 279 insertions(+), 120 deletions(-) diff --git a/platypush/plugins/switchbot/__init__.py b/platypush/plugins/switchbot/__init__.py index 6545f8873..3b91c9114 100644 --- a/platypush/plugins/switchbot/__init__.py +++ b/platypush/plugins/switchbot/__init__.py @@ -43,16 +43,21 @@ class SwitchbotPlugin(SwitchPlugin): return url def _run(self, method: str = 'get', *args, device=None, **kwargs): - response = getattr(requests, method)(self._url_for(*args, device=device), headers={ - 'Authorization': self._api_token, - 'Accept': 'application/json', - 'Content-Type': 'application/json; charset=utf-8', - }, **kwargs) + response = getattr(requests, method)( + self._url_for(*args, device=device), + headers={ + 'Authorization': self._api_token, + 'Accept': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + }, + **kwargs, + ) response.raise_for_status() response = response.json() - assert response.get('statusCode') == 100, \ - f'Switchbot API request failed: {response.get("statusCode")}: {response.get("message")}' + assert ( + response.get('statusCode') == 100 + ), f'Switchbot API request failed: {response.get("statusCode")}: {response.get("message")}' return response.get('body') @@ -77,16 +82,20 @@ class SwitchbotPlugin(SwitchPlugin): """ devices = self._run('get', 'devices') devices = [ - DeviceSchema().dump({ - **device, - 'is_virtual': False, - }) + DeviceSchema().dump( + { + **device, + 'is_virtual': False, + } + ) for device in devices.get('deviceList', []) ] + [ - DeviceSchema().dump({ - **device, - 'is_virtual': True, - }) + DeviceSchema().dump( + { + **device, + 'is_virtual': True, + } + ) for device in devices.get('infraredRemoteList', []) ] @@ -96,10 +105,43 @@ 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"), + 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 +163,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 +179,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 +194,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 +249,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 +260,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 +280,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 +303,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 +326,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 +349,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 +372,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 +404,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 +435,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 +457,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 +476,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 +494,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 +512,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 +530,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 +548,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 +566,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 +584,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 +602,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 +620,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 +638,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 +656,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 +674,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 +702,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 ] From b9c78ad9132f4b2d900545329c06fe4a548cb0c3 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 4 Apr 2022 21:12:59 +0200 Subject: [PATCH 08/96] Added native support for switch entities to switchbot.bluetooth plugin --- .../plugins/switchbot/bluetooth/__init__.py | 69 ++++++++++++++----- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/platypush/plugins/switchbot/bluetooth/__init__.py b/platypush/plugins/switchbot/bluetooth/__init__.py index e441ef651..5ab98ab6f 100644 --- a/platypush/plugins/switchbot/bluetooth/__init__.py +++ b/platypush/plugins/switchbot/bluetooth/__init__.py @@ -1,6 +1,6 @@ import enum import time -from typing import List +from typing import List, Optional from platypush.message.response.bluetooth import BluetoothScanResponse from platypush.plugins import action @@ -8,7 +8,9 @@ from platypush.plugins.bluetooth.ble import BluetoothBlePlugin from platypush.plugins.switch import SwitchPlugin -class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/missing-call-to-init] +class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init] + SwitchPlugin, BluetoothBlePlugin +): """ Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and programmatically control switches over a Bluetooth interface. @@ -31,6 +33,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m """ Base64 encoded commands """ + # \x57\x01\x00 PRESS = 'VwEA' # # \x57\x01\x01 @@ -38,8 +41,14 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m # # \x57\x01\x02 OFF = 'VwEC' - def __init__(self, interface=None, connect_timeout=None, - scan_timeout=2, devices=None, **kwargs): + def __init__( + self, + interface=None, + connect_timeout=None, + scan_timeout=2, + devices=None, + **kwargs + ): """ :param interface: Bluetooth interface to use (e.g. hci0) default: first available one :type interface: str @@ -59,17 +68,21 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m self.scan_timeout = scan_timeout if scan_timeout else 2 self.configured_devices = devices or {} self.configured_devices_by_name = { - name: addr - for addr, name in self.configured_devices.items() + name: addr for addr, name in self.configured_devices.items() } def _run(self, device: str, command: Command): - if device in self.configured_devices_by_name: - device = self.configured_devices_by_name[device] + device = self.configured_devices_by_name.get(device, '') n_tries = 1 try: - self.write(device, command.value, handle=self.handle, channel_type='random', binary=True) + self.write( + device, + command.value, + handle=self.handle, + channel_type='random', + binary=True, + ) except Exception as e: self.logger.exception(e) n_tries -= 1 @@ -78,7 +91,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m raise e time.sleep(5) - return self.status(device) + return self.status(device) # type: ignore @action def press(self, device): @@ -91,11 +104,11 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m return self._run(device, self.Command.PRESS) @action - def toggle(self, device, **kwargs): + def toggle(self, device, **_): return self.press(device) @action - def on(self, device, **kwargs): + def on(self, device, **_): """ Send a press-on button command to a device @@ -105,7 +118,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m return self._run(device, self.Command.ON) @action - def off(self, device, **kwargs): + def off(self, device, **_): """ Send a press-off button command to a device @@ -115,7 +128,9 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m return self._run(device, self.Command.OFF) @action - def scan(self, interface: str = None, duration: int = 10) -> BluetoothScanResponse: + def scan( + self, interface: Optional[str] = None, duration: int = 10 + ) -> BluetoothScanResponse: """ Scan for available Switchbot devices nearby. @@ -129,9 +144,13 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m for dev in devices: try: characteristics = [ - chrc for chrc in self.discover_characteristics( - dev['addr'], channel_type='random', wait=False, - timeout=self.scan_timeout).characteristics + chrc + for chrc in self.discover_characteristics( + dev['addr'], + channel_type='random', + wait=False, + timeout=self.scan_timeout, + ).characteristics if chrc.get('uuid') == self.uuid ] @@ -140,10 +159,12 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): # lgtm [py/m except Exception as e: self.logger.warning('Device scan error', e) + self.publish_entities(compatible_devices) # type: ignore return BluetoothScanResponse(devices=compatible_devices) @property def switches(self) -> List[dict]: + self.publish_entities(self.configured_devices) # type: ignore return [ { 'address': addr, @@ -154,5 +175,19 @@ 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, + ) + for addr, name in devices.items() + ] + ) + # vim:sw=4:ts=4:et: From 9d9ec1dc5959ab79e40f63f81b8e7e40dcf0bc07 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 4 Apr 2022 22:41:04 +0200 Subject: [PATCH 09/96] Added native support for switch entities to the smartthings plugin --- platypush/plugins/smartthings/__init__.py | 145 ++++++++++++++-------- 1 file changed, 94 insertions(+), 51 deletions(-) diff --git a/platypush/plugins/smartthings/__init__.py b/platypush/plugins/smartthings/__init__.py index 4853f2533..249a27e7a 100644 --- a/platypush/plugins/smartthings/__init__.py +++ b/platypush/plugins/smartthings/__init__.py @@ -18,7 +18,7 @@ class SmartthingsPlugin(SwitchPlugin): """ - _timeout = aiohttp.ClientTimeout(total=20.) + _timeout = aiohttp.ClientTimeout(total=20.0) def __init__(self, access_token: str, **kwargs): """ @@ -44,45 +44,30 @@ 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_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_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_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_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,10 +287,15 @@ 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) @@ -340,24 +336,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,17 +401,39 @@ 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: + async def _get_device_status(self, api, device_id: str) -> dict: + from platypush.entities.switches import Switch + device = await api.device(device_id) await device.status.refresh() + if 'switch' in device.capabilities: + self.publish_entities( + [ # type: ignore + Switch( + id=device_id, + name=device.label, + state=device.status.switch, + data={ + 'location_id': getattr(device, 'location_id', None), + 'room_id': getattr(device, 'room_id', None), + }, + ) + ] + ) + return { 'device_id': device_id, 'name': device.label, @@ -407,7 +442,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 +469,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) @@ -529,13 +566,19 @@ 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) + assert ret, 'The command switch={state} failed on device {device}'.format( + state=state, device=dev.label + ) return not dev.status.switch with self._refresh_lock: From 9f2793118bd58438f98754316a22078bdc7a663c Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 4 Apr 2022 22:43:04 +0200 Subject: [PATCH 10/96] black fix --- platypush/plugins/smartthings/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/platypush/plugins/smartthings/__init__.py b/platypush/plugins/smartthings/__init__.py index 249a27e7a..7fb2562f7 100644 --- a/platypush/plugins/smartthings/__init__.py +++ b/platypush/plugins/smartthings/__init__.py @@ -420,8 +420,8 @@ class SmartthingsPlugin(SwitchPlugin): await device.status.refresh() if 'switch' in device.capabilities: - self.publish_entities( - [ # type: ignore + self.publish_entities( # type: ignore + [ Switch( id=device_id, name=device.label, From 28b3672432407c62c3fcaee0ad0e18141c08f62f Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 5 Apr 2022 00:07:55 +0200 Subject: [PATCH 11/96] Added native support for switch entities to the zigbee.mqtt plugin. --- platypush/plugins/zigbee/mqtt/__init__.py | 490 +++++++++++++++------- 1 file changed, 343 insertions(+), 147 deletions(-) diff --git a/platypush/plugins/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py index 65cb2734b..f785305d0 100644 --- a/platypush/plugins/zigbee/mqtt/__init__.py +++ b/platypush/plugins/zigbee/mqtt/__init__.py @@ -9,7 +9,7 @@ from platypush.plugins.mqtt import MqttPlugin, action from platypush.plugins.switch import SwitchPlugin -class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-init] +class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-init] """ This plugin allows you to interact with Zigbee devices over MQTT through any Zigbee sniffer and `zigbee2mqtt <https://www.zigbee2mqtt.io/>`_. @@ -35,7 +35,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 +79,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 +105,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,9 +136,17 @@ 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 @@ -135,6 +155,38 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i 'groups': {}, } + def transform_entities(self, devices): + from platypush.entities.switches import Switch + + compatible_entities = [] + for dev in devices: + 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.get("definition", {}).get("model"), + "vendor": dev.get("definition", {}).get("vendor"), + "supported": dev.get("supported"), + "description": dev.get("definition", {}).get("description"), + } + + switch_info = self._get_switch_info(dev) + if switch_info: + compatible_entities.append( + Switch( + id=dev['ieee_address'], + name=dev.get('friendly_name'), + state=switch_info['property'] == switch_info['value_on'], + data=dev_info, + ) + ) + + return compatible_entities + def _get_network_info(self, **kwargs): self.logger.info('Fetching Zigbee network information') client = None @@ -157,7 +209,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 +230,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'] = { @@ -183,10 +241,10 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i } self._info['groups'] = { - group.get('name'): group - for group in info.get('groups', []) + group.get('name'): group for group in info.get('groups', []) } + self.publish_entities(self._info['devices'].values()) # type: ignore self.logger.info('Zigbee network configuration updated') return info finally: @@ -194,7 +252,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 +264,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 +353,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 +377,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 +444,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 +458,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 +482,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 +498,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 +518,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 +544,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 +562,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 +581,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 +606,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 +622,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: @@ -563,7 +657,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i # 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 @@ -578,26 +674,45 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i kwargs = self._mqtt_args(**kwargs) 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]} + refreshed = False if device not in self._info.get('devices', {}): # Refresh devices info self._get_network_info(**kwargs) + refreshed = True - assert self._info.get('devices', {}).get(device), 'No such device: ' + device - exposes = (self._info.get('devices', {}).get(device, {}).get('definition', {}) or {}).get('exposes', []) + assert self._info.get('devices', {}).get(device), 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_info = self.publish( + topic=self._topic(device) + '/get', + reply_topic=self._topic(device), + msg=self.build_device_get_request(exposes), + **kwargs, + ) + + if not refreshed: + self.publish_entities([device_info]) # type: ignore + + return device_info @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 +737,14 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i kwargs = self._mqtt_args(**kwargs) if not devices: - # noinspection PyUnresolvedReferences - devices = set([ - device['friendly_name'] or device['ieee_address'] - for device in self.devices(**kwargs).output - ]) + 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 +753,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 +763,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 +778,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 +794,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 +828,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 +852,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 +1014,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 +1048,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 +1075,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 +1104,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 +1127,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 +1146,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 +1170,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 +1205,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 +1226,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: @@ -1069,8 +1242,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i """ switch_info = self._get_switches_info().get(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) + 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: @@ -1080,8 +1257,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i """ switch_info = self._get_switches_info().get(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) + 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: @@ -1091,8 +1272,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i """ switch_info = self._get_switches_info().get(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) + props = self.device_set( + device, switch_info['property'], switch_info['value_toggle'] + ).output + return self._properties_to_switch( + device=device, props=props, switch_info=switch_info + ) @staticmethod def _properties_to_switch(device: str, props: dict, switch_info: dict) -> dict: @@ -1103,32 +1288,39 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-i **props, } + @staticmethod + def _get_switch_info(device_info: dict) -> dict: + exposes = (device_info.get('definition', {}) or {}).get('exposes', []) + for exposed in exposes: + for feature in exposed.get('features', []): + if ( + feature.get('type') == 'binary' + and 'value_on' in feature + and 'value_off' in feature + and feature.get('access', 0) & 2 + ): + return { + 'property': feature['property'], + 'value_on': feature['value_on'], + 'value_off': feature['value_off'], + 'value_toggle': feature.get('value_toggle', None), + } + + return {} + def _get_switches_info(self) -> dict: - def switch_info(device_info: dict) -> dict: - exposes = (device_info.get('definition', {}) or {}).get('exposes', []) - for exposed in exposes: - for feature in exposed.get('features', []): - if feature.get('type') == 'binary' and 'value_on' in feature and 'value_off' in feature and \ - feature.get('access', 0) & 2: - return { - 'property': feature['property'], - 'value_on': feature['value_on'], - 'value_off': feature['value_off'], - 'value_toggle': feature.get('value_toggle', None), - } - - return {} - # noinspection PyUnresolvedReferences devices = self.devices().output switches_info = {} for device in devices: - info = switch_info(device) + info = self._get_switch_info(device) if not info: continue - switches_info[device.get('friendly_name', device.get('ieee_address'))] = info + switches_info[ + device.get('friendly_name', device.get('ieee_address')) + ] = info return switches_info @@ -1142,8 +1334,12 @@ 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() ] From 0dac2c0e921eeacdd1dbb1010d240bd43d22cdc7 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 5 Apr 2022 00:31:04 +0200 Subject: [PATCH 12/96] Fixed handling of possible null device definition in zigbee.mqtt --- platypush/plugins/zigbee/mqtt/__init__.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/platypush/plugins/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py index f785305d0..f0d718bd1 100644 --- a/platypush/plugins/zigbee/mqtt/__init__.py +++ b/platypush/plugins/zigbee/mqtt/__init__.py @@ -160,6 +160,10 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in compatible_entities = [] for dev in devices: + if not dev: + continue + + dev_def = dev.get("definition") or {} dev_info = { "type": dev.get("type"), "date_code": dev.get("date_code"), @@ -168,10 +172,10 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in "power_source": dev.get("power_source"), "software_build_id": dev.get("software_build_id"), "model_id": dev.get("model_id"), - "model": dev.get("definition", {}).get("model"), - "vendor": dev.get("definition", {}).get("vendor"), + "model": dev_def.get("model"), + "vendor": dev_def.get("vendor"), "supported": dev.get("supported"), - "description": dev.get("definition", {}).get("description"), + "description": dev_def.get("description"), } switch_info = self._get_switch_info(dev) @@ -185,7 +189,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in ) ) - return compatible_entities + return super().transform_entities(compatible_entities) # type: ignore def _get_network_info(self, **kwargs): self.logger.info('Fetching Zigbee network information') @@ -738,10 +742,8 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in if not devices: devices = { - [ - device['friendly_name'] or device['ieee_address'] - for device in self.devices(**kwargs).output - ] + device['friendly_name'] or device['ieee_address'] + for device in self.devices(**kwargs).output } def worker(device: str, q: Queue): From b43ed169c70acc9c5ebc7e2a793d8d1e2ace1f91 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 5 Apr 2022 20:22:47 +0200 Subject: [PATCH 13/96] Added support for switches as native entities to zwave.mqtt plugin --- platypush/plugins/zwave/mqtt/__init__.py | 674 +++++++++++++++++------ 1 file changed, 509 insertions(+), 165 deletions(-) diff --git a/platypush/plugins/zwave/mqtt/__init__.py b/platypush/plugins/zwave/mqtt/__init__.py index 63279b900..772a046df 100644 --- a/platypush/plugins/zwave/mqtt/__init__.py +++ b/platypush/plugins/zwave/mqtt/__init__.py @@ -3,8 +3,9 @@ import queue from datetime import datetime from threading import Timer -from typing import Optional, List, Any, Dict, Union, Iterable, Callable +from typing import Optional, List, Any, Dict, Union, Iterable, Mapping, Callable +from platypush.entities.switches import Switch from platypush.message.event.zwave import ZwaveNodeRenamedEvent, ZwaveNodeEvent from platypush.context import get_backend, get_bus @@ -45,10 +46,21 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): """ - def __init__(self, name: str, host: str = 'localhost', port: int = 1883, topic_prefix: str = 'zwave', - timeout: int = 10, tls_certfile: Optional[str] = None, tls_keyfile: Optional[str] = None, - tls_version: Optional[str] = None, tls_ciphers: Optional[str] = None, username: Optional[str] = None, - password: Optional[str] = None, **kwargs): + def __init__( + self, + name: str, + host: str = 'localhost', + port: int = 1883, + topic_prefix: str = 'zwave', + timeout: int = 10, + tls_certfile: Optional[str] = None, + tls_keyfile: Optional[str] = None, + tls_version: Optional[str] = None, + tls_ciphers: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + **kwargs, + ): """ :param name: Gateway name, as configured from the zwavejs2mqtt web panel from Mqtt -> Name. :param host: MQTT broker host, as configured from the zwavejs2mqtt web panel from Mqtt -> Host @@ -70,9 +82,17 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param password: If the connection requires user authentication, specify the password (default: None) """ - super().__init__(host=host, port=port, tls_certfile=tls_certfile, tls_keyfile=tls_keyfile, - tls_version=tls_version, tls_ciphers=tls_ciphers, username=username, - password=password, **kwargs) + super().__init__( + host=host, + port=port, + tls_certfile=tls_certfile, + tls_keyfile=tls_keyfile, + tls_version=tls_version, + tls_ciphers=tls_ciphers, + username=username, + password=password, + **kwargs, + ) self.topic_prefix = topic_prefix self.base_topic = topic_prefix + '/{}/ZWAVE_GATEWAY-' + name @@ -118,14 +138,21 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): if isinstance(response, Response): response = response.output - assert response.get('success') is True, response.get('message', 'zwavejs2mqtt error') + assert response.get('success') is True, response.get( + 'message', 'zwavejs2mqtt error' + ) return response def _api_request(self, api: str, *args, **kwargs): payload = json.dumps({'args': args}) ret = self._parse_response( - self.publish(topic=self._api_topic(api) + '/set', msg=payload, reply_topic=self._api_topic(api), - **self._mqtt_args(**kwargs))) + self.publish( + topic=self._api_topic(api) + '/set', + msg=payload, + reply_topic=self._api_topic(api), + **self._mqtt_args(**kwargs), + ) + ) assert not ret or ret.get('success') is True, ret.get('message') return ret.get('result') @@ -135,7 +162,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): if t: return datetime.fromtimestamp(t / 1000) - def _get_scene(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs) -> dict: + def _get_scene( + self, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + **kwargs, + ) -> dict: assert scene_id or scene_label, 'No scene_id/scene_label specified' if scene_id in self._scenes_cache['by_id']: return self._scenes_cache['by_id'][scene_id] @@ -191,8 +223,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): 'id_on_network': value['id'], 'value_id': value['id'], 'data': value.get('value'), - 'data_items': [state for state in value['states']] if len(value.get('states', [])) > 1 else None, - 'label': value.get('label', value.get('propertyName', value.get('property'))), + 'data_items': value['states'] if len(value.get('states', [])) > 1 else None, + 'label': value.get( + 'label', value.get('propertyName', value.get('property')) + ), 'property_id': value.get('property'), 'help': value.get('description'), 'node_id': value.get('nodeId'), @@ -209,8 +243,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): 'is_read_only': value['readable'] and not value['writeable'], 'is_write_only': value['writeable'] and not value['readable'], 'last_update': cls._convert_timestamp(value.get('lastUpdate')), - **({'property_key': value['propertyKey']} if 'propertyKey' in value else {}), - **({'property_key_name': value['propertyKeyName']} if 'propertyKeyName' in value else {}), + **( + {'property_key': value['propertyKey']} if 'propertyKey' in value else {} + ), + **( + {'property_key_name': value['propertyKeyName']} + if 'propertyKeyName' in value + else {} + ), } @staticmethod @@ -250,7 +290,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): 'device_id': node['hexId'].replace('0x', ''), 'name': node.get('name'), 'capabilities': capabilities, - 'manufacturer_id': '0x{:04x}'.format(node['manufacturerId']) if node.get('manufacturerId') else None, + 'manufacturer_id': '0x{:04x}'.format(node['manufacturerId']) + if node.get('manufacturerId') + else None, 'manufacturer_name': node.get('manufacturer'), 'location': node.get('loc'), 'status': node.get('status'), @@ -268,9 +310,15 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): 'is_security_device': node.get('supportsSecurity'), 'is_sleeping': node.get('ready') and node.get('status') == 'Asleep', 'last_update': cls._convert_timestamp(node.get('lastActive')), - 'product_id': '0x{:04x}'.format(node['productId']) if node.get('productId') else None, - 'product_type': '0x{:04x}'.format(node['productType']) if node.get('productType') else None, - 'product_name': ' '.join([node.get('productLabel', ''), node.get('productDescription', '')]), + 'product_id': '0x{:04x}'.format(node['productId']) + if node.get('productId') + else None, + 'product_type': '0x{:04x}'.format(node['productType']) + if node.get('productType') + else None, + 'product_name': ' '.join( + [node.get('productLabel', ''), node.get('productDescription', '')] + ), 'baud_rate': node.get('dataRate'), 'max_baud_rate': node.get('maxBaudRate', node.get('dataRate')), 'device_class': node.get('deviceClass'), @@ -284,25 +332,28 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): 'zwave_plus_node_type': node.get('zwavePlusNodeType'), 'zwave_plus_role_type': node.get('zwavePlusRoleType'), 'neighbours': node.get('neighbors', []), - 'command_classes': { value['commandClassName'] - for value in node.get('values', {}).values() if value.get('commandClassName') + for value in node.get('values', {}).values() + if value.get('commandClassName') }, - 'groups': { group['value']: cls.group_to_dict(group, node_id=node['id']) for group in node.get('groups', []) }, - 'values': { value['id']: cls.value_to_dict(value) for value in node.get('values', {}).values() }, } - def _get_node(self, node_id: Optional[int] = None, node_name: Optional[str] = None, use_cache: bool = True, - **kwargs) -> Optional[dict]: + def _get_node( + self, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + use_cache: bool = True, + **kwargs, + ) -> Optional[dict]: assert node_id or node_name, 'Please provide either a node_id or node_name' if use_cache: if node_id and node_id in self._nodes_cache['by_id']: @@ -335,12 +386,21 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): return node - def _get_value(self, value_id: Optional[int] = None, id_on_network: Optional[str] = None, - value_label: Optional[str] = None, node_id: Optional[int] = None, node_name: Optional[str] = None, - use_cache: bool = True, **_) -> Dict[str, Any]: + def _get_value( + self, + value_id: Optional[int] = None, + id_on_network: Optional[str] = None, + value_label: Optional[str] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + use_cache: bool = True, + **_, + ) -> Dict[str, Any]: # Unlike python-openzwave, value_id and id_on_network are the same on zwavejs2mqtt value_id = value_id or id_on_network - assert value_id or value_label, 'Please provide either value_id, id_on_network or value_label' + assert ( + value_id or value_label + ), 'Please provide either value_id, id_on_network or value_label' if use_cache: if value_id and value_id in self._values_cache['by_id']: @@ -350,7 +410,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): nodes = [] if node_id or node_name: - nodes = [self._get_node(node_id=node_id, node_name=node_name, use_cache=False)] + nodes = [ + self._get_node(node_id=node_id, node_name=node_name, use_cache=False) + ] if not nodes: # noinspection PyUnresolvedReferences @@ -376,8 +438,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): value = values[0] if value.get('property_id') == 'targetValue': - cur_value_id = '-'.join(value['value_id'].split('-')[:-1] + ['currentValue']) - cur_value = self._nodes_cache['by_id'][value['node_id']].get('values', {}).get(cur_value_id) + cur_value_id = '-'.join( + value['value_id'].split('-')[:-1] + ['currentValue'] + ) + cur_value = ( + self._nodes_cache['by_id'][value['node_id']] + .get('values', {}) + .get(cur_value_id) + ) if cur_value: value['data'] = cur_value['data'] @@ -385,20 +453,59 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): if value['label']: self._values_cache['by_label'][value['label']] = value + self.publish_entities([value]) # type: ignore return value + @staticmethod + def _is_switch(value: Mapping): + return value.get('command_class_name', '').endswith('Switch') + + def transform_entities(self, values: Iterable[Mapping]): + entities = [] + + for value in values: + if self._is_switch(value): + entities.append( + Switch( + id=value['id'], + name='{node_name} [{value_name}]'.format( + node_name=self._nodes_cache['by_id'][value['node_id']].get( + 'name', f'[Node {value["node_id"]}]' + ), + value_name=value["label"], + ), + state=value['data'], + data={ + 'help': value.get('help'), + 'is_read_only': value.get('is_read_only'), + 'is_write_only': value.get('is_write_only'), + 'label': value.get('label'), + 'node_id': value.get('node_id'), + }, + ) + ) + + return super().transform_entities(entities) # type: ignore + def _topic_by_value_id(self, value_id: str) -> str: return self.topic_prefix + '/' + '/'.join(value_id.split('-')) - def _filter_values(self, command_classes: Iterable[str], filter_callback: Optional[Callable[[dict], bool]] = None, - node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: - # noinspection PyUnresolvedReferences - nodes = [self._get_node(node_name=node_name, use_cache=False, **kwargs)] if node_id or node_name else \ - self.get_nodes(**kwargs).output.values() + def _filter_values( + self, + command_classes: Iterable[str], + filter_callback: Optional[Callable[[dict], bool]] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ) -> Dict[str, Any]: + nodes = ( + [self._get_node(node_name=node_name, use_cache=False, **kwargs)] + if node_id or node_name + else self.get_nodes(**kwargs).output.values() + ) command_classes = { - command_class_by_name[command_name] - for command_name in command_classes + command_class_by_name[command_name] for command_name in command_classes } values = {} @@ -406,21 +513,32 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): for node in nodes: for value in node.get('values', {}).values(): if value.get('command_class') not in command_classes or ( - filter_callback and not filter_callback(value)): + filter_callback and not filter_callback(value) + ): continue value_id = value['id_on_network'] if value_id.split('-').pop() == 'targetValue': value_id = '-'.join(value_id.split('-')[:-1]) + '-currentValue' - cur_value = self._nodes_cache['by_id'][value['node_id']].get('values', {}).get(value_id) + cur_value = ( + self._nodes_cache['by_id'][value['node_id']] + .get('values', {}) + .get(value_id) + ) if cur_value: value['data'] = cur_value['data'] values[value['id_on_network']] = value + self.publish_entities(values.values()) # type: ignore return values - def _get_group(self, group_id: Optional[str] = None, group_index: Optional[int] = None, **kwargs) -> dict: + def _get_group( + self, + group_id: Optional[str] = None, + group_index: Optional[int] = None, + **kwargs, + ) -> dict: group_id = group_id or group_index assert group_id is not None, 'No group_id/group_index specified' group = self._groups_cache.get(group_id) @@ -465,14 +583,19 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): msg_queue.put(json.loads(msg.payload)) client.on_message = on_message - client.connect(kwargs.get('host', self.host), kwargs.get('port', self.port), - keepalive=kwargs.get('timeout', self.timeout)) + client.connect( + kwargs.get('host', self.host), + kwargs.get('port', self.port), + keepalive=kwargs.get('timeout', self.timeout), + ) client.subscribe(topic) client.loop_start() try: - status = msg_queue.get(block=True, timeout=kwargs.get('timeout', self.timeout)) + status = msg_queue.get( + block=True, timeout=kwargs.get('timeout', self.timeout) + ) except queue.Empty: raise TimeoutError('The request timed out') finally: @@ -510,7 +633,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def remove_failed_node(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def remove_failed_node( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Remove a failed node from the network. @@ -526,7 +651,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('removeFailedNode', node_id, **kwargs) @action - def replace_failed_node(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def replace_failed_node( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Replace a failed node on the network. @@ -549,7 +676,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def request_network_update(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def request_network_update( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Request a network update to a node. @@ -575,8 +704,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('refreshNeighbors', **kwargs) @action - def get_nodes(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) \ - -> Optional[Dict[str, Any]]: + def get_nodes( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Optional[Dict[str, Any]]: """ Get the nodes associated to the network. @@ -740,7 +870,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): (default: query the default configured device). """ if node_id or node_name: - return self._get_node(node_id=node_id, node_name=node_name, use_cache=False, **kwargs) + return self._get_node( + node_id=node_id, node_name=node_name, use_cache=False, **kwargs + ) nodes = { node['id']: self.node_to_dict(node) @@ -748,10 +880,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): } self._nodes_cache['by_id'] = nodes - self._nodes_cache['by_name'] = { - node['name']: node - for node in nodes.values() - } + self._nodes_cache['by_name'] = {node['name']: node for node in nodes.values()} return nodes @@ -763,7 +892,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def set_node_name(self, new_name: str, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def set_node_name( + self, + new_name: str, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Rename a node on the network. @@ -778,10 +913,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): assert node_id, f'No such node: {node_id}' self._api_request('setNodeName', node_id, new_name, **kwargs) - get_bus().post(ZwaveNodeRenamedEvent(node={ - **self._get_node(node_id=node_id), - 'name': new_name, - })) + get_bus().post( + ZwaveNodeRenamedEvent( + node={ + **self._get_node(node_id=node_id), + 'name': new_name, + } + ) + ) @action def set_node_product_name(self, **kwargs): @@ -798,8 +937,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def set_node_location(self, location: str, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs): + def set_node_location( + self, + location: str, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Set the location of a node. @@ -814,10 +958,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): assert node_id, f'No such node: {node_id}' self._api_request('setNodeLocation', node_id, location, **kwargs) - get_bus().post(ZwaveNodeEvent(node={ - **self._get_node(node_id=node_id), - 'location': location, - })) + get_bus().post( + ZwaveNodeEvent( + node={ + **self._get_node(node_id=node_id), + 'location': location, + } + ) + ) @action def cancel_command(self, **kwargs): @@ -877,7 +1025,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): """ self._api_request('beginHealingNetwork', **kwargs) if timeout: - Timer(timeout, lambda: self._api_request('stopHealingNetwork', **kwargs)).start() + Timer( + timeout, lambda: self._api_request('stopHealingNetwork', **kwargs) + ).start() @action def switch_all(self, **kwargs): @@ -894,9 +1044,15 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def get_value(self, value_id: Optional[int] = None, id_on_network: Optional[str] = None, - value_label: Optional[str] = None, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs) -> Dict[str, Any]: + def get_value( + self, + value_id: Optional[int] = None, + id_on_network: Optional[str] = None, + value_label: Optional[str] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ) -> Dict[str, Any]: """ Get a value on the network. @@ -908,13 +1064,27 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network, node_id=node_id, - node_name=node_name, use_cache=False, **kwargs) + return self._get_value( + value_id=value_id, + value_label=value_label, + id_on_network=id_on_network, + node_id=node_id, + node_name=node_name, + use_cache=False, + **kwargs, + ) @action - def set_value(self, data, value_id: Optional[int] = None, id_on_network: Optional[str] = None, - value_label: Optional[str] = None, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs): + def set_value( + self, + data, + value_id: Optional[int] = None, + id_on_network: Optional[str] = None, + value_label: Optional[str] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Set a value. @@ -927,16 +1097,31 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - value = self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network, - node_id=node_id, node_name=node_name, **kwargs) + value = self._get_value( + value_id=value_id, + value_label=value_label, + id_on_network=id_on_network, + node_id=node_id, + node_name=node_name, + **kwargs, + ) - self._api_request('writeValue', { - 'nodeId': value['node_id'], - 'commandClass': value['command_class'], - 'endpoint': value.get('endpoint', 0), - 'property': value['property_id'], - **({'propertyKey': value['property_key']} if 'property_key' in value else {}), - }, data, **kwargs) + self._api_request( + 'writeValue', + { + 'nodeId': value['node_id'], + 'commandClass': value['command_class'], + 'endpoint': value.get('endpoint', 0), + 'property': value['property_id'], + **( + {'propertyKey': value['property_key']} + if 'property_key' in value + else {} + ), + }, + data, + **kwargs, + ) @action def set_value_label(self, **kwargs): @@ -960,7 +1145,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def node_heal(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def node_heal( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Heal network node by requesting the node to rediscover their neighbours. @@ -974,7 +1161,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('healNode', node_id, **kwargs) @action - def node_update_neighbours(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def node_update_neighbours( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Ask a node to update its neighbours table (same as :meth:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin.node_heal`). @@ -987,7 +1176,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self.node_heal(node_id=node_id, node_name=node_name, **kwargs) @action - def node_network_update(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def node_network_update( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Update the controller with network information (same as :meth:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin.node_heal`). @@ -1000,7 +1191,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self.node_heal(node_id=node_id, node_name=node_name, **kwargs) @action - def node_refresh_info(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def node_refresh_info( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ): """ Fetch up-to-date information about the node. @@ -1014,7 +1207,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('refreshInfo', node_id, **kwargs) @action - def get_dimmers(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_dimmers( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the dimmers on the network or associated to a node. @@ -1023,12 +1218,17 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['switch_multilevel', 'switch_toggle_multilevel'], node_id=node_id, - node_name=node_name, **kwargs) + return self._filter_values( + ['switch_multilevel', 'switch_toggle_multilevel'], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_node_config(self, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs) -> Dict[str, Any]: + def get_node_config( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the configuration values of a node or of all the nodes on the network. @@ -1037,12 +1237,22 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['configuration', 'ip_configuration', 'association_command_configuration', - 'sensor_configuration'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + [ + 'configuration', + 'ip_configuration', + 'association_command_configuration', + 'sensor_configuration', + ], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_battery_levels(self, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs) -> Dict[str, Any]: + def get_battery_levels( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the battery levels of a node or of all the nodes on the network. @@ -1051,11 +1261,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['battery'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['battery'], node_id=node_id, node_name=node_name, **kwargs + ) @action - def get_power_levels(self, node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs) -> Dict[str, Any]: + def get_power_levels( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the power levels of this node. @@ -1064,10 +1277,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['powerlevel'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['powerlevel'], node_id=node_id, node_name=node_name, **kwargs + ) @action - def get_bulbs(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_bulbs( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the bulbs/LEDs on the network or associated to a node. @@ -1076,11 +1293,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['color'], filter_callback=lambda value: not value['is_read_only'], node_id=node_id, - node_name=node_name, **kwargs) + return self._filter_values( + ['color'], + filter_callback=lambda value: not value['is_read_only'], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_switches(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_switches( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the switches on the network or associated to a node. @@ -1089,12 +1313,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['switch_binary', 'switch_toggle_binary'], - filter_callback=lambda value: not value['is_read_only'], - node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['switch_binary', 'switch_toggle_binary'], + filter_callback=lambda value: not value['is_read_only'], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_sensors(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_sensors( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the sensors on the network or associated to a node. @@ -1103,12 +1333,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['sensor_binary', 'sensor_multilevel', 'sensor_alarm', 'meter'], - filter_callback=lambda value: not value['is_write_only'], - node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['sensor_binary', 'sensor_multilevel', 'sensor_alarm', 'meter'], + filter_callback=lambda value: not value['is_write_only'], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_doorlocks(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_doorlocks( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the doorlocks on the network or associated to a node. @@ -1117,10 +1353,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['door_lock'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['door_lock'], node_id=node_id, node_name=node_name, **kwargs + ) @action - def get_locks(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_locks( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the locks on the network or associated to a node. @@ -1129,10 +1369,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['lock'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['lock'], node_id=node_id, node_name=node_name, **kwargs + ) @action - def get_usercodes(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]: + def get_usercodes( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the usercodes on the network or associated to a node. @@ -1141,11 +1385,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['user_code'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['user_code'], node_id=node_id, node_name=node_name, **kwargs + ) @action - def get_thermostats(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) \ - -> Dict[str, Any]: + def get_thermostats( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the thermostats on the network or associated to a node. @@ -1154,13 +1401,25 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['thermostat_heating', 'thermostat_mode', 'thermostat_operating_state', - 'thermostat_setpoint', 'thermostat_fan_mode', 'thermostat_fan_state', - 'thermostat_setback'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + [ + 'thermostat_heating', + 'thermostat_mode', + 'thermostat_operating_state', + 'thermostat_setpoint', + 'thermostat_fan_mode', + 'thermostat_fan_state', + 'thermostat_setback', + ], + node_id=node_id, + node_name=node_name, + **kwargs, + ) @action - def get_protections(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) \ - -> Dict[str, Any]: + def get_protections( + self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: """ Get the protection-compatible devices on the network or associated to a node. @@ -1169,7 +1428,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - return self._filter_values(['protection'], node_id=node_id, node_name=node_name, **kwargs) + return self._filter_values( + ['protection'], node_id=node_id, node_name=node_name, **kwargs + ) @action def get_groups(self, **kwargs) -> Dict[str, dict]: @@ -1218,7 +1479,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): group['group_id']: { **group, 'associations': [ - assoc['nodeId'] for assoc in self._api_request('getAssociations', node_id, group['index']) + assoc['nodeId'] + for assoc in self._api_request( + 'getAssociations', node_id, group['index'] + ) ], } for node_id, node in nodes.items() @@ -1275,7 +1539,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('_createScene', label) @action - def remove_scene(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs): + def remove_scene( + self, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + **kwargs, + ): """ Remove a scene. @@ -1288,7 +1557,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('_removeScene', scene['scene_id']) @action - def activate_scene(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs): + def activate_scene( + self, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + **kwargs, + ): """ Activate a scene. @@ -1301,8 +1575,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('_activateScene', scene['scene_id']) @action - def set_scene_label(self, new_label: str, scene_id: Optional[int] = None, scene_label: Optional[str] = None, - **kwargs): + def set_scene_label( + self, + new_label: str, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + **kwargs, + ): """ Rename a scene/set the scene label. @@ -1315,10 +1594,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def scene_add_value(self, data: Optional[Any] = None, value_id: Optional[int] = None, - id_on_network: Optional[str] = None, value_label: Optional[str] = None, - scene_id: Optional[int] = None, scene_label: Optional[str] = None, - node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs): + def scene_add_value( + self, + data: Optional[Any] = None, + value_id: Optional[int] = None, + id_on_network: Optional[str] = None, + value_label: Optional[str] = None, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Add a value to a scene. @@ -1333,22 +1620,41 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - value = self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network, - node_id=node_id, node_name=node_name, **kwargs) + value = self._get_value( + value_id=value_id, + value_label=value_label, + id_on_network=id_on_network, + node_id=node_id, + node_name=node_name, + **kwargs, + ) scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs) - self._api_request('_addSceneValue', scene['scene_id'], { - 'nodeId': value['node_id'], - 'commandClass': value['command_class'], - 'property': value['property_id'], - 'endpoint': value['endpoint'], - }, data, kwargs.get('timeout', self.timeout)) + self._api_request( + '_addSceneValue', + scene['scene_id'], + { + 'nodeId': value['node_id'], + 'commandClass': value['command_class'], + 'property': value['property_id'], + 'endpoint': value['endpoint'], + }, + data, + kwargs.get('timeout', self.timeout), + ) @action - def scene_remove_value(self, value_id: Optional[int] = None, id_on_network: Optional[str] = None, - value_label: Optional[str] = None, scene_id: Optional[int] = None, - scene_label: Optional[str] = None, node_id: Optional[int] = None, - node_name: Optional[str] = None, **kwargs): + def scene_remove_value( + self, + value_id: Optional[int] = None, + id_on_network: Optional[str] = None, + value_label: Optional[str] = None, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Remove a value from a scene. @@ -1362,13 +1668,24 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - value = self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network, - node_id=node_id, node_name=node_name, **kwargs) + value = self._get_value( + value_id=value_id, + value_label=value_label, + id_on_network=id_on_network, + node_id=node_id, + node_name=node_name, + **kwargs, + ) scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs) self._api_request('_removeSceneValue', scene['scene_id'], value['value_id']) @action - def get_scene_values(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs) -> dict: + def get_scene_values( + self, + scene_id: Optional[int] = None, + scene_label: Optional[str] = None, + **kwargs, + ) -> dict: """ Get the values associated to a scene. @@ -1381,8 +1698,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): return scene.get('values', {}) @action - def create_button(self, button_id: Union[int, str], node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs): + def create_button( + self, + button_id: Union[int, str], + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Create a handheld button on a device. Only intended for bridge firmware controllers (not implemented by zwavejs2mqtt). @@ -1396,8 +1718,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def delete_button(self, button_id: Union[int, str], node_id: Optional[int] = None, node_name: Optional[str] = None, - **kwargs): + def delete_button( + self, + button_id: Union[int, str], + node_id: Optional[int] = None, + node_name: Optional[str] = None, + **kwargs, + ): """ Delete a button association from a device. Only intended for bridge firmware controllers. (not implemented by zwavejs2mqtt). @@ -1411,8 +1738,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def add_node_to_group(self, group_id: Optional[str] = None, node_id: Optional[int] = None, - endpoint: Optional[int] = None, **kwargs): + def add_node_to_group( + self, + group_id: Optional[str] = None, + node_id: Optional[int] = None, + endpoint: Optional[int] = None, + **kwargs, + ): """ Add a node to a group. @@ -1430,8 +1762,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('addAssociations', group['node_id'], group['index'], [assoc]) @action - def remove_node_from_group(self, group_id: Optional[str] = None, node_id: Optional[int] = None, - endpoint: Optional[int] = None, **kwargs): + def remove_node_from_group( + self, + group_id: Optional[str] = None, + node_id: Optional[int] = None, + endpoint: Optional[int] = None, + **kwargs, + ): """ Remove a node from a group. @@ -1446,7 +1783,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): if endpoint is not None: assoc['endpoint'] = endpoint - self._api_request('removeAssociations', group['node_id'], group['index'], [assoc]) + self._api_request( + 'removeAssociations', group['node_id'], group['index'], [assoc] + ) @action def create_new_primary(self, **kwargs): @@ -1527,8 +1866,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self.set_value(data=value['data'], id_on_network=device, **kwargs) return { - 'name': '{} - {}'.format(self._nodes_cache['by_id'][value['node_id']]['name'], - value.get('label', '[No Label]')), + 'name': '{} - {}'.format( + self._nodes_cache['by_id'][value['node_id']]['name'], + value.get('label', '[No Label]'), + ), 'on': value['data'], 'id': value['value_id'], } @@ -1541,11 +1882,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): devices = self.get_switches().output.values() return [ { - 'name': '{} - {}'.format(self._nodes_cache['by_id'][dev['node_id']]['name'], - dev.get('label', '[No Label]')), + 'name': '{} - {}'.format( + self._nodes_cache['by_id'][dev['node_id']]['name'], + dev.get('label', '[No Label]'), + ), 'on': dev['data'], 'id': dev['value_id'], - } for dev in devices + } + for dev in devices ] From 4b7eeaa4ed0ea19642a24bf8c7f0b3b252228620 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 5 Apr 2022 21:17:58 +0200 Subject: [PATCH 14/96] Smarter merging of entities with the same key before they are committed --- platypush/entities/_engine.py | 71 ++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py index f99a4725c..f747027ee 100644 --- a/platypush/entities/_engine.py +++ b/platypush/entities/_engine.py @@ -6,14 +6,13 @@ from typing import Iterable, List from sqlalchemy import and_, or_, inspect as schema_inspect from sqlalchemy.orm import Session -from sqlalchemy.sql.elements import Null from ._base import Entity class EntitiesEngine(Thread): # Processing queue timeout in seconds - _queue_timeout = 5. + _queue_timeout = 5.0 def __init__(self): obj_name = self.__class__.__name__ @@ -42,7 +41,8 @@ class EntitiesEngine(Thread): last_poll_time = time() while not self.should_stop and ( - time() - last_poll_time < self._queue_timeout): + time() - last_poll_time < self._queue_timeout + ): try: msg = self._queue.get(block=True, timeout=0.5) except Empty: @@ -58,44 +58,64 @@ class EntitiesEngine(Thread): self.logger.info('Stopped entities engine') - def _get_if_exist(self, session: Session, entities: Iterable[Entity]) -> Iterable[Entity]: + def _get_if_exist( + self, session: Session, entities: Iterable[Entity] + ) -> Iterable[Entity]: existing_entities = { (entity.external_id or 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.plugin == entity.plugin) - for entity in entities - ]) - ).all() + 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.plugin == entity.plugin + ) + for entity in entities + ] + ) + ) + .all() } return [ existing_entities.get( (entity.external_id or entity.name, entity.plugin), None - ) for entity in entities + ) + for entity in entities ] def _merge_entities( - self, entities: List[Entity], - existing_entities: List[Entity] + self, entities: List[Entity], existing_entities: List[Entity] ) -> List[Entity]: - new_entities = [] + def merge(entity: Entity, existing_entity: Entity) -> Entity: + inspector = schema_inspect(entity.__class__) + columns = [col.key for col in inspector.mapper.column_attrs] + for col in columns: + if 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: - inspector = schema_inspect(entity.__class__) - columns = [col.key for col in inspector.mapper.column_attrs] - for col in columns: - new_value = getattr(entity, col) - if new_value is not None and new_value.__class__ != Null: - setattr(existing_entity, col, getattr(entity, col)) + entity = merge(entity, existing_entity) - new_entities.append(existing_entity) - else: - new_entities.append(entity) + new_entities.append(entity) return new_entities @@ -107,4 +127,3 @@ class EntitiesEngine(Thread): entities = self._merge_entities(entities, existing_entities) # type: ignore session.add_all(entities) session.commit() - From 8a70f1d38ee1fc63fa7cc7e19bfd86bddd85736b Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 5 Apr 2022 22:47:44 +0200 Subject: [PATCH 15/96] Replaced deprecated sqlalchemy.ext.declarative with sqlalchemy.orm --- platypush/backend/covid19/__init__.py | 73 ++++--- platypush/backend/github/__init__.py | 120 +++++++++--- .../backend/http/request/rss/__init__.py | 183 ++++++++++++------ platypush/backend/mail/__init__.py | 154 ++++++++++----- platypush/entities/_base.py | 19 +- platypush/plugins/media/search/local.py | 173 ++++++++++------- platypush/user/__init__.py | 70 +++++-- 7 files changed, 540 insertions(+), 252 deletions(-) diff --git a/platypush/backend/covid19/__init__.py b/platypush/backend/covid19/__init__.py index 598f10871..1be1db016 100644 --- a/platypush/backend/covid19/__init__.py +++ b/platypush/backend/covid19/__init__.py @@ -3,8 +3,7 @@ import os from typing import Optional, Union, List, Dict, Any from sqlalchemy import create_engine, Column, Integer, String, DateTime -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from platypush.backend import Backend from platypush.config import Config @@ -17,10 +16,10 @@ Session = scoped_session(sessionmaker()) class Covid19Update(Base): - """ Models the Covid19Data table """ + """Models the Covid19Data table""" __tablename__ = 'covid19data' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} country = Column(String, primary_key=True) confirmed = Column(Integer, nullable=False, default=0) @@ -40,7 +39,12 @@ class Covid19Backend(Backend): """ # noinspection PyProtectedMember - def __init__(self, country: Optional[Union[str, List[str]]], poll_seconds: Optional[float] = 3600.0, **kwargs): + def __init__( + self, + country: Optional[Union[str, List[str]]], + poll_seconds: Optional[float] = 3600.0, + **kwargs + ): """ :param country: Default country (or list of countries) to retrieve the stats for. It can either be the full country name or the country code. Special values: @@ -56,7 +60,9 @@ class Covid19Backend(Backend): super().__init__(poll_seconds=poll_seconds, **kwargs) self._plugin: Covid19Plugin = get_plugin('covid19') self.country: List[str] = self._plugin._get_countries(country) - self.workdir = os.path.join(os.path.expanduser(Config.get('workdir')), 'covid19') + self.workdir = os.path.join( + os.path.expanduser(Config.get('workdir')), 'covid19' + ) self.dbfile = os.path.join(self.workdir, 'data.db') os.makedirs(self.workdir, exist_ok=True) @@ -67,22 +73,30 @@ class Covid19Backend(Backend): self.logger.info('Stopped Covid19 backend') def _process_update(self, summary: Dict[str, Any], session: Session): - update_time = datetime.datetime.fromisoformat(summary['Date'].replace('Z', '+00:00')) + update_time = datetime.datetime.fromisoformat( + summary['Date'].replace('Z', '+00:00') + ) - self.bus.post(Covid19UpdateEvent( - country=summary['Country'], - country_code=summary['CountryCode'], - confirmed=summary['TotalConfirmed'], - deaths=summary['TotalDeaths'], - recovered=summary['TotalRecovered'], - update_time=update_time, - )) + self.bus.post( + Covid19UpdateEvent( + country=summary['Country'], + country_code=summary['CountryCode'], + confirmed=summary['TotalConfirmed'], + deaths=summary['TotalDeaths'], + recovered=summary['TotalRecovered'], + update_time=update_time, + ) + ) - session.merge(Covid19Update(country=summary['CountryCode'], - confirmed=summary['TotalConfirmed'], - deaths=summary['TotalDeaths'], - recovered=summary['TotalRecovered'], - last_updated_at=update_time)) + session.merge( + Covid19Update( + country=summary['CountryCode'], + confirmed=summary['TotalConfirmed'], + deaths=summary['TotalDeaths'], + recovered=summary['TotalRecovered'], + last_updated_at=update_time, + ) + ) def loop(self): # noinspection PyUnresolvedReferences @@ -90,23 +104,30 @@ class Covid19Backend(Backend): if not summaries: return - engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False}) + engine = create_engine( + 'sqlite:///{}'.format(self.dbfile), + connect_args={'check_same_thread': False}, + ) Base.metadata.create_all(engine) Session.configure(bind=engine) session = Session() last_records = { record.country: record - for record in session.query(Covid19Update).filter(Covid19Update.country.in_(self.country)).all() + for record in session.query(Covid19Update) + .filter(Covid19Update.country.in_(self.country)) + .all() } for summary in summaries: country = summary['CountryCode'] last_record = last_records.get(country) - if not last_record or \ - summary['TotalConfirmed'] != last_record.confirmed or \ - summary['TotalDeaths'] != last_record.deaths or \ - summary['TotalRecovered'] != last_record.recovered: + if ( + not last_record + or summary['TotalConfirmed'] != last_record.confirmed + or summary['TotalDeaths'] != last_record.deaths + or summary['TotalRecovered'] != last_record.recovered + ): self._process_update(summary=summary, session=session) session.commit() diff --git a/platypush/backend/github/__init__.py b/platypush/backend/github/__init__.py index ad49b73d4..0a1bc3e67 100644 --- a/platypush/backend/github/__init__.py +++ b/platypush/backend/github/__init__.py @@ -6,15 +6,28 @@ from typing import Optional, List import requests from sqlalchemy import create_engine, Column, String, DateTime -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker, scoped_session +from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from platypush.backend import Backend from platypush.config import Config -from platypush.message.event.github import GithubPushEvent, GithubCommitCommentEvent, GithubCreateEvent, \ - GithubDeleteEvent, GithubEvent, GithubForkEvent, GithubWikiEvent, GithubIssueCommentEvent, GithubIssueEvent, \ - GithubMemberEvent, GithubPublicEvent, GithubPullRequestEvent, GithubPullRequestReviewCommentEvent, \ - GithubReleaseEvent, GithubSponsorshipEvent, GithubWatchEvent +from platypush.message.event.github import ( + GithubPushEvent, + GithubCommitCommentEvent, + GithubCreateEvent, + GithubDeleteEvent, + GithubEvent, + GithubForkEvent, + GithubWikiEvent, + GithubIssueCommentEvent, + GithubIssueEvent, + GithubMemberEvent, + GithubPublicEvent, + GithubPullRequestEvent, + GithubPullRequestReviewCommentEvent, + GithubReleaseEvent, + GithubSponsorshipEvent, + GithubWatchEvent, +) Base = declarative_base() Session = scoped_session(sessionmaker()) @@ -71,8 +84,17 @@ class GithubBackend(Backend): _base_url = 'https://api.github.com' - def __init__(self, user: str, user_token: str, repos: Optional[List[str]] = None, org: Optional[str] = None, - poll_seconds: int = 60, max_events_per_scan: Optional[int] = 10, *args, **kwargs): + def __init__( + self, + user: str, + user_token: str, + repos: Optional[List[str]] = None, + org: Optional[str] = None, + poll_seconds: int = 60, + max_events_per_scan: Optional[int] = 10, + *args, + **kwargs + ): """ If neither ``repos`` nor ``org`` is specified then the backend will monitor all new events on user level. @@ -102,17 +124,23 @@ class GithubBackend(Backend): def _request(self, uri: str, method: str = 'get') -> dict: method = getattr(requests, method.lower()) - return method(self._base_url + uri, auth=(self.user, self.user_token), - headers={'Accept': 'application/vnd.github.v3+json'}).json() + return method( + self._base_url + uri, + auth=(self.user, self.user_token), + headers={'Accept': 'application/vnd.github.v3+json'}, + ).json() def _init_db(self): - engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False}) + engine = create_engine( + 'sqlite:///{}'.format(self.dbfile), + connect_args={'check_same_thread': False}, + ) Base.metadata.create_all(engine) Session.configure(bind=engine) @staticmethod def _to_datetime(time_string: str) -> datetime.datetime: - """ Convert ISO 8061 string format with leading 'Z' into something understandable by Python """ + """Convert ISO 8061 string format with leading 'Z' into something understandable by Python""" return datetime.datetime.fromisoformat(time_string[:-1] + '+00:00') @staticmethod @@ -128,7 +156,11 @@ class GithubBackend(Backend): def _get_last_event_time(self, uri: str): with self.db_lock: record = self._get_or_create_resource(uri=uri, session=Session()) - return record.last_updated_at.replace(tzinfo=datetime.timezone.utc) if record.last_updated_at else None + return ( + record.last_updated_at.replace(tzinfo=datetime.timezone.utc) + if record.last_updated_at + else None + ) def _update_last_event_time(self, uri: str, last_updated_at: datetime.datetime): with self.db_lock: @@ -158,9 +190,18 @@ class GithubBackend(Backend): 'WatchEvent': GithubWatchEvent, } - event_type = event_mapping[event['type']] if event['type'] in event_mapping else GithubEvent - return event_type(event_type=event['type'], actor=event['actor'], repo=event.get('repo', {}), - payload=event['payload'], created_at=cls._to_datetime(event['created_at'])) + event_type = ( + event_mapping[event['type']] + if event['type'] in event_mapping + else GithubEvent + ) + return event_type( + event_type=event['type'], + actor=event['actor'], + repo=event.get('repo', {}), + payload=event['payload'], + created_at=cls._to_datetime(event['created_at']), + ) def _events_monitor(self, uri: str, method: str = 'get'): def thread(): @@ -175,7 +216,10 @@ class GithubBackend(Backend): fired_events = [] for event in events: - if self.max_events_per_scan and len(fired_events) >= self.max_events_per_scan: + if ( + self.max_events_per_scan + and len(fired_events) >= self.max_events_per_scan + ): break event_time = self._to_datetime(event['created_at']) @@ -189,14 +233,19 @@ class GithubBackend(Backend): for event in fired_events: self.bus.post(event) - self._update_last_event_time(uri=uri, last_updated_at=new_last_event_time) + self._update_last_event_time( + uri=uri, last_updated_at=new_last_event_time + ) except Exception as e: - self.logger.warning('Encountered exception while fetching events from {}: {}'.format( - uri, str(e))) + self.logger.warning( + 'Encountered exception while fetching events from {}: {}'.format( + uri, str(e) + ) + ) self.logger.exception(e) - finally: - if self.wait_stop(timeout=self.poll_seconds): - break + + if self.wait_stop(timeout=self.poll_seconds): + break return thread @@ -206,12 +255,30 @@ class GithubBackend(Backend): if self.repos: for repo in self.repos: - monitors.append(threading.Thread(target=self._events_monitor('/networks/{repo}/events'.format(repo=repo)))) + monitors.append( + threading.Thread( + target=self._events_monitor( + '/networks/{repo}/events'.format(repo=repo) + ) + ) + ) if self.org: - monitors.append(threading.Thread(target=self._events_monitor('/orgs/{org}/events'.format(org=self.org)))) + monitors.append( + threading.Thread( + target=self._events_monitor( + '/orgs/{org}/events'.format(org=self.org) + ) + ) + ) if not (self.repos or self.org): - monitors.append(threading.Thread(target=self._events_monitor('/users/{user}/events'.format(user=self.user)))) + monitors.append( + threading.Thread( + target=self._events_monitor( + '/users/{user}/events'.format(user=self.user) + ) + ) + ) for monitor in monitors: monitor.start() @@ -222,4 +289,5 @@ class GithubBackend(Backend): self.logger.info('Github backend terminated') + # vim:sw=4:ts=4:et: diff --git a/platypush/backend/http/request/rss/__init__.py b/platypush/backend/http/request/rss/__init__.py index b16565dc5..7ca6d9c64 100644 --- a/platypush/backend/http/request/rss/__init__.py +++ b/platypush/backend/http/request/rss/__init__.py @@ -2,11 +2,17 @@ import datetime import enum import os -from sqlalchemy import create_engine, Column, Integer, String, DateTime, \ - Enum, ForeignKey +from sqlalchemy import ( + create_engine, + Column, + Integer, + String, + DateTime, + Enum, + ForeignKey, +) -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from sqlalchemy.sql.expression import func from platypush.backend.http.request import HttpRequest @@ -44,18 +50,31 @@ class RssUpdates(HttpRequest): """ - user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + \ - 'Chrome/62.0.3202.94 Safari/537.36' + user_agent = ( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/62.0.3202.94 Safari/537.36' + ) - def __init__(self, url, title=None, headers=None, params=None, max_entries=None, - extract_content=False, digest_format=None, user_agent: str = user_agent, - body_style: str = 'font-size: 22px; ' + - 'font-family: "Merriweather", Georgia, "Times New Roman", Times, serif;', - title_style: str = 'margin-top: 30px', - subtitle_style: str = 'margin-top: 10px; page-break-after: always', - article_title_style: str = 'page-break-before: always', - article_link_style: str = 'color: #555; text-decoration: none; border-bottom: 1px dotted', - article_content_style: str = '', *argv, **kwargs): + def __init__( + self, + url, + title=None, + headers=None, + params=None, + max_entries=None, + extract_content=False, + digest_format=None, + user_agent: str = user_agent, + body_style: str = 'font-size: 22px; ' + + 'font-family: "Merriweather", Georgia, "Times New Roman", Times, serif;', + title_style: str = 'margin-top: 30px', + subtitle_style: str = 'margin-top: 10px; page-break-after: always', + article_title_style: str = 'page-break-before: always', + article_link_style: str = 'color: #555; text-decoration: none; border-bottom: 1px dotted', + article_content_style: str = '', + *argv, + **kwargs, + ): """ :param url: URL to the RSS feed to be monitored. :param title: Optional title for the feed. @@ -91,7 +110,9 @@ class RssUpdates(HttpRequest): # If true, then the http.webpage plugin will be used to parse the content self.extract_content = extract_content - self.digest_format = digest_format.lower() if digest_format else None # Supported formats: html, pdf + self.digest_format = ( + digest_format.lower() if digest_format else None + ) # Supported formats: html, pdf os.makedirs(os.path.expanduser(os.path.dirname(self.dbfile)), exist_ok=True) @@ -119,7 +140,11 @@ class RssUpdates(HttpRequest): @staticmethod def _get_latest_update(session, source_id): - return session.query(func.max(FeedEntry.published)).filter_by(source_id=source_id).scalar() + return ( + session.query(func.max(FeedEntry.published)) + .filter_by(source_id=source_id) + .scalar() + ) def _parse_entry_content(self, link): self.logger.info('Extracting content from {}'.format(link)) @@ -130,14 +155,20 @@ class RssUpdates(HttpRequest): errors = response.errors if not output: - self.logger.warning('Mercury parser error: {}'.format(errors or '[unknown error]')) + self.logger.warning( + 'Mercury parser error: {}'.format(errors or '[unknown error]') + ) return return output.get('content') def get_new_items(self, response): import feedparser - engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False}) + + engine = create_engine( + 'sqlite:///{}'.format(self.dbfile), + connect_args={'check_same_thread': False}, + ) Base.metadata.create_all(engine) Session.configure(bind=engine) @@ -157,12 +188,16 @@ class RssUpdates(HttpRequest): content = u''' <h1 style="{title_style}">{title}</h1> - <h2 style="{subtitle_style}">Feeds digest generated on {creation_date}</h2>'''.\ - format(title_style=self.title_style, title=self.title, subtitle_style=self.subtitle_style, - creation_date=datetime.datetime.now().strftime('%d %B %Y, %H:%M')) + <h2 style="{subtitle_style}">Feeds digest generated on {creation_date}</h2>'''.format( + title_style=self.title_style, + title=self.title, + subtitle_style=self.subtitle_style, + creation_date=datetime.datetime.now().strftime('%d %B %Y, %H:%M'), + ) - self.logger.info('Parsed {:d} items from RSS feed <{}>' - .format(len(feed.entries), self.url)) + self.logger.info( + 'Parsed {:d} items from RSS feed <{}>'.format(len(feed.entries), self.url) + ) for entry in feed.entries: if not entry.published_parsed: @@ -171,9 +206,10 @@ class RssUpdates(HttpRequest): try: entry_timestamp = datetime.datetime(*entry.published_parsed[:6]) - if latest_update is None \ - or entry_timestamp > latest_update: - self.logger.info('Processed new item from RSS feed <{}>'.format(self.url)) + if latest_update is None or entry_timestamp > latest_update: + self.logger.info( + 'Processed new item from RSS feed <{}>'.format(self.url) + ) entry.summary = entry.summary if hasattr(entry, 'summary') else None if self.extract_content: @@ -188,9 +224,13 @@ class RssUpdates(HttpRequest): <a href="{link}" target="_blank" style="{article_link_style}">{title}</a> </h1> <div class="_parsed-content" style="{article_content_style}">{content}</div>'''.format( - article_title_style=self.article_title_style, article_link_style=self.article_link_style, - article_content_style=self.article_content_style, link=entry.link, title=entry.title, - content=entry.content) + article_title_style=self.article_title_style, + article_link_style=self.article_link_style, + article_content_style=self.article_content_style, + link=entry.link, + title=entry.title, + content=entry.content, + ) e = { 'entry_id': entry.id, @@ -207,21 +247,32 @@ class RssUpdates(HttpRequest): if self.max_entries and len(entries) > self.max_entries: break except Exception as e: - self.logger.warning('Exception encountered while parsing RSS ' + - 'RSS feed {}: {}'.format(entry.link, str(e))) + self.logger.warning( + 'Exception encountered while parsing RSS ' + + f'RSS feed {entry.link}: {e}' + ) self.logger.exception(e) source_record.last_updated_at = parse_start_time digest_filename = None if entries: - self.logger.info('Parsed {} new entries from the RSS feed {}'.format( - len(entries), self.title)) + self.logger.info( + 'Parsed {} new entries from the RSS feed {}'.format( + len(entries), self.title + ) + ) if self.digest_format: - digest_filename = os.path.join(self.workdir, 'cache', '{}_{}.{}'.format( - datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), - self.title, self.digest_format)) + digest_filename = os.path.join( + self.workdir, + 'cache', + '{}_{}.{}'.format( + datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), + self.title, + self.digest_format, + ), + ) os.makedirs(os.path.dirname(digest_filename), exist_ok=True) @@ -233,12 +284,15 @@ class RssUpdates(HttpRequest): </head> <body style="{body_style}">{content}</body> </html> - '''.format(title=self.title, body_style=self.body_style, content=content) + '''.format( + title=self.title, body_style=self.body_style, content=content + ) with open(digest_filename, 'w', encoding='utf-8') as f: f.write(content) elif self.digest_format == 'pdf': from weasyprint import HTML, CSS + try: from weasyprint.fonts import FontConfiguration except ImportError: @@ -246,37 +300,47 @@ class RssUpdates(HttpRequest): body_style = 'body { ' + self.body_style + ' }' font_config = FontConfiguration() - css = [CSS('https://fonts.googleapis.com/css?family=Merriweather'), - CSS(string=body_style, font_config=font_config)] + css = [ + CSS('https://fonts.googleapis.com/css?family=Merriweather'), + CSS(string=body_style, font_config=font_config), + ] HTML(string=content).write_pdf(digest_filename, stylesheets=css) else: - raise RuntimeError('Unsupported format: {}. Supported formats: ' + - 'html or pdf'.format(self.digest_format)) + raise RuntimeError( + f'Unsupported format: {self.digest_format}. Supported formats: html, pdf' + ) - digest_entry = FeedDigest(source_id=source_record.id, - format=self.digest_format, - filename=digest_filename) + digest_entry = FeedDigest( + source_id=source_record.id, + format=self.digest_format, + filename=digest_filename, + ) session.add(digest_entry) - self.logger.info('{} digest ready: {}'.format(self.digest_format, digest_filename)) + self.logger.info( + '{} digest ready: {}'.format(self.digest_format, digest_filename) + ) session.commit() self.logger.info('Parsing RSS feed {}: completed'.format(self.title)) - return NewFeedEvent(request=dict(self), response=entries, - source_id=source_record.id, - source_title=source_record.title, - title=self.title, - digest_format=self.digest_format, - digest_filename=digest_filename) + return NewFeedEvent( + request=dict(self), + response=entries, + source_id=source_record.id, + source_title=source_record.title, + title=self.title, + digest_format=self.digest_format, + digest_filename=digest_filename, + ) class FeedSource(Base): - """ Models the FeedSource table, containing RSS sources to be parsed """ + """Models the FeedSource table, containing RSS sources to be parsed""" __tablename__ = 'FeedSource' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) title = Column(String) @@ -285,10 +349,10 @@ class FeedSource(Base): class FeedEntry(Base): - """ Models the FeedEntry table, which contains RSS entries """ + """Models the FeedEntry table, which contains RSS entries""" __tablename__ = 'FeedEntry' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) entry_id = Column(String) @@ -301,15 +365,15 @@ class FeedEntry(Base): class FeedDigest(Base): - """ Models the FeedDigest table, containing feed digests either in HTML - or PDF format """ + """Models the FeedDigest table, containing feed digests either in HTML + or PDF format""" class DigestFormat(enum.Enum): html = 1 pdf = 2 __tablename__ = 'FeedDigest' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) source_id = Column(Integer, ForeignKey('FeedSource.id'), nullable=False) @@ -317,4 +381,5 @@ class FeedDigest(Base): filename = Column(String, nullable=False) created_at = Column(DateTime, nullable=False, default=datetime.datetime.utcnow) + # vim:sw=4:ts=4:et: diff --git a/platypush/backend/mail/__init__.py b/platypush/backend/mail/__init__.py index 686075286..84f1f8ab6 100644 --- a/platypush/backend/mail/__init__.py +++ b/platypush/backend/mail/__init__.py @@ -8,15 +8,18 @@ from queue import Queue, Empty from threading import Thread, RLock from typing import List, Dict, Any, Optional, Tuple -from sqlalchemy import create_engine, Column, Integer, String, DateTime -import sqlalchemy.engine as engine -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import engine, create_engine, Column, Integer, String, DateTime +from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from platypush.backend import Backend from platypush.config import Config from platypush.context import get_plugin -from platypush.message.event.mail import MailReceivedEvent, MailSeenEvent, MailFlaggedEvent, MailUnflaggedEvent +from platypush.message.event.mail import ( + MailReceivedEvent, + MailSeenEvent, + MailFlaggedEvent, + MailUnflaggedEvent, +) from platypush.plugins.mail import MailInPlugin, Mail # <editor-fold desc="Database tables"> @@ -25,7 +28,8 @@ Session = scoped_session(sessionmaker()) class MailboxStatus(Base): - """ Models the MailboxStatus table, containing information about the state of a monitored mailbox. """ + """Models the MailboxStatus table, containing information about the state of a monitored mailbox.""" + __tablename__ = 'MailboxStatus' mailbox_id = Column(Integer, primary_key=True) @@ -64,8 +68,13 @@ class MailBackend(Backend): """ - def __init__(self, mailboxes: List[Dict[str, Any]], timeout: Optional[int] = 60, poll_seconds: Optional[int] = 60, - **kwargs): + def __init__( + self, + mailboxes: List[Dict[str, Any]], + timeout: Optional[int] = 60, + poll_seconds: Optional[int] = 60, + **kwargs + ): """ :param mailboxes: List of mailboxes to be monitored. Each mailbox entry contains a ``plugin`` attribute to identify the :class:`platypush.plugins.mail.MailInPlugin` plugin that will be used (e.g. ``mail.imap``) @@ -128,9 +137,13 @@ class MailBackend(Backend): # Parse mailboxes for i, mbox in enumerate(mailboxes): - assert 'plugin' in mbox, 'No plugin attribute specified for mailbox n.{}'.format(i) + assert ( + 'plugin' in mbox + ), 'No plugin attribute specified for mailbox n.{}'.format(i) plugin = get_plugin(mbox.pop('plugin')) - assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format(plugin) + assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format( + plugin + ) name = mbox.pop('name') if 'name' in mbox else 'Mailbox #{}'.format(i + 1) self.mailboxes.append(Mailbox(plugin=plugin, name=name, args=mbox)) @@ -144,7 +157,10 @@ class MailBackend(Backend): # <editor-fold desc="Database methods"> def _db_get_engine(self) -> engine.Engine: - return create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False}) + return create_engine( + 'sqlite:///{}'.format(self.dbfile), + connect_args={'check_same_thread': False}, + ) def _db_load_mailboxes_status(self) -> None: mailbox_ids = list(range(len(self.mailboxes))) @@ -153,12 +169,18 @@ class MailBackend(Backend): session = Session() records = { record.mailbox_id: record - for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all() + for record in session.query(MailboxStatus) + .filter(MailboxStatus.mailbox_id.in_(mailbox_ids)) + .all() } - for mbox_id, mbox in enumerate(self.mailboxes): + for mbox_id, _ in enumerate(self.mailboxes): if mbox_id not in records: - record = MailboxStatus(mailbox_id=mbox_id, unseen_message_ids='[]', flagged_message_ids='[]') + record = MailboxStatus( + mailbox_id=mbox_id, + unseen_message_ids='[]', + flagged_message_ids='[]', + ) session.add(record) else: record = records[mbox_id] @@ -170,19 +192,25 @@ class MailBackend(Backend): session.commit() - def _db_get_mailbox_status(self, mailbox_ids: List[int]) -> Dict[int, MailboxStatus]: + def _db_get_mailbox_status( + self, mailbox_ids: List[int] + ) -> Dict[int, MailboxStatus]: with self._db_lock: session = Session() return { record.mailbox_id: record - for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all() + for record in session.query(MailboxStatus) + .filter(MailboxStatus.mailbox_id.in_(mailbox_ids)) + .all() } # </editor-fold> # <editor-fold desc="Parse unread messages logic"> @staticmethod - def _check_thread(unread_queue: Queue, flagged_queue: Queue, plugin: MailInPlugin, **args): + def _check_thread( + unread_queue: Queue, flagged_queue: Queue, plugin: MailInPlugin, **args + ): def thread(): # noinspection PyUnresolvedReferences unread = plugin.search_unseen_messages(**args).output @@ -194,8 +222,9 @@ class MailBackend(Backend): return thread - def _get_unread_seen_msgs(self, mailbox_idx: int, unread_msgs: Dict[int, Mail]) \ - -> Tuple[Dict[int, Mail], Dict[int, Mail]]: + def _get_unread_seen_msgs( + self, mailbox_idx: int, unread_msgs: Dict[int, Mail] + ) -> Tuple[Dict[int, Mail], Dict[int, Mail]]: prev_unread_msgs = self._unread_msgs[mailbox_idx] return { @@ -208,35 +237,51 @@ class MailBackend(Backend): if msg_id not in unread_msgs } - def _get_flagged_unflagged_msgs(self, mailbox_idx: int, flagged_msgs: Dict[int, Mail]) \ - -> Tuple[Dict[int, Mail], Dict[int, Mail]]: + def _get_flagged_unflagged_msgs( + self, mailbox_idx: int, flagged_msgs: Dict[int, Mail] + ) -> Tuple[Dict[int, Mail], Dict[int, Mail]]: prev_flagged_msgs = self._flagged_msgs[mailbox_idx] return { - msg_id: flagged_msgs[msg_id] - for msg_id in flagged_msgs - if msg_id not in prev_flagged_msgs - }, { - msg_id: prev_flagged_msgs[msg_id] - for msg_id in prev_flagged_msgs - if msg_id not in flagged_msgs - } + msg_id: flagged_msgs[msg_id] + for msg_id in flagged_msgs + if msg_id not in prev_flagged_msgs + }, { + msg_id: prev_flagged_msgs[msg_id] + for msg_id in prev_flagged_msgs + if msg_id not in flagged_msgs + } - def _process_msg_events(self, mailbox_id: int, unread: List[Mail], seen: List[Mail], - flagged: List[Mail], unflagged: List[Mail], last_checked_date: Optional[datetime] = None): + def _process_msg_events( + self, + mailbox_id: int, + unread: List[Mail], + seen: List[Mail], + flagged: List[Mail], + unflagged: List[Mail], + last_checked_date: Optional[datetime] = None, + ): for msg in unread: if msg.date and last_checked_date and msg.date < last_checked_date: continue - self.bus.post(MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)) + self.bus.post( + MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg) + ) for msg in seen: - self.bus.post(MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)) + self.bus.post( + MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg) + ) for msg in flagged: - self.bus.post(MailFlaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)) + self.bus.post( + MailFlaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg) + ) for msg in unflagged: - self.bus.post(MailUnflaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)) + self.bus.post( + MailUnflaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg) + ) def _check_mailboxes(self) -> List[Tuple[Dict[int, Mail], Dict[int, Mail]]]: workers = [] @@ -245,8 +290,14 @@ class MailBackend(Backend): for mbox in self.mailboxes: unread_queue, flagged_queue = [Queue()] * 2 - worker = Thread(target=self._check_thread(unread_queue=unread_queue, flagged_queue=flagged_queue, - plugin=mbox.plugin, **mbox.args)) + worker = Thread( + target=self._check_thread( + unread_queue=unread_queue, + flagged_queue=flagged_queue, + plugin=mbox.plugin, + **mbox.args + ) + ) worker.start() workers.append(worker) queues.append((unread_queue, flagged_queue)) @@ -260,7 +311,11 @@ class MailBackend(Backend): flagged = flagged_queue.get(timeout=self.timeout) results.append((unread, flagged)) except Empty: - self.logger.warning('Checks on mailbox #{} timed out after {} seconds'.format(i + 1, self.timeout)) + self.logger.warning( + 'Checks on mailbox #{} timed out after {} seconds'.format( + i + 1, self.timeout + ) + ) continue return results @@ -276,16 +331,25 @@ class MailBackend(Backend): for i, (unread, flagged) in enumerate(results): unread_msgs, seen_msgs = self._get_unread_seen_msgs(i, unread) flagged_msgs, unflagged_msgs = self._get_flagged_unflagged_msgs(i, flagged) - self._process_msg_events(i, unread=list(unread_msgs.values()), seen=list(seen_msgs.values()), - flagged=list(flagged_msgs.values()), unflagged=list(unflagged_msgs.values()), - last_checked_date=mailbox_statuses[i].last_checked_date) + self._process_msg_events( + i, + unread=list(unread_msgs.values()), + seen=list(seen_msgs.values()), + flagged=list(flagged_msgs.values()), + unflagged=list(unflagged_msgs.values()), + last_checked_date=mailbox_statuses[i].last_checked_date, + ) self._unread_msgs[i] = unread self._flagged_msgs[i] = flagged - records.append(MailboxStatus(mailbox_id=i, - unseen_message_ids=json.dumps([msg_id for msg_id in unread.keys()]), - flagged_message_ids=json.dumps([msg_id for msg_id in flagged.keys()]), - last_checked_date=datetime.now())) + records.append( + MailboxStatus( + mailbox_id=i, + unseen_message_ids=json.dumps(list(unread.keys())), + flagged_message_ids=json.dumps(list(flagged.keys())), + last_checked_date=datetime.now(), + ) + ) with self._db_lock: session = Session() diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py index 80be23b83..fb38fa460 100644 --- a/platypush/entities/_base.py +++ b/platypush/entities/_base.py @@ -5,7 +5,7 @@ from typing import Mapping, Type import pkgutil from sqlalchemy import Column, Index, Integer, String, DateTime, JSON, UniqueConstraint -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import declarative_base Base = declarative_base() entities_registry: Mapping[Type['Entity'], Mapping] = {} @@ -24,14 +24,16 @@ class Entity(Base): type = Column(String, nullable=False, index=True) plugin = Column(String, nullable=False) data = Column(JSON, default=dict) - created_at = Column(DateTime(timezone=False), default=datetime.utcnow(), nullable=False) - updated_at = Column(DateTime(timezone=False), default=datetime.utcnow(), onupdate=datetime.now()) + created_at = Column( + DateTime(timezone=False), default=datetime.utcnow(), nullable=False + ) + updated_at = Column( + DateTime(timezone=False), default=datetime.utcnow(), onupdate=datetime.now() + ) UniqueConstraint(external_id, plugin) - __table_args__ = ( - Index(name, plugin), - ) + __table_args__ = (Index(name, plugin),) __mapper_args__ = { 'polymorphic_identity': __tablename__, @@ -41,13 +43,14 @@ class Entity(Base): 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 + onerror=lambda _: None, ): try: mod_loader = loader.find_module(modname) # type: ignore @@ -65,9 +68,9 @@ def _discover_entity_types(): def init_entities_db(): from platypush.context import get_plugin + _discover_entity_types() db = get_plugin('db') assert db engine = db.get_engine() db.create_all(engine, Base) - diff --git a/platypush/plugins/media/search/local.py b/platypush/plugins/media/search/local.py index 3298dfe17..76868aa4f 100644 --- a/platypush/plugins/media/search/local.py +++ b/platypush/plugins/media/search/local.py @@ -3,9 +3,16 @@ import os import re import time -from sqlalchemy import create_engine, Column, Integer, String, DateTime, PrimaryKeyConstraint, ForeignKey -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import ( + create_engine, + Column, + Integer, + String, + DateTime, + PrimaryKeyConstraint, + ForeignKey, +) +from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from sqlalchemy.sql.expression import func from platypush.config import Config @@ -38,7 +45,8 @@ class LocalMediaSearcher(MediaSearcher): if not self._db_engine: self._db_engine = create_engine( 'sqlite:///{}'.format(self.db_file), - connect_args={'check_same_thread': False}) + connect_args={'check_same_thread': False}, + ) Base.metadata.create_all(self._db_engine) Session.configure(bind=self._db_engine) @@ -57,27 +65,30 @@ class LocalMediaSearcher(MediaSearcher): @classmethod def _get_last_modify_time(cls, path, recursive=False): - return max([os.path.getmtime(p) for p, _, _ in os.walk(path)]) \ - if recursive else os.path.getmtime(path) + return ( + max([os.path.getmtime(p) for p, _, _ in os.walk(path)]) + if recursive + else os.path.getmtime(path) + ) @classmethod - def _has_directory_changed_since_last_indexing(self, dir_record): + def _has_directory_changed_since_last_indexing(cls, dir_record): if not dir_record.last_indexed_at: return True - return datetime.datetime.fromtimestamp( - self._get_last_modify_time(dir_record.path)) > dir_record.last_indexed_at + return ( + datetime.datetime.fromtimestamp(cls._get_last_modify_time(dir_record.path)) + > dir_record.last_indexed_at + ) @classmethod def _matches_query(cls, filename, query): filename = filename.lower() - query_tokens = [_.lower() for _ in re.split( - cls._filename_separators, query.strip())] + query_tokens = [ + _.lower() for _ in re.split(cls._filename_separators, query.strip()) + ] - for token in query_tokens: - if token not in filename: - return False - return True + return all(token in filename for token in query_tokens) @classmethod def _sync_token_records(cls, session, *tokens): @@ -85,9 +96,12 @@ class LocalMediaSearcher(MediaSearcher): if not tokens: return [] - records = {record.token: record for record in - session.query(MediaToken).filter( - MediaToken.token.in_(tokens)).all()} + records = { + record.token: record + for record in session.query(MediaToken) + .filter(MediaToken.token.in_(tokens)) + .all() + } for token in tokens: if token in records: @@ -97,13 +111,11 @@ class LocalMediaSearcher(MediaSearcher): records[token] = record session.commit() - return session.query(MediaToken).filter( - MediaToken.token.in_(tokens)).all() + return session.query(MediaToken).filter(MediaToken.token.in_(tokens)).all() @classmethod def _get_file_records(cls, dir_record, session): - return session.query(MediaFile).filter_by( - directory_id=dir_record.id).all() + return session.query(MediaFile).filter_by(directory_id=dir_record.id).all() def scan(self, media_dir, session=None, dir_record=None): """ @@ -121,17 +133,19 @@ class LocalMediaSearcher(MediaSearcher): dir_record = self._get_or_create_dir_entry(session, media_dir) if not os.path.isdir(media_dir): - self.logger.info('Directory {} is no longer accessible, removing it'. - format(media_dir)) - session.query(MediaDirectory) \ - .filter(MediaDirectory.path == media_dir) \ - .delete(synchronize_session='fetch') + self.logger.info( + 'Directory {} is no longer accessible, removing it'.format(media_dir) + ) + session.query(MediaDirectory).filter( + MediaDirectory.path == media_dir + ).delete(synchronize_session='fetch') return stored_file_records = { - f.path: f for f in self._get_file_records(dir_record, session)} + f.path: f for f in self._get_file_records(dir_record, session) + } - for path, dirs, files in os.walk(media_dir): + for path, _, files in os.walk(media_dir): for filename in files: filepath = os.path.join(path, filename) @@ -142,26 +156,32 @@ class LocalMediaSearcher(MediaSearcher): del stored_file_records[filepath] continue - if not MediaPlugin.is_video_file(filename) and \ - not MediaPlugin.is_audio_file(filename): + if not MediaPlugin.is_video_file( + filename + ) and not MediaPlugin.is_audio_file(filename): continue self.logger.debug('Syncing item {}'.format(filepath)) - tokens = [_.lower() for _ in re.split(self._filename_separators, - filename.strip())] + tokens = [ + _.lower() + for _ in re.split(self._filename_separators, filename.strip()) + ] token_records = self._sync_token_records(session, *tokens) - file_record = MediaFile.build(directory_id=dir_record.id, - path=filepath) + file_record = MediaFile.build(directory_id=dir_record.id, path=filepath) session.add(file_record) session.commit() - file_record = session.query(MediaFile).filter_by( - directory_id=dir_record.id, path=filepath).one() + file_record = ( + session.query(MediaFile) + .filter_by(directory_id=dir_record.id, path=filepath) + .one() + ) for token_record in token_records: - file_token = MediaFileToken.build(file_id=file_record.id, - token_id=token_record.id) + file_token = MediaFileToken.build( + file_id=file_record.id, token_id=token_record.id + ) session.add(file_token) # stored_file_records should now only contain the records of the files @@ -169,15 +189,20 @@ class LocalMediaSearcher(MediaSearcher): if stored_file_records: self.logger.info( 'Removing references to {} deleted media items from {}'.format( - len(stored_file_records), media_dir)) + len(stored_file_records), media_dir + ) + ) - session.query(MediaFile).filter(MediaFile.id.in_( - [record.id for record in stored_file_records.values()] - )).delete(synchronize_session='fetch') + session.query(MediaFile).filter( + MediaFile.id.in_([record.id for record in stored_file_records.values()]) + ).delete(synchronize_session='fetch') dir_record.last_indexed_at = datetime.datetime.now() - self.logger.info('Scanned {} in {} seconds'.format( - media_dir, int(time.time() - index_start_time))) + self.logger.info( + 'Scanned {} in {} seconds'.format( + media_dir, int(time.time() - index_start_time) + ) + ) session.commit() @@ -197,25 +222,30 @@ class LocalMediaSearcher(MediaSearcher): dir_record = self._get_or_create_dir_entry(session, media_dir) if self._has_directory_changed_since_last_indexing(dir_record): - self.logger.info('{} has changed since last indexing, '.format( - media_dir) + 're-indexing') + self.logger.info( + '{} has changed since last indexing, '.format(media_dir) + + 're-indexing' + ) self.scan(media_dir, session=session, dir_record=dir_record) - query_tokens = [_.lower() for _ in re.split( - self._filename_separators, query.strip())] + query_tokens = [ + _.lower() for _ in re.split(self._filename_separators, query.strip()) + ] - for file_record in session.query(MediaFile.path). \ - join(MediaFileToken). \ - join(MediaToken). \ - filter(MediaToken.token.in_(query_tokens)). \ - group_by(MediaFile.path). \ - having(func.count(MediaFileToken.token_id) >= len(query_tokens)): + for file_record in ( + session.query(MediaFile.path) + .join(MediaFileToken) + .join(MediaToken) + .filter(MediaToken.token.in_(query_tokens)) + .group_by(MediaFile.path) + .having(func.count(MediaFileToken.token_id) >= len(query_tokens)) + ): if os.path.isfile(file_record.path): results[file_record.path] = { 'url': 'file://' + file_record.path, 'title': os.path.basename(file_record.path), - 'size': os.path.getsize(file_record.path) + 'size': os.path.getsize(file_record.path), } return results.values() @@ -223,11 +253,12 @@ class LocalMediaSearcher(MediaSearcher): # --- Table definitions + class MediaDirectory(Base): - """ Models the MediaDirectory table """ + """Models the MediaDirectory table""" __tablename__ = 'MediaDirectory' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) path = Column(String) @@ -243,14 +274,15 @@ class MediaDirectory(Base): class MediaFile(Base): - """ Models the MediaFile table """ + """Models the MediaFile table""" __tablename__ = 'MediaFile' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) - directory_id = Column(Integer, ForeignKey( - 'MediaDirectory.id', ondelete='CASCADE'), nullable=False) + directory_id = Column( + Integer, ForeignKey('MediaDirectory.id', ondelete='CASCADE'), nullable=False + ) path = Column(String, nullable=False, unique=True) indexed_at = Column(DateTime) @@ -265,10 +297,10 @@ class MediaFile(Base): class MediaToken(Base): - """ Models the MediaToken table """ + """Models the MediaToken table""" __tablename__ = 'MediaToken' - __table_args__ = ({'sqlite_autoincrement': True}) + __table_args__ = {'sqlite_autoincrement': True} id = Column(Integer, primary_key=True) token = Column(String, nullable=False, unique=True) @@ -282,14 +314,16 @@ class MediaToken(Base): class MediaFileToken(Base): - """ Models the MediaFileToken table """ + """Models the MediaFileToken table""" __tablename__ = 'MediaFileToken' - file_id = Column(Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'), - nullable=False) - token_id = Column(Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'), - nullable=False) + file_id = Column( + Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'), nullable=False + ) + token_id = Column( + Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'), nullable=False + ) __table_args__ = (PrimaryKeyConstraint(file_id, token_id), {}) @@ -301,4 +335,5 @@ class MediaFileToken(Base): record.token_id = token_id return record + # vim:sw=4:ts=4:et: diff --git a/platypush/user/__init__.py b/platypush/user/__init__.py index 673497b7f..c6c2bcd22 100644 --- a/platypush/user/__init__.py +++ b/platypush/user/__init__.py @@ -13,11 +13,13 @@ except ImportError: from jwt import PyJWTError, encode as jwt_encode, decode as jwt_decode from sqlalchemy import Column, Integer, String, DateTime, ForeignKey -from sqlalchemy.orm import make_transient -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() @@ -68,8 +70,12 @@ class UserManager: 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() @@ -93,10 +99,16 @@ class UserManager: def authenticate_user_session(self, session_token): with self.db.get_session() as session: - user_session = session.query(UserSession).filter_by(session_token=session_token).first() + user_session = ( + session.query(UserSession) + .filter_by(session_token=session_token) + .first() + ) if not user_session or ( - user_session.expires_at and user_session.expires_at < datetime.datetime.utcnow()): + user_session.expires_at + and user_session.expires_at < datetime.datetime.utcnow() + ): return None, None user = session.query(User).filter_by(user_id=user_session.user_id).first() @@ -108,7 +120,9 @@ class UserManager: if not user: raise NameError('No such user: {}'.format(username)) - user_sessions = session.query(UserSession).filter_by(user_id=user.user_id).all() + user_sessions = ( + session.query(UserSession).filter_by(user_id=user.user_id).all() + ) for user_session in user_sessions: session.delete(user_session) @@ -118,7 +132,11 @@ class UserManager: def delete_user_session(self, session_token): with self.db.get_session() as session: - user_session = session.query(UserSession).filter_by(session_token=session_token).first() + user_session = ( + session.query(UserSession) + .filter_by(session_token=session_token) + .first() + ) if not user_session: return False @@ -134,14 +152,18 @@ class UserManager: return None if expires_at: - if isinstance(expires_at, int) or isinstance(expires_at, float): + if isinstance(expires_at, (int, float)): expires_at = datetime.datetime.fromtimestamp(expires_at) elif isinstance(expires_at, str): expires_at = datetime.datetime.fromisoformat(expires_at) - user_session = UserSession(user_id=user.user_id, session_token=self.generate_session_token(), - csrf_token=self.generate_session_token(), created_at=datetime.datetime.utcnow(), - expires_at=expires_at) + user_session = UserSession( + user_id=user.user_id, + session_token=self.generate_session_token(), + csrf_token=self.generate_session_token(), + created_at=datetime.datetime.utcnow(), + expires_at=expires_at, + ) session.add(user_session) session.commit() @@ -179,9 +201,19 @@ class UserManager: :param session_token: Session token. """ with self.db.get_session() as session: - return session.query(User).join(UserSession).filter_by(session_token=session_token).first() + 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. @@ -253,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) @@ -265,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) From fe0f3202fef81fb8a223de37f2caad8973c7d10b Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 5 Apr 2022 23:04:19 +0200 Subject: [PATCH 16/96] columns should be a property of the Entity object --- platypush/entities/_base.py | 21 ++++++++++++++++++--- platypush/entities/_engine.py | 5 ++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py index fb38fa460..1e8fed934 100644 --- a/platypush/entities/_base.py +++ b/platypush/entities/_base.py @@ -1,11 +1,20 @@ import inspect import pathlib from datetime import datetime -from typing import Mapping, Type +from typing import Mapping, Type, Tuple import pkgutil -from sqlalchemy import Column, Index, Integer, String, DateTime, JSON, UniqueConstraint -from sqlalchemy.orm import declarative_base +from sqlalchemy import ( + Column, + Index, + Integer, + String, + DateTime, + JSON, + UniqueConstraint, + inspect as schema_inspect, +) +from sqlalchemy.orm import declarative_base, ColumnProperty Base = declarative_base() entities_registry: Mapping[Type['Entity'], Mapping] = {} @@ -40,6 +49,12 @@ class Entity(Base): 'polymorphic_on': type, } + @classmethod + @property + def columns(cls) -> Tuple[ColumnProperty]: + inspector = schema_inspect(cls) + return tuple(inspector.mapper.column_attrs) + def _discover_entity_types(): from platypush.context import get_plugin diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py index f747027ee..52d7d3d4a 100644 --- a/platypush/entities/_engine.py +++ b/platypush/entities/_engine.py @@ -4,7 +4,7 @@ from threading import Thread, Event from time import time from typing import Iterable, List -from sqlalchemy import and_, or_, inspect as schema_inspect +from sqlalchemy import and_, or_ from sqlalchemy.orm import Session from ._base import Entity @@ -93,8 +93,7 @@ class EntitiesEngine(Thread): self, entities: List[Entity], existing_entities: List[Entity] ) -> List[Entity]: def merge(entity: Entity, existing_entity: Entity) -> Entity: - inspector = schema_inspect(entity.__class__) - columns = [col.key for col in inspector.mapper.column_attrs] + columns = [col.key for col in entity.columns] for col in columns: if col not in ('id', 'created_at'): setattr(existing_entity, col, getattr(entity, col)) From 91ff47167bf9ac005a08da707a6144326aa3d298 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 5 Apr 2022 23:04:57 +0200 Subject: [PATCH 17/96] Don't terminate the entities engine thread if a batch of entity records fails --- platypush/entities/_engine.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py index 52d7d3d4a..d88e2c9d7 100644 --- a/platypush/entities/_engine.py +++ b/platypush/entities/_engine.py @@ -54,7 +54,11 @@ class EntitiesEngine(Thread): if not msgs or self.should_stop: continue - self._process_entities(*msgs) + 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') From 061268cdaf151136ec600c51055cf3ebc723f34a Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 5 Apr 2022 23:22:54 +0200 Subject: [PATCH 18/96] Support for direct actions on native entities [WIP] --- platypush/entities/_base.py | 7 +++ platypush/entities/switches.py | 8 +++ platypush/plugins/switch/tplink/__init__.py | 58 +++++++++++++-------- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py index 1e8fed934..2bc102b57 100644 --- a/platypush/entities/_base.py +++ b/platypush/entities/_base.py @@ -55,6 +55,13 @@ class Entity(Base): inspector = schema_inspect(cls) return tuple(inspector.mapper.column_attrs) + 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 _discover_entity_types(): from platypush.context import get_plugin diff --git a/platypush/entities/switches.py b/platypush/entities/switches.py index 4af4ba189..ebae03e53 100644 --- a/platypush/entities/switches.py +++ b/platypush/entities/switches.py @@ -13,3 +13,11 @@ class Switch(Device): 'polymorphic_identity': __tablename__, } + def on(self): + return self.get_plugin().on(self) + + def off(self): + return self.get_plugin().off(self) + + def toggle(self): + return self.get_plugin().toggle(self) diff --git a/platypush/plugins/switch/tplink/__init__.py b/platypush/plugins/switch/tplink/__init__.py index cab5a6c13..1b1c646e1 100644 --- a/platypush/plugins/switch/tplink/__init__.py +++ b/platypush/plugins/switch/tplink/__init__.py @@ -1,7 +1,15 @@ 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 @@ -24,7 +32,8 @@ class SwitchTplinkPlugin(SwitchPlugin): 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 + 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 @@ -73,7 +82,9 @@ class SwitchTplinkPlugin(SwitchPlugin): 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 @@ -84,20 +95,23 @@ class SwitchTplinkPlugin(SwitchPlugin): 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 []) - ]) + + 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() @@ -108,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] @@ -123,7 +140,7 @@ class SwitchTplinkPlugin(SwitchPlugin): action_name = 'turn_on' if state else 'turn_off' action = getattr(device, action_name) action() - self.publish_entities([device]) # type: ignore + self.publish_entities([device]) # type: ignore return self._serialize(device) @action @@ -176,10 +193,7 @@ class SwitchTplinkPlugin(SwitchPlugin): @property def switches(self) -> List[dict]: - return [ - self._serialize(dev) - for dev in self._scan().values() - ] + return [self._serialize(dev) for dev in self._scan().values()] # vim:sw=4:ts=4:et: From d52ae2fb80c916a1ecd9b997e1ab3432dd43323b Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 5 Apr 2022 23:33:02 +0200 Subject: [PATCH 19/96] Implemented RunnablePlugin.wait_stop() utility method --- platypush/plugins/__init__.py | 25 ++++-- platypush/plugins/chat/irc/__init__.py | 111 ++++++++++++++----------- platypush/plugins/gpio/__init__.py | 28 ++++--- 3 files changed, 93 insertions(+), 71 deletions(-) diff --git a/platypush/plugins/__init__.py b/platypush/plugins/__init__.py index 8338620ec..ff69c263c 100644 --- a/platypush/plugins/__init__.py +++ b/platypush/plugins/__init__.py @@ -19,12 +19,12 @@ def action(f): result = f(*args, **kwargs) if result and isinstance(result, Response): - result.errors = result.errors \ - if isinstance(result.errors, list) else [result.errors] + result.errors = ( + result.errors if isinstance(result.errors, list) else [result.errors] + ) response = result elif isinstance(result, tuple) and len(result) == 2: - response.errors = result[1] \ - if isinstance(result[1], list) else [result[1]] + response.errors = result[1] if isinstance(result[1], list) else [result[1]] if len(response.errors) == 1 and response.errors[0] is None: response.errors = [] @@ -39,12 +39,14 @@ def action(f): return _execute_action -class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to-init] - """ Base plugin class """ +class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to-init] + """Base plugin class""" def __init__(self, **kwargs): super().__init__() - self.logger = logging.getLogger('platypush:plugin:' + get_plugin_name_by_class(self.__class__)) + self.logger = logging.getLogger( + 'platypush:plugin:' + get_plugin_name_by_class(self.__class__) + ) if 'logging' in kwargs: self.logger.setLevel(getattr(logging, kwargs['logging'].upper())) @@ -53,8 +55,9 @@ class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-t ) def run(self, method, *args, **kwargs): - assert method in self.registered_actions, '{} is not a registered action on {}'.\ - format(method, self.__class__.__name__) + assert ( + method in self.registered_actions + ), '{} is not a registered action on {}'.format(method, self.__class__.__name__) return getattr(self, method)(*args, **kwargs) @@ -62,6 +65,7 @@ class RunnablePlugin(Plugin): """ Class for runnable plugins - i.e. plugins that have a start/stop method and can be started. """ + def __init__(self, poll_interval: Optional[float] = None, **kwargs): """ :param poll_interval: How often the :meth:`.loop` function should be execute (default: None, no pause/interval). @@ -78,6 +82,9 @@ class RunnablePlugin(Plugin): def should_stop(self): return self._should_stop.is_set() + def wait_stop(self, timeout=None): + return self._should_stop.wait(timeout) + def start(self): set_thread_name(self.__class__.__name__) self._thread = threading.Thread(target=self._runner) diff --git a/platypush/plugins/chat/irc/__init__.py b/platypush/plugins/chat/irc/__init__.py index e7abdae21..681c59159 100644 --- a/platypush/plugins/chat/irc/__init__.py +++ b/platypush/plugins/chat/irc/__init__.py @@ -2,7 +2,11 @@ import os from typing import Sequence, Dict, Tuple, Union, Optional from platypush.plugins import RunnablePlugin, action -from platypush.schemas.irc import IRCServerSchema, IRCServerStatusSchema, IRCChannelSchema +from platypush.schemas.irc import ( + IRCServerSchema, + IRCServerStatusSchema, + IRCChannelSchema, +) from ._bot import IRCBot from .. import ChatPlugin @@ -59,29 +63,19 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @property def _bots_by_server(self) -> Dict[str, IRCBot]: - return { - bot.server: bot - for srv, bot in self._bots.items() - } + return {bot.server: bot for srv, bot in self._bots.items()} @property def _bots_by_server_and_port(self) -> Dict[Tuple[str, int], IRCBot]: - return { - (bot.server, bot.port): bot - for srv, bot in self._bots.items() - } + return {(bot.server, bot.port): bot for srv, bot in self._bots.items()} @property def _bots_by_alias(self) -> Dict[str, IRCBot]: - return { - bot.alias: bot - for srv, bot in self._bots.items() - if bot.alias - } + return {bot.alias: bot for srv, bot in self._bots.items() if bot.alias} def main(self): self._connect() - self._should_stop.wait() + self.wait_stop() def _connect(self): for srv, bot in self._bots.items(): @@ -109,7 +103,11 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @action def send_file( - self, file: str, server: Union[str, Tuple[str, int]], nick: str, bind_address: Optional[str] = None + self, + file: str, + server: Union[str, Tuple[str, int]], + nick: str, + bind_address: Optional[str] = None, ): """ Send a file to an IRC user over DCC connection. @@ -127,7 +125,10 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @action def send_message( - self, text: str, server: Union[str, Tuple[str, int]], target: Union[str, Sequence[str]] + self, + text: str, + server: Union[str, Tuple[str, int]], + target: Union[str, Sequence[str]], ): """ Send a message to a channel or a nick. @@ -139,15 +140,14 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): """ bot = self._get_bot(server) method = ( - bot.connection.privmsg if isinstance(target, str) + bot.connection.privmsg + if isinstance(target, str) else bot.connection.privmsg_many ) method(target, text) @action - def send_notice( - self, text: str, server: Union[str, Tuple[str, int]], target: str - ): + def send_notice(self, text: str, server: Union[str, Tuple[str, int]], target: str): """ Send a notice to a channel or a nick. @@ -192,22 +192,28 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): channel_name = channel channel = bot.channels.get(channel) assert channel, f'Not connected to channel {channel}' - return IRCChannelSchema().dump({ - 'is_invite_only': channel.is_invite_only(), - 'is_moderated': channel.is_moderated(), - 'is_protected': channel.is_protected(), - 'is_secret': channel.is_secret(), - 'name': channel_name, - 'modes': channel.modes, - 'opers': list(channel.opers()), - 'owners': channel.owners(), - 'users': list(channel.users()), - 'voiced': list(channel.voiced()), - }) + return IRCChannelSchema().dump( + { + 'is_invite_only': channel.is_invite_only(), + 'is_moderated': channel.is_moderated(), + 'is_protected': channel.is_protected(), + 'is_secret': channel.is_secret(), + 'name': channel_name, + 'modes': channel.modes, + 'opers': list(channel.opers()), + 'owners': channel.owners(), + 'users': list(channel.users()), + 'voiced': list(channel.voiced()), + } + ) @action def send_ctcp_message( - self, ctcp_type: str, body: str, server: Union[str, Tuple[str, int]], target: str + self, + ctcp_type: str, + body: str, + server: Union[str, Tuple[str, int]], + target: str, ): """ Send a CTCP message to a target. @@ -222,7 +228,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @action def send_ctcp_reply( - self, body: str, server: Union[str, Tuple[str, int]], target: str + self, body: str, server: Union[str, Tuple[str, int]], target: str ): """ Send a CTCP REPLY command. @@ -235,7 +241,9 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): bot.connection.ctcp_reply(target, body) @action - def disconnect(self, server: Union[str, Tuple[str, int]], message: Optional[str] = None): + def disconnect( + self, server: Union[str, Tuple[str, int]], message: Optional[str] = None + ): """ Disconnect from a server. @@ -246,9 +254,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): bot.connection.disconnect(message or bot.stop_message) @action - def invite( - self, nick: str, channel: str, server: Union[str, Tuple[str, int]] - ): + def invite(self, nick: str, channel: str, server: Union[str, Tuple[str, int]]): """ Invite a nick to a channel. @@ -272,7 +278,11 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @action def kick( - self, nick: str, channel: str, server: Union[str, Tuple[str, int]], reason: Optional[str] = None + self, + nick: str, + channel: str, + server: Union[str, Tuple[str, int]], + reason: Optional[str] = None, ): """ Kick a nick from a channel. @@ -286,9 +296,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): bot.connection.kick(channel, nick, reason) @action - def mode( - self, target: str, command: str, server: Union[str, Tuple[str, int]] - ): + def mode(self, target: str, command: str, server: Union[str, Tuple[str, int]]): """ Send a MODE command on the selected target. @@ -324,8 +332,10 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): @action def part( - self, channel: Union[str, Sequence[str]], server: Union[str, Tuple[str, int]], - message: Optional[str] = None + self, + channel: Union[str, Sequence[str]], + server: Union[str, Tuple[str, int]], + message: Optional[str] = None, ): """ Parts/exits a channel. @@ -339,9 +349,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): bot.connection.part(channels=channels, message=message or bot.stop_message) @action - def quit( - self, server: Union[str, Tuple[str, int]], message: Optional[str] = None - ): + def quit(self, server: Union[str, Tuple[str, int]], message: Optional[str] = None): """ Send a QUIT command. @@ -363,7 +371,12 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): bot.connection.send_raw(message) @action - def topic(self, channel: str, server: Union[str, Tuple[str, int]], topic: Optional[str] = None) -> str: + def topic( + self, + channel: str, + server: Union[str, Tuple[str, int]], + topic: Optional[str] = None, + ) -> str: """ Get/set the topic of an IRC channel. diff --git a/platypush/plugins/gpio/__init__.py b/platypush/plugins/gpio/__init__.py index 53f677a68..d312bfc02 100644 --- a/platypush/plugins/gpio/__init__.py +++ b/platypush/plugins/gpio/__init__.py @@ -27,11 +27,11 @@ class GpioPlugin(RunnablePlugin): """ def __init__( - self, - pins: Optional[Dict[str, int]] = None, - monitored_pins: Optional[Collection[Union[str, int]]] = None, - mode: str = 'board', - **kwargs + self, + pins: Optional[Dict[str, int]] = None, + monitored_pins: Optional[Collection[Union[str, int]]] = None, + mode: str = 'board', + **kwargs ): """ :param mode: Specify ``board`` if you want to use the board PIN numbers, @@ -64,8 +64,9 @@ class GpioPlugin(RunnablePlugin): self._initialized_pins = {} self._monitored_pins = monitored_pins or [] self.pins_by_name = pins if pins else {} - self.pins_by_number = {number: name - for (name, number) in self.pins_by_name.items()} + self.pins_by_number = { + number: name for (name, number) in self.pins_by_name.items() + } def _init_board(self): import RPi.GPIO as GPIO @@ -98,6 +99,7 @@ class GpioPlugin(RunnablePlugin): def on_gpio_event(self): def callback(pin: int): import RPi.GPIO as GPIO + value = GPIO.input(pin) pin = self.pins_by_number.get(pin, pin) get_bus().post(GPIOEvent(pin=pin, value=value)) @@ -106,23 +108,23 @@ class GpioPlugin(RunnablePlugin): def main(self): import RPi.GPIO as GPIO + if not self._monitored_pins: return # No need to start the monitor self._init_board() - monitored_pins = [ - self._get_pin_number(pin) for pin in self._monitored_pins - ] + monitored_pins = [self._get_pin_number(pin) for pin in self._monitored_pins] for pin in monitored_pins: GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) GPIO.add_event_detect(pin, GPIO.BOTH, callback=self.on_gpio_event()) - self._should_stop.wait() + self.wait_stop() @action - def write(self, pin: Union[int, str], value: Union[int, bool], - name: Optional[str] = None) -> Dict[str, Any]: + def write( + self, pin: Union[int, str], value: Union[int, bool], name: Optional[str] = None + ) -> Dict[str, Any]: """ Write a byte value to a pin. From d3116294031669ab62e93c02ea9eae3f25ac71cd Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Wed, 6 Apr 2022 23:48:27 +0200 Subject: [PATCH 20/96] black validation should run before flake8 --- .pre-commit-config.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 18c7457a1..d82b70d92 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,6 +17,11 @@ repos: hooks: - id: markdown-toc +- repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black + - repo: https://github.com/pycqa/flake8 rev: 4.0.1 hooks: @@ -26,7 +31,3 @@ repos: - flake8-comprehensions - flake8-simplify -- repo: https://github.com/psf/black - rev: 22.3.0 - hooks: - - id: black From 1c6ff2fa49d59d4d8e15b0b2bbfd997d14ec33c7 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Wed, 6 Apr 2022 23:56:10 +0200 Subject: [PATCH 21/96] (actually, the other way around is better) --- .pre-commit-config.yaml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d82b70d92..18c7457a1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,11 +17,6 @@ repos: hooks: - id: markdown-toc -- repo: https://github.com/psf/black - rev: 22.3.0 - hooks: - - id: black - - repo: https://github.com/pycqa/flake8 rev: 4.0.1 hooks: @@ -31,3 +26,7 @@ repos: - flake8-comprehensions - flake8-simplify +- repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black From 7b1a63e28747867c051b9a79ff9d6cffe1c0f78d Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 7 Apr 2022 00:17:39 +0200 Subject: [PATCH 22/96] Make sure that flake8 and black don't step on each other's toes --- pyproject.toml | 1 - setup.cfg | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e7c6caf61..5ec87cec8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,3 @@ [tool.black] skip-string-normalization = true skip-numeric-underscore-normalization = true - diff --git a/setup.cfg b/setup.cfg index 1dd336c25..f989b007f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,3 +8,4 @@ description-file = README.md [flake8] max-line-length = 120 +extend-ignore = E203 From 26ffc0b0e1995e461d2a17e071fc0c889f4757d8 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 7 Apr 2022 00:18:11 +0200 Subject: [PATCH 23/96] 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. --- platypush/entities/__init__.py | 10 +- platypush/entities/_registry.py | 36 ++++--- platypush/utils/__init__.py | 160 +++++++++++++++++++------------- 3 files changed, 127 insertions(+), 79 deletions(-) diff --git a/platypush/entities/__init__.py b/platypush/entities/__init__.py index f59ab240b..361d67e5e 100644 --- a/platypush/entities/__init__.py +++ b/platypush/entities/__init__.py @@ -1,15 +1,16 @@ import warnings from typing import Collection, Optional -from ._base import Entity +from ._base import Entity, get_entities_registry from ._engine import EntitiesEngine -from ._registry import manages, register_entity_plugin, get_plugin_registry +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() @@ -24,13 +25,14 @@ def publish_entities(entities: Collection[Entity]): _engine.post(*entities) + __all__ = ( 'Entity', 'EntitiesEngine', 'init_entities_engine', 'publish_entities', 'register_entity_plugin', - 'get_plugin_registry', + 'get_plugin_entity_registry', + 'get_entities_registry', 'manages', ) - diff --git a/platypush/entities/_registry.py b/platypush/entities/_registry.py index b8644808f..53d87234c 100644 --- a/platypush/entities/_registry.py +++ b/platypush/entities/_registry.py @@ -1,24 +1,38 @@ +import json from datetime import datetime -from typing import Optional, Mapping, Dict, Collection, Type +from typing import Optional, Dict, Collection, Type from platypush.plugins import Plugin -from platypush.utils import get_plugin_name_by_class +from platypush.utils import get_plugin_name_by_class, get_redis from ._base import Entity -_entity_plugin_registry: Mapping[Type[Entity], Dict[str, Plugin]] = {} +_entity_registry_varname = '_platypush/plugin_entity_registry' def register_entity_plugin(entity_type: Type[Entity], plugin: Plugin): - plugins = _entity_plugin_registry.get(entity_type, {}) - plugin_name = get_plugin_name_by_class(plugin.__class__) - assert plugin_name - plugins[plugin_name] = plugin - _entity_plugin_registry[entity_type] = plugins + 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_registry(): - return _entity_plugin_registry.copy() +def get_plugin_entity_registry() -> Dict[str, Dict[str, Collection[str]]]: + redis = get_redis() + registry = redis.mget([_entity_registry_varname])[0] + try: + return json.loads((registry or b'').decode()) + except (TypeError, ValueError): + return {'by_plugin': {}, 'by_entity_type': {}} class EntityManagerMixin: @@ -37,6 +51,7 @@ class EntityManagerMixin: def publish_entities(self, entities: Optional[Collection[Entity]]): from . import publish_entities + entities = self.transform_entities(entities) publish_entities(entities) @@ -59,4 +74,3 @@ def manages(*entities: Type[Entity]): return plugin return wrapper - diff --git a/platypush/utils/__init__.py b/platypush/utils/__init__.py index c75615c85..61f2fc23e 100644 --- a/platypush/utils/__init__.py +++ b/platypush/utils/__init__.py @@ -1,4 +1,5 @@ import ast +import contextlib import datetime import hashlib import importlib @@ -20,8 +21,8 @@ logger = logging.getLogger('utils') def get_module_and_method_from_action(action): - """ Input : action=music.mpd.play - Output : ('music.mpd', 'play') """ + """Input : action=music.mpd.play + Output : ('music.mpd', 'play')""" tokens = action.split('.') module_name = str.join('.', tokens[:-1]) @@ -30,7 +31,7 @@ def get_module_and_method_from_action(action): def get_message_class_by_type(msgtype): - """ Gets the class of a message type given as string """ + """Gets the class of a message type given as string""" try: module = importlib.import_module('platypush.message.' + msgtype) @@ -43,8 +44,7 @@ def get_message_class_by_type(msgtype): try: msgclass = getattr(module, cls_name) except AttributeError as e: - logger.warning('No such class in {}: {}'.format( - module.__name__, cls_name)) + logger.warning('No such class in {}: {}'.format(module.__name__, cls_name)) raise RuntimeError(e) return msgclass @@ -52,13 +52,13 @@ def get_message_class_by_type(msgtype): # noinspection PyShadowingBuiltins def get_event_class_by_type(type): - """ Gets an event class by type name """ + """Gets an event class by type name""" event_module = importlib.import_module('.'.join(type.split('.')[:-1])) return getattr(event_module, type.split('.')[-1]) def get_plugin_module_by_name(plugin_name): - """ Gets the module of a plugin by name (e.g. "music.mpd" or "media.vlc") """ + """Gets the module of a plugin by name (e.g. "music.mpd" or "media.vlc")""" module_name = 'platypush.plugins.' + plugin_name try: @@ -69,22 +69,26 @@ def get_plugin_module_by_name(plugin_name): def get_plugin_class_by_name(plugin_name): - """ Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc") """ + """Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc")""" module = get_plugin_module_by_name(plugin_name) if not module: return - class_name = getattr(module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin') + class_name = getattr( + module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin' + ) try: - return getattr(module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin') + return getattr( + module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin' + ) except Exception as e: logger.error('Cannot import class {}: {}'.format(class_name, str(e))) return None def get_plugin_name_by_class(plugin) -> Optional[str]: - """Gets the common name of a plugin (e.g. "music.mpd" or "media.vlc") given its class. """ + """Gets the common name of a plugin (e.g. "music.mpd" or "media.vlc") given its class.""" from platypush.plugins import Plugin @@ -93,7 +97,8 @@ def get_plugin_name_by_class(plugin) -> Optional[str]: class_name = plugin.__name__ class_tokens = [ - token.lower() for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ') + token.lower() + for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ') if token.strip() and token != 'Plugin' ] @@ -101,7 +106,7 @@ def get_plugin_name_by_class(plugin) -> Optional[str]: def get_backend_name_by_class(backend) -> Optional[str]: - """Gets the common name of a backend (e.g. "http" or "mqtt") given its class. """ + """Gets the common name of a backend (e.g. "http" or "mqtt") given its class.""" from platypush.backend import Backend @@ -110,7 +115,8 @@ def get_backend_name_by_class(backend) -> Optional[str]: class_name = backend.__name__ class_tokens = [ - token.lower() for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ') + token.lower() + for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ') if token.strip() and token != 'Backend' ] @@ -135,12 +141,12 @@ def set_timeout(seconds, on_timeout): def clear_timeout(): - """ Clear any previously set timeout """ + """Clear any previously set timeout""" signal.alarm(0) def get_hash(s): - """ Get the SHA256 hash hex digest of a string input """ + """Get the SHA256 hash hex digest of a string input""" return hashlib.sha256(s.encode('utf-8')).hexdigest() @@ -177,11 +183,8 @@ def get_decorators(cls, climb_class_hierarchy=False): node_iter.visit_FunctionDef = visit_FunctionDef for target in targets: - try: + with contextlib.suppress(TypeError): node_iter.visit(ast.parse(inspect.getsource(target))) - except TypeError: - # Ignore built-in classes - pass return decorators @@ -195,45 +198,57 @@ def get_redis_queue_name_by_message(msg): return 'platypush/responses/{}'.format(msg.id) if msg.id else None -def _get_ssl_context(context_type=None, ssl_cert=None, ssl_key=None, - ssl_cafile=None, ssl_capath=None): +def _get_ssl_context( + context_type=None, ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None +): if not context_type: - ssl_context = ssl.create_default_context(cafile=ssl_cafile, - capath=ssl_capath) + ssl_context = ssl.create_default_context(cafile=ssl_cafile, capath=ssl_capath) else: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) if ssl_cafile or ssl_capath: - ssl_context.load_verify_locations( - cafile=ssl_cafile, capath=ssl_capath) + ssl_context.load_verify_locations(cafile=ssl_cafile, capath=ssl_capath) ssl_context.load_cert_chain( certfile=os.path.abspath(os.path.expanduser(ssl_cert)), - keyfile=os.path.abspath(os.path.expanduser(ssl_key)) if ssl_key else None + keyfile=os.path.abspath(os.path.expanduser(ssl_key)) if ssl_key else None, ) return ssl_context -def get_ssl_context(ssl_cert=None, ssl_key=None, ssl_cafile=None, - ssl_capath=None): - return _get_ssl_context(context_type=None, - ssl_cert=ssl_cert, ssl_key=ssl_key, - ssl_cafile=ssl_cafile, ssl_capath=ssl_capath) +def get_ssl_context(ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None): + return _get_ssl_context( + context_type=None, + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) -def get_ssl_server_context(ssl_cert=None, ssl_key=None, ssl_cafile=None, - ssl_capath=None): - return _get_ssl_context(context_type=ssl.PROTOCOL_TLS_SERVER, - ssl_cert=ssl_cert, ssl_key=ssl_key, - ssl_cafile=ssl_cafile, ssl_capath=ssl_capath) +def get_ssl_server_context( + ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None +): + return _get_ssl_context( + context_type=ssl.PROTOCOL_TLS_SERVER, + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) -def get_ssl_client_context(ssl_cert=None, ssl_key=None, ssl_cafile=None, - ssl_capath=None): - return _get_ssl_context(context_type=ssl.PROTOCOL_TLS_CLIENT, - ssl_cert=ssl_cert, ssl_key=ssl_key, - ssl_cafile=ssl_cafile, ssl_capath=ssl_capath) +def get_ssl_client_context( + ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None +): + return _get_ssl_context( + context_type=ssl.PROTOCOL_TLS_CLIENT, + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) def set_thread_name(name): @@ -241,6 +256,7 @@ def set_thread_name(name): try: import prctl + # noinspection PyUnresolvedReferences prctl.set_name(name) except ImportError: @@ -251,9 +267,9 @@ def find_bins_in_path(bin_name): return [ os.path.join(p, bin_name) for p in os.environ.get('PATH', '').split(':') - if os.path.isfile(os.path.join(p, bin_name)) and ( - os.name == 'nt' or os.access(os.path.join(p, bin_name), os.X_OK) - )] + if os.path.isfile(os.path.join(p, bin_name)) + and (os.name == 'nt' or os.access(os.path.join(p, bin_name), os.X_OK)) + ] def find_files_by_ext(directory, *exts): @@ -271,7 +287,7 @@ def find_files_by_ext(directory, *exts): max_len = len(max(exts, key=len)) result = [] - for root, dirs, files in os.walk(directory): + for _, __, files in os.walk(directory): for i in range(min_len, max_len + 1): result += [f for f in files if f[-i:] in exts] @@ -302,8 +318,9 @@ def get_ip_or_hostname(): def get_mime_type(resource): import magic + if resource.startswith('file://'): - resource = resource[len('file://'):] + resource = resource[len('file://') :] # noinspection HttpUrlsUsage if resource.startswith('http://') or resource.startswith('https://'): @@ -315,7 +332,9 @@ def get_mime_type(resource): elif hasattr(magic, 'from_file'): mime = magic.from_file(resource, mime=True) else: - raise RuntimeError('The installed magic version provides neither detect_from_filename nor from_file') + raise RuntimeError( + 'The installed magic version provides neither detect_from_filename nor from_file' + ) if mime: return mime.mime_type if hasattr(mime, 'mime_type') else mime @@ -332,6 +351,7 @@ def grouper(n, iterable, fillvalue=None): grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx """ from itertools import zip_longest + args = [iter(iterable)] * n if fillvalue: @@ -355,6 +375,7 @@ def is_functional_cron(obj) -> bool: def run(action, *args, **kwargs): from platypush.context import get_plugin + (module_name, method_name) = get_module_and_method_from_action(action) plugin = get_plugin(module_name) method = getattr(plugin, method_name) @@ -366,7 +387,9 @@ def run(action, *args, **kwargs): return response.output -def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> Tuple[str, str]: +def generate_rsa_key_pair( + key_file: Optional[str] = None, size: int = 2048 +) -> Tuple[str, str]: """ Generate an RSA key pair. @@ -390,27 +413,30 @@ def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> T public_exp = 65537 private_key = rsa.generate_private_key( - public_exponent=public_exp, - key_size=size, - backend=default_backend() + public_exponent=public_exp, key_size=size, backend=default_backend() ) logger.info('Generating RSA {} key pair'.format(size)) private_key_str = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() + encryption_algorithm=serialization.NoEncryption(), ).decode() - public_key_str = private_key.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.PKCS1, - ).decode() + public_key_str = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.PKCS1, + ) + .decode() + ) if key_file: logger.info('Saving private key to {}'.format(key_file)) - with open(os.path.expanduser(key_file), 'w') as f1, \ - open(os.path.expanduser(key_file) + '.pub', 'w') as f2: + with open(os.path.expanduser(key_file), 'w') as f1, open( + os.path.expanduser(key_file) + '.pub', 'w' + ) as f2: f1.write(private_key_str) f2.write(public_key_str) os.chmod(key_file, 0o600) @@ -426,8 +452,7 @@ def get_or_generate_jwt_rsa_key_pair(): pub_key_file = priv_key_file + '.pub' if os.path.isfile(priv_key_file) and os.path.isfile(pub_key_file): - with open(pub_key_file, 'r') as f1, \ - open(priv_key_file, 'r') as f2: + with open(pub_key_file, 'r') as f1, open(priv_key_file, 'r') as f2: return f1.read(), f2.read() pathlib.Path(key_dir).mkdir(parents=True, exist_ok=True, mode=0o755) @@ -439,7 +464,7 @@ def get_enabled_plugins() -> dict: from platypush.context import get_plugin plugins = {} - for name, config in Config.get_plugins().items(): + for name in Config.get_plugins(): try: plugin = get_plugin(name) if plugin: @@ -453,11 +478,18 @@ def get_enabled_plugins() -> dict: def get_redis() -> Redis: from platypush.config import Config - return Redis(**(Config.get('backend.redis') or {}).get('redis_args', {})) + + return Redis( + **( + (Config.get('backend.redis') or {}).get('redis_args', {}) + or Config.get('redis') + or {} + ) + ) def to_datetime(t: Union[str, int, float, datetime.datetime]) -> datetime.datetime: - if isinstance(t, int) or isinstance(t, float): + if isinstance(t, (int, float)): return datetime.datetime.fromtimestamp(t, tz=tz.tzutc()) if isinstance(t, str): return parser.parse(t) From 2eeb1d4feaf8b005cbfb0ab2e7b0fce4cd7f3b24 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 7 Apr 2022 00:21:54 +0200 Subject: [PATCH 24/96] Entity objects are now JSON-able --- platypush/entities/_base.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py index 2bc102b57..5e0ee423b 100644 --- a/platypush/entities/_base.py +++ b/platypush/entities/_base.py @@ -16,13 +16,15 @@ from sqlalchemy import ( ) 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 + Model for a general-purpose platform entity. """ __tablename__ = 'entity' @@ -55,6 +57,9 @@ class Entity(Base): inspector = schema_inspect(cls) return tuple(inspector.mapper.column_attrs) + def to_json(self) -> dict: + return {col.key: getattr(self, col.key) for col in self.columns} + def get_plugin(self): from platypush.context import get_plugin @@ -63,6 +68,11 @@ class Entity(Base): return plugin +# 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 @@ -88,6 +98,10 @@ def _discover_entity_types(): entities_registry[obj] = {} +def get_entities_registry(): + return entities_registry.copy() + + def init_entities_db(): from platypush.context import get_plugin From 3b4f7d3dadd5f62cf1d48ec860ca21399e9571e2 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 7 Apr 2022 00:22:54 +0200 Subject: [PATCH 25/96] Added entities plugin to query/action entities --- platypush/plugins/entities/__init__.py | 131 +++++++++++++++++++++++ platypush/plugins/entities/manifest.yaml | 4 + 2 files changed, 135 insertions(+) create mode 100644 platypush/plugins/entities/__init__.py create mode 100644 platypush/plugins/entities/manifest.yaml diff --git a/platypush/plugins/entities/__init__.py b/platypush/plugins/entities/__init__.py new file mode 100644 index 000000000..dc65a661b --- /dev/null +++ b/platypush/plugins/entities/__init__.py @@ -0,0 +1,131 @@ +from queue import Queue, Empty +from threading import Thread +from time import time +from typing import Optional + +from platypush.context import get_plugin +from platypush.entities import get_plugin_entity_registry, get_entities_registry +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) + + @action + def get(self, type: str = 'entity', **filter): + """ + Retrieve a list of entities. + + :param type: Entity type, as specified by the (lowercase) class name and table name. + Default: `entity` (retrieve all the types) + :param filter: Filter entities with these criteria (e.g. `name`, `id`, + `state`, `plugin` etc.) + """ + entity_registry = get_entities_registry() + all_types = {e.__tablename__.lower(): e for e in entity_registry} + + entity_type = all_types.get(type.lower()) + assert ( + entity_type + ), f'No such entity type: {type}. Supported types: {list(all_types.keys())}' + + db = get_plugin('db') + assert db + + with db.get_session() as session: + query = session.query(entity_type) + if filter: + query = query.filter_by(**filter) + + return [e.to_json() for e in query.all()] + + @action + def scan( + self, + type: Optional[str] = None, + plugin: Optional[str] = None, + timeout: Optional[float] = 30.0, + ): + """ + (Re-)scan entities and return the updated results. + + :param type: Filter by entity type (e.g. `switch`, `light`, `sensor` etc.). Default: all. + :param plugin: Filter by plugin name (e.g. `switch.tplink` or `light.hue`). Default: all. + :param timeout: Scan timeout in seconds. Default: 30. + """ + filter = {} + plugin_registry = get_plugin_entity_registry() + + if plugin: + filter['plugin'] = plugin + plugin_registry['by_plugin'] = { + **( + {plugin: plugin_registry['by_plugin'][plugin]} + if plugin in plugin_registry['by_plugin'] + else {} + ) + } + + if type: + filter['type'] = type + filter_plugins = set(plugin_registry['by_entity_type'].get(type, [])) + plugin_registry['by_plugin'] = { + plugin_name: entity_types + for plugin_name, entity_types in plugin_registry['by_plugin'].items() + if plugin_name in filter_plugins + } + + 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) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/entities/manifest.yaml b/platypush/plugins/entities/manifest.yaml new file mode 100644 index 000000000..83628bb5e --- /dev/null +++ b/platypush/plugins/entities/manifest.yaml @@ -0,0 +1,4 @@ +manifest: + events: {} + package: platypush.plugins.entities + type: plugin From 948f37afd451f4ca580b96fc00cdba4d3892da8b Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 7 Apr 2022 01:04:06 +0200 Subject: [PATCH 26/96] Filter by configured/enabled plugins when returning the entity/plugin registry --- platypush/entities/_registry.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/platypush/entities/_registry.py b/platypush/entities/_registry.py index 53d87234c..162a1a2f5 100644 --- a/platypush/entities/_registry.py +++ b/platypush/entities/_registry.py @@ -2,6 +2,7 @@ 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 @@ -30,10 +31,24 @@ def get_plugin_entity_registry() -> Dict[str, Dict[str, Collection[str]]]: redis = get_redis() registry = redis.mget([_entity_registry_varname])[0] try: - return json.loads((registry or b'').decode()) + 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: def transform_entities(self, entities): From 44707731a8b65772c121bec63f399d68f3e9d68d Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 7 Apr 2022 01:13:29 +0200 Subject: [PATCH 27/96] Normalize UTC timezone on all the entity timestamps --- platypush/entities/_base.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py index 5e0ee423b..98e9bdcd0 100644 --- a/platypush/entities/_base.py +++ b/platypush/entities/_base.py @@ -1,7 +1,7 @@ import inspect import pathlib from datetime import datetime -from typing import Mapping, Type, Tuple +from typing import Mapping, Type, Tuple, Any import pkgutil from sqlalchemy import ( @@ -57,8 +57,16 @@ class Entity(Base): 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: getattr(self, col.key) for col in self.columns} + return {col.key: self._serialize_value(col) for col in self.columns} def get_plugin(self): from platypush.context import get_plugin From 28026b042847ae63a1bd34d605e1313e9e8a72d2 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 7 Apr 2022 01:46:37 +0200 Subject: [PATCH 28/96] Trigger an EntityUpdateEvent when an entity state changes --- platypush/entities/_engine.py | 6 ++++++ platypush/message/event/entities.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 platypush/message/event/entities.py diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py index d88e2c9d7..63d9b95be 100644 --- a/platypush/entities/_engine.py +++ b/platypush/entities/_engine.py @@ -7,6 +7,9 @@ from typing import Iterable, List from sqlalchemy import and_, or_ from sqlalchemy.orm import Session +from platypush.context import get_bus +from platypush.message.event.entities import EntityUpdateEvent + from ._base import Entity @@ -130,3 +133,6 @@ class EntitiesEngine(Thread): entities = self._merge_entities(entities, existing_entities) # type: ignore session.add_all(entities) session.commit() + + for entity in entities: + get_bus().post(EntityUpdateEvent(entity=entity)) diff --git a/platypush/message/event/entities.py b/platypush/message/event/entities.py new file mode 100644 index 000000000..6b56100a7 --- /dev/null +++ b/platypush/message/event/entities.py @@ -0,0 +1,19 @@ +from typing import Union + +from platypush.entities import Entity +from platypush.message.event import Event + + +class EntityUpdateEvent(Event): + """ + This even is triggered whenever an entity of any type (a switch, a light, + a sensor, a media player etc.) updates its state. + """ + + def __init__(self, entity: Union[Entity, dict], *args, **kwargs): + if isinstance(entity, Entity): + entity = entity.to_json() + super().__init__(entity=entity, *args, **kwargs) + + +# vim:sw=4:ts=4:et: From d3dc86a5e2bd89eb22d9e34bc68306a8780f19b7 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 7 Apr 2022 01:47:42 +0200 Subject: [PATCH 29/96] Added documentation for plugin/entity type registry --- platypush/entities/_registry.py | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/platypush/entities/_registry.py b/platypush/entities/_registry.py index 162a1a2f5..1bee049d4 100644 --- a/platypush/entities/_registry.py +++ b/platypush/entities/_registry.py @@ -12,6 +12,11 @@ _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() @@ -28,6 +33,10 @@ def register_entity_plugin(entity_type: Type[Entity], plugin: Plugin): 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: @@ -51,7 +60,23 @@ def get_plugin_entity_registry() -> Dict[str, Dict[str, Collection[str]]]: 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: @@ -65,6 +90,20 @@ class EntityManagerMixin: 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) @@ -72,6 +111,11 @@ class EntityManagerMixin: 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__ From e40b668380c54e19f80daa1a6f5445b7a331d268 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 7 Apr 2022 01:49:13 +0200 Subject: [PATCH 30/96] Added missing docs --- docs/source/events.rst | 1 + docs/source/platypush/events/entities.rst | 5 +++++ docs/source/platypush/plugins/entities.rst | 5 +++++ docs/source/plugins.rst | 1 + 4 files changed, 12 insertions(+) create mode 100644 docs/source/platypush/events/entities.rst create mode 100644 docs/source/platypush/plugins/entities.rst diff --git a/docs/source/events.rst b/docs/source/events.rst index 9d0b63ba4..b5bf19b40 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -20,6 +20,7 @@ Events platypush/events/custom.rst platypush/events/dbus.rst platypush/events/distance.rst + platypush/events/entities.rst platypush/events/file.rst platypush/events/foursquare.rst platypush/events/geo.rst diff --git a/docs/source/platypush/events/entities.rst b/docs/source/platypush/events/entities.rst new file mode 100644 index 000000000..206b44d4c --- /dev/null +++ b/docs/source/platypush/events/entities.rst @@ -0,0 +1,5 @@ +``entities`` +============ + +.. automodule:: platypush.message.event.entities + :members: diff --git a/docs/source/platypush/plugins/entities.rst b/docs/source/platypush/plugins/entities.rst new file mode 100644 index 000000000..a17e2e298 --- /dev/null +++ b/docs/source/platypush/plugins/entities.rst @@ -0,0 +1,5 @@ +``entities`` +============ + +.. automodule:: platypush.plugins.entities + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 0d60cbd11..a066a87c9 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -32,6 +32,7 @@ Plugins platypush/plugins/db.rst platypush/plugins/dbus.rst platypush/plugins/dropbox.rst + platypush/plugins/entities.rst platypush/plugins/esp.rst platypush/plugins/ffmpeg.rst platypush/plugins/file.rst From db7c2095eaceff1baa80689a52192550574283c1 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 7 Apr 2022 18:09:25 +0200 Subject: [PATCH 31/96] Implemented meta property for entities (for now it only include `icon_class`) --- platypush/entities/_base.py | 12 +++++++++++- platypush/entities/devices.py | 6 ++++++ platypush/entities/lights.py | 6 ++++++ platypush/entities/switches.py | 7 +++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py index 98e9bdcd0..3de25334c 100644 --- a/platypush/entities/_base.py +++ b/platypush/entities/_base.py @@ -51,6 +51,13 @@ class Entity(Base): 'polymorphic_on': type, } + @classmethod + @property + def meta(cls) -> Mapping: + return { + 'icon_class': 'fa-solid fa-circle-question', + } + @classmethod @property def columns(cls) -> Tuple[ColumnProperty]: @@ -66,7 +73,10 @@ class Entity(Base): return val def to_json(self) -> dict: - return {col.key: self._serialize_value(col) for col in self.columns} + return { + **{col.key: self._serialize_value(col) for col in self.columns}, + 'meta': self.meta, + } def get_plugin(self): from platypush.context import get_plugin diff --git a/platypush/entities/devices.py b/platypush/entities/devices.py index dfc64f01f..f5d596cb9 100644 --- a/platypush/entities/devices.py +++ b/platypush/entities/devices.py @@ -12,3 +12,9 @@ class Device(Entity): 'polymorphic_identity': __tablename__, } + @classmethod + @property + def meta(cls): + return { + 'icon_class': 'fa-solid fa-gear', + } diff --git a/platypush/entities/lights.py b/platypush/entities/lights.py index 95f303f90..a571e8698 100644 --- a/platypush/entities/lights.py +++ b/platypush/entities/lights.py @@ -12,3 +12,9 @@ class Light(Device): 'polymorphic_identity': __tablename__, } + @classmethod + @property + def meta(cls): + return { + 'icon_class': 'fa-solid fa-lightbulb', + } diff --git a/platypush/entities/switches.py b/platypush/entities/switches.py index ebae03e53..8f01350b6 100644 --- a/platypush/entities/switches.py +++ b/platypush/entities/switches.py @@ -13,6 +13,13 @@ class Switch(Device): 'polymorphic_identity': __tablename__, } + @classmethod + @property + def meta(cls): + return { + 'icon_class': 'fa-solid fa-light-switch', + } + def on(self): return self.get_plugin().on(self) From 947b50b9378245c872e704dee95f1a6a87986760 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 7 Apr 2022 22:11:31 +0200 Subject: [PATCH 32/96] 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. --- platypush/entities/_base.py | 9 ++++++--- platypush/entities/devices.py | 4 ++-- platypush/entities/lights.py | 4 ++-- platypush/entities/switches.py | 4 ++-- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py index 3de25334c..aa589261e 100644 --- a/platypush/entities/_base.py +++ b/platypush/entities/_base.py @@ -35,6 +35,7 @@ class Entity(Base): type = Column(String, nullable=False, index=True) plugin = Column(String, nullable=False) data = Column(JSON, default=dict) + meta = Column(JSON, default=dict) created_at = Column( DateTime(timezone=False), default=datetime.utcnow(), nullable=False ) @@ -51,9 +52,8 @@ class Entity(Base): 'polymorphic_on': type, } - @classmethod @property - def meta(cls) -> Mapping: + def _meta(self) -> dict: return { 'icon_class': 'fa-solid fa-circle-question', } @@ -75,7 +75,10 @@ class Entity(Base): def to_json(self) -> dict: return { **{col.key: self._serialize_value(col) for col in self.columns}, - 'meta': self.meta, + 'meta': { + **self._meta, + **(self.meta or {}), + }, } def get_plugin(self): diff --git a/platypush/entities/devices.py b/platypush/entities/devices.py index f5d596cb9..bf26c2acb 100644 --- a/platypush/entities/devices.py +++ b/platypush/entities/devices.py @@ -12,9 +12,9 @@ class Device(Entity): 'polymorphic_identity': __tablename__, } - @classmethod @property - def meta(cls): + def _meta(self): return { + **super()._meta, 'icon_class': 'fa-solid fa-gear', } diff --git a/platypush/entities/lights.py b/platypush/entities/lights.py index a571e8698..4df4c51cb 100644 --- a/platypush/entities/lights.py +++ b/platypush/entities/lights.py @@ -12,9 +12,9 @@ class Light(Device): 'polymorphic_identity': __tablename__, } - @classmethod @property - def meta(cls): + def _meta(self): return { + **super()._meta, 'icon_class': 'fa-solid fa-lightbulb', } diff --git a/platypush/entities/switches.py b/platypush/entities/switches.py index 8f01350b6..f26ca6ef6 100644 --- a/platypush/entities/switches.py +++ b/platypush/entities/switches.py @@ -13,10 +13,10 @@ class Switch(Device): 'polymorphic_identity': __tablename__, } - @classmethod @property - def meta(cls): + def _meta(self): return { + **super()._meta, 'icon_class': 'fa-solid fa-light-switch', } From f52b55621915e15f80d4f9e4ba054fb17a047e69 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Fri, 8 Apr 2022 16:49:47 +0200 Subject: [PATCH 33/96] - 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 --- platypush/entities/_base.py | 20 ++++++---------- platypush/entities/devices.py | 7 ------ platypush/entities/lights.py | 7 ------ platypush/entities/switches.py | 16 ------------- platypush/plugins/entities/__init__.py | 33 ++++++++++++++++++++++---- 5 files changed, 35 insertions(+), 48 deletions(-) diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py index aa589261e..fe4ec2c6f 100644 --- a/platypush/entities/_base.py +++ b/platypush/entities/_base.py @@ -52,12 +52,6 @@ class Entity(Base): 'polymorphic_on': type, } - @property - def _meta(self) -> dict: - return { - 'icon_class': 'fa-solid fa-circle-question', - } - @classmethod @property def columns(cls) -> Tuple[ColumnProperty]: @@ -73,13 +67,7 @@ class Entity(Base): return val def to_json(self) -> dict: - return { - **{col.key: self._serialize_value(col) for col in self.columns}, - 'meta': { - **self._meta, - **(self.meta or {}), - }, - } + return {col.key: self._serialize_value(col) for col in self.columns} def get_plugin(self): from platypush.context import get_plugin @@ -88,6 +76,12 @@ class Entity(Base): 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) diff --git a/platypush/entities/devices.py b/platypush/entities/devices.py index bf26c2acb..24820eef9 100644 --- a/platypush/entities/devices.py +++ b/platypush/entities/devices.py @@ -11,10 +11,3 @@ class Device(Entity): __mapper_args__ = { 'polymorphic_identity': __tablename__, } - - @property - def _meta(self): - return { - **super()._meta, - 'icon_class': 'fa-solid fa-gear', - } diff --git a/platypush/entities/lights.py b/platypush/entities/lights.py index 4df4c51cb..44392aecb 100644 --- a/platypush/entities/lights.py +++ b/platypush/entities/lights.py @@ -11,10 +11,3 @@ class Light(Device): __mapper_args__ = { 'polymorphic_identity': __tablename__, } - - @property - def _meta(self): - return { - **super()._meta, - 'icon_class': 'fa-solid fa-lightbulb', - } diff --git a/platypush/entities/switches.py b/platypush/entities/switches.py index f26ca6ef6..e7dcfbde4 100644 --- a/platypush/entities/switches.py +++ b/platypush/entities/switches.py @@ -12,19 +12,3 @@ class Switch(Device): __mapper_args__ = { 'polymorphic_identity': __tablename__, } - - @property - def _meta(self): - return { - **super()._meta, - 'icon_class': 'fa-solid fa-light-switch', - } - - def on(self): - return self.get_plugin().on(self) - - def off(self): - return self.get_plugin().off(self) - - def toggle(self): - return self.get_plugin().toggle(self) diff --git a/platypush/plugins/entities/__init__.py b/platypush/plugins/entities/__init__.py index dc65a661b..40918d9b0 100644 --- a/platypush/plugins/entities/__init__.py +++ b/platypush/plugins/entities/__init__.py @@ -1,10 +1,10 @@ from queue import Queue, Empty from threading import Thread from time import time -from typing import Optional +from typing import Optional, Any from platypush.context import get_plugin -from platypush.entities import get_plugin_entity_registry, get_entities_registry +from platypush.entities import Entity, get_plugin_entity_registry, get_entities_registry from platypush.plugins import Plugin, action @@ -17,6 +17,11 @@ class EntitiesPlugin(Plugin): def __init__(self, **kwargs): super().__init__(**kwargs) + def _get_db(self): + db = get_plugin('db') + assert db + return db + @action def get(self, type: str = 'entity', **filter): """ @@ -35,9 +40,7 @@ class EntitiesPlugin(Plugin): entity_type ), f'No such entity type: {type}. Supported types: {list(all_types.keys())}' - db = get_plugin('db') - assert db - + db = self._get_db() with db.get_session() as session: query = session.query(entity_type) if filter: @@ -127,5 +130,25 @@ class EntitiesPlugin(Plugin): 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) + # vim:sw=4:ts=4:et: From 655d56f4dae22d294b5a7fa72627295f88ada44a Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 10 Apr 2022 01:49:14 +0200 Subject: [PATCH 34/96] Upgraded font-awesome to 6.x --- platypush/backend/http/webapp/package-lock.json | 14 +++++++------- platypush/backend/http/webapp/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index ef3e46df3..7cd532d1e 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -8,7 +8,7 @@ "name": "platypush", "version": "0.1.0", "dependencies": { - "@fortawesome/fontawesome-free": "^5.15.4", + "@fortawesome/fontawesome-free": "^6.1.1", "axios": "^0.21.4", "core-js": "^3.21.1", "lato-font": "^3.0.0", @@ -1731,9 +1731,9 @@ } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz", - "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz", + "integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg==", "hasInstallScript": true, "engines": { "node": ">=6" @@ -12917,9 +12917,9 @@ } }, "@fortawesome/fontawesome-free": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz", - "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==" + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz", + "integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg==" }, "@humanwhocodes/config-array": { "version": "0.5.0", diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json index 531f041d0..d62b06598 100644 --- a/platypush/backend/http/webapp/package.json +++ b/platypush/backend/http/webapp/package.json @@ -8,7 +8,7 @@ "lint": "vue-cli-service lint" }, "dependencies": { - "@fortawesome/fontawesome-free": "^5.15.4", + "@fortawesome/fontawesome-free": "^6.1.1", "axios": "^0.21.4", "core-js": "^3.21.1", "lato-font": "^3.0.0", From b2ff66aa62a2d7001b6d6b3a656e7b5e85dcca25 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 10 Apr 2022 01:50:13 +0200 Subject: [PATCH 35/96] Added mixins to capitalize/prettify text --- platypush/backend/http/webapp/src/Utils.vue | 3 ++- .../backend/http/webapp/src/utils/Text.vue | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 platypush/backend/http/webapp/src/utils/Text.vue diff --git a/platypush/backend/http/webapp/src/Utils.vue b/platypush/backend/http/webapp/src/Utils.vue index 50ce3fe4b..455785424 100644 --- a/platypush/backend/http/webapp/src/Utils.vue +++ b/platypush/backend/http/webapp/src/Utils.vue @@ -5,10 +5,11 @@ import DateTime from "@/utils/DateTime"; import Events from "@/utils/Events"; import Notification from "@/utils/Notification"; import Screen from "@/utils/Screen"; +import Text from "@/utils/Text"; import Types from "@/utils/Types"; export default { name: "Utils", - mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Types], + mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Text, Types], } </script> diff --git a/platypush/backend/http/webapp/src/utils/Text.vue b/platypush/backend/http/webapp/src/utils/Text.vue new file mode 100644 index 000000000..26aee37a7 --- /dev/null +++ b/platypush/backend/http/webapp/src/utils/Text.vue @@ -0,0 +1,17 @@ +<script> +export default { + name: "Text", + methods: { + capitalize(text) { + if (!text?.length) + return text + + return text.charAt(0).toUpperCase() + text.slice(1) + }, + + prettify(text) { + return text.split('_').map((t) => this.capitalize(t)).join(' ') + }, + }, +} +</script> From 453652ef763444ee23d3ba4b50ca5dd03279a700 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 10 Apr 2022 01:50:45 +0200 Subject: [PATCH 36/96] Updated plugin icons --- .../backend/http/webapp/src/assets/icons.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/platypush/backend/http/webapp/src/assets/icons.json b/platypush/backend/http/webapp/src/assets/icons.json index ee45a8013..93e8886c0 100644 --- a/platypush/backend/http/webapp/src/assets/icons.json +++ b/platypush/backend/http/webapp/src/assets/icons.json @@ -17,6 +17,9 @@ "camera.pi": { "class": "fas fa-camera" }, + "entities": { + "class": "fa fa-home" + }, "execute": { "class": "fa fa-play" }, @@ -59,9 +62,21 @@ "rtorrent": { "class": "fa fa-magnet" }, + "smartthings": { + "imgUrl": "/icons/smartthings.png" + }, "switches": { "class": "fas fa-toggle-on" }, + "switch.switchbot": { + "class": "fas fa-toggle-on" + }, + "switch.tplink": { + "class": "fas fa-toggle-on" + }, + "switchbot": { + "class": "fas fa-toggle-on" + }, "sound": { "class": "fa fa-microphone" }, From 19223bbbe197e070da89ea55523b8e06fd3cb57d Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 10 Apr 2022 01:56:47 +0200 Subject: [PATCH 37/96] Added SmartThings icon --- .../http/webapp/public/icons/smartthings.png | Bin 0 -> 4057 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 platypush/backend/http/webapp/public/icons/smartthings.png diff --git a/platypush/backend/http/webapp/public/icons/smartthings.png b/platypush/backend/http/webapp/public/icons/smartthings.png new file mode 100644 index 0000000000000000000000000000000000000000..9110a99c5412a686b6a32e173bb4a8b50d4fcc2b GIT binary patch literal 4057 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 literal 0 HcmV?d00001 From 3435f591eb0ed924804c2fbad81e11d11d503eb1 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 10 Apr 2022 01:57:39 +0200 Subject: [PATCH 38/96] Support for keep-open-on-item-click and icon URLs on dropdown elements --- .../src/components/elements/Dropdown.vue | 5 ++++ .../src/components/elements/DropdownItem.vue | 30 ++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/elements/Dropdown.vue b/platypush/backend/http/webapp/src/components/elements/Dropdown.vue index 0ab113595..fd6de368b 100644 --- a/platypush/backend/http/webapp/src/components/elements/Dropdown.vue +++ b/platypush/backend/http/webapp/src/components/elements/Dropdown.vue @@ -37,6 +37,11 @@ export default { title: { type: String, }, + + keepOpenOnItemClick: { + type: Boolean, + default: false, + }, }, data() { diff --git a/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue b/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue index ac8c8f4d7..4698b7042 100644 --- a/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue +++ b/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue @@ -1,20 +1,27 @@ <template> <div class="row item" :class="itemClass" @click="clicked"> - <div class="col-1 icon" v-if="iconClass"> - <i :class="iconClass" /> + <div class="col-1 icon" v-if="iconClass?.length || iconUrl?.length"> + <Icon :class="iconClass" :url="iconUrl" /> </div> <div class="text" :class="{'col-11': iconClass != null}" v-text="text" /> </div> </template> <script> +import Icon from "@/components/elements/Icon"; + export default { name: "DropdownItem", + components: {Icon}, props: { iconClass: { type: String, }, + iconUrl: { + type: String, + }, + text: { type: String, }, @@ -31,8 +38,12 @@ export default { methods: { clicked(event) { + if (this.disabled) + return false + this.$parent.$emit('click', event) - this.$parent.visible = false + if (!this.$parent.keepOpenOnItemClick) + this.$parent.visible = false } } } @@ -55,7 +66,18 @@ export default { } .icon { - margin: 0 .5em; + display: inline-flex; + align-items: center; + } + + ::v-deep(.icon-container) { + width: 2em; + display: inline-flex; + align-items: center; + + .icon { + margin: 0 1.5em 0 .5em; + } } } </style> From 8ec9c8f2034efc10d51c5e6c336d1952f26423a2 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 10 Apr 2022 13:07:01 +0200 Subject: [PATCH 39/96] Added standard component for icons --- .../webapp/src/components/elements/Icon.vue | 43 +++++++++++++++++++ .../backend/http/webapp/src/style/icons.scss | 2 + 2 files changed, 45 insertions(+) create mode 100644 platypush/backend/http/webapp/src/components/elements/Icon.vue diff --git a/platypush/backend/http/webapp/src/components/elements/Icon.vue b/platypush/backend/http/webapp/src/components/elements/Icon.vue new file mode 100644 index 000000000..eb44fd603 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/elements/Icon.vue @@ -0,0 +1,43 @@ +<template> + <div class="icon-container"> + <img class="icon" :src="url" :alt="alt" v-if="url?.length"> + <i class="icon" :class="className" v-else-if="className?.length" /> + </div> +</template> + +<script> +export default { + props: { + class: { + type: String, + }, + url: { + type: String, + }, + alt: { + type: String, + default: '', + }, + }, + + computed: { + className() { + return this.class + } + } +} +</script> + +<style lang="scss" scoped> +.icon-container { + display: inline-flex; + width: $icon-container-size; + justify-content: center; + text-align: center; + + .icon { + width: 1em; + height: 1em; + } +} +</style> diff --git a/platypush/backend/http/webapp/src/style/icons.scss b/platypush/backend/http/webapp/src/style/icons.scss index fb599674e..a2cb7ef1a 100644 --- a/platypush/backend/http/webapp/src/style/icons.scss +++ b/platypush/backend/http/webapp/src/style/icons.scss @@ -1,3 +1,5 @@ +$icon-container-size: 3em; + @mixin icon { content: ' '; background-size: 1em 1em; From 58861afb1c19f9444046ff4fd818ba3b7c4a71ea Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 10 Apr 2022 13:07:36 +0200 Subject: [PATCH 40/96] Added entities panel --- .../http/webapp/src/components/Nav.vue | 14 +- .../src/components/panels/Entities/Entity.vue | 74 ++++++ .../src/components/panels/Entities/Index.vue | 242 ++++++++++++++++++ .../components/panels/Entities/Selector.vue | 198 ++++++++++++++ .../src/components/panels/Entities/Switch.vue | 53 ++++ .../src/components/panels/Entities/meta.json | 33 +++ .../src/components/panels/Entities/vars.scss | 2 + .../backend/http/webapp/src/style/items.scss | 2 + .../http/webapp/src/style/themes/light.scss | 5 +- .../backend/http/webapp/src/views/Panel.vue | 2 +- 10 files changed, 621 insertions(+), 4 deletions(-) create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/Index.vue create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/meta.json create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/vars.scss diff --git a/platypush/backend/http/webapp/src/components/Nav.vue b/platypush/backend/http/webapp/src/components/Nav.vue index 649bf6ad1..3a551ed2a 100644 --- a/platypush/backend/http/webapp/src/components/Nav.vue +++ b/platypush/backend/http/webapp/src/components/Nav.vue @@ -6,7 +6,7 @@ </div> <ul class="plugins"> - <li v-for="name in Object.keys(panels).sort()" :key="name" class="entry" :class="{selected: name === selectedPanel}" + <li v-for="name in panelNames" :key="name" class="entry" :class="{selected: name === selectedPanel}" :title="name" @click="onItemClick(name)"> <a :href="`/#${name}`"> <span class="icon"> @@ -14,7 +14,7 @@ <img :src="icons[name].imgUrl" v-else-if="icons[name]?.imgUrl" alt="name"/> <i class="fas fa-puzzle-piece" v-else /> </span> - <span class="name" v-if="!collapsed" v-text="name" /> + <span class="name" v-if="!collapsed" v-text="name == 'entities' ? 'Home' : name" /> </a> </li> </ul> @@ -66,6 +66,16 @@ export default { }, }, + computed: { + panelNames() { + let panelNames = Object.keys(this.panels) + const homeIdx = panelNames.indexOf('entities') + if (homeIdx >= 0) + return ['entities'].concat((panelNames.slice(0, homeIdx).concat(panelNames.slice(homeIdx+1))).sort()) + return panelNames.sort() + }, + }, + methods: { onItemClick(name) { this.$emit('select', name) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue new file mode 100644 index 000000000..dd2417981 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue @@ -0,0 +1,74 @@ +<template> + <div class="row item entity"> + <Loading v-if="loading" /> + <Icon v-bind="value.meta?.icon || {}" /> + <div class="component-container"> + <component :is="component" :value="value" @input="$emit('input', $event)" /> + </div> + </div> +</template> + +<script> +import { defineAsyncComponent } from 'vue' +import Utils from "@/Utils" +import Loading from "@/components/Loading" +import Icon from "@/components/elements/Icon"; + +export default { + name: "Entity", + components: {Loading, Icon}, + mixins: [Utils], + emits: ['input'], + props: { + loading: { + type: Boolean, + default: false, + }, + + value: { + type: Object, + required: true, + }, + }, + + data() { + return { + component: null, + } + }, + + computed: { + type() { + let entityType = (this.value.type || '') + return entityType.charAt(0).toUpperCase() + entityType.slice(1) + }, + }, + + mounted() { + if (this.type !== 'Entity') + this.component = defineAsyncComponent( + () => import(`@/components/panels/Entities/${this.type}`) + ) + }, +} +</script> + +<style lang="scss"> +@import "vars"; + +.entity { + width: 100%; + display: table; + + .icon-container, + .component-container { + height: 100%; + display: table-cell; + vertical-align: middle; + } + + .component-container { + width: calc(100% - #{$icon-container-size}); + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue new file mode 100644 index 000000000..714928da1 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -0,0 +1,242 @@ +<template> + <div class="row plugin entities-container"> + <Loading v-if="loading" /> + + <div class="entity-selector-container"> + <Selector :entity-groups="entityGroups" :value="selector" @input="selector = $event" /> + </div> + + <div class="groups-canvas"> + <div class="groups-container"> + <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" /> + </div> + + <div class="body"> + <div class="entity-frame" v-for="entity in group.entities" :key="entity.id"> + <Entity :value="entity" @input="entities[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 Entity from "./Entity.vue"; +import Selector from "./Selector.vue"; +import icons from '@/assets/icons.json' +import meta from './meta.json' + +export default { + name: "Entities", + components: {Loading, Icon, Entity, Selector}, + mixins: [Utils], + + data() { + return { + loading: false, + entities: {}, + 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, + } + } + ) + }, + }, + + 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() { + this.loading = true + + try { + this.entities = (await this.request('entities.get')).reduce((obj, entity) => { + entity.meta = { + ...(meta[entity.type] || {}), + ...(entity.meta || {}), + } + + obj[entity.id] = entity + return obj + }, {}) + + this.selector.selectedEntities = this.entityGroups.id + } finally { + this.loading = false + } + }, + }, + + mounted() { + this.refresh() + }, +} +</script> + +<style lang="scss"> +@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%; + display: flex; + flex-direction: column; + overflow: auto; + color: $default-fg-2; + font-weight: 400; + + .entity-selector-container { + height: $selector-height; + } + + .groups-canvas { + width: 100%; + height: calc(100% - #{$selector-height}); + display: flex; + flex-direction: column; + } + + .groups-container { + overflow: auto; + @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 { + max-height: calc(100% - #{2 * $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%; + } + + &.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; + } + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue new file mode 100644 index 000000000..3d737ae07 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue @@ -0,0 +1,198 @@ +<template> + <div class="entities-selectors-container"> + <div class="selector"> + <Dropdown title="Group by" icon-class="fas fa-layer-group" + :text="prettifyGroupingName(value.grouping)" 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> +</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: {}, + } + }, + + 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) => + !!this.selectedGroups[entity[this.value?.grouping]] + ).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) + }, + + resetGroupFilter() { + this.selectedGroups = Object.keys( + this.entityGroups[this.value?.grouping] || {} + ).reduce( + (obj, group) => { + obj[group] = true + return obj + }, {} + ) + + this.synchronizeSelectedEntities() + }, + + toggleGroup(group) { + if (this.selectedGroups[group]) + delete this.selectedGroups[group] + else + this.selectedGroups[group] = true + + 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.resetGroupFilter() + this.$watch(() => this.value?.grouping, this.resetGroupFilter) + this.$watch(() => this.entityGroups, this.resetGroupFilter) + }, +} +</script> + +<style lang="scss" scoped> +.entities-selectors-container { + width: 100%; + background: $default-bg-2; + display: flex; + align-items: center; + box-shadow: $border-shadow-bottom; + + .selector { + height: 100%; + display: inline-flex; + + &.active { + ::v-deep(.dropdown-container) { + button { + color: $default-hover-fg; + } + } + } + } + + ::v-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; + + &.selected { + font-weight: bold; + background: #ffffff00; + } + + &:hover { + background: $hover-bg; + } + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue new file mode 100644 index 000000000..e8fcf1c99 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue @@ -0,0 +1,53 @@ +<template> + <div class="switch"> + <div class="col-10 label"> + <div class="name" v-text="value.name" /> + </div> + + <div class="col-2 switch pull-right"> + <ToggleSwitch :value="value.state" @input="toggle" /> + </div> + </div> +</template> + +<script> +import ToggleSwitch from "@/components/elements/ToggleSwitch" +import Utils from "@/Utils" + +export default { + name: 'Switch', + components: {ToggleSwitch}, + emits: ['input'], + mixins: [Utils], + props: { + value: { + type: Object, + required: true, + }, + }, + + data() { + return { + component: null, + } + }, + + methods: { + async toggle() { + const response = await this.request('entities.execute', { + id: this.value.id, + action: 'toggle', + }) + + this.$emit('input', { + ...this.value, + state: response.on, + }) + }, + }, +} +</script> + +<style lang="scss"> +@import "vars"; +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/meta.json b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json new file mode 100644 index 000000000..e4eafd8f0 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json @@ -0,0 +1,33 @@ +{ + "entity": { + "name": "Entity", + "name_plural": "Entities", + "icon": { + "class": "fas fa-circle-question" + } + }, + + "device": { + "name": "Device", + "name_plural": "Devices", + "icon": { + "class": "fas fa-gear" + } + }, + + "switch": { + "name": "Switch", + "name_plural": "Switches", + "icon": { + "class": "fas fa-toggle-on" + } + }, + + "light": { + "name": "Light", + "name_plural": "Lights", + "icon": { + "class": "fas fa-lightbulb" + } + } +} diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/vars.scss b/platypush/backend/http/webapp/src/components/panels/Entities/vars.scss new file mode 100644 index 000000000..388ce2595 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/vars.scss @@ -0,0 +1,2 @@ +$main-margin: 1em; +$selector-height: 2.5em; diff --git a/platypush/backend/http/webapp/src/style/items.scss b/platypush/backend/http/webapp/src/style/items.scss index ae1adf67c..db6e7b471 100644 --- a/platypush/backend/http/webapp/src/style/items.scss +++ b/platypush/backend/http/webapp/src/style/items.scss @@ -1,3 +1,5 @@ +$header-height: 3.5em; + .item { display: flex; align-items: center; diff --git a/platypush/backend/http/webapp/src/style/themes/light.scss b/platypush/backend/http/webapp/src/style/themes/light.scss index c7f621765..b3e793378 100644 --- a/platypush/backend/http/webapp/src/style/themes/light.scss +++ b/platypush/backend/http/webapp/src/style/themes/light.scss @@ -10,6 +10,7 @@ $default-bg-7: #e4e4e4 !default; $default-fg: black !default; $default-fg-2: #23513a !default; $default-fg-3: #195331b3 !default; +$header-bg: linear-gradient(0deg, #c0e8e4, #e4f8f4) !default; //// Notifications $notification-bg: rgba(185, 255, 193, 0.9) !default; @@ -51,6 +52,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 +144,5 @@ $dropdown-shadow: 1px 1px 1px #bbb !default; //// Scrollbars $scrollbar-track-bg: $slider-bg !default; $scrollbar-track-shadow: inset 1px 0px 3px 0 $slider-track-shadow !default; -$scrollbar-thumb-bg: #50ca80 !default; +$scrollbar-thumb-bg: #a5a2a2 !default; diff --git a/platypush/backend/http/webapp/src/views/Panel.vue b/platypush/backend/http/webapp/src/views/Panel.vue index e143b04a9..78a6cdbe5 100644 --- a/platypush/backend/http/webapp/src/views/Panel.vue +++ b/platypush/backend/http/webapp/src/views/Panel.vue @@ -90,7 +90,7 @@ export default { initializeDefaultViews() { this.plugins.execute = {} - this.plugins.switches = {} + this.plugins.entities = {} }, }, From f301fd7e6914710e35fb6cf6c493b084bdf43589 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 10 Apr 2022 14:27:32 +0200 Subject: [PATCH 41/96] Added standard NoItems component to handle visualization of no-results divs --- .../src/components/elements/NoItems.vue | 51 +++++++++++++++++++ .../src/components/panels/Entities/Index.vue | 6 ++- .../http/webapp/src/style/themes/light.scss | 1 + 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 platypush/backend/http/webapp/src/components/elements/NoItems.vue diff --git a/platypush/backend/http/webapp/src/components/elements/NoItems.vue b/platypush/backend/http/webapp/src/components/elements/NoItems.vue new file mode 100644 index 000000000..db0278cb8 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/elements/NoItems.vue @@ -0,0 +1,51 @@ +<template> +<div class="no-items-container"> + <div class="no-items fade-in"> + <slot /> + </div> +</div> +</template> + +<script> +export default { + name: "NoItems", +} +</script> + +<style lang="scss" scoped> +.no-items-container { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + + .no-items { + min-width: 100%; + max-width: 100%; + + @include from($tablet) { + min-width: 80%; + } + + @include from($desktop) { + min-width: 50%; + max-width: 35em; + } + + @include from($fullhd) { + min-width: 33%; + } + + background: $background-color; + margin: 1em; + padding: 1em; + font-size: 1.5em; + color: $no-items-color; + display: flex; + align-items: center; + justify-content: center; + border-radius: 1em; + box-shadow: $border-shadow-bottom; + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index 714928da1..59f63be17 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -7,7 +7,8 @@ </div> <div class="groups-canvas"> - <div class="groups-container"> + <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"> @@ -43,6 +44,7 @@ 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 icons from '@/assets/icons.json' @@ -50,7 +52,7 @@ import meta from './meta.json' export default { name: "Entities", - components: {Loading, Icon, Entity, Selector}, + components: {Loading, Icon, Entity, Selector, NoItems}, mixins: [Utils], data() { diff --git a/platypush/backend/http/webapp/src/style/themes/light.scss b/platypush/backend/http/webapp/src/style/themes/light.scss index b3e793378..9b2b269f7 100644 --- a/platypush/backend/http/webapp/src/style/themes/light.scss +++ b/platypush/backend/http/webapp/src/style/themes/light.scss @@ -11,6 +11,7 @@ $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; From 532217be124e80a8c9324a1c414d81cd39f02422 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 10 Apr 2022 17:57:51 +0200 Subject: [PATCH 42/96] Support for filtering entities by search string --- .../src/components/panels/Entities/Index.vue | 9 +++--- .../components/panels/Entities/Selector.vue | 32 +++++++++++++++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index 59f63be17..9cbf97bce 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -95,7 +95,9 @@ export default { ([grouping, entities]) => { return { name: grouping, - entities: entities, + entities: entities.filter( + (e) => e.id in this.selector.selectedEntities + ), } } ) @@ -190,10 +192,7 @@ export default { padding: $main-margin 0; display: flex; break-inside: avoid; - - @include from($tablet) { - padding: $main-margin; - } + padding: $main-margin; .frame { max-height: calc(100% - #{2 * $main-margin}); diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue index 3d737ae07..2e25052a5 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue @@ -17,6 +17,10 @@ @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> @@ -47,6 +51,7 @@ export default { data() { return { selectedGroups: {}, + searchTerm: '', } }, @@ -70,9 +75,22 @@ export default { }, selectedEntities() { - return Object.values(this.entityGroups.id).filter((entity) => - !!this.selectedGroups[entity[this.value?.grouping]] - ).reduce((obj, entity) => { + 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 }, {}) @@ -103,6 +121,13 @@ export default { this.$emit('input', value) }, + updateSearchTerm() { + const value = {...this.value} + value.searchTerm = this.searchTerm + value.selectedEntities = this.selectedEntities + this.$emit('input', value) + }, + resetGroupFilter() { this.selectedGroups = Object.keys( this.entityGroups[this.value?.grouping] || {} @@ -138,6 +163,7 @@ export default { mounted() { this.resetGroupFilter() this.$watch(() => this.value?.grouping, this.resetGroupFilter) + this.$watch(() => this.searchTerm, this.updateSearchTerm) this.$watch(() => this.entityGroups, this.resetGroupFilter) }, } From 17615ff028e591752ec721ee0cd7c21fcf594382 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 10 Apr 2022 21:23:03 +0200 Subject: [PATCH 43/96] Support for multiple entity types/plugins filter on entities.get --- platypush/plugins/entities/__init__.py | 65 ++++++++++++++++---------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/platypush/plugins/entities/__init__.py b/platypush/plugins/entities/__init__.py index 40918d9b0..72b3bdb6e 100644 --- a/platypush/plugins/entities/__init__.py +++ b/platypush/plugins/entities/__init__.py @@ -1,7 +1,7 @@ from queue import Queue, Empty from threading import Thread from time import time -from typing import Optional, Any +from typing import Optional, Any, Collection from platypush.context import get_plugin from platypush.entities import Entity, get_plugin_entity_registry, get_entities_registry @@ -23,26 +23,43 @@ class EntitiesPlugin(Plugin): return db @action - def get(self, type: str = 'entity', **filter): + def get( + self, + types: Optional[Collection[str]] = None, + plugins: Optional[Collection[str]] = None, + **filter, + ): """ Retrieve a list of entities. - :param type: Entity type, as specified by the (lowercase) class name and table name. - Default: `entity` (retrieve all the types) + :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`, `plugin` etc.) + `state`, `type`, `plugin` etc.) """ entity_registry = get_entities_registry() + selected_types = [] all_types = {e.__tablename__.lower(): e for e in entity_registry} - entity_type = all_types.get(type.lower()) - assert ( - entity_type - ), f'No such entity type: {type}. Supported types: {list(all_types.keys())}' + 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() with db.get_session() as session: - query = session.query(entity_type) + query = session.query(Entity) + 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) @@ -51,37 +68,35 @@ class EntitiesPlugin(Plugin): @action def scan( self, - type: Optional[str] = None, - plugin: Optional[str] = None, + types: Optional[Collection[str]] = None, + plugins: Optional[Collection[str]] = None, timeout: Optional[float] = 30.0, ): """ (Re-)scan entities and return the updated results. - :param type: Filter by entity type (e.g. `switch`, `light`, `sensor` etc.). Default: all. - :param plugin: Filter by plugin name (e.g. `switch.tplink` or `light.hue`). Default: all. + :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 plugin: - filter['plugin'] = plugin + if plugins: + filter['plugins'] = plugins plugin_registry['by_plugin'] = { - **( - {plugin: plugin_registry['by_plugin'][plugin]} - if plugin in plugin_registry['by_plugin'] - else {} - ) + plugin: plugin_registry['by_plugin'][plugin] + for plugin in plugins + if plugin in plugin_registry['by_plugin'] } - if type: - filter['type'] = type - filter_plugins = set(plugin_registry['by_entity_type'].get(type, [])) + 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 plugin_name in filter_plugins + if any(t for t in entity_types if t in filter_entity_types) } enabled_plugins = plugin_registry['by_plugin'].keys() From 67ff585f6c296c692afb8a1a6c1a7367ac36443d Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 11 Apr 2022 00:01:21 +0200 Subject: [PATCH 44/96] 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) --- platypush/entities/_engine.py | 111 +++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 8 deletions(-) diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py index 63d9b95be..bbb0bb6c9 100644 --- a/platypush/entities/_engine.py +++ b/platypush/entities/_engine.py @@ -1,11 +1,11 @@ from logging import getLogger from queue import Queue, Empty -from threading import Thread, Event +from threading import Thread, Event, RLock from time import time -from typing import Iterable, List +from typing import Iterable, List, Optional from sqlalchemy import and_, or_ -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, make_transient from platypush.context import get_bus from platypush.message.event.entities import EntityUpdateEvent @@ -23,6 +23,98 @@ class EntitiesEngine(Thread): self.logger = getLogger(name=obj_name) self._queue = Queue() self._should_stop = Event() + 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 _entity_has_changes(self, new_entity: Entity) -> bool: + with self._entities_cache_lock: + cached_entity = self._get_cached_entity(new_entity) + if cached_entity: + if cached_entity.get('id'): + new_entity.id = cached_entity['id'] + if cached_entity == self._cache_repr(new_entity): + return False + + if new_entity.id: + self._cache_entities(new_entity) + + return True + + 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): + if self._entity_has_changes(entity): + get_bus().post(EntityUpdateEvent(entity=entity)) def post(self, *entities: Entity): for entity in entities: @@ -38,6 +130,7 @@ class EntitiesEngine(Thread): def run(self): super().run() self.logger.info('Started entities engine') + self._init_entities_cache() while not self.should_stop: msgs = [] @@ -53,6 +146,9 @@ class EntitiesEngine(Thread): 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 @@ -126,13 +222,12 @@ class EntitiesEngine(Thread): return new_entities def _process_entities(self, *entities: Entity): - from platypush.context import get_plugin - - with get_plugin('db').get_session() as session: # type: ignore + with self._get_db().get_session() as session: existing_entities = self._get_if_exist(session, entities) entities = self._merge_entities(entities, existing_entities) # type: ignore session.add_all(entities) session.commit() - for entity in entities: - get_bus().post(EntityUpdateEvent(entity=entity)) + with self._entities_cache_lock: + for entity in entities: + self._cache_entities(entity, overwrite_cache=True) From f17245e8c7207147e6f63296bf3ed99353df636f Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 11 Apr 2022 00:38:11 +0200 Subject: [PATCH 45/96] 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. --- platypush/entities/_engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py index bbb0bb6c9..f82c70297 100644 --- a/platypush/entities/_engine.py +++ b/platypush/entities/_engine.py @@ -113,7 +113,7 @@ class EntitiesEngine(Thread): self.logger.info('Entities cache initialized') def _process_event(self, entity: Entity): - if self._entity_has_changes(entity): + if self._entity_has_changes(entity) and entity.id: get_bus().post(EntityUpdateEvent(entity=entity)) def post(self, *entities: Entity): From 4471001110b7983eb19d4eaa4e099e4e8f8b550f Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 11 Apr 2022 00:42:14 +0200 Subject: [PATCH 46/96] smartthings.toggle should properly publish the updated entity --- platypush/plugins/smartthings/__init__.py | 37 +++++++++++++---------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/platypush/plugins/smartthings/__init__.py b/platypush/plugins/smartthings/__init__.py index 7fb2562f7..4abc55dce 100644 --- a/platypush/plugins/smartthings/__init__.py +++ b/platypush/plugins/smartthings/__init__.py @@ -413,17 +413,16 @@ class SmartthingsPlugin(SwitchPlugin): finally: loop.stop() - async def _get_device_status(self, api, device_id: str) -> dict: + def transform_entities(self, entities): from platypush.entities.switches import Switch - device = await api.device(device_id) - await device.status.refresh() + compatible_entities = [] - if 'switch' in device.capabilities: - self.publish_entities( # type: ignore - [ + for device in entities: + if 'switch' in device.capabilities: + compatible_entities.append( Switch( - id=device_id, + id=device.device_id, name=device.label, state=device.status.switch, data={ @@ -431,8 +430,14 @@ class SmartthingsPlugin(SwitchPlugin): 'room_id': getattr(device, 'room_id', None), }, ) - ] - ) + ) + + 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, @@ -526,7 +531,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. @@ -534,11 +539,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. @@ -546,11 +550,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. @@ -584,6 +587,8 @@ class SmartthingsPlugin(SwitchPlugin): with self._refresh_lock: loop = asyncio.new_event_loop() state = loop.run_until_complete(_toggle()) + device.status.switch = state + self.publish_entities([device]) # type: ignore return { 'id': device_id, 'name': device.label, From db4ad5825e480888eb95a5482443e25446f3681f Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 11 Apr 2022 01:40:49 +0200 Subject: [PATCH 47/96] Fire an EntityUpdateEvent when the zwave.mqtt backend gets a value changed message --- platypush/backend/zwave/mqtt/__init__.py | 107 ++++++++++++++++------- platypush/plugins/zwave/mqtt/__init__.py | 28 +++--- 2 files changed, 89 insertions(+), 46 deletions(-) diff --git a/platypush/backend/zwave/mqtt/__init__.py b/platypush/backend/zwave/mqtt/__init__.py index 7d38c8e18..5a5d426c3 100644 --- a/platypush/backend/zwave/mqtt/__init__.py +++ b/platypush/backend/zwave/mqtt/__init__.py @@ -1,3 +1,4 @@ +import contextlib import json from queue import Queue, Empty from typing import Optional, Type @@ -5,14 +6,24 @@ from typing import Optional, Type from platypush.backend.mqtt import MqttBackend from platypush.context import get_plugin -from platypush.message.event.zwave import ZwaveEvent, ZwaveNodeAddedEvent, ZwaveValueChangedEvent, \ - ZwaveNodeRemovedEvent, ZwaveNodeRenamedEvent, ZwaveNodeReadyEvent, ZwaveNodeEvent, ZwaveNodeAsleepEvent, \ - ZwaveNodeAwakeEvent +from platypush.message.event.zwave import ( + ZwaveEvent, + ZwaveNodeAddedEvent, + ZwaveValueChangedEvent, + ZwaveNodeRemovedEvent, + ZwaveNodeRenamedEvent, + ZwaveNodeReadyEvent, + ZwaveNodeEvent, + ZwaveNodeAsleepEvent, + ZwaveNodeAwakeEvent, +) class ZwaveMqttBackend(MqttBackend): """ Listen for events on a `zwavejs2mqtt <https://github.com/zwave-js/zwavejs2mqtt>`_ service. + For historical reasons, this should be enabled together with the ``zwave.mqtt`` plugin, + even though the actual configuration is only specified on the plugin. Triggers: @@ -41,6 +52,7 @@ class ZwaveMqttBackend(MqttBackend): """ from platypush.plugins.zwave.mqtt import ZwaveMqttPlugin + self.plugin: ZwaveMqttPlugin = get_plugin('zwave.mqtt') assert self.plugin, 'The zwave.mqtt plugin is not configured' @@ -61,27 +73,48 @@ class ZwaveMqttBackend(MqttBackend): 'password': self.plugin.password, } - listeners = [{ - **self.server_info, - 'topics': [ - self.plugin.events_topic + '/node/' + topic - for topic in ['node_ready', 'node_sleep', 'node_value_updated', 'node_metadata_updated', 'node_wakeup'] - ], - }] + listeners = [ + { + **self.server_info, + 'topics': [ + self.plugin.events_topic + '/node/' + topic + for topic in [ + 'node_ready', + 'node_sleep', + 'node_value_updated', + 'node_metadata_updated', + 'node_wakeup', + ] + ], + } + ] - super().__init__(*args, subscribe_default_topic=False, listeners=listeners, client_id=client_id, **kwargs) + super().__init__( + *args, + subscribe_default_topic=False, + listeners=listeners, + client_id=client_id, + **kwargs, + ) if not client_id: self.client_id += '-zwavejs-mqtt' - def _dispatch_event(self, event_type: Type[ZwaveEvent], node: Optional[dict] = None, value: Optional[dict] = None, - **kwargs): + def _dispatch_event( + self, + event_type: Type[ZwaveEvent], + node: Optional[dict] = None, + value: Optional[dict] = None, + **kwargs, + ): if value and 'id' not in value: value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}" if 'propertyKey' in value: value_id += '-' + str(value['propertyKey']) if value_id not in node.get('values', {}): - self.logger.warning(f'value_id {value_id} not found on node {node["id"]}') + self.logger.warning( + f'value_id {value_id} not found on node {node["id"]}' + ) return value = node['values'][value_id] @@ -107,41 +140,47 @@ class ZwaveMqttBackend(MqttBackend): evt = event_type(**kwargs) self._events_queue.put(evt) - # zwavejs2mqtt currently treats some values (e.g. binary switches) in an inconsistent way, - # using two values - a read-only value called currentValue that gets updated on the - # node_value_updated topic, and a writable value called targetValue that doesn't get updated - # (see https://github.com/zwave-js/zwavejs2mqtt/blob/4a6a5c5f1274763fd3aced4cae2c72ea060716b5/docs/guide/migrating.md). - # To properly manage updates on writable values, propagate an event for both. - if event_type == ZwaveValueChangedEvent and kwargs.get('value', {}).get('property_id') == 'currentValue': - value = kwargs['value'].copy() - target_value_id = f'{kwargs["node"]["node_id"]}-{value["command_class"]}-{value.get("endpoint", 0)}' \ - f'-targetValue' - kwargs['value'] = kwargs['node'].get('values', {}).get(target_value_id) + if event_type == ZwaveValueChangedEvent: + # zwavejs2mqtt currently treats some values (e.g. binary switches) in an inconsistent way, + # using two values - a read-only value called currentValue that gets updated on the + # node_value_updated topic, and a writable value called targetValue that doesn't get updated + # (see https://github.com/zwave-js/zwavejs2mqtt/blob/4a6a5c5f1274763fd3aced4cae2c72ea060716b5 \ + # /docs/guide/migrating.md). + # To properly manage updates on writable values, propagate an event for both. + if kwargs.get('value', {}).get('property_id') == 'currentValue': + value = kwargs['value'].copy() + target_value_id = ( + f'{kwargs["node"]["node_id"]}-{value["command_class"]}-{value.get("endpoint", 0)}' + f'-targetValue' + ) + kwargs['value'] = kwargs['node'].get('values', {}).get(target_value_id) - if kwargs['value']: - kwargs['value']['data'] = value['data'] - kwargs['node']['values'][target_value_id] = kwargs['value'] - evt = event_type(**kwargs) - self._events_queue.put(evt) + if kwargs['value']: + kwargs['value']['data'] = value['data'] + kwargs['node']['values'][target_value_id] = kwargs['value'] + evt = event_type(**kwargs) + self._events_queue.put(evt) + + self.plugin.publish_entities([kwargs['value']]) # type: ignore def on_mqtt_message(self): def handler(_, __, msg): if not msg.topic.startswith(self.events_topic): return - topic = msg.topic[len(self.events_topic) + 1:].split('/').pop() + topic = msg.topic[len(self.events_topic) + 1 :].split('/').pop() data = msg.payload.decode() if not data: return - try: + with contextlib.suppress(ValueError, TypeError): data = json.loads(data)['data'] - except (ValueError, TypeError): - pass try: if topic == 'node_value_updated': - self._dispatch_event(ZwaveValueChangedEvent, node=data[0], value=data[1]) + self._dispatch_event( + ZwaveValueChangedEvent, node=data[0], value=data[1] + ) elif topic == 'node_metadata_updated': self._dispatch_event(ZwaveNodeEvent, node=data[0]) elif topic == 'node_sleep': diff --git a/platypush/plugins/zwave/mqtt/__init__.py b/platypush/plugins/zwave/mqtt/__init__.py index 772a046df..4c1bbab14 100644 --- a/platypush/plugins/zwave/mqtt/__init__.py +++ b/platypush/plugins/zwave/mqtt/__init__.py @@ -22,6 +22,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): This plugin allows you to manage a Z-Wave network over MQTT through `zwavejs2mqtt <https://github.com/zwave-js/zwavejs2mqtt>`_. + For historical reasons, it is advised to enabled this plugin together + with the ``zwave.mqtt`` backend, or you may lose the ability to listen + to asynchronous events. + Configuration required on the zwavejs2mqtt gateway: * Install the gateway following the instructions reported @@ -1124,21 +1128,21 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): ) @action - def set_value_label(self, **kwargs): + def set_value_label(self, **_): """ Change the label/name of a value (not implemented by zwavejs2mqtt). """ raise _NOT_IMPLEMENTED_ERR @action - def node_add_value(self, **kwargs): + def node_add_value(self, **_): """ Add a value to a node (not implemented by zwavejs2mqtt). """ raise _NOT_IMPLEMENTED_ERR @action - def node_remove_value(self, **kwargs): + def node_remove_value(self, **_): """ Remove a value from a node (not implemented by zwavejs2mqtt). """ @@ -1492,7 +1496,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): return self._groups_cache @action - def get_scenes(self, **kwargs) -> Dict[int, Dict[str, Any]]: + def get_scenes(self, **_) -> Dict[int, Dict[str, Any]]: """ Get the scenes configured on the network. @@ -1528,7 +1532,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): } @action - def create_scene(self, label: str, **kwargs): + def create_scene(self, label: str, **_): """ Create a new scene. @@ -1788,7 +1792,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): ) @action - def create_new_primary(self, **kwargs): + def create_new_primary(self, **_): """ Create a new primary controller on the network when the previous primary fails (not implemented by zwavejs2mqtt). @@ -1799,7 +1803,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def hard_reset(self, **kwargs): + def hard_reset(self, **_): """ Perform a hard reset of the controller. It erases its network configuration settings. The controller becomes a primary controller ready to add devices to a new network. @@ -1810,7 +1814,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self._api_request('hardReset') @action - def soft_reset(self, **kwargs): + def soft_reset(self, **_): """ Perform a soft reset of the controller. Resets a controller without erasing its network configuration settings (not implemented by zwavejs2). @@ -1821,7 +1825,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def write_config(self, **kwargs): + def write_config(self, **_): """ Store the current configuration of the network to the user directory (not implemented by zwavejs2mqtt). @@ -1831,7 +1835,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): raise _NOT_IMPLEMENTED_ERR @action - def on(self, device: str, *args, **kwargs): + def on(self, device: str, *_, **kwargs): """ Turn on a switch on a device. @@ -1842,7 +1846,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self.set_value(data=True, id_on_network=device, **kwargs) @action - def off(self, device: str, *args, **kwargs): + def off(self, device: str, *_, **kwargs): """ Turn off a switch on a device. @@ -1853,7 +1857,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): self.set_value(data=False, id_on_network=device, **kwargs) @action - def toggle(self, device: str, *args, **kwargs) -> dict: + def toggle(self, device: str, *_, **kwargs) -> dict: """ Toggle a switch on a device. From be4d1e8e01e21dfef7c37e39660f2ba016bfe275 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 11 Apr 2022 21:16:45 +0200 Subject: [PATCH 48/96] Proper support for native entities in zigbee.mqtt integration --- platypush/backend/zigbee/mqtt/__init__.py | 151 ++++++++++++++++------ platypush/plugins/zigbee/mqtt/__init__.py | 78 ++++++++--- 2 files changed, 168 insertions(+), 61 deletions(-) diff --git a/platypush/backend/zigbee/mqtt/__init__.py b/platypush/backend/zigbee/mqtt/__init__.py index fd805596e..b7af6b1ea 100644 --- a/platypush/backend/zigbee/mqtt/__init__.py +++ b/platypush/backend/zigbee/mqtt/__init__.py @@ -1,21 +1,38 @@ +import contextlib import json -from typing import Optional +from typing import Optional, Mapping from platypush.backend.mqtt import MqttBackend from platypush.context import get_plugin -from platypush.message.event.zigbee.mqtt import ZigbeeMqttOnlineEvent, ZigbeeMqttOfflineEvent, \ - ZigbeeMqttDevicePropertySetEvent, ZigbeeMqttDevicePairingEvent, ZigbeeMqttDeviceConnectedEvent, \ - ZigbeeMqttDeviceBannedEvent, ZigbeeMqttDeviceRemovedEvent, ZigbeeMqttDeviceRemovedFailedEvent, \ - ZigbeeMqttDeviceWhitelistedEvent, ZigbeeMqttDeviceRenamedEvent, ZigbeeMqttDeviceBindEvent, \ - ZigbeeMqttDeviceUnbindEvent, ZigbeeMqttGroupAddedEvent, ZigbeeMqttGroupAddedFailedEvent, \ - ZigbeeMqttGroupRemovedEvent, ZigbeeMqttGroupRemovedFailedEvent, ZigbeeMqttGroupRemoveAllEvent, \ - ZigbeeMqttGroupRemoveAllFailedEvent, ZigbeeMqttErrorEvent +from platypush.message.event.zigbee.mqtt import ( + ZigbeeMqttOnlineEvent, + ZigbeeMqttOfflineEvent, + ZigbeeMqttDevicePropertySetEvent, + ZigbeeMqttDevicePairingEvent, + ZigbeeMqttDeviceConnectedEvent, + ZigbeeMqttDeviceBannedEvent, + ZigbeeMqttDeviceRemovedEvent, + ZigbeeMqttDeviceRemovedFailedEvent, + ZigbeeMqttDeviceWhitelistedEvent, + ZigbeeMqttDeviceRenamedEvent, + ZigbeeMqttDeviceBindEvent, + ZigbeeMqttDeviceUnbindEvent, + ZigbeeMqttGroupAddedEvent, + ZigbeeMqttGroupAddedFailedEvent, + ZigbeeMqttGroupRemovedEvent, + ZigbeeMqttGroupRemovedFailedEvent, + ZigbeeMqttGroupRemoveAllEvent, + ZigbeeMqttGroupRemoveAllFailedEvent, + ZigbeeMqttErrorEvent, +) class ZigbeeMqttBackend(MqttBackend): """ Listen for events on a zigbee2mqtt service. + For historical reasons, this backend should be enabled together with the `zigbee.mqtt` plugin. + Triggers: * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent` when the service comes online. @@ -59,11 +76,22 @@ class ZigbeeMqttBackend(MqttBackend): """ - def __init__(self, host: Optional[str] = None, port: Optional[int] = None, base_topic='zigbee2mqtt', - tls_cafile: Optional[str] = None, tls_certfile: Optional[str] = None, - tls_keyfile: Optional[str] = None, tls_version: Optional[str] = None, - tls_ciphers: Optional[str] = None, username: Optional[str] = None, - password: Optional[str] = None, client_id: Optional[str] = None, *args, **kwargs): + def __init__( + self, + host: Optional[str] = None, + port: Optional[int] = None, + base_topic='zigbee2mqtt', + tls_cafile: Optional[str] = None, + tls_certfile: Optional[str] = None, + tls_keyfile: Optional[str] = None, + tls_version: Optional[str] = None, + tls_ciphers: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + client_id: Optional[str] = None, + *args, + **kwargs + ): """ :param host: MQTT broker host (default: host configured on the ``zigbee.mqtt`` plugin). :param port: MQTT broker port (default: 1883). @@ -87,6 +115,7 @@ class ZigbeeMqttBackend(MqttBackend): plugin = get_plugin('zigbee.mqtt') self.base_topic = base_topic or plugin.base_topic self._devices = {} + self._devices_info = {} self._groups = {} self._last_state = None self.server_info = { @@ -106,17 +135,28 @@ class ZigbeeMqttBackend(MqttBackend): **self.server_info, } - listeners = [{ - **self.server_info, - 'topics': [ - self.base_topic + '/' + topic - for topic in ['bridge/state', 'bridge/log', 'bridge/logging', 'bridge/devices', 'bridge/groups'] - ], - }] + listeners = [ + { + **self.server_info, + 'topics': [ + self.base_topic + '/' + topic + for topic in [ + 'bridge/state', + 'bridge/log', + 'bridge/logging', + 'bridge/devices', + 'bridge/groups', + ] + ], + } + ] super().__init__( - *args, subscribe_default_topic=False, - listeners=listeners, client_id=client_id, **kwargs + *args, + subscribe_default_topic=False, + listeners=listeners, + client_id=client_id, + **kwargs ) if not client_id: @@ -146,7 +186,7 @@ class ZigbeeMqttBackend(MqttBackend): if msg_type == 'devices': devices = {} - for dev in (text or []): + for dev in text or []: devices[dev['friendly_name']] = dev client.subscribe(self.base_topic + '/' + dev['friendly_name']) elif msg_type == 'pairing': @@ -155,7 +195,9 @@ class ZigbeeMqttBackend(MqttBackend): self.bus.post(ZigbeeMqttDeviceBannedEvent(device=text, **args)) elif msg_type in ['device_removed_failed', 'device_force_removed_failed']: force = msg_type == 'device_force_removed_failed' - self.bus.post(ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args)) + self.bus.post( + ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args) + ) elif msg_type == 'device_whitelisted': self.bus.post(ZigbeeMqttDeviceWhitelistedEvent(device=text, **args)) elif msg_type == 'device_renamed': @@ -181,7 +223,11 @@ class ZigbeeMqttBackend(MqttBackend): self.bus.post(ZigbeeMqttErrorEvent(error=text, **args)) elif msg.get('level') in ['warning', 'error']: log = getattr(self.logger, msg['level']) - log('zigbee2mqtt {}: {}'.format(msg['level'], text or msg.get('error', msg.get('warning')))) + log( + 'zigbee2mqtt {}: {}'.format( + msg['level'], text or msg.get('error', msg.get('warning')) + ) + ) def _process_devices(self, client, msg): devices_info = { @@ -191,10 +237,9 @@ class ZigbeeMqttBackend(MqttBackend): # noinspection PyProtectedMember event_args = {'host': client._host, 'port': client._port} - client.subscribe(*[ - self.base_topic + '/' + device - for device in devices_info.keys() - ]) + client.subscribe( + *[self.base_topic + '/' + device for device in devices_info.keys()] + ) for name, device in devices_info.items(): if name not in self._devices: @@ -203,7 +248,7 @@ class ZigbeeMqttBackend(MqttBackend): exposes = (device.get('definition', {}) or {}).get('exposes', []) client.publish( self.base_topic + '/' + name + '/get', - json.dumps(get_plugin('zigbee.mqtt').build_device_get_request(exposes)) + json.dumps(self._plugin.build_device_get_request(exposes)), ) devices_copy = [*self._devices.keys()] @@ -213,13 +258,13 @@ class ZigbeeMqttBackend(MqttBackend): del self._devices[name] self._devices = {device: {} for device in devices_info.keys()} + self._devices_info = devices_info def _process_groups(self, client, msg): # noinspection PyProtectedMember event_args = {'host': client._host, 'port': client._port} groups_info = { - group.get('friendly_name', group.get('id')): group - for group in msg + group.get('friendly_name', group.get('id')): group for group in msg } for name in groups_info.keys(): @@ -236,15 +281,13 @@ class ZigbeeMqttBackend(MqttBackend): def on_mqtt_message(self): def handler(client, _, msg): - topic = msg.topic[len(self.base_topic)+1:] + topic = msg.topic[len(self.base_topic) + 1 :] data = msg.payload.decode() if not data: return - try: + with contextlib.suppress(ValueError, TypeError): data = json.loads(data) - except (ValueError, TypeError): - pass if topic == 'bridge/state': self._process_state_message(client, data) @@ -260,17 +303,45 @@ class ZigbeeMqttBackend(MqttBackend): return name = suffix - changed_props = {k: v for k, v in data.items() if v != self._devices[name].get(k)} + changed_props = { + k: v for k, v in data.items() if v != self._devices[name].get(k) + } if changed_props: - # noinspection PyProtectedMember - self.bus.post(ZigbeeMqttDevicePropertySetEvent(host=client._host, port=client._port, - device=name, properties=changed_props)) + self._process_property_update(name, changed_props) + self.bus.post( + ZigbeeMqttDevicePropertySetEvent( + host=client._host, + port=client._port, + device=name, + properties=changed_props, + ) + ) self._devices[name].update(data) return handler + @property + def _plugin(self): + plugin = get_plugin('zigbee.mqtt') + assert plugin, 'The zigbee.mqtt plugin is not configured' + return plugin + + def _process_property_update(self, device_name: str, properties: Mapping): + device_info = self._devices_info.get(device_name) + if not (device_info and properties): + return + + self._plugin.publish_entities( + [ + { + **device_info, + 'state': properties, + } + ] + ) + def run(self): super().run() diff --git a/platypush/plugins/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py index f0d718bd1..9b5636aaa 100644 --- a/platypush/plugins/zigbee/mqtt/__init__.py +++ b/platypush/plugins/zigbee/mqtt/__init__.py @@ -3,6 +3,7 @@ import threading from queue import Queue from typing import Optional, List, Any, Dict, Union +from platypush.message import Mapping from platypush.message.response import Response from platypush.plugins.mqtt import MqttPlugin, action @@ -153,6 +154,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in self._info = { 'devices': {}, 'groups': {}, + 'devices_by_addr': {}, } def transform_entities(self, devices): @@ -163,6 +165,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in if not dev: continue + converted_entity = None dev_def = dev.get("definition") or {} dev_info = { "type": dev.get("type"), @@ -178,17 +181,18 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in "description": dev_def.get("description"), } - switch_info = self._get_switch_info(dev) + switch_info = self._get_switch_meta(dev) if switch_info: - compatible_entities.append( - Switch( - id=dev['ieee_address'], - name=dev.get('friendly_name'), - state=switch_info['property'] == switch_info['value_on'], - data=dev_info, - ) + converted_entity = Switch( + id=dev['ieee_address'], + name=dev.get('friendly_name'), + state=dev.get('state', {}).get('state') == 'ON', + 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): @@ -244,11 +248,14 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in 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', []) } - self.publish_entities(self._info['devices'].values()) # type: ignore self.logger.info('Zigbee network configuration updated') return info finally: @@ -659,6 +666,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in 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( @@ -676,6 +688,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in :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( @@ -688,11 +703,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in assert property in properties, f'No such property: {property}' return {property: properties[property]} - refreshed = False if device not in self._info.get('devices', {}): # Refresh devices info self._get_network_info(**kwargs) - refreshed = True assert self._info.get('devices', {}).get(device), f'No such device: {device}' exposes = ( @@ -701,17 +714,24 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in if not exposes: return {} - device_info = self.publish( + device_state = self.publish( topic=self._topic(device) + '/get', reply_topic=self._topic(device), msg=self.build_device_get_request(exposes), **kwargs, - ) + ).output - if not refreshed: - self.publish_entities([device_info]) # type: ignore + if device_info: + self.publish_entities( + [ + { # type: ignore + **device_info, + 'state': device_state, + } + ] + ) - return device_info + return device_state @action def devices_get( @@ -1242,8 +1262,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in 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) + device = switch_info.get('friendly_name') or switch_info['ieee_address'] props = self.device_set( device, switch_info['property'], switch_info['value_on'] ).output @@ -1257,8 +1278,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in 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) + device = switch_info.get('friendly_name') or switch_info['ieee_address'] props = self.device_set( device, switch_info['property'], switch_info['value_off'] ).output @@ -1272,8 +1294,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in 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) + device = switch_info.get('friendly_name') or switch_info['ieee_address'] props = self.device_set( device, switch_info['property'], switch_info['value_toggle'] ).output @@ -1281,6 +1304,17 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in 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: return { @@ -1291,7 +1325,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in } @staticmethod - def _get_switch_info(device_info: dict) -> dict: + 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', []): @@ -1302,6 +1336,8 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in and feature.get('access', 0) & 2 ): 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'], @@ -1316,7 +1352,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in switches_info = {} for device in devices: - info = self._get_switch_info(device) + info = self._get_switch_meta(device) if not info: continue From 72617b4b759253dbacba9187560f8207df22b8d0 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 11 Apr 2022 23:16:29 +0200 Subject: [PATCH 49/96] Handle EntityUpdateEvents on the UI --- .../src/components/panels/Entities/Index.vue | 20 +++++++++++++++++++ .../src/components/panels/Entities/Switch.vue | 7 +------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index 9cbf97bce..4bae4aafe 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -136,9 +136,29 @@ export default { this.loading = false } }, + + onEntityUpdate(event) { + const entityId = event.entity.id + if (entityId == null) + return + + this.entities[entityId] = { + ...event.entity, + meta: { + ...(this.entities[entityId]?.meta || {}), + ...(event.entity?.meta || {}), + }, + } + }, }, mounted() { + this.subscribe( + this.onEntityUpdate, + 'on-entity-update', + 'platypush.message.event.entities.EntityUpdateEvent' + ) + this.refresh() }, } diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue index e8fcf1c99..7d49dcf2c 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue @@ -34,15 +34,10 @@ export default { methods: { async toggle() { - const response = await this.request('entities.execute', { + await this.request('entities.execute', { id: this.value.id, action: 'toggle', }) - - this.$emit('input', { - ...this.value, - state: response.on, - }) }, }, } From 2aa8778078239088b34047e4c2568a4bda6ce5a5 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 12 Apr 2022 00:41:20 +0200 Subject: [PATCH 50/96] 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. --- platypush/entities/_engine.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py index f82c70297..e7a72726f 100644 --- a/platypush/entities/_engine.py +++ b/platypush/entities/_engine.py @@ -87,20 +87,14 @@ class EntitiesEngine(Thread): (entity.name, entity.plugin) ] = e - def _entity_has_changes(self, new_entity: Entity) -> bool: + 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: - if cached_entity.get('id'): - new_entity.id = cached_entity['id'] - if cached_entity == self._cache_repr(new_entity): - return False - + if cached_entity and cached_entity.get('id'): + new_entity.id = cached_entity['id'] if new_entity.id: self._cache_entities(new_entity) - return True - def _init_entities_cache(self): with self._get_db().get_session() as session: entities = session.query(Entity).all() @@ -113,7 +107,8 @@ class EntitiesEngine(Thread): self.logger.info('Entities cache initialized') def _process_event(self, entity: Entity): - if self._entity_has_changes(entity) and entity.id: + self._populate_entity_id_from_cache(entity) + if entity.id: get_bus().post(EntityUpdateEvent(entity=entity)) def post(self, *entities: Entity): From 9ddcf5eaeb3583e177aa91f3abc47fb7dc25cf5b Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 12 Apr 2022 00:43:22 +0200 Subject: [PATCH 51/96] Implemented entities refresh on the UI --- .../http/webapp/public/img/spinner.gif | Bin 0 -> 32701 bytes .../src/components/panels/Entities/Entity.vue | 33 +++++++-- .../src/components/panels/Entities/Index.vue | 69 ++++++++++++++++-- .../components/panels/Entities/Selector.vue | 2 - 4 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 platypush/backend/http/webapp/public/img/spinner.gif diff --git a/platypush/backend/http/webapp/public/img/spinner.gif b/platypush/backend/http/webapp/public/img/spinner.gif new file mode 100644 index 0000000000000000000000000000000000000000..0b3ba6284d73f3aa86128563810e018aa2d22f20 GIT binary patch literal 32701 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 literal 0 HcmV?d00001 diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue index dd2417981..bf46a2f80 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue @@ -1,9 +1,13 @@ <template> <div class="row item entity"> - <Loading v-if="loading" /> - <Icon v-bind="value.meta?.icon || {}" /> + <div class="status-container"> + <img src="@/assets/img/spinner.gif" class="loading" v-if="loading"> + <Icon v-bind="value.meta?.icon || {}" v-else /> + </div> <div class="component-container"> - <component :is="component" :value="value" @input="$emit('input', $event)" /> + <component :is="component" :value="value" + @input="$emit('input', $event)" + @loading="$emit('loading', $event)" /> </div> </div> </template> @@ -11,14 +15,13 @@ <script> import { defineAsyncComponent } from 'vue' import Utils from "@/Utils" -import Loading from "@/components/Loading" import Icon from "@/components/elements/Icon"; export default { name: "Entity", - components: {Loading, Icon}, + components: {Icon}, mixins: [Utils], - emits: ['input'], + emits: ['input', 'loading'], props: { loading: { type: Boolean, @@ -53,13 +56,29 @@ export default { } </script> -<style lang="scss"> +<style lang="scss" scoped> @import "vars"; .entity { width: 100%; display: table; + .status-container { + width: 2.5em; + height: 1.5em; + display: table-cell; + vertical-align: middle; + position: relative; + + .loading { + position: absolute; + bottom: 0; + transform: translate(50%, -50%); + width: 1em; + height: 1em; + } + } + .icon-container, .component-container { height: 100%; diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index 4bae4aafe..f10481b16 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -2,9 +2,17 @@ <div class="row plugin entities-container"> <Loading v-if="loading" /> - <div class="entity-selector-container"> - <Selector :entity-groups="entityGroups" :value="selector" @input="selector = $event" /> - </div> + <header> + <div class="col-11"> + <Selector :entity-groups="entityGroups" :value="selector" @input="selector = $event" /> + </div> + + <div class="col-1 pull-right"> + <button title="Refresh" @click="refresh"> + <i class="fa fa-sync-alt" /> + </button> + </div> + </header> <div class="groups-canvas"> <NoItems v-if="!Object.keys(displayGroups || {})?.length">No entities found</NoItems> @@ -30,7 +38,12 @@ <div class="body"> <div class="entity-frame" v-for="entity in group.entities" :key="entity.id"> - <Entity :value="entity" @input="entities[entity.id] = $event" /> + <Entity + :value="entity" + @input="onEntityInput" + :loading="!!loadingEntities[entity.id]" + @loading="loadingEntities[entity.id] = true" + /> </div> </div> </div> @@ -58,6 +71,7 @@ export default { data() { return { loading: false, + loadingEntities: {}, entities: {}, selector: { grouping: 'type', @@ -118,6 +132,27 @@ export default { }, async refresh() { + const actions = Object.keys( + Object.values(this.selector.selectedEntities).reduce((obj, entity) => { + if (entity.plugin) + obj[entity.plugin] = true + return obj + }, {}) + ).map((plugin) => `${plugin}.status`) + + this.loadingEntities = { + ...this.loadingEntities, + ...Object.keys(this.selector.selectedEntities).reduce((obj, id) => { + obj[id] = true + return obj + }, {}), + } + + // Force refresh by calling `.status` on all the selected plugins + await Promise.all(actions.map((act) => this.request(act))) + }, + + async sync() { this.loading = true try { @@ -137,10 +172,19 @@ export default { } }, + onEntityInput(entity) { + const entityId = entity.id + this.entities[entityId] = entity + if (this.loadingEntities[entity.id]) + delete this.loadingEntities[entity.id] + }, + onEntityUpdate(event) { const entityId = event.entity.id if (entityId == null) return + if (this.loadingEntities[entityId]) + delete this.loadingEntities[entityId] this.entities[entityId] = { ...event.entity, @@ -159,7 +203,7 @@ export default { 'platypush.message.event.entities.EntityUpdateEvent' ) - this.refresh() + this.sync() }, } </script> @@ -187,8 +231,21 @@ export default { color: $default-fg-2; font-weight: 400; - .entity-selector-container { + 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; } .groups-canvas { diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue index 2e25052a5..4a44dd906 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue @@ -172,10 +172,8 @@ export default { <style lang="scss" scoped> .entities-selectors-container { width: 100%; - background: $default-bg-2; display: flex; align-items: center; - box-shadow: $border-shadow-bottom; .selector { height: 100%; From 20530c2b6dc0326136d87cd142e752875ec7036c Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 12 Apr 2022 01:10:09 +0200 Subject: [PATCH 52/96] Loading events are now synchronized both ways upon entity action/refresh --- .../src/components/panels/Entities/Entity.vue | 7 +++-- .../src/components/panels/Entities/Index.vue | 2 +- .../src/components/panels/Entities/Switch.vue | 29 ++++++++++++------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue index bf46a2f80..4d0fb780b 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue @@ -5,9 +5,12 @@ <Icon v-bind="value.meta?.icon || {}" v-else /> </div> <div class="component-container"> - <component :is="component" :value="value" + <component :is="component" + :value="value" @input="$emit('input', $event)" - @loading="$emit('loading', $event)" /> + :loading="loading" + @loading="$emit('loading', $event)" + /> </div> </div> </template> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index f10481b16..41a01014b 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -42,7 +42,7 @@ :value="entity" @input="onEntityInput" :loading="!!loadingEntities[entity.id]" - @loading="loadingEntities[entity.id] = true" + @loading="loadingEntities[entity.id] = $event" /> </div> </div> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue index 7d49dcf2c..87380890c 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue @@ -5,7 +5,8 @@ </div> <div class="col-2 switch pull-right"> - <ToggleSwitch :value="value.state" @input="toggle" /> + <ToggleSwitch :value="value.state" @input="toggle" + :disabled="loading" /> </div> </div> </template> @@ -24,20 +25,26 @@ export default { type: Object, required: true, }, - }, - data() { - return { - component: null, - } + loading: { + type: Boolean, + default: false, + }, }, methods: { - async toggle() { - await this.request('entities.execute', { - id: this.value.id, - action: 'toggle', - }) + 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) + } }, }, } From 548d487e73c7f1054f42f744d225ed7cc6eb4742 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 12 Apr 2022 14:41:21 +0200 Subject: [PATCH 53/96] Publish a switch entity from zigbee.mqtt only if the update includes its state --- platypush/plugins/zigbee/mqtt/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/platypush/plugins/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py index 9b5636aaa..0aa8f2af5 100644 --- a/platypush/plugins/zigbee/mqtt/__init__.py +++ b/platypush/plugins/zigbee/mqtt/__init__.py @@ -182,7 +182,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in } switch_info = self._get_switch_meta(dev) - if switch_info: + if switch_info and dev.get('state', {}).get('state') is not None: converted_entity = Switch( id=dev['ieee_address'], name=dev.get('friendly_name'), @@ -722,9 +722,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in ).output if device_info: - self.publish_entities( + self.publish_entities( # type: ignore [ - { # type: ignore + { **device_info, 'state': device_state, } From 595ebe49cab24830c51576dae6d033b73ea53242 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 12 Apr 2022 15:58:19 +0200 Subject: [PATCH 54/96] Support for entity scan timeout errors and visual error handling --- .../src/components/panels/Entities/Entity.vue | 11 ++++ .../src/components/panels/Entities/Index.vue | 51 +++++++++++++++++-- .../http/webapp/src/style/themes/light.scss | 1 + 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue index 4d0fb780b..dbb015aec 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue @@ -2,6 +2,7 @@ <div class="row item entity"> <div class="status-container"> <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="value.meta?.icon || {}" v-else /> </div> <div class="component-container"> @@ -31,6 +32,11 @@ export default { default: false, }, + error: { + type: Boolean, + default: false, + }, + value: { type: Object, required: true, @@ -80,6 +86,11 @@ export default { width: 1em; height: 1em; } + + .error { + color: $error-fg; + margin-left: .5em; + } } .icon-container, diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index 41a01014b..da294754d 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -41,6 +41,7 @@ <Entity :value="entity" @input="onEntityInput" + :error="!!errorEntities[entity.id]" :loading="!!loadingEntities[entity.id]" @loading="loadingEntities[entity.id] = $event" /> @@ -68,10 +69,20 @@ export default { components: {Loading, Icon, Entity, Selector, NoItems}, mixins: [Utils], + props: { + // Entity scan timeout in seconds + entityScanTimeout: { + type: Number, + default: 30, + }, + }, + data() { return { loading: false, loadingEntities: {}, + errorEntities: {}, + entityTimeouts: {}, entities: {}, selector: { grouping: 'type', @@ -143,6 +154,26 @@ export default { this.loadingEntities = { ...this.loadingEntities, ...Object.keys(this.selector.selectedEntities).reduce((obj, id) => { + const self = this + const entity = this.entities[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 }, {}), @@ -172,9 +203,20 @@ export default { } }, + 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) { - const entityId = entity.id - this.entities[entityId] = entity + this.entities[entity.id] = entity + this.clearEntityTimeouts(entity.id) if (this.loadingEntities[entity.id]) delete this.loadingEntities[entity.id] }, @@ -183,14 +225,13 @@ export default { const entityId = event.entity.id if (entityId == null) return - if (this.loadingEntities[entityId]) - delete this.loadingEntities[entityId] + this.clearEntityTimeouts(entityId) this.entities[entityId] = { ...event.entity, meta: { - ...(this.entities[entityId]?.meta || {}), ...(event.entity?.meta || {}), + ...(this.entities[entityId]?.meta || {}), }, } }, diff --git a/platypush/backend/http/webapp/src/style/themes/light.scss b/platypush/backend/http/webapp/src/style/themes/light.scss index 9b2b269f7..fe8e606ab 100644 --- a/platypush/backend/http/webapp/src/style/themes/light.scss +++ b/platypush/backend/http/webapp/src/style/themes/light.scss @@ -6,6 +6,7 @@ $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; From 08c07793473f6baa4d0abab68fbfa7c13f3fee98 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 12 Apr 2022 16:00:31 +0200 Subject: [PATCH 55/96] <style> on entity components should be scoped --- .../http/webapp/src/components/panels/Entities/Index.vue | 2 +- .../http/webapp/src/components/panels/Entities/Switch.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index da294754d..dee82773a 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -249,7 +249,7 @@ export default { } </script> -<style lang="scss"> +<style lang="scss" scoped> @import "vars"; @import "~@/style/items"; diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue index 87380890c..e63d46b6b 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue @@ -50,6 +50,6 @@ export default { } </script> -<style lang="scss"> +<style lang="scss" scoped> @import "vars"; </style> From b35c761a43699a1e821a4346c8f4d37fe5295ae0 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 12 Apr 2022 22:24:19 +0200 Subject: [PATCH 56/96] Fixed entities panel mobile layout --- .../src/components/elements/ToggleSwitch.vue | 2 +- .../src/components/panels/Entities/Index.vue | 35 +++++++++++++------ .../components/panels/Entities/Selector.vue | 17 ++++++--- .../src/components/panels/Entities/Switch.vue | 8 ++++- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue b/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue index a751861d4..43be6765f 100644 --- a/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue +++ b/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue @@ -59,7 +59,7 @@ export default { display: none; & + label { border-radius: 1em; - display: block; + display: inline-flex; cursor: pointer; position: relative; transition: box-shadow .4s; diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index dee82773a..4b82501ae 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -3,11 +3,11 @@ <Loading v-if="loading" /> <header> - <div class="col-11"> + <div class="col-11 left"> <Selector :entity-groups="entityGroups" :value="selector" @input="selector = $event" /> </div> - <div class="col-1 pull-right"> + <div class="col-1 right"> <button title="Refresh" @click="refresh"> <i class="fa fa-sync-alt" /> </button> @@ -230,8 +230,8 @@ export default { this.entities[entityId] = { ...event.entity, meta: { - ...(event.entity?.meta || {}), ...(this.entities[entityId]?.meta || {}), + ...(event.entity?.meta || {}), }, } }, @@ -266,8 +266,6 @@ export default { width: 100%; height: 100%; - display: flex; - flex-direction: column; overflow: auto; color: $default-fg-2; font-weight: 400; @@ -287,17 +285,28 @@ export default { 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}); - display: flex; - flex-direction: column; + overflow: auto; } .groups-container { - overflow: auto; @include from($desktop) { column-count: var(--groups-per-row); } @@ -310,10 +319,16 @@ export default { padding: $main-margin 0; display: flex; break-inside: avoid; - padding: $main-margin; + + @include from ($tablet) { + padding: $main-margin; + } .frame { - max-height: calc(100% - #{2 * $main-margin}); + @include from($desktop) { + max-height: calc(100vh - #{$header-height} - #{$main-margin}); + } + display: flex; flex-direction: column; flex-grow: 1; diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue index 4a44dd906..5715924f3 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue @@ -1,8 +1,7 @@ <template> <div class="entities-selectors-container"> <div class="selector"> - <Dropdown title="Group by" icon-class="fas fa-layer-group" - :text="prettifyGroupingName(value.grouping)" ref="groupingSelector"> + <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)" /> @@ -180,7 +179,7 @@ export default { display: inline-flex; &.active { - ::v-deep(.dropdown-container) { + :deep(.dropdown-container) { button { color: $default-hover-fg; } @@ -188,7 +187,13 @@ export default { } } - ::v-deep(.dropdown-container) { + @media (max-width: 330px) { + .search-bar { + display: none; + } + } + + :deep(.dropdown-container) { height: 100%; display: flex; @@ -208,6 +213,10 @@ export default { border: 0; box-shadow: none; + .col-1.icon { + width: 1.5em; + } + &.selected { font-weight: bold; background: #ffffff00; diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue index e63d46b6b..3e0869533 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue @@ -1,5 +1,5 @@ <template> - <div class="switch"> + <div class="switch-container"> <div class="col-10 label"> <div class="name" v-text="value.name" /> </div> @@ -52,4 +52,10 @@ export default { <style lang="scss" scoped> @import "vars"; + +.switch-container { + .switch { + direction: rtl; + } +} </style> From 332c91252cd2051927d7f7a4ba46cc0fb7665bb1 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 12 Apr 2022 23:44:14 +0200 Subject: [PATCH 57/96] zwave.mqtt.status renamed to controller_status, while status should return the current state of the values --- .../src/components/panels/Zwave/Zwave.vue | 2 +- platypush/plugins/zwave/mqtt/__init__.py | 29 ++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue b/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue index e0466ba98..516e8687b 100644 --- a/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue +++ b/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue @@ -356,7 +356,7 @@ export default { async refreshStatus() { this.loading.status = true try { - this.status = await this.zrequest('status') + this.status = await this.zrequest('controller_status') } finally { this.loading.status = false } diff --git a/platypush/plugins/zwave/mqtt/__init__.py b/platypush/plugins/zwave/mqtt/__init__.py index 4c1bbab14..642a7dbe1 100644 --- a/platypush/plugins/zwave/mqtt/__init__.py +++ b/platypush/plugins/zwave/mqtt/__init__.py @@ -496,7 +496,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): def _filter_values( self, - command_classes: Iterable[str], + command_classes: Optional[Iterable[str]] = None, filter_callback: Optional[Callable[[dict], bool]] = None, node_id: Optional[int] = None, node_name: Optional[str] = None, @@ -509,16 +509,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): ) command_classes = { - command_class_by_name[command_name] for command_name in command_classes + command_class_by_name[command_name] + for command_name in (command_classes or []) } values = {} for node in nodes: for value in node.get('values', {}).values(): - if value.get('command_class') not in command_classes or ( - filter_callback and not filter_callback(value) - ): + if ( + command_classes + and value.get('command_class') not in command_classes + ) or (filter_callback and not filter_callback(value)): continue value_id = value['id_on_network'] @@ -570,15 +572,24 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): @action def status(self, **kwargs) -> Dict[str, Any]: + """ + Get the current status of the Z-Wave values. + + :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` + (default: query the default configured device). + """ + return self._filter_values(**kwargs) + + @action + def controller_status(self, **kwargs) -> Dict[str, Any]: """ Get the status of the controller. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). - :return: dict with the following fields: ``device`` and ``state``. """ msg_queue = queue.Queue() - topic = f'{self.topic_prefix}/Controller/status' + topic = f'{self.topic_prefix}/driver/status' client = self._get_client(**kwargs) def on_message(_, __, msg): @@ -606,9 +617,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): client.loop_stop() return { - 'device': status['nodeId'], - 'state': status['status'], - 'stats': {}, + 'state': status, } @action From e6bfa1c50f751696f217d1f8be5716cffd7089ec Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Wed, 13 Apr 2022 11:25:14 +0200 Subject: [PATCH 58/96] Better dynamic entities discovery --- .../src/components/panels/Entities/Index.vue | 55 +++++++------------ platypush/entities/_engine.py | 19 +++++++ platypush/entities/devices.py | 2 +- platypush/entities/lights.py | 2 +- platypush/entities/switches.py | 2 +- 5 files changed, 43 insertions(+), 37 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index 4b82501ae..e72ea268b 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -143,44 +143,30 @@ export default { }, async refresh() { - const actions = Object.keys( - Object.values(this.selector.selectedEntities).reduce((obj, entity) => { - if (entity.plugin) - obj[entity.plugin] = true - return obj - }, {}) - ).map((plugin) => `${plugin}.status`) + this.loadingEntities = Object.entries(this.entities).reduce((obj, [id, entity]) => { + const self = this + if (this.entityTimeouts[id]) + clearTimeout(this.entityTimeouts[id]) - this.loadingEntities = { - ...this.loadingEntities, - ...Object.keys(this.selector.selectedEntities).reduce((obj, id) => { - const self = this - const entity = this.entities[id] + this.entityTimeouts[id] = setTimeout(() => { + if (self.loadingEntities[id]) + delete self.loadingEntities[id] + if (self.entityTimeouts[id]) + delete self.entityTimeouts[id] - if (this.entityTimeouts[id]) - clearTimeout(this.entityTimeouts[id]) + self.errorEntities[id] = entity + self.notify({ + error: true, + title: entity.plugin, + text: `Scan timeout for ${entity.name}`, + }) + }, this.entityScanTimeout * 1000) - this.entityTimeouts[id] = setTimeout(() => { - if (self.loadingEntities[id]) - delete self.loadingEntities[id] - if (self.entityTimeouts[id]) - delete self.entityTimeouts[id] + obj[id] = true + return obj + }, {}) - 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 - }, {}), - } - - // Force refresh by calling `.status` on all the selected plugins - await Promise.all(actions.map((act) => this.request(act))) + await this.request('entities.scan') }, async sync() { @@ -231,6 +217,7 @@ export default { ...event.entity, meta: { ...(this.entities[entityId]?.meta || {}), + ...(meta[event.entity.type] || {}), ...(event.entity?.meta || {}), }, } diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py index e7a72726f..5330e6820 100644 --- a/platypush/entities/_engine.py +++ b/platypush/entities/_engine.py @@ -1,3 +1,4 @@ +import json from logging import getLogger from queue import Queue, Empty from threading import Thread, Event, RLock @@ -23,6 +24,7 @@ class EntitiesEngine(Thread): 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': {}, @@ -110,6 +112,16 @@ class EntitiesEngine(Thread): 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: @@ -226,3 +238,10 @@ class EntitiesEngine(Thread): with self._entities_cache_lock: for entity in entities: self._cache_entities(entity, overwrite_cache=True) + + entities_awaiting_flush = {*self._entities_awaiting_flush} + for entity in entities: + e = self._to_entity_awaiting_flush(entity) + if e in entities_awaiting_flush: + self._process_event(entity) + self._entities_awaiting_flush.remove(e) diff --git a/platypush/entities/devices.py b/platypush/entities/devices.py index 24820eef9..619a22529 100644 --- a/platypush/entities/devices.py +++ b/platypush/entities/devices.py @@ -6,7 +6,7 @@ from ._base import Entity class Device(Entity): __tablename__ = 'device' - id = Column(Integer, ForeignKey(Entity.id), primary_key=True) + id = Column(Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True) __mapper_args__ = { 'polymorphic_identity': __tablename__, diff --git a/platypush/entities/lights.py b/platypush/entities/lights.py index 44392aecb..b8fa26f3a 100644 --- a/platypush/entities/lights.py +++ b/platypush/entities/lights.py @@ -6,7 +6,7 @@ from .devices import Device class Light(Device): __tablename__ = 'light' - id = Column(Integer, ForeignKey(Device.id), primary_key=True) + id = Column(Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True) __mapper_args__ = { 'polymorphic_identity': __tablename__, diff --git a/platypush/entities/switches.py b/platypush/entities/switches.py index e7dcfbde4..3c7d6d100 100644 --- a/platypush/entities/switches.py +++ b/platypush/entities/switches.py @@ -6,7 +6,7 @@ from .devices import Device class Switch(Device): __tablename__ = 'switch' - id = Column(Integer, ForeignKey(Device.id), primary_key=True) + id = Column(Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True) state = Column(Boolean) __mapper_args__ = { From 7d4bd20df0d32eff8f6d91c7e742d3020c338cd6 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Tue, 19 Apr 2022 23:56:49 +0200 Subject: [PATCH 59/96] Support for individual entity group refresh --- .../src/components/panels/Entities/Index.vue | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index e72ea268b..aad90dcb1 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -33,7 +33,11 @@ <div class="title" v-text="group.name" v-else-if="selector.grouping === 'plugin'"/> </span> - <span class="section right" /> + <span class="section right"> + <button title="Refresh" @click="refresh(group)"> + <i class="fa fa-sync-alt" /> + </button> + </span> </div> <div class="body"> @@ -142,8 +146,16 @@ export default { }, {}) }, - async refresh() { - this.loadingEntities = Object.entries(this.entities).reduce((obj, [id, entity]) => { + 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.entries(entities).reduce((obj, [id, entity]) => { const self = this if (this.entityTimeouts[id]) clearTimeout(this.entityTimeouts[id]) @@ -166,7 +178,7 @@ export default { return obj }, {}) - await this.request('entities.scan') + await this.request('entities.scan', args) }, async sync() { From ef6b57df3174f6f48e5ae881e94fc34d4bed92c1 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sat, 23 Apr 2022 01:01:14 +0200 Subject: [PATCH 60/96] Added entity info modal and (partial) support for renaming entities --- .../src/components/elements/EditButton.vue | 33 ++++++ .../src/components/elements/NameEditor.vue | 74 ++++++++++++ .../src/components/panels/Entities/Entity.vue | 1 + .../src/components/panels/Entities/Index.vue | 77 +++++++++++-- .../src/components/panels/Entities/Modal.vue | 109 ++++++++++++++++++ .../src/components/panels/Entities/Switch.vue | 2 +- .../backend/http/webapp/src/style/items.scss | 40 +++++++ .../http/webapp/src/style/themes/light.scss | 2 + platypush/entities/_engine.py | 9 +- platypush/plugins/entities/__init__.py | 48 +++++++- 10 files changed, 378 insertions(+), 17 deletions(-) create mode 100644 platypush/backend/http/webapp/src/components/elements/EditButton.vue create mode 100644 platypush/backend/http/webapp/src/components/elements/NameEditor.vue create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue diff --git a/platypush/backend/http/webapp/src/components/elements/EditButton.vue b/platypush/backend/http/webapp/src/components/elements/EditButton.vue new file mode 100644 index 000000000..f24bdde68 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/elements/EditButton.vue @@ -0,0 +1,33 @@ +<template> + <button class="edit-btn" + @click="proxy($event)" @touch="proxy($event)" @input="proxy($event)" + > + <i class="fas fa-pen-to-square" /> + </button> +</template> + +<script> +export default { + emits: ['input', 'click', 'touch'], + methods: { + proxy(e) { + this.$emit(e.type, e) + }, + }, +} +</script> + +<style lang="scss" scoped> +.edit-btn { + border: 0; + background: none; + padding: 0 0.25em; + margin-left: 0.25em; + border: 1px solid rgba(0, 0, 0, 0); + + &:hover { + background: $hover-bg; + border: 1px solid $selected-fg; + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/elements/NameEditor.vue b/platypush/backend/http/webapp/src/components/elements/NameEditor.vue new file mode 100644 index 000000000..8446b0431 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/elements/NameEditor.vue @@ -0,0 +1,74 @@ +<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> + </form> +</template> + +<script> +export default { + emits: ['input', 'cancel'], + props: { + value: { + type: String, + }, + + disabled: { + type: Boolean, + deafult: false, + }, + }, + + data() { + return { + text: null, + } + }, + + methods: { + proxy(e) { + this.$emit(e.type, e) + }, + + submit() { + this.$emit('input', this.text) + return false + }, + }, + + mounted() { + this.text = this.value + }, +} +</script> + +<style lang="scss" scoped> +.name-editor { + background: #00000000; + display: inline-flex; + flex-direction: row; + padding: 0; + border: 0; + border-radius: 0; + box-shadow: none; + + button { + border: none; + background: none; + padding: 0 0.5em; + + &.confirm { + color: $selected-fg; + } + + &.cancel { + color: $error-fg; + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue index dbb015aec..9b0248926 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue @@ -46,6 +46,7 @@ export default { data() { return { component: null, + modalVisible: false, } }, diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index aad90dcb1..d7b21e953 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -8,14 +8,20 @@ </div> <div class="col-1 right"> - <button title="Refresh" @click="refresh"> + <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"> @@ -41,7 +47,8 @@ </div> <div class="body"> - <div class="entity-frame" v-for="entity in group.entities" :key="entity.id"> + <div class="entity-frame" @click="onEntityModal(entity.id)" + v-for="entity in group.entities" :key="entity.id"> <Entity :value="entity" @input="onEntityInput" @@ -65,12 +72,13 @@ 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}, + components: {Loading, Icon, Entity, Selector, NoItems, EntityModal}, mixins: [Utils], props: { @@ -88,6 +96,8 @@ export default { errorEntities: {}, entityTimeouts: {}, entities: {}, + modalEntityId: null, + modalVisible: false, selector: { grouping: 'type', selectedEntities: {}, @@ -147,7 +157,7 @@ export default { }, async refresh(group) { - const entities = group ? group.entities : this.entities + const entities = (group ? group.entities : this.entities) || {} const args = {} if (group) args.plugins = Object.keys(entities.reduce((obj, entity) => { @@ -155,8 +165,9 @@ export default { return obj }, {})) - this.loadingEntities = Object.entries(entities).reduce((obj, [id, entity]) => { + this.loadingEntities = Object.values(entities).reduce((obj, entity) => { const self = this + const id = entity.id if (this.entityTimeouts[id]) clearTimeout(this.entityTimeouts[id]) @@ -186,6 +197,7 @@ export default { 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 || {}), @@ -225,13 +237,30 @@ export default { return this.clearEntityTimeouts(entityId) - this.entities[entityId] = { - ...event.entity, - meta: { - ...(this.entities[entityId]?.meta || {}), - ...(meta[event.entity.type] || {}), - ...(event.entity?.meta || {}), - }, + const entity = {...event.entity} + 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 = { + ...(this.entities[entityId]?.meta || {}), + ...(meta[event.entity.type] || {}), + ...(event.entity?.meta || {}), + } + + this.entities[entityId] = entity + }, + + onEntityModal(entityId) { + if (entityId) { + this.modalEntityId = entityId + this.modalVisible = true + } else { + this.modalEntityId = null + this.modalVisible = false } }, }, @@ -371,5 +400,29 @@ export default { } } } + + :deep(.modal) { + @include until($tablet) { + width: 95%; + } + + .content { + @include until($tablet) { + width: 100%; + } + + @include from($tablet) { + min-width: 30em; + } + + .body { + padding: 0; + + .table-row { + padding: 0.5em; + } + } + } + } } </style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue new file mode 100644 index 000000000..5d041961f --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue @@ -0,0 +1,109 @@ +<template> + <Modal :visible="visible" :title="entity.name || entity.external_id"> + <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</div> + <div class="value icon-container"> + <i class="icon" :class="entity.meta.icon.class" v-if="entity?.meta?.icon?.class" /> + </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 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> + </Modal> +</template> + +<script> +import Modal from "@/components/Modal"; +import EditButton from "@/components/elements/EditButton"; +import NameEditor from "@/components/elements/NameEditor"; +import Utils from "@/Utils"; + +export default { + name: "Entity", + components: {Modal, EditButton, NameEditor}, + 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 + } + } + }, +} +</script> + +<style lang="scss" scoped> +:deep(.modal) { + .icon-container { + display: inline-flex; + align-items: center; + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue index 3e0869533..e5701a90b 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue @@ -6,7 +6,7 @@ <div class="col-2 switch pull-right"> <ToggleSwitch :value="value.state" @input="toggle" - :disabled="loading" /> + @click.stop :disabled="loading" /> </div> </div> </template> diff --git a/platypush/backend/http/webapp/src/style/items.scss b/platypush/backend/http/webapp/src/style/items.scss index db6e7b471..ba88c4ae5 100644 --- a/platypush/backend/http/webapp/src/style/items.scss +++ b/platypush/backend/http/webapp/src/style/items.scss @@ -80,3 +80,43 @@ $header-height: 3.5em; } } } + +:deep(.table-row) { + width: 100%; + display: flex; + flex-direction: column; + box-shadow: $row-shadow; + + &:hover { + background: $hover-bg; + } + + @include from($tablet) { + flex-direction: row; + align-items: center; + } + + .title, + .value { + width: 100%; + display: flex; + + @include from($tablet) { + display: inline-flex; + } + } + + .title { + font-weight: bold; + + @include from($tablet) { + width: 30%; + } + } + + .value { + @include from($tablet) { + justify-content: right; + } + } +} diff --git a/platypush/backend/http/webapp/src/style/themes/light.scss b/platypush/backend/http/webapp/src/style/themes/light.scss index fe8e606ab..c75b45050 100644 --- a/platypush/backend/http/webapp/src/style/themes/light.scss +++ b/platypush/backend/http/webapp/src/style/themes/light.scss @@ -148,3 +148,5 @@ $scrollbar-track-bg: $slider-bg !default; $scrollbar-track-shadow: inset 1px 0px 3px 0 $slider-track-shadow !default; $scrollbar-thumb-bg: #a5a2a2 !default; +//// Rows +$row-shadow: 0 0 1px 0.5px #cfcfcf !default; diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py index 5330e6820..13f69d4cc 100644 --- a/platypush/entities/_engine.py +++ b/platypush/entities/_engine.py @@ -16,7 +16,7 @@ from ._base import Entity class EntitiesEngine(Thread): # Processing queue timeout in seconds - _queue_timeout = 5.0 + _queue_timeout = 2.0 def __init__(self): obj_name = self.__class__.__name__ @@ -205,7 +205,12 @@ class EntitiesEngine(Thread): def merge(entity: Entity, existing_entity: Entity) -> Entity: columns = [col.key for col in entity.columns] for col in columns: - if col not in ('id', 'created_at'): + if col == 'meta': + existing_entity.meta = { # type: ignore + **(existing_entity.meta or {}), + **(entity.meta or {}), + } + elif col not in ('id', 'created_at'): setattr(existing_entity, col, getattr(entity, col)) return existing_entity diff --git a/platypush/plugins/entities/__init__.py b/platypush/plugins/entities/__init__.py index 72b3bdb6e..d2e2652ae 100644 --- a/platypush/plugins/entities/__init__.py +++ b/platypush/plugins/entities/__init__.py @@ -1,10 +1,13 @@ from queue import Queue, Empty from threading import Thread from time import time -from typing import Optional, Any, Collection +from typing import Optional, Any, Collection, Mapping -from platypush.context import get_plugin +from sqlalchemy.orm import make_transient + +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 from platypush.plugins import Plugin, action @@ -165,5 +168,46 @@ class EntitiesPlugin(Plugin): assert entity, f'No such entity ID: {id}' return entity.run(action, *args, **kwargs) + @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: From 135965176dcbac9a875773b0fa915e3401224f3c Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sat, 23 Apr 2022 17:52:21 +0200 Subject: [PATCH 61/96] Support for entity icon color change --- .../webapp/src/components/elements/Icon.vue | 7 +- .../src/components/elements/NameEditor.vue | 1 + .../src/components/panels/Entities/Index.vue | 2 +- .../src/components/panels/Entities/Modal.vue | 106 +++++++++++++++++- 4 files changed, 108 insertions(+), 8 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/elements/Icon.vue b/platypush/backend/http/webapp/src/components/elements/Icon.vue index eb44fd603..e61c7a7d9 100644 --- a/platypush/backend/http/webapp/src/components/elements/Icon.vue +++ b/platypush/backend/http/webapp/src/components/elements/Icon.vue @@ -1,7 +1,8 @@ <template> <div class="icon-container"> <img class="icon" :src="url" :alt="alt" v-if="url?.length"> - <i class="icon" :class="className" v-else-if="className?.length" /> + <i class="icon" :class="className" :style="{color: color}" + v-else-if="className?.length" /> </div> </template> @@ -14,6 +15,10 @@ export default { url: { type: String, }, + color: { + type: String, + default: '', + }, alt: { type: String, default: '', diff --git a/platypush/backend/http/webapp/src/components/elements/NameEditor.vue b/platypush/backend/http/webapp/src/components/elements/NameEditor.vue index 8446b0431..73cf72899 100644 --- a/platypush/backend/http/webapp/src/components/elements/NameEditor.vue +++ b/platypush/backend/http/webapp/src/components/elements/NameEditor.vue @@ -7,6 +7,7 @@ <button class="cancel" @click="$emit('cancel')" @touch="$emit('cancel')"> <i class="fas fa-ban" /> </button> + <slot /> </form> </template> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index d7b21e953..b177a79f5 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -246,8 +246,8 @@ export default { entity.name = event.entity?.name || this.entities[entityId]?.name entity.meta = { - ...(this.entities[entityId]?.meta || {}), ...(meta[event.entity.type] || {}), + ...(this.entities[entityId]?.meta || {}), ...(event.entity?.meta || {}), } diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue index 5d041961f..4890f1654 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue @@ -13,9 +13,39 @@ </div> <div class="table-row"> - <div class="title">Icon</div> - <div class="value icon-container"> - <i class="icon" :class="entity.meta.icon.class" v-if="entity?.meta?.icon?.class" /> + <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> @@ -53,13 +83,15 @@ <script> import Modal from "@/components/Modal"; +import Icon from "@/components/elements/Icon"; 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}, + components: {Modal, EditButton, NameEditor, Icon}, mixins: [Utils], emits: ['input', 'loading'], props: { @@ -94,16 +126,78 @@ export default { this.loading = false this.editName = 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-container { + .icon-canvas { display: inline-flex; align-items: center; + + .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; } } </style> From a9751f21f1f7190cc3778901c4433df8bc70c6ca Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 24 Apr 2022 01:40:34 +0200 Subject: [PATCH 62/96] `entities` should be the default view when the web panel is opened --- platypush/backend/http/webapp/src/components/Nav.vue | 5 ----- platypush/backend/http/webapp/src/views/Panel.vue | 7 ++----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/Nav.vue b/platypush/backend/http/webapp/src/components/Nav.vue index 3a551ed2a..92531e2f7 100644 --- a/platypush/backend/http/webapp/src/components/Nav.vue +++ b/platypush/backend/http/webapp/src/components/Nav.vue @@ -90,11 +90,6 @@ export default { host: null, } }, - - mounted() { - if (this.isMobile() && !this.$root.$route.hash.length) - this.collapsed = false - }, } </script> diff --git a/platypush/backend/http/webapp/src/views/Panel.vue b/platypush/backend/http/webapp/src/views/Panel.vue index 78a6cdbe5..56a72e7ff 100644 --- a/platypush/backend/http/webapp/src/views/Panel.vue +++ b/platypush/backend/http/webapp/src/views/Panel.vue @@ -45,10 +45,7 @@ export default { methods: { initSelectedPanel() { const match = this.$route.hash.match('#?([a-zA-Z0-9.]+)[?]?(.*)') - if (!match) - return - - const plugin = match[1] + const plugin = match ? match[1] : 'entities' if (plugin?.length) this.selectedPanel = plugin }, @@ -113,7 +110,7 @@ main { height: 100%; display: flex; - @media screen and (max-width: $tablet) { + @include until($tablet) { flex-direction: column; } From 8e2154f2b5868a332a07425af3c174b5a5d210a0 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 24 Apr 2022 01:41:45 +0200 Subject: [PATCH 63/96] Do not overwrite an entity's state from an event if the state was not sampled --- .../http/webapp/src/components/panels/Entities/Index.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index b177a79f5..8c6cf1711 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -238,6 +238,8 @@ export default { 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) From b22df768ebfdee091f1ed71ce890ce0a8b926b9d Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 24 Apr 2022 01:42:14 +0200 Subject: [PATCH 64/96] Fixed entity icon alignment on mobile --- .../webapp/src/components/panels/Entities/Modal.vue | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue index 4890f1654..f4f256b08 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue @@ -180,8 +180,16 @@ export default { display: inline-flex; align-items: center; - .icon-container { - justify-content: right; + @include until($tablet) { + .icon-container { + justify-content: left; + } + } + + @include from($tablet) { + .icon-container { + justify-content: right; + } } } From 321a61d06de3a2d9de0ba6fc9e9d6c764c15b6d6 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 24 Apr 2022 11:30:52 +0200 Subject: [PATCH 65/96] Align .section.right content to the right --- .../http/webapp/src/components/panels/Entities/Index.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index 8c6cf1711..041bf22a1 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -384,6 +384,10 @@ export default { width: 10%; } + &.right { + text-align: right; + } + &.center { width: 80%; text-align: center; From 3e4b13d20fc27be375e835dd7ceed5ac7863efa3 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 24 Apr 2022 21:34:39 +0200 Subject: [PATCH 66/96] Added standard Vue component for confirm dialogs --- .../src/components/elements/ConfirmDialog.vue | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 platypush/backend/http/webapp/src/components/elements/ConfirmDialog.vue diff --git a/platypush/backend/http/webapp/src/components/elements/ConfirmDialog.vue b/platypush/backend/http/webapp/src/components/elements/ConfirmDialog.vue new file mode 100644 index 000000000..54368aac7 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/elements/ConfirmDialog.vue @@ -0,0 +1,84 @@ +<template> + <Modal ref="modal" :title="title"> + <div class="dialog-content"> + <slot /> + </div> + + <form class="buttons" @submit.prevent="onConfirm"> + <button type="submit" class="ok-btn" @click="onConfirm" @touch="onConfirm"> + <i class="fas fa-check" /> {{ confirmText }} + </button> + <button type="button" class="cancel-btn" @click="close" @touch="close"> + <i class="fas fa-xmark" /> {{ cancelText }} + </button> + </form> + </Modal> +</template> + +<script> +import Modal from "@/components/Modal"; + +export default { + emits: ['input', 'click', 'touch'], + components: {Modal}, + props: { + title: { + type: String, + }, + + confirmText: { + type: String, + default: "OK", + }, + + cancelText: { + type: String, + default: "Cancel", + }, + }, + + methods: { + onConfirm() { + this.$emit('input') + this.close() + }, + + show() { + this.$refs.modal.show() + }, + + close() { + this.$refs.modal.hide() + }, + }, +} +</script> + +<style lang="scss" scoped> +:deep(.modal) { + .dialog-content { + padding: 1em; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: right; + padding: 1em 0 1em 1em; + border: 0; + border-radius: 0; + box-shadow: 0 -1px 2px 0 $default-shadow-color; + + button { + margin-right: 1em; + padding: 0.5em 1em; + border: 1px solid $border-color-2; + border-radius: 1em; + + &:hover { + background: $hover-bg; + } + } + } +} +</style> From 9981cc47462cee20bb1c39c01b0ec85990acd2a8 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 24 Apr 2022 21:38:45 +0200 Subject: [PATCH 67/96] Backend support for entities deletion --- platypush/message/event/entities.py | 21 +++++++++++++------ platypush/plugins/entities/__init__.py | 28 +++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/platypush/message/event/entities.py b/platypush/message/event/entities.py index 6b56100a7..4c475c588 100644 --- a/platypush/message/event/entities.py +++ b/platypush/message/event/entities.py @@ -1,19 +1,28 @@ +from abc import ABC from typing import Union from platypush.entities import Entity from platypush.message.event import Event -class EntityUpdateEvent(Event): - """ - This even is triggered whenever an entity of any type (a switch, a light, - a sensor, a media player etc.) updates its state. - """ - +class EntityEvent(Event, ABC): def __init__(self, entity: Union[Entity, dict], *args, **kwargs): if isinstance(entity, Entity): entity = entity.to_json() super().__init__(entity=entity, *args, **kwargs) +class EntityUpdateEvent(EntityEvent): + """ + This event is triggered whenever an entity of any type (a switch, a light, + a sensor, a media player etc.) updates its state. + """ + + +class EntityDeleteEvent(EntityEvent): + """ + This event is triggered whenever an entity is deleted. + """ + + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/entities/__init__.py b/platypush/plugins/entities/__init__.py index d2e2652ae..acf7a1205 100644 --- a/platypush/plugins/entities/__init__.py +++ b/platypush/plugins/entities/__init__.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import make_transient 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 +from platypush.message.event.entities import EntityUpdateEvent, EntityDeleteEvent from platypush.plugins import Plugin, action @@ -168,6 +168,32 @@ class EntitiesPlugin(Plugin): 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]): """ From d261b9bb9bd16b7abd75ea012fb91dc38b3d3017 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 24 Apr 2022 21:40:10 +0200 Subject: [PATCH 68/96] Frontend support for entities deletion --- .../src/components/panels/Entities/Index.vue | 16 +++++++++ .../src/components/panels/Entities/Modal.vue | 36 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index 041bf22a1..662b87651 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -256,6 +256,16 @@ export default { 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 @@ -274,6 +284,12 @@ export default { 'platypush.message.event.entities.EntityUpdateEvent' ) + this.subscribe( + this.onEntityDelete, + 'on-entity-delete', + 'platypush.message.event.entities.EntityDeleteEvent' + ) + this.sync() }, } diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue index f4f256b08..1a2b608bb 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue @@ -1,5 +1,12 @@ <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 @@ -78,12 +85,22 @@ <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"; @@ -91,7 +108,7 @@ import meta from './meta.json' export default { name: "Entity", - components: {Modal, EditButton, NameEditor, Icon}, + components: {Modal, EditButton, NameEditor, Icon, ConfirmDialog}, mixins: [Utils], emits: ['input', 'loading'], props: { @@ -128,6 +145,16 @@ export default { } }, + async onDelete() { + this.loading = true + + try { + await this.request('entities.delete', [this.entity.id]) + } finally { + this.loading = false + } + }, + async onIconEdit(newIcon) { this.loading = true @@ -207,5 +234,12 @@ export default { .help { font-size: 0.75em; } + + .delete-entity-container { + color: $error-fg; + button { + color: $error-fg; + } + } } </style> From 47f8520f3ba0482c70310e3aeb433c68afe6c3aa Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 24 Apr 2022 22:18:29 +0200 Subject: [PATCH 69/96] Added support for description/read_only/write_only on entity level --- .../http/webapp/src/components/panels/Entities/Switch.vue | 2 +- platypush/entities/_base.py | 4 ++++ platypush/plugins/switchbot/__init__.py | 1 + platypush/plugins/switchbot/bluetooth/__init__.py | 1 + platypush/plugins/zigbee/mqtt/__init__.py | 2 +- platypush/plugins/zwave/mqtt/__init__.py | 6 +++--- 6 files changed, 11 insertions(+), 5 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue index e5701a90b..2a9d2cd29 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue @@ -6,7 +6,7 @@ <div class="col-2 switch pull-right"> <ToggleSwitch :value="value.state" @input="toggle" - @click.stop :disabled="loading" /> + @click.stop :disabled="loading || value.is_read_only" /> </div> </div> </template> diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py index fe4ec2c6f..0f5195727 100644 --- a/platypush/entities/_base.py +++ b/platypush/entities/_base.py @@ -5,6 +5,7 @@ from typing import Mapping, Type, Tuple, Any import pkgutil from sqlalchemy import ( + Boolean, Column, Index, Integer, @@ -32,10 +33,13 @@ class Entity(Base): 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 ) diff --git a/platypush/plugins/switchbot/__init__.py b/platypush/plugins/switchbot/__init__.py index 3b91c9114..8d7b0e602 100644 --- a/platypush/plugins/switchbot/__init__.py +++ b/platypush/plugins/switchbot/__init__.py @@ -114,6 +114,7 @@ class SwitchbotPlugin(SwitchPlugin): 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), diff --git a/platypush/plugins/switchbot/bluetooth/__init__.py b/platypush/plugins/switchbot/bluetooth/__init__.py index 5ab98ab6f..5da12b3d9 100644 --- a/platypush/plugins/switchbot/bluetooth/__init__.py +++ b/platypush/plugins/switchbot/bluetooth/__init__.py @@ -184,6 +184,7 @@ class SwitchbotBluetoothPlugin( # lgtm [py/missing-call-to-init] id=addr, name=name, state=False, + is_write_only=True, ) for addr, name in devices.items() ] diff --git a/platypush/plugins/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py index 0aa8f2af5..de0e189b4 100644 --- a/platypush/plugins/zigbee/mqtt/__init__.py +++ b/platypush/plugins/zigbee/mqtt/__init__.py @@ -178,7 +178,6 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in "model": dev_def.get("model"), "vendor": dev_def.get("vendor"), "supported": dev.get("supported"), - "description": dev_def.get("description"), } switch_info = self._get_switch_meta(dev) @@ -187,6 +186,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in id=dev['ieee_address'], name=dev.get('friendly_name'), state=dev.get('state', {}).get('state') == 'ON', + description=dev_def.get("description"), data=dev_info, ) diff --git a/platypush/plugins/zwave/mqtt/__init__.py b/platypush/plugins/zwave/mqtt/__init__.py index 642a7dbe1..49eb21751 100644 --- a/platypush/plugins/zwave/mqtt/__init__.py +++ b/platypush/plugins/zwave/mqtt/__init__.py @@ -479,10 +479,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): value_name=value["label"], ), state=value['data'], + description=value.get('help'), + is_read_only=value.get('is_read_only'), + is_write_only=value.get('is_write_only'), data={ - 'help': value.get('help'), - 'is_read_only': value.get('is_read_only'), - 'is_write_only': value.get('is_write_only'), 'label': value.get('label'), 'node_id': value.get('node_id'), }, From f45df5d4d3d3fd8ed40e5d3d218838a7a99f2903 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Fri, 29 Apr 2022 23:24:28 +0200 Subject: [PATCH 70/96] 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. --- platypush/entities/_engine.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py index 13f69d4cc..3ed383ef2 100644 --- a/platypush/entities/_engine.py +++ b/platypush/entities/_engine.py @@ -172,7 +172,12 @@ class EntitiesEngine(Thread): self, session: Session, entities: Iterable[Entity] ) -> Iterable[Entity]: existing_entities = { - (entity.external_id or entity.name, entity.plugin): entity + ( + 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_( @@ -194,7 +199,13 @@ class EntitiesEngine(Thread): return [ existing_entities.get( - (entity.external_id or entity.name, entity.plugin), None + ( + str(entity.external_id) + if entity.external_id is not None + else entity.name, + entity.plugin, + ), + None, ) for entity in entities ] @@ -207,8 +218,8 @@ class EntitiesEngine(Thread): for col in columns: if col == 'meta': existing_entity.meta = { # type: ignore - **(existing_entity.meta or {}), - **(entity.meta or {}), + **(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)) From 90f067de61e6ea6e7861a72e93b844be63225fa0 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Fri, 29 Apr 2022 23:27:35 +0200 Subject: [PATCH 71/96] Added `reachable` flag to device entities --- platypush/entities/devices.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/platypush/entities/devices.py b/platypush/entities/devices.py index 619a22529..8d3d45230 100644 --- a/platypush/entities/devices.py +++ b/platypush/entities/devices.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, ForeignKey +from sqlalchemy import Column, Integer, Boolean, ForeignKey from ._base import Entity @@ -7,6 +7,7 @@ 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__, From 975d37c56258ccf8df5ee0d0ff582263f5cd879b Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Fri, 29 Apr 2022 23:29:04 +0200 Subject: [PATCH 72/96] Added relevant attributes to `light` entities --- platypush/entities/lights.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/platypush/entities/lights.py b/platypush/entities/lights.py index b8fa26f3a..8e98049f7 100644 --- a/platypush/entities/lights.py +++ b/platypush/entities/lights.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, ForeignKey +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Float from .devices import Device @@ -7,6 +7,12 @@ 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) + colormode = Column(String) __mapper_args__ = { 'polymorphic_identity': __tablename__, From 8d57cf06c2ea969ccfe818b12bcf1786e7166064 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sat, 30 Apr 2022 01:07:00 +0200 Subject: [PATCH 73/96] 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. --- platypush/plugins/light/__init__.py | 22 +- platypush/plugins/light/hue/__init__.py | 542 +++++++++++++++--------- 2 files changed, 366 insertions(+), 198 deletions(-) diff --git a/platypush/plugins/light/__init__.py b/platypush/plugins/light/__init__.py index 3943924d7..ea8a050b1 100644 --- a/platypush/plugins/light/__init__.py +++ b/platypush/plugins/light/__init__.py @@ -1,10 +1,12 @@ 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. """ @@ -12,19 +14,27 @@ class LightPlugin(SwitchPlugin, ABC): @action @abstractmethod def on(self): - """ Turn the light on """ + """Turn the light on""" raise NotImplementedError() @action @abstractmethod def off(self): - """ Turn the light off """ + """Turn the light off""" raise NotImplementedError() @action @abstractmethod def toggle(self): - """ Toggle the light status (on/off) """ + """Toggle the light status (on/off)""" + raise NotImplementedError() + + @action + @abstractmethod + def status(self): + """ + Get the current status of the lights. + """ raise NotImplementedError() diff --git a/platypush/plugins/light/hue/__init__.py b/platypush/plugins/light/hue/__init__.py index 06ec97652..e75ec5b88 100644 --- a/platypush/plugins/light/hue/__init__.py +++ b/platypush/plugins/light/hue/__init__.py @@ -4,10 +4,15 @@ 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.entities import Entity +from platypush.entities.lights import Light as LightEntity +from platypush.message.event.light import ( + LightAnimationStartedEvent, + LightAnimationStoppedEvent, +) from platypush.plugins import action from platypush.plugins.light import LightPlugin from platypush.utils import set_thread_name @@ -34,6 +39,7 @@ class LightHuePlugin(LightPlugin): 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' @@ -61,32 +67,43 @@ class LightHuePlugin(LightPlugin): 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() 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 +111,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 +127,7 @@ class LightHuePlugin(LightPlugin): # Lazy init if not self.bridge: from phue import Bridge, PhueRegistrationException + success = False n_tries = 0 @@ -119,12 +137,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 +188,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 +235,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 +293,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 +341,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,12 +367,13 @@ 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 @@ -375,6 +403,7 @@ class LightHuePlugin(LightPlugin): """ self.connect() + assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR self.bridge.set_light(light, **kwargs) @action @@ -382,7 +411,8 @@ class LightHuePlugin(LightPlugin): """ 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:: @@ -400,6 +430,7 @@ class LightHuePlugin(LightPlugin): """ self.connect() + assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR self.bridge.set_group(group, **kwargs) @action @@ -451,15 +482,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,15 +499,17 @@ class LightHuePlugin(LightPlugin): lights = self.lights if lights: - all_lights = self.bridge.get_light().values() + all_lights = self._get_lights().values() lights_on = [ - light['name'] for light in all_lights + light['name'] + for light in all_lights if light['name'] in lights and light['state']['on'] is True ] lights_off = [ - light['name'] for light in all_lights + light['name'] + for light in all_lights if light['name'] in lights and light['state']['on'] is False ] @@ -499,8 +533,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 +555,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 +577,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): @@ -584,25 +633,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 +683,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 +733,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 +801,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 +824,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 +858,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 +894,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 +955,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 +976,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 +1003,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 +1019,87 @@ 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['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('reachable'), + data={ + 'effect': entity.get('state', {}).get('effect'), + 'xy': entity.get('state', {}).get('xy'), + }, + ) + ) - """ + 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() + 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 groups + + def _get_scenes(self) -> dict: + assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR + scenes = self.bridge.get_scene() or {} + return scenes + + @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 # vim:sw=4:ts=4:et: From d29b377cf1be5f7c2d265fbd8f169a433cfb41ca Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sat, 30 Apr 2022 01:39:39 +0200 Subject: [PATCH 74/96] Exclude deleted lights/groups/scenes from the returned lists --- platypush/plugins/light/hue/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/platypush/plugins/light/hue/__init__.py b/platypush/plugins/light/hue/__init__.py index e75ec5b88..476026fa5 100644 --- a/platypush/plugins/light/hue/__init__.py +++ b/platypush/plugins/light/hue/__init__.py @@ -1076,17 +1076,17 @@ class LightHuePlugin(LightPlugin): assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR lights = self.bridge.get_light() self.publish_entities(lights) # type: ignore - return lights + return {id: light for id, light in lights.items() if not light.get('recycle')} def _get_groups(self) -> dict: assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR groups = self.bridge.get_group() or {} - return groups + 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 scenes + return {id: scene for id, scene in scenes.items() if not scene.get('recycle')} @action def status(self) -> Iterable[LightEntity]: From 7df67aca829ab31b895b96c16ce37d1b463c2be5 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sat, 30 Apr 2022 01:48:55 +0200 Subject: [PATCH 75/96] updated_at should have utcnow() onupdate, not now() --- platypush/entities/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py index 0f5195727..f64030c57 100644 --- a/platypush/entities/_base.py +++ b/platypush/entities/_base.py @@ -44,7 +44,7 @@ class Entity(Base): DateTime(timezone=False), default=datetime.utcnow(), nullable=False ) updated_at = Column( - DateTime(timezone=False), default=datetime.utcnow(), onupdate=datetime.now() + DateTime(timezone=False), default=datetime.utcnow(), onupdate=datetime.utcnow() ) UniqueConstraint(external_id, plugin) From c7970842d7b3ecf2e0890921988bf3ddfadd7199 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sat, 30 Apr 2022 02:13:20 +0200 Subject: [PATCH 76/96] Disable logging by default for entity events (they can be quite spammy) --- platypush/message/event/entities.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/platypush/message/event/entities.py b/platypush/message/event/entities.py index 4c475c588..6f82f092a 100644 --- a/platypush/message/event/entities.py +++ b/platypush/message/event/entities.py @@ -6,10 +6,14 @@ from platypush.message.event import Event class EntityEvent(Event, ABC): - def __init__(self, entity: Union[Entity, dict], *args, **kwargs): + 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, **kwargs) + super().__init__( + entity=entity, *args, disable_logging=disable_logging, **kwargs + ) class EntityUpdateEvent(EntityEvent): From b16af0a97f8f9d76c89037872ff829680780aeef Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sat, 30 Apr 2022 16:39:37 +0200 Subject: [PATCH 77/96] Include entity `data` attributes in the entity info modal --- .../http/webapp/src/components/panels/Entities/Modal.vue | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue index 1a2b608bb..18a847fc0 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue @@ -76,6 +76,13 @@ <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="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)" /> From 30a024befbdc8e377e3578aab3d76c9330b03db4 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sat, 30 Apr 2022 19:38:50 +0200 Subject: [PATCH 78/96] Manage hue/sat/bri/ct light ranges on the light entity object itself --- platypush/entities/lights.py | 11 ++++ platypush/plugins/light/hue/__init__.py | 75 +++++++++++++++++++++---- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/platypush/entities/lights.py b/platypush/entities/lights.py index 8e98049f7..ae65eae6e 100644 --- a/platypush/entities/lights.py +++ b/platypush/entities/lights.py @@ -12,7 +12,18 @@ class Light(Device): saturation = Column(Float) hue = Column(Float) temperature = Column(Float) + x = Column(Float) + y = Column(Float) colormode = Column(String) + effect = Column(String) + hue_min = Column(Float) + hue_max = Column(Float) + saturation_min = Column(Float) + saturation_max = Column(Float) + brightness_min = Column(Float) + brightness_max = Column(Float) + temperature_min = Column(Float) + temperature_max = Column(Float) __mapper_args__ = { 'polymorphic_identity': __tablename__, diff --git a/platypush/plugins/light/hue/__init__.py b/platypush/plugins/light/hue/__init__.py index 476026fa5..a62a02f40 100644 --- a/platypush/plugins/light/hue/__init__.py +++ b/platypush/plugins/light/hue/__init__.py @@ -36,6 +36,8 @@ class LightHuePlugin(LightPlugin): 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 @@ -379,6 +381,8 @@ class LightHuePlugin(LightPlugin): self.bridge = None raise e + return self._get_lights() + @action def set_light(self, light, **kwargs): """ @@ -499,18 +503,20 @@ class LightHuePlugin(LightPlugin): lights = self.lights if lights: - all_lights = self._get_lights().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 + 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 + 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: @@ -606,7 +612,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. @@ -1055,7 +1061,7 @@ class LightHuePlugin(LightPlugin): LightEntity( id=entity['id'], name=entity['name'], - description=entity['type'], + description=entity.get('type'), on=entity.get('state', {}).get('on', False), brightness=entity.get('state', {}).get('bri'), saturation=entity.get('state', {}).get('sat'), @@ -1063,10 +1069,57 @@ class LightHuePlugin(LightPlugin): temperature=entity.get('state', {}).get('ct'), colormode=entity.get('colormode'), reachable=entity.get('reachable'), - data={ - 'effect': entity.get('state', {}).get('effect'), - 'xy': entity.get('state', {}).get('xy'), - }, + 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, + } + ), ) ) From 8e06b8c7277ae099f88f4af1dc89a9c49ba88718 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sat, 30 Apr 2022 23:40:14 +0200 Subject: [PATCH 79/96] Fixed range scaling on Slider component --- .../backend/http/webapp/src/components/elements/Slider.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platypush/backend/http/webapp/src/components/elements/Slider.vue b/platypush/backend/http/webapp/src/components/elements/Slider.vue index 8cf7393e9..9969db470 100644 --- a/platypush/backend/http/webapp/src/components/elements/Slider.vue +++ b/platypush/backend/http/webapp/src/components/elements/Slider.vue @@ -62,7 +62,7 @@ export default { }, update(value) { - const percent = (value * 100) / (this.range[1] - this.range[0]) + const percent = ((value - this.range[0]) * 100) / (this.range[1] - this.range[0]) this.$refs.thumb.style.left = `${percent}%` this.$refs.thumb.style.transform = `translate(-${percent}%, -50%)` this.$refs.track.style.width = `${percent}%` From 46df3a6a98f5996b109203b5372bb5b7d446b1a4 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 01:58:05 +0200 Subject: [PATCH 80/96] FIX: `reachable` is an attribute of `state` --- platypush/plugins/light/hue/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platypush/plugins/light/hue/__init__.py b/platypush/plugins/light/hue/__init__.py index a62a02f40..02f9a58ae 100644 --- a/platypush/plugins/light/hue/__init__.py +++ b/platypush/plugins/light/hue/__init__.py @@ -1068,7 +1068,7 @@ class LightHuePlugin(LightPlugin): hue=entity.get('state', {}).get('hue'), temperature=entity.get('state', {}).get('ct'), colormode=entity.get('colormode'), - reachable=entity.get('reachable'), + reachable=entity.get('state', {}).get('reachable'), x=entity['state']['xy'][0] if entity.get('state', {}).get('xy') else None, From c22c17a55d90c1e6c751b2d315ddd590991f70ee Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 15:31:45 +0200 Subject: [PATCH 81/96] More flexible implementation for LightPlugin abstract methods --- platypush/plugins/light/__init__.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/platypush/plugins/light/__init__.py b/platypush/plugins/light/__init__.py index ea8a050b1..b683c884a 100644 --- a/platypush/plugins/light/__init__.py +++ b/platypush/plugins/light/__init__.py @@ -13,25 +13,37 @@ class LightPlugin(Plugin, ABC): @action @abstractmethod - def on(self): + def on(self, lights=None, *args, **kwargs): """Turn the light on""" raise NotImplementedError() @action @abstractmethod - def off(self): + def off(self, lights=None, *args, **kwargs): """Turn the light off""" raise NotImplementedError() @action @abstractmethod - def toggle(self): + def toggle(self, lights=None, *args, **kwargs): """Toggle the light status (on/off)""" raise NotImplementedError() @action @abstractmethod - def status(self): + 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. """ From 8d91fec771b2fa01e49d5a9301f7522022b624e4 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 15:33:12 +0200 Subject: [PATCH 82/96] Better implementation for light.hue.set_lights --- platypush/plugins/light/hue/__init__.py | 41 +++++++++++++++++++------ 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/platypush/plugins/light/hue/__init__.py b/platypush/plugins/light/hue/__init__.py index 02f9a58ae..f62b3200d 100644 --- a/platypush/plugins/light/hue/__init__.py +++ b/platypush/plugins/light/hue/__init__.py @@ -76,6 +76,7 @@ class LightHuePlugin(LightPlugin): self.connect() self.lights = set() self.groups = set() + self._cached_lights = {} if lights: self.lights = set(lights) @@ -384,22 +385,21 @@ class LightHuePlugin(LightPlugin): return self._get_lights() @action - def set_light(self, light, **kwargs): + def set_lights(self, lights, **kwargs): """ - Set a light (or lights) property. + Set a set of properties on a set of 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. + :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 } } @@ -408,7 +408,27 @@ class LightHuePlugin(LightPlugin): self.connect() assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR - self.bridge.set_light(light, **kwargs) + all_lights = self._get_lights() + + for i, l in enumerate(lights): + if str(l) in all_lights: + lights[i] = all_lights[str(l)]['name'] + + # Convert entity attributes to local attributes + if kwargs.get('saturation') is not None: + kwargs['sat'] = kwargs.pop('saturation') + if kwargs.get('brightness') is not None: + kwargs['bri'] = kwargs.pop('brightness') + if kwargs.get('temperature') is not None: + kwargs['ct'] = kwargs.pop('temperature') + + # "Unroll" the map + args = [] + for arg, value in kwargs.items(): + args += [arg, value] + + self.bridge.set_light(lights, *args) + return self._get_lights() @action def set_group(self, group, **kwargs): @@ -423,7 +443,6 @@ class LightHuePlugin(LightPlugin): { "type": "request", - "target": "hostname", "action": "light.hue.set_group", "args": { "light": "Living Room", @@ -1128,8 +1147,10 @@ class LightHuePlugin(LightPlugin): 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 {id: light for id, light in lights.items() if not light.get('recycle')} + return lights def _get_groups(self) -> dict: assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR From f760d442240884e3318bc9787f6245954eced0c5 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 15:34:15 +0200 Subject: [PATCH 83/96] Refactored/simplified UI code for entities management --- .../src/components/panels/Entities/Entity.vue | 91 +++---------------- .../components/panels/Entities/EntityIcon.vue | 71 +++++++++++++++ .../panels/Entities/EntityMixin.vue | 38 ++++++++ .../src/components/panels/Entities/Modal.vue | 2 +- .../src/components/panels/Entities/Switch.vue | 42 ++++----- .../components/panels/Entities/common.scss | 52 +++++++++++ 6 files changed, 195 insertions(+), 101 deletions(-) create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/EntityMixin.vue create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/common.scss diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue index 9b0248926..1e6eea805 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue @@ -1,62 +1,30 @@ <template> - <div class="row item entity"> - <div class="status-container"> - <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="value.meta?.icon || {}" v-else /> - </div> - <div class="component-container"> - <component :is="component" - :value="value" - @input="$emit('input', $event)" - :loading="loading" - @loading="$emit('loading', $event)" - /> - </div> + <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 Utils from "@/Utils" -import Icon from "@/components/elements/Icon"; +import EntityMixin from "./EntityMixin" export default { name: "Entity", - components: {Icon}, - mixins: [Utils], + mixins: [EntityMixin], emits: ['input', 'loading'], - props: { - loading: { - type: Boolean, - default: false, - }, - - error: { - type: Boolean, - default: false, - }, - - value: { - type: Object, - required: true, - }, - }, data() { return { component: null, - modalVisible: false, } }, - computed: { - type() { - let entityType = (this.value.type || '') - return entityType.charAt(0).toUpperCase() + entityType.slice(1) - }, - }, - mounted() { if (this.type !== 'Entity') this.component = defineAsyncComponent( @@ -67,42 +35,11 @@ export default { </script> <style lang="scss" scoped> -@import "vars"; +@import "common"; -.entity { +.entity-container { width: 100%; - display: table; - - .status-container { - width: 2.5em; - height: 1.5em; - display: table-cell; - vertical-align: middle; - position: relative; - - .loading { - position: absolute; - bottom: 0; - transform: translate(50%, -50%); - width: 1em; - height: 1em; - } - - .error { - color: $error-fg; - margin-left: .5em; - } - } - - .icon-container, - .component-container { - height: 100%; - display: table-cell; - vertical-align: middle; - } - - .component-container { - width: calc(100% - #{$icon-container-size}); - } + position: relative; + padding: 0 !important; } </style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue b/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue new file mode 100644 index 000000000..ceb461e91 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue @@ -0,0 +1,71 @@ +<template> + <div class="entity-icon-container"> + <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="icon" 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, + }, + }, + + data() { + return { + component: null, + modalVisible: false, + } + }, + + computed: { + 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: 2.5em; + height: 1.5em; + margin-top: 0.25em; + position: relative; + text-align: center; + + .loading { + position: absolute; + bottom: 0; + transform: translate(50%, -50%); + width: 1em; + height: 1em; + } + + .error { + color: $error-fg; + margin-left: .5em; + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/EntityMixin.vue b/platypush/backend/http/webapp/src/components/panels/Entities/EntityMixin.vue new file mode 100644 index 000000000..74198d298 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/EntityMixin.vue @@ -0,0 +1,38 @@ +<script> +import Utils from "@/Utils" + +export default { + name: "EntityMixin", + mixins: [Utils], + emits: ['input'], + props: { + loading: { + type: Boolean, + default: false, + }, + + error: { + type: Boolean, + default: false, + }, + + value: { + type: Object, + required: true, + }, + }, + + data() { + return { + modalVisible: false, + } + }, + + computed: { + type() { + let entityType = (this.value.type || '') + return entityType.charAt(0).toUpperCase() + entityType.slice(1) + }, + }, +} +</script> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue index 18a847fc0..e130adc7b 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue @@ -78,7 +78,7 @@ <div v-for="value, attr in entity.data || {}" :key="attr"> <div class="table-row" v-if="value != null"> - <div class="title" v-text="attr" /> + <div class="title" v-text="prettify(attr)" /> <div class="value" v-text="'' + value" /> </div> </div> diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue index 2a9d2cd29..899436572 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue @@ -1,36 +1,32 @@ <template> - <div class="switch-container"> - <div class="col-10 label"> - <div class="name" v-text="value.name" /> - </div> + <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-2 switch pull-right"> - <ToggleSwitch :value="value.state" @input="toggle" - @click.stop :disabled="loading || value.is_read_only" /> + <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 Utils from "@/Utils" +import EntityIcon from "./EntityIcon" +import EntityMixin from "./EntityMixin" export default { name: 'Switch', - components: {ToggleSwitch}, - emits: ['input'], - mixins: [Utils], - props: { - value: { - type: Object, - required: true, - }, - - loading: { - type: Boolean, - default: false, - }, - }, + components: {ToggleSwitch, EntityIcon}, + mixins: [EntityMixin], methods: { async toggle(event) { @@ -51,7 +47,7 @@ export default { </script> <style lang="scss" scoped> -@import "vars"; +@import "common"; .switch-container { .switch { diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/common.scss b/platypush/backend/http/webapp/src/components/panels/Entities/common.scss new file mode 100644 index 000000000..4729060e2 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/common.scss @@ -0,0 +1,52 @@ +@import "vars"; + +.entity { + width: 100%; + display: flex; + flex-direction: column; + + .head { + 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; + } + } +} From 5aa37508074a051872e1b54ce9205909f7e57874 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 15:34:45 +0200 Subject: [PATCH 84/96] Re-sync the list of entities when the entities component is mounted --- .../http/webapp/src/components/panels/Entities/Index.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue index 662b87651..9f1d03b56 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue @@ -277,7 +277,7 @@ export default { }, }, - mounted() { + async mounted() { this.subscribe( this.onEntityUpdate, 'on-entity-update', @@ -290,7 +290,8 @@ export default { 'platypush.message.event.entities.EntityDeleteEvent' ) - this.sync() + await this.sync() + await this.refresh() }, } </script> From dd12d57552d6a526f197682089521dfc6cb93c5d Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 15:35:20 +0200 Subject: [PATCH 85/96] Added light UI entity component --- .../src/components/panels/Entities/Light.vue | 212 ++++++++++++++++++ .../src/components/panels/Light/color.js | 17 ++ 2 files changed, 229 insertions(+) create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/Light.vue diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue new file mode 100644 index 000000000..9c7dc7b41 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue @@ -0,0 +1,212 @@ +<template> + <div class="entity light-container"> + <div class="head" :class="{expanded: expanded}"> + <div class="col-1 icon"> + <EntityIcon :icon="icon" :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"> + <button @click.stop="expanded = !expanded"> + <i class="fas" + :class="{'fa-angle-up': expanded, 'fa-angle-down': !expanded}" /> + </button> + + <ToggleSwitch :value="value.on" @input="toggle" + @click.stop :disabled="loading || value.is_read_only" /> + </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.colorConverter || this.value.hue == null || + (this.value.x == null && this.value.y == null) + ) + return + if (this.value.meta?.icon?.color) + return this.value.meta.icon.color + + 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, + } + }) + + this.$emit('input', 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 { + .body { + .row { + display: flex; + + .icon { + width: 2em; + text-align: center; + } + + .input { + width: calc(100% - 2em); + + [type=color] { + width: 100%; + } + + :deep(.slider) { + margin-top: 0.5em; + } + } + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Light/color.js b/platypush/backend/http/webapp/src/components/panels/Light/color.js index f74464346..775df1b94 100644 --- a/platypush/backend/http/webapp/src/components/panels/Light/color.js +++ b/platypush/backend/http/webapp/src/components/panels/Light/color.js @@ -211,4 +211,21 @@ export class ColorConverter { console.debug('Could not determine color space') console.debug(color) } + + hexToRgb(hex) { + return [ + hex.slice(1, 3), + hex.slice(3, 5), + hex.slice(5, 7), + ].map(_ => parseInt(_, 16)) + } + + rgbToHex(rgb) { + return '#' + rgb.map((x) => { + let hex = x.toString(16) + if (hex.length < 2) + hex = '0' + hex + return hex + }).join('') + } } From e8f4b7c10e8adae43c8c05c9da7c4337ace50f69 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 15:44:57 +0200 Subject: [PATCH 86/96] CSS adjustments --- .../http/webapp/src/components/panels/Entities/EntityIcon.vue | 2 +- .../http/webapp/src/components/panels/Entities/common.scss | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue b/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue index ceb461e91..2186b0c40 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue @@ -58,7 +58,7 @@ export default { .loading { position: absolute; bottom: 0; - transform: translate(50%, -50%); + transform: translate(0%, -50%); width: 1em; height: 1em; } diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/common.scss b/platypush/backend/http/webapp/src/components/panels/Entities/common.scss index 4729060e2..289b1ec85 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/common.scss +++ b/platypush/backend/http/webapp/src/components/panels/Entities/common.scss @@ -6,6 +6,9 @@ flex-direction: column; .head { + height: 100%; + display: flex; + align-items: center; padding: 0.75em 0.25em; .label { From 088cf23958a75fba94cd8bea881d16694b4585b4 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 21:08:02 +0200 Subject: [PATCH 87/96] 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 --- .../http/webapp/src/components/panels/Entities/Light.vue | 2 -- 1 file changed, 2 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue index 9c7dc7b41..c6e1ea394 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue @@ -161,8 +161,6 @@ export default { ...attrs, } }) - - this.$emit('input', attrs) }, }, From b23f45f45ef868726c96948df4cd8b528f2d1316 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 21:09:13 +0200 Subject: [PATCH 88/96] Process a zigbee entity update event with all the properties, not only the ones that have been changed --- platypush/backend/zigbee/mqtt/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platypush/backend/zigbee/mqtt/__init__.py b/platypush/backend/zigbee/mqtt/__init__.py index b7af6b1ea..7fca6a676 100644 --- a/platypush/backend/zigbee/mqtt/__init__.py +++ b/platypush/backend/zigbee/mqtt/__init__.py @@ -308,7 +308,7 @@ class ZigbeeMqttBackend(MqttBackend): } if changed_props: - self._process_property_update(name, changed_props) + self._process_property_update(name, data) self.bus.post( ZigbeeMqttDevicePropertySetEvent( host=client._host, From a5541c33b0433c5021953e0d51da51fab510ea0c Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 21:10:54 +0200 Subject: [PATCH 89/96] 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) --- platypush/plugins/zigbee/mqtt/__init__.py | 127 ++++++++++++++++++++-- 1 file changed, 119 insertions(+), 8 deletions(-) diff --git a/platypush/plugins/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py index de0e189b4..b7a12cffc 100644 --- a/platypush/plugins/zigbee/mqtt/__init__.py +++ b/platypush/plugins/zigbee/mqtt/__init__.py @@ -3,14 +3,17 @@ import threading from queue import Queue from typing import Optional, List, Any, Dict, Union -from platypush.message import Mapping +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/>`_. @@ -180,12 +183,28 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in "supported": dev.get("supported"), } + light_info = self._get_light_meta(dev) switch_info = self._get_switch_meta(dev) - if switch_info and dev.get('state', {}).get('state') is not None: + + 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') == 'ON', + state=dev.get('state', {}).get('state') == switch_info['value_on'], description=dev_def.get("description"), data=dev_info, ) @@ -707,7 +726,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in # Refresh devices info self._get_network_info(**kwargs) - assert self._info.get('devices', {}).get(device), f'No such device: {device}' + 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', []) @@ -1330,10 +1353,10 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in for exposed in exposes: for feature in exposed.get('features', []): if ( - feature.get('type') == 'binary' + feature.get('property') == 'state' + and feature.get('type') == 'binary' and 'value_on' in feature and 'value_off' in feature - and feature.get('access', 0) & 2 ): return { 'friendly_name': device_info.get('friendly_name'), @@ -1342,10 +1365,74 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in 'value_on': feature['value_on'], 'value_off': feature['value_off'], 'value_toggle': feature.get('value_toggle', None), + 'is_read_only': not bool(feature.get('access', 0) & 2), + 'is_write_only': not bool(feature.get('access', 0) & 1), } return {} + @staticmethod + def _get_light_meta(device_info: dict) -> dict: + exposes = (device_info.get('definition', {}) or {}).get('exposes', []) + for exposed in exposes: + if exposed.get('type') == 'light': + features = exposed.get('features', []) + switch = {} + brightness = {} + temperature = {} + + for feature in features: + if ( + feature.get('property') == 'state' + and feature.get('type') == 'binary' + and 'value_on' in feature + and 'value_off' in feature + ): + switch = { + 'value_on': feature['value_on'], + 'value_off': feature['value_off'], + 'state_name': feature['name'], + 'value_toggle': feature.get('value_toggle', None), + 'is_read_only': not bool(feature.get('access', 0) & 2), + 'is_write_only': not bool(feature.get('access', 0) & 1), + } + elif ( + feature.get('property') == 'brightness' + and feature.get('type') == 'numeric' + and 'value_min' in feature + and 'value_max' in feature + ): + brightness = { + 'brightness_name': feature['name'], + 'brightness_min': feature['value_min'], + 'brightness_max': feature['value_max'], + 'is_read_only': not bool(feature.get('access', 0) & 2), + 'is_write_only': not bool(feature.get('access', 0) & 1), + } + elif ( + feature.get('property') == 'color_temp' + and feature.get('type') == 'numeric' + and 'value_min' in feature + and 'value_max' in feature + ): + temperature = { + 'temperature_name': feature['name'], + 'temperature_min': feature['value_min'], + 'temperature_max': feature['value_max'], + 'is_read_only': not bool(feature.get('access', 0) & 2), + 'is_write_only': not bool(feature.get('access', 0) & 1), + } + + return { + 'friendly_name': device_info.get('friendly_name'), + 'ieee_address': device_info.get('friendly_name'), + **switch, + **brightness, + **temperature, + } + + return {} + def _get_switches_info(self) -> dict: # noinspection PyUnresolvedReferences devices = self.devices().output @@ -1380,5 +1467,29 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin): # lgtm [py/missing-call-to-in ).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: From 117f92e5b451aeb081d3c62a03646cd02b978b65 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 21:55:35 +0200 Subject: [PATCH 90/96] 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. --- platypush/backend/light/hue/__init__.py | 92 ++--------------------- platypush/backend/light/hue/manifest.yaml | 2 - platypush/plugins/light/hue/__init__.py | 41 +++++++++- platypush/plugins/light/hue/manifest.yaml | 2 + 4 files changed, 48 insertions(+), 89 deletions(-) diff --git a/platypush/backend/light/hue/__init__.py b/platypush/backend/light/hue/__init__.py index 95cbed6ff..686e1a51b 100644 --- a/platypush/backend/light/hue/__init__.py +++ b/platypush/backend/light/hue/__init__.py @@ -1,100 +1,24 @@ -from threading import Thread +import warnings from platypush.backend import Backend -from platypush.context import get_plugin -from platypush.message.event.light import LightStatusChangeEvent class LightHueBackend(Backend): """ - This backend will periodically check for the status of your configured - Philips Hue light devices and trigger events when the status of a device - (power, saturation, brightness or hue) changes. + **DEPRECATED** - Triggers: - - * :class:`platypush.message.event.light.LightStatusChangeEvent` when the - status of a lightbulb changes - - Requires: - - * The :class:`platypush.plugins.light.hue.LightHuePlugin` plugin to be - active and configured. + The polling logic of this backend has been moved to the ``light.hue`` plugin itself. """ - _DEFAULT_POLL_SECONDS = 10 - - def __init__(self, poll_seconds=_DEFAULT_POLL_SECONDS, *args, **kwargs): - """ - :param poll_seconds: How often the backend will poll the Hue plugin for - status updates. Default: 10 seconds - :type poll_seconds: float - """ - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.poll_seconds = poll_seconds - - @staticmethod - def _get_lights(): - plugin = get_plugin('light.hue') - if not plugin: - plugin = get_plugin('light.hue', reload=True) - - return plugin.get_lights().output - - def _listener(self): - def _thread(): - lights = self._get_lights() - - while not self.should_stop(): - try: - lights_new = self._get_lights() - - for light_id, light in lights_new.items(): - event_args = {} - state = light.get('state') - prev_state = lights.get(light_id, {}).get('state', {}) - - if 'on' in state and state.get('on') != prev_state.get('on'): - event_args['on'] = state.get('on') - if 'bri' in state and state.get('bri') != prev_state.get('bri'): - event_args['bri'] = state.get('bri') - if 'sat' in state and state.get('sat') != prev_state.get('sat'): - event_args['sat'] = state.get('sat') - if 'hue' in state and state.get('hue') != prev_state.get('hue'): - event_args['hue'] = state.get('hue') - if 'ct' in state and state.get('ct') != prev_state.get('ct'): - event_args['ct'] = state.get('ct') - if 'xy' in state and state.get('xy') != prev_state.get('xy'): - event_args['xy'] = state.get('xy') - - if event_args: - event_args['plugin_name'] = 'light.hue' - event_args['light_id'] = light_id - event_args['light_name'] = light.get('name') - self.bus.post(LightStatusChangeEvent(**event_args)) - - lights = lights_new - except Exception as e: - self.logger.exception(e) - finally: - self.wait_stop(self.poll_seconds) - - return _thread + warnings.warn( + 'The light.hue backend is deprecated. All of its logic ' + 'has been moved to the light.hue plugin itself.' + ) def run(self): super().run() - self.logger.info('Starting Hue lights backend') - - while not self.should_stop(): - try: - poll_thread = Thread(target=self._listener()) - poll_thread.start() - poll_thread.join() - except Exception as e: - self.logger.exception(e) - self.wait_stop(self.poll_seconds) - self.logger.info('Stopped Hue lights backend') diff --git a/platypush/backend/light/hue/manifest.yaml b/platypush/backend/light/hue/manifest.yaml index 5d5e4ed03..8103a7866 100644 --- a/platypush/backend/light/hue/manifest.yaml +++ b/platypush/backend/light/hue/manifest.yaml @@ -1,7 +1,5 @@ manifest: events: - platypush.message.event.light.LightStatusChangeEvent: when thestatus of a lightbulb - changes install: pip: [] package: platypush.backend.light.hue diff --git a/platypush/plugins/light/hue/__init__.py b/platypush/plugins/light/hue/__init__.py index f62b3200d..9a79c7e3a 100644 --- a/platypush/plugins/light/hue/__init__.py +++ b/platypush/plugins/light/hue/__init__.py @@ -12,13 +12,14 @@ from platypush.entities.lights import Light as LightEntity from platypush.message.event.light import ( LightAnimationStartedEvent, LightAnimationStoppedEvent, + LightStatusChangeEvent, ) -from platypush.plugins import action +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. @@ -30,6 +31,8 @@ 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. """ @@ -53,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 @@ -63,6 +66,9 @@ 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__() @@ -76,6 +82,7 @@ class LightHuePlugin(LightPlugin): self.connect() self.lights = set() self.groups = set() + self.poll_seconds = poll_seconds self._cached_lights = {} if lights: @@ -1175,5 +1182,33 @@ class LightHuePlugin(LightPlugin): return lights + def main(self): + lights_prev = self._get_lights() # Initialize the lights + + while not self.should_stop(): + try: + lights_new = self._get_lights() + for light_id, light in lights_new.items(): + event_args = {} + new_state = light.get('state', {}) + prev_state = lights_prev.get(light_id, {}).get('state', {}) + + for attr in ['on', 'bri', 'sat', 'hue', 'ct', 'xy']: + if attr in new_state and new_state.get(attr) != prev_state.get( + attr + ): + event_args[attr] = new_state.get(attr) + + if event_args: + event_args['plugin_name'] = 'light.hue' + event_args['light_id'] = light_id + event_args['light_name'] = light.get('name') + get_bus().post(LightStatusChangeEvent(**event_args)) + self.publish_entities([{'id': light_id, **light}]) # type: ignore + + lights_prev = lights_new + finally: + self.wait_stop(self.poll_seconds) + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/light/hue/manifest.yaml b/platypush/plugins/light/hue/manifest.yaml index cd18d5195..1a8581aa2 100644 --- a/platypush/plugins/light/hue/manifest.yaml +++ b/platypush/plugins/light/hue/manifest.yaml @@ -4,6 +4,8 @@ manifest: started. platypush.message.event.light.LightAnimationStoppedEvent: when an animation is stopped. + platypush.message.event.light.LightStatusChangeEvent: when the status of a + lightbulb changes. install: pip: - phue From f57f940d574843ed2cfad14da593c9530a96df97 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 1 May 2022 22:18:46 +0200 Subject: [PATCH 91/96] Made _is_switch more resilient against rogue Z-Wave values --- platypush/plugins/zwave/mqtt/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/platypush/plugins/zwave/mqtt/__init__.py b/platypush/plugins/zwave/mqtt/__init__.py index 49eb21751..c07449c6b 100644 --- a/platypush/plugins/zwave/mqtt/__init__.py +++ b/platypush/plugins/zwave/mqtt/__init__.py @@ -462,7 +462,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin): @staticmethod def _is_switch(value: Mapping): - return value.get('command_class_name', '').endswith('Switch') + return ( + value.get('command_class_name', '').endswith('Switch') if value else False + ) def transform_entities(self, values: Iterable[Mapping]): entities = [] From 89560e7c38eebde78e1b7b5c3ff662a8b0613569 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Sun, 29 May 2022 23:59:46 +0200 Subject: [PATCH 92/96] Only include entities associated to enabled plugins or with no plugins in `entities.get` --- platypush/plugins/entities/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/platypush/plugins/entities/__init__.py b/platypush/plugins/entities/__init__.py index acf7a1205..e600a2d49 100644 --- a/platypush/plugins/entities/__init__.py +++ b/platypush/plugins/entities/__init__.py @@ -3,8 +3,10 @@ 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 @@ -57,8 +59,18 @@ class EntitiesPlugin(Plugin): 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) + 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: From 0689e05e9682625c8dedcbd91542e96debe4c05a Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 30 May 2022 09:18:19 +0200 Subject: [PATCH 93/96] Apply the light color to the icon fill instead of the bulb icon itself --- .../components/panels/Entities/EntityIcon.vue | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue b/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue index 2186b0c40..e977be11a 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue @@ -1,8 +1,10 @@ <template> - <div class="entity-icon-container"> + <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="icon" v-else /> + <Icon v-bind="computedIcon" v-else /> </div> </template> @@ -27,6 +29,11 @@ export default { type: Object, required: true, }, + + hasColorFill: { + type: Boolean, + default: false, + }, }, data() { @@ -37,6 +44,21 @@ export default { }, 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) @@ -49,11 +71,19 @@ export default { @import "vars"; .entity-icon-container { - width: 2.5em; + 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; From 1df71cb54a6708d0db51a7e6fa58b469c267d67c Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 30 May 2022 09:23:05 +0200 Subject: [PATCH 94/96] Proper support for light entities on smartthings --- .../src/components/panels/Entities/Light.vue | 30 +++- platypush/entities/_base.py | 2 +- platypush/entities/_engine.py | 8 +- platypush/plugins/smartthings/__init__.py | 162 +++++++++++++++--- 4 files changed, 166 insertions(+), 36 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue index c6e1ea394..ae85e59bc 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue @@ -2,7 +2,8 @@ <div class="entity light-container"> <div class="head" :class="{expanded: expanded}"> <div class="col-1 icon"> - <EntityIcon :icon="icon" :loading="loading" :error="error" /> + <EntityIcon :icon="icon" :hasColorFill="true" + :loading="loading" :error="error" /> </div> <div class="col-s-8 col-m-9 label"> @@ -10,13 +11,13 @@ </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> - - <ToggleSwitch :value="value.on" @input="toggle" - @click.stop :disabled="loading || value.is_read_only" /> </div> </div> @@ -84,14 +85,17 @@ export default { computed: { rgbColor() { - if ( - !this.colorConverter || this.value.hue == null || - (this.value.x == null && this.value.y == null) - ) - return 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, @@ -184,6 +188,14 @@ export default { @import "common"; .light-container { + .head { + .buttons { + button { + margin-right: 0.5em; + } + } + } + .body { .row { display: flex; diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py index f64030c57..ea6da9164 100644 --- a/platypush/entities/_base.py +++ b/platypush/entities/_base.py @@ -49,7 +49,7 @@ class Entity(Base): UniqueConstraint(external_id, plugin) - __table_args__ = (Index(name, plugin),) + __table_args__ = (Index(name, plugin), Index(name, type, plugin)) __mapper_args__ = { 'polymorphic_identity': __tablename__, diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py index 3ed383ef2..fef07a054 100644 --- a/platypush/entities/_engine.py +++ b/platypush/entities/_engine.py @@ -188,7 +188,9 @@ class EntitiesEngine(Thread): ) if entity.external_id is not None else and_( - Entity.name == entity.name, Entity.plugin == entity.plugin + Entity.name == entity.name, + Entity.type == entity.type, + Entity.plugin == entity.plugin, ) for entity in entities ] @@ -246,6 +248,10 @@ class EntitiesEngine(Thread): 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) diff --git a/platypush/plugins/smartthings/__init__.py b/platypush/plugins/smartthings/__init__.py index 4abc55dce..36df8ae67 100644 --- a/platypush/plugins/smartthings/__init__.py +++ b/platypush/plugins/smartthings/__init__.py @@ -2,13 +2,17 @@ import asyncio import aiohttp from threading import RLock -from typing import Optional, Dict, List, Union +from typing import Optional, Dict, List, Union, Iterable +from platypush.entities import manages +from platypush.entities.lights import Light +from platypush.entities.switches import Switch from platypush.plugins import action -from platypush.plugins.switch import SwitchPlugin +from platypush.plugins.switch import Plugin -class SmartthingsPlugin(SwitchPlugin): +@manages(Switch, Light) +class SmartthingsPlugin(Plugin): """ Plugin to interact with devices and locations registered to a Samsung SmartThings account. @@ -43,16 +47,12 @@ 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} 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} async def _refresh_rooms(self, api, location_id: str): @@ -300,12 +300,24 @@ class SmartthingsPlugin(SwitchPlugin): 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: @@ -413,22 +425,68 @@ class SmartthingsPlugin(SwitchPlugin): finally: loop.stop() + @staticmethod + def _is_light(device): + if isinstance(device, dict): + capabilities = device.get('capabilities', []) + else: + capabilities = device.capabilities + + return 'colorControl' in capabilities or 'colorTemperature' in capabilities + def transform_entities(self, entities): from platypush.entities.switches import Switch compatible_entities = [] for device in entities: - if 'switch' in device.capabilities: + 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={ - 'location_id': getattr(device, 'location_id', None), - 'room_id': getattr(device, 'room_id', None), - }, + data=data, ) ) @@ -582,17 +640,15 @@ class SmartthingsPlugin(SwitchPlugin): assert ret, 'The command switch={state} failed on device {device}'.format( state=state, device=dev.label ) - return not dev.status.switch with self._refresh_lock: loop = asyncio.new_event_loop() - state = loop.run_until_complete(_toggle()) - device.status.switch = state - self.publish_entities([device]) # type: ignore + 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 @@ -617,8 +673,7 @@ class SmartthingsPlugin(SwitchPlugin): ] """ - # noinspection PyUnresolvedReferences - devices = self.status().output + devices = self.status().output # type: ignore return [ { 'name': device['name'], @@ -626,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: From 5b3e1317f498fc1c404f7f1aba08e98ab69a2488 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Mon, 30 May 2022 09:23:25 +0200 Subject: [PATCH 95/96] Only refresh entities that are visible on the interface --- .../components/panels/Entities/Selector.vue | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue index 5715924f3..d8abd90b4 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue @@ -127,25 +127,27 @@ export default { this.$emit('input', value) }, - resetGroupFilter() { - this.selectedGroups = Object.keys( - this.entityGroups[this.value?.grouping] || {} - ).reduce( - (obj, group) => { - obj[group] = true - return obj - }, {} - ) + 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) { - if (this.selectedGroups[group]) - delete this.selectedGroups[group] - else - this.selectedGroups[group] = true - + this.selectedGroups[group] = !this.selectedGroups[group] this.synchronizeSelectedEntities() }, @@ -160,10 +162,10 @@ export default { }, mounted() { - this.resetGroupFilter() - this.$watch(() => this.value?.grouping, this.resetGroupFilter) + this.refreshGroupFilter(true) + this.$watch(() => this.value?.grouping, () => { this.refreshGroupFilter(true) }) this.$watch(() => this.searchTerm, this.updateSearchTerm) - this.$watch(() => this.entityGroups, this.resetGroupFilter) + this.$watch(() => this.entityGroups, () => { this.refreshGroupFilter(false) }) }, } </script> From 2898a337524619d9710f10c64091e4c5cf414d78 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <info@fabiomanganiello.com> Date: Thu, 2 Jun 2022 00:36:14 +0200 Subject: [PATCH 96/96] s/click_url/url/g in ntfy message definitions --- platypush/message/event/ntfy.py | 6 +++--- platypush/plugins/ntfy/__init__.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/platypush/message/event/ntfy.py b/platypush/message/event/ntfy.py index 1709cb186..3dfb8fc8f 100644 --- a/platypush/message/event/ntfy.py +++ b/platypush/message/event/ntfy.py @@ -21,7 +21,7 @@ class NotificationEvent(Event): attachment: Optional[Mapping] = None, actions: Optional[Collection[Mapping]] = None, tags: Optional[Collection[str]] = None, - click_url: Optional[str] = None, + url: Optional[str] = None, **kwargs ): """ @@ -32,7 +32,7 @@ class NotificationEvent(Event): :param priority: Message priority. :param time: Message UNIX timestamp. :param tags: Notification tags. - :param click_url: URL spawned when the notification is clicked. + :param url: URL spawned when the notification is clicked. :param actions: List of actions associated to the notification. Example: @@ -89,6 +89,6 @@ class NotificationEvent(Event): tags=tags, attachment=attachment, actions=actions, - click_url=click_url, + url=url, **kwargs ) diff --git a/platypush/plugins/ntfy/__init__.py b/platypush/plugins/ntfy/__init__.py index d197a6b1f..f286ee4d3 100644 --- a/platypush/plugins/ntfy/__init__.py +++ b/platypush/plugins/ntfy/__init__.py @@ -95,7 +95,7 @@ class NtfyPlugin(RunnablePlugin): message=msg.get('message'), title=msg.get('title'), tags=msg.get('tags'), - click_url=msg.get('click'), + url=msg.get('click'), actions=msg.get('actions'), attachment=msg.get('attachment'), ) @@ -142,7 +142,7 @@ class NtfyPlugin(RunnablePlugin): username: Optional[str] = None, password: Optional[str] = None, title: Optional[str] = None, - click_url: Optional[str] = None, + url: Optional[str] = None, attachment: Optional[str] = None, filename: Optional[str] = None, actions: Optional[Collection[Mapping[str, str]]] = None, @@ -160,7 +160,7 @@ class NtfyPlugin(RunnablePlugin): :param username: Set if publishing to the topic requires authentication :param password: Set if publishing to the topic requires authentication :param title: Custom notification title. - :param click_url: URL that should be opened when the user clicks the + :param url: URL that should be opened when the user clicks the notification. It can be an ``http(s)://`` URL, a ``mailto:`, a ``geo:``, a link to another ntfy topic (e.g. ``ntfy://mytopic``) or a Twitter link (e.g. ``twitter://user?screen_name=myname``). @@ -240,7 +240,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 {}), @@ -255,7 +255,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 {}),