From 706810bbce620d19286b047e458cb952ba715259 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Thu, 7 Feb 2019 22:51:17 -0600 Subject: [PATCH] Add SmartThings Sensor platform (#20848) * Add Sensor platform and update pysmartthings 0.6.0 * Add tests for Sensor platform * Redesigned capability subscription process * Removed redundant Entity inheritance * Updated per review feedback. --- .../components/smartthings/__init__.py | 2 +- homeassistant/components/smartthings/const.py | 18 +- .../components/smartthings/sensor.py | 218 ++++++++++++++++++ .../components/smartthings/smartapp.py | 83 ++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/conftest.py | 20 +- .../smartthings/test_binary_sensor.py | 14 +- tests/components/smartthings/test_init.py | 2 +- tests/components/smartthings/test_sensor.py | 97 ++++++++ tests/components/smartthings/test_smartapp.py | 64 ++++- 11 files changed, 457 insertions(+), 65 deletions(-) create mode 100644 homeassistant/components/smartthings/sensor.py create mode 100644 tests/components/smartthings/test_sensor.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index bdbbfcf2590..b7b5436da3e 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -23,7 +23,7 @@ from .const import ( from .smartapp import ( setup_smartapp, setup_smartapp_endpoint, validate_installed_app) -REQUIREMENTS = ['pysmartapp==0.3.0', 'pysmartthings==0.5.0'] +REQUIREMENTS = ['pysmartapp==0.3.0', 'pysmartthings==0.6.0'] DEPENDENCIES = ['webhook'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 3d0e5cb95f8..9391c871b25 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -22,25 +22,9 @@ SUPPORTED_PLATFORMS = [ 'binary_sensor', 'fan', 'light', + 'sensor', 'switch' ] -SUPPORTED_CAPABILITIES = [ - 'accelerationSensor', - 'button', - 'colorControl', - 'colorTemperature', - 'contactSensor', - 'fanSpeed', - 'filterStatus', - 'motionSensor', - 'presenceSensor', - 'soundSensor', - 'switch', - 'switchLevel', - 'tamperAlert', - 'valve', - 'waterSensor' -] VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]" \ "{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$" VAL_UID_MATCHER = re.compile(VAL_UID) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py new file mode 100644 index 00000000000..5539703e77e --- /dev/null +++ b/homeassistant/components/smartthings/sensor.py @@ -0,0 +1,218 @@ +""" +Support for sensors through the SmartThings cloud API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/smartthings.sensor/ +""" +from collections import namedtuple + +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, MASS_KILOGRAMS, + TEMP_CELSIUS, TEMP_FAHRENHEIT) + +from . import SmartThingsEntity +from .const import DATA_BROKERS, DOMAIN + +DEPENDENCIES = ['smartthings'] + +Map = namedtuple("map", "attribute name default_unit device_class") + +CAPABILITY_TO_SENSORS = { + 'activityLightingMode': [ + Map('lightingMode', "Activity Lighting Mode", None, None)], + 'airConditionerMode': [ + Map('airConditionerMode', "Air Conditioner Mode", None, None)], + 'airQualitySensor': [ + Map('airQuality', "Air Quality", 'CAQI', None)], + 'alarm': [ + Map('alarm', "Alarm", None, None)], + 'audioVolume': [ + Map('volume', "Volume", "%", None)], + 'battery': [ + Map('battery', "Battery", "%", DEVICE_CLASS_BATTERY)], + 'bodyMassIndexMeasurement': [ + Map('bmiMeasurement', "Body Mass Index", "kg/m^2", None)], + 'bodyWeightMeasurement': [ + Map('bodyWeightMeasurement', "Body Weight", MASS_KILOGRAMS, None)], + 'carbonDioxideMeasurement': [ + Map('carbonDioxide', "Carbon Dioxide Measurement", "ppm", None)], + 'carbonMonoxideDetector': [ + Map('carbonMonoxide', "Carbon Monoxide Detector", None, None)], + 'carbonMonoxideMeasurement': [ + Map('carbonMonoxideLevel', "Carbon Monoxide Measurement", "ppm", + None)], + 'dishwasherOperatingState': [ + Map('machineState', "Dishwasher Machine State", None, None), + Map('dishwasherJobState', "Dishwasher Job State", None, None), + Map('completionTime', "Dishwasher Completion Time", None, + DEVICE_CLASS_TIMESTAMP)], + 'doorControl': [ + Map('door', "Door", None, None)], + 'dryerMode': [ + Map('dryerMode', "Dryer Mode", None, None)], + 'dryerOperatingState': [ + Map('machineState', "Dryer Machine State", None, None), + Map('dryerJobState', "Dryer Job State", None, None), + Map('completionTime', "Dryer Completion Time", None, + DEVICE_CLASS_TIMESTAMP)], + 'dustSensor': [ + Map('fineDustLevel', "Fine Dust Level", None, None), + Map('dustLevel', "Dust Level", None, None)], + 'energyMeter': [ + Map('energy', "Energy Meter", 'kWh', None)], + 'equivalentCarbonDioxideMeasurement': [ + Map('equivalentCarbonDioxideMeasurement', + 'Equivalent Carbon Dioxide Measurement', 'ppm', None)], + 'formaldehydeMeasurement': [ + Map('formaldehydeLevel', 'Formaldehyde Measurement', 'ppm', None)], + 'garageDoorControl': [ + Map('door', 'Garage Door', None, None)], + 'illuminanceMeasurement': [ + Map('illuminance', "Illuminance", 'lux', DEVICE_CLASS_ILLUMINANCE)], + 'infraredLevel': [ + Map('infraredLevel', "Infrared Level", '%', None)], + 'lock': [ + Map('lock', "Lock", None, None)], + 'mediaInputSource': [ + Map('inputSource', "Media Input Source", None, None)], + 'mediaPlaybackRepeat': [ + Map('playbackRepeatMode', "Media Playback Repeat", None, None)], + 'mediaPlaybackShuffle': [ + Map('playbackShuffle', "Media Playback Shuffle", None, None)], + 'mediaPlayback': [ + Map('playbackStatus', "Media Playback Status", None, None)], + 'odorSensor': [ + Map('odorLevel', "Odor Sensor", None, None)], + 'ovenMode': [ + Map('ovenMode', "Oven Mode", None, None)], + 'ovenOperatingState': [ + Map('machineState', "Oven Machine State", None, None), + Map('ovenJobState', "Oven Job State", None, None), + Map('completionTime', "Oven Completion Time", None, None)], + 'ovenSetpoint': [ + Map('ovenSetpoint', "Oven Set Point", None, None)], + 'powerMeter': [ + Map('power', "Power Meter", 'W', None)], + 'powerSource': [ + Map('powerSource', "Power Source", None, None)], + 'refrigerationSetpoint': [ + Map('refrigerationSetpoint', "Refrigeration Setpoint", TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE)], + 'relativeHumidityMeasurement': [ + Map('humidity', "Relative Humidity Measurement", '%', + DEVICE_CLASS_HUMIDITY)], + 'robotCleanerCleaningMode': [ + Map('robotCleanerCleaningMode', "Robot Cleaner Cleaning Mode", + None, None)], + 'robotCleanerMovement': [ + Map('robotCleanerMovement', "Robot Cleaner Movement", None, None)], + 'robotCleanerTurboMode': [ + Map('robotCleanerTurboMode', "Robot Cleaner Turbo Mode", None, None)], + 'signalStrength': [ + Map('lqi', "LQI Signal Strength", None, None), + Map('rssi', "RSSI Signal Strength", None, None)], + 'smokeDetector': [ + Map('smoke', "Smoke Detector", None, None)], + 'temperatureMeasurement': [ + Map('temperature', "Temperature Measurement", TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE)], + 'thermostatCoolingSetpoint': [ + Map('coolingSetpoint', "Thermostat Cooling Setpoint", TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE)], + 'thermostatFanMode': [ + Map('thermostatFanMode', "Thermostat Fan Mode", None, None)], + 'thermostatHeatingSetpoint': [ + Map('heatingSetpoint', "Thermostat Heating Setpoint", TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE)], + 'thermostatMode': [ + Map('thermostatMode', "Thermostat Mode", None, None)], + 'thermostatOperatingState': [ + Map('thermostatOperatingState', "Thermostat Operating State", + None, None)], + 'thermostatSetpoint': [ + Map('thermostatSetpoint', "Thermostat Setpoint", TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE)], + 'tvChannel': [ + Map('tvChannel', "Tv Channel", None, None)], + 'tvocMeasurement': [ + Map('tvocLevel', "Tvoc Measurement", 'ppm', None)], + 'ultravioletIndex': [ + Map('ultravioletIndex', "Ultraviolet Index", None, None)], + 'voltageMeasurement': [ + Map('voltage', "Voltage Measurement", 'V', None)], + 'washerMode': [ + Map('washerMode', "Washer Mode", None, None)], + 'washerOperatingState': [ + Map('machineState', "Washer Machine State", None, None), + Map('washerJobState', "Washer Job State", None, None), + Map('completionTime', "Washer Completion Time", None, + DEVICE_CLASS_TIMESTAMP)], + 'windowShade': [ + Map('windowShade', 'Window Shade', None, None)] +} + +UNITS = { + 'C': TEMP_CELSIUS, + 'F': TEMP_FAHRENHEIT +} + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add binary sensors for a config entry.""" + broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + sensors = [] + for device in broker.devices.values(): + for capability, maps in CAPABILITY_TO_SENSORS.items(): + if capability in device.capabilities: + sensors.extend([ + SmartThingsSensor( + device, m.attribute, m.name, m.default_unit, + m.device_class) + for m in maps]) + async_add_entities(sensors) + + +class SmartThingsSensor(SmartThingsEntity): + """Define a SmartThings Binary Sensor.""" + + def __init__(self, device, attribute: str, name: str, + default_unit: str, device_class: str): + """Init the class.""" + super().__init__(device) + self._attribute = attribute + self._name = name + self._device_class = device_class + self._default_unit = default_unit + + @property + def name(self) -> str: + """Return the name of the binary sensor.""" + return '{} {}'.format(self._device.label, self._name) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return '{}.{}'.format(self._device.device_id, self._attribute) + + @property + def state(self): + """Return the state of the sensor.""" + return self._device.status.attributes[self._attribute].value + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + unit = self._device.status.attributes[self._attribute].unit + return UNITS.get(unit, unit) if unit else self._default_unit diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 9d9dacf8460..89043d4f76c 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -22,8 +22,7 @@ from homeassistant.helpers.typing import HomeAssistantType from .const import ( APP_NAME_PREFIX, APP_OAUTH_SCOPES, CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_INSTANCE_ID, CONF_LOCATION_ID, DATA_BROKERS, DATA_MANAGER, DOMAIN, - SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX, STORAGE_KEY, STORAGE_VERSION, - SUPPORTED_CAPABILITIES) + SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX, STORAGE_KEY, STORAGE_VERSION) _LOGGER = logging.getLogger(__name__) @@ -176,6 +175,7 @@ async def setup_smartapp_endpoint(hass: HomeAssistantType): webhook.async_generate_path(config[CONF_WEBHOOK_ID]), dispatcher=dispatcher) manager.connect_install(functools.partial(smartapp_install, hass)) + manager.connect_update(functools.partial(smartapp_update, hass)) manager.connect_uninstall(functools.partial(smartapp_uninstall, hass)) webhook.async_register(hass, DOMAIN, 'SmartApp', @@ -189,6 +189,45 @@ async def setup_smartapp_endpoint(hass: HomeAssistantType): } +async def smartapp_sync_subscriptions( + hass: HomeAssistantType, auth_token: str, location_id: str, + installed_app_id: str, *, skip_delete=False): + """Synchronize subscriptions of an installed up.""" + from pysmartthings import ( + CAPABILITIES, SmartThings, SourceType, Subscription) + + api = SmartThings(async_get_clientsession(hass), auth_token) + devices = await api.devices(location_ids=[location_id]) + + # Build set of capabilities and prune unsupported ones + capabilities = set() + for device in devices: + capabilities.update(device.capabilities) + capabilities.intersection_update(CAPABILITIES) + + # Remove all (except for installs) + if not skip_delete: + await api.delete_subscriptions(installed_app_id) + + # Create for each capability + async def create_subscription(target): + sub = Subscription() + sub.installed_app_id = installed_app_id + sub.location_id = location_id + sub.source_type = SourceType.CAPABILITY + sub.capability = target + try: + await api.create_subscription(sub) + _LOGGER.debug("Created subscription for '%s' under app '%s'", + target, installed_app_id) + except Exception: # pylint:disable=broad-except + _LOGGER.exception("Failed to create subscription for '%s' under " + "app '%s'", target, installed_app_id) + + tasks = [create_subscription(c) for c in capabilities] + await asyncio.gather(*tasks) + + async def smartapp_install(hass: HomeAssistantType, req, resp, app): """ Handle when a SmartApp is installed by the user into a location. @@ -199,30 +238,9 @@ async def smartapp_install(hass: HomeAssistantType, req, resp, app): representing the installation if this is not the first installation under the account. """ - from pysmartthings import SmartThings, Subscription, SourceType - - # This access token is a temporary 'SmartApp token' that expires in 5 min - # and is used to create subscriptions only. - api = SmartThings(async_get_clientsession(hass), req.auth_token) - - async def create_subscription(target): - sub = Subscription() - sub.installed_app_id = req.installed_app_id - sub.location_id = req.location_id - sub.source_type = SourceType.CAPABILITY - sub.capability = target - try: - await api.create_subscription(sub) - _LOGGER.debug("Created subscription for '%s' under app '%s'", - target, req.installed_app_id) - except Exception: # pylint:disable=broad-except - _LOGGER.exception("Failed to create subscription for '%s' under " - "app '%s'", target, req.installed_app_id) - - tasks = [create_subscription(c) for c in SUPPORTED_CAPABILITIES] - await asyncio.gather(*tasks) - _LOGGER.debug("SmartApp '%s' under parent app '%s' was installed", - req.installed_app_id, app.app_id) + await smartapp_sync_subscriptions( + hass, req.auth_token, req.location_id, req.installed_app_id, + skip_delete=True) # The permanent access token is copied from another config flow with the # same parent app_id. If one is not found, that means the user is within @@ -244,6 +262,19 @@ async def smartapp_install(hass: HomeAssistantType, req, resp, app): }) +async def smartapp_update(hass: HomeAssistantType, req, resp, app): + """ + Handle when a SmartApp is updated (reconfigured) by the user. + + Synchronize subscriptions to ensure we're up-to-date. + """ + await smartapp_sync_subscriptions( + hass, req.auth_token, req.location_id, req.installed_app_id) + + _LOGGER.debug("SmartApp '%s' under parent app '%s' was updated", + req.installed_app_id, app.app_id) + + async def smartapp_uninstall(hass: HomeAssistantType, req, resp, app): """ Handle when a SmartApp is removed from a location by the user. diff --git a/requirements_all.txt b/requirements_all.txt index 452275bfcdc..6328e7f7135 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1231,7 +1231,7 @@ pysma==0.3.1 pysmartapp==0.3.0 # homeassistant.components.smartthings -pysmartthings==0.5.0 +pysmartthings==0.6.0 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd455ef88fd..df750d69972 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -217,7 +217,7 @@ pyqwikswitch==0.8 pysmartapp==0.3.0 # homeassistant.components.smartthings -pysmartthings==0.5.0 +pysmartthings==0.6.0 # homeassistant.components.sonos pysonos==0.0.6 diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 7358e05f346..c1a1769f04c 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -10,9 +10,10 @@ from pysmartthings.api import Api import pytest from homeassistant.components import webhook +from homeassistant.components.smartthings import DeviceBroker from homeassistant.components.smartthings.const import ( APP_NAME_PREFIX, CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_INSTANCE_ID, - CONF_LOCATION_ID, DOMAIN, SETTINGS_INSTANCE_ID, STORAGE_KEY, + CONF_LOCATION_ID, DATA_BROKERS, DOMAIN, SETTINGS_INSTANCE_ID, STORAGE_KEY, STORAGE_VERSION) from homeassistant.config_entries import ( CONN_CLASS_CLOUD_PUSH, SOURCE_USER, ConfigEntry) @@ -22,6 +23,23 @@ from homeassistant.setup import async_setup_component from tests.common import mock_coro +async def setup_platform(hass, platform: str, *devices): + """Set up the SmartThings platform and prerequisites.""" + hass.config.components.add(DOMAIN) + broker = DeviceBroker(hass, devices, '') + config_entry = ConfigEntry("1", DOMAIN, "Test", {}, + SOURCE_USER, CONN_CLASS_CLOUD_PUSH) + hass.data[DOMAIN] = { + DATA_BROKERS: { + config_entry.entry_id: broker + } + } + await hass.config_entries.async_forward_entry_setup( + config_entry, platform) + await hass.async_block_till_done() + return config_entry + + @pytest.fixture(autouse=True) async def setup_component(hass, config_file, hass_storage): """Load the SmartThing component.""" diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 92d891c06d6..4b47537fa19 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -4,12 +4,12 @@ Test for the SmartThings binary_sensor platform. The only mocking required is of the underlying SmartThings API object so real HTTP calls are not initiated during testing. """ -from pysmartthings import Attribute, Capability +from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability from homeassistant.components.binary_sensor import DEVICE_CLASSES from homeassistant.components.smartthings import DeviceBroker, binary_sensor from homeassistant.components.smartthings.const import ( - DATA_BROKERS, DOMAIN, SIGNAL_SMARTTHINGS_UPDATE, SUPPORTED_CAPABILITIES) + DATA_BROKERS, DOMAIN, SIGNAL_SMARTTHINGS_UPDATE) from homeassistant.config_entries import ( CONN_CLASS_CLOUD_PUSH, SOURCE_USER, ConfigEntry) from homeassistant.const import ATTR_FRIENDLY_NAME @@ -35,14 +35,16 @@ async def _setup_platform(hass, *devices): async def test_mapping_integrity(): """Test ensures the map dicts have proper integrity.""" - # Ensure every CAPABILITY_TO_ATTRIB key is in SUPPORTED_CAPABILITIES + # Ensure every CAPABILITY_TO_ATTRIB key is in CAPABILITIES # Ensure every CAPABILITY_TO_ATTRIB value is in ATTRIB_TO_CLASS keys for capability, attrib in binary_sensor.CAPABILITY_TO_ATTRIB.items(): - assert capability in SUPPORTED_CAPABILITIES, capability + assert capability in CAPABILITIES, capability + assert attrib in ATTRIBUTES, attrib assert attrib in binary_sensor.ATTRIB_TO_CLASS.keys(), attrib # Ensure every ATTRIB_TO_CLASS value is in DEVICE_CLASSES - for device_class in binary_sensor.ATTRIB_TO_CLASS.values(): - assert device_class in DEVICE_CLASSES + for attrib, device_class in binary_sensor.ATTRIB_TO_CLASS.items(): + assert attrib in ATTRIBUTES, attrib + assert device_class in DEVICE_CLASSES, device_class async def test_async_setup_platform(): diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 4aef42c1b6f..014cfe7da98 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -162,7 +162,7 @@ async def test_event_handler_dispatches_updated_devices( assert called for device in devices: - assert device.status.attributes['Updated'] == 'Value' + assert device.status.values['Updated'] == 'Value' async def test_event_handler_ignores_other_installed_app( diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py new file mode 100644 index 00000000000..773f157dd87 --- /dev/null +++ b/tests/components/smartthings/test_sensor.py @@ -0,0 +1,97 @@ +""" +Test for the SmartThings sensors platform. + +The only mocking required is of the underlying SmartThings API object so +real HTTP calls are not initiated during testing. +""" +from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability + +from homeassistant.components.sensor import ( + DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN) +from homeassistant.components.smartthings import sensor +from homeassistant.components.smartthings.const import ( + DOMAIN, SIGNAL_SMARTTHINGS_UPDATE) +from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .conftest import setup_platform + + +async def test_mapping_integrity(): + """Test ensures the map dicts have proper integrity.""" + for capability, maps in sensor.CAPABILITY_TO_SENSORS.items(): + assert capability in CAPABILITIES, capability + for sensor_map in maps: + assert sensor_map.attribute in ATTRIBUTES, sensor_map.attribute + if sensor_map.device_class: + assert sensor_map.device_class in DEVICE_CLASSES, \ + sensor_map.device_class + + +async def test_async_setup_platform(): + """Test setup platform does nothing (it uses config entries).""" + await sensor.async_setup_platform(None, None, None) + + +async def test_entity_state(hass, device_factory): + """Tests the state attributes properly match the light types.""" + device = device_factory('Sensor 1', [Capability.battery], + {Attribute.battery: 100}) + await setup_platform(hass, SENSOR_DOMAIN, device) + state = hass.states.get('sensor.sensor_1_battery') + assert state.state == '100' + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == '%' + assert state.attributes[ATTR_FRIENDLY_NAME] ==\ + device.label + " Battery" + + +async def test_entity_and_device_attributes(hass, device_factory): + """Test the attributes of the entity are correct.""" + # Arrange + device = device_factory('Sensor 1', [Capability.battery], + {Attribute.battery: 100}) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + device_registry = await hass.helpers.device_registry.async_get_registry() + # Act + await setup_platform(hass, SENSOR_DOMAIN, device) + # Assert + entry = entity_registry.async_get('sensor.sensor_1_battery') + assert entry + assert entry.unique_id == device.device_id + '.' + Attribute.battery + entry = device_registry.async_get_device( + {(DOMAIN, device.device_id)}, []) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == 'Unavailable' + + +async def test_update_from_signal(hass, device_factory): + """Test the binary_sensor updates when receiving a signal.""" + # Arrange + device = device_factory('Sensor 1', [Capability.battery], + {Attribute.battery: 100}) + await setup_platform(hass, SENSOR_DOMAIN, device) + device.status.apply_attribute_update( + 'main', Capability.battery, Attribute.battery, 75) + # Act + async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, + [device.device_id]) + # Assert + await hass.async_block_till_done() + state = hass.states.get('sensor.sensor_1_battery') + assert state is not None + assert state.state == '75' + + +async def test_unload_config_entry(hass, device_factory): + """Test the binary_sensor is removed when the config entry is unloaded.""" + # Arrange + device = device_factory('Sensor 1', [Capability.battery], + {Attribute.battery: 100}) + config_entry = await setup_platform(hass, SENSOR_DOMAIN, device) + # Act + await hass.config_entries.async_forward_entry_unload( + config_entry, 'sensor') + # Assert + assert not hass.states.get('sensor.sensor_1_battery') diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py index 0f517222c4a..162a8f9a4e5 100644 --- a/tests/components/smartthings/test_smartapp.py +++ b/tests/components/smartthings/test_smartapp.py @@ -2,11 +2,10 @@ from unittest.mock import Mock, patch from uuid import uuid4 -from pysmartthings import AppEntity +from pysmartthings import AppEntity, Capability from homeassistant.components.smartthings import smartapp -from homeassistant.components.smartthings.const import ( - DATA_MANAGER, DOMAIN, SUPPORTED_CAPABILITIES) +from homeassistant.components.smartthings.const import DATA_MANAGER, DOMAIN from tests.common import mock_coro @@ -36,8 +35,10 @@ async def test_update_app_updated_needed(hass, app): assert mock_app.classifications == app.classifications -async def test_smartapp_install_abort_if_no_other(hass, smartthings_mock): +async def test_smartapp_install_abort_if_no_other( + hass, smartthings_mock, device_factory): """Test aborts if no other app was configured already.""" + # Arrange api = smartthings_mock.return_value api.create_subscription.return_value = mock_coro() app = Mock() @@ -46,17 +47,23 @@ async def test_smartapp_install_abort_if_no_other(hass, smartthings_mock): request.installed_app_id = uuid4() request.auth_token = uuid4() request.location_id = uuid4() - + devices = [ + device_factory('', [Capability.battery, 'ping']), + device_factory('', [Capability.switch, Capability.switch_level]), + device_factory('', [Capability.switch]) + ] + api.devices = Mock() + api.devices.return_value = mock_coro(return_value=devices) + # Act await smartapp.smartapp_install(hass, request, None, app) - + # Assert entries = hass.config_entries.async_entries('smartthings') assert not entries - assert api.create_subscription.call_count == \ - len(SUPPORTED_CAPABILITIES) + assert api.create_subscription.call_count == 3 async def test_smartapp_install_creates_flow( - hass, smartthings_mock, config_entry, location): + hass, smartthings_mock, config_entry, location, device_factory): """Test installation creates flow.""" # Arrange setattr(hass.config_entries, '_entries', [config_entry]) @@ -68,14 +75,20 @@ async def test_smartapp_install_creates_flow( request.installed_app_id = str(uuid4()) request.auth_token = str(uuid4()) request.location_id = location.location_id + devices = [ + device_factory('', [Capability.battery, 'ping']), + device_factory('', [Capability.switch, Capability.switch_level]), + device_factory('', [Capability.switch]) + ] + api.devices = Mock() + api.devices.return_value = mock_coro(return_value=devices) # Act await smartapp.smartapp_install(hass, request, None, app) # Assert await hass.async_block_till_done() entries = hass.config_entries.async_entries('smartthings') assert len(entries) == 2 - assert api.create_subscription.call_count == \ - len(SUPPORTED_CAPABILITIES) + assert api.create_subscription.call_count == 3 assert entries[1].data['app_id'] == app.app_id assert entries[1].data['installed_app_id'] == request.installed_app_id assert entries[1].data['location_id'] == request.location_id @@ -84,6 +97,35 @@ async def test_smartapp_install_creates_flow( assert entries[1].title == location.name +async def test_smartapp_update_syncs_subs( + hass, smartthings_mock, config_entry, location, device_factory): + """Test update synchronizes subscriptions.""" + # Arrange + setattr(hass.config_entries, '_entries', [config_entry]) + app = Mock() + app.app_id = config_entry.data['app_id'] + api = smartthings_mock.return_value + api.delete_subscriptions = Mock() + api.delete_subscriptions.return_value = mock_coro() + api.create_subscription.return_value = mock_coro() + request = Mock() + request.installed_app_id = str(uuid4()) + request.auth_token = str(uuid4()) + request.location_id = location.location_id + devices = [ + device_factory('', [Capability.battery, 'ping']), + device_factory('', [Capability.switch, Capability.switch_level]), + device_factory('', [Capability.switch]) + ] + api.devices = Mock() + api.devices.return_value = mock_coro(return_value=devices) + # Act + await smartapp.smartapp_update(hass, request, None, app) + # Assert + assert api.create_subscription.call_count == 3 + assert api.delete_subscriptions.call_count == 1 + + async def test_smartapp_uninstall(hass, config_entry): """Test the config entry is unloaded when the app is uninstalled.""" setattr(hass.config_entries, '_entries', [config_entry])