From 1df71cb54a6708d0db51a7e6fa58b469c267d67c Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 30 May 2022 09:23:05 +0200 Subject: [PATCH] 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 @@
- +
@@ -10,13 +11,13 @@
+ + - -
@@ -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: