[WIP] Refactoring `@manages` annotation into a proper `EntityManager` hierarchy

This commit is contained in:
Fabio Manganiello 2023-02-02 23:21:12 +01:00
parent 63d6920716
commit be3b99326f
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
13 changed files with 535 additions and 394 deletions

View File

@ -1,18 +1,26 @@
import logging import logging
from typing import Collection, Optional from typing import Collection, Optional
from ._base import Entity, get_entities_registry from ._base import Entity, get_entities_registry, init_entities_db
from ._engine import EntitiesEngine from ._engine import EntitiesEngine
from ._registry import manages, register_entity_plugin, get_plugin_entity_registry from ._managers import register_entity_manager, get_plugin_entity_registry
from ._managers.lights import LightEntityManager
from ._managers.sensors import SensorEntityManager
from ._managers.switches import (
SwitchEntityManager,
DimmerEntityManager,
EnumSwitchEntityManager,
)
_engine: Optional[EntitiesEngine] = None _engine: Optional[EntitiesEngine] = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def init_entities_engine() -> EntitiesEngine: def init_entities_engine() -> EntitiesEngine:
from ._base import init_entities_db """
Initialize and start the entities engine.
global _engine """
global _engine # pylint: disable=global-statement
init_entities_db() init_entities_db()
_engine = EntitiesEngine() _engine = EntitiesEngine()
_engine.start() _engine.start()
@ -20,6 +28,17 @@ def init_entities_engine() -> EntitiesEngine:
def publish_entities(entities: Collection[Entity]): def publish_entities(entities: Collection[Entity]):
"""
Publish a collection of entities to be processed by the engine.
The engine will:
- Normalize and merge the provided entities.
- Trigger ``EntityUpdateEvent`` events.
- Persist the new state to the local database.
:param entities: Entities to be published.
"""
if not _engine: if not _engine:
logger.debug('No entities engine registered') logger.debug('No entities engine registered')
return return
@ -28,12 +47,16 @@ def publish_entities(entities: Collection[Entity]):
__all__ = ( __all__ = (
'Entity', 'DimmerEntityManager',
'EntitiesEngine', 'EntitiesEngine',
'Entity',
'EnumSwitchEntityManager',
'LightEntityManager',
'SensorEntityManager',
'SwitchEntityManager',
'get_entities_registry',
'get_plugin_entity_registry',
'init_entities_engine', 'init_entities_engine',
'publish_entities', 'publish_entities',
'register_entity_plugin', 'register_entity_manager',
'get_plugin_entity_registry',
'get_entities_registry',
'manages',
) )

View File

@ -0,0 +1,155 @@
import inspect
import json
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Optional, Dict, Collection, Type
from platypush.config import Config
from platypush.entities import Entity
from platypush.utils import get_plugin_name_by_class, get_redis
_entity_registry_varname = '_platypush/plugin_entity_registry'
class EntityManager(ABC):
"""
Base mixin for all the integrations that support entities mapping.
The classes that implement the entity manager need to implement the
:meth:`.transform_entities` method, which will convert the supported
entities from whichever format the integration supports to a collection of
:class:`platypush.entities.Entity` objects.
The converted entities will then be passed to the
:class:`platypush.entities.EntitiesEngine` whenever
:meth:`.publish_entities` is called
The implemented classes should also implement the :meth:`.status` method.
This method should retrieve the current state of the entities and call
:meth:`.publish_entities`.
"""
def __new__(cls, *_, **__) -> 'EntityManager':
register_entity_manager(cls)
return super().__new__(cls)
@abstractmethod
def transform_entities(self, entities: Collection[Any]) -> Collection[Entity]:
"""
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.
"""
assert all(isinstance(e, Entity) for e in entities), (
'Expected all the instances to be entities, got '
f'{[e.__class__.__name__ for e in entities]}'
)
return entities
@abstractmethod
def status(self, *_, **__):
"""
All derived classes should implement this method.
At the very least, this method should refresh the current state of the
integration's entities and call :meth:`.publish_entities`.
It should also return the current state of the entities as a list of
serialized entities, if possible.
"""
raise NotImplementedError(
'The `status` method has not been implemented in '
f'{self.__class__.__name__}'
)
def _normalize_entities(self, entities: Collection[Entity]) -> Collection[Entity]:
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() # type: ignore
return entities
def publish_entities(self, entities: Optional[Collection[Any]]):
"""
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)
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 platypush.entities import publish_entities
transformed_entities = self._normalize_entities(
self.transform_entities(entities or [])
)
publish_entities(transformed_entities)
def register_entity_manager(cls: Type[EntityManager]):
"""
Associates a plugin as a manager for a certain entity type.
You usually don't have to call this method directly.
"""
entity_managers = [
c
for c in inspect.getmro(cls)
if issubclass(c, EntityManager) and c not in {cls, EntityManager}
]
plugin_name = get_plugin_name_by_class(cls) or ''
redis = get_redis()
registry = get_plugin_entity_registry()
registry_by_plugin = set(registry['by_plugin'].get(plugin_name, []))
for manager in entity_managers:
entity_type_name = manager.__name__
registry_by_type = set(registry['by_type'].get(entity_type_name, []))
registry_by_plugin.add(entity_type_name)
registry_by_type.add(plugin_name)
registry['by_plugin'][plugin_name] = list(registry_by_plugin)
registry['by_type'][entity_type_name] = list(registry_by_type)
redis.mset({_entity_registry_varname: json.dumps(registry)})
def get_plugin_entity_registry() -> Dict[str, Dict[str, Collection[str]]]:
"""
Get the `plugin->entity_types` and `entity_type->plugin`
mappings supported by the current configuration.
"""
redis = get_redis()
registry = redis.mget([_entity_registry_varname])[0]
try:
registry = json.loads((registry or b'').decode())
except (TypeError, ValueError):
return {'by_plugin': {}, 'by_type': {}}
enabled_plugins = set(Config.get_plugins().keys())
return {
'by_plugin': {
plugin_name: entity_types
for plugin_name, entity_types in registry.get('by_plugin', {}).items()
if plugin_name in enabled_plugins
},
'by_type': {
entity_type: [p for p in plugins if p in enabled_plugins]
for entity_type, plugins in registry.get('by_type', {}).items()
},
}

View File

@ -0,0 +1,20 @@
from abc import ABC, abstractmethod
from .switches import SwitchEntityManager
class LightEntityManager(SwitchEntityManager, ABC):
"""
Base class for integrations that support light/bulbs entities.
"""
@abstractmethod
def set_lights(self, *args, lights=None, **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()

View File

@ -0,0 +1,9 @@
from abc import ABC
from . import EntityManager
class SensorEntityManager(EntityManager, ABC):
"""
Base class for integrations that support sensor entities.
"""

View File

@ -0,0 +1,54 @@
from abc import ABC, abstractmethod
from . import EntityManager
class SwitchEntityManager(EntityManager, ABC):
"""
Base class for integrations that support binary switches.
"""
@abstractmethod
def on(self, *_, **__):
"""Turn on a device"""
raise NotImplementedError()
@abstractmethod
def off(self, *_, **__):
"""Turn off a device"""
raise NotImplementedError()
@abstractmethod
def toggle(self, *_, **__):
"""Toggle the state of a device (on->off or off->on)"""
raise NotImplementedError()
class MultiLevelSwitchEntityManager(EntityManager, ABC):
"""
Base class for integrations that support dimmers/multi-level/enum switches.
Don't extend this class directly. Instead, use on of the available
intermediate abstract classes - like ``DimmerEntityManager`` or
``EnumSwitchEntityManager``.
"""
@abstractmethod
def set_value( # pylint: disable=redefined-builtin
self, *entities, property=None, value=None, **__
):
"""Set a value"""
raise NotImplementedError()
class DimmerEntityManager(MultiLevelSwitchEntityManager, ABC):
"""
Base class for integrations that support dimmers/multi-level switches.
"""
class EnumSwitchEntityManager(MultiLevelSwitchEntityManager, ABC):
"""
Base class for integrations that support switches with a pre-defined,
enum-like set of possible values.
"""

View File

@ -1,135 +0,0 @@
import json
from datetime import datetime
from typing import Optional, Dict, Collection, Type
from platypush.config import Config
from platypush.plugins import Plugin
from platypush.utils import get_plugin_name_by_class, get_redis
from ._base import Entity
_entity_registry_varname = '_platypush/plugin_entity_registry'
def register_entity_plugin(entity_type: Type[Entity], plugin: Plugin):
"""
Associates a plugin as a manager for a certain entity type.
If you use the `@manages` decorator then you usually don't have
to call this method directly.
"""
plugin_name = get_plugin_name_by_class(plugin.__class__) or ''
entity_type_name = entity_type.__name__.lower()
redis = get_redis()
registry = get_plugin_entity_registry()
registry_by_plugin = set(registry['by_plugin'].get(plugin_name, []))
registry_by_entity_type = set(registry['by_entity_type'].get(entity_type_name, []))
registry_by_plugin.add(entity_type_name)
registry_by_entity_type.add(plugin_name)
registry['by_plugin'][plugin_name] = list(registry_by_plugin)
registry['by_entity_type'][entity_type_name] = list(registry_by_entity_type)
redis.mset({_entity_registry_varname: json.dumps(registry)})
def get_plugin_entity_registry() -> Dict[str, Dict[str, Collection[str]]]:
"""
Get the `plugin->entity_types` and `entity_type->plugin`
mappings supported by the current configuration.
"""
redis = get_redis()
registry = redis.mget([_entity_registry_varname])[0]
try:
registry = json.loads((registry or b'').decode())
except (TypeError, ValueError):
return {'by_plugin': {}, 'by_entity_type': {}}
enabled_plugins = set(Config.get_plugins().keys())
return {
'by_plugin': {
plugin_name: entity_types
for plugin_name, entity_types in registry['by_plugin'].items()
if plugin_name in enabled_plugins
},
'by_entity_type': {
entity_type: [p for p in plugins if p in enabled_plugins]
for entity_type, plugins in registry['by_entity_type'].items()
},
}
class EntityManagerMixin:
"""
This mixin is injected on the fly into any plugin class declared with
the @manages decorator. The class will therefore implement the
`publish_entities` and `transform_entities` methods, which can be
overridden if required.
"""
def transform_entities(self, entities):
"""
This method takes a list of entities in any (plugin-specific)
format and converts them into a standardized collection of
`Entity` objects. Since this method is called by
:meth:`.publish_entities` before entity updates are published,
you may usually want to extend it to pre-process the entities
managed by your extension into the standard format before they
are stored and published to all the consumers.
"""
entities = entities or []
for entity in entities:
if entity.id:
# Entity IDs can only refer to the internal primary key
entity.external_id = entity.id
entity.id = None # type: ignore
entity.plugin = get_plugin_name_by_class(self.__class__) # type: ignore
entity.updated_at = datetime.utcnow()
return entities
def publish_entities(self, entities: Optional[Collection[Entity]]):
"""
Publishes a list of entities. The downstream consumers include:
- The entity persistence manager
- The web server
- Any consumer subscribed to
:class:`platypush.message.event.entities.EntityUpdateEvent`
events (e.g. web clients)
If your extension class uses the `@manages` decorator then you usually
don't need to override this class (but you may want to extend
:meth:`.transform_entities` instead if your extension doesn't natively
handle `Entity` objects).
"""
from . import publish_entities
transformed_entities = self.transform_entities(entities)
publish_entities(transformed_entities)
def manages(*entities: Type[Entity]):
"""
This decorator is used to register a plugin/backend class as a
manager of one or more types of entities.
"""
def wrapper(plugin: Type[Plugin]):
init = plugin.__init__
def __init__(self, *args, **kwargs):
for entity_type in entities:
register_entity_plugin(entity_type, self)
init(self, *args, **kwargs)
plugin.__init__ = __init__ # type: ignore
# Inject the EntityManagerMixin
if EntityManagerMixin not in plugin.__bases__:
plugin.__bases__ = (EntityManagerMixin,) + plugin.__bases__
return plugin
return wrapper

View File

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

View File

@ -4,22 +4,29 @@ import time
from enum import Enum from enum import Enum
from threading import Thread, Event from threading import Thread, Event
from typing import Iterable, Union, Mapping, Any, Set from typing import (
Any,
Collection,
Dict,
Iterable,
Mapping,
Set,
Union,
)
from platypush.context import get_bus from platypush.context import get_bus
from platypush.entities import Entity from platypush.entities import Entity, LightEntityManager
from platypush.entities.lights import Light as LightEntity from platypush.entities.lights import Light as LightEntity
from platypush.message.event.light import ( from platypush.message.event.light import (
LightAnimationStartedEvent, LightAnimationStartedEvent,
LightAnimationStoppedEvent, LightAnimationStoppedEvent,
LightStatusChangeEvent, LightStatusChangeEvent,
) )
from platypush.plugins import action, RunnablePlugin from platypush.plugins import RunnablePlugin, action
from platypush.plugins.light import LightPlugin
from platypush.utils import set_thread_name from platypush.utils import set_thread_name
class LightHuePlugin(RunnablePlugin, LightPlugin): class LightHuePlugin(RunnablePlugin, LightEntityManager):
""" """
Philips Hue lights plugin. Philips Hue lights plugin.
@ -47,14 +54,22 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
_UNINITIALIZED_BRIDGE_ERR = 'The Hue bridge is not initialized' _UNINITIALIZED_BRIDGE_ERR = 'The Hue bridge is not initialized'
class Animation(Enum): class Animation(Enum):
"""
Inner class to model light animations.
"""
COLOR_TRANSITION = 'color_transition' COLOR_TRANSITION = 'color_transition'
BLINK = 'blink' BLINK = 'blink'
def __eq__(self, other): def __eq__(self, other):
"""
Check if the configuration of two light animations matches.
"""
if isinstance(other, str): if isinstance(other, str):
return self.value == other return self.value == other
elif isinstance(other, self.__class__): if isinstance(other, self.__class__):
return self == other return self == other
return False
def __init__(self, bridge, lights=None, groups=None, poll_seconds: float = 20.0): def __init__(self, bridge, lights=None, groups=None, poll_seconds: float = 20.0):
""" """
@ -76,14 +91,14 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
self.bridge_address = bridge self.bridge_address = bridge
self.bridge = None self.bridge = None
self.logger.info( self.logger.info(
'Initializing Hue lights plugin - bridge: "{}"'.format(self.bridge_address) 'Initializing Hue lights plugin - bridge: "%s"', self.bridge_address
) )
self.connect() self.connect()
self.lights = set() self.lights = set()
self.groups = set() self.groups = set()
self.poll_seconds = poll_seconds self.poll_seconds = poll_seconds
self._cached_lights = {} self._cached_lights: Dict[str, dict] = {}
if lights: if lights:
self.lights = set(lights) self.lights = set(lights)
@ -94,10 +109,10 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
self.lights = {light['name'] for light in self._get_lights().values()} self.lights = {light['name'] for light in self._get_lights().values()}
self.animation_thread = None self.animation_thread = None
self.animations = {} self.animations: Dict[str, dict] = {}
self._animation_stop = Event() self._animation_stop = Event()
self._init_animations() self._init_animations()
self.logger.info(f'Configured lights: {self.lights}') self.logger.info('Configured lights: %s', self.lights)
def _expand_groups(self, groups: Iterable[str]) -> Set[str]: def _expand_groups(self, groups: Iterable[str]) -> Set[str]:
lights = set() lights = set()
@ -147,7 +162,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
self.bridge = Bridge(self.bridge_address) self.bridge = Bridge(self.bridge_address)
success = True success = True
except PhueRegistrationException as e: except PhueRegistrationException as e:
self.logger.warning('Bridge registration error: {}'.format(str(e))) self.logger.warning('Bridge registration error: %s', e)
if n_tries >= self._MAX_RECONNECT_TRIES: if n_tries >= self._MAX_RECONNECT_TRIES:
self.logger.error( self.logger.error(
@ -392,7 +407,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
return self._get_lights(publish_entities=True) return self._get_lights(publish_entities=True)
@action @action
def set_lights(self, lights, **kwargs): def set_lights(self, lights, *_, **kwargs): # pylint: disable=arguments-differ
""" """
Set a set of properties on a set of lights. Set a set of properties on a set of lights.
@ -404,7 +419,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
{ {
"type": "request", "type": "request",
"action": "light.hue.set_light", "action": "light.hue.set_lights",
"args": { "args": {
"lights": ["Bulb 1", "Bulb 2"], "lights": ["Bulb 1", "Bulb 2"],
"sat": 255 "sat": 255
@ -434,7 +449,10 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
for arg, value in kwargs.items(): for arg, value in kwargs.items():
args += [arg, value] args += [arg, value]
self.bridge.set_light(lights, *args) assert len(args) > 1, 'Not enough parameters passed to set_lights'
param = args.pop(0)
value = args.pop(0)
self.bridge.set_light(lights, param, value, *args)
return self._get_lights(publish_entities=True) return self._get_lights(publish_entities=True)
@action @action
@ -442,8 +460,9 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
""" """
Set a group (or groups) property. Set a group (or groups) property.
:param group: Group or groups to set. It can be a string representing the :param group: Group or groups to set. Can be a string representing the
group name, a group object, a list of strings, or a list of group objects. group name, a group object, a list of strings, or a list of group
objects.
:param kwargs: key-value list of parameters to set. :param kwargs: key-value list of parameters to set.
Example call:: Example call::
@ -464,7 +483,9 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
self.bridge.set_group(group, **kwargs) self.bridge.set_group(group, **kwargs)
@action @action
def on(self, lights=None, groups=None, **kwargs): def on( # pylint: disable=arguments-differ
self, lights=None, groups=None, **kwargs
):
""" """
Turn lights/groups on. Turn lights/groups on.
@ -479,7 +500,9 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
return self._exec('on', True, lights=lights, groups=groups, **kwargs) return self._exec('on', True, lights=lights, groups=groups, **kwargs)
@action @action
def off(self, lights=None, groups=None, **kwargs): def off( # pylint: disable=arguments-differ
self, lights=None, groups=None, **kwargs
):
""" """
Turn lights/groups off. Turn lights/groups off.
@ -494,7 +517,9 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
return self._exec('on', False, lights=lights, groups=groups, **kwargs) return self._exec('on', False, lights=lights, groups=groups, **kwargs)
@action @action
def toggle(self, lights=None, groups=None, **kwargs): def toggle( # pylint: disable=arguments-differ
self, lights=None, groups=None, **kwargs
):
""" """
Toggle lights/groups on/off. Toggle lights/groups on/off.
@ -857,34 +882,42 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
:type duration: float :type duration: float
:param hue_range: If you selected a ``color_transition``, this will :param hue_range: If you selected a ``color_transition``, this will
specify the hue range of your color ``color_transition``. Default: [0, 65535] specify the hue range of your color ``color_transition``.
Default: [0, 65535]
:type hue_range: list[int] :type hue_range: list[int]
:param sat_range: If you selected a color ``color_transition``, this :param sat_range: If you selected a color ``color_transition``, this
will specify the saturation range of your color ``color_transition``. will specify the saturation range of your color
Default: [0, 255] ``color_transition``. Default: [0, 255]
:type sat_range: list[int] :type sat_range: list[int]
:param bri_range: If you selected a color ``color_transition``, this :param bri_range: If you selected a color ``color_transition``, this
will specify the brightness range of your color ``color_transition``. will specify the brightness range of your color
Default: [254, 255] :type bri_range: list[int] ``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 lights: Lights to control (names, IDs or light objects).
:param groups: Groups to control (names, IDs or group objects). Default: plugin default groups 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 :param hue_step: If you selected a color ``color_transition``, this
will specify by how much the color hue will change between iterations. will specify by how much the color hue will change between
Default: 1000 :type hue_step: int iterations. Default: 1000
:type hue_step: int
:param sat_step: If you selected a color ``color_transition``, this :param sat_step: If you selected a color ``color_transition``, this
will specify by how much the saturation will change between iterations. will specify by how much the saturation will change
Default: 2 :type sat_step: int between iterations. Default: 2
:type sat_step: int
:param bri_step: If you selected a color ``color_transition``, this :param bri_step: If you selected a color ``color_transition``, this
will specify by how much the brightness will change between iterations. will specify by how much the brightness will change between iterations.
Default: 1 :type bri_step: int Default: 1
:type bri_step: int
:param transition_seconds: Time between two transitions or blinks in seconds. Default: 1.0 :param transition_seconds: Time between two transitions or blinks in
seconds. Default: 1.0
:type transition_seconds: float :type transition_seconds: float
""" """
@ -1028,11 +1061,11 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
try: try:
if animation == self.Animation.COLOR_TRANSITION: if animation == self.Animation.COLOR_TRANSITION:
for (light, attrs) in lights.items(): for (light, attrs) in lights.items():
self.logger.debug('Setting {} to {}'.format(light, attrs)) self.logger.debug('Setting %s to %s', lights, attrs)
self.bridge.set_light(light, attrs) self.bridge.set_light(light, attrs)
elif animation == self.Animation.BLINK: elif animation == self.Animation.BLINK:
conf = lights[list(lights.keys())[0]] conf = lights[list(lights.keys())[0]]
self.logger.debug('Setting lights to {}'.format(conf)) self.logger.debug('Setting lights to %s', conf)
if groups: if groups:
self.bridge.set_group( self.bridge.set_group(
@ -1074,7 +1107,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
def transform_entities( def transform_entities(
self, entities: Union[Iterable[Union[dict, Entity]], Mapping[Any, dict]] self, entities: Union[Iterable[Union[dict, Entity]], Mapping[Any, dict]]
) -> Iterable[Entity]: ) -> Collection[Entity]:
new_entities = [] new_entities = []
if isinstance(entities, dict): if isinstance(entities, dict):
entities = [{'id': id, **e} for id, e in entities.items()] entities = [{'id': id, **e} for id, e in entities.items()]
@ -1108,10 +1141,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
'hue_max': self.MAX_HUE, 'hue_max': self.MAX_HUE,
} }
if entity.get('state', {}).get('hue') is not None if entity.get('state', {}).get('hue') is not None
else { else {}
'hue_min': None,
'hue_max': None,
}
), ),
**( **(
{ {
@ -1119,10 +1149,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
'saturation_max': self.MAX_SAT, 'saturation_max': self.MAX_SAT,
} }
if entity.get('state', {}).get('sat') is not None if entity.get('state', {}).get('sat') is not None
else { else {}
'saturation_min': None,
'saturation_max': None,
}
), ),
**( **(
{ {
@ -1130,10 +1157,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
'brightness_max': self.MAX_BRI, 'brightness_max': self.MAX_BRI,
} }
if entity.get('state', {}).get('bri') is not None if entity.get('state', {}).get('bri') is not None
else { else {}
'brightness_min': None,
'brightness_max': None,
}
), ),
**( **(
{ {
@ -1141,15 +1165,12 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
'temperature_max': self.MAX_CT, 'temperature_max': self.MAX_CT,
} }
if entity.get('state', {}).get('ct') is not None if entity.get('state', {}).get('ct') is not None
else { else {}
'temperature_min': None,
'temperature_max': None,
}
), ),
) )
) )
return super().transform_entities(new_entities) # type: ignore return new_entities
def _get_lights(self, publish_entities=False) -> dict: def _get_lights(self, publish_entities=False) -> dict:
assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
@ -1171,7 +1192,7 @@ class LightHuePlugin(RunnablePlugin, LightPlugin):
return {id: scene for id, scene in scenes.items() if not scene.get('recycle')} return {id: scene for id, scene in scenes.items() if not scene.get('recycle')}
@action @action
def status(self) -> Iterable[LightEntity]: def status(self, *_, **__) -> Iterable[LightEntity]:
lights = self.transform_entities(self._get_lights(publish_entities=True)) lights = self.transform_entities(self._get_lights(publish_entities=True))
for light in lights: for light in lights:
light.id = light.external_id light.id = light.external_id

View File

@ -259,7 +259,7 @@ class MqttPlugin(Plugin):
try: try:
msg = Message.build(json.loads(msg)) msg = Message.build(json.loads(msg))
except Exception as e: except Exception as e:
self.logger.debug(f'Not a valid JSON: {str(e)}') self.logger.debug('Not a valid JSON: %s', e)
host = host or self.host host = host or self.host
port = port or self.port or 1883 port = port or self.port or 1883
@ -303,7 +303,7 @@ class MqttPlugin(Plugin):
try: try:
client.loop_stop() client.loop_stop()
except Exception as e: except Exception as e:
self.logger.warning(f'Could not stop client loop: {e}') self.logger.warning('Could not stop client loop: %s', e)
client.disconnect() client.disconnect()

View File

@ -1,6 +1,6 @@
import asyncio import asyncio
from threading import RLock from threading import RLock
from typing import Dict, Iterable, List, Optional, Tuple, Type, Union from typing import Any, Dict, Iterable, List, Optional, Tuple, Type, Union
import aiohttp import aiohttp
from pysmartthings import ( from pysmartthings import (
@ -9,10 +9,19 @@ from pysmartthings import (
Command, Command,
DeviceEntity, DeviceEntity,
DeviceStatus, DeviceStatus,
Location,
Room,
SmartThings, SmartThings,
) )
from platypush.entities import Entity, manages from platypush.entities import (
DimmerEntityManager,
Entity,
EnumSwitchEntityManager,
LightEntityManager,
SensorEntityManager,
SwitchEntityManager,
)
from platypush.entities.devices import Device from platypush.entities.devices import Device
from platypush.entities.dimmers import Dimmer from platypush.entities.dimmers import Dimmer
from platypush.entities.lights import Light from platypush.entities.lights import Light
@ -24,8 +33,14 @@ from platypush.utils import camel_case_to_snake_case
from ._mappers import DeviceMapper, device_mappers from ._mappers import DeviceMapper, device_mappers
@manages(Device, Dimmer, EnumSwitch, Light, Sensor, Switch) class SmartthingsPlugin(
class SmartthingsPlugin(RunnablePlugin): RunnablePlugin,
DimmerEntityManager,
EnumSwitchEntityManager,
LightEntityManager,
SensorEntityManager,
SwitchEntityManager,
):
""" """
Plugin to interact with devices and locations registered to a Samsung SmartThings account. Plugin to interact with devices and locations registered to a Samsung SmartThings account.
@ -49,17 +64,17 @@ class SmartthingsPlugin(RunnablePlugin):
self._refresh_lock = RLock() self._refresh_lock = RLock()
self._execute_lock = RLock() self._execute_lock = RLock()
self._locations = [] self._locations: List[Location] = []
self._devices = [] self._devices: List[DeviceEntity] = []
self._rooms_by_location = {} self._rooms_by_location: Dict[str, Room] = {}
self._locations_by_id = {} self._locations_by_id: Dict[str, Location] = {}
self._locations_by_name = {} self._locations_by_name: Dict[str, Location] = {}
self._devices_by_id = {} self._devices_by_id: Dict[str, DeviceEntity] = {}
self._devices_by_name = {} self._devices_by_name: Dict[str, DeviceEntity] = {}
self._rooms_by_id = {} self._rooms_by_id: Dict[str, Room] = {}
self._rooms_by_location_and_id = {} self._rooms_by_location_and_id: Dict[str, Dict[str, Room]] = {}
self._rooms_by_location_and_name = {} self._rooms_by_location_and_name: Dict[str, Dict[str, Room]] = {}
self._entities_by_id: Dict[str, Entity] = {} self._entities_by_id: Dict[str, Entity] = {}
async def _refresh_locations(self, api): async def _refresh_locations(self, api):
@ -308,10 +323,13 @@ class SmartthingsPlugin(RunnablePlugin):
): ):
self.refresh_info() self.refresh_info()
location = self._locations_by_id.get( location: Optional[Dict[str, Any]] = {}
location_id, self._locations_by_name.get(name) if location_id:
) location = self._locations_by_id.get(location_id)
assert location, 'Location {} not found'.format(location_id or name) elif name:
location = self._locations_by_name.get(name)
assert location, f'Location {location_id or name} not found'
return self._location_to_dict(location) return self._location_to_dict(location)
def _get_device(self, device: str) -> DeviceEntity: def _get_device(self, device: str) -> DeviceEntity:
@ -321,7 +339,7 @@ class SmartthingsPlugin(RunnablePlugin):
def _to_device_and_property(device: str) -> Tuple[str, Optional[str]]: def _to_device_and_property(device: str) -> Tuple[str, Optional[str]]:
tokens = device.split(':') tokens = device.split(':')
if len(tokens) > 1: if len(tokens) > 1:
return tuple(tokens[:2]) return (tokens[0], tokens[1])
return tokens[0], None return tokens[0], None
def _get_existing_and_missing_devices( def _get_existing_and_missing_devices(
@ -397,9 +415,7 @@ class SmartthingsPlugin(RunnablePlugin):
assert ( assert (
ret ret
), 'The command {capability}={command} failed on device {device}'.format( ), f'The command {capability}={command} failed on device {device_id}'
capability=capability, command=command, device=device_id
)
await self._get_device_status(api, device_id, publish_entities=True) await self._get_device_status(api, device_id, publish_entities=True)
@ -455,7 +471,9 @@ class SmartthingsPlugin(RunnablePlugin):
loop.stop() loop.stop()
@staticmethod @staticmethod
def _property_to_entity_name(property: str) -> str: def _property_to_entity_name(
property: str,
) -> str: # pylint: disable=redefined-builtin
return ' '.join( return ' '.join(
[ [
t[:1].upper() + t[1:] t[:1].upper() + t[1:]
@ -464,7 +482,7 @@ class SmartthingsPlugin(RunnablePlugin):
) )
@classmethod @classmethod
def _to_entity( def _to_entity( # pylint: disable=redefined-builtin
cls, device: DeviceEntity, property: str, entity_type: Type[Entity], **kwargs cls, device: DeviceEntity, property: str, entity_type: Type[Entity], **kwargs
) -> Entity: ) -> Entity:
return entity_type( return entity_type(
@ -669,7 +687,7 @@ class SmartthingsPlugin(RunnablePlugin):
# Fail if some devices haven't been found after refreshing # Fail if some devices haven't been found after refreshing
assert ( assert (
not missing_device_ids not missing_device_ids
), 'Could not find the following devices: {}'.format(list(missing_device_ids)) ), f'Could not find the following devices: {list(missing_device_ids)}'
async with aiohttp.ClientSession(timeout=self._timeout) as session: async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = SmartThings(session, self._access_token) api = SmartThings(session, self._access_token)
@ -682,12 +700,11 @@ class SmartthingsPlugin(RunnablePlugin):
for device_id in device_ids for device_id in device_ids
] ]
# noinspection PyTypeChecker
return await asyncio.gather(*status_tasks) return await asyncio.gather(*status_tasks)
@action @action
def status( def status( # pylint: disable=arguments-differ
self, device: Optional[Union[str, List[str]]] = None, publish_entities=True self, device: Optional[Union[str, List[str]]] = None, publish_entities=True, **_
) -> List[dict]: ) -> List[dict]:
""" """
Refresh and return the status of one or more devices. Refresh and return the status of one or more devices.
@ -715,7 +732,7 @@ class SmartthingsPlugin(RunnablePlugin):
if not device: if not device:
self.refresh_info() self.refresh_info()
devices = self._devices_by_id.keys() devices = list(self._devices_by_id.keys())
elif isinstance(device, str): elif isinstance(device, str):
devices = [device] devices = [device]
else: else:
@ -734,7 +751,13 @@ class SmartthingsPlugin(RunnablePlugin):
loop.stop() loop.stop()
def _set_switch(self, device: str, value: Optional[bool] = None): def _set_switch(self, device: str, value: Optional[bool] = None):
device, property = self._to_device_and_property(device) (
device,
property,
) = self._to_device_and_property( # pylint: disable=redefined-builtin
device
)
if not property: if not property:
property = Attribute.switch property = Attribute.switch
@ -744,6 +767,7 @@ class SmartthingsPlugin(RunnablePlugin):
if property == 'light': if property == 'light':
property = 'switch' property = 'switch'
else: else:
assert property, 'No property specified'
assert hasattr( assert hasattr(
dev.status, property dev.status, property
), f'No such property on device "{dev.label}": "{property}"' ), f'No such property on device "{dev.label}": "{property}"'
@ -760,7 +784,7 @@ class SmartthingsPlugin(RunnablePlugin):
return self.set_value(device, property, value) return self.set_value(device, property, value)
@action @action
def on(self, device: str, *_, **__): def on(self, device: str, *_, **__): # pylint: disable=arguments-differ
""" """
Turn on a device with ``switch`` capability. Turn on a device with ``switch`` capability.
@ -769,7 +793,7 @@ class SmartthingsPlugin(RunnablePlugin):
return self._set_switch(device, True) return self._set_switch(device, True)
@action @action
def off(self, device: str, *_, **__): def off(self, device: str, *_, **__): # pylint: disable=arguments-differ
""" """
Turn off a device with ``switch`` capability. Turn off a device with ``switch`` capability.
@ -778,7 +802,7 @@ class SmartthingsPlugin(RunnablePlugin):
return self._set_switch(device, False) return self._set_switch(device, False)
@action @action
def toggle(self, device: str, *_, **__): def toggle(self, device: str, *_, **__): # pylint: disable=arguments-differ
""" """
Toggle a device with ``switch`` capability. Toggle a device with ``switch`` capability.
@ -799,7 +823,7 @@ class SmartthingsPlugin(RunnablePlugin):
""" """
return self.set_value(device, Capability.switch_level, level, **kwargs) return self.set_value(device, Capability.switch_level, level, **kwargs)
def _set_value( def _set_value( # pylint: disable=redefined-builtin
self, device: str, property: Optional[str] = None, data=None, **kwargs self, device: str, property: Optional[str] = None, data=None, **kwargs
): ):
if not property: if not property:
@ -830,14 +854,14 @@ class SmartthingsPlugin(RunnablePlugin):
device, device,
mapper.capability, mapper.capability,
command, command,
args=mapper.set_value_args(data), args=mapper.set_value_args(data), # type: ignore
**kwargs, **kwargs,
) )
return self.status(device) return self.status(device)
@action @action
def set_value( def set_value( # pylint: disable=arguments-differ,redefined-builtin
self, device: str, property: Optional[str] = None, data=None, **kwargs self, device: str, property: Optional[str] = None, data=None, **kwargs
): ):
""" """
@ -854,10 +878,10 @@ class SmartthingsPlugin(RunnablePlugin):
return self._set_value(device, property, data, **kwargs) return self._set_value(device, property, data, **kwargs)
except Exception as e: except Exception as e:
self.logger.exception(e) self.logger.exception(e)
raise AssertionError(e) raise AssertionError(e) from e
@action @action
def set_lights( def set_lights( # pylint: disable=arguments-differ,redefined-builtin
self, self,
lights: Iterable[str], lights: Iterable[str],
on: Optional[bool] = None, on: Optional[bool] = None,
@ -961,7 +985,7 @@ class SmartthingsPlugin(RunnablePlugin):
return self.status(publish_entities=False) return self.status(publish_entities=False)
except Exception as e: except Exception as e:
self.logger.exception(e) self.logger.exception(e)
self.logger.error(f'Could not refresh the status: {e}') self.logger.error('Could not refresh the status: %s', e)
self.wait_stop(3 * (self.poll_interval or 5)) self.wait_stop(3 * (self.poll_interval or 5))
while not self.should_stop(): while not self.should_stop():

View File

@ -5,6 +5,7 @@ import threading
from queue import Queue from queue import Queue
from typing import ( from typing import (
Any, Any,
Collection,
Dict, Dict,
List, List,
Optional, Optional,
@ -13,7 +14,14 @@ from typing import (
Union, Union,
) )
from platypush.entities import Entity, manages from platypush.entities import (
DimmerEntityManager,
Entity,
EnumSwitchEntityManager,
LightEntityManager,
SensorEntityManager,
SwitchEntityManager,
)
from platypush.entities.batteries import Battery from platypush.entities.batteries import Battery
from platypush.entities.devices import Device from platypush.entities.devices import Device
from platypush.entities.dimmers import Dimmer from platypush.entities.dimmers import Dimmer
@ -40,8 +48,15 @@ from platypush.plugins import RunnablePlugin
from platypush.plugins.mqtt import MqttPlugin, action from platypush.plugins.mqtt import MqttPlugin, action
@manages(Battery, Device, Dimmer, Light, LinkQuality, Sensor, Switch) class ZigbeeMqttPlugin(
class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-init] RunnablePlugin,
MqttPlugin,
DimmerEntityManager,
EnumSwitchEntityManager,
LightEntityManager,
SensorEntityManager,
SwitchEntityManager,
): # lgtm [py/missing-call-to-init]
""" """
This plugin allows you to interact with Zigbee devices over MQTT through any Zigbee sniffer and This plugin allows you to interact with Zigbee devices over MQTT through any Zigbee sniffer and
`zigbee2mqtt <https://www.zigbee2mqtt.io/>`_. `zigbee2mqtt <https://www.zigbee2mqtt.io/>`_.
@ -245,9 +260,9 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
if option.get('property') if option.get('property')
} }
def transform_entities(self, devices): def transform_entities(self, entities: Collection[dict]) -> List[Entity]:
compatible_entities = [] compatible_entities = []
for dev in devices: for dev in entities:
if not dev: if not dev:
continue continue
@ -275,7 +290,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
) )
light_info = self._get_light_meta(dev) light_info = self._get_light_meta(dev)
dev_entities = [ dev_entities: List[Entity] = [
*self._get_sensors(dev, exposed, options), *self._get_sensors(dev, exposed, options),
*self._get_dimmers(dev, exposed, options), *self._get_dimmers(dev, exposed, options),
*self._get_switches(dev, exposed, options), *self._get_switches(dev, exposed, options),
@ -348,7 +363,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
compatible_entities += dev_entities compatible_entities += dev_entities
return super().transform_entities(compatible_entities) # type: ignore return compatible_entities
@staticmethod @staticmethod
def _get_device_url(device_info: dict) -> Optional[str]: def _get_device_url(device_info: dict) -> Optional[str]:
@ -400,7 +415,9 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
try: try:
host = mqtt_args.pop('host') host = mqtt_args.pop('host')
port = mqtt_args.pop('port') port = mqtt_args.pop('port')
client = self._get_client(**mqtt_args) client = self._get_client( # pylint: disable=unexpected-keyword-arg
**mqtt_args
)
client.on_message = _on_message() client.on_message = _on_message()
client.connect(host, port, keepalive=timeout) client.connect(host, port, keepalive=timeout)
client.subscribe(self.base_topic + '/bridge/#') client.subscribe(self.base_topic + '/bridge/#')
@ -444,7 +461,8 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
@staticmethod @staticmethod
def _parse_response(response: Union[dict, Response]) -> dict: def _parse_response(response: Union[dict, Response]) -> dict:
if isinstance(response, Response): if isinstance(response, Response):
response = dict(response.output) rs: dict = response.output # type: ignore
response = rs
assert response.get('status') != 'error', response.get( assert response.get('status') != 'error', response.get(
'error', 'zigbee2mqtt error' 'error', 'zigbee2mqtt error'
@ -873,7 +891,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
return name == device.get('friendly_name') or name == device.get('ieee_address') return name == device.get('friendly_name') or name == device.get('ieee_address')
@action @action
def device_get( def device_get( # pylint: disable=redefined-builtin
self, device: str, property: Optional[str] = None, **kwargs self, device: str, property: Optional[str] = None, **kwargs
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
@ -994,7 +1012,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
return self.devices_get([device] if device else None, *args, **kwargs) return self.devices_get([device] if device else None, *args, **kwargs)
@action @action
def device_set( def device_set( # pylint: disable=redefined-builtin
self, self,
device: str, device: str,
property: Optional[str] = None, property: Optional[str] = None,
@ -1054,7 +1072,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
return properties return properties
@action @action
def set_value( def set_value( # pylint: disable=redefined-builtin,arguments-differ
self, device: str, property: Optional[str] = None, data=None, **kwargs self, device: str, property: Optional[str] = None, data=None, **kwargs
): ):
""" """
@ -1069,7 +1087,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
:meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query
the default configured device). the default configured device).
""" """
dev, prop = self._ieee_address(device, with_property=True) dev, prop = self._ieee_address_and_property(device)
if not property: if not property:
property = prop property = prop
@ -1273,7 +1291,9 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
} }
@action @action
def group_add(self, name: str, id: Optional[int] = None, **kwargs): def group_add( # pylint: disable=redefined-builtin
self, name: str, id: Optional[int] = None, **kwargs
):
""" """
Add a new group. Add a new group.
@ -1301,7 +1321,9 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
) )
@action @action
def group_get(self, group: str, property: Optional[str] = None, **kwargs) -> dict: def group_get( # pylint: disable=redefined-builtin
self, group: str, property: Optional[str] = None, **kwargs
) -> dict:
""" """
Get one or more properties of a group. The compatible properties vary depending on the devices on the group. Get one or more properties of a group. The compatible properties vary depending on the devices on the group.
For example, a light bulb may have the "``state``" (with values ``"ON"`` and ``"OFF"``) and "``brightness``" For example, a light bulb may have the "``state``" (with values ``"ON"`` and ``"OFF"``) and "``brightness``"
@ -1329,9 +1351,10 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
return properties return properties
# noinspection PyShadowingBuiltins,DuplicatedCode
@action @action
def group_set(self, group: str, property: str, value: Any, **kwargs): def group_set( # pylint: disable=redefined-builtin
self, group: str, property: str, value: Any, **kwargs
):
""" """
Set a properties on a group. The compatible properties vary depending on the devices on the group. Set a properties on a group. The compatible properties vary depending on the devices on the group.
For example, a light bulb may have the "``state``" (with values ``"ON"`` and ``"OFF"``) and "``brightness``" For example, a light bulb may have the "``state``" (with values ``"ON"`` and ``"OFF"``) and "``brightness``"
@ -1501,7 +1524,9 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
) )
@action @action
def on(self, device, *_, **__): def on( # pylint: disable=redefined-builtin,arguments-differ
self, device, *_, **__
):
""" """
Turn on/set to true a switch, a binary property or an option. Turn on/set to true a switch, a binary property or an option.
""" """
@ -1511,7 +1536,9 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
) )
@action @action
def off(self, device, *_, **__): def off( # pylint: disable=redefined-builtin,arguments-differ
self, device, *_, **__
):
""" """
Turn off/set to false a switch, a binary property or an option. Turn off/set to false a switch, a binary property or an option.
""" """
@ -1521,7 +1548,9 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
) )
@action @action
def toggle(self, device, *_, **__): def toggle( # pylint: disable=redefined-builtin,arguments-differ
self, device, *_, **__
):
""" """
Toggles the state of a switch, a binary property or an option. Toggles the state of a switch, a binary property or an option.
""" """
@ -1540,7 +1569,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
) )
def _get_switch_info(self, name: str) -> Tuple[str, dict]: def _get_switch_info(self, name: str) -> Tuple[str, dict]:
name, prop = self._ieee_address(name, with_property=True) name, prop = self._ieee_address_and_property(name)
if not prop or prop == 'light': if not prop or prop == 'light':
prop = 'state' prop = 'state'
@ -1548,13 +1577,13 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
assert device_info, f'No such device: {name}' assert device_info, f'No such device: {name}'
name = self._preferred_name(device_info) name = self._preferred_name(device_info)
property = self._get_properties(device_info).get(prop) prop = self._get_properties(device_info).get(prop)
option = self._get_options(device_info).get(prop) option = self._get_options(device_info).get(prop)
if option: if option:
return name, option return name, option
assert property, f'No such property on device {name}: {prop}' assert prop, f'No such property on device {name}: {prop}'
return name, property return name, prop
@staticmethod @staticmethod
def _is_read_only(feature: dict) -> bool: def _is_read_only(feature: dict) -> bool:
@ -1575,9 +1604,9 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
return bool(feature.get('access', 0) & 4) == 0 return bool(feature.get('access', 0) & 4) == 0
@staticmethod @staticmethod
def _ieee_address( def _ieee_address_and_property(
device: Union[dict, str], with_property=False device: Union[dict, str]
) -> Union[str, Tuple[str, Optional[str]]]: ) -> Tuple[str, Optional[str]]:
# Entity value IDs are stored in the `<address>:<property>` # Entity value IDs are stored in the `<address>:<property>`
# format. Therefore, we need to split by `:` if we want to # format. Therefore, we need to split by `:` if we want to
# retrieve the original address. # retrieve the original address.
@ -1589,13 +1618,13 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
# IEEE address + property format # IEEE address + property format
if re.search(r'^0x[0-9a-fA-F]{16}:', dev): if re.search(r'^0x[0-9a-fA-F]{16}:', dev):
parts = dev.split(':') parts = dev.split(':')
return ( return (parts[0], parts[1] if len(parts) > 1 else None)
(parts[0], parts[1] if len(parts) > 1 else None)
if with_property
else parts[0]
)
return (dev, None) if with_property else dev return (dev, None)
@classmethod
def _ieee_address(cls, device: Union[dict, str]) -> str:
return cls._ieee_address_and_property(device)[0]
@classmethod @classmethod
def _get_switches( def _get_switches(
@ -1733,7 +1762,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
] ]
@classmethod @classmethod
def _to_entity( def _to_entity( # pylint: disable=redefined-builtin
cls, cls,
entity_type: Type[Entity], entity_type: Type[Entity],
device_info: dict, device_info: dict,
@ -1867,7 +1896,7 @@ class ZigbeeMqttPlugin(RunnablePlugin, MqttPlugin): # lgtm [py/missing-call-to-
return {} return {}
@action @action
def set_lights(self, lights, **kwargs): def set_lights(self, *_, lights, **kwargs):
""" """
Set the state for one or more Zigbee lights. Set the state for one or more Zigbee lights.
""" """

View File

@ -1,16 +1,25 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, List, Union from typing import Any, Dict, Optional, List, Union
from platypush.entities import manages from platypush.entities import (
from platypush.entities.batteries import Battery DimmerEntityManager,
from platypush.entities.dimmers import Dimmer EnumSwitchEntityManager,
from platypush.entities.lights import Light LightEntityManager,
from platypush.entities.switches import Switch SensorEntityManager,
SwitchEntityManager,
)
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
@manages(Battery, Dimmer, Light, Switch) class ZwaveBasePlugin(
class ZwaveBasePlugin(Plugin, ABC): DimmerEntityManager,
EnumSwitchEntityManager,
LightEntityManager,
SensorEntityManager,
SwitchEntityManager,
Plugin,
ABC,
):
""" """
Base class for Z-Wave plugins. Base class for Z-Wave plugins.
""" """
@ -27,7 +36,7 @@ class ZwaveBasePlugin(Plugin, ABC):
@abstractmethod @abstractmethod
@action @action
def status(self) -> Dict[str, Any]: def status(self) -> Dict[str, Any]: # pylint: disable=arguments-differ
""" """
Get the status of the controller. Get the status of the controller.
""" """
@ -316,7 +325,7 @@ class ZwaveBasePlugin(Plugin, ABC):
@abstractmethod @abstractmethod
@action @action
def set_value( def set_value( # pylint: disable=arguments-differ
self, self,
data, data,
value_id: Optional[int] = None, value_id: Optional[int] = None,
@ -864,7 +873,7 @@ class ZwaveBasePlugin(Plugin, ABC):
@abstractmethod @abstractmethod
@action @action
def on(self, device: str, *args, **kwargs): def on(self, device: str, *args, **kwargs): # pylint: disable=arguments-differ
""" """
Turn on a switch on a device. Turn on a switch on a device.
@ -874,7 +883,7 @@ class ZwaveBasePlugin(Plugin, ABC):
@abstractmethod @abstractmethod
@action @action
def off(self, device: str, *args, **kwargs): def off(self, device: str, *args, **kwargs): # pylint: disable=arguments-differ
""" """
Turn off a switch on a device. Turn off a switch on a device.
@ -884,7 +893,7 @@ class ZwaveBasePlugin(Plugin, ABC):
@abstractmethod @abstractmethod
@action @action
def toggle(self, device: str, *args, **kwargs): def toggle(self, device: str, *args, **kwargs): # pylint: disable=arguments-differ
""" """
Toggle a switch on a device. Toggle a switch on a device.

View File

@ -8,6 +8,7 @@ from threading import Timer
from typing import ( from typing import (
Any, Any,
Callable, Callable,
Collection,
Dict, Dict,
Iterable, Iterable,
List, List,
@ -204,13 +205,13 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
return rs return rs
def _api_request(self, api: str, *args, **kwargs): def _api_request(self, api: str, *args: Any, **kwargs):
if len(args) == 1 and isinstance(args[0], dict): if len(args) == 1 and isinstance(args[0], dict):
args = args[0] args = args[0] # type: ignore
payload = json.dumps({'args': args}) payload = json.dumps({'args': args})
ret = self._parse_response( ret = self._parse_response(
self.publish( # type: ignore[reportGeneralTypeIssues] self.publish(
topic=self._api_topic(api) + '/set', topic=self._api_topic(api) + '/set',
msg=payload, msg=payload,
reply_topic=self._api_topic(api), reply_topic=self._api_topic(api),
@ -402,7 +403,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
'product_id': f'0x{node["productId"]:04x}' 'product_id': f'0x{node["productId"]:04x}'
if node.get('productId') if node.get('productId')
else None, else None,
'product_type': '0x{:04x}'.format(node['productType']) 'product_type': f'0x{node["productType"]:04x}'
if node.get('productType') if node.get('productType')
else None, else None,
'product_name': ' '.join( 'product_name': ' '.join(
@ -506,7 +507,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
if not nodes: if not nodes:
nodes = self.get_nodes().output # type: ignore[reportGeneralTypeIssues] nodes = self.get_nodes().output # type: ignore[reportGeneralTypeIssues]
assert nodes, 'No nodes found on the network' assert nodes, 'No nodes found on the network'
nodes = nodes.values() nodes = nodes.values() # type: ignore
if value_id: if value_id:
values = [ values = [
@ -528,7 +529,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
if value['label']: if value['label']:
self._values_cache['by_label'][value['label']] = value self._values_cache['by_label'][value['label']] = value
self.publish_entities([self._to_current_value(value)]) # type: ignore self.publish_entities([self._to_current_value(value)])
return value return value
@staticmethod @staticmethod
@ -683,10 +684,10 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
args['updated_at'] = value['last_update'] args['updated_at'] = value['last_update']
return args return args
def transform_entities(self, values: Iterable[dict]): def transform_entities(self, entities: Iterable[dict]) -> Collection[Entity]:
entities = [] transformed_entities = []
for value in values: for value in entities:
if not value or self._matches_classes(value, *self._ignored_entity_classes): if not value or self._matches_classes(value, *self._ignored_entity_classes):
continue continue
@ -739,10 +740,10 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
entity_args.update(sensor_args) entity_args.update(sensor_args)
if entity_type: if entity_type:
entities.append(entity_type(**entity_args)) transformed_entities.append(entity_type(**entity_args))
self._process_parent_entities(values, entities) self._process_parent_entities(entities, transformed_entities)
return super().transform_entities(entities) # type: ignore return transformed_entities
def _process_parent_entities( def _process_parent_entities(
self, values: Iterable[Mapping], entities: List[Entity] self, values: Iterable[Mapping], entities: List[Entity]
@ -867,7 +868,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
else self.get_nodes(**kwargs).output.values() # type: ignore[reportGeneralTypeIssues] else self.get_nodes(**kwargs).output.values() # type: ignore[reportGeneralTypeIssues]
) )
command_classes: set = { classes: set = {
command_class_by_name[command_name] command_class_by_name[command_name]
for command_name in (command_classes or []) for command_name in (command_classes or [])
} }
@ -879,10 +880,9 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
continue continue
for value in node.get('values', {}).values(): for value in node.get('values', {}).values():
if ( if (classes and value.get('command_class') not in classes) or (
command_classes filter_callback and not filter_callback(value)
and value.get('command_class') not in command_classes ):
) or (filter_callback and not filter_callback(value)):
continue continue
value = self._to_current_value(value) value = self._to_current_value(value)
@ -940,7 +940,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
(default: query the default configured device). (default: query the default configured device).
""" """
msg_queue = queue.Queue() msg_queue: queue.Queue = queue.Queue()
topic = f'{self.topic_prefix}/driver/status' topic = f'{self.topic_prefix}/driver/status'
client = self._get_client(**kwargs) client = self._get_client(**kwargs)
@ -963,8 +963,8 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
status = msg_queue.get( status = msg_queue.get(
block=True, timeout=kwargs.get('timeout', self.timeout) block=True, timeout=kwargs.get('timeout', self.timeout)
) )
except queue.Empty: except queue.Empty as e:
raise TimeoutError('The request timed out') raise TimeoutError('The request timed out') from e
finally: finally:
client.loop_stop() client.loop_stop()
@ -973,7 +973,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
} }
@action @action
def add_node( def add_node( # pylint: disable=arguments-differ
self, self,
name: str, name: str,
location: str = '', location: str = '',
@ -1056,7 +1056,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
self._api_request('replaceFailedNode', node_id, **kwargs) self._api_request('replaceFailedNode', node_id, **kwargs)
@action @action
def replication_send(self, **_): def replication_send(self, **_): # pylint: disable=arguments-differ
""" """
Send node information from the primary to the secondary controller (not implemented by zwavejs2mqtt). Send node information from the primary to the secondary controller (not implemented by zwavejs2mqtt).
""" """
@ -1312,14 +1312,14 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
) )
@action @action
def set_node_product_name(self, **_): def set_node_product_name(self, **_): # pylint: disable=arguments-differ
""" """
Set the product name of a node (not implemented by zwavejs2mqtt). Set the product name of a node (not implemented by zwavejs2mqtt).
""" """
raise _NOT_IMPLEMENTED_ERR raise _NOT_IMPLEMENTED_ERR
@action @action
def set_node_manufacturer_name(self, **_): def set_node_manufacturer_name(self, **_): # pylint: disable=arguments-differ
""" """
Set the manufacturer name of a node (not implemented by zwavejs2mqtt). Set the manufacturer name of a node (not implemented by zwavejs2mqtt).
""" """
@ -1374,7 +1374,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
raise _NOT_IMPLEMENTED_ERR raise _NOT_IMPLEMENTED_ERR
@action @action
def set_controller_name(self, **_): def set_controller_name(self, **_): # pylint: disable=arguments-differ
""" """
Set the name of the controller on the network (not implemented: use Set the name of the controller on the network (not implemented: use
:meth:`platypush.plugin.zwave.mqtt.ZwaveMqttPlugin.set_node_name` instead). :meth:`platypush.plugin.zwave.mqtt.ZwaveMqttPlugin.set_node_name` instead).
@ -1406,7 +1406,9 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
raise _NOT_IMPLEMENTED_ERR raise _NOT_IMPLEMENTED_ERR
@action @action
def heal(self, timeout: Optional[int] = 60, **kwargs): def heal(
self, *_, timeout: Optional[int] = 60, **kwargs
): # pylint: disable=arguments-differ
""" """
Heal network by requesting nodes rediscover their neighbours. Heal network by requesting nodes rediscover their neighbours.
@ -1421,7 +1423,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
).start() ).start()
@action @action
def switch_all(self, **_): def switch_all(self, **_): # pylint: disable=arguments-differ
""" """
Switch all the connected devices on/off (not implemented). Switch all the connected devices on/off (not implemented).
""" """
@ -1524,7 +1526,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
) )
@action @action
def set_lights(self, lights, **kwargs): def set_lights(self, lights, **kwargs): # pylint: disable=arguments-differ
""" """
Set the state for one or more Z-Wave lights. Set the state for one or more Z-Wave lights.
""" """
@ -1533,28 +1535,28 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
self.set_value(light, kwargs) self.set_value(light, kwargs)
@action @action
def set_value_label(self, **_): def set_value_label(self, **_): # pylint: disable=arguments-differ
""" """
Change the label/name of a value (not implemented by zwavejs2mqtt). Change the label/name of a value (not implemented by zwavejs2mqtt).
""" """
raise _NOT_IMPLEMENTED_ERR raise _NOT_IMPLEMENTED_ERR
@action @action
def node_add_value(self, **_): def node_add_value(self, **_): # pylint: disable=arguments-differ
""" """
Add a value to a node (not implemented by zwavejs2mqtt). Add a value to a node (not implemented by zwavejs2mqtt).
""" """
raise _NOT_IMPLEMENTED_ERR raise _NOT_IMPLEMENTED_ERR
@action @action
def node_remove_value(self, **_): def node_remove_value(self, **_): # pylint: disable=arguments-differ
""" """
Remove a value from a node (not implemented by zwavejs2mqtt). Remove a value from a node (not implemented by zwavejs2mqtt).
""" """
raise _NOT_IMPLEMENTED_ERR raise _NOT_IMPLEMENTED_ERR
@action @action
def node_heal( def node_heal( # pylint: disable=arguments-differ
self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
): ):
""" """
@ -2133,7 +2135,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
raise _NOT_IMPLEMENTED_ERR raise _NOT_IMPLEMENTED_ERR
@action @action
def add_node_to_group( def add_node_to_group( # pylint: disable=arguments-differ
self, self,
group_id: Optional[str] = None, group_id: Optional[str] = None,
node_id: Optional[int] = None, node_id: Optional[int] = None,
@ -2157,7 +2159,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
self._api_request('addAssociations', group['node_id'], group['index'], [assoc]) self._api_request('addAssociations', group['node_id'], group['index'], [assoc])
@action @action
def remove_node_from_group( def remove_node_from_group( # pylint: disable=arguments-differ
self, self,
group_id: Optional[str] = None, group_id: Optional[str] = None,
node_id: Optional[int] = None, node_id: Optional[int] = None,
@ -2261,32 +2263,15 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
self.set_value(data=value['data'], id_on_network=device, **kwargs) self.set_value(data=value['data'], id_on_network=device, **kwargs)
return { return {
'name': '{} - {}'.format( 'name': (
self._nodes_cache['by_id'][value['node_id']]['name'], self._nodes_cache['by_id'][value['node_id']]['name']
value.get('label', '[No Label]'), + ' - '
+ value.get('label', '[No Label]')
), ),
'on': value['data'], 'on': value['data'],
'id': value['value_id'], 'id': value['value_id'],
} }
@property
def switches(self) -> List[dict]:
# Repopulate the nodes cache
self.get_nodes()
# noinspection PyUnresolvedReferences
devices = self.get_switches().output.values() # type: ignore[reportGeneralTypeIssues]
return [
{
'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
]
def main(self): def main(self):
from ._listener import ZwaveMqttListener from ._listener import ZwaveMqttListener