platypush/platypush/plugins/smartthings/__init__.py

746 lines
26 KiB
Python
Raw Normal View History

2021-03-05 02:23:28 +01:00
import asyncio
import aiohttp
from threading import RLock
from typing import Optional, Dict, List, Union, Iterable
2021-03-05 02:23:28 +01:00
from platypush.entities import manages
from platypush.entities.lights import Light
from platypush.entities.switches import Switch
2021-03-05 02:23:28 +01:00
from platypush.plugins import action
from platypush.plugins.switch import Plugin
2021-03-05 02:23:28 +01:00
@manages(Switch, Light)
class SmartthingsPlugin(Plugin):
2021-03-05 02:23:28 +01:00
"""
Plugin to interact with devices and locations registered to a Samsung SmartThings account.
Requires:
* **pysmartthings** (``pip install pysmartthings``)
"""
_timeout = aiohttp.ClientTimeout(total=20.0)
2021-03-05 02:23:28 +01:00
def __init__(self, access_token: str, **kwargs):
"""
:param access_token: SmartThings API access token - you can get one at https://account.smartthings.com/tokens.
"""
super().__init__(**kwargs)
self._access_token = access_token
self._refresh_lock = RLock()
self._execute_lock = RLock()
self._locations = []
self._devices = []
self._rooms_by_location = {}
self._locations_by_id = {}
self._locations_by_name = {}
self._devices_by_id = {}
self._devices_by_name = {}
self._rooms_by_id = {}
self._rooms_by_location_and_id = {}
self._rooms_by_location_and_name = {}
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}
2021-03-05 02:23:28 +01:00
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}
2021-03-05 02:23:28 +01:00
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]}
)
2021-03-05 02:23:28 +01:00
self._rooms_by_location_and_id[location_id] = {
room.room_id: room for room in self._rooms_by_location[location_id]
2021-03-05 02:23:28 +01:00
}
self._rooms_by_location_and_name[location_id] = {
room.name: room for room in self._rooms_by_location[location_id]
2021-03-05 02:23:28 +01:00
}
async def _refresh_info(self):
import pysmartthings
async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = pysmartthings.SmartThings(session, self._access_token)
tasks = [
asyncio.ensure_future(self._refresh_locations(api)),
asyncio.ensure_future(self._refresh_devices(api)),
]
await asyncio.gather(*tasks)
room_tasks = [
asyncio.ensure_future(self._refresh_rooms(api, location.location_id))
for location in self._locations
]
await asyncio.gather(*room_tasks)
def refresh_info(self):
with self._refresh_lock:
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
loop.run_until_complete(self._refresh_info())
finally:
loop.stop()
def _location_to_dict(self, location) -> Dict:
return {
'name': location.name,
'location_id': location.location_id,
'country_code': location.country_code,
'locale': location.locale,
'latitude': location.latitude,
'longitude': location.longitude,
'temperature_scale': location.temperature_scale,
'region_radius': location.region_radius,
'timezone_id': location.timezone_id,
'rooms': {
room.room_id: self._room_to_dict(room)
for room in self._rooms_by_location.get(location.location_id, {})
},
2021-03-05 02:23:28 +01:00
}
@staticmethod
def _device_to_dict(device) -> Dict:
return {
'capabilities': device.capabilities,
'name': device.label,
'device_id': device.device_id,
'location_id': device.location_id,
'room_id': device.room_id,
'device_type_id': device.device_type_id,
'device_type_name': device.device_type_name,
'device_type_network': device.device_type_network,
}
@staticmethod
def _room_to_dict(room) -> Dict:
return {
'name': room.name,
'background_image': room.background_image,
'room_id': room.room_id,
'location_id': room.location_id,
}
@action
def info(self) -> Dict[str, Dict[str, dict]]:
"""
Return the objects registered to the account, including locations and devices.
.. code-block:: json
{
"devices": {
"smart-tv-id": {
"capabilities": [
"ocf",
"switch",
"audioVolume",
"audioMute",
"tvChannel",
"mediaInputSource",
"mediaPlayback",
"mediaTrackControl",
"custom.error",
"custom.picturemode",
"custom.soundmode",
"custom.accessibility",
"custom.launchapp",
"custom.recording",
"custom.tvsearch",
"custom.disabledCapabilities",
"samsungvd.ambient",
"samsungvd.ambientContent",
"samsungvd.ambient18",
"samsungvd.mediaInputSource",
"refresh",
"execute",
"samsungvd.firmwareVersion",
"samsungvd.supportsPowerOnByOcf"
],
"device_id": "smart-tv-id",
"device_type_id": null,
"device_type_name": null,
"device_type_network": null,
"location_id": "location-id",
"name": "Samsung Smart TV",
"room_id": "room-1"
},
"tv-switch-id": {
"capabilities": [
"switch",
"refresh",
"healthCheck"
],
"device_id": "tv-switch-id",
"device_type_id": null,
"device_type_name": null,
"device_type_network": null,
"location_id": "location-id",
"name": "TV Smart Switch",
"room_id": "room-1"
},
"lights-switch-id": {
"capabilities": [
"switch",
"refresh",
"healthCheck"
],
"device_id": "lights-switch-id",
"device_type_id": null,
"device_type_name": null,
"device_type_network": null,
"location_id": "location-id",
"name": "Lights Switch",
"room_id": "room-2"
}
},
"locations": {
"location-id": {
"name": "My home",
"location_id": "location-id",
"country_code": "us",
"locale": "en-US",
"latitude": "latitude",
"longitude": "longitude",
"temperature_scale": null,
"region_radius": null,
"timezone_id": null,
"rooms": {
"room-1": {
"background_image": null,
"location_id": "location-1",
"name": "Living Room",
"room_id": "room-1"
},
"room-2": {
"background_image": null,
"location_id": "location-1",
"name": "Bedroom",
"room_id": "room-2"
}
}
}
}
}
"""
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
},
2021-03-05 02:23:28 +01:00
}
@action
def get_location(
self, location_id: Optional[str] = None, name: Optional[str] = None
) -> dict:
2021-03-05 02:23:28 +01:00
"""
Get the info of a location by ID or name.
.. code-block:: json
{
"name": "My home",
"location_id": "location-id",
"country_code": "us",
"locale": "en-US",
"latitude": "latitude",
"longitude": "longitude",
"temperature_scale": null,
"region_radius": null,
"timezone_id": null,
"rooms": {
"room-1": {
"background_image": null,
"location_id": "location-1",
"name": "Living Room",
"room_id": "room-1"
},
"room-2": {
"background_image": null,
"location_id": "location-1",
"name": "Bedroom",
"room_id": "room-2"
}
}
}
"""
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
):
2021-03-05 02:23:28 +01:00
self.refresh_info()
location = self._locations_by_id.get(
location_id, self._locations_by_name.get(name)
)
2021-03-05 02:23:28 +01:00
assert location, 'Location {} not found'.format(location_id or name)
return self._location_to_dict(location)
def _get_device(self, device: str):
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:
2021-03-05 02:23:28 +01:00
self.refresh_info()
devs, missing_devs = get_found_and_missing_devs()
assert not missing_devs, f'Devices not found: {missing_devs}'
return devs
2021-03-05 02:23:28 +01:00
@action
def get_device(self, device: str) -> dict:
"""
Get a device info by ID or name.
:param device: Device ID or name.
:return:
.. code-block:: json
"tv-switch-id": {
"capabilities": [
"switch",
"refresh",
"healthCheck"
],
"device_id": "tv-switch-id",
"device_type_id": null,
"device_type_name": null,
"device_type_network": null,
"location_id": "location-id",
"name": "TV Smart Switch",
"room_id": "room-1"
}
"""
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],
):
2021-03-05 02:23:28 +01:00
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,
)
assert (
ret
), 'The command {capability}={command} failed on device {device}'.format(
capability=capability, command=command, device=device_id
)
2021-03-05 02:23:28 +01:00
@action
def execute(
self,
device: str,
capability: str,
command,
component_id: str = 'main',
args: Optional[list] = None,
):
2021-03-05 02:23:28 +01:00
"""
Execute a command on a device.
Example request to turn on a device with ``switch`` capability:
.. code-block:: json
{
"type": "request",
"action": "smartthings.execute",
"args": {
"device": "My Switch",
"capability": "switch",
"command": "on"
}
}
:param device: Device ID or name.
:param capability: Property to be read/written (see device ``capabilities`` returned from :meth:`.get_device`).
:param command: Command to execute on the ``capability``
(see https://smartthings.developer.samsung.com/docs/api-ref/capabilities.html).
:param component_id: ID of the component to execute the command on (default: ``main``, i.e. the device itself).
:param args: Command extra arguments, as a list.
"""
device = self._get_device(device)
with self._execute_lock:
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,
)
)
2021-03-05 02:23:28 +01:00
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 = []
2021-03-05 02:23:28 +01:00
for device in entities:
data = {
'location_id': getattr(device, 'location_id', None),
'room_id': getattr(device, 'room_id', None),
}
if self._is_light(device):
light_attrs = {
'id': device.device_id,
'name': device.label,
'data': data,
}
if 'switch' in device.capabilities:
light_attrs['on'] = device.status.switch
if getattr(device.status, 'level', None) is not None:
light_attrs['brightness'] = device.status.level
light_attrs['brightness_min'] = 0
light_attrs['brightness_max'] = 100
if 'colorTemperature' in device.capabilities:
# Color temperature range on SmartThings is expressed in Kelvin
light_attrs['temperature_min'] = 2000
light_attrs['temperature_max'] = 6500
if (
device.status.color_temperature
>= light_attrs['temperature_min']
):
light_attrs['temperature'] = (
light_attrs['temperature_max']
- light_attrs['temperature_min']
) / 2
if getattr(device.status, 'hue', None) is not None:
light_attrs['hue'] = device.status.hue
light_attrs['hue_min'] = 0
light_attrs['hue_max'] = 100
if getattr(device.status, 'saturation', None) is not None:
light_attrs['saturation'] = device.status.saturation
light_attrs['saturation_min'] = 0
light_attrs['saturation_max'] = 80
compatible_entities.append(Light(**light_attrs))
elif 'switch' in device.capabilities:
compatible_entities.append(
Switch(
id=device.device_id,
name=device.label,
state=device.status.switch,
data=data,
)
)
return super().transform_entities(compatible_entities) # type: ignore
async def _get_device_status(self, api, device_id: str) -> dict:
device = await api.device(device_id)
await device.status.refresh()
self.publish_entities([device]) # type: ignore
2021-03-05 02:23:28 +01:00
return {
'device_id': device_id,
'name': device.label,
**{
cap: getattr(device.status, cap)
for cap in device.capabilities
if hasattr(device.status, cap)
and not callable(getattr(device.status, cap))
},
2021-03-05 02:23:28 +01:00
}
async def _refresh_status(self, devices: List[str]) -> List[dict]:
import pysmartthings
device_ids = []
missing_device_ids = set()
def parse_device_id(device):
device_id = None
if device in self._devices_by_id:
device_id = device
device_ids.append(device_id)
elif device in self._devices_by_name:
device_id = self._devices_by_name[device].device_id
device_ids.append(device_id)
else:
missing_device_ids.add(device)
if device_id and device in missing_device_ids:
missing_device_ids.remove(device)
for dev in devices:
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))
2021-03-05 02:23:28 +01:00
async with aiohttp.ClientSession(timeout=self._timeout) as session:
api = pysmartthings.SmartThings(session, self._access_token)
status_tasks = [
asyncio.ensure_future(self._get_device_status(api, device_id))
for device_id in device_ids
]
# noinspection PyTypeChecker
return await asyncio.gather(*status_tasks)
@action
def status(self, device: Optional[Union[str, List[str]]] = None) -> List[dict]:
"""
Refresh and return the status of one or more devices.
:param device: Device or list of devices to refresh (default: all)
:return: A list containing on entry per device, and each entry containing the current device state. Example:
.. code-block:: json
[
{
"device_id": "switch-1",
"name": "Fan",
"switch": false
},
{
"device_id": "tv-1",
"name": "Samsung Smart TV",
"switch": true
}
]
"""
self.refresh_info()
if not device:
self.refresh_info()
devices = self._devices_by_id.keys()
elif isinstance(device, str):
devices = [device]
else:
devices = device
with self._refresh_lock:
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
return loop.run_until_complete(self._refresh_status(devices))
finally:
loop.stop()
@action
def on(self, device: str, *_, **__) -> dict:
2021-03-05 02:23:28 +01:00
"""
Turn on a device with ``switch`` capability.
:param device: Device name or ID.
2021-03-05 21:29:32 +01:00
:return: Device status
2021-03-05 02:23:28 +01:00
"""
2021-03-05 21:29:32 +01:00
self.execute(device, 'switch', 'on')
return self.status(device).output[0] # type: ignore
2021-03-05 02:23:28 +01:00
@action
def off(self, device: str, *_, **__) -> dict:
2021-03-05 02:23:28 +01:00
"""
Turn off a device with ``switch`` capability.
:param device: Device name or ID.
2021-03-05 21:29:32 +01:00
:return: Device status
2021-03-05 02:23:28 +01:00
"""
2021-03-05 21:29:32 +01:00
self.execute(device, 'switch', 'off')
return self.status(device).output[0] # type: ignore
2021-03-05 02:23:28 +01:00
@action
def toggle(self, device: str, *args, **__) -> dict:
2021-03-05 02:23:28 +01:00
"""
Toggle a device with ``switch`` capability.
:param device: Device name or ID.
2021-03-05 21:29:32 +01:00
:return: Device status
2021-03-05 02:23:28 +01:00
"""
import pysmartthings
device = self._get_device(device)
device_id = device.device_id
2021-03-05 21:29:32 +01:00
async def _toggle() -> bool:
2021-03-05 02:23:28 +01:00
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)
2021-03-05 02:23:28 +01:00
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
)
2021-03-05 02:23:28 +01:00
assert ret, 'The command switch={state} failed on device {device}'.format(
state=state, device=dev.label
)
2021-03-05 02:23:28 +01:00
with self._refresh_lock:
loop = asyncio.new_event_loop()
loop.run_until_complete(_toggle())
device = loop.run_until_complete(self._refresh_status([device_id]))[0] # type: ignore
2021-03-05 21:29:32 +01:00
return {
'id': device_id,
'name': device['name'],
'on': device['switch'],
2021-03-05 21:29:32 +01:00
}
2021-03-05 02:23:28 +01:00
@property
def switches(self) -> List[dict]:
"""
:return: List of switch devices statuses in :class:`platypush.plugins.switch.SwitchPlugin` compatible format.
Example:
.. code-block:: json
[
{
"id": "switch-1",
"name": "Fan",
"on": false
},
{
"id": "tv-1",
"name": "Samsung Smart TV",
"on": true
}
]
"""
devices = self.status().output # type: ignore
2021-03-05 02:23:28 +01:00
return [
{
'name': device['name'],
'id': device['device_id'],
'on': device['switch'],
}
for device in devices
if 'switch' in device and not self._is_light(device)
2021-03-05 02:23:28 +01:00
]
@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
2021-03-05 02:23:28 +01:00
# vim:sw=4:ts=4:et: