import asyncio import aiohttp from threading import RLock from typing import Optional, Dict, List, Union from platypush.plugins import action from platypush.plugins.switch import SwitchPlugin class SmartthingsPlugin(SwitchPlugin): """ Plugin to interact with devices and locations registered to a Samsung SmartThings account. Requires: * **pysmartthings** (``pip install pysmartthings``) """ _timeout = aiohttp.ClientTimeout(total=20.) 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 } 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): self._rooms_by_location[location_id] = await api.rooms(location_id=location_id) self._rooms_by_id.update(**{ room.room_id: room for room in self._rooms_by_location[location_id] }) self._rooms_by_location_and_id[location_id] = { room.room_id: room for room in self._rooms_by_location[location_id] } self._rooms_by_location_and_name[location_id] = { room.name: room for room in self._rooms_by_location[location_id] } 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, {}) } } @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}, } @action def get_location(self, location_id: Optional[str] = None, name: Optional[str] = None) -> dict: """ 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: self.refresh_info() location = self._locations_by_id.get(location_id, self._locations_by_name.get(name)) assert location, 'Location {} not found'.format(location_id or name) return self._location_to_dict(location) def _get_device(self, device: str): if device not in self._devices_by_id or device not in self._devices_by_name: 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 @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]): 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) @action def execute(self, device: str, capability: str, command, component_id: str = 'main', args: Optional[list] = None): """ 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)) finally: loop.stop() @staticmethod async def _get_device_status(api, device_id: str) -> dict: device = await api.device(device_id) await device.status.refresh() 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)) } } 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)) 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, *args, **kwargs) -> dict: """ Turn on a device with ``switch`` capability. :param device: Device name or ID. :return: Device status """ self.execute(device, 'switch', 'on') # noinspection PyUnresolvedReferences return self.status(device).output[0] @action def off(self, device: str, *args, **kwargs) -> dict: """ Turn off a device with ``switch`` capability. :param device: Device name or ID. :return: Device status """ self.execute(device, 'switch', 'off') # noinspection PyUnresolvedReferences return self.status(device).output[0] @action def toggle(self, device: str, *args, **kwargs) -> dict: """ Toggle a device with ``switch`` capability. :param device: Device name or ID. :return: Device status """ import pysmartthings device = self._get_device(device) device_id = device.device_id async def _toggle() -> bool: 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) 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) 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()) return { 'id': device_id, 'name': device.label, 'on': state, } @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 } ] """ # noinspection PyUnresolvedReferences devices = self.status().output return [ { 'name': device['name'], 'id': device['device_id'], 'on': device['switch'], } for device in devices if 'switch' in device ] # vim:sw=4:ts=4:et: