From 9986df358a9a0b671c8ecaddfb0009bd7665e730 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 17 Apr 2019 10:32:59 -0700 Subject: [PATCH 01/79] Bumped version to 0.92.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e4f1ac95af4..d8ee3e00cc4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 92 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0b0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From d0c3a8ecaf7af021a7422f9aea34fd3d162bba31 Mon Sep 17 00:00:00 2001 From: Richard Mitchell Date: Wed, 17 Apr 2019 21:48:17 +0100 Subject: [PATCH 02/79] Kill bluetooth LE scanning gracefully when asked to shut down. (#22586) * Kill bluetooth LE scanning gracefully when asked to shut down. * Add missing argument. * Refactor to use data instead of passing nonlocal variables about. * Fix typo. --- .../bluetooth_le_tracker/device_tracker.py | 15 +++++++++++++++ .../components/bluetooth_le_tracker/manifest.json | 2 +- homeassistant/components/skybeacon/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index f1aab4e1fd5..f24b943f188 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -6,10 +6,13 @@ from homeassistant.components.device_tracker import ( YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, load_config, SOURCE_TYPE_BLUETOOTH_LE ) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) +DATA_BLE = 'BLE' +DATA_BLE_ADAPTER = 'ADAPTER' BLE_PREFIX = 'BLE_' MIN_SEEN_NEW = 5 @@ -19,6 +22,17 @@ def setup_scanner(hass, config, see, discovery_info=None): # pylint: disable=import-error import pygatt new_devices = {} + hass.data.setdefault(DATA_BLE, {DATA_BLE_ADAPTER: None}) + + async def async_stop(event): + """Try to shut down the bluetooth child process nicely.""" + # These should never be unset at the point this runs, but just for + # safety's sake, use `get`. + adapter = hass.data.get(DATA_BLE, {}).get(DATA_BLE_ADAPTER) + if adapter is not None: + adapter.kill() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) def see_device(address, name, new_device=False): """Mark a device as seen.""" @@ -48,6 +62,7 @@ def setup_scanner(hass, config, see, discovery_info=None): _LOGGER.debug("Discovering Bluetooth LE devices") try: adapter = pygatt.GATTToolBackend() + hass.data[DATA_BLE][DATA_BLE_ADAPTER] = adapter devs = adapter.scan() devices = {x['address']: x['name'] for x in devs} diff --git a/homeassistant/components/bluetooth_le_tracker/manifest.json b/homeassistant/components/bluetooth_le_tracker/manifest.json index cd67ec31536..d2f8f10290e 100644 --- a/homeassistant/components/bluetooth_le_tracker/manifest.json +++ b/homeassistant/components/bluetooth_le_tracker/manifest.json @@ -3,7 +3,7 @@ "name": "Bluetooth le tracker", "documentation": "https://www.home-assistant.io/components/bluetooth_le_tracker", "requirements": [ - "pygatt[GATTTOOL]==3.2.0" + "pygatt[GATTTOOL]==4.0.1" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/skybeacon/manifest.json b/homeassistant/components/skybeacon/manifest.json index 8d2f758aed2..893a1f3469e 100644 --- a/homeassistant/components/skybeacon/manifest.json +++ b/homeassistant/components/skybeacon/manifest.json @@ -3,7 +3,7 @@ "name": "Skybeacon", "documentation": "https://www.home-assistant.io/components/skybeacon", "requirements": [ - "pygatt[GATTTOOL]==3.2.0" + "pygatt[GATTTOOL]==4.0.1" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index b8b81d90017..b861e8e2a49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1061,7 +1061,7 @@ pyfttt==0.3 # homeassistant.components.bluetooth_le_tracker # homeassistant.components.skybeacon -pygatt[GATTTOOL]==3.2.0 +pygatt[GATTTOOL]==4.0.1 # homeassistant.components.gogogate2 pygogogate2==0.1.1 From dc2cb62265440a189b872f50665c786aa5ae74f5 Mon Sep 17 00:00:00 2001 From: Richard Mitchell Date: Thu, 18 Apr 2019 06:13:03 +0100 Subject: [PATCH 03/79] Add basic support for native Hue sensors (#22598) * Add basic support for native Hue sensors * Update coveragerc * Simplify attributes * Remove config option * Refactor and document device-ness and update mechanism * Entity docstrings * Remove lingering config for sensors * Whitespace * Remove redundant entity ID generation and hass assignment. * More meaningful variable name. * Add new 'not-darkness' pseudo-sensor. * Refactor sensors into separate binary, non-binary, and shared modules. * formatting * make linter happy. * Refactor again, fix update mechanism, and address comments. * Remove unnecessary assignment * Small fixes. * docstring * Another refactor: only call API once and make testing easier * Tests & test fixes * Flake & lint * Use gather and dispatcher * Remove unnecessary whitespace change. * Move component related stuff out of the shared module * Remove unused remnant of failed approach. * Increase test coverage * Don't get too upset if we're already trying to update an entity before it has finished adding * relative imports --- homeassistant/components/hue/binary_sensor.py | 27 + homeassistant/components/hue/bridge.py | 16 +- homeassistant/components/hue/sensor.py | 57 ++ homeassistant/components/hue/sensor_base.py | 283 ++++++++++ tests/components/hue/test_bridge.py | 14 +- tests/components/hue/test_sensor_base.py | 485 ++++++++++++++++++ 6 files changed, 875 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/hue/binary_sensor.py create mode 100644 homeassistant/components/hue/sensor.py create mode 100644 homeassistant/components/hue/sensor_base.py create mode 100644 tests/components/hue/test_sensor_base.py diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py new file mode 100644 index 00000000000..d60750721ac --- /dev/null +++ b/homeassistant/components/hue/binary_sensor.py @@ -0,0 +1,27 @@ +"""Hue binary sensor entities.""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.hue.sensor_base import ( + GenericZLLSensor, async_setup_entry as shared_async_setup_entry) + + +PRESENCE_NAME_FORMAT = "{} presence" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer binary sensor setup to the shared sensor module.""" + await shared_async_setup_entry( + hass, config_entry, async_add_entities, binary=True) + + +class HuePresence(GenericZLLSensor, BinarySensorDevice): + """The presence sensor entity for a Hue motion sensor device.""" + + device_class = 'presence' + + async def _async_update_ha_state(self, *args, **kwargs): + await self.async_update_ha_state(self, *args, **kwargs) + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.sensor.presence diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 9e99d219316..25db031e6bf 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -69,6 +69,10 @@ class HueBridge: hass.async_create_task(hass.config_entries.async_forward_entry_setup( self.config_entry, 'light')) + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + self.config_entry, 'binary_sensor')) + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + self.config_entry, 'sensor')) hass.services.async_register( DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene, @@ -94,8 +98,16 @@ class HueBridge: # If setup was successful, we set api variable, forwarded entry and # register service - return await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, 'light') + results = await asyncio.gather( + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'light'), + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'binary_sensor'), + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'sensor') + ) + # None and True are OK + return False not in results async def hue_activate_scene(self, call, updated=False): """Service to call directly into bridge to set scenes.""" diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py new file mode 100644 index 00000000000..555c16a0be7 --- /dev/null +++ b/homeassistant/components/hue/sensor.py @@ -0,0 +1,57 @@ +"""Hue sensor entities.""" +from homeassistant.const import ( + DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) +from homeassistant.helpers.entity import Entity +from homeassistant.components.hue.sensor_base import ( + GenericZLLSensor, async_setup_entry as shared_async_setup_entry) + + +LIGHT_LEVEL_NAME_FORMAT = "{} light level" +TEMPERATURE_NAME_FORMAT = "{} temperature" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + await shared_async_setup_entry( + hass, config_entry, async_add_entities, binary=False) + + +class GenericHueGaugeSensorEntity(GenericZLLSensor, Entity): + """Parent class for all 'gauge' Hue device sensors.""" + + async def _async_update_ha_state(self, *args, **kwargs): + await self.async_update_ha_state(self, *args, **kwargs) + + +class HueLightLevel(GenericHueGaugeSensorEntity): + """The light level sensor entity for a Hue motion sensor device.""" + + device_class = DEVICE_CLASS_ILLUMINANCE + unit_of_measurement = "Lux" + + @property + def state(self): + """Return the state of the device.""" + return self.sensor.lightlevel + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = super().device_state_attributes + attributes.update({ + "threshold_dark": self.sensor.tholddark, + "threshold_offset": self.sensor.tholdoffset, + }) + return attributes + + +class HueTemperature(GenericHueGaugeSensorEntity): + """The temperature sensor entity for a Hue motion sensor device.""" + + device_class = DEVICE_CLASS_TEMPERATURE + unit_of_measurement = TEMP_CELSIUS + + @property + def state(self): + """Return the state of the device.""" + return self.sensor.temperature / 100 diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py new file mode 100644 index 00000000000..1d6fa2d34b4 --- /dev/null +++ b/homeassistant/components/hue/sensor_base.py @@ -0,0 +1,283 @@ +"""Support for the Philips Hue sensors as a platform.""" +import asyncio +from datetime import timedelta +import logging +from time import monotonic + +import async_timeout + +from homeassistant.components import hue +from homeassistant.exceptions import NoEntitySpecifiedError +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow + + +CURRENT_SENSORS = 'current_sensors' +SENSOR_MANAGER = 'sensor_manager' + +_LOGGER = logging.getLogger(__name__) + + +def _device_id(aiohue_sensor): + # Work out the shared device ID, as described below + device_id = aiohue_sensor.uniqueid + if device_id and len(device_id) > 23: + device_id = device_id[:23] + return device_id + + +async def async_setup_entry(hass, config_entry, async_add_entities, + binary=False): + """Set up the Hue sensors from a config entry.""" + bridge = hass.data[hue.DOMAIN][config_entry.data['host']] + hass.data[hue.DOMAIN].setdefault(CURRENT_SENSORS, {}) + + manager = hass.data[hue.DOMAIN].get(SENSOR_MANAGER) + if manager is None: + manager = SensorManager(hass, bridge) + hass.data[hue.DOMAIN][SENSOR_MANAGER] = manager + + manager.register_component(binary, async_add_entities) + await manager.start() + + +class SensorManager: + """Class that handles registering and updating Hue sensor entities. + + Intended to be a singleton. + """ + + SCAN_INTERVAL = timedelta(seconds=5) + sensor_config_map = {} + + def __init__(self, hass, bridge): + """Initialize the sensor manager.""" + import aiohue + from .binary_sensor import HuePresence, PRESENCE_NAME_FORMAT + from .sensor import ( + HueLightLevel, HueTemperature, LIGHT_LEVEL_NAME_FORMAT, + TEMPERATURE_NAME_FORMAT) + + self.hass = hass + self.bridge = bridge + self._component_add_entities = {} + self._started = False + + self.sensor_config_map.update({ + aiohue.sensors.TYPE_ZLL_LIGHTLEVEL: { + "binary": False, + "name_format": LIGHT_LEVEL_NAME_FORMAT, + "class": HueLightLevel, + }, + aiohue.sensors.TYPE_ZLL_TEMPERATURE: { + "binary": False, + "name_format": TEMPERATURE_NAME_FORMAT, + "class": HueTemperature, + }, + aiohue.sensors.TYPE_ZLL_PRESENCE: { + "binary": True, + "name_format": PRESENCE_NAME_FORMAT, + "class": HuePresence, + }, + }) + + def register_component(self, binary, async_add_entities): + """Register async_add_entities methods for components.""" + self._component_add_entities[binary] = async_add_entities + + async def start(self): + """Start updating sensors from the bridge on a schedule.""" + # but only if it's not already started, and when we've got both + # async_add_entities methods + if self._started or len(self._component_add_entities) < 2: + return + + self._started = True + _LOGGER.info('Starting sensor polling loop with %s second interval', + self.SCAN_INTERVAL.total_seconds()) + + async def async_update_bridge(now): + """Will update sensors from the bridge.""" + await self.async_update_items() + + async_track_point_in_utc_time( + self.hass, async_update_bridge, utcnow() + self.SCAN_INTERVAL) + + await async_update_bridge(None) + + async def async_update_items(self): + """Update sensors from the bridge.""" + import aiohue + + api = self.bridge.api.sensors + + try: + start = monotonic() + with async_timeout.timeout(4): + await api.update() + except (asyncio.TimeoutError, aiohue.AiohueException) as err: + _LOGGER.debug('Failed to fetch sensor: %s', err) + + if not self.bridge.available: + return + + _LOGGER.error('Unable to reach bridge %s (%s)', self.bridge.host, + err) + self.bridge.available = False + + return + + finally: + _LOGGER.debug('Finished sensor request in %.3f seconds', + monotonic() - start) + + if not self.bridge.available: + _LOGGER.info('Reconnected to bridge %s', self.bridge.host) + self.bridge.available = True + + new_sensors = [] + new_binary_sensors = [] + primary_sensor_devices = {} + current = self.hass.data[hue.DOMAIN][CURRENT_SENSORS] + + # Physical Hue motion sensors present as three sensors in the API: a + # presence sensor, a temperature sensor, and a light level sensor. Of + # these, only the presence sensor is assigned the user-friendly name + # that the user has given to the device. Each of these sensors is + # linked by a common device_id, which is the first twenty-three + # characters of the unique id (then followed by a hyphen and an ID + # specific to the individual sensor). + # + # To set up neat values, and assign the sensor entities to the same + # device, we first, iterate over all the sensors and find the Hue + # presence sensors, then iterate over all the remaining sensors - + # finding the remaining ones that may or may not be related to the + # presence sensors. + for item_id in api: + if api[item_id].type != aiohue.sensors.TYPE_ZLL_PRESENCE: + continue + + primary_sensor_devices[_device_id(api[item_id])] = api[item_id] + + # Iterate again now we have all the presence sensors, and add the + # related sensors with nice names where appropriate. + for item_id in api: + existing = current.get(api[item_id].uniqueid) + if existing is not None: + self.hass.async_create_task( + existing.async_maybe_update_ha_state()) + continue + + primary_sensor = None + sensor_config = self.sensor_config_map.get(api[item_id].type) + if sensor_config is None: + continue + + base_name = api[item_id].name + primary_sensor = primary_sensor_devices.get( + _device_id(api[item_id])) + if primary_sensor is not None: + base_name = primary_sensor.name + name = sensor_config["name_format"].format(base_name) + + current[api[item_id].uniqueid] = sensor_config["class"]( + api[item_id], name, self.bridge, primary_sensor=primary_sensor) + if sensor_config['binary']: + new_binary_sensors.append(current[api[item_id].uniqueid]) + else: + new_sensors.append(current[api[item_id].uniqueid]) + + async_add_sensor_entities = self._component_add_entities.get(False) + async_add_binary_entities = self._component_add_entities.get(True) + if new_sensors and async_add_sensor_entities: + async_add_sensor_entities(new_sensors) + if new_binary_sensors and async_add_binary_entities: + async_add_binary_entities(new_binary_sensors) + + +class GenericHueSensor: + """Representation of a Hue sensor.""" + + should_poll = False + + def __init__(self, sensor, name, bridge, primary_sensor=None): + """Initialize the sensor.""" + self.sensor = sensor + self._name = name + self._primary_sensor = primary_sensor + self.bridge = bridge + + async def _async_update_ha_state(self, *args, **kwargs): + raise NotImplementedError + + @property + def primary_sensor(self): + """Return the primary sensor entity of the physical device.""" + return self._primary_sensor or self.sensor + + @property + def device_id(self): + """Return the ID of the physical device this sensor is part of.""" + return self.unique_id[:23] + + @property + def unique_id(self): + """Return the ID of this Hue sensor.""" + return self.sensor.uniqueid + + @property + def name(self): + """Return a friendly name for the sensor.""" + return self._name + + @property + def available(self): + """Return if sensor is available.""" + return self.bridge.available and (self.bridge.allow_unreachable or + self.sensor.config['reachable']) + + @property + def swupdatestate(self): + """Return detail of available software updates for this device.""" + return self.primary_sensor.raw.get('swupdate', {}).get('state') + + async def async_maybe_update_ha_state(self): + """Try to update Home Assistant with current state of entity. + + But if it's not been added to hass yet, then don't throw an error. + """ + try: + await self._async_update_ha_state() + except (RuntimeError, NoEntitySpecifiedError): + _LOGGER.debug( + "Hue sensor update requested before it has been added.") + + @property + def device_info(self): + """Return the device info. + + Links individual entities together in the hass device registry. + """ + return { + 'identifiers': { + (hue.DOMAIN, self.device_id) + }, + 'name': self.primary_sensor.name, + 'manufacturer': self.primary_sensor.manufacturername, + 'model': ( + self.primary_sensor.productname or + self.primary_sensor.modelid), + 'sw_version': self.primary_sensor.swversion, + 'via_hub': (hue.DOMAIN, self.bridge.api.config.bridgeid), + } + + +class GenericZLLSensor(GenericHueSensor): + """Representation of a Hue-brand, physical sensor.""" + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return { + "battery_level": self.sensor.battery + } diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index 855a12e2620..5b383afc53d 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -21,9 +21,13 @@ async def test_bridge_setup(): assert await hue_bridge.async_setup() is True assert hue_bridge.api is api - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 - assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ - (entry, 'light') + forward_entries = set( + c[1][1] + for c in + hass.config_entries.async_forward_entry_setup.mock_calls + ) + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 3 + assert forward_entries == set(['light', 'binary_sensor', 'sensor']) async def test_bridge_setup_invalid_username(): @@ -84,11 +88,11 @@ async def test_reset_unloads_entry_if_setup(): assert await hue_bridge.async_setup() is True assert len(hass.services.async_register.mock_calls) == 1 - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 3 hass.config_entries.async_forward_entry_unload.return_value = \ mock_coro(True) assert await hue_bridge.async_reset() - assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1 + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 3 assert len(hass.services.async_remove.mock_calls) == 1 diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py new file mode 100644 index 00000000000..99829c59666 --- /dev/null +++ b/tests/components/hue/test_sensor_base.py @@ -0,0 +1,485 @@ +"""Philips Hue sensors platform tests.""" +import asyncio +from collections import deque +import datetime +import logging +from unittest.mock import Mock + +import aiohue +from aiohue.sensors import Sensors +import pytest + +from homeassistant import config_entries +from homeassistant.components import hue +from homeassistant.components.hue import sensor_base as hue_sensor_base + +_LOGGER = logging.getLogger(__name__) + +PRESENCE_SENSOR_1_PRESENT = { + "state": { + "presence": True, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T00:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "sensitivity": 2, + "sensitivitymax": 2, + "pending": [] + }, + "name": "Living room sensor", + "type": "ZLLPresence", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue motion sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:77-02-0406", + "capabilities": { + "certified": True + } +} +LIGHT_LEVEL_SENSOR_1 = { + "state": { + "lightlevel": 0, + "dark": True, + "daylight": True, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T00:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "tholddark": 12467, + "tholdoffset": 7000, + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue ambient light sensor 1", + "type": "ZLLLightLevel", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue ambient light sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:77-02-0400", + "capabilities": { + "certified": True + } +} +TEMPERATURE_SENSOR_1 = { + "state": { + "temperature": 1775, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T01:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue temperature sensor 1", + "type": "ZLLTemperature", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue temperature sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:77-02-0402", + "capabilities": { + "certified": True + } +} +PRESENCE_SENSOR_2_NOT_PRESENT = { + "state": { + "presence": False, + "lastupdated": "2019-01-01T00:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T01:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "sensitivity": 2, + "sensitivitymax": 2, + "pending": [] + }, + "name": "Kitchen sensor", + "type": "ZLLPresence", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue motion sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:88-02-0406", + "capabilities": { + "certified": True + } +} +LIGHT_LEVEL_SENSOR_2 = { + "state": { + "lightlevel": 100, + "dark": True, + "daylight": True, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T00:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "tholddark": 12467, + "tholdoffset": 7000, + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue ambient light sensor 2", + "type": "ZLLLightLevel", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue ambient light sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:88-02-0400", + "capabilities": { + "certified": True + } +} +TEMPERATURE_SENSOR_2 = { + "state": { + "temperature": 1875, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T01:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue temperature sensor 2", + "type": "ZLLTemperature", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue temperature sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:88-02-0402", + "capabilities": { + "certified": True + } +} +PRESENCE_SENSOR_3_PRESENT = { + "state": { + "presence": True, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T00:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "sensitivity": 2, + "sensitivitymax": 2, + "pending": [] + }, + "name": "Bedroom sensor", + "type": "ZLLPresence", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue motion sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:99-02-0406", + "capabilities": { + "certified": True + } +} +LIGHT_LEVEL_SENSOR_3 = { + "state": { + "lightlevel": 0, + "dark": True, + "daylight": True, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T00:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "tholddark": 12467, + "tholdoffset": 7000, + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue ambient light sensor 3", + "type": "ZLLLightLevel", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue ambient light sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:99-02-0400", + "capabilities": { + "certified": True + } +} +TEMPERATURE_SENSOR_3 = { + "state": { + "temperature": 1775, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T01:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue temperature sensor 3", + "type": "ZLLTemperature", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue temperature sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:99-02-0402", + "capabilities": { + "certified": True + } +} +UNSUPPORTED_SENSOR = { + "state": { + "status": 0, + "lastupdated": "2019-01-01T01:00:00" + }, + "config": { + "on": True, + "reachable": True + }, + "name": "Unsupported sensor", + "type": "CLIPGenericStatus", + "modelid": "PHWA01", + "manufacturername": "Philips", + "swversion": "1.0", + "uniqueid": "arbitrary", + "recycle": True +} +SENSOR_RESPONSE = { + "1": PRESENCE_SENSOR_1_PRESENT, + "2": LIGHT_LEVEL_SENSOR_1, + "3": TEMPERATURE_SENSOR_1, + "4": PRESENCE_SENSOR_2_NOT_PRESENT, + "5": LIGHT_LEVEL_SENSOR_2, + "6": TEMPERATURE_SENSOR_2, +} + + +@pytest.fixture +def mock_bridge(hass): + """Mock a Hue bridge.""" + bridge = Mock( + available=True, + allow_unreachable=False, + allow_groups=False, + api=Mock(), + spec=hue.HueBridge + ) + bridge.mock_requests = [] + # We're using a deque so we can schedule multiple responses + # and also means that `popleft()` will blow up if we get more updates + # than expected. + bridge.mock_sensor_responses = deque() + + async def mock_request(method, path, **kwargs): + kwargs['method'] = method + kwargs['path'] = path + bridge.mock_requests.append(kwargs) + + if path == 'sensors': + return bridge.mock_sensor_responses.popleft() + return None + + bridge.api.config.apiversion = '9.9.9' + bridge.api.sensors = Sensors({}, mock_request) + + return bridge + + +@pytest.fixture +def increase_scan_interval(hass): + """Increase the SCAN_INTERVAL to prevent unexpected scans during tests.""" + hue_sensor_base.SensorManager.SCAN_INTERVAL = datetime.timedelta(days=365) + + +async def setup_bridge(hass, mock_bridge): + """Load the Hue platform with the provided bridge.""" + hass.config.components.add(hue.DOMAIN) + hass.data[hue.DOMAIN] = {'mock-host': mock_bridge} + config_entry = config_entries.ConfigEntry(1, hue.DOMAIN, 'Mock Title', { + 'host': 'mock-host' + }, 'test', config_entries.CONN_CLASS_LOCAL_POLL) + await hass.config_entries.async_forward_entry_setup( + config_entry, 'binary_sensor') + await hass.config_entries.async_forward_entry_setup( + config_entry, 'sensor') + # and make sure it completes before going further + await hass.async_block_till_done() + + +async def test_no_sensors(hass, mock_bridge): + """Test the update_items function when no sensors are found.""" + mock_bridge.allow_groups = True + mock_bridge.mock_sensor_responses.append({}) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 0 + + +async def test_sensors(hass, mock_bridge): + """Test the update_items function with some sensors.""" + mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + # 2 "physical" sensors with 3 virtual sensors each + assert len(hass.states.async_all()) == 6 + + presence_sensor_1 = hass.states.get( + 'binary_sensor.living_room_sensor_presence') + light_level_sensor_1 = hass.states.get( + 'sensor.living_room_sensor_light_level') + temperature_sensor_1 = hass.states.get( + 'sensor.living_room_sensor_temperature') + assert presence_sensor_1 is not None + assert presence_sensor_1.state == 'on' + assert light_level_sensor_1 is not None + assert light_level_sensor_1.state == '0' + assert light_level_sensor_1.name == 'Living room sensor light level' + assert temperature_sensor_1 is not None + assert temperature_sensor_1.state == '17.75' + assert temperature_sensor_1.name == 'Living room sensor temperature' + + presence_sensor_2 = hass.states.get( + 'binary_sensor.kitchen_sensor_presence') + light_level_sensor_2 = hass.states.get( + 'sensor.kitchen_sensor_light_level') + temperature_sensor_2 = hass.states.get( + 'sensor.kitchen_sensor_temperature') + assert presence_sensor_2 is not None + assert presence_sensor_2.state == 'off' + assert light_level_sensor_2 is not None + assert light_level_sensor_2.state == '100' + assert light_level_sensor_2.name == 'Kitchen sensor light level' + assert temperature_sensor_2 is not None + assert temperature_sensor_2.state == '18.75' + assert temperature_sensor_2.name == 'Kitchen sensor temperature' + + +async def test_unsupported_sensors(hass, mock_bridge): + """Test that unsupported sensors don't get added and don't fail.""" + response_with_unsupported = dict(SENSOR_RESPONSE) + response_with_unsupported['7'] = UNSUPPORTED_SENSOR + mock_bridge.mock_sensor_responses.append(response_with_unsupported) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + # 2 "physical" sensors with 3 virtual sensors each + assert len(hass.states.async_all()) == 6 + + +async def test_new_sensor_discovered(hass, mock_bridge): + """Test if 2nd update has a new sensor.""" + mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 6 + + new_sensor_response = dict(SENSOR_RESPONSE) + new_sensor_response.update({ + "7": PRESENCE_SENSOR_3_PRESENT, + "8": LIGHT_LEVEL_SENSOR_3, + "9": TEMPERATURE_SENSOR_3, + }) + + mock_bridge.mock_sensor_responses.append(new_sensor_response) + + # Force updates to run again + sm = hass.data[hue.DOMAIN][hue_sensor_base.SENSOR_MANAGER] + await sm.async_update_items() + + # To flush out the service call to update the group + await hass.async_block_till_done() + + assert len(mock_bridge.mock_requests) == 2 + assert len(hass.states.async_all()) == 9 + + presence = hass.states.get('binary_sensor.bedroom_sensor_presence') + assert presence is not None + assert presence.state == 'on' + temperature = hass.states.get('sensor.bedroom_sensor_temperature') + assert temperature is not None + assert temperature.state == '17.75' + + +async def test_update_timeout(hass, mock_bridge): + """Test bridge marked as not available if timeout error during update.""" + mock_bridge.api.sensors.update = Mock(side_effect=asyncio.TimeoutError) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 0 + assert len(hass.states.async_all()) == 0 + assert mock_bridge.available is False + + +async def test_update_unauthorized(hass, mock_bridge): + """Test bridge marked as not available if unauthorized during update.""" + mock_bridge.api.sensors.update = Mock(side_effect=aiohue.Unauthorized) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 0 + assert len(hass.states.async_all()) == 0 + assert mock_bridge.available is False From c508d5905bb817bdc1ab117b3aae69bca53791aa Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 18 Apr 2019 07:37:39 +0200 Subject: [PATCH 04/79] Google assistant skip missing type (#23174) * Skip entity if no device type found * Add test for potentially skipped binary sensors * Reorg code, add tests to ensure all exposed things have types * Lint * Fix tests * Lint --- homeassistant/components/cloud/http_api.py | 5 +- .../components/google_assistant/const.py | 46 ++++ .../components/google_assistant/error.py | 13 + .../components/google_assistant/helpers.py | 195 ++++++++++++++- .../components/google_assistant/smart_home.py | 229 +----------------- .../components/google_assistant/trait.py | 2 +- tests/components/cloud/test_http_api.py | 2 +- .../google_assistant/test_smart_home.py | 6 +- .../components/google_assistant/test_trait.py | 27 +++ 9 files changed, 285 insertions(+), 240 deletions(-) create mode 100644 homeassistant/components/google_assistant/error.py diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index fe13172d7fe..6ab7d911d47 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -14,7 +14,8 @@ from homeassistant.components.http.data_validator import ( RequestDataValidator) from homeassistant.components import websocket_api from homeassistant.components.alexa import smart_home as alexa_sh -from homeassistant.components.google_assistant import smart_home as google_sh +from homeassistant.components.google_assistant import ( + const as google_const) from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, @@ -415,7 +416,7 @@ def _account_data(cloud): 'cloud': cloud.iot.state, 'prefs': client.prefs.as_dict(), 'google_entities': client.google_user_config['filter'].config, - 'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES), + 'google_domains': list(google_const.DOMAIN_TO_GOOGLE_TYPES), 'alexa_entities': client.alexa_config.should_expose.config, 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), 'remote_domain': remote.instance_domain, diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 477e67ab75a..67c767c080b 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -1,4 +1,20 @@ """Constants for Google Assistant.""" +from homeassistant.components import ( + binary_sensor, + camera, + climate, + cover, + fan, + group, + input_boolean, + light, + lock, + media_player, + scene, + script, + switch, + vacuum, +) DOMAIN = 'google_assistant' GOOGLE_ASSISTANT_API_ENDPOINT = '/api/google_assistant' @@ -32,6 +48,7 @@ TYPE_LOCK = PREFIX_TYPES + 'LOCK' TYPE_BLINDS = PREFIX_TYPES + 'BLINDS' TYPE_GARAGE = PREFIX_TYPES + 'GARAGE' TYPE_OUTLET = PREFIX_TYPES + 'OUTLET' +TYPE_SENSOR = PREFIX_TYPES + 'SENSOR' SERVICE_REQUEST_SYNC = 'request_sync' HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' @@ -51,3 +68,32 @@ ERR_FUNCTION_NOT_SUPPORTED = 'functionNotSupported' EVENT_COMMAND_RECEIVED = 'google_assistant_command' EVENT_QUERY_RECEIVED = 'google_assistant_query' EVENT_SYNC_RECEIVED = 'google_assistant_sync' + +DOMAIN_TO_GOOGLE_TYPES = { + camera.DOMAIN: TYPE_CAMERA, + climate.DOMAIN: TYPE_THERMOSTAT, + cover.DOMAIN: TYPE_BLINDS, + fan.DOMAIN: TYPE_FAN, + group.DOMAIN: TYPE_SWITCH, + input_boolean.DOMAIN: TYPE_SWITCH, + light.DOMAIN: TYPE_LIGHT, + lock.DOMAIN: TYPE_LOCK, + media_player.DOMAIN: TYPE_SWITCH, + scene.DOMAIN: TYPE_SCENE, + script.DOMAIN: TYPE_SCENE, + switch.DOMAIN: TYPE_SWITCH, + vacuum.DOMAIN: TYPE_VACUUM, +} + +DEVICE_CLASS_TO_GOOGLE_TYPES = { + (cover.DOMAIN, cover.DEVICE_CLASS_GARAGE): TYPE_GARAGE, + (switch.DOMAIN, switch.DEVICE_CLASS_SWITCH): TYPE_SWITCH, + (switch.DOMAIN, switch.DEVICE_CLASS_OUTLET): TYPE_OUTLET, + (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_DOOR): TYPE_SENSOR, + (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_GARAGE_DOOR): + TYPE_SENSOR, + (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_LOCK): TYPE_SENSOR, + (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_OPENING): TYPE_SENSOR, + (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_WINDOW): TYPE_SENSOR, + +} diff --git a/homeassistant/components/google_assistant/error.py b/homeassistant/components/google_assistant/error.py new file mode 100644 index 00000000000..2225bb58242 --- /dev/null +++ b/homeassistant/components/google_assistant/error.py @@ -0,0 +1,13 @@ +"""Errors for Google Assistant.""" + + +class SmartHomeError(Exception): + """Google Assistant Smart Home errors. + + https://developers.google.com/actions/smarthome/create-app#error_responses + """ + + def __init__(self, code, msg): + """Log error code.""" + super().__init__(msg) + self.code = code diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 8afa55acc5c..982b840393e 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -1,17 +1,19 @@ """Helper classes for Google Assistant integration.""" -from homeassistant.core import Context +from asyncio import gather +from collections.abc import Mapping +from homeassistant.core import Context, callback +from homeassistant.const import ( + CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES, + ATTR_DEVICE_CLASS +) -class SmartHomeError(Exception): - """Google Assistant Smart Home errors. - - https://developers.google.com/actions/smarthome/create-app#error_responses - """ - - def __init__(self, code, msg): - """Log error code.""" - super().__init__(msg) - self.code = code +from . import trait +from .const import ( + DOMAIN_TO_GOOGLE_TYPES, CONF_ALIASES, ERR_FUNCTION_NOT_SUPPORTED, + DEVICE_CLASS_TO_GOOGLE_TYPES, CONF_ROOM_HINT, +) +from .error import SmartHomeError class Config: @@ -33,3 +35,174 @@ class RequestData: self.config = config self.request_id = request_id self.context = Context(user_id=user_id) + + +def get_google_type(domain, device_class): + """Google type based on domain and device class.""" + typ = DEVICE_CLASS_TO_GOOGLE_TYPES.get((domain, device_class)) + + return typ if typ is not None else DOMAIN_TO_GOOGLE_TYPES[domain] + + +class GoogleEntity: + """Adaptation of Entity expressed in Google's terms.""" + + def __init__(self, hass, config, state): + """Initialize a Google entity.""" + self.hass = hass + self.config = config + self.state = state + self._traits = None + + @property + def entity_id(self): + """Return entity ID.""" + return self.state.entity_id + + @callback + def traits(self): + """Return traits for entity.""" + if self._traits is not None: + return self._traits + + state = self.state + domain = state.domain + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + self._traits = [Trait(self.hass, state, self.config) + for Trait in trait.TRAITS + if Trait.supported(domain, features, device_class)] + return self._traits + + async def sync_serialize(self): + """Serialize entity for a SYNC response. + + https://developers.google.com/actions/smarthome/create-app#actiondevicessync + """ + state = self.state + + # When a state is unavailable, the attributes that describe + # capabilities will be stripped. For example, a light entity will miss + # the min/max mireds. Therefore they will be excluded from a sync. + if state.state == STATE_UNAVAILABLE: + return None + + entity_config = self.config.entity_config.get(state.entity_id, {}) + name = (entity_config.get(CONF_NAME) or state.name).strip() + domain = state.domain + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + # If an empty string + if not name: + return None + + traits = self.traits() + + # Found no supported traits for this entity + if not traits: + return None + + device_type = get_google_type(domain, + device_class) + + device = { + 'id': state.entity_id, + 'name': { + 'name': name + }, + 'attributes': {}, + 'traits': [trait.name for trait in traits], + 'willReportState': False, + 'type': device_type, + } + + # use aliases + aliases = entity_config.get(CONF_ALIASES) + if aliases: + device['name']['nicknames'] = aliases + + for trt in traits: + device['attributes'].update(trt.sync_attributes()) + + room = entity_config.get(CONF_ROOM_HINT) + if room: + device['roomHint'] = room + return device + + dev_reg, ent_reg, area_reg = await gather( + self.hass.helpers.device_registry.async_get_registry(), + self.hass.helpers.entity_registry.async_get_registry(), + self.hass.helpers.area_registry.async_get_registry(), + ) + + entity_entry = ent_reg.async_get(state.entity_id) + if not (entity_entry and entity_entry.device_id): + return device + + device_entry = dev_reg.devices.get(entity_entry.device_id) + if not (device_entry and device_entry.area_id): + return device + + area_entry = area_reg.areas.get(device_entry.area_id) + if area_entry and area_entry.name: + device['roomHint'] = area_entry.name + + return device + + @callback + def query_serialize(self): + """Serialize entity for a QUERY response. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesquery + """ + state = self.state + + if state.state == STATE_UNAVAILABLE: + return {'online': False} + + attrs = {'online': True} + + for trt in self.traits(): + deep_update(attrs, trt.query_attributes()) + + return attrs + + async def execute(self, command, data, params): + """Execute a command. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + """ + executed = False + for trt in self.traits(): + if trt.can_execute(command, params): + await trt.execute(command, data, params) + executed = True + break + + if not executed: + raise SmartHomeError( + ERR_FUNCTION_NOT_SUPPORTED, + 'Unable to execute {} for {}'.format(command, + self.state.entity_id)) + + @callback + def async_update(self): + """Update the entity with latest info from Home Assistant.""" + self.state = self.hass.states.get(self.entity_id) + + if self._traits is None: + return + + for trt in self._traits: + trt.state = self.state + + +def deep_update(target, source): + """Update a nested dictionary with another nested dictionary.""" + for key, value in source.items(): + if isinstance(value, Mapping): + target[key] = deep_update(target.get(key, {}), value) + else: + target[key] = value + return target diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index ab2907cf661..9edde36f09d 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,237 +1,22 @@ """Support for Google Assistant Smart Home API.""" -from asyncio import gather -from collections.abc import Mapping from itertools import product import logging from homeassistant.util.decorator import Registry -from homeassistant.core import callback from homeassistant.const import ( - CLOUD_NEVER_EXPOSED_ENTITIES, CONF_NAME, STATE_UNAVAILABLE, - ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, ATTR_DEVICE_CLASS, -) -from homeassistant.components import ( - camera, - climate, - cover, - fan, - group, - input_boolean, - light, - lock, - media_player, - scene, - script, - switch, - vacuum, -) + CLOUD_NEVER_EXPOSED_ENTITIES, ATTR_ENTITY_ID) - -from . import trait from .const import ( - TYPE_LIGHT, TYPE_LOCK, TYPE_SCENE, TYPE_SWITCH, TYPE_VACUUM, - TYPE_THERMOSTAT, TYPE_FAN, TYPE_CAMERA, TYPE_BLINDS, TYPE_GARAGE, - TYPE_OUTLET, - CONF_ALIASES, CONF_ROOM_HINT, - ERR_FUNCTION_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, - ERR_UNKNOWN_ERROR, + ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, ERR_UNKNOWN_ERROR, EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED, EVENT_QUERY_RECEIVED ) -from .helpers import SmartHomeError, RequestData +from .helpers import RequestData, GoogleEntity +from .error import SmartHomeError HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) -DOMAIN_TO_GOOGLE_TYPES = { - camera.DOMAIN: TYPE_CAMERA, - climate.DOMAIN: TYPE_THERMOSTAT, - cover.DOMAIN: TYPE_BLINDS, - fan.DOMAIN: TYPE_FAN, - group.DOMAIN: TYPE_SWITCH, - input_boolean.DOMAIN: TYPE_SWITCH, - light.DOMAIN: TYPE_LIGHT, - lock.DOMAIN: TYPE_LOCK, - media_player.DOMAIN: TYPE_SWITCH, - scene.DOMAIN: TYPE_SCENE, - script.DOMAIN: TYPE_SCENE, - switch.DOMAIN: TYPE_SWITCH, - vacuum.DOMAIN: TYPE_VACUUM, -} - -DEVICE_CLASS_TO_GOOGLE_TYPES = { - (cover.DOMAIN, cover.DEVICE_CLASS_GARAGE): TYPE_GARAGE, - (switch.DOMAIN, switch.DEVICE_CLASS_SWITCH): TYPE_SWITCH, - (switch.DOMAIN, switch.DEVICE_CLASS_OUTLET): TYPE_OUTLET, -} - - -def deep_update(target, source): - """Update a nested dictionary with another nested dictionary.""" - for key, value in source.items(): - if isinstance(value, Mapping): - target[key] = deep_update(target.get(key, {}), value) - else: - target[key] = value - return target - - -def get_google_type(domain, device_class): - """Google type based on domain and device class.""" - typ = DEVICE_CLASS_TO_GOOGLE_TYPES.get((domain, device_class)) - - return typ if typ is not None else DOMAIN_TO_GOOGLE_TYPES.get(domain) - - -class _GoogleEntity: - """Adaptation of Entity expressed in Google's terms.""" - - def __init__(self, hass, config, state): - self.hass = hass - self.config = config - self.state = state - self._traits = None - - @property - def entity_id(self): - """Return entity ID.""" - return self.state.entity_id - - @callback - def traits(self): - """Return traits for entity.""" - if self._traits is not None: - return self._traits - - state = self.state - domain = state.domain - features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - - self._traits = [Trait(self.hass, state, self.config) - for Trait in trait.TRAITS - if Trait.supported(domain, features, device_class)] - return self._traits - - async def sync_serialize(self): - """Serialize entity for a SYNC response. - - https://developers.google.com/actions/smarthome/create-app#actiondevicessync - """ - state = self.state - - # When a state is unavailable, the attributes that describe - # capabilities will be stripped. For example, a light entity will miss - # the min/max mireds. Therefore they will be excluded from a sync. - if state.state == STATE_UNAVAILABLE: - return None - - entity_config = self.config.entity_config.get(state.entity_id, {}) - name = (entity_config.get(CONF_NAME) or state.name).strip() - domain = state.domain - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - - # If an empty string - if not name: - return None - - traits = self.traits() - - # Found no supported traits for this entity - if not traits: - return None - - device = { - 'id': state.entity_id, - 'name': { - 'name': name - }, - 'attributes': {}, - 'traits': [trait.name for trait in traits], - 'willReportState': False, - 'type': get_google_type(domain, device_class), - } - - # use aliases - aliases = entity_config.get(CONF_ALIASES) - if aliases: - device['name']['nicknames'] = aliases - - for trt in traits: - device['attributes'].update(trt.sync_attributes()) - - room = entity_config.get(CONF_ROOM_HINT) - if room: - device['roomHint'] = room - return device - - dev_reg, ent_reg, area_reg = await gather( - self.hass.helpers.device_registry.async_get_registry(), - self.hass.helpers.entity_registry.async_get_registry(), - self.hass.helpers.area_registry.async_get_registry(), - ) - - entity_entry = ent_reg.async_get(state.entity_id) - if not (entity_entry and entity_entry.device_id): - return device - - device_entry = dev_reg.devices.get(entity_entry.device_id) - if not (device_entry and device_entry.area_id): - return device - - area_entry = area_reg.areas.get(device_entry.area_id) - if area_entry and area_entry.name: - device['roomHint'] = area_entry.name - - return device - - @callback - def query_serialize(self): - """Serialize entity for a QUERY response. - - https://developers.google.com/actions/smarthome/create-app#actiondevicesquery - """ - state = self.state - - if state.state == STATE_UNAVAILABLE: - return {'online': False} - - attrs = {'online': True} - - for trt in self.traits(): - deep_update(attrs, trt.query_attributes()) - - return attrs - - async def execute(self, command, data, params): - """Execute a command. - - https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute - """ - executed = False - for trt in self.traits(): - if trt.can_execute(command, params): - await trt.execute(command, data, params) - executed = True - break - - if not executed: - raise SmartHomeError( - ERR_FUNCTION_NOT_SUPPORTED, - 'Unable to execute {} for {}'.format(command, - self.state.entity_id)) - - @callback - def async_update(self): - """Update the entity with latest info from Home Assistant.""" - self.state = self.hass.states.get(self.entity_id) - - if self._traits is None: - return - - for trt in self._traits: - trt.state = self.state - async def async_handle_message(hass, config, user_id, message): """Handle incoming API messages.""" @@ -304,7 +89,7 @@ async def async_devices_sync(hass, data, payload): if not data.config.should_expose(state): continue - entity = _GoogleEntity(hass, data.config, state) + entity = GoogleEntity(hass, data.config, state) serialized = await entity.sync_serialize() if serialized is None: @@ -345,7 +130,7 @@ async def async_devices_query(hass, data, payload): devices[devid] = {'online': False} continue - entity = _GoogleEntity(hass, data.config, state) + entity = GoogleEntity(hass, data.config, state) devices[devid] = entity.query_serialize() return {'devices': devices} @@ -389,7 +174,7 @@ async def handle_devices_execute(hass, data, payload): } continue - entities[entity_id] = _GoogleEntity(hass, data.config, state) + entities[entity_id] = GoogleEntity(hass, data.config, state) try: await entities[entity_id].execute(execution['command'], diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index a79dfdd3dca..5bec683ccc7 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -38,7 +38,7 @@ from .const import ( ERR_NOT_SUPPORTED, ERR_FUNCTION_NOT_SUPPORTED, ) -from .helpers import SmartHomeError +from .error import SmartHomeError _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 6c50a158cad..c147f8492d7 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -335,7 +335,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, client = await hass_ws_client(hass) with patch.dict( - 'homeassistant.components.google_assistant.smart_home.' + 'homeassistant.components.google_assistant.const.' 'DOMAIN_TO_GOOGLE_TYPES', {'light': None}, clear=True ), patch.dict('homeassistant.components.alexa.smart_home.ENTITY_ADAPTERS', {'switch': None}, clear=True): diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 24c7059d5c5..30a398fccc3 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -96,7 +96,7 @@ async def test_sync_message(hass): trait.TRAIT_ONOFF, trait.TRAIT_COLOR_SETTING, ], - 'type': sh.TYPE_LIGHT, + 'type': const.TYPE_LIGHT, 'willReportState': False, 'attributes': { 'colorModel': 'hsv', @@ -176,7 +176,7 @@ async def test_sync_in_area(hass, registries): trait.TRAIT_ONOFF, trait.TRAIT_COLOR_SETTING, ], - 'type': sh.TYPE_LIGHT, + 'type': const.TYPE_LIGHT, 'willReportState': False, 'attributes': { 'colorModel': 'hsv', @@ -489,7 +489,7 @@ async def test_serialize_input_boolean(hass): """Test serializing an input boolean entity.""" state = State('input_boolean.bla', 'on') # pylint: disable=protected-access - entity = sh._GoogleEntity(hass, BASIC_CONFIG, state) + entity = sh.GoogleEntity(hass, BASIC_CONFIG, state) result = await entity.sync_serialize() assert result == { 'id': 'input_boolean.bla', diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 07db4c47296..12731978f57 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -49,6 +49,7 @@ UNSAFE_CONFIG = helpers.Config( async def test_brightness_light(hass): """Test brightness trait support for light domain.""" + assert helpers.get_google_type(light.DOMAIN, None) is not None assert trait.BrightnessTrait.supported(light.DOMAIN, light.SUPPORT_BRIGHTNESS, None) @@ -87,6 +88,7 @@ async def test_brightness_light(hass): async def test_brightness_media_player(hass): """Test brightness trait support for media player domain.""" + assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.BrightnessTrait.supported(media_player.DOMAIN, media_player.SUPPORT_VOLUME_SET, None) @@ -117,6 +119,7 @@ async def test_brightness_media_player(hass): async def test_camera_stream(hass): """Test camera stream trait support for camera domain.""" hass.config.api = Mock(base_url='http://1.1.1.1:8123') + assert helpers.get_google_type(camera.DOMAIN, None) is not None assert trait.CameraStreamTrait.supported(camera.DOMAIN, camera.SUPPORT_STREAM, None) @@ -145,6 +148,7 @@ async def test_camera_stream(hass): async def test_onoff_group(hass): """Test OnOff trait support for group domain.""" + assert helpers.get_google_type(group.DOMAIN, None) is not None assert trait.OnOffTrait.supported(group.DOMAIN, 0, None) trt_on = trait.OnOffTrait(hass, State('group.bla', STATE_ON), BASIC_CONFIG) @@ -183,6 +187,7 @@ async def test_onoff_group(hass): async def test_onoff_input_boolean(hass): """Test OnOff trait support for input_boolean domain.""" + assert helpers.get_google_type(input_boolean.DOMAIN, None) is not None assert trait.OnOffTrait.supported(input_boolean.DOMAIN, 0, None) trt_on = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_ON), @@ -223,6 +228,7 @@ async def test_onoff_input_boolean(hass): async def test_onoff_switch(hass): """Test OnOff trait support for switch domain.""" + assert helpers.get_google_type(switch.DOMAIN, None) is not None assert trait.OnOffTrait.supported(switch.DOMAIN, 0, None) trt_on = trait.OnOffTrait(hass, State('switch.bla', STATE_ON), @@ -262,6 +268,7 @@ async def test_onoff_switch(hass): async def test_onoff_fan(hass): """Test OnOff trait support for fan domain.""" + assert helpers.get_google_type(fan.DOMAIN, None) is not None assert trait.OnOffTrait.supported(fan.DOMAIN, 0, None) trt_on = trait.OnOffTrait(hass, State('fan.bla', STATE_ON), BASIC_CONFIG) @@ -298,6 +305,7 @@ async def test_onoff_fan(hass): async def test_onoff_light(hass): """Test OnOff trait support for light domain.""" + assert helpers.get_google_type(light.DOMAIN, None) is not None assert trait.OnOffTrait.supported(light.DOMAIN, 0, None) trt_on = trait.OnOffTrait(hass, State('light.bla', STATE_ON), BASIC_CONFIG) @@ -336,6 +344,7 @@ async def test_onoff_light(hass): async def test_onoff_media_player(hass): """Test OnOff trait support for media_player domain.""" + assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.OnOffTrait.supported(media_player.DOMAIN, 0, None) trt_on = trait.OnOffTrait(hass, State('media_player.bla', STATE_ON), @@ -377,12 +386,14 @@ async def test_onoff_media_player(hass): async def test_onoff_climate(hass): """Test OnOff trait not supported for climate domain.""" + assert helpers.get_google_type(climate.DOMAIN, None) is not None assert not trait.OnOffTrait.supported( climate.DOMAIN, climate.SUPPORT_ON_OFF, None) async def test_dock_vacuum(hass): """Test dock trait support for vacuum domain.""" + assert helpers.get_google_type(vacuum.DOMAIN, None) is not None assert trait.DockTrait.supported(vacuum.DOMAIN, 0, None) trt = trait.DockTrait(hass, State('vacuum.bla', vacuum.STATE_IDLE), @@ -406,6 +417,7 @@ async def test_dock_vacuum(hass): async def test_startstop_vacuum(hass): """Test startStop trait support for vacuum domain.""" + assert helpers.get_google_type(vacuum.DOMAIN, None) is not None assert trait.StartStopTrait.supported(vacuum.DOMAIN, 0, None) trt = trait.StartStopTrait(hass, State('vacuum.bla', vacuum.STATE_PAUSED, { @@ -454,6 +466,7 @@ async def test_startstop_vacuum(hass): async def test_color_setting_color_light(hass): """Test ColorSpectrum trait support for light domain.""" + assert helpers.get_google_type(light.DOMAIN, None) is not None assert not trait.ColorSettingTrait.supported(light.DOMAIN, 0, None) assert trait.ColorSettingTrait.supported(light.DOMAIN, light.SUPPORT_COLOR, None) @@ -515,6 +528,7 @@ async def test_color_setting_color_light(hass): async def test_color_setting_temperature_light(hass): """Test ColorTemperature trait support for light domain.""" + assert helpers.get_google_type(light.DOMAIN, None) is not None assert not trait.ColorSettingTrait.supported(light.DOMAIN, 0, None) assert trait.ColorSettingTrait.supported(light.DOMAIN, light.SUPPORT_COLOR_TEMP, None) @@ -568,6 +582,7 @@ async def test_color_setting_temperature_light(hass): async def test_color_light_temperature_light_bad_temp(hass): """Test ColorTemperature trait support for light domain.""" + assert helpers.get_google_type(light.DOMAIN, None) is not None assert not trait.ColorSettingTrait.supported(light.DOMAIN, 0, None) assert trait.ColorSettingTrait.supported(light.DOMAIN, light.SUPPORT_COLOR_TEMP, None) @@ -584,6 +599,7 @@ async def test_color_light_temperature_light_bad_temp(hass): async def test_scene_scene(hass): """Test Scene trait support for scene domain.""" + assert helpers.get_google_type(scene.DOMAIN, None) is not None assert trait.SceneTrait.supported(scene.DOMAIN, 0, None) trt = trait.SceneTrait(hass, State('scene.bla', scene.STATE), BASIC_CONFIG) @@ -601,6 +617,7 @@ async def test_scene_scene(hass): async def test_scene_script(hass): """Test Scene trait support for script domain.""" + assert helpers.get_google_type(script.DOMAIN, None) is not None assert trait.SceneTrait.supported(script.DOMAIN, 0, None) trt = trait.SceneTrait(hass, State('script.bla', STATE_OFF), BASIC_CONFIG) @@ -622,6 +639,7 @@ async def test_scene_script(hass): async def test_temperature_setting_climate_onoff(hass): """Test TemperatureSetting trait support for climate domain - range.""" + assert helpers.get_google_type(climate.DOMAIN, None) is not None assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None) assert trait.TemperatureSettingTrait.supported( climate.DOMAIN, climate.SUPPORT_OPERATION_MODE, None) @@ -666,6 +684,7 @@ async def test_temperature_setting_climate_onoff(hass): async def test_temperature_setting_climate_range(hass): """Test TemperatureSetting trait support for climate domain - range.""" + assert helpers.get_google_type(climate.DOMAIN, None) is not None assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None) assert trait.TemperatureSettingTrait.supported( climate.DOMAIN, climate.SUPPORT_OPERATION_MODE, None) @@ -741,6 +760,7 @@ async def test_temperature_setting_climate_range(hass): async def test_temperature_setting_climate_setpoint(hass): """Test TemperatureSetting trait support for climate domain - setpoint.""" + assert helpers.get_google_type(climate.DOMAIN, None) is not None assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None) assert trait.TemperatureSettingTrait.supported( climate.DOMAIN, climate.SUPPORT_OPERATION_MODE, None) @@ -841,6 +861,7 @@ async def test_temperature_setting_climate_setpoint_auto(hass): async def test_lock_unlock_lock(hass): """Test LockUnlock trait locking support for lock domain.""" + assert helpers.get_google_type(lock.DOMAIN, None) is not None assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None) @@ -867,6 +888,7 @@ async def test_lock_unlock_lock(hass): async def test_lock_unlock_unlock(hass): """Test LockUnlock trait unlocking support for lock domain.""" + assert helpers.get_google_type(lock.DOMAIN, None) is not None assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None) @@ -905,6 +927,7 @@ async def test_lock_unlock_unlock(hass): async def test_fan_speed(hass): """Test FanSpeed trait speed control support for fan domain.""" + assert helpers.get_google_type(fan.DOMAIN, None) is not None assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED, None) @@ -988,6 +1011,7 @@ async def test_fan_speed(hass): async def test_modes(hass): """Test Mode trait.""" + assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.ModesTrait.supported( media_player.DOMAIN, media_player.SUPPORT_SELECT_SOURCE, None) @@ -1076,6 +1100,7 @@ async def test_modes(hass): async def test_openclose_cover(hass): """Test OpenClose trait support for cover domain.""" + assert helpers.get_google_type(cover.DOMAIN, None) is not None assert trait.OpenCloseTrait.supported(cover.DOMAIN, cover.SUPPORT_SET_POSITION, None) @@ -1137,6 +1162,8 @@ async def test_openclose_cover(hass): )) async def test_openclose_binary_sensor(hass, device_class): """Test OpenClose trait support for binary_sensor domain.""" + assert helpers.get_google_type( + binary_sensor.DOMAIN, device_class) is not None assert trait.OpenCloseTrait.supported(binary_sensor.DOMAIN, 0, device_class) From fa0d538358775519860de371a4e3cf2b1383a671 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 17 Apr 2019 19:17:13 -0700 Subject: [PATCH 05/79] Fix empty components (#23177) --- homeassistant/loader.py | 8 ++++---- tests/common.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index ecc39f8db8f..ed2ea83afb0 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -51,11 +51,11 @@ CUSTOM_WARNING = ( _UNDEF = object() -def manifest_from_legacy_module(module: ModuleType) -> Dict: +def manifest_from_legacy_module(domain: str, module: ModuleType) -> Dict: """Generate a manifest from a legacy module.""" return { - 'domain': module.DOMAIN, # type: ignore - 'name': module.DOMAIN, # type: ignore + 'domain': domain, + 'name': domain, 'documentation': None, 'requirements': getattr(module, 'REQUIREMENTS', []), 'dependencies': getattr(module, 'DEPENDENCIES', []), @@ -106,7 +106,7 @@ class Integration: return cls( hass, comp.__name__, pathlib.Path(comp.__file__).parent, - manifest_from_legacy_module(comp) + manifest_from_legacy_module(domain, comp) ) def __init__(self, hass: 'HomeAssistant', pkg_path: str, diff --git a/tests/common.py b/tests/common.py index 99afd4fdb95..2467dae04b9 100644 --- a/tests/common.py +++ b/tests/common.py @@ -487,7 +487,7 @@ class MockModule: def mock_manifest(self): """Generate a mock manifest to represent this module.""" return { - **loader.manifest_from_legacy_module(self), + **loader.manifest_from_legacy_module(self.DOMAIN, self), **(self._partial_manifest or {}) } From 3665e87800bcf215a785899b4ce5f93d7b6d8203 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 17 Apr 2019 22:27:11 -0700 Subject: [PATCH 06/79] Don't warn for missing services (#23182) --- homeassistant/helpers/service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 1cfbf9e3c5f..8c576f58c14 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -210,9 +210,8 @@ async def async_get_all_descriptions(hass): domain_yaml = loaded[domain] yaml_description = domain_yaml.get(service, {}) - if not yaml_description: - _LOGGER.warning("Missing service description for %s/%s", - domain, service) + # Don't warn for missing services, because it triggers false + # positives for things like scripts, that register as a service description = descriptions_cache[cache_key] = { 'description': yaml_description.get('description', ''), From 25a5bd32e2775e541ee7cf7db3fbd3e7014243f3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 17 Apr 2019 22:42:41 -0700 Subject: [PATCH 07/79] Bumped version to 0.92.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d8ee3e00cc4..2032148bfdd 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 92 -PATCH_VERSION = '0b0' +PATCH_VERSION = '0b1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From dbb49afb3ec894c484742fc897c8bc723739db79 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 19 Apr 2019 16:57:45 -0700 Subject: [PATCH 08/79] Updated frontend to 20190419.0 --- homeassistant/components/frontend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4821c39ff32..ae91178e4c4 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190417.0" + "home-assistant-frontend==20190419.0" ], "dependencies": [ "api", diff --git a/requirements_all.txt b/requirements_all.txt index b861e8e2a49..e983be30515 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -551,7 +551,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190417.0 +home-assistant-frontend==20190419.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e5012d76e0..2a83e2db30c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -136,7 +136,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190417.0 +home-assistant-frontend==20190419.0 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From df02879c51c4f87514e376e3a2a0f7db7a75a285 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 18 Apr 2019 13:37:52 +0100 Subject: [PATCH 09/79] Improve configuration schema for Geniushub integration (#23155) * configuration for hub tokens are now separate from host addresses/credentials * small change to docstring * use *args **kwargs --- .../components/geniushub/__init__.py | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 90e04db0111..aa57af55852 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -1,10 +1,10 @@ -"""This module connects to the Genius hub and shares the data.""" +"""This module connects to a Genius hub and shares the data.""" import logging import voluptuous as vol from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME) + CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import async_load_platform @@ -13,12 +13,19 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'geniushub' +_V1_API_SCHEMA = vol.Schema({ + vol.Required(CONF_TOKEN): cv.string, +}) +_V3_API_SCHEMA = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Required(CONF_HOST): cv.string, - }), + DOMAIN: vol.Any( + _V3_API_SCHEMA, + _V1_API_SCHEMA, + ) }, extra=vol.ALLOW_EXTRA) @@ -26,16 +33,17 @@ async def async_setup(hass, hass_config): """Create a Genius Hub system.""" from geniushubclient import GeniusHubClient # noqa; pylint: disable=no-name-in-module - host = hass_config[DOMAIN].get(CONF_HOST) - username = hass_config[DOMAIN].get(CONF_USERNAME) - password = hass_config[DOMAIN].get(CONF_PASSWORD) - geniushub_data = hass.data[DOMAIN] = {} + kwargs = dict(hass_config[DOMAIN]) + if CONF_HOST in kwargs: + args = (kwargs.pop(CONF_HOST), ) + else: + args = (kwargs.pop(CONF_TOKEN), ) + try: client = geniushub_data['client'] = GeniusHubClient( - host, username, password, - session=async_get_clientsession(hass) + *args, **kwargs, session=async_get_clientsession(hass) ) await client.hub.update() From decaabeb4a6843a40c393af1b725733ae2833e28 Mon Sep 17 00:00:00 2001 From: Dries De Peuter Date: Thu, 18 Apr 2019 10:52:48 +0200 Subject: [PATCH 10/79] Fix niko home control dependency installation (#23176) * Upgrade niko-home-control library * Fix additional feedback * Lint --- .../components/niko_home_control/light.py | 37 +++++-------------- .../niko_home_control/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index aabee41694c..17b5f60cf44 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -7,7 +7,7 @@ import voluptuous as vol # Import the device class from the component that you want to support from homeassistant.components.light import ( ATTR_BRIGHTNESS, PLATFORM_SCHEMA, Light) -from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_HOST from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -18,7 +18,6 @@ SCAN_INTERVAL = timedelta(seconds=30) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, }) @@ -56,27 +55,12 @@ class NikoHomeControlLight(Light): self._name = light.name self._state = light.is_on self._brightness = None - _LOGGER.debug("Init new light: %s", light.name) @property def unique_id(self): """Return unique ID for light.""" return self._unique_id - @property - def device_info(self): - """Return device info for light.""" - return { - 'identifiers': { - ('niko_home_control', self.unique_id) - }, - 'name': self.name, - 'manufacturer': 'Niko group nv', - 'model': 'Niko connected controller', - 'sw_version': self._data.info_swversion(self._light), - 'via_hub': ('niko_home_control'), - } - @property def name(self): """Return the display name of this light.""" @@ -92,16 +76,16 @@ class NikoHomeControlLight(Light): """Return true if light is on.""" return self._state - async def async_turn_on(self, **kwargs): + def turn_on(self, **kwargs): """Instruct the light to turn on.""" self._light.brightness = kwargs.get(ATTR_BRIGHTNESS, 255) _LOGGER.debug('Turn on: %s', self.name) - await self._data.hass.async_add_executor_job(self._light.turn_on) + self._light.turn_on() - async def async_turn_off(self, **kwargs): + def turn_off(self, **kwargs): """Instruct the light to turn off.""" _LOGGER.debug('Turn off: %s', self.name) - await self._data.hass.async_add_executor_job(self._light.turn_off) + self._light.turn_off() async def async_update(self): """Get the latest data from NikoHomeControl API.""" @@ -134,10 +118,7 @@ class NikoHomeControlData: def get_state(self, aid): """Find and filter state based on action id.""" - return next(filter(lambda a: a['id'] == aid, self.data))['value1'] != 0 - - def info_swversion(self, light): - """Return software version information.""" - if self._system_info is None: - self._system_info = self._nhc.system_info() - return self._system_info['swversion'] + for state in self.data: + if state['id'] == aid: + return state['value1'] != 0 + _LOGGER.error("Failed to retrieve state off unknown light") diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index c1c095f989a..8cb58a7b74c 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -3,7 +3,7 @@ "name": "Niko home control", "documentation": "https://www.home-assistant.io/components/niko_home_control", "requirements": [ - "niko-home-control==0.2.0" + "niko-home-control==0.2.1" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index e983be30515..f32e6d0f7ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -746,7 +746,7 @@ netdisco==2.6.0 neurio==0.3.1 # homeassistant.components.niko_home_control -niko-home-control==0.2.0 +niko-home-control==0.2.1 # homeassistant.components.nilu niluclient==0.1.2 From 7eebf4631dd28adf93c2d78022c44ae6081cad5b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 19 Apr 2019 09:43:47 +0200 Subject: [PATCH 11/79] Hass.io Add-on panel support for Ingress (#23185) * Hass.io Add-on panel support for Ingress * Revert part of discovery startup handling * Add type * Fix tests * Add tests * Fix lint * Fix lint on test --- homeassistant/components/hassio/__init__.py | 16 ++- .../components/hassio/addon_panel.py | 93 +++++++++++++ homeassistant/components/hassio/auth.py | 9 +- homeassistant/components/hassio/const.py | 6 + homeassistant/components/hassio/discovery.py | 23 ++-- homeassistant/components/hassio/handler.py | 8 ++ homeassistant/components/hassio/ingress.py | 2 +- homeassistant/components/hassio/manifest.json | 1 + tests/components/hassio/test_addon_panel.py | 128 ++++++++++++++++++ tests/components/hassio/test_handler.py | 20 +++ tests/components/hassio/test_init.py | 31 +++-- 11 files changed, 298 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/hassio/addon_panel.py create mode 100644 tests/components/hassio/test_addon_panel.py diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 2fdb859c320..c8c0f6c9f19 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -16,11 +16,12 @@ import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass from homeassistant.util.dt import utcnow -from .auth import async_setup_auth -from .discovery import async_setup_discovery +from .auth import async_setup_auth_view +from .addon_panel import async_setup_addon_panel +from .discovery import async_setup_discovery_view from .handler import HassIO, HassioAPIError from .http import HassIOView -from .ingress import async_setup_ingress +from .ingress import async_setup_ingress_view _LOGGER = logging.getLogger(__name__) @@ -265,12 +266,15 @@ async def async_setup(hass, config): HASS_DOMAIN, service, async_handle_core_service) # Init discovery Hass.io feature - async_setup_discovery(hass, hassio, config) + async_setup_discovery_view(hass, hassio) # Init auth Hass.io feature - async_setup_auth(hass) + async_setup_auth_view(hass) # Init ingress Hass.io feature - async_setup_ingress(hass, host) + async_setup_ingress_view(hass, host) + + # Init add-on ingress panels + await async_setup_addon_panel(hass, hassio) return True diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py new file mode 100644 index 00000000000..d19ca23799a --- /dev/null +++ b/homeassistant/components/hassio/addon_panel.py @@ -0,0 +1,93 @@ +"""Implement the Ingress Panel feature for Hass.io Add-ons.""" +import asyncio +import logging + +from aiohttp import web + +from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ATTR_PANELS, ATTR_TITLE, ATTR_ICON, ATTR_ADMIN, ATTR_ENABLE +from .handler import HassioAPIError + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_addon_panel(hass: HomeAssistantType, hassio): + """Add-on Ingress Panel setup.""" + hassio_addon_panel = HassIOAddonPanel(hass, hassio) + hass.http.register_view(hassio_addon_panel) + + # If panels are exists + panels = await hassio_addon_panel.get_panels() + if not panels: + return + + # Register available panels + jobs = [] + for addon, data in panels.items(): + if not data[ATTR_ENABLE]: + continue + jobs.append(_register_panel(hass, addon, data)) + + if jobs: + await asyncio.wait(jobs) + + +class HassIOAddonPanel(HomeAssistantView): + """Hass.io view to handle base part.""" + + name = "api:hassio_push:panel" + url = "/api/hassio_push/panel/{addon}" + + def __init__(self, hass, hassio): + """Initialize WebView.""" + self.hass = hass + self.hassio = hassio + + async def post(self, request, addon): + """Handle new add-on panel requests.""" + panels = await self.get_panels() + + # Panel exists for add-on slug + if addon not in panels or not panels[addon][ATTR_ENABLE]: + _LOGGER.error("Panel is not enable for %s", addon) + return web.Response(status=400) + data = panels[addon] + + # Register panel + await _register_panel(self.hass, addon, data) + return web.Response() + + async def delete(self, request, addon): + """Handle remove add-on panel requests.""" + # Currently not supported by backend / frontend + return web.Response() + + async def get_panels(self): + """Return panels add-on info data.""" + try: + data = await self.hassio.get_ingress_panels() + return data[ATTR_PANELS] + except HassioAPIError as err: + _LOGGER.error("Can't read panel info: %s", err) + return {} + + +def _register_panel(hass, addon, data): + """Init coroutine to register the panel. + + Return coroutine. + """ + return hass.components.frontend.async_register_built_in_panel( + frontend_url_path=addon, + webcomponent_name='hassio-main', + sidebar_title=data[ATTR_TITLE], + sidebar_icon=data[ATTR_ICON], + js_url='/api/hassio/app/entrypoint.js', + embed_iframe=True, + require_admin=data[ATTR_ADMIN], + config={ + "ingress": addon + } + ) diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 05c183ccd60..85ae6473562 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -1,18 +1,19 @@ """Implement the auth feature from Hass.io for Add-ons.""" -from ipaddress import ip_address import logging import os +from ipaddress import ip_address +import voluptuous as vol from aiohttp import web from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound -import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.const import KEY_REAL_IP from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME @@ -27,7 +28,7 @@ SCHEMA_API_AUTH = vol.Schema({ @callback -def async_setup_auth(hass): +def async_setup_auth_view(hass: HomeAssistantType): """Auth setup.""" hassio_auth = HassIOAuth(hass) hass.http.register_view(hassio_auth) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index e4132562c31..9656346cd2c 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,5 +1,6 @@ """Hass.io const variables.""" +ATTR_ADDONS = 'addons' ATTR_DISCOVERY = 'discovery' ATTR_ADDON = 'addon' ATTR_NAME = 'name' @@ -8,6 +9,11 @@ ATTR_CONFIG = 'config' ATTR_UUID = 'uuid' ATTR_USERNAME = 'username' ATTR_PASSWORD = 'password' +ATTR_PANELS = 'panels' +ATTR_ENABLE = 'enable' +ATTR_TITLE = 'title' +ATTR_ICON = 'icon' +ATTR_ADMIN = 'admin' X_HASSIO = 'X-Hassio-Key' X_INGRESS_PATH = "X-Ingress-Path" diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 09a98edc148..90953d634c3 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -5,9 +5,9 @@ import logging from aiohttp import web from aiohttp.web_exceptions import HTTPServiceUnavailable -from homeassistant.components.http import HomeAssistantView from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.core import CoreState, callback +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView from .const import ( ATTR_ADDON, ATTR_CONFIG, ATTR_DISCOVERY, ATTR_NAME, ATTR_SERVICE, @@ -18,12 +18,13 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_setup_discovery(hass, hassio, config): +def async_setup_discovery_view(hass: HomeAssistantView, hassio): """Discovery setup.""" - hassio_discovery = HassIODiscovery(hass, hassio, config) + hassio_discovery = HassIODiscovery(hass, hassio) + hass.http.register_view(hassio_discovery) # Handle exists discovery messages - async def async_discovery_start_handler(event): + async def _async_discovery_start_handler(event): """Process all exists discovery on startup.""" try: data = await hassio.retrieve_discovery_messages() @@ -36,13 +37,8 @@ def async_setup_discovery(hass, hassio, config): if jobs: await asyncio.wait(jobs) - if hass.state == CoreState.running: - hass.async_create_task(async_discovery_start_handler(None)) - else: - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_discovery_start_handler) - - hass.http.register_view(hassio_discovery) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, _async_discovery_start_handler) class HassIODiscovery(HomeAssistantView): @@ -51,11 +47,10 @@ class HassIODiscovery(HomeAssistantView): name = "api:hassio_push:discovery" url = "/api/hassio_push/discovery/{uuid}" - def __init__(self, hass, hassio, config): + def __init__(self, hass: HomeAssistantView, hassio): """Initialize WebView.""" self.hass = hass self.hassio = hassio - self.config = config async def post(self, request, uuid): """Handle new discovery requests.""" diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 7eddc639690..aae1f31d486 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -81,6 +81,14 @@ class HassIO: return self.send_command( "/addons/{}/info".format(addon), method="get") + @_api_data + def get_ingress_panels(self): + """Return data for Add-on ingress panels. + + This method return a coroutine. + """ + return self.send_command("/ingress/panels", method="get") + @_api_bool def restart_homeassistant(self): """Restart Home-Assistant container. diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 0ba83f1ca1b..824dee86fad 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_setup_ingress(hass: HomeAssistantType, host: str): +def async_setup_ingress_view(hass: HomeAssistantType, host: str): """Auth setup.""" websession = hass.helpers.aiohttp_client.async_get_clientsession() diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index 23095064d55..24782e45799 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -5,6 +5,7 @@ "requirements": [], "dependencies": [ "http", + "frontend", "panel_custom" ], "codeowners": [ diff --git a/tests/components/hassio/test_addon_panel.py b/tests/components/hassio/test_addon_panel.py new file mode 100644 index 00000000000..05915218659 --- /dev/null +++ b/tests/components/hassio/test_addon_panel.py @@ -0,0 +1,128 @@ +"""Test add-on panel.""" +from unittest.mock import patch, Mock + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.const import HTTP_HEADER_HA_AUTH + +from tests.common import mock_coro +from . import API_PASSWORD + + +@pytest.fixture(autouse=True) +def mock_all(aioclient_mock): + """Mock all setup requests.""" + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/supervisor/options", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + + +async def test_hassio_addon_panel_startup(hass, aioclient_mock, hassio_env): + """Test startup and panel setup after event.""" + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={ + 'result': 'ok', 'data': {'panels': { + "test1": { + "enable": True, + "title": "Test", + "icon": "mdi:test", + "admin": False + }, + "test2": { + "enable": False, + "title": "Test 2", + "icon": "mdi:test2", + "admin": True + }, + }}}) + + assert aioclient_mock.call_count == 0 + + with patch( + 'homeassistant.components.hassio.addon_panel._register_panel', + Mock(return_value=mock_coro()) + ) as mock_panel: + await async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': API_PASSWORD + } + }) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 2 + assert mock_panel.called + mock_panel.assert_called_with( + hass, 'test1', { + 'enable': True, 'title': 'Test', + 'icon': 'mdi:test', 'admin': False + }) + + +async def test_hassio_addon_panel_api(hass, aioclient_mock, hassio_env, + hass_client): + """Test panel api after event.""" + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={ + 'result': 'ok', 'data': {'panels': { + "test1": { + "enable": True, + "title": "Test", + "icon": "mdi:test", + "admin": False + }, + "test2": { + "enable": False, + "title": "Test 2", + "icon": "mdi:test2", + "admin": True + }, + }}}) + + assert aioclient_mock.call_count == 0 + + with patch( + 'homeassistant.components.hassio.addon_panel._register_panel', + Mock(return_value=mock_coro()) + ) as mock_panel: + await async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': API_PASSWORD + } + }) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 2 + assert mock_panel.called + mock_panel.assert_called_with( + hass, 'test1', { + 'enable': True, 'title': 'Test', + 'icon': 'mdi:test', 'admin': False + }) + + hass_client = await hass_client() + + resp = await hass_client.post( + '/api/hassio_push/panel/test2', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) + assert resp.status == 400 + + resp = await hass_client.post( + '/api/hassio_push/panel/test1', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) + assert resp.status == 200 + assert mock_panel.call_count == 2 + + mock_panel.assert_called_with( + hass, 'test1', { + 'enable': True, 'title': 'Test', + 'icon': 'mdi:test', 'admin': False + }) diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 3e7b9e95d92..372d567c021 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -105,3 +105,23 @@ async def test_api_retrieve_discovery(hassio_handler, aioclient_mock): data = await hassio_handler.retrieve_discovery_messages() assert data['discovery'][-1]['service'] == "mqtt" assert aioclient_mock.call_count == 1 + + +async def test_api_ingress_panels(hassio_handler, aioclient_mock): + """Test setup with API Ingress panels.""" + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={'result': 'ok', 'data': { + "panels": { + "slug": { + "enable": True, + "title": "Test", + "icon": "mdi:test", + "admin": False + } + } + }}) + + data = await hassio_handler.get_ingress_panels() + assert aioclient_mock.call_count == 1 + assert data['panels'] + assert "slug" in data['panels'] diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f1f148f8495..7b8fad3ec09 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -31,6 +31,9 @@ def mock_all(aioclient_mock): aioclient_mock.get( "http://127.0.0.1/homeassistant/info", json={ 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={ + 'result': 'ok', 'data': {'panels': {}}}) @asyncio.coroutine @@ -40,7 +43,7 @@ def test_setup_api_ping(hass, aioclient_mock): result = yield from async_setup_component(hass, 'hassio', {}) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert hass.components.hassio.get_homeassistant_version() == "10.0" assert hass.components.hassio.is_hassio() @@ -79,7 +82,7 @@ def test_setup_api_push_api_data(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 9999 assert aioclient_mock.mock_calls[1][2]['watchdog'] @@ -98,7 +101,7 @@ def test_setup_api_push_api_data_server_host(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 9999 assert not aioclient_mock.mock_calls[1][2]['watchdog'] @@ -114,7 +117,7 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, }) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]['refresh_token'] @@ -174,7 +177,7 @@ async def test_setup_api_existing_hassio_user(hass, aioclient_mock, }) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 8123 assert aioclient_mock.mock_calls[1][2]['refresh_token'] == token.token @@ -192,7 +195,7 @@ def test_setup_core_push_timezone(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 assert aioclient_mock.mock_calls[2][2]['timezone'] == "testzone" @@ -206,7 +209,7 @@ def test_setup_hassio_no_additional_data(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert aioclient_mock.mock_calls[-1][3]['X-Hassio-Key'] == "123456" @@ -285,14 +288,14 @@ def test_service_calls(hassio_env, hass, aioclient_mock): 'hassio', 'addon_stdin', {'addon': 'test', 'input': 'test'}) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 5 + assert aioclient_mock.call_count == 6 assert aioclient_mock.mock_calls[-1][2] == 'test' yield from hass.services.async_call('hassio', 'host_shutdown', {}) yield from hass.services.async_call('hassio', 'host_reboot', {}) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 7 + assert aioclient_mock.call_count == 8 yield from hass.services.async_call('hassio', 'snapshot_full', {}) yield from hass.services.async_call('hassio', 'snapshot_partial', { @@ -302,7 +305,7 @@ def test_service_calls(hassio_env, hass, aioclient_mock): }) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 9 + assert aioclient_mock.call_count == 10 assert aioclient_mock.mock_calls[-1][2] == { 'addons': ['test'], 'folders': ['ssl'], 'password': "123456"} @@ -318,7 +321,7 @@ def test_service_calls(hassio_env, hass, aioclient_mock): }) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 11 + assert aioclient_mock.call_count == 12 assert aioclient_mock.mock_calls[-1][2] == { 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False, 'password': "123456" @@ -338,12 +341,12 @@ def test_service_calls_core(hassio_env, hass, aioclient_mock): yield from hass.services.async_call('homeassistant', 'stop') yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 3 yield from hass.services.async_call('homeassistant', 'check_config') yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 3 with patch( 'homeassistant.config.async_check_ha_config_file', @@ -353,4 +356,4 @@ def test_service_calls_core(hassio_env, hass, aioclient_mock): yield from hass.async_block_till_done() assert mock_check_config.called - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 From a3c3c41faaf2ea966929d6058846c26c8f02a8f4 Mon Sep 17 00:00:00 2001 From: Richard Mitchell Date: Thu, 18 Apr 2019 16:53:02 +0100 Subject: [PATCH 12/79] Hue motion senors are motion sensors, not presence sensors. (#23193) --- homeassistant/components/hue/binary_sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index d60750721ac..3286f185ea4 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -1,5 +1,6 @@ """Hue binary sensor entities.""" -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, DEVICE_CLASS_MOTION) from homeassistant.components.hue.sensor_base import ( GenericZLLSensor, async_setup_entry as shared_async_setup_entry) @@ -16,7 +17,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HuePresence(GenericZLLSensor, BinarySensorDevice): """The presence sensor entity for a Hue motion sensor device.""" - device_class = 'presence' + device_class = DEVICE_CLASS_MOTION async def _async_update_ha_state(self, *args, **kwargs): await self.async_update_ha_state(self, *args, **kwargs) From 89037b367b92f5493999861ef6968369d80f25d5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 18 Apr 2019 11:11:43 -0700 Subject: [PATCH 13/79] Don't load component when fetching translations (#23196) --- homeassistant/helpers/translation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 24e6f4f390d..4f655e692f7 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -52,11 +52,9 @@ async def component_translation_file(hass: HomeAssistantType, component: str, filename = "{}.{}.json".format(parts[0], language) return str(integration.file_path / '.translations' / filename) - module = integration.get_component() - # If it's a component that is just one file, we don't support translations # Example custom_components/my_component.py - if module.__name__ != module.__package__: + if integration.file_path.name != domain: return None filename = '{}.json'.format(language) From 9c9b25d4b95cd66af6afb6b8a5d764616d79235b Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Thu, 18 Apr 2019 22:10:36 +0200 Subject: [PATCH 14/79] Create empty services.yaml for esphome (#23200) --- homeassistant/components/esphome/services.yaml | 1 + 1 file changed, 1 insertion(+) create mode 100644 homeassistant/components/esphome/services.yaml diff --git a/homeassistant/components/esphome/services.yaml b/homeassistant/components/esphome/services.yaml new file mode 100644 index 00000000000..f4c31420f9a --- /dev/null +++ b/homeassistant/components/esphome/services.yaml @@ -0,0 +1 @@ +# Empty file, ESPHome services are dynamically created (user-defined services) From ad5d4bbf51e3dcb6081877b0d0c2e17661d21266 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 18 Apr 2019 16:10:25 -0400 Subject: [PATCH 15/79] Create services.yaml for python_script and script (#23201) * Create services.yaml for python_script * Create services.yaml for script --- .../components/python_script/services.yaml | 4 +++ homeassistant/components/script/services.yaml | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 homeassistant/components/python_script/services.yaml create mode 100644 homeassistant/components/script/services.yaml diff --git a/homeassistant/components/python_script/services.yaml b/homeassistant/components/python_script/services.yaml new file mode 100644 index 00000000000..835f6402481 --- /dev/null +++ b/homeassistant/components/python_script/services.yaml @@ -0,0 +1,4 @@ +# Describes the format for available python_script services + +reload: + description: Reload all available python_scripts diff --git a/homeassistant/components/script/services.yaml b/homeassistant/components/script/services.yaml new file mode 100644 index 00000000000..736b0ec71c3 --- /dev/null +++ b/homeassistant/components/script/services.yaml @@ -0,0 +1,25 @@ +# Describes the format for available python_script services + +reload: + description: Reload all the available scripts + +turn_on: + description: Turn on script + fields: + entity_id: + description: Name(s) of script to be turned on. + example: 'script.arrive_home' + +turn_off: + description: Turn off script + fields: + entity_id: + description: Name(s) of script to be turned off. + example: 'script.arrive_home' + +toggle: + description: Toggle script + fields: + entity_id: + description: Name(s) of script to be toggled. + example: 'script.arrive_home' From 2bb772bbdcc116efad84f7350cb1fe9fb9bdeeb0 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 18 Apr 2019 13:46:49 -0700 Subject: [PATCH 16/79] Set encoding before connecting (#23204) --- homeassistant/components/mikrotik/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 0c3b6b313f1..3709bc476f5 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -55,13 +55,13 @@ class MikrotikScanner(DeviceScanner): self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] self.method = config.get(CONF_METHOD) + self.encoding = config[CONF_ENCODING] self.connected = False self.success_init = False self.client = None self.wireless_exist = None self.success_init = self.connect_to_device() - self.encoding = config[CONF_ENCODING] if self.success_init: _LOGGER.info("Start polling Mikrotik (%s) router...", self.host) From 1a4a9532dd3cd2fde507ee84d6dfb0238789aaa5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 18 Apr 2019 13:40:46 -0700 Subject: [PATCH 17/79] Add services.yaml validator (#23205) * Add services.yaml validator * Fix path --- .../components/climate/services.yaml | 1 - homeassistant/components/deconz/services.yaml | 7 +- .../components/device_tracker/services.yaml | 73 ++++++------ .../components/hdmi_cec/services.yaml | 2 +- .../components/system_log/services.yaml | 30 ++--- homeassistant/components/zwave/services.yaml | 10 +- script/hassfest/__main__.py | 4 +- script/hassfest/model.py | 12 +- script/hassfest/services.py | 104 ++++++++++++++++++ 9 files changed, 172 insertions(+), 71 deletions(-) create mode 100644 script/hassfest/services.py diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 1460181ddc2..c0dd231ef95 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -80,7 +80,6 @@ set_swing_mode: example: 'climate.nest' swing_mode: description: New value of swing mode. - example: turn_on: description: Turn climate device on. diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml index a39bbc01ea1..4d77101cf0d 100644 --- a/homeassistant/components/deconz/services.yaml +++ b/homeassistant/components/deconz/services.yaml @@ -19,6 +19,7 @@ configure: device_refresh: description: Refresh device lists from deCONZ. - bridgeid: - description: (Optional) Bridgeid is a string unique for each deCONZ hardware. It can be found as part of the integration name. - example: '00212EFFFF012345' \ No newline at end of file + fields: + bridgeid: + description: (Optional) Bridgeid is a string unique for each deCONZ hardware. It can be found as part of the integration name. + example: '00212EFFFF012345' diff --git a/homeassistant/components/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml index 7436bbd6ea4..938e9c8e324 100644 --- a/homeassistant/components/device_tracker/services.yaml +++ b/homeassistant/components/device_tracker/services.yaml @@ -25,40 +25,39 @@ see: description: Battery level of device. example: '100' -icloud: - icloud_lost_iphone: - description: Service to play the lost iphone sound on an iDevice. - fields: - account_name: - description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. - example: 'bart' - device_name: - description: Name of the device that will play the sound. This is optional, if it isn't given it will play on all devices for the given account. - example: 'iphonebart' - icloud_set_interval: - description: Service to set the interval of an iDevice. - fields: - account_name: - description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. - example: 'bart' - device_name: - description: Name of the device that will get a new interval. This is optional, if it isn't given it will change the interval for all devices for the given account. - example: 'iphonebart' - interval: - description: The interval (in minutes) that the iDevice will have until the according device_tracker entity changes from zone or until this service is used again. This is optional, if it isn't given the interval of the device will revert back to the original interval based on the current state. - example: 1 - icloud_update: - description: Service to ask for an update of an iDevice. - fields: - account_name: - description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. - example: 'bart' - device_name: - description: Name of the device that will be updated. This is optional, if it isn't given it will update all devices for the given account. - example: 'iphonebart' - icloud_reset_account: - description: Service to restart an iCloud account. Helpful when not all devices are found after initializing or when you add a new device. - fields: - account_name: - description: Name of the account in the config that will be restarted. This is optional, if it isn't given it will restart all accounts. - example: 'bart' +icloud_lost_iphone: + description: Service to play the lost iphone sound on an iDevice. + fields: + account_name: + description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. + example: 'bart' + device_name: + description: Name of the device that will play the sound. This is optional, if it isn't given it will play on all devices for the given account. + example: 'iphonebart' +icloud_set_interval: + description: Service to set the interval of an iDevice. + fields: + account_name: + description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. + example: 'bart' + device_name: + description: Name of the device that will get a new interval. This is optional, if it isn't given it will change the interval for all devices for the given account. + example: 'iphonebart' + interval: + description: The interval (in minutes) that the iDevice will have until the according device_tracker entity changes from zone or until this service is used again. This is optional, if it isn't given the interval of the device will revert back to the original interval based on the current state. + example: 1 +icloud_update: + description: Service to ask for an update of an iDevice. + fields: + account_name: + description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. + example: 'bart' + device_name: + description: Name of the device that will be updated. This is optional, if it isn't given it will update all devices for the given account. + example: 'iphonebart' +icloud_reset_account: + description: Service to restart an iCloud account. Helpful when not all devices are found after initializing or when you add a new device. + fields: + account_name: + description: Name of the account in the config that will be restarted. This is optional, if it isn't given it will restart all accounts. + example: 'bart' diff --git a/homeassistant/components/hdmi_cec/services.yaml b/homeassistant/components/hdmi_cec/services.yaml index bb0f5f932ae..f2e5f0b837a 100644 --- a/homeassistant/components/hdmi_cec/services.yaml +++ b/homeassistant/components/hdmi_cec/services.yaml @@ -19,7 +19,7 @@ send_command: are source and destination, second byte is command and optional other bytes are command parameters. If raw command specified, other params are ignored.', example: '"10:36"'} - src: {desctiption: 'Source of command. Could be decimal number or string with + src: {description: 'Source of command. Could be decimal number or string with hexadeximal notation: "0x10".', example: 12 or "0xc"} standby: {description: Standby all devices which supports it.} update: {description: Update devices state from network.} diff --git a/homeassistant/components/system_log/services.yaml b/homeassistant/components/system_log/services.yaml index c168185c9b3..2545d47c825 100644 --- a/homeassistant/components/system_log/services.yaml +++ b/homeassistant/components/system_log/services.yaml @@ -1,15 +1,15 @@ -system_log: - clear: - description: Clear all log entries. - write: - description: Write log entry. - fields: - message: - description: Message to log. [Required] - example: Something went wrong - level: - description: "Log level: debug, info, warning, error, critical. Defaults to 'error'." - example: debug - logger: - description: Logger name under which to log the message. Defaults to 'system_log.external'. - example: mycomponent.myplatform +clear: + description: Clear all log entries. + +write: + description: Write log entry. + fields: + message: + description: Message to log. [Required] + example: Something went wrong + level: + description: "Log level: debug, info, warning, error, critical. Defaults to 'error'." + example: debug + logger: + description: Logger name under which to log the message. Defaults to 'system_log.external'. + example: mycomponent.myplatform diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index 7c926a5a879..83e6ea2533b 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -30,15 +30,15 @@ heal_network: description: Start a Z-Wave network heal. This might take a while and will slow down the Z-Wave network greatly while it is being processed. Refer to OZW_Log.txt for progress. fields: return_routes: - description: Whether or not to update the return routes from the nodes to the controller. Defaults to False. - example: True + description: Whether or not to update the return routes from the nodes to the controller. Defaults to False. + example: True heal_node: description: Start a Z-Wave node heal. Refer to OZW_Log.txt for progress. fields: return_routes: - description: Whether or not to update the return routes from the node to the controller. Defaults to False. - example: True + description: Whether or not to update the return routes from the node to the controller. Defaults to False. + example: True remove_node: description: Remove a node from the Z-Wave network. Refer to OZW_Log.txt for progress. @@ -160,7 +160,7 @@ test_node: example: 10 messages: description: Optional. Amount of test messages to send. - example: 3 + example: 3 rename_node: description: Set the name of a node. This will also affect the IDs of all entities in the node. diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 2514db6314d..b555f98d883 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -3,12 +3,13 @@ import pathlib import sys from .model import Integration, Config -from . import dependencies, manifest, codeowners +from . import dependencies, manifest, codeowners, services PLUGINS = [ manifest, dependencies, codeowners, + services, ] @@ -37,6 +38,7 @@ def main(): manifest.validate(integrations, config) dependencies.validate(integrations, config) codeowners.validate(integrations, config) + services.validate(integrations, config) # When we generate, all errors that are fixable will be ignored, # as generating them will be fixed. diff --git a/script/hassfest/model.py b/script/hassfest/model.py index c2a72ebd509..059231cf954 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -61,26 +61,22 @@ class Integration: """Integration domain.""" return self.path.name - @property - def manifest_path(self) -> pathlib.Path: - """Integration manifest path.""" - return self.path / 'manifest.json' - def add_error(self, *args, **kwargs): """Add an error.""" self.errors.append(Error(*args, **kwargs)) def load_manifest(self) -> None: """Load manifest.""" - if not self.manifest_path.is_file(): + manifest_path = self.path / 'manifest.json' + if not manifest_path.is_file(): self.add_error( 'model', - "Manifest file {} not found".format(self.manifest_path) + "Manifest file {} not found".format(manifest_path) ) return try: - manifest = json.loads(self.manifest_path.read_text()) + manifest = json.loads(manifest_path.read_text()) except ValueError as err: self.add_error( 'model', diff --git a/script/hassfest/services.py b/script/hassfest/services.py new file mode 100644 index 00000000000..9765eff1d36 --- /dev/null +++ b/script/hassfest/services.py @@ -0,0 +1,104 @@ +"""Validate dependencies.""" +import pathlib +from typing import Dict + +import re +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.util.yaml import load_yaml + +from .model import Integration + + +def exists(value): + """Check if value exists.""" + if value is None: + raise vol.Invalid("Value cannot be None") + return value + + +FIELD_SCHEMA = vol.Schema({ + vol.Required('description'): str, + vol.Optional('example'): exists, + vol.Optional('default'): exists, + vol.Optional('values'): exists, + vol.Optional('required'): bool, +}) + +SERVICE_SCHEMA = vol.Schema({ + vol.Required('description'): str, + vol.Optional('fields'): vol.Schema({ + str: FIELD_SCHEMA + }) +}) + +SERVICES_SCHEMA = vol.Schema({ + cv.slug: SERVICE_SCHEMA +}) + + +def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) \ + -> bool: + """Recursively go through a dir and it's children and find the regex.""" + pattern = re.compile(search_pattern) + + for fil in path.glob(glob_pattern): + if not fil.is_file(): + continue + + if pattern.search(fil.read_text()): + return True + + return False + + +def validate_services(integration: Integration): + """Validate services.""" + # Find if integration uses services + has_services = grep_dir(integration.path, "**/*.py", + r"hass\.(services|async_register)") + + if not has_services: + return + + try: + data = load_yaml(str(integration.path / 'services.yaml')) + except FileNotFoundError: + print( + "Warning: {} registeres services but has no services.yaml".format( + integration.domain)) + # integration.add_error( + # 'services', 'Registers services but has no services.yaml') + return + except HomeAssistantError: + integration.add_error( + 'services', 'Registers services but unable to load services.yaml') + return + + try: + SERVICES_SCHEMA(data) + except vol.Invalid as err: + integration.add_error( + 'services', + "Invalid services.yaml: {}".format(humanize_error(data, err))) + + +def validate(integrations: Dict[str, Integration], config): + """Handle dependencies for integrations.""" + # check services.yaml is cool + for integration in integrations.values(): + if not integration.manifest: + continue + + validate_services(integration) + + # check that all referenced dependencies exist + for dep in integration.manifest['dependencies']: + if dep not in integrations: + integration.add_error( + 'dependencies', + "Dependency {} does not exist" + ) From 35da3f053c6207ce7b26a56812f31f4981b8644e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 19 Apr 2019 06:01:19 +0200 Subject: [PATCH 18/79] Fix clearing error message for MQTT vacuum (#23206) * Fix clearing error message * Remove redundant hass.async_block_till_done --- homeassistant/components/mqtt/vacuum.py | 2 +- tests/components/mqtt/test_vacuum.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index ae4b3322b8e..5895d52e9dc 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -313,7 +313,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, error = self._templates[CONF_ERROR_TEMPLATE]\ .async_render_with_possible_json_value( msg.payload, error_value=None) - if error: + if error is not None: self._error = cv.string(error) if self._docked: diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index 4140177a929..78ca45a792f 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -298,10 +298,17 @@ async def test_status_error(hass, mock_publish): }""" async_fire_mqtt_message(hass, 'vacuum/state', message) await hass.async_block_till_done() - await hass.async_block_till_done() state = hass.states.get('vacuum.mqtttest') assert 'Error: Error1' == state.attributes.get(ATTR_STATUS) + message = """{ + "error": "" + }""" + async_fire_mqtt_message(hass, 'vacuum/state', message) + await hass.async_block_till_done() + state = hass.states.get('vacuum.mqtttest') + assert 'Stopped' == state.attributes.get(ATTR_STATUS) + async def test_battery_template(hass, mock_publish): """Test that you can use non-default templates for battery_level.""" From 52eb9e50aa170c4a02479d654aa3edefdc4ec125 Mon Sep 17 00:00:00 2001 From: Richard Mitchell Date: Fri, 19 Apr 2019 19:01:54 +0100 Subject: [PATCH 19/79] Name sensors correctly (#23208) * Hue motion senors are motion sensors, not presence sensors. * Name the sensors 'motion' instead of 'presence' - match the HA paradigm. --- homeassistant/components/hue/binary_sensor.py | 2 +- tests/components/hue/test_sensor_base.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index 3286f185ea4..b9921a9a01f 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -5,7 +5,7 @@ from homeassistant.components.hue.sensor_base import ( GenericZLLSensor, async_setup_entry as shared_async_setup_entry) -PRESENCE_NAME_FORMAT = "{} presence" +PRESENCE_NAME_FORMAT = "{} motion" async def async_setup_entry(hass, config_entry, async_add_entities): diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py index 99829c59666..38eb3d8c55b 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_base.py @@ -391,7 +391,7 @@ async def test_sensors(hass, mock_bridge): assert len(hass.states.async_all()) == 6 presence_sensor_1 = hass.states.get( - 'binary_sensor.living_room_sensor_presence') + 'binary_sensor.living_room_sensor_motion') light_level_sensor_1 = hass.states.get( 'sensor.living_room_sensor_light_level') temperature_sensor_1 = hass.states.get( @@ -406,7 +406,7 @@ async def test_sensors(hass, mock_bridge): assert temperature_sensor_1.name == 'Living room sensor temperature' presence_sensor_2 = hass.states.get( - 'binary_sensor.kitchen_sensor_presence') + 'binary_sensor.kitchen_sensor_motion') light_level_sensor_2 = hass.states.get( 'sensor.kitchen_sensor_light_level') temperature_sensor_2 = hass.states.get( @@ -459,7 +459,7 @@ async def test_new_sensor_discovered(hass, mock_bridge): assert len(mock_bridge.mock_requests) == 2 assert len(hass.states.async_all()) == 9 - presence = hass.states.get('binary_sensor.bedroom_sensor_presence') + presence = hass.states.get('binary_sensor.bedroom_sensor_motion') assert presence is not None assert presence.state == 'on' temperature = hass.states.get('sensor.bedroom_sensor_temperature') From 23cb053e8209ae1ef1d4b7f9a12f81b13325fbe8 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 18 Apr 2019 16:09:42 -0400 Subject: [PATCH 20/79] Create services.yaml for Tuya (#23209) --- homeassistant/components/tuya/services.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 homeassistant/components/tuya/services.yaml diff --git a/homeassistant/components/tuya/services.yaml b/homeassistant/components/tuya/services.yaml new file mode 100644 index 00000000000..c96ea3fd09f --- /dev/null +++ b/homeassistant/components/tuya/services.yaml @@ -0,0 +1,7 @@ +# Describes the format for available Tuya services + +pull_devices: + description: Pull device list from Tuya server. + +force_update: + description: Force all Tuya devices to pull data. From f18a49ce97c292799887b751cae69927bff4a430 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 18 Apr 2019 16:10:10 -0400 Subject: [PATCH 21/79] create services.yaml for shell_command (#23210) --- homeassistant/components/shell_command/services.yaml | 1 + 1 file changed, 1 insertion(+) create mode 100644 homeassistant/components/shell_command/services.yaml diff --git a/homeassistant/components/shell_command/services.yaml b/homeassistant/components/shell_command/services.yaml new file mode 100644 index 00000000000..df056f94e85 --- /dev/null +++ b/homeassistant/components/shell_command/services.yaml @@ -0,0 +1 @@ +# Empty file, shell_command services are dynamically created From 4433ad0a06d5041c0a17fb3b8ad1f543f3fd0b8e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 18 Apr 2019 15:13:35 -0700 Subject: [PATCH 22/79] Add stub services.yaml and make validation mandatory (#23213) --- homeassistant/components/alarmdecoder/services.yaml | 0 homeassistant/components/alexa/services.yaml | 0 homeassistant/components/api/services.yaml | 0 homeassistant/components/apns/services.yaml | 0 homeassistant/components/arlo/services.yaml | 0 homeassistant/components/blackbird/services.yaml | 0 homeassistant/components/bluesound/services.yaml | 0 homeassistant/components/bluetooth_tracker/services.yaml | 0 homeassistant/components/browser/services.yaml | 0 homeassistant/components/channels/services.yaml | 0 homeassistant/components/cloudflare/services.yaml | 0 homeassistant/components/config/services.yaml | 0 homeassistant/components/configurator/services.yaml | 0 homeassistant/components/demo/services.yaml | 0 .../components/device_sun_light_trigger/services.yaml | 0 homeassistant/components/dominos/services.yaml | 0 homeassistant/components/downloader/services.yaml | 0 homeassistant/components/duckdns/services.yaml | 0 homeassistant/components/ecobee/services.yaml | 0 homeassistant/components/econet/services.yaml | 0 homeassistant/components/emulated_hue/services.yaml | 0 homeassistant/components/epson/services.yaml | 0 homeassistant/components/facebox/services.yaml | 0 homeassistant/components/flux/services.yaml | 0 homeassistant/components/generic_thermostat/services.yaml | 0 homeassistant/components/harmony/services.yaml | 0 homeassistant/components/html5/services.yaml | 0 homeassistant/components/hue/services.yaml | 0 homeassistant/components/icloud/services.yaml | 0 homeassistant/components/ifttt/services.yaml | 0 homeassistant/components/joaoapps_join/services.yaml | 0 homeassistant/components/keyboard/services.yaml | 0 homeassistant/components/kodi/services.yaml | 0 homeassistant/components/lifx/services.yaml | 0 homeassistant/components/local_file/services.yaml | 0 homeassistant/components/logbook/services.yaml | 0 homeassistant/components/matrix/services.yaml | 0 homeassistant/components/media_extractor/services.yaml | 0 homeassistant/components/mill/services.yaml | 0 homeassistant/components/mobile_app/services.yaml | 0 homeassistant/components/monoprice/services.yaml | 0 homeassistant/components/mysensors/services.yaml | 0 homeassistant/components/neato/services.yaml | 0 homeassistant/components/ness_alarm/services.yaml | 0 homeassistant/components/nuheat/services.yaml | 0 homeassistant/components/nuimo_controller/services.yaml | 0 homeassistant/components/nuki/services.yaml | 0 homeassistant/components/onkyo/services.yaml | 0 homeassistant/components/onvif/services.yaml | 0 homeassistant/components/pilight/services.yaml | 0 homeassistant/components/rest_command/services.yaml | 0 homeassistant/components/roku/services.yaml | 0 homeassistant/components/route53/services.yaml | 0 homeassistant/components/sabnzbd/services.yaml | 0 homeassistant/components/sensibo/services.yaml | 0 homeassistant/components/snapcast/services.yaml | 0 homeassistant/components/songpal/services.yaml | 0 homeassistant/components/sonos/services.yaml | 0 homeassistant/components/soundtouch/services.yaml | 0 homeassistant/components/squeezebox/services.yaml | 0 homeassistant/components/stream/services.yaml | 0 homeassistant/components/telegram/services.yaml | 0 homeassistant/components/todoist/services.yaml | 0 homeassistant/components/universal/services.yaml | 0 homeassistant/components/websocket_api/services.yaml | 0 homeassistant/components/wemo/services.yaml | 0 homeassistant/components/xiaomi_miio/services.yaml | 0 homeassistant/components/yamaha/services.yaml | 0 script/hassfest/services.py | 7 ++----- 69 files changed, 2 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/alarmdecoder/services.yaml create mode 100644 homeassistant/components/alexa/services.yaml create mode 100644 homeassistant/components/api/services.yaml create mode 100644 homeassistant/components/apns/services.yaml create mode 100644 homeassistant/components/arlo/services.yaml create mode 100644 homeassistant/components/blackbird/services.yaml create mode 100644 homeassistant/components/bluesound/services.yaml create mode 100644 homeassistant/components/bluetooth_tracker/services.yaml create mode 100644 homeassistant/components/browser/services.yaml create mode 100644 homeassistant/components/channels/services.yaml create mode 100644 homeassistant/components/cloudflare/services.yaml create mode 100644 homeassistant/components/config/services.yaml create mode 100644 homeassistant/components/configurator/services.yaml create mode 100644 homeassistant/components/demo/services.yaml create mode 100644 homeassistant/components/device_sun_light_trigger/services.yaml create mode 100644 homeassistant/components/dominos/services.yaml create mode 100644 homeassistant/components/downloader/services.yaml create mode 100644 homeassistant/components/duckdns/services.yaml create mode 100644 homeassistant/components/ecobee/services.yaml create mode 100644 homeassistant/components/econet/services.yaml create mode 100644 homeassistant/components/emulated_hue/services.yaml create mode 100644 homeassistant/components/epson/services.yaml create mode 100644 homeassistant/components/facebox/services.yaml create mode 100644 homeassistant/components/flux/services.yaml create mode 100644 homeassistant/components/generic_thermostat/services.yaml create mode 100644 homeassistant/components/harmony/services.yaml create mode 100644 homeassistant/components/html5/services.yaml create mode 100644 homeassistant/components/hue/services.yaml create mode 100644 homeassistant/components/icloud/services.yaml create mode 100644 homeassistant/components/ifttt/services.yaml create mode 100644 homeassistant/components/joaoapps_join/services.yaml create mode 100644 homeassistant/components/keyboard/services.yaml create mode 100644 homeassistant/components/kodi/services.yaml create mode 100644 homeassistant/components/lifx/services.yaml create mode 100644 homeassistant/components/local_file/services.yaml create mode 100644 homeassistant/components/logbook/services.yaml create mode 100644 homeassistant/components/matrix/services.yaml create mode 100644 homeassistant/components/media_extractor/services.yaml create mode 100644 homeassistant/components/mill/services.yaml create mode 100644 homeassistant/components/mobile_app/services.yaml create mode 100644 homeassistant/components/monoprice/services.yaml create mode 100644 homeassistant/components/mysensors/services.yaml create mode 100644 homeassistant/components/neato/services.yaml create mode 100644 homeassistant/components/ness_alarm/services.yaml create mode 100644 homeassistant/components/nuheat/services.yaml create mode 100644 homeassistant/components/nuimo_controller/services.yaml create mode 100644 homeassistant/components/nuki/services.yaml create mode 100644 homeassistant/components/onkyo/services.yaml create mode 100644 homeassistant/components/onvif/services.yaml create mode 100644 homeassistant/components/pilight/services.yaml create mode 100644 homeassistant/components/rest_command/services.yaml create mode 100644 homeassistant/components/roku/services.yaml create mode 100644 homeassistant/components/route53/services.yaml create mode 100644 homeassistant/components/sabnzbd/services.yaml create mode 100644 homeassistant/components/sensibo/services.yaml create mode 100644 homeassistant/components/snapcast/services.yaml create mode 100644 homeassistant/components/songpal/services.yaml create mode 100644 homeassistant/components/sonos/services.yaml create mode 100644 homeassistant/components/soundtouch/services.yaml create mode 100644 homeassistant/components/squeezebox/services.yaml create mode 100644 homeassistant/components/stream/services.yaml create mode 100644 homeassistant/components/telegram/services.yaml create mode 100644 homeassistant/components/todoist/services.yaml create mode 100644 homeassistant/components/universal/services.yaml create mode 100644 homeassistant/components/websocket_api/services.yaml create mode 100644 homeassistant/components/wemo/services.yaml create mode 100644 homeassistant/components/xiaomi_miio/services.yaml create mode 100644 homeassistant/components/yamaha/services.yaml diff --git a/homeassistant/components/alarmdecoder/services.yaml b/homeassistant/components/alarmdecoder/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/alexa/services.yaml b/homeassistant/components/alexa/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/api/services.yaml b/homeassistant/components/api/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/apns/services.yaml b/homeassistant/components/apns/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/arlo/services.yaml b/homeassistant/components/arlo/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/blackbird/services.yaml b/homeassistant/components/blackbird/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/bluesound/services.yaml b/homeassistant/components/bluesound/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/bluetooth_tracker/services.yaml b/homeassistant/components/bluetooth_tracker/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/browser/services.yaml b/homeassistant/components/browser/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/channels/services.yaml b/homeassistant/components/channels/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/cloudflare/services.yaml b/homeassistant/components/cloudflare/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/config/services.yaml b/homeassistant/components/config/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/configurator/services.yaml b/homeassistant/components/configurator/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/demo/services.yaml b/homeassistant/components/demo/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/device_sun_light_trigger/services.yaml b/homeassistant/components/device_sun_light_trigger/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/dominos/services.yaml b/homeassistant/components/dominos/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/downloader/services.yaml b/homeassistant/components/downloader/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/duckdns/services.yaml b/homeassistant/components/duckdns/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/ecobee/services.yaml b/homeassistant/components/ecobee/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/econet/services.yaml b/homeassistant/components/econet/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/emulated_hue/services.yaml b/homeassistant/components/emulated_hue/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/epson/services.yaml b/homeassistant/components/epson/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/facebox/services.yaml b/homeassistant/components/facebox/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/flux/services.yaml b/homeassistant/components/flux/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/generic_thermostat/services.yaml b/homeassistant/components/generic_thermostat/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/harmony/services.yaml b/homeassistant/components/harmony/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/html5/services.yaml b/homeassistant/components/html5/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/hue/services.yaml b/homeassistant/components/hue/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/icloud/services.yaml b/homeassistant/components/icloud/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/ifttt/services.yaml b/homeassistant/components/ifttt/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/joaoapps_join/services.yaml b/homeassistant/components/joaoapps_join/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/keyboard/services.yaml b/homeassistant/components/keyboard/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/kodi/services.yaml b/homeassistant/components/kodi/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/local_file/services.yaml b/homeassistant/components/local_file/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/logbook/services.yaml b/homeassistant/components/logbook/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/matrix/services.yaml b/homeassistant/components/matrix/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/media_extractor/services.yaml b/homeassistant/components/media_extractor/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/mill/services.yaml b/homeassistant/components/mill/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/mobile_app/services.yaml b/homeassistant/components/mobile_app/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/monoprice/services.yaml b/homeassistant/components/monoprice/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/mysensors/services.yaml b/homeassistant/components/mysensors/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/neato/services.yaml b/homeassistant/components/neato/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/ness_alarm/services.yaml b/homeassistant/components/ness_alarm/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/nuheat/services.yaml b/homeassistant/components/nuheat/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/nuimo_controller/services.yaml b/homeassistant/components/nuimo_controller/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/nuki/services.yaml b/homeassistant/components/nuki/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/onkyo/services.yaml b/homeassistant/components/onkyo/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/onvif/services.yaml b/homeassistant/components/onvif/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/pilight/services.yaml b/homeassistant/components/pilight/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/rest_command/services.yaml b/homeassistant/components/rest_command/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/roku/services.yaml b/homeassistant/components/roku/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/route53/services.yaml b/homeassistant/components/route53/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/sabnzbd/services.yaml b/homeassistant/components/sabnzbd/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/snapcast/services.yaml b/homeassistant/components/snapcast/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/songpal/services.yaml b/homeassistant/components/songpal/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/soundtouch/services.yaml b/homeassistant/components/soundtouch/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/squeezebox/services.yaml b/homeassistant/components/squeezebox/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/stream/services.yaml b/homeassistant/components/stream/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/telegram/services.yaml b/homeassistant/components/telegram/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/todoist/services.yaml b/homeassistant/components/todoist/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/universal/services.yaml b/homeassistant/components/universal/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/websocket_api/services.yaml b/homeassistant/components/websocket_api/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/wemo/services.yaml b/homeassistant/components/wemo/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/xiaomi_miio/services.yaml b/homeassistant/components/xiaomi_miio/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/yamaha/services.yaml b/homeassistant/components/yamaha/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 9765eff1d36..bb04d2fc13f 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -67,11 +67,8 @@ def validate_services(integration: Integration): try: data = load_yaml(str(integration.path / 'services.yaml')) except FileNotFoundError: - print( - "Warning: {} registeres services but has no services.yaml".format( - integration.domain)) - # integration.add_error( - # 'services', 'Registers services but has no services.yaml') + integration.add_error( + 'services', 'Registers services but has no services.yaml') return except HomeAssistantError: integration.add_error( From 5df57bbda51ffeca098ebd46f4212f5d3d7e594f Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 18 Apr 2019 20:23:48 -0400 Subject: [PATCH 23/79] update zha-quirks (#23215) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index e2b2c54fd93..9fd0629fcb2 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/components/zha", "requirements": [ "bellows-homeassistant==0.7.2", - "zha-quirks==0.0.7", + "zha-quirks==0.0.8", "zigpy-deconz==0.1.3", "zigpy-homeassistant==0.3.1", "zigpy-xbee-homeassistant==0.1.3" diff --git a/requirements_all.txt b/requirements_all.txt index f32e6d0f7ee..db853d5ce0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1833,7 +1833,7 @@ zengge==0.2 zeroconf==0.21.3 # homeassistant.components.zha -zha-quirks==0.0.7 +zha-quirks==0.0.8 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 From f618e7253a85c0f9e80279857c899cd322d10308 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 18 Apr 2019 20:21:30 -0400 Subject: [PATCH 24/79] fix bindable devices (#23216) --- homeassistant/components/zha/api.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index aacb0a711a5..0604c2fada4 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -351,10 +351,11 @@ async def websocket_get_bindable_devices(hass, connection, msg): zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] source_ieee = msg[ATTR_IEEE] source_device = zha_gateway.get_device(source_ieee) + ha_device_registry = await async_get_registry(hass) devices = [ - { - **device.device_info - } for device in zha_gateway.devices.values() if + async_get_device_info( + hass, device, ha_device_registry=ha_device_registry + ) for device in zha_gateway.devices.values() if async_is_bindable_target(source_device, device) ] From 15c121392847919c867cf5f075ef495d7140013b Mon Sep 17 00:00:00 2001 From: cgtobi Date: Fri, 19 Apr 2019 23:53:58 +0200 Subject: [PATCH 25/79] Add missing services.yaml file for hue (#23217) * Add hue services.yaml * Add lifx services.yaml * Add lutron services.yaml * Update lifx services.yaml * Update hue services.yaml * Revert lifx services.yaml as it is not necessary * Remove hue from lights/services.yaml --- homeassistant/components/hue/services.yaml | 11 +++++++++++ homeassistant/components/light/services.yaml | 10 ---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/hue/services.yaml b/homeassistant/components/hue/services.yaml index e69de29bb2d..68eaf6ac377 100644 --- a/homeassistant/components/hue/services.yaml +++ b/homeassistant/components/hue/services.yaml @@ -0,0 +1,11 @@ +# Describes the format for available hue services + +hue_activate_scene: + description: Activate a hue scene stored in the hue hub. + fields: + group_name: + description: Name of hue group/room from the hue app. + example: "Living Room" + scene_name: + description: Name of hue scene from the hue app. + example: "Energize" diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index d4985258368..ef944d75efc 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -71,16 +71,6 @@ toggle: '...': description: All turn_on parameters can be used. -hue_activate_scene: - description: Activate a hue scene stored in the hue hub. - fields: - group_name: - description: Name of hue group/room from the hue app. - example: "Living Room" - scene_name: - description: Name of hue scene from the hue app. - example: "Energize" - lifx_set_state: description: Set a color/brightness and possibliy turn the light on/off. fields: From d0c6f0b7104c1996abe5ce4f1d28e0cc99595135 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 19 Apr 2019 14:50:21 -0700 Subject: [PATCH 26/79] Ask users for a pin when interacting with locks/garage doors (#23223) * Ask users for a pin when interacting with locks/garage doors * Deprecate allow_unlock option --- homeassistant/components/cloud/client.py | 2 +- homeassistant/components/cloud/const.py | 2 +- homeassistant/components/cloud/http_api.py | 21 +- homeassistant/components/cloud/prefs.py | 12 +- .../components/google_assistant/__init__.py | 27 +-- .../components/google_assistant/const.py | 15 +- .../components/google_assistant/error.py | 29 +++ .../components/google_assistant/helpers.py | 13 +- .../components/google_assistant/http.py | 21 +- .../components/google_assistant/smart_home.py | 6 +- .../components/google_assistant/trait.py | 66 ++++-- tests/components/cloud/__init__.py | 2 +- tests/components/cloud/test_http_api.py | 9 +- .../google_assistant/test_smart_home.py | 3 - .../components/google_assistant/test_trait.py | 200 +++++++++++++----- 15 files changed, 290 insertions(+), 138 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 9e24b619460..aedd71bd9ac 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -102,7 +102,7 @@ class CloudClient(Interface): self._google_config = ga_h.Config( should_expose=should_expose, - allow_unlock=self._prefs.google_allow_unlock, + secure_devices_pin=self._prefs.google_secure_devices_pin, entity_config=google_conf.get(CONF_ENTITY_CONFIG), ) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 1286832c0c7..5002286edb9 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -5,7 +5,7 @@ REQUEST_TIMEOUT = 10 PREF_ENABLE_ALEXA = 'alexa_enabled' PREF_ENABLE_GOOGLE = 'google_enabled' PREF_ENABLE_REMOTE = 'remote_enabled' -PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' +PREF_GOOGLE_SECURE_DEVICES_PIN = 'google_secure_devices_pin' PREF_CLOUDHOOKS = 'cloudhooks' PREF_CLOUD_USER = 'cloud_user' diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6ab7d911d47..bf9b7833527 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -19,7 +19,7 @@ from homeassistant.components.google_assistant import ( from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_ALLOW_UNLOCK, InvalidTrustedNetworks) + PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks) _LOGGER = logging.getLogger(__name__) @@ -30,15 +30,6 @@ SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) -WS_TYPE_UPDATE_PREFS = 'cloud/update_prefs' -SCHEMA_WS_UPDATE_PREFS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_UPDATE_PREFS, - vol.Optional(PREF_ENABLE_GOOGLE): bool, - vol.Optional(PREF_ENABLE_ALEXA): bool, - vol.Optional(PREF_GOOGLE_ALLOW_UNLOCK): bool, -}) - - WS_TYPE_SUBSCRIPTION = 'cloud/subscription' SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_SUBSCRIPTION, @@ -77,9 +68,7 @@ async def async_setup(hass): SCHEMA_WS_SUBSCRIPTION ) hass.components.websocket_api.async_register_command( - WS_TYPE_UPDATE_PREFS, websocket_update_prefs, - SCHEMA_WS_UPDATE_PREFS - ) + websocket_update_prefs) hass.components.websocket_api.async_register_command( WS_TYPE_HOOK_CREATE, websocket_hook_create, SCHEMA_WS_HOOK_CREATE @@ -358,6 +347,12 @@ async def websocket_subscription(hass, connection, msg): @_require_cloud_login @websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'cloud/update_prefs', + vol.Optional(PREF_ENABLE_GOOGLE): bool, + vol.Optional(PREF_ENABLE_ALEXA): bool, + vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), +}) async def websocket_update_prefs(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index b0244f6b1fb..0e2abae15b0 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -3,7 +3,7 @@ from ipaddress import ip_address from .const import ( DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, - PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS, PREF_CLOUD_USER, + PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER, InvalidTrustedNetworks) STORAGE_KEY = DOMAIN @@ -29,7 +29,7 @@ class CloudPreferences: PREF_ENABLE_ALEXA: True, PREF_ENABLE_GOOGLE: True, PREF_ENABLE_REMOTE: False, - PREF_GOOGLE_ALLOW_UNLOCK: False, + PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_CLOUDHOOKS: {}, PREF_CLOUD_USER: None, } @@ -38,14 +38,14 @@ class CloudPreferences: async def async_update(self, *, google_enabled=_UNDEF, alexa_enabled=_UNDEF, remote_enabled=_UNDEF, - google_allow_unlock=_UNDEF, cloudhooks=_UNDEF, + google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF, cloud_user=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), (PREF_ENABLE_ALEXA, alexa_enabled), (PREF_ENABLE_REMOTE, remote_enabled), - (PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock), + (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUD_USER, cloud_user), ): @@ -85,9 +85,9 @@ class CloudPreferences: return self._prefs[PREF_ENABLE_GOOGLE] @property - def google_allow_unlock(self): + def google_secure_devices_pin(self): """Return if Google is allowed to unlock locks.""" - return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False) + return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN) @property def cloudhooks(self): diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 2d3a19afa13..c8078b7d9d2 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -20,7 +20,7 @@ from .const import ( CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS, CONF_API_KEY, SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG, CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT, CONF_ALLOW_UNLOCK, - DEFAULT_ALLOW_UNLOCK + CONF_SECURE_DEVICES_PIN ) from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401 from .const import EVENT_QUERY_RECEIVED # noqa: F401 @@ -35,17 +35,20 @@ ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_ROOM_HINT): cv.string, }) -GOOGLE_ASSISTANT_SCHEMA = vol.Schema({ - vol.Required(CONF_PROJECT_ID): cv.string, - vol.Optional(CONF_EXPOSE_BY_DEFAULT, - default=DEFAULT_EXPOSE_BY_DEFAULT): cv.boolean, - vol.Optional(CONF_EXPOSED_DOMAINS, - default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, - vol.Optional(CONF_API_KEY): cv.string, - vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA}, - vol.Optional(CONF_ALLOW_UNLOCK, - default=DEFAULT_ALLOW_UNLOCK): cv.boolean, -}, extra=vol.PREVENT_EXTRA) +GOOGLE_ASSISTANT_SCHEMA = vol.All( + cv.deprecated(CONF_ALLOW_UNLOCK, invalidation_version='0.95'), + vol.Schema({ + vol.Required(CONF_PROJECT_ID): cv.string, + vol.Optional(CONF_EXPOSE_BY_DEFAULT, + default=DEFAULT_EXPOSE_BY_DEFAULT): cv.boolean, + vol.Optional(CONF_EXPOSED_DOMAINS, + default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, + vol.Optional(CONF_API_KEY): cv.string, + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA}, + vol.Optional(CONF_ALLOW_UNLOCK): cv.boolean, + # str on purpose, makes sure it is configured correctly. + vol.Optional(CONF_SECURE_DEVICES_PIN): str, + }, extra=vol.PREVENT_EXTRA)) CONFIG_SCHEMA = vol.Schema({ DOMAIN: GOOGLE_ASSISTANT_SCHEMA diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 67c767c080b..07506611109 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -28,13 +28,13 @@ CONF_ALIASES = 'aliases' CONF_API_KEY = 'api_key' CONF_ROOM_HINT = 'room' CONF_ALLOW_UNLOCK = 'allow_unlock' +CONF_SECURE_DEVICES_PIN = 'secure_devices_pin' DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ 'climate', 'cover', 'fan', 'group', 'input_boolean', 'light', 'media_player', 'scene', 'script', 'switch', 'vacuum', 'lock', ] -DEFAULT_ALLOW_UNLOCK = False PREFIX_TYPES = 'action.devices.types.' TYPE_CAMERA = PREFIX_TYPES + 'CAMERA' @@ -55,7 +55,7 @@ HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync' # Error codes used for SmartHomeError class -# https://developers.google.com/actions/smarthome/create-app#error_responses +# https://developers.google.com/actions/reference/smarthome/errors-exceptions ERR_DEVICE_OFFLINE = "deviceOffline" ERR_DEVICE_NOT_FOUND = "deviceNotFound" ERR_VALUE_OUT_OF_RANGE = "valueOutOfRange" @@ -64,6 +64,12 @@ ERR_PROTOCOL_ERROR = 'protocolError' ERR_UNKNOWN_ERROR = 'unknownError' ERR_FUNCTION_NOT_SUPPORTED = 'functionNotSupported' +ERR_CHALLENGE_NEEDED = 'challengeNeeded' +ERR_CHALLENGE_NOT_SETUP = 'challengeFailedNotSetup' +ERR_TOO_MANY_FAILED_ATTEMPTS = 'tooManyFailedAttempts' +ERR_PIN_INCORRECT = 'pinIncorrect' +ERR_USER_CANCELLED = 'userCancelled' + # Event types EVENT_COMMAND_RECEIVED = 'google_assistant_command' EVENT_QUERY_RECEIVED = 'google_assistant_query' @@ -95,5 +101,8 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_LOCK): TYPE_SENSOR, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_OPENING): TYPE_SENSOR, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_WINDOW): TYPE_SENSOR, - } + +CHALLENGE_ACK_NEEDED = 'ackNeeded' +CHALLENGE_PIN_NEEDED = 'pinNeeded' +CHALLENGE_FAILED_PIN_NEEDED = 'challengeFailedPinNeeded' diff --git a/homeassistant/components/google_assistant/error.py b/homeassistant/components/google_assistant/error.py index 2225bb58242..3aef1e9408d 100644 --- a/homeassistant/components/google_assistant/error.py +++ b/homeassistant/components/google_assistant/error.py @@ -1,4 +1,5 @@ """Errors for Google Assistant.""" +from .const import ERR_CHALLENGE_NEEDED class SmartHomeError(Exception): @@ -11,3 +12,31 @@ class SmartHomeError(Exception): """Log error code.""" super().__init__(msg) self.code = code + + def to_response(self): + """Convert to a response format.""" + return { + 'errorCode': self.code + } + + +class ChallengeNeeded(SmartHomeError): + """Google Assistant Smart Home errors. + + https://developers.google.com/actions/smarthome/create-app#error_responses + """ + + def __init__(self, challenge_type): + """Initialize challenge needed error.""" + super().__init__(ERR_CHALLENGE_NEEDED, + 'Challenge needed: {}'.format(challenge_type)) + self.challenge_type = challenge_type + + def to_response(self): + """Convert to a response format.""" + return { + 'errorCode': self.code, + 'challengeNeeded': { + 'type': self.challenge_type + } + } diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 982b840393e..71cce9de500 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -19,12 +19,12 @@ from .error import SmartHomeError class Config: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, allow_unlock, - entity_config=None): + def __init__(self, should_expose, + entity_config=None, secure_devices_pin=None): """Initialize the configuration.""" self.should_expose = should_expose self.entity_config = entity_config or {} - self.allow_unlock = allow_unlock + self.secure_devices_pin = secure_devices_pin class RequestData: @@ -168,15 +168,18 @@ class GoogleEntity: return attrs - async def execute(self, command, data, params): + async def execute(self, data, command_payload): """Execute a command. https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute """ + command = command_payload['command'] + params = command_payload.get('params', {}) + challenge = command_payload.get('challenge', {}) executed = False for trt in self.traits(): if trt.can_execute(command, params): - await trt.execute(command, data, params) + await trt.execute(command, data, params, challenge) executed = True break diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 11d8a384165..d385d742c7d 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -10,12 +10,12 @@ from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, - CONF_ALLOW_UNLOCK, CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, CONF_ENTITY_CONFIG, CONF_EXPOSE, - ) + CONF_SECURE_DEVICES_PIN, +) from .smart_home import async_handle_message from .helpers import Config @@ -28,7 +28,7 @@ def async_register_http(hass, cfg): expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) entity_config = cfg.get(CONF_ENTITY_CONFIG) or {} - allow_unlock = cfg.get(CONF_ALLOW_UNLOCK, False) + secure_devices_pin = cfg.get(CONF_SECURE_DEVICES_PIN) def is_exposed(entity) -> bool: """Determine if an entity should be exposed to Google Assistant.""" @@ -53,8 +53,13 @@ def async_register_http(hass, cfg): return is_default_exposed or explicit_expose - hass.http.register_view( - GoogleAssistantView(is_exposed, entity_config, allow_unlock)) + config = Config( + should_expose=is_exposed, + entity_config=entity_config, + secure_devices_pin=secure_devices_pin + ) + + hass.http.register_view(GoogleAssistantView(config)) class GoogleAssistantView(HomeAssistantView): @@ -64,11 +69,9 @@ class GoogleAssistantView(HomeAssistantView): name = 'api:google_assistant' requires_auth = True - def __init__(self, is_exposed, entity_config, allow_unlock): + def __init__(self, config): """Initialize the Google Assistant request handler.""" - self.config = Config(is_exposed, - allow_unlock, - entity_config) + self.config = config async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 9edde36f09d..37f35edf645 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -177,14 +177,12 @@ async def handle_devices_execute(hass, data, payload): entities[entity_id] = GoogleEntity(hass, data.config, state) try: - await entities[entity_id].execute(execution['command'], - data, - execution.get('params', {})) + await entities[entity_id].execute(data, execution) except SmartHomeError as err: results[entity_id] = { 'ids': [entity_id], 'status': 'ERROR', - 'errorCode': err.code + **err.to_response() } final_results = list(results.values()) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 5bec683ccc7..bad186a4edb 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -19,6 +19,7 @@ from homeassistant.components import ( from homeassistant.components.climate import const as climate from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_DEVICE_CLASS, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_LOCKED, @@ -37,8 +38,12 @@ from .const import ( ERR_VALUE_OUT_OF_RANGE, ERR_NOT_SUPPORTED, ERR_FUNCTION_NOT_SUPPORTED, + ERR_CHALLENGE_NOT_SETUP, + CHALLENGE_ACK_NEEDED, + CHALLENGE_PIN_NEEDED, + CHALLENGE_FAILED_PIN_NEEDED, ) -from .error import SmartHomeError +from .error import SmartHomeError, ChallengeNeeded _LOGGER = logging.getLogger(__name__) @@ -114,7 +119,7 @@ class _Trait: """Test if command can be executed.""" return command in self.commands - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a trait command.""" raise NotImplementedError @@ -164,7 +169,7 @@ class BrightnessTrait(_Trait): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a brightness command.""" domain = self.state.domain @@ -219,7 +224,7 @@ class CameraStreamTrait(_Trait): """Return camera stream attributes.""" return self.stream_info or {} - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a get camera stream command.""" url = await self.hass.components.camera.async_request_stream( self.state.entity_id, 'hls') @@ -260,7 +265,7 @@ class OnOffTrait(_Trait): """Return OnOff query attributes.""" return {'on': self.state.state != STATE_OFF} - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute an OnOff command.""" domain = self.state.domain @@ -353,7 +358,7 @@ class ColorSettingTrait(_Trait): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a color temperature command.""" if 'temperature' in params['color']: temp = color_util.color_temperature_kelvin_to_mired( @@ -424,7 +429,7 @@ class SceneTrait(_Trait): """Return scene query attributes.""" return {} - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a scene command.""" # Don't block for scripts as they can be slow. await self.hass.services.async_call( @@ -459,7 +464,7 @@ class DockTrait(_Trait): """Return dock query attributes.""" return {'isDocked': self.state.state == vacuum.STATE_DOCKED} - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a dock command.""" await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_RETURN_TO_BASE, { @@ -498,7 +503,7 @@ class StartStopTrait(_Trait): 'isPaused': self.state.state == vacuum.STATE_PAUSED, } - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a StartStop command.""" if command == COMMAND_STARTSTOP: if params['start']: @@ -634,7 +639,7 @@ class TemperatureSettingTrait(_Trait): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a temperature point or mode command.""" # All sent in temperatures are always in Celsius unit = self.hass.config.units.temperature_unit @@ -748,13 +753,10 @@ class LockUnlockTrait(_Trait): """Return LockUnlock query attributes.""" return {'isLocked': self.state.state == STATE_LOCKED} - def can_execute(self, command, params): - """Test if command can be executed.""" - allowed_unlock = not params['lock'] and self.config.allow_unlock - return params['lock'] or allowed_unlock - - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute an LockUnlock command.""" + _verify_pin_challenge(data, challenge) + if params['lock']: service = lock.SERVICE_LOCK else: @@ -832,7 +834,7 @@ class FanSpeedTrait(_Trait): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute an SetFanSpeed command.""" await self.hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_SPEED, { @@ -1006,7 +1008,7 @@ class ModesTrait(_Trait): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute an SetModes command.""" settings = params.get('updateModeSettings') requested_source = settings.get( @@ -1097,11 +1099,16 @@ class OpenCloseTrait(_Trait): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute an Open, close, Set position command.""" domain = self.state.domain if domain == cover.DOMAIN: + if self.state.attributes.get(ATTR_DEVICE_CLASS) in ( + cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE + ): + _verify_pin_challenge(data, challenge) + position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) if params['openPercent'] == 0: await self.hass.services.async_call( @@ -1123,3 +1130,24 @@ class OpenCloseTrait(_Trait): raise SmartHomeError( ERR_FUNCTION_NOT_SUPPORTED, 'Setting a position is not supported') + + +def _verify_pin_challenge(data, challenge): + """Verify a pin challenge.""" + if not data.config.secure_devices_pin: + raise SmartHomeError( + ERR_CHALLENGE_NOT_SETUP, 'Challenge is not set up') + + if not challenge: + raise ChallengeNeeded(CHALLENGE_PIN_NEEDED) + + pin = challenge.get('pin') + + if pin != data.config.secure_devices_pin: + raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) + + +def _verify_ack_challenge(data, challenge): + """Verify a pin challenge.""" + if not challenge or not challenge.get('ack'): + raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 3a07e52724f..08ab5324b97 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -26,7 +26,7 @@ def mock_cloud_prefs(hass, prefs={}): prefs_to_set = { const.PREF_ENABLE_ALEXA: True, const.PREF_ENABLE_GOOGLE: True, - const.PREF_GOOGLE_ALLOW_UNLOCK: True, + const.PREF_GOOGLE_SECURE_DEVICES_PIN: None, } prefs_to_set.update(prefs) hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index c147f8492d7..4aebc5679a0 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -9,7 +9,8 @@ from hass_nabucasa.const import STATE_CONNECTED from homeassistant.auth.providers import trusted_networks as tn_auth from homeassistant.components.cloud.const import ( - PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK, DOMAIN) + PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_SECURE_DEVICES_PIN, + DOMAIN) from tests.common import mock_coro @@ -493,21 +494,21 @@ async def test_websocket_update_preferences(hass, hass_ws_client, """Test updating preference.""" assert setup_api[PREF_ENABLE_GOOGLE] assert setup_api[PREF_ENABLE_ALEXA] - assert setup_api[PREF_GOOGLE_ALLOW_UNLOCK] + assert setup_api[PREF_GOOGLE_SECURE_DEVICES_PIN] is None client = await hass_ws_client(hass) await client.send_json({ 'id': 5, 'type': 'cloud/update_prefs', 'alexa_enabled': False, 'google_enabled': False, - 'google_allow_unlock': False, + 'google_secure_devices_pin': '1234', }) response = await client.receive_json() assert response['success'] assert not setup_api[PREF_ENABLE_GOOGLE] assert not setup_api[PREF_ENABLE_ALEXA] - assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK] + assert setup_api[PREF_GOOGLE_SECURE_DEVICES_PIN] == '1234' async def test_enabling_webhook(hass, hass_ws_client, setup_api, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 30a398fccc3..8ea6f26553d 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -22,7 +22,6 @@ from tests.common import (mock_device_registry, mock_registry, BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=False ) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -57,7 +56,6 @@ async def test_sync_message(hass): config = helpers.Config( should_expose=lambda state: state.entity_id != 'light.not_expose', - allow_unlock=False, entity_config={ 'light.demo_light': { const.CONF_ROOM_HINT: 'Living Room', @@ -146,7 +144,6 @@ async def test_sync_in_area(hass, registries): config = helpers.Config( should_expose=lambda _: True, - allow_unlock=False, entity_config={} ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 12731978f57..8b7f0788f34 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -19,7 +19,8 @@ from homeassistant.components import ( group, ) from homeassistant.components.climate import const as climate -from homeassistant.components.google_assistant import trait, helpers, const +from homeassistant.components.google_assistant import ( + trait, helpers, const, error) from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, @@ -30,7 +31,6 @@ from tests.common import async_mock_service, mock_coro BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=False ) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -41,9 +41,15 @@ BASIC_DATA = helpers.RequestData( REQ_ID, ) -UNSAFE_CONFIG = helpers.Config( +PIN_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=True, + secure_devices_pin='1234' +) + +PIN_DATA = helpers.RequestData( + PIN_CONFIG, + 'test-agent', + REQ_ID, ) @@ -69,7 +75,7 @@ async def test_brightness_light(hass): calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) await trt.execute( trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, - {'brightness': 50}) + {'brightness': 50}, {}) await hass.async_block_till_done() assert len(calls) == 1 @@ -108,7 +114,7 @@ async def test_brightness_media_player(hass): hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET) await trt.execute( trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, - {'brightness': 60}) + {'brightness': 60}, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -139,7 +145,7 @@ async def test_camera_stream(hass): with patch('homeassistant.components.camera.async_request_stream', return_value=mock_coro('/api/streams/bla')): - await trt.execute(trait.COMMAND_GET_CAMERA_STREAM, BASIC_DATA, {}) + await trt.execute(trait.COMMAND_GET_CAMERA_STREAM, BASIC_DATA, {}, {}) assert trt.query_attributes() == { 'cameraStreamAccessUrl': 'http://1.1.1.1:8123/api/streams/bla' @@ -169,7 +175,7 @@ async def test_onoff_group(hass): on_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'group.bla', @@ -178,7 +184,7 @@ async def test_onoff_group(hass): off_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_OFF) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'group.bla', @@ -209,7 +215,7 @@ async def test_onoff_input_boolean(hass): on_calls = async_mock_service(hass, input_boolean.DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'input_boolean.bla', @@ -219,7 +225,7 @@ async def test_onoff_input_boolean(hass): SERVICE_TURN_OFF) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'input_boolean.bla', @@ -250,7 +256,7 @@ async def test_onoff_switch(hass): on_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'switch.bla', @@ -259,7 +265,7 @@ async def test_onoff_switch(hass): off_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_OFF) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'switch.bla', @@ -287,7 +293,7 @@ async def test_onoff_fan(hass): on_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'fan.bla', @@ -296,7 +302,7 @@ async def test_onoff_fan(hass): off_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_OFF) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'fan.bla', @@ -326,7 +332,7 @@ async def test_onoff_light(hass): on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'light.bla', @@ -335,7 +341,7 @@ async def test_onoff_light(hass): off_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_OFF) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'light.bla', @@ -366,7 +372,7 @@ async def test_onoff_media_player(hass): on_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -377,7 +383,7 @@ async def test_onoff_media_player(hass): await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -408,7 +414,7 @@ async def test_dock_vacuum(hass): calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_RETURN_TO_BASE) await trt.execute( - trait.COMMAND_DOCK, BASIC_DATA, {}) + trait.COMMAND_DOCK, BASIC_DATA, {}, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -433,7 +439,7 @@ async def test_startstop_vacuum(hass): start_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_START) - await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': True}) + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': True}, {}) assert len(start_calls) == 1 assert start_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -441,7 +447,8 @@ async def test_startstop_vacuum(hass): stop_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_STOP) - await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': False}) + await trt.execute( + trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': False}, {}) assert len(stop_calls) == 1 assert stop_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -449,7 +456,8 @@ async def test_startstop_vacuum(hass): pause_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_PAUSE) - await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': True}) + await trt.execute( + trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': True}, {}) assert len(pause_calls) == 1 assert pause_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -457,7 +465,8 @@ async def test_startstop_vacuum(hass): unpause_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_START) - await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': False}) + await trt.execute( + trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': False}, {}) assert len(unpause_calls) == 1 assert unpause_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -502,7 +511,7 @@ async def test_color_setting_color_light(hass): 'color': { 'spectrumRGB': 1052927 } - }) + }, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'light.bla', @@ -517,7 +526,7 @@ async def test_color_setting_color_light(hass): 'value': .20, } } - }) + }, {}) assert len(calls) == 2 assert calls[1].data == { ATTR_ENTITY_ID: 'light.bla', @@ -565,14 +574,14 @@ async def test_color_setting_temperature_light(hass): 'color': { 'temperature': 5555 } - }) + }, {}) assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, { 'color': { 'temperature': 2857 } - }) + }, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'light.bla', @@ -608,7 +617,7 @@ async def test_scene_scene(hass): assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}) + await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'scene.bla', @@ -626,7 +635,7 @@ async def test_scene_script(hass): assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) calls = async_mock_service(hass, script.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}) + await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}, {}) # We don't wait till script execution is done. await hass.async_block_till_done() @@ -671,14 +680,14 @@ async def test_temperature_setting_climate_onoff(hass): hass, climate.DOMAIN, SERVICE_TURN_ON) await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, { 'thermostatMode': 'on', - }) + }, {}) assert len(calls) == 1 calls = async_mock_service( hass, climate.DOMAIN, SERVICE_TURN_OFF) await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, { 'thermostatMode': 'off', - }) + }, {}) assert len(calls) == 1 @@ -731,7 +740,7 @@ async def test_temperature_setting_climate_range(hass): trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, BASIC_DATA, { 'thermostatTemperatureSetpointHigh': 25, 'thermostatTemperatureSetpointLow': 20, - }) + }, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -743,7 +752,7 @@ async def test_temperature_setting_climate_range(hass): hass, climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE) await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, { 'thermostatMode': 'heatcool', - }) + }, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -753,7 +762,7 @@ async def test_temperature_setting_climate_range(hass): with pytest.raises(helpers.SmartHomeError) as err: await trt.execute( trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, - {'thermostatTemperatureSetpoint': -100}) + {'thermostatTemperatureSetpoint': -100}, {}) assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE hass.config.units.temperature_unit = TEMP_CELSIUS @@ -799,11 +808,11 @@ async def test_temperature_setting_climate_setpoint(hass): with pytest.raises(helpers.SmartHomeError): await trt.execute( trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, - {'thermostatTemperatureSetpoint': -100}) + {'thermostatTemperatureSetpoint': -100}, {}) await trt.execute( trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, - {'thermostatTemperatureSetpoint': 19}) + {'thermostatTemperatureSetpoint': 19}, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -851,7 +860,7 @@ async def test_temperature_setting_climate_setpoint_auto(hass): await trt.execute( trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, - {'thermostatTemperatureSetpoint': 19}) + {'thermostatTemperatureSetpoint': 19}, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -867,7 +876,7 @@ async def test_lock_unlock_lock(hass): trt = trait.LockUnlockTrait(hass, State('lock.front_door', lock.STATE_UNLOCKED), - BASIC_CONFIG) + PIN_CONFIG) assert trt.sync_attributes() == {} @@ -878,7 +887,26 @@ async def test_lock_unlock_lock(hass): assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': True}) calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_LOCK) - await trt.execute(trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': True}) + + # No challenge data + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': True}, {}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_PIN_NEEDED + + # invalid pin + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': True}, + {'pin': 9999}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_FAILED_PIN_NEEDED + + await trt.execute(trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': True}, + {'pin': '1234'}) assert len(calls) == 1 assert calls[0].data == { @@ -894,19 +922,7 @@ async def test_lock_unlock_unlock(hass): trt = trait.LockUnlockTrait(hass, State('lock.front_door', lock.STATE_LOCKED), - BASIC_CONFIG) - - assert trt.sync_attributes() == {} - - assert trt.query_attributes() == { - 'isLocked': True - } - - assert not trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) - - trt = trait.LockUnlockTrait(hass, - State('lock.front_door', lock.STATE_LOCKED), - UNSAFE_CONFIG) + PIN_CONFIG) assert trt.sync_attributes() == {} @@ -917,7 +933,26 @@ async def test_lock_unlock_unlock(hass): assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_UNLOCK) - await trt.execute(trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}) + + # No challenge data + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': False}, {}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_PIN_NEEDED + + # invalid pin + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': False}, + {'pin': 9999}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_FAILED_PIN_NEEDED + + await trt.execute( + trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': False}, {'pin': '1234'}) assert len(calls) == 1 assert calls[0].data == { @@ -1000,7 +1035,7 @@ async def test_fan_speed(hass): calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_SPEED) await trt.execute( - trait.COMMAND_FANSPEED, BASIC_DATA, {'fanSpeed': 'medium'}) + trait.COMMAND_FANSPEED, BASIC_DATA, {'fanSpeed': 'medium'}, {}) assert len(calls) == 1 assert calls[0].data == { @@ -1089,7 +1124,7 @@ async def test_modes(hass): trait.COMMAND_MODES, BASIC_DATA, { 'updateModeSettings': { trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media' - }}) + }}, {}) assert len(calls) == 1 assert calls[0].data == { @@ -1145,7 +1180,58 @@ async def test_openclose_cover(hass): hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) await trt.execute( trait.COMMAND_OPENCLOSE, BASIC_DATA, - {'openPercent': 50}) + {'openPercent': 50}, {}) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'cover.bla', + cover.ATTR_POSITION: 50 + } + + +@pytest.mark.parametrize('device_class', ( + cover.DEVICE_CLASS_DOOR, + cover.DEVICE_CLASS_GARAGE, +)) +async def test_openclose_cover_secure(hass, device_class): + """Test OpenClose trait support for cover domain.""" + assert helpers.get_google_type(cover.DOMAIN, device_class) is not None + assert trait.OpenCloseTrait.supported( + cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class) + + trt = trait.OpenCloseTrait(hass, State('cover.bla', cover.STATE_OPEN, { + ATTR_DEVICE_CLASS: device_class, + cover.ATTR_CURRENT_POSITION: 75 + }), PIN_CONFIG) + + assert trt.sync_attributes() == {} + assert trt.query_attributes() == { + 'openPercent': 75 + } + + calls = async_mock_service( + hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + + # No challenge data + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_OPENCLOSE, PIN_DATA, + {'openPercent': 50}, {}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_PIN_NEEDED + + # invalid pin + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_OPENCLOSE, PIN_DATA, + {'openPercent': 50}, {'pin': '9999'}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_FAILED_PIN_NEEDED + + await trt.execute( + trait.COMMAND_OPENCLOSE, PIN_DATA, + {'openPercent': 50}, {'pin': '1234'}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'cover.bla', From 8741a2019187006093d7c837d4e7d5a7c53e4339 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 18 Apr 2019 23:56:24 -0700 Subject: [PATCH 27/79] Async fix for bluetooth stopping (#23225) --- .../components/bluetooth_le_tracker/device_tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index f24b943f188..d256f56e7fe 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -24,7 +24,7 @@ def setup_scanner(hass, config, see, discovery_info=None): new_devices = {} hass.data.setdefault(DATA_BLE, {DATA_BLE_ADAPTER: None}) - async def async_stop(event): + def handle_stop(event): """Try to shut down the bluetooth child process nicely.""" # These should never be unset at the point this runs, but just for # safety's sake, use `get`. @@ -32,7 +32,7 @@ def setup_scanner(hass, config, see, discovery_info=None): if adapter is not None: adapter.kill() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop) def see_device(address, name, new_device=False): """Mark a device as seen.""" From ff047e1cd163c7faf4bdb404f8baa2b9c7f60f97 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 19 Apr 2019 18:56:34 -0500 Subject: [PATCH 28/79] Return 0 instead of None (#23261) --- homeassistant/components/plex/media_player.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 9ff00ed1c23..4cb4204f274 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -672,7 +672,7 @@ class PlexClient(MediaPlayerDevice): def supported_features(self): """Flag media player features that are supported.""" if not self._is_player_active: - return None + return 0 # force show all controls if self.config.get(CONF_SHOW_ALL_CONTROLS): @@ -683,7 +683,7 @@ class PlexClient(MediaPlayerDevice): # only show controls when we know what device is connecting if not self._make: - return None + return 0 # no mute support if self.make.lower() == "shield android tv": _LOGGER.debug( @@ -708,7 +708,7 @@ class PlexClient(MediaPlayerDevice): SUPPORT_VOLUME_SET | SUPPORT_PLAY | SUPPORT_TURN_OFF | SUPPORT_VOLUME_MUTE) - return None + return 0 def set_volume_level(self, volume): """Set volume level, range 0..1.""" From ef28e2cc2a1c97a4ab844b413c3007990365369d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 19 Apr 2019 17:05:56 -0700 Subject: [PATCH 29/79] Bumped version to 0.92.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2032148bfdd..5e5934c272f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 92 -PATCH_VERSION = '0b1' +PATCH_VERSION = '0b2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 6d31d56c03d7c869cba0c13fced1e6f808fe9445 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 22 Apr 2019 20:07:03 +0100 Subject: [PATCH 30/79] Backport missing folder fix from #23191 (#23297) --- homeassistant/components/homekit_controller/connection.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 2ca568b547f..032032d30ab 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -145,9 +145,14 @@ class HKDevice(): self.pairing = self.controller.pairings.get(self.hkid) if self.pairing is not None: - pairing_file = os.path.join( + pairing_dir = os.path.join( self.hass.config.path(), HOMEKIT_DIR, + ) + if not os.path.exists(pairing_dir): + os.makedirs(pairing_dir) + pairing_file = os.path.join( + pairing_dir, PAIRING_FILE, ) self.controller.save_data(pairing_file) From a2565ad3b4db1d4b94a7b38e92bdfd3e8ea7c093 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 19 Apr 2019 21:22:40 -0500 Subject: [PATCH 31/79] Update pyheos and log service errors in HEOS integration (#23222) * Update pyheos and command error handling * Correct comment and remove unnecessary autospec --- homeassistant/components/heos/manifest.json | 2 +- homeassistant/components/heos/media_player.py | 29 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/heos/conftest.py | 8 +- tests/components/heos/test_media_player.py | 208 +++++++++++++----- 6 files changed, 192 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 2977345f97d..97b53935614 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -3,7 +3,7 @@ "name": "Heos", "documentation": "https://www.home-assistant.io/components/heos", "requirements": [ - "pyheos==0.3.1" + "pyheos==0.4.0" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 0da9db31bb2..8821591df20 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -1,5 +1,6 @@ """Denon HEOS Media Player.""" -from functools import reduce +from functools import reduce, wraps +import logging from operator import ior from typing import Sequence @@ -21,6 +22,8 @@ BASE_SUPPORTED_FEATURES = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \ SUPPORT_VOLUME_STEP | SUPPORT_CLEAR_PLAYLIST | \ SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOURCE +_LOGGER = logging.getLogger(__name__) + async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): @@ -36,6 +39,20 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, async_add_entities(devices, True) +def log_command_error(command: str): + """Return decorator that logs command failure.""" + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + from pyheos import CommandError + try: + await func(*args, **kwargs) + except CommandError as ex: + _LOGGER.error("Unable to %s: %s", command, ex) + return wrapper + return decorator + + class HeosMediaPlayer(MediaPlayerDevice): """The HEOS player.""" @@ -101,42 +118,52 @@ class HeosMediaPlayer(MediaPlayerDevice): self.hass.helpers.dispatcher.async_dispatcher_connect( SIGNAL_HEOS_SOURCES_UPDATED, self._sources_updated)) + @log_command_error("clear playlist") async def async_clear_playlist(self): """Clear players playlist.""" await self._player.clear_queue() + @log_command_error("pause") async def async_media_pause(self): """Send pause command.""" await self._player.pause() + @log_command_error("play") async def async_media_play(self): """Send play command.""" await self._player.play() + @log_command_error("move to previous track") async def async_media_previous_track(self): """Send previous track command.""" await self._player.play_previous() + @log_command_error("move to next track") async def async_media_next_track(self): """Send next track command.""" await self._player.play_next() + @log_command_error("stop") async def async_media_stop(self): """Send stop command.""" await self._player.stop() + @log_command_error("set mute") async def async_mute_volume(self, mute): """Mute the volume.""" await self._player.set_mute(mute) + @log_command_error("select source") async def async_select_source(self, source): """Select input source.""" await self._source_manager.play_source(source, self._player) + @log_command_error("set shuffle") async def async_set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" await self._player.set_play_mode(self._player.repeat, shuffle) + @log_command_error("set volume level") async def async_set_volume_level(self, volume): """Set volume level, range 0..1.""" await self._player.set_volume(int(volume * 100)) diff --git a/requirements_all.txt b/requirements_all.txt index db853d5ce0c..5810fb5e82e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1076,7 +1076,7 @@ pygtt==1.1.2 pyhaversion==2.2.0 # homeassistant.components.heos -pyheos==0.3.1 +pyheos==0.4.0 # homeassistant.components.hikvision pyhik==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a83e2db30c..9499ccdac32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -217,7 +217,7 @@ pydeconz==54 pydispatcher==2.0.5 # homeassistant.components.heos -pyheos==0.3.1 +pyheos==0.4.0 # homeassistant.components.homematic pyhomematic==0.1.58 diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 211153b1cc7..496f143d51f 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -44,7 +44,7 @@ def config_fixture(): @pytest.fixture(name="players") def player_fixture(dispatcher): """Create a mock HeosPlayer.""" - player = Mock(HeosPlayer, autospec=True) + player = Mock(HeosPlayer) player.heos.dispatcher = dispatcher player.player_id = 1 player.name = "Test Player" @@ -77,11 +77,11 @@ def player_fixture(dispatcher): @pytest.fixture(name="favorites") def favorites_fixture() -> Dict[int, HeosSource]: """Create favorites fixture.""" - station = Mock(HeosSource, autospec=True) + station = Mock(HeosSource) station.type = const.TYPE_STATION station.name = "Today's Hits Radio" station.media_id = '123456789' - radio = Mock(HeosSource, autospec=True) + radio = Mock(HeosSource) radio.type = const.TYPE_STATION radio.name = "Classical MPR (Classical Music)" radio.media_id = 's1234' @@ -94,7 +94,7 @@ def favorites_fixture() -> Dict[int, HeosSource]: @pytest.fixture(name="input_sources") def input_sources_fixture() -> Sequence[InputSource]: """Create a set of input sources for testing.""" - source = Mock(InputSource, autospec=True) + source = Mock(InputSource) source.player_id = 1 source.input_name = const.INPUT_AUX_IN_1 source.name = "HEOS Drive - Line In 1" diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index dd36c2c013d..0870f82b3ff 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -1,7 +1,7 @@ """Tests for the Heos Media Player platform.""" import asyncio -from pyheos import const +from pyheos import const, CommandError from homeassistant.components.heos import media_player from homeassistant.components.heos.const import ( @@ -162,67 +162,142 @@ async def test_updates_from_user_changed( assert state.attributes[ATTR_INPUT_SOURCE_LIST] == source_list -async def test_services(hass, config_entry, config, controller): - """Tests player commands.""" +async def test_clear_playlist(hass, config_entry, config, controller, caplog): + """Test the clear playlist service.""" await setup_platform(hass, config_entry, config) player = controller.players[1] + # First pass completes successfully, second pass raises command error + for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, + {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) + assert player.clear_queue.call_count == 1 + player.clear_queue.reset_mock() + player.clear_queue.side_effect = CommandError(None, "Failure", 1) + assert "Unable to clear playlist: Failure (1)" in caplog.text - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, - {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) - assert player.clear_queue.call_count == 1 - player.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, - {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) - assert player.pause.call_count == 1 +async def test_pause(hass, config_entry, config, controller, caplog): + """Test the pause service.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + # First pass completes successfully, second pass raises command error + for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) + assert player.pause.call_count == 1 + player.pause.reset_mock() + player.pause.side_effect = CommandError(None, "Failure", 1) + assert "Unable to pause: Failure (1)" in caplog.text - player.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, - {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) - assert player.play.call_count == 1 - player.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, - {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) - assert player.play_previous.call_count == 1 +async def test_play(hass, config_entry, config, controller, caplog): + """Test the play service.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + # First pass completes successfully, second pass raises command error + for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) + assert player.play.call_count == 1 + player.play.reset_mock() + player.play.side_effect = CommandError(None, "Failure", 1) + assert "Unable to play: Failure (1)" in caplog.text - player.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, - {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) - assert player.play_next.call_count == 1 - player.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, - {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) - assert player.stop.call_count == 1 +async def test_previous_track(hass, config_entry, config, controller, caplog): + """Test the previous track service.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + # First pass completes successfully, second pass raises command error + for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) + assert player.play_previous.call_count == 1 + player.play_previous.reset_mock() + player.play_previous.side_effect = CommandError(None, "Failure", 1) + assert "Unable to move to previous track: Failure (1)" in caplog.text - player.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, - {ATTR_ENTITY_ID: 'media_player.test_player', - ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True) - player.set_mute.assert_called_once_with(True) - player.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, SERVICE_SHUFFLE_SET, - {ATTR_ENTITY_ID: 'media_player.test_player', - ATTR_MEDIA_SHUFFLE: True}, blocking=True) - player.set_play_mode.assert_called_once_with(player.repeat, True) +async def test_next_track(hass, config_entry, config, controller, caplog): + """Test the next track service.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + # First pass completes successfully, second pass raises command error + for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) + assert player.play_next.call_count == 1 + player.play_next.reset_mock() + player.play_next.side_effect = CommandError(None, "Failure", 1) + assert "Unable to move to next track: Failure (1)" in caplog.text - player.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, - {ATTR_ENTITY_ID: 'media_player.test_player', - ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True) - player.set_volume.assert_called_once_with(100) - assert isinstance(player.set_volume.call_args[0][0], int) + +async def test_stop(hass, config_entry, config, controller, caplog): + """Test the stop service.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + # First pass completes successfully, second pass raises command error + for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True) + assert player.stop.call_count == 1 + player.stop.reset_mock() + player.stop.side_effect = CommandError(None, "Failure", 1) + assert "Unable to stop: Failure (1)" in caplog.text + + +async def test_volume_mute(hass, config_entry, config, controller, caplog): + """Test the volume mute service.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + # First pass completes successfully, second pass raises command error + for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: 'media_player.test_player', + ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True) + assert player.set_mute.call_count == 1 + player.set_mute.reset_mock() + player.set_mute.side_effect = CommandError(None, "Failure", 1) + assert "Unable to set mute: Failure (1)" in caplog.text + + +async def test_shuffle_set(hass, config_entry, config, controller, caplog): + """Test the shuffle set service.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + # First pass completes successfully, second pass raises command error + for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_SHUFFLE_SET, + {ATTR_ENTITY_ID: 'media_player.test_player', + ATTR_MEDIA_SHUFFLE: True}, blocking=True) + player.set_play_mode.assert_called_once_with(player.repeat, True) + player.set_play_mode.reset_mock() + player.set_play_mode.side_effect = CommandError(None, "Failure", 1) + assert "Unable to set shuffle: Failure (1)" in caplog.text + + +async def test_volume_set(hass, config_entry, config, controller, caplog): + """Test the volume set service.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + # First pass completes successfully, second pass raises command error + for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: 'media_player.test_player', + ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True) + player.set_volume.assert_called_once_with(100) + player.set_volume.reset_mock() + player.set_volume.side_effect = CommandError(None, "Failure", 1) + assert "Unable to set volume level: Failure (1)" in caplog.text async def test_select_favorite( @@ -270,6 +345,22 @@ async def test_select_radio_favorite( assert state.attributes[ATTR_INPUT_SOURCE] == favorite.name +async def test_select_radio_favorite_command_error( + hass, config_entry, config, controller, favorites, caplog): + """Tests command error loged when playing favorite.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + # Test set radio preset + favorite = favorites[2] + player.play_favorite.side_effect = CommandError(None, "Failure", 1) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: 'media_player.test_player', + ATTR_INPUT_SOURCE: favorite.name}, blocking=True) + player.play_favorite.assert_called_once_with(2) + assert "Unable to select source: Failure (1)" in caplog.text + + async def test_select_input_source( hass, config_entry, config, controller, input_sources): """Tests selecting input source and state.""" @@ -304,6 +395,21 @@ async def test_select_input_unknown( assert "Unknown source: Unknown" in caplog.text +async def test_select_input_command_error( + hass, config_entry, config, controller, caplog, input_sources): + """Tests selecting an unknown input.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + input_source = input_sources[0] + player.play_input_source.side_effect = CommandError(None, "Failure", 1) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: 'media_player.test_player', + ATTR_INPUT_SOURCE: input_source.name}, blocking=True) + player.play_input_source.assert_called_once_with(input_source) + assert "Unable to select source: Failure (1)" in caplog.text + + async def test_unload_config_entry(hass, config_entry, config, controller): """Test the player is removed when the config entry is unloaded.""" await setup_platform(hass, config_entry, config) From 85ac85c959d5c38b1c57896829f0f55d041b69b0 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 22 Apr 2019 21:13:21 +0200 Subject: [PATCH 32/79] Fix ESPHome setup errors in beta (#23242) * Fix ESPHome setup errors in beta * Update requirements_all.txt --- homeassistant/components/esphome/__init__.py | 3 ++- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 121e210a0a0..e5feedd8421 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -149,7 +149,8 @@ class RuntimeEntryData: def _attr_obj_from_dict(cls, **kwargs): - return cls(**{key: kwargs[key] for key in attr.fields_dict(cls)}) + return cls(**{key: kwargs[key] for key in attr.fields_dict(cls) + if key in kwargs}) async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 734544b49c7..9d25ec6d034 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "documentation": "https://www.home-assistant.io/components/esphome", "requirements": [ - "aioesphomeapi==2.0.0" + "aioesphomeapi==2.0.1" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 5810fb5e82e..2b627b5f77b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -112,7 +112,7 @@ aiobotocore==0.10.2 aiodns==1.1.1 # homeassistant.components.esphome -aioesphomeapi==2.0.0 +aioesphomeapi==2.0.1 # homeassistant.components.freebox aiofreepybox==0.0.8 From 364f5c8c02a56903a7aeaff96c01dd9282fd2488 Mon Sep 17 00:00:00 2001 From: damarco Date: Sat, 20 Apr 2019 16:12:28 +0200 Subject: [PATCH 33/79] Bump zigpy-deconz (#23270) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 9fd0629fcb2..fb30c09d26b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -5,7 +5,7 @@ "requirements": [ "bellows-homeassistant==0.7.2", "zha-quirks==0.0.8", - "zigpy-deconz==0.1.3", + "zigpy-deconz==0.1.4", "zigpy-homeassistant==0.3.1", "zigpy-xbee-homeassistant==0.1.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 2b627b5f77b..bcd6080b4aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1842,7 +1842,7 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.1.3 +zigpy-deconz==0.1.4 # homeassistant.components.zha zigpy-homeassistant==0.3.1 From 6df31da18091bf96a53334cc429e1a446c12e1bd Mon Sep 17 00:00:00 2001 From: damarco Date: Sun, 21 Apr 2019 00:04:30 +0200 Subject: [PATCH 34/79] Bump zigpy and zigpy-xbee (#23275) --- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index fb30c09d26b..c8bc0479f30 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -6,8 +6,8 @@ "bellows-homeassistant==0.7.2", "zha-quirks==0.0.8", "zigpy-deconz==0.1.4", - "zigpy-homeassistant==0.3.1", - "zigpy-xbee-homeassistant==0.1.3" + "zigpy-homeassistant==0.3.2", + "zigpy-xbee-homeassistant==0.2.0" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index bcd6080b4aa..1672d2c591a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1845,10 +1845,10 @@ ziggo-mediabox-xl==1.1.0 zigpy-deconz==0.1.4 # homeassistant.components.zha -zigpy-homeassistant==0.3.1 +zigpy-homeassistant==0.3.2 # homeassistant.components.zha -zigpy-xbee-homeassistant==0.1.3 +zigpy-xbee-homeassistant==0.2.0 # homeassistant.components.zoneminder zm-py==0.3.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9499ccdac32..2d3dc316a93 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -330,4 +330,4 @@ vultr==0.1.2 wakeonlan==1.1.6 # homeassistant.components.zha -zigpy-homeassistant==0.3.1 +zigpy-homeassistant==0.3.2 From f81eeded90cae35c5c3f1594b35620b2ccbb6276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 23 Apr 2019 07:07:56 +0200 Subject: [PATCH 35/79] Show correct version for stable (#23291) --- homeassistant/components/version/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/version/manifest.json b/homeassistant/components/version/manifest.json index 5684a3c64d1..16d11e913f7 100644 --- a/homeassistant/components/version/manifest.json +++ b/homeassistant/components/version/manifest.json @@ -3,7 +3,7 @@ "name": "Version", "documentation": "https://www.home-assistant.io/components/version", "requirements": [ - "pyhaversion==2.2.0" + "pyhaversion==2.2.1" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 1672d2c591a..6181c316fdf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1073,7 +1073,7 @@ pygtfs==0.1.5 pygtt==1.1.2 # homeassistant.components.version -pyhaversion==2.2.0 +pyhaversion==2.2.1 # homeassistant.components.heos pyheos==0.4.0 From 7fb0055a92d831b075f4673819ff1ae8d9a0ef94 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Mon, 22 Apr 2019 12:09:55 -0700 Subject: [PATCH 36/79] Bump skybellpy to 0.4.0 (#23294) * Bump skybellpy to 0.4.0 * Bump skybellpy to 0.4.0 in requirements_all.txt --- homeassistant/components/skybell/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/skybell/manifest.json b/homeassistant/components/skybell/manifest.json index 6a22a698b4c..843fd3d13b0 100644 --- a/homeassistant/components/skybell/manifest.json +++ b/homeassistant/components/skybell/manifest.json @@ -3,7 +3,7 @@ "name": "Skybell", "documentation": "https://www.home-assistant.io/components/skybell", "requirements": [ - "skybellpy==0.3.0" + "skybellpy==0.4.0" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 6181c316fdf..429d3308930 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1579,7 +1579,7 @@ simplisafe-python==3.4.1 sisyphus-control==2.1 # homeassistant.components.skybell -skybellpy==0.3.0 +skybellpy==0.4.0 # homeassistant.components.slack slacker==0.12.0 From f514d442245fc4d51a5e042edf921a54648cd365 Mon Sep 17 00:00:00 2001 From: VDRainer <26381449+VDRainer@users.noreply.github.com> Date: Tue, 23 Apr 2019 06:29:34 +0200 Subject: [PATCH 37/79] Create services.yaml for input_datetime (#23303) * Create services.yaml for input_datetime * HA error while parsing a flow mapping --- homeassistant/components/input_datetime/services.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 homeassistant/components/input_datetime/services.yaml diff --git a/homeassistant/components/input_datetime/services.yaml b/homeassistant/components/input_datetime/services.yaml new file mode 100644 index 00000000000..9534ad3f696 --- /dev/null +++ b/homeassistant/components/input_datetime/services.yaml @@ -0,0 +1,9 @@ +set_datetime: + description: This can be used to dynamically set the date and/or time. + fields: + entity_id: {description: Entity id of the input datetime to set the new value., + example: input_datetime.test_date_time} + date: {description: The target date the entity should be set to., + example: '"date": "2019-04-22"'} + time: {description: The target time the entity should be set to., + example: '"time": "05:30:00"'} From df3ceb8d8744cf1ebbfae21db9a3bc6ce6fe9d1b Mon Sep 17 00:00:00 2001 From: Richard Mitchell Date: Tue, 23 Apr 2019 05:28:40 +0100 Subject: [PATCH 38/79] Correct calculation and units of light level values. (#23309) --- homeassistant/components/hue/sensor.py | 9 +++++++-- tests/components/hue/test_sensor_base.py | 10 +++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 555c16a0be7..30a439f92e9 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -27,12 +27,17 @@ class HueLightLevel(GenericHueGaugeSensorEntity): """The light level sensor entity for a Hue motion sensor device.""" device_class = DEVICE_CLASS_ILLUMINANCE - unit_of_measurement = "Lux" + unit_of_measurement = "lx" @property def state(self): """Return the state of the device.""" - return self.sensor.lightlevel + # https://developers.meethue.com/develop/hue-api/supported-devices/#clip_zll_lightlevel + # Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm + # scale used because the human eye adjusts to light levels and small + # changes at low lux levels are more noticeable than at high lux + # levels. + return 10 ** ((self.sensor.lightlevel - 1) / 10000) @property def device_state_attributes(self): diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py index 38eb3d8c55b..6259921dcfb 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_base.py @@ -48,7 +48,7 @@ PRESENCE_SENSOR_1_PRESENT = { } LIGHT_LEVEL_SENSOR_1 = { "state": { - "lightlevel": 0, + "lightlevel": 1, "dark": True, "daylight": True, "lastupdated": "2019-01-01T01:00:00" @@ -141,7 +141,7 @@ PRESENCE_SENSOR_2_NOT_PRESENT = { } LIGHT_LEVEL_SENSOR_2 = { "state": { - "lightlevel": 100, + "lightlevel": 10001, "dark": True, "daylight": True, "lastupdated": "2019-01-01T01:00:00" @@ -234,7 +234,7 @@ PRESENCE_SENSOR_3_PRESENT = { } LIGHT_LEVEL_SENSOR_3 = { "state": { - "lightlevel": 0, + "lightlevel": 1, "dark": True, "daylight": True, "lastupdated": "2019-01-01T01:00:00" @@ -399,7 +399,7 @@ async def test_sensors(hass, mock_bridge): assert presence_sensor_1 is not None assert presence_sensor_1.state == 'on' assert light_level_sensor_1 is not None - assert light_level_sensor_1.state == '0' + assert light_level_sensor_1.state == '1.0' assert light_level_sensor_1.name == 'Living room sensor light level' assert temperature_sensor_1 is not None assert temperature_sensor_1.state == '17.75' @@ -414,7 +414,7 @@ async def test_sensors(hass, mock_bridge): assert presence_sensor_2 is not None assert presence_sensor_2.state == 'off' assert light_level_sensor_2 is not None - assert light_level_sensor_2.state == '100' + assert light_level_sensor_2.state == '10.0' assert light_level_sensor_2.name == 'Kitchen sensor light level' assert temperature_sensor_2 is not None assert temperature_sensor_2.state == '18.75' From 34c03109e5428b9aa13c3ea27c7ec1931273c205 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 23 Apr 2019 06:47:12 +0200 Subject: [PATCH 39/79] Fix hass.io panel_custom/frontend (#23313) * Fix hass.io panel_custom/frontend * Update manifest.json --- homeassistant/components/hassio/addon_panel.py | 2 +- homeassistant/components/hassio/manifest.json | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index d19ca23799a..7291a87e954 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -79,7 +79,7 @@ def _register_panel(hass, addon, data): Return coroutine. """ - return hass.components.frontend.async_register_built_in_panel( + return hass.components.panel_custom.async_register_panel( frontend_url_path=addon, webcomponent_name='hassio-main', sidebar_title=data[ATTR_TITLE], diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index 24782e45799..23095064d55 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -5,7 +5,6 @@ "requirements": [], "dependencies": [ "http", - "frontend", "panel_custom" ], "codeowners": [ From d5bd8b940599a13750f7cc4d46ca3944d517c58c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 22 Apr 2019 22:06:58 -0700 Subject: [PATCH 40/79] Dont cache integrations that are not found (#23316) --- homeassistant/loader.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index ed2ea83afb0..fb2c1bae894 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -161,11 +161,13 @@ async def async_get_integration(hass: 'HomeAssistant', domain: str)\ await int_or_evt.wait() int_or_evt = cache.get(domain, _UNDEF) - if int_or_evt is _UNDEF: - pass - elif int_or_evt is None: - raise IntegrationNotFound(domain) - else: + # When we have waited and it's _UNDEF, it doesn't exist + # We don't cache that it doesn't exist, or else people can't fix it + # and then restart, because their config will never be valid. + if int_or_evt is _UNDEF: + raise IntegrationNotFound(domain) + + if int_or_evt is not _UNDEF: return cast(Integration, int_or_evt) event = cache[domain] = asyncio.Event() @@ -197,7 +199,12 @@ async def async_get_integration(hass: 'HomeAssistant', domain: str)\ return integration integration = Integration.resolve_legacy(hass, domain) - cache[domain] = integration + if integration is not None: + cache[domain] = integration + else: + # Remove event from cache. + cache.pop(domain) + event.set() if not integration: From 06f76e8e973ab2456c1ca300cbfe6fbdc1d91664 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 22 Apr 2019 22:09:33 -0700 Subject: [PATCH 41/79] Bumped version to 0.92.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5e5934c272f..2ee4ac00727 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 92 -PATCH_VERSION = '0b2' +PATCH_VERSION = '0b3' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 9fc271d178dc11e02dec9272be1454ff89948483 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 24 Apr 2019 11:15:56 -0700 Subject: [PATCH 42/79] Updated frontend to 20190424.0 --- homeassistant/components/frontend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ae91178e4c4..608687610e4 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190419.0" + "home-assistant-frontend==20190424.0" ], "dependencies": [ "api", diff --git a/requirements_all.txt b/requirements_all.txt index 429d3308930..6fe4babd6e4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -551,7 +551,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190419.0 +home-assistant-frontend==20190424.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d3dc316a93..ee0746f7955 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -136,7 +136,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190419.0 +home-assistant-frontend==20190424.0 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From 2efe607b78e3e3b89ee799fe882ef571b96cf171 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 24 Apr 2019 04:25:20 +0200 Subject: [PATCH 43/79] Expose door cover/binary_sensor as door type (#23307) * Expose door cover/binary_sensor as door type More logical to ask "What doors are open" than "What sensors are open" * Add test for binary_sensor device_classes * Cosmetic flake8 * Add test for device class for cover --- .../components/google_assistant/const.py | 4 +- .../google_assistant/test_smart_home.py | 85 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 07506611109..e5d562057f3 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -49,6 +49,7 @@ TYPE_BLINDS = PREFIX_TYPES + 'BLINDS' TYPE_GARAGE = PREFIX_TYPES + 'GARAGE' TYPE_OUTLET = PREFIX_TYPES + 'OUTLET' TYPE_SENSOR = PREFIX_TYPES + 'SENSOR' +TYPE_DOOR = PREFIX_TYPES + 'DOOR' SERVICE_REQUEST_SYNC = 'request_sync' HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' @@ -93,9 +94,10 @@ DOMAIN_TO_GOOGLE_TYPES = { DEVICE_CLASS_TO_GOOGLE_TYPES = { (cover.DOMAIN, cover.DEVICE_CLASS_GARAGE): TYPE_GARAGE, + (cover.DOMAIN, cover.DEVICE_CLASS_DOOR): TYPE_DOOR, (switch.DOMAIN, switch.DEVICE_CLASS_SWITCH): TYPE_SWITCH, (switch.DOMAIN, switch.DEVICE_CLASS_OUTLET): TYPE_OUTLET, - (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_DOOR): TYPE_SENSOR, + (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_DOOR): TYPE_DOOR, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_GARAGE_DOOR): TYPE_SENSOR, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_LOCK): TYPE_SENSOR, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 8ea6f26553d..375f647da22 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -13,6 +13,8 @@ from homeassistant.components.climate.const import ( from homeassistant.components.google_assistant import ( const, trait, helpers, smart_home as sh, EVENT_COMMAND_RECEIVED, EVENT_QUERY_RECEIVED, EVENT_SYNC_RECEIVED) +from homeassistant.components.demo.binary_sensor import DemoBinarySensor +from homeassistant.components.demo.cover import DemoCover from homeassistant.components.demo.light import DemoLight from homeassistant.components.demo.switch import DemoSwitch @@ -598,6 +600,89 @@ async def test_device_class_switch(hass, device_class, google_type): } +@pytest.mark.parametrize("device_class,google_type", [ + ('door', 'action.devices.types.DOOR'), + ('garage_door', 'action.devices.types.SENSOR'), + ('lock', 'action.devices.types.SENSOR'), + ('opening', 'action.devices.types.SENSOR'), + ('window', 'action.devices.types.SENSOR'), +]) +async def test_device_class_binary_sensor(hass, device_class, google_type): + """Test that a binary entity syncs to the correct device type.""" + sensor = DemoBinarySensor( + 'Demo Sensor', + state=False, + device_class=device_class + ) + sensor.hass = hass + sensor.entity_id = 'binary_sensor.demo_sensor' + await sensor.async_update_ha_state() + + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'agentUserId': 'test-agent', + 'devices': [{ + 'attributes': {'queryOnlyOpenClose': True}, + 'id': 'binary_sensor.demo_sensor', + 'name': {'name': 'Demo Sensor'}, + 'traits': ['action.devices.traits.OpenClose'], + 'type': google_type, + 'willReportState': False + }] + } + } + + +@pytest.mark.parametrize("device_class,google_type", [ + ('non_existing_class', 'action.devices.types.BLINDS'), + ('door', 'action.devices.types.DOOR'), +]) +async def test_device_class_cover(hass, device_class, google_type): + """Test that a binary entity syncs to the correct device type.""" + sensor = DemoCover( + hass, + 'Demo Sensor', + device_class=device_class + ) + sensor.hass = hass + sensor.entity_id = 'cover.demo_sensor' + await sensor.async_update_ha_state() + + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'agentUserId': 'test-agent', + 'devices': [{ + 'attributes': {}, + 'id': 'cover.demo_sensor', + 'name': {'name': 'Demo Sensor'}, + 'traits': ['action.devices.traits.OpenClose'], + 'type': google_type, + 'willReportState': False + }] + } + } + + async def test_query_disconnect(hass): """Test a disconnect message.""" result = await sh.async_handle_message( From 6149c2877deafe6d43f954e8fe59a4df580e1542 Mon Sep 17 00:00:00 2001 From: dreed47 Date: Tue, 23 Apr 2019 14:44:13 -0400 Subject: [PATCH 44/79] Zestimate - Added check for the existence of data in response (#23310) --- homeassistant/components/zestimate/sensor.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index e66aad701b7..0a1f14324f6 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -113,12 +113,16 @@ class ZestimateDataSensor(Entity): return data = data_dict['response'][NAME] details = {} - details[ATTR_AMOUNT] = data['amount']['#text'] - details[ATTR_CURRENCY] = data['amount']['@currency'] - details[ATTR_LAST_UPDATED] = data['last-updated'] - details[ATTR_CHANGE] = int(data['valueChange']['#text']) - details[ATTR_VAL_HI] = int(data['valuationRange']['high']['#text']) - details[ATTR_VAL_LOW] = int(data['valuationRange']['low']['#text']) + if 'amount' in data and data['amount'] is not None: + details[ATTR_AMOUNT] = data['amount']['#text'] + details[ATTR_CURRENCY] = data['amount']['@currency'] + if 'last-updated' in data and data['last-updated'] is not None: + details[ATTR_LAST_UPDATED] = data['last-updated'] + if 'valueChange' in data and data['valueChange'] is not None: + details[ATTR_CHANGE] = int(data['valueChange']['#text']) + if 'valuationRange' in data and data['valuationRange'] is not None: + details[ATTR_VAL_HI] = int(data['valuationRange']['high']['#text']) + details[ATTR_VAL_LOW] = int(data['valuationRange']['low']['#text']) self.address = data_dict['response']['address']['street'] self.data = details if self.data is not None: From 9e762931413c26346556563466d519efbc871bf5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 23 Apr 2019 13:13:00 -0700 Subject: [PATCH 45/79] Always set latest pin (#23328) --- homeassistant/components/cloud/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index aedd71bd9ac..5bbd7bb48fa 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -106,6 +106,10 @@ class CloudClient(Interface): entity_config=google_conf.get(CONF_ENTITY_CONFIG), ) + # Set it to the latest. + self._google_config.secure_devices_pin = \ + self._prefs.google_secure_devices_pin + return self._google_config @property From 3c0146d382a2d6d1aa4c877c13745865ba9cd359 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 23 Apr 2019 19:19:23 -0700 Subject: [PATCH 46/79] Add sensor and binary senseor to default expose (#23332) --- homeassistant/components/google_assistant/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index e5d562057f3..1bab27bdd12 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -34,6 +34,7 @@ DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ 'climate', 'cover', 'fan', 'group', 'input_boolean', 'light', 'media_player', 'scene', 'script', 'switch', 'vacuum', 'lock', + 'binary_sensor', 'sensor' ] PREFIX_TYPES = 'action.devices.types.' From febdb72fb218879593bc791586177ee8c8a6ca7a Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Tue, 23 Apr 2019 17:47:09 -0700 Subject: [PATCH 47/79] Support unicode in configuration migration (#23335) --- homeassistant/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 1ed2bb6db59..a7267441cdb 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -392,13 +392,13 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: config_path = find_config_file(hass.config.config_dir) assert config_path is not None - with open(config_path, 'rt') as config_file: + with open(config_path, 'rt', encoding='utf-8') as config_file: config_raw = config_file.read() if TTS_PRE_92 in config_raw: _LOGGER.info("Migrating google tts to google_translate tts") config_raw = config_raw.replace(TTS_PRE_92, TTS_92) - with open(config_path, 'wt') as config_file: + with open(config_path, 'wt', encoding='utf-8') as config_file: config_file.write(config_raw) with open(version_path, 'wt') as outp: From 1128cf576f094f3518d7bf5523db10614522e843 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Wed, 24 Apr 2019 09:55:48 -0700 Subject: [PATCH 48/79] Remove ghost folder (#23350) --- homeassistant/components/aws_lambda/manifest.json | 10 ---------- homeassistant/components/aws_sns/manifest.json | 10 ---------- homeassistant/components/aws_sqs/manifest.json | 10 ---------- requirements_all.txt | 3 --- 4 files changed, 33 deletions(-) delete mode 100644 homeassistant/components/aws_lambda/manifest.json delete mode 100644 homeassistant/components/aws_sns/manifest.json delete mode 100644 homeassistant/components/aws_sqs/manifest.json diff --git a/homeassistant/components/aws_lambda/manifest.json b/homeassistant/components/aws_lambda/manifest.json deleted file mode 100644 index 40c8c7b0629..00000000000 --- a/homeassistant/components/aws_lambda/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "aws_lambda", - "name": "Aws lambda", - "documentation": "https://www.home-assistant.io/components/aws_lambda", - "requirements": [ - "boto3==1.9.16" - ], - "dependencies": [], - "codeowners": [] -} diff --git a/homeassistant/components/aws_sns/manifest.json b/homeassistant/components/aws_sns/manifest.json deleted file mode 100644 index f6c3438025d..00000000000 --- a/homeassistant/components/aws_sns/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "aws_sns", - "name": "Aws sns", - "documentation": "https://www.home-assistant.io/components/aws_sns", - "requirements": [ - "boto3==1.9.16" - ], - "dependencies": [], - "codeowners": [] -} diff --git a/homeassistant/components/aws_sqs/manifest.json b/homeassistant/components/aws_sqs/manifest.json deleted file mode 100644 index fcfc8cfb297..00000000000 --- a/homeassistant/components/aws_sqs/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "aws_sqs", - "name": "Aws sqs", - "documentation": "https://www.home-assistant.io/components/aws_sqs", - "requirements": [ - "boto3==1.9.16" - ], - "dependencies": [], - "codeowners": [] -} diff --git a/requirements_all.txt b/requirements_all.txt index 6fe4babd6e4..8772528c520 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -239,9 +239,6 @@ blockchain==1.4.4 bomradarloop==0.1.2 # homeassistant.components.amazon_polly -# homeassistant.components.aws_lambda -# homeassistant.components.aws_sns -# homeassistant.components.aws_sqs # homeassistant.components.route53 boto3==1.9.16 From 806aba4a1a8dceb27eb3acecd9ff1a50f74562d8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 24 Apr 2019 11:25:57 -0700 Subject: [PATCH 49/79] Bumped version to 0.92.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2ee4ac00727..9a376c04eea 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 92 -PATCH_VERSION = '0b3' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 700b8b2d0c16d7915947f5200b2ad7548966df59 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 24 Apr 2019 13:37:08 -0700 Subject: [PATCH 50/79] Fix config test when current version is 92 (#23356) --- tests/test_config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 3cbcec0214e..e9ca2a6c806 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -318,7 +318,8 @@ class TestConfig(unittest.TestCase): ha_version = '0.92.0' mock_open = mock.mock_open() - with mock.patch('homeassistant.config.open', mock_open, create=True): + with mock.patch('homeassistant.config.open', mock_open, create=True), \ + mock.patch.object(config_util, '__version__', '0.91.0'): opened_file = mock_open.return_value # pylint: disable=no-member opened_file.readline.return_value = ha_version @@ -326,7 +327,7 @@ class TestConfig(unittest.TestCase): config_util.process_ha_config_upgrade(self.hass) assert opened_file.write.call_count == 1 - assert opened_file.write.call_args == mock.call(__version__) + assert opened_file.write.call_args == mock.call('0.91.0') def test_config_upgrade_same_version(self): """Test no update of version on no upgrade.""" From a269603e3b0fe1be899114a80ba9d3695e7a5552 Mon Sep 17 00:00:00 2001 From: dreed47 Date: Thu, 25 Apr 2019 00:11:07 -0400 Subject: [PATCH 51/79] fix for issue #21381 (#23306) --- homeassistant/components/zestimate/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 0a1f14324f6..036422d6800 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -47,10 +47,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Zestimate sensor.""" name = config.get(CONF_NAME) properties = config[CONF_ZPID] - params = {'zws-id': config[CONF_API_KEY]} sensors = [] for zpid in properties: + params = {'zws-id': config[CONF_API_KEY]} params['zpid'] = zpid sensors.append(ZestimateDataSensor(name, params)) add_entities(sensors, True) From 3bfb5b119a45d2dac12f9f070bfdd8994699ecd3 Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Wed, 24 Apr 2019 14:47:22 -0700 Subject: [PATCH 52/79] Bump ecovacs lib 2 (#23354) * Bump Ecovacs dependency (sucks) Update to new version of sucks, which switches to a custom-built SleekXMPP that turns off certificate validation. This is to fix issues caused by Ecovacs serving invalid certificates. * Update requirements file --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index d36768fb1b0..4495cb3c2f9 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -3,7 +3,7 @@ "name": "Ecovacs", "documentation": "https://www.home-assistant.io/components/ecovacs", "requirements": [ - "sucks==0.9.3" + "sucks==0.9.4" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 8772528c520..8702cd1d460 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1650,7 +1650,7 @@ steamodd==4.21 stringcase==1.2.0 # homeassistant.components.ecovacs -sucks==0.9.3 +sucks==0.9.4 # homeassistant.components.onvif suds-passworddigest-homeassistant==0.1.2a0.dev0 From dd1e352d1d43e4c5b79c506e4b1cdcfa7b671892 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 24 Apr 2019 23:31:32 -0500 Subject: [PATCH 53/79] Bump pyheos to 0.4.1 (#23360) * Bump pyheos==0.4.1 * Refresh player after reconnection --- homeassistant/components/heos/manifest.json | 2 +- homeassistant/components/heos/media_player.py | 10 +++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/heos/test_media_player.py | 31 +++++++++++++++++-- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 97b53935614..5b0a8e67893 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -3,7 +3,7 @@ "name": "Heos", "documentation": "https://www.home-assistant.io/components/heos", "requirements": [ - "pyheos==0.4.0" + "pyheos==0.4.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 8821591df20..e2a5b3177e0 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -1,4 +1,5 @@ """Denon HEOS Media Player.""" +import asyncio from functools import reduce, wraps import logging from operator import ior @@ -47,7 +48,7 @@ def log_command_error(command: str): from pyheos import CommandError try: await func(*args, **kwargs) - except CommandError as ex: + except (CommandError, asyncio.TimeoutError, ConnectionError) as ex: _LOGGER.error("Unable to %s: %s", command, ex) return wrapper return decorator @@ -85,6 +86,13 @@ class HeosMediaPlayer(MediaPlayerDevice): async def _heos_event(self, event): """Handle connection event.""" + from pyheos import CommandError, const + if event == const.EVENT_CONNECTED: + try: + await self._player.refresh() + except (CommandError, asyncio.TimeoutError, ConnectionError) as ex: + _LOGGER.error("Unable to refresh player %s: %s", + self._player, ex) await self.async_update_ha_state(True) async def _player_update(self, player_id, event): diff --git a/requirements_all.txt b/requirements_all.txt index 8702cd1d460..b522da9f9c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1073,7 +1073,7 @@ pygtt==1.1.2 pyhaversion==2.2.1 # homeassistant.components.heos -pyheos==0.4.0 +pyheos==0.4.1 # homeassistant.components.hikvision pyhik==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee0746f7955..2c0f5d40f4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -217,7 +217,7 @@ pydeconz==54 pydispatcher==2.0.5 # homeassistant.components.heos -pyheos==0.4.0 +pyheos==0.4.1 # homeassistant.components.homematic pyhomematic==0.1.58 diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 0870f82b3ff..4888018af04 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -108,13 +108,40 @@ async def test_updates_start_from_signals( state = hass.states.get('media_player.test_player') assert state.state == STATE_UNAVAILABLE - # Test heos events update + +async def test_updates_from_connection_event( + hass, config_entry, config, controller, input_sources, caplog): + """Tests player updates from connection event after connection failure.""" + # Connected + await setup_platform(hass, config_entry, config) + player = controller.players[1] player.available = True player.heos.dispatcher.send( const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) await hass.async_block_till_done() state = hass.states.get('media_player.test_player') - assert state.state == STATE_PLAYING + assert state.state == STATE_IDLE + assert player.refresh.call_count == 1 + + # Connected handles refresh failure + player.reset_mock() + player.refresh.side_effect = CommandError(None, "Failure", 1) + player.heos.dispatcher.send( + const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + await hass.async_block_till_done() + state = hass.states.get('media_player.test_player') + assert player.refresh.call_count == 1 + assert "Unable to refresh player" in caplog.text + + # Disconnected + player.reset_mock() + player.available = False + player.heos.dispatcher.send( + const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) + await hass.async_block_till_done() + state = hass.states.get('media_player.test_player') + assert state.state == STATE_UNAVAILABLE + assert player.refresh.call_count == 0 async def test_updates_from_sources_updated( From ffcaeb4ef11d108da8d97db7bf669db28fe42b88 Mon Sep 17 00:00:00 2001 From: Chuang Zheng <545029543@qq.com> Date: Thu, 25 Apr 2019 20:50:28 +0800 Subject: [PATCH 54/79] async_setup_component stage_1_domains (#23375) --- homeassistant/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index c2039161ceb..3959eb88035 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -355,7 +355,7 @@ async def _async_set_up_integrations( if stage_1_domains: await asyncio.gather(*[ async_setup_component(hass, domain, config) - for domain in logging_domains + for domain in stage_1_domains ]) # Load all integrations From 1ab03d9e151a5713f1589e6fb98f34fef03e1b85 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Thu, 25 Apr 2019 12:58:10 -0700 Subject: [PATCH 55/79] Add error handling for migration failure (#23383) --- homeassistant/config.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index a7267441cdb..44008214535 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -398,8 +398,12 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: if TTS_PRE_92 in config_raw: _LOGGER.info("Migrating google tts to google_translate tts") config_raw = config_raw.replace(TTS_PRE_92, TTS_92) - with open(config_path, 'wt', encoding='utf-8') as config_file: - config_file.write(config_raw) + try: + with open(config_path, 'wt', encoding='utf-8') as config_file: + config_file.write(config_raw) + except IOError: + _LOGGER.exception("Migrating to google_translate tts failed") + pass with open(version_path, 'wt') as outp: outp.write(__version__) From ed16681b8e83459d149f7a71a6c2eab66ca2fbee Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 26 Apr 2019 04:33:05 +0200 Subject: [PATCH 56/79] Broadlink fixup unintended breakage from service refactor (#23408) * Allow host/ipv6 address for broadlink service This matches switch config and is a regression fix * Restore padding of packets for broadlink * Drop unused import * Fix comment on test --- .../components/broadlink/__init__.py | 19 ++++++-------- tests/components/broadlink/test_init.py | 25 ++++++------------- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 3404bdef99b..a1cc0a0caa3 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -2,7 +2,6 @@ import asyncio from base64 import b64decode, b64encode import logging -import re import socket from datetime import timedelta @@ -19,26 +18,22 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_RETRY = 3 -def ipv4_address(value): - """Validate an ipv4 address.""" - regex = re.compile(r'^\d+\.\d+\.\d+\.\d+$') - if not regex.match(value): - raise vol.Invalid('Invalid Ipv4 address, expected a.b.c.d') - return value - - def data_packet(value): """Decode a data packet given for broadlink.""" - return b64decode(cv.string(value)) + value = cv.string(value) + extra = len(value) % 4 + if extra > 0: + value = value + ('=' * (4 - extra)) + return b64decode(value) SERVICE_SEND_SCHEMA = vol.Schema({ - vol.Required(CONF_HOST): ipv4_address, + vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PACKET): vol.All(cv.ensure_list, [data_packet]) }) SERVICE_LEARN_SCHEMA = vol.Schema({ - vol.Required(CONF_HOST): ipv4_address, + vol.Required(CONF_HOST): cv.string, }) diff --git a/tests/components/broadlink/test_init.py b/tests/components/broadlink/test_init.py index 5dca559cb0e..44ae3d7612a 100644 --- a/tests/components/broadlink/test_init.py +++ b/tests/components/broadlink/test_init.py @@ -4,10 +4,9 @@ from base64 import b64decode from unittest.mock import MagicMock, patch, call import pytest -import voluptuous as vol from homeassistant.util.dt import utcnow -from homeassistant.components.broadlink import async_setup_service +from homeassistant.components.broadlink import async_setup_service, data_packet from homeassistant.components.broadlink.const import ( DOMAIN, SERVICE_LEARN, SERVICE_SEND) @@ -26,6 +25,13 @@ def dummy_broadlink(): yield broadlink +async def test_padding(hass): + """Verify that non padding strings are allowed.""" + assert data_packet('Jg') == b'&' + assert data_packet('Jg=') == b'&' + assert data_packet('Jg==') == b'&' + + async def test_send(hass): """Test send service.""" mock_device = MagicMock() @@ -100,18 +106,3 @@ async def test_learn_timeout(hass): assert mock_create.call_args == call( "No signal was received", title='Broadlink switch') - - -async def test_ipv4(): - """Test ipv4 parsing.""" - from homeassistant.components.broadlink import ipv4_address - - schema = vol.Schema(ipv4_address) - - for value in ('invalid', '1', '192', '192.168', - '192.168.0', '192.168.0.A'): - with pytest.raises(vol.MultipleInvalid): - schema(value) - - for value in ('192.168.0.1', '10.0.0.1'): - schema(value) From c1429f5d802b8e0ba356ab9f66caabd8890d14b0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 26 Apr 2019 12:41:30 -0700 Subject: [PATCH 57/79] Make setup more robust (#23414) * Make setup more robust * Fix typing --- homeassistant/setup.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 05e3307299a..ee362ad130f 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -151,9 +151,12 @@ async def _async_setup_component(hass: core.HomeAssistant, if hasattr(component, 'async_setup'): result = await component.async_setup( # type: ignore hass, processed_config) - else: + elif hasattr(component, 'setup'): result = await hass.async_add_executor_job( component.setup, hass, processed_config) # type: ignore + else: + log_error("No setup function defined.") + return False except Exception: # pylint: disable=broad-except _LOGGER.exception("Error during setup of component %s", domain) async_notify_setup_error(hass, domain, True) @@ -176,7 +179,7 @@ async def _async_setup_component(hass: core.HomeAssistant, for entry in hass.config_entries.async_entries(domain): await entry.async_setup(hass, integration=integration) - hass.config.components.add(component.DOMAIN) # type: ignore + hass.config.components.add(domain) # Cleanup if domain in hass.data[DATA_SETUP]: @@ -184,7 +187,7 @@ async def _async_setup_component(hass: core.HomeAssistant, hass.bus.async_fire( EVENT_COMPONENT_LOADED, - {ATTR_COMPONENT: component.DOMAIN} # type: ignore + {ATTR_COMPONENT: domain} ) return True From 46c955a5019c153981c4ec5f5620526230e438ce Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 26 Apr 2019 01:47:40 -0500 Subject: [PATCH 58/79] Add missing feature support flag (#23417) --- homeassistant/components/soundtouch/media_player.py | 9 +++++---- tests/components/soundtouch/test_media_player.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index a2a6c315eda..74c614c03a6 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -5,11 +5,12 @@ import re import voluptuous as vol from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) + PLATFORM_SCHEMA, MediaPlayerDevice) from homeassistant.components.media_player.const import ( DOMAIN, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, - SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP) + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE) @@ -56,7 +57,7 @@ DEFAULT_PORT = 8090 SUPPORT_SOUNDTOUCH = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | \ - SUPPORT_VOLUME_SET | SUPPORT_TURN_ON | SUPPORT_PLAY + SUPPORT_VOLUME_SET | SUPPORT_TURN_ON | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py index 87f41b11d95..432229a482c 100644 --- a/tests/components/soundtouch/test_media_player.py +++ b/tests/components/soundtouch/test_media_player.py @@ -372,7 +372,7 @@ class TestSoundtouchMediaPlayer(unittest.TestCase): mock.MagicMock()) assert mocked_soundtouch_device.call_count == 1 all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH] - assert all_devices[0].supported_features == 17853 + assert all_devices[0].supported_features == 18365 @mock.patch('libsoundtouch.device.SoundTouchDevice.power_off') @mock.patch('libsoundtouch.device.SoundTouchDevice.volume') From 1ec08ce243a9b3abdeb517a289d54638ca695cd1 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Thu, 25 Apr 2019 23:42:39 -0500 Subject: [PATCH 59/79] Fix supported features gates in media_player volume up/down services (#23419) * Correct media player feature gates * Fix failing test * Lint... --- .../components/media_player/__init__.py | 47 ++++++++++--------- homeassistant/helpers/service.py | 3 +- .../media_player/test_async_helpers.py | 14 ++++++ tests/helpers/test_service.py | 2 +- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 7dcfdac5217..6efbdd7c3d4 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -45,7 +45,8 @@ from .const import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOUND_MODE, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET) + SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP) from .reproduce_state import async_reproduce_states # noqa _LOGGER = logging.getLogger(__name__) @@ -164,77 +165,77 @@ async def async_setup(hass, config): component.async_register_entity_service( SERVICE_TURN_ON, MEDIA_PLAYER_SCHEMA, - 'async_turn_on', SUPPORT_TURN_ON + 'async_turn_on', [SUPPORT_TURN_ON] ) component.async_register_entity_service( SERVICE_TURN_OFF, MEDIA_PLAYER_SCHEMA, - 'async_turn_off', SUPPORT_TURN_OFF + 'async_turn_off', [SUPPORT_TURN_OFF] ) component.async_register_entity_service( SERVICE_TOGGLE, MEDIA_PLAYER_SCHEMA, - 'async_toggle', SUPPORT_TURN_OFF | SUPPORT_TURN_ON + 'async_toggle', [SUPPORT_TURN_OFF | SUPPORT_TURN_ON] ) component.async_register_entity_service( SERVICE_VOLUME_UP, MEDIA_PLAYER_SCHEMA, - 'async_volume_up', SUPPORT_VOLUME_SET + 'async_volume_up', [SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP] ) component.async_register_entity_service( SERVICE_VOLUME_DOWN, MEDIA_PLAYER_SCHEMA, - 'async_volume_down', SUPPORT_VOLUME_SET + 'async_volume_down', [SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP] ) component.async_register_entity_service( SERVICE_MEDIA_PLAY_PAUSE, MEDIA_PLAYER_SCHEMA, - 'async_media_play_pause', SUPPORT_PLAY | SUPPORT_PAUSE + 'async_media_play_pause', [SUPPORT_PLAY | SUPPORT_PAUSE] ) component.async_register_entity_service( SERVICE_MEDIA_PLAY, MEDIA_PLAYER_SCHEMA, - 'async_media_play', SUPPORT_PLAY + 'async_media_play', [SUPPORT_PLAY] ) component.async_register_entity_service( SERVICE_MEDIA_PAUSE, MEDIA_PLAYER_SCHEMA, - 'async_media_pause', SUPPORT_PAUSE + 'async_media_pause', [SUPPORT_PAUSE] ) component.async_register_entity_service( SERVICE_MEDIA_STOP, MEDIA_PLAYER_SCHEMA, - 'async_media_stop', SUPPORT_STOP + 'async_media_stop', [SUPPORT_STOP] ) component.async_register_entity_service( SERVICE_MEDIA_NEXT_TRACK, MEDIA_PLAYER_SCHEMA, - 'async_media_next_track', SUPPORT_NEXT_TRACK + 'async_media_next_track', [SUPPORT_NEXT_TRACK] ) component.async_register_entity_service( SERVICE_MEDIA_PREVIOUS_TRACK, MEDIA_PLAYER_SCHEMA, - 'async_media_previous_track', SUPPORT_PREVIOUS_TRACK + 'async_media_previous_track', [SUPPORT_PREVIOUS_TRACK] ) component.async_register_entity_service( SERVICE_CLEAR_PLAYLIST, MEDIA_PLAYER_SCHEMA, - 'async_clear_playlist', SUPPORT_CLEAR_PLAYLIST + 'async_clear_playlist', [SUPPORT_CLEAR_PLAYLIST] ) component.async_register_entity_service( SERVICE_VOLUME_SET, MEDIA_PLAYER_SET_VOLUME_SCHEMA, lambda entity, call: entity.async_set_volume_level( volume=call.data[ATTR_MEDIA_VOLUME_LEVEL]), - SUPPORT_VOLUME_SET + [SUPPORT_VOLUME_SET] ) component.async_register_entity_service( SERVICE_VOLUME_MUTE, MEDIA_PLAYER_MUTE_VOLUME_SCHEMA, lambda entity, call: entity.async_mute_volume( mute=call.data[ATTR_MEDIA_VOLUME_MUTED]), - SUPPORT_VOLUME_MUTE + [SUPPORT_VOLUME_MUTE] ) component.async_register_entity_service( SERVICE_MEDIA_SEEK, MEDIA_PLAYER_MEDIA_SEEK_SCHEMA, lambda entity, call: entity.async_media_seek( position=call.data[ATTR_MEDIA_SEEK_POSITION]), - SUPPORT_SEEK + [SUPPORT_SEEK] ) component.async_register_entity_service( SERVICE_SELECT_SOURCE, MEDIA_PLAYER_SELECT_SOURCE_SCHEMA, - 'async_select_source', SUPPORT_SELECT_SOURCE + 'async_select_source', [SUPPORT_SELECT_SOURCE] ) component.async_register_entity_service( SERVICE_SELECT_SOUND_MODE, MEDIA_PLAYER_SELECT_SOUND_MODE_SCHEMA, - 'async_select_sound_mode', SUPPORT_SELECT_SOUND_MODE + 'async_select_sound_mode', [SUPPORT_SELECT_SOUND_MODE] ) component.async_register_entity_service( SERVICE_PLAY_MEDIA, MEDIA_PLAYER_PLAY_MEDIA_SCHEMA, @@ -242,11 +243,11 @@ async def async_setup(hass, config): media_type=call.data[ATTR_MEDIA_CONTENT_TYPE], media_id=call.data[ATTR_MEDIA_CONTENT_ID], enqueue=call.data.get(ATTR_MEDIA_ENQUEUE) - ), SUPPORT_PLAY_MEDIA + ), [SUPPORT_PLAY_MEDIA] ) component.async_register_entity_service( SERVICE_SHUFFLE_SET, MEDIA_PLAYER_SET_SHUFFLE_SCHEMA, - 'async_set_shuffle', SUPPORT_SHUFFLE_SET + 'async_set_shuffle', [SUPPORT_SHUFFLE_SET] ) return True @@ -686,7 +687,8 @@ class MediaPlayerDevice(Entity): await self.hass.async_add_job(self.volume_up) return - if self.volume_level < 1: + if self.volume_level < 1 \ + and self.supported_features & SUPPORT_VOLUME_SET: await self.async_set_volume_level(min(1, self.volume_level + .1)) async def async_volume_down(self): @@ -699,7 +701,8 @@ class MediaPlayerDevice(Entity): await self.hass.async_add_job(self.volume_down) return - if self.volume_level > 0: + if self.volume_level > 0 \ + and self.supported_features & SUPPORT_VOLUME_SET: await self.async_set_volume_level( max(0, self.volume_level - .1)) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 8c576f58c14..7eb72a66c8b 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -327,7 +327,8 @@ async def _handle_service_platform_call(func, data, entities, context, # Skip entities that don't have the required feature. if required_features is not None \ - and not entity.supported_features & required_features: + and not any(entity.supported_features & feature_set + for feature_set in required_features): continue entity.async_set_context(context) diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 1c4a2fa84a2..aa3d1eff209 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -29,6 +29,13 @@ class AsyncMediaPlayer(mp.MediaPlayerDevice): """Volume level of the media player (0..1).""" return self._volume + @property + def supported_features(self): + """Flag media player features that are supported.""" + return mp.const.SUPPORT_VOLUME_SET | mp.const.SUPPORT_PLAY \ + | mp.const.SUPPORT_PAUSE | mp.const.SUPPORT_TURN_OFF \ + | mp.const.SUPPORT_TURN_ON + @asyncio.coroutine def async_set_volume_level(self, volume): """Set volume level, range 0..1.""" @@ -74,6 +81,13 @@ class SyncMediaPlayer(mp.MediaPlayerDevice): """Volume level of the media player (0..1).""" return self._volume + @property + def supported_features(self): + """Flag media player features that are supported.""" + return mp.const.SUPPORT_VOLUME_SET | mp.const.SUPPORT_VOLUME_STEP \ + | mp.const.SUPPORT_PLAY | mp.const.SUPPORT_PAUSE \ + | mp.const.SUPPORT_TURN_OFF | mp.const.SUPPORT_TURN_ON + def set_volume_level(self, volume): """Set volume level, range 0..1.""" self._volume = volume diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 647ca981da3..81cdd097855 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -280,7 +280,7 @@ async def test_call_with_required_features(hass, mock_entities): Mock(entities=mock_entities) ], test_service_mock, ha.ServiceCall('test_domain', 'test_service', { 'entity_id': 'all' - }), required_features=1) + }), required_features=[1]) assert len(mock_entities) == 2 # Called once because only one of the entities had the required features assert test_service_mock.call_count == 1 From 065b077369d53b8ece732069538bad7efd8d59b2 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Fri, 26 Apr 2019 17:15:37 +0200 Subject: [PATCH 60/79] Refactor netatmo to use hass.data (#23429) * Refactor NETATMO_AUTH to use hass.data * Minor cleanup * Rename conf to auth and other suggestions by Martin * Revert webhook name change * Rename constant * Move auth * Don't use hass.data.get() * Fix auth string --- homeassistant/components/netatmo/__init__.py | 19 +++++++++++-------- .../components/netatmo/binary_sensor.py | 8 ++++++-- homeassistant/components/netatmo/camera.py | 8 ++++++-- homeassistant/components/netatmo/climate.py | 9 ++++++--- homeassistant/components/netatmo/const.py | 5 +++++ homeassistant/components/netatmo/sensor.py | 17 ++++++++++------- 6 files changed, 44 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/netatmo/const.py diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index cf64363ba50..9ed9051ed50 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -12,6 +12,8 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle +from .const import DOMAIN, DATA_NETATMO_AUTH + _LOGGER = logging.getLogger(__name__) DATA_PERSONS = 'netatmo_persons' @@ -20,8 +22,6 @@ DATA_WEBHOOK_URL = 'netatmo_webhook_url' CONF_SECRET_KEY = 'secret_key' CONF_WEBHOOKS = 'webhooks' -DOMAIN = 'netatmo' - SERVICE_ADDWEBHOOK = 'addwebhook' SERVICE_DROPWEBHOOK = 'dropwebhook' @@ -83,10 +83,9 @@ def setup(hass, config): """Set up the Netatmo devices.""" import pyatmo - global NETATMO_AUTH hass.data[DATA_PERSONS] = {} try: - NETATMO_AUTH = pyatmo.ClientAuth( + auth = pyatmo.ClientAuth( config[DOMAIN][CONF_API_KEY], config[DOMAIN][CONF_SECRET_KEY], config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD], 'read_station read_camera access_camera ' @@ -96,6 +95,9 @@ def setup(hass, config): _LOGGER.error("Unable to connect to Netatmo API") return False + # Store config to be used during entry setup + hass.data[DATA_NETATMO_AUTH] = auth + if config[DOMAIN][CONF_DISCOVERY]: for component in 'camera', 'sensor', 'binary_sensor', 'climate': discovery.load_platform(hass, component, DOMAIN, {}, config) @@ -107,7 +109,7 @@ def setup(hass, config): webhook_id) hass.components.webhook.async_register( DOMAIN, 'Netatmo', webhook_id, handle_webhook) - NETATMO_AUTH.addwebhook(hass.data[DATA_WEBHOOK_URL]) + auth.addwebhook(hass.data[DATA_WEBHOOK_URL]) hass.bus.listen_once( EVENT_HOMEASSISTANT_STOP, dropwebhook) @@ -117,7 +119,7 @@ def setup(hass, config): if url is None: url = hass.data[DATA_WEBHOOK_URL] _LOGGER.info("Adding webhook for URL: %s", url) - NETATMO_AUTH.addwebhook(url) + auth.addwebhook(url) hass.services.register( DOMAIN, SERVICE_ADDWEBHOOK, _service_addwebhook, @@ -126,7 +128,7 @@ def setup(hass, config): def _service_dropwebhook(service): """Service to drop webhooks during runtime.""" _LOGGER.info("Dropping webhook") - NETATMO_AUTH.dropwebhook() + auth.dropwebhook() hass.services.register( DOMAIN, SERVICE_DROPWEBHOOK, _service_dropwebhook, @@ -137,7 +139,8 @@ def setup(hass, config): def dropwebhook(hass): """Drop the webhook subscription.""" - NETATMO_AUTH.dropwebhook() + auth = hass.data[DATA_NETATMO_AUTH] + auth.dropwebhook() async def handle_webhook(hass, webhook_id, request): diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index f282faf82c8..432820d6dbd 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -8,7 +8,8 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import CONF_TIMEOUT from homeassistant.helpers import config_validation as cv -from . import CameraData, NETATMO_AUTH +from .const import DATA_NETATMO_AUTH +from . import CameraData _LOGGER = logging.getLogger(__name__) @@ -59,8 +60,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): module_name = None import pyatmo + + auth = hass.data[DATA_NETATMO_AUTH] + try: - data = CameraData(hass, NETATMO_AUTH, home) + data = CameraData(hass, auth, home) if not data.get_camera_names(): return None except pyatmo.NoDevice: diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index b74dce4b262..976e0794938 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -9,7 +9,8 @@ from homeassistant.components.camera import ( from homeassistant.const import CONF_VERIFY_SSL from homeassistant.helpers import config_validation as cv -from . import CameraData, NETATMO_AUTH +from .const import DATA_NETATMO_AUTH +from . import CameraData _LOGGER = logging.getLogger(__name__) @@ -37,8 +38,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): verify_ssl = config.get(CONF_VERIFY_SSL, True) quality = config.get(CONF_QUALITY, DEFAULT_QUALITY) import pyatmo + + auth = hass.data[DATA_NETATMO_AUTH] + try: - data = CameraData(hass, NETATMO_AUTH, home) + data = CameraData(hass, auth, home) for camera_name in data.get_camera_names(): camera_type = data.get_camera_type(camera=camera_name, home=home) if CONF_CAMERAS in config: diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 00c08c654ef..33ad34b25ff 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -14,7 +14,7 @@ from homeassistant.const import ( STATE_OFF, TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_NAME) from homeassistant.util import Throttle -from . import NETATMO_AUTH +from .const import DATA_NETATMO_AUTH _LOGGER = logging.getLogger(__name__) @@ -68,8 +68,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the NetAtmo Thermostat.""" import pyatmo homes_conf = config.get(CONF_HOMES) + + auth = hass.data[DATA_NETATMO_AUTH] + try: - home_data = HomeData(NETATMO_AUTH) + home_data = HomeData(auth) except pyatmo.NoDevice: return @@ -88,7 +91,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for home in homes: _LOGGER.debug("Setting up %s ...", home) try: - room_data = ThermostatData(NETATMO_AUTH, home) + room_data = ThermostatData(auth, home) except pyatmo.NoDevice: continue for room_id in room_data.get_room_ids(): diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py new file mode 100644 index 00000000000..ea547aaa52b --- /dev/null +++ b/homeassistant/components/netatmo/const.py @@ -0,0 +1,5 @@ +"""Constants used by the Netatmo component.""" +DOMAIN = 'netatmo' + +DATA_NETATMO = 'netatmo' +DATA_NETATMO_AUTH = 'netatmo_auth' diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index c9c1101c2a2..161177c9c76 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -from . import NETATMO_AUTH +from .const import DATA_NETATMO_AUTH _LOGGER = logging.getLogger(__name__) @@ -68,23 +68,26 @@ MODULE_TYPE_INDOOR = 'NAModule4' def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the available Netatmo weather sensors.""" dev = [] + auth = hass.data[DATA_NETATMO_AUTH] + if CONF_MODULES in config: - manual_config(config, dev) + manual_config(auth, config, dev) else: - auto_config(config, dev) + auto_config(auth, config, dev) if dev: add_entities(dev, True) -def manual_config(config, dev): +def manual_config(auth, config, dev): """Handle manual configuration.""" import pyatmo all_classes = all_product_classes() not_handled = {} + for data_class in all_classes: - data = NetAtmoData(NETATMO_AUTH, data_class, + data = NetAtmoData(auth, data_class, config.get(CONF_STATION)) try: # Iterate each module @@ -107,12 +110,12 @@ def manual_config(config, dev): _LOGGER.error('Module name: "%s" not found', module_name) -def auto_config(config, dev): +def auto_config(auth, config, dev): """Handle auto configuration.""" import pyatmo for data_class in all_product_classes(): - data = NetAtmoData(NETATMO_AUTH, data_class, config.get(CONF_STATION)) + data = NetAtmoData(auth, data_class, config.get(CONF_STATION)) try: for module_name in data.get_module_names(): for variable in \ From f6a6be9a22b118fa32e99d5e26ef3fb103af014c Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Fri, 26 Apr 2019 17:55:30 +0200 Subject: [PATCH 61/79] Fix Flux component (#23431) * Fix Flux component * Update manifest.json * Update manifest.json --- homeassistant/components/flux/manifest.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flux/manifest.json b/homeassistant/components/flux/manifest.json index d4d67edbd35..9bf3ba09ce7 100644 --- a/homeassistant/components/flux/manifest.json +++ b/homeassistant/components/flux/manifest.json @@ -3,8 +3,7 @@ "name": "Flux", "documentation": "https://www.home-assistant.io/components/flux", "requirements": [], - "dependencies": [ - "light" - ], + "dependencies": [], + "after_dependencies": ["light"], "codeowners": [] } From 0d4858e29633641f279a11436360d8bcf3e26096 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Fri, 26 Apr 2019 20:24:02 +0200 Subject: [PATCH 62/79] Fix daikin setup (#23440) Fix daikin setup --- homeassistant/components/daikin/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index fc15ebea772..edc447fe721 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -63,10 +63,10 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): if not daikin_api: return False hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: daikin_api}) - await asyncio.wait([ - hass.config_entries.async_forward_entry_setup(entry, component) - for component in COMPONENT_TYPES - ]) + for component in COMPONENT_TYPES: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + entry, component)) return True From 95ed8fb24555fc7d19a2e1fa753c6b5e725f5a59 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Fri, 26 Apr 2019 20:56:55 +0200 Subject: [PATCH 63/79] Fix point setup (#23441) Fix point setup --- homeassistant/components/point/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index c0b2f7acd0f..2ed83fe1d9b 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -88,7 +88,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): await async_setup_webhook(hass, entry, session) client = MinutPointClient(hass, entry, session) hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client}) - await client.update() + hass.async_create_task(client.update()) return True From 081a0290ba02081d3327e964a04cc7534aec1756 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 26 Apr 2019 20:00:33 +0000 Subject: [PATCH 64/79] Bump version 0.91.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9a376c04eea..f38b1496994 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 92 -PATCH_VERSION = '0' +PATCH_VERSION = '1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 2dbe6d3289dcb4b0a55aceb3845ca63362c9b7e5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 27 Apr 2019 22:18:05 -0700 Subject: [PATCH 65/79] Updated frontend to 20190427.0 --- homeassistant/components/frontend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 608687610e4..0a82a36536f 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190424.0" + "home-assistant-frontend==20190427.0" ], "dependencies": [ "api", diff --git a/requirements_all.txt b/requirements_all.txt index b522da9f9c2..5e7e06a2be7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -548,7 +548,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190424.0 +home-assistant-frontend==20190427.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2c0f5d40f4a..25d1f54105b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -136,7 +136,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190424.0 +home-assistant-frontend==20190427.0 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From e054d6856594441e97c83004034e2e512b7efbdf Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 18 Apr 2019 09:03:25 -0700 Subject: [PATCH 66/79] Further improve IndieAuth redirect_uri lookup failure logs (#23183) --- homeassistant/components/auth/indieauth.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index 1437685692b..a56671c9dcd 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -6,7 +6,6 @@ from html.parser import HTMLParser from urllib.parse import urlparse, urljoin import aiohttp -from aiohttp.client_exceptions import ClientError from homeassistant.util.network import is_local @@ -81,8 +80,22 @@ async def fetch_redirect_uris(hass, url): if chunks == 10: break - except (asyncio.TimeoutError, ClientError) as ex: - _LOGGER.error("Error while looking up redirect_uri %s: %s", url, ex) + except asyncio.TimeoutError: + _LOGGER.error("Timeout while looking up redirect_uri %s", url) + pass + except aiohttp.client_exceptions.ClientSSLError: + _LOGGER.error("SSL error while looking up redirect_uri %s", url) + pass + except aiohttp.client_exceptions.ClientOSError as ex: + _LOGGER.error("OS error while looking up redirect_uri %s: %s", url, + ex.strerror) + pass + except aiohttp.client_exceptions.ClientConnectionError: + _LOGGER.error(("Low level connection error while looking up " + "redirect_uri %s"), url) + pass + except aiohttp.client_exceptions.ClientError: + _LOGGER.error("Unknown error while looking up redirect_uri %s", url) pass # Authorization endpoints verifying that a redirect_uri is allowed for use From c7a34d022222fab4b5e8a0a707bf0c3f7e4fa1af Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Thu, 18 Apr 2019 22:47:17 +0200 Subject: [PATCH 67/79] Don't create connections between sensors. Fixes #22787 (#23202) --- homeassistant/components/upnp/sensor.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 411d529b33f..0527904a083 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -3,7 +3,6 @@ from datetime import datetime import logging from homeassistant.core import callback -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType @@ -108,9 +107,6 @@ class UpnpSensor(Entity): 'identifiers': { (DOMAIN_UPNP, self.unique_id) }, - 'connections': { - (dr.CONNECTION_UPNP, self._device.udn) - }, 'name': self.name, 'manufacturer': self._device.manufacturer, } From fa4264be3f7cb31b6c17e97921ae57f45db977f9 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 26 Apr 2019 23:18:30 +0100 Subject: [PATCH 68/79] update geniushub client library to fix issue #23444 (#23450) --- homeassistant/components/geniushub/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 4546be8078b..78efeca7311 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -3,7 +3,7 @@ "name": "Genius Hub", "documentation": "https://www.home-assistant.io/components/geniushub", "requirements": [ - "geniushub-client==0.3.6" + "geniushub-client==0.4.5" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 5e7e06a2be7..aa84b8a60b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -459,7 +459,7 @@ gearbest_parser==1.0.7 geizhals==0.0.9 # homeassistant.components.geniushub -geniushub-client==0.3.6 +geniushub-client==0.4.5 # homeassistant.components.geo_json_events # homeassistant.components.nsw_rural_fire_service_feed From ffdf48b15a534031960a1f484a569c48e6ef1c75 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Sat, 27 Apr 2019 17:18:55 +0200 Subject: [PATCH 69/79] Fix netatmo_public sensor to use netatmo authentication (#23455) --- homeassistant/components/netatmo_public/sensor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo_public/sensor.py b/homeassistant/components/netatmo_public/sensor.py index 814675ca8b7..8295c0c0688 100644 --- a/homeassistant/components/netatmo_public/sensor.py +++ b/homeassistant/components/netatmo_public/sensor.py @@ -12,6 +12,8 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +from homeassistant.components.netatmo.const import DATA_NETATMO_AUTH + _LOGGER = logging.getLogger(__name__) CONF_AREAS = 'areas' @@ -54,12 +56,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the access to Netatmo binary sensor.""" - netatmo = hass.components.netatmo + auth = hass.data[DATA_NETATMO_AUTH] sensors = [] areas = config.get(CONF_AREAS) for area_conf in areas: - data = NetatmoPublicData(netatmo.NETATMO_AUTH, + data = NetatmoPublicData(auth, lat_ne=area_conf.get(CONF_LAT_NE), lon_ne=area_conf.get(CONF_LON_NE), lat_sw=area_conf.get(CONF_LAT_SW), From 185595c113072e2798f5b556077e6e8a7709f821 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 27 Apr 2019 18:17:50 +0200 Subject: [PATCH 70/79] Bump pychromecast (#23463) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index c506dba8cf1..dd189ac91e7 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Cast", "documentation": "https://www.home-assistant.io/components/cast", "requirements": [ - "pychromecast==3.2.0" + "pychromecast==3.2.1" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index aa84b8a60b4..6fae5381ed4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -976,7 +976,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==3.2.0 +pychromecast==3.2.1 # homeassistant.components.cmus pycmus==0.1.1 From 0fe573ecc07d35f3064b1c9e51a42cead92527e1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 28 Apr 2019 21:09:06 +0200 Subject: [PATCH 71/79] Fix cleanup of dynamic group (#23475) --- homeassistant/components/cast/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 4b2972b0c00..35fe29809f8 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -659,7 +659,7 @@ class CastDevice(MediaPlayerDevice): self.entity_id, self._cast_info.friendly_name, self._cast_info.host, self._cast_info.port, cast_info) - self.async_del_dynamic_group() + await self.async_del_dynamic_group() self._dynamic_group_cast_info = cast_info # pylint: disable=protected-access From 8180797d2f1ae21b55c703ac825dfb991629f946 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 27 Apr 2019 13:27:17 -0700 Subject: [PATCH 72/79] Fix Hue sensors returning None value (#23478) --- homeassistant/components/hue/sensor.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 30a439f92e9..7664bd38d97 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -32,6 +32,9 @@ class HueLightLevel(GenericHueGaugeSensorEntity): @property def state(self): """Return the state of the device.""" + if self.sensor.lightlevel is None: + return None + # https://developers.meethue.com/develop/hue-api/supported-devices/#clip_zll_lightlevel # Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm # scale used because the human eye adjusts to light levels and small @@ -59,4 +62,7 @@ class HueTemperature(GenericHueGaugeSensorEntity): @property def state(self): """Return the state of the device.""" + if self.sensor.temperature is None: + return None + return self.sensor.temperature / 100 From 04bca7be6be6235d5dec0bea00dbe50d2f25420d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 28 Apr 2019 00:42:06 -0700 Subject: [PATCH 73/79] Only include agent user ID in SYNC responses (#23497) --- homeassistant/components/cloud/client.py | 14 ++++---------- .../components/google_assistant/helpers.py | 6 +++++- .../components/google_assistant/smart_home.py | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 5bbd7bb48fa..f47eae74986 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -100,10 +100,13 @@ class CloudClient(Interface): return google_conf['filter'](entity.entity_id) + username = self._hass.data[DOMAIN].claims["cognito:username"] + self._google_config = ga_h.Config( should_expose=should_expose, secure_devices_pin=self._prefs.google_secure_devices_pin, entity_config=google_conf.get(CONF_ENTITY_CONFIG), + agent_user_id=username, ) # Set it to the latest. @@ -149,19 +152,10 @@ class CloudClient(Interface): if not self._prefs.google_enabled: return ga.turned_off_response(payload) - answer = await ga.async_handle_message( + return await ga.async_handle_message( self._hass, self.google_config, self.prefs.cloud_user, payload ) - # Fix AgentUserId - try: - cloud = self._hass.data[DOMAIN] - answer['payload']['agentUserId'] = cloud.claims['cognito:username'] - except (TypeError, KeyError): - return ga.turned_off_response(payload) - - return answer - async def async_webhook_message( self, payload: Dict[Any, Any]) -> Dict[Any, Any]: """Process cloud webhook message to client.""" diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 71cce9de500..4d3f2855b31 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -20,12 +20,16 @@ class Config: """Hold the configuration for Google Assistant.""" def __init__(self, should_expose, - entity_config=None, secure_devices_pin=None): + entity_config=None, secure_devices_pin=None, + agent_user_id=None): """Initialize the configuration.""" self.should_expose = should_expose self.entity_config = entity_config or {} self.secure_devices_pin = secure_devices_pin + # Agent User Id to use for query responses + self.agent_user_id = agent_user_id + class RequestData: """Hold data associated with a particular request.""" diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 37f35edf645..1ec47bbedd6 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -99,7 +99,7 @@ async def async_devices_sync(hass, data, payload): devices.append(serialized) response = { - 'agentUserId': data.context.user_id, + 'agentUserId': data.config.agent_user_id or data.context.user_id, 'devices': devices, } From 55a7ea6cc55488b5216119d2c0decf2600942755 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 29 Apr 2019 22:40:55 +0200 Subject: [PATCH 74/79] Fix media_player alexa power control bug (#23537) --- homeassistant/components/alexa/smart_home.py | 6 ++++- tests/components/alexa/test_smart_home.py | 26 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index e16a1d45ab7..8a95f4702c7 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -911,13 +911,17 @@ class _MediaPlayerCapabilities(_AlexaEntity): return [_DisplayCategory.TV] def interfaces(self): - yield _AlexaPowerController(self.entity) yield _AlexaEndpointHealth(self.hass, self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & media_player.const.SUPPORT_VOLUME_SET: yield _AlexaSpeaker(self.entity) + power_features = (media_player.SUPPORT_TURN_ON | + media_player.SUPPORT_TURN_OFF) + if supported & power_features: + yield _AlexaPowerController(self.entity) + step_volume_features = (media_player.const.SUPPORT_VOLUME_MUTE | media_player.const.SUPPORT_VOLUME_STEP) if supported & step_volume_features: diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 924a568dea2..20b4495cd1a 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -574,6 +574,32 @@ async def test_media_player(hass): payload={'volumeSteps': -20}) +async def test_media_player_power(hass): + """Test media player discovery with mapped on/off.""" + device = ( + 'media_player.test', + 'off', { + 'friendly_name': "Test media player", + 'supported_features': 0xfa3f, + 'volume_level': 0.75 + } + ) + appliance = await discovery_test(device, hass) + + assert appliance['endpointId'] == 'media_player#test' + assert appliance['displayCategories'][0] == "TV" + assert appliance['friendlyName'] == "Test media player" + + assert_endpoint_capabilities( + appliance, + 'Alexa.InputController', + 'Alexa.Speaker', + 'Alexa.StepSpeaker', + 'Alexa.PlaybackController', + 'Alexa.EndpointHealth', + ) + + async def test_alert(hass): """Test alert discovery.""" device = ('alert.test', 'off', {'friendly_name': "Test alert"}) From 38aa7d2c951daa86e798af2a0a2390fbd5ae9b8b Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 30 Apr 2019 08:02:45 +0200 Subject: [PATCH 75/79] Fix problem with using Traccar event (#23543) --- homeassistant/components/traccar/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 1600227bfe2..1fef18a6ae1 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -170,7 +170,7 @@ class TraccarScanner: if events is not None: for event in events: device_name = next(( - dev.get('name') for dev in self._api.devices() + dev.get('name') for dev in self._api.devices if dev.get('id') == event['deviceId']), None) self._hass.bus.async_fire( 'traccar_' + self._event_types.get(event["type"]), { From 35e9505ad5a318af2a7550dfba292439b19d0f24 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Wed, 1 May 2019 16:05:35 -0600 Subject: [PATCH 76/79] Fix authentication issue (#23600) Update to pymy1 version 1.2.1 to fix authentication issue --- homeassistant/components/myq/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index c4057fecb25..b870ff66309 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -3,7 +3,7 @@ "name": "Myq", "documentation": "https://www.home-assistant.io/components/myq", "requirements": [ - "pymyq==1.2.0" + "pymyq==1.2.1" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 6fae5381ed4..1e0a19778e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1166,7 +1166,7 @@ pymonoprice==0.3 pymusiccast==0.1.6 # homeassistant.components.myq -pymyq==1.2.0 +pymyq==1.2.1 # homeassistant.components.mysensors pymysensors==0.18.0 From bc9548fdaf21b28c3bdd696b49ddcc35910e88cb Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 1 May 2019 22:35:13 -0700 Subject: [PATCH 77/79] Fix unexpected error thrown if instance_domain is not set (#23615) --- homeassistant/components/cloud/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 1ad76c3d7aa..d4d443a692d 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -122,6 +122,9 @@ def async_remote_ui_url(hass) -> str: if not async_is_logged_in(hass): raise CloudNotAvailable + if not hass.data[DOMAIN].remote.instance_domain: + raise CloudNotAvailable + return "https://" + hass.data[DOMAIN].remote.instance_domain From 741d0fd09b410e816de5e7d4c904cfe6c776ac6b Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Thu, 2 May 2019 00:39:59 -0500 Subject: [PATCH 78/79] Bump pyheos (#23616) --- homeassistant/components/heos/__init__.py | 2 +- homeassistant/components/heos/manifest.json | 2 +- homeassistant/components/heos/media_player.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/heos/test_init.py | 2 +- tests/components/heos/test_media_player.py | 6 +++--- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 529ee27997e..334c2572e74 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -195,7 +195,7 @@ class SourceManager: exc_info=isinstance(error, CommandError)) return - async def update_sources(event): + async def update_sources(event, data): if event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED): sources = await get_sources() diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 5b0a8e67893..3faa346988c 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -3,7 +3,7 @@ "name": "Heos", "documentation": "https://www.home-assistant.io/components/heos", "requirements": [ - "pyheos==0.4.1" + "pyheos==0.5.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index e2a5b3177e0..739667f5834 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -78,7 +78,7 @@ class HeosMediaPlayer(MediaPlayerDevice): const.CONTROL_PLAY_NEXT: SUPPORT_NEXT_TRACK } - async def _controller_event(self, event): + async def _controller_event(self, event, data): """Handle controller event.""" from pyheos import const if event == const.EVENT_PLAYERS_CHANGED: diff --git a/requirements_all.txt b/requirements_all.txt index 1e0a19778e3..c2a8f6b90d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1073,7 +1073,7 @@ pygtt==1.1.2 pyhaversion==2.2.1 # homeassistant.components.heos -pyheos==0.4.1 +pyheos==0.5.1 # homeassistant.components.hikvision pyhik==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25d1f54105b..dc1e770aa50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -217,7 +217,7 @@ pydeconz==54 pydispatcher==2.0.5 # homeassistant.components.heos -pyheos==0.4.1 +pyheos==0.5.1 # homeassistant.components.homematic pyhomematic==0.1.58 diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 408b2f7d088..2276f4ce2eb 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -161,7 +161,7 @@ async def test_update_sources_retry(hass, config_entry, config, controller, source_manager.max_retry_attempts = 1 controller.get_favorites.side_effect = CommandError("Test", "test", 0) controller.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED) + const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {}) # Wait until it's finished while "Unable to update sources" not in caplog.text: await asyncio.sleep(0.1) diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 4888018af04..d2a1abf72f6 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -103,7 +103,7 @@ async def test_updates_start_from_signals( # Test controller player change updates player.available = False player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED) + const.SIGNAL_CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, {}) await hass.async_block_till_done() state = hass.states.get('media_player.test_player') assert state.state == STATE_UNAVAILABLE @@ -158,7 +158,7 @@ async def test_updates_from_sources_updated( input_sources.clear() player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED) + const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {}) await event.wait() source_list = hass.data[DOMAIN][DATA_SOURCE_MANAGER].source_list assert len(source_list) == 2 @@ -181,7 +181,7 @@ async def test_updates_from_user_changed( controller.is_signed_in = False controller.signed_in_username = None player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_USER_CHANGED) + const.SIGNAL_CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None) await event.wait() source_list = hass.data[DOMAIN][DATA_SOURCE_MANAGER].source_list assert len(source_list) == 1 From 58cde6b4977a266a0e9cf065b1dae95ddcb6e42e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 May 2019 22:43:25 -0700 Subject: [PATCH 79/79] Bumped version to 0.92.2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f38b1496994..8c012fcac8e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 92 -PATCH_VERSION = '1' +PATCH_VERSION = '2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3)