From 71ebc4f5946f42e5e5802b3401e718c12b170aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9-Marc=20Simard?= Date: Sun, 24 Mar 2019 07:15:30 -0400 Subject: [PATCH 01/69] Define GTFS sensor as a timestamp device class (#21053) --- homeassistant/components/gtfs/sensor.py | 57 +++++++++++++------------ 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index eec08be093f038..25b352c14545df 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -12,9 +12,10 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, DEVICE_CLASS_TIMESTAMP from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util REQUIREMENTS = ['pygtfs==0.1.5'] @@ -40,9 +41,6 @@ 7: 'mdi:stairs', } -DATE_FORMAT = '%Y-%m-%d' -TIME_FORMAT = '%Y-%m-%d %H:%M:%S' - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ORIGIN): cv.string, vol.Required(CONF_DESTINATION): cv.string, @@ -60,7 +58,7 @@ def get_next_departure(sched, start_station_id, end_station_id, offset): now = datetime.datetime.now() + offset day_name = now.strftime('%A').lower() now_str = now.strftime('%H:%M:%S') - today = now.strftime(DATE_FORMAT) + today = now.strftime(dt_util.DATE_STR_FORMAT) from sqlalchemy.sql import text @@ -117,28 +115,28 @@ def get_next_departure(sched, start_station_id, end_station_id, offset): origin_arrival = now if item['origin_arrival_time'] > item['origin_depart_time']: origin_arrival -= datetime.timedelta(days=1) - origin_arrival_time = '{} {}'.format(origin_arrival.strftime(DATE_FORMAT), - item['origin_arrival_time']) + origin_arrival_time = '{} {}'.format( + origin_arrival.strftime(dt_util.DATE_STR_FORMAT), + item['origin_arrival_time']) origin_depart_time = '{} {}'.format(today, item['origin_depart_time']) dest_arrival = now if item['dest_arrival_time'] < item['origin_depart_time']: dest_arrival += datetime.timedelta(days=1) - dest_arrival_time = '{} {}'.format(dest_arrival.strftime(DATE_FORMAT), - item['dest_arrival_time']) + dest_arrival_time = '{} {}'.format( + dest_arrival.strftime(dt_util.DATE_STR_FORMAT), + item['dest_arrival_time']) dest_depart = dest_arrival if item['dest_depart_time'] < item['dest_arrival_time']: dest_depart += datetime.timedelta(days=1) - dest_depart_time = '{} {}'.format(dest_depart.strftime(DATE_FORMAT), - item['dest_depart_time']) - - depart_time = datetime.datetime.strptime(origin_depart_time, TIME_FORMAT) - arrival_time = datetime.datetime.strptime(dest_arrival_time, TIME_FORMAT) + dest_depart_time = '{} {}'.format( + dest_depart.strftime(dt_util.DATE_STR_FORMAT), + item['dest_depart_time']) - seconds_until = (depart_time - datetime.datetime.now()).total_seconds() - minutes_until = int(seconds_until / 60) + depart_time = dt_util.parse_datetime(origin_depart_time) + arrival_time = dt_util.parse_datetime(dest_arrival_time) route = sched.routes_by_id(item['route_id'])[0] @@ -168,11 +166,9 @@ def get_next_departure(sched, start_station_id, end_station_id, offset): 'route': route, 'agency': sched.agencies_by_id(route.agency_id)[0], 'origin_station': origin_station, - 'departure_time': depart_time, 'destination_station': destination_station, + 'departure_time': depart_time, 'arrival_time': arrival_time, - 'seconds_until_departure': seconds_until, - 'minutes_until_departure': minutes_until, 'origin_stop_time': origin_stop_time_dict, 'destination_stop_time': destination_stop_time_dict } @@ -222,7 +218,6 @@ def __init__(self, pygtfs, name, origin, destination, offset): self._custom_name = name self._icon = ICON self._name = '' - self._unit_of_measurement = 'min' self._state = None self._attributes = {} self.lock = threading.Lock() @@ -238,11 +233,6 @@ def state(self): """Return the state of the sensor.""" return self._state - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - @property def device_state_attributes(self): """Return the state attributes.""" @@ -253,6 +243,11 @@ def icon(self): """Icon to use in the frontend, if any.""" return self._icon + @property + def device_class(self): + """Return the class of this device.""" + return DEVICE_CLASS_TIMESTAMP + def update(self): """Get the latest data from GTFS and update the states.""" with self.lock: @@ -265,7 +260,12 @@ def update(self): self._name = (self._custom_name or DEFAULT_NAME) return - self._state = self._departure['minutes_until_departure'] + # Define the state as a UTC timestamp with ISO 8601 format. + arrival_time = dt_util.as_utc( + self._departure['arrival_time']).isoformat() + departure_time = dt_util.as_utc( + self._departure['departure_time']).isoformat() + self._state = departure_time origin_station = self._departure['origin_station'] destination_station = self._departure['destination_station'] @@ -281,12 +281,13 @@ def update(self): origin_station.stop_id, destination_station.stop_id)) + self._icon = ICONS.get(route.route_type, ICON) + # Build attributes self._attributes = {} + self._attributes['arrival'] = arrival_time self._attributes['offset'] = self._offset.seconds / 60 - self._icon = ICONS.get(route.route_type, ICON) - def dict_for_table(resource): """Return a dict for the SQLAlchemy resource given.""" return dict((col, getattr(resource, col)) From 9214934d47871575d6b235dd547fc1ccab79c029 Mon Sep 17 00:00:00 2001 From: zewelor Date: Sun, 24 Mar 2019 13:01:12 +0100 Subject: [PATCH 02/69] Move yeelight into component (#21593) --- .../components/discovery/__init__.py | 3 +- homeassistant/components/light/services.yaml | 26 -- homeassistant/components/yeelight/__init__.py | 358 +++++++++++++++++- homeassistant/components/yeelight/light.py | 306 ++++----------- .../components/yeelight/services.yaml | 25 ++ requirements_all.txt | 2 +- 6 files changed, 450 insertions(+), 270 deletions(-) create mode 100644 homeassistant/components/yeelight/services.yaml diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index d4816213f50085..1fb727642bc50d 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -46,6 +46,7 @@ SERVICE_SABNZBD = 'sabnzbd' SERVICE_SAMSUNG_PRINTER = 'samsung_printer' SERVICE_TELLDUSLIVE = 'tellstick' +SERVICE_YEELIGHT = 'yeelight' SERVICE_WEMO = 'belkin_wemo' SERVICE_WINK = 'wink' SERVICE_XIAOMI_GW = 'xiaomi_gw' @@ -79,6 +80,7 @@ SERVICE_KONNECTED: ('konnected', None), SERVICE_OCTOPRINT: ('octoprint', None), SERVICE_FREEBOX: ('freebox', None), + SERVICE_YEELIGHT: ('yeelight', None), 'panasonic_viera': ('media_player', 'panasonic_viera'), 'plex_mediaserver': ('media_player', 'plex'), 'yamaha': ('media_player', 'yamaha'), @@ -86,7 +88,6 @@ 'directv': ('media_player', 'directv'), 'denonavr': ('media_player', 'denonavr'), 'samsung_tv': ('media_player', 'samsungtv'), - 'yeelight': ('light', 'yeelight'), 'frontier_silicon': ('media_player', 'frontier_silicon'), 'openhome': ('media_player', 'openhome'), 'harmony': ('remote', 'harmony'), diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index a2863482477a9a..cdf82e97429ad2 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -178,29 +178,3 @@ xiaomi_miio_set_delayed_turn_off: time_period: description: Time period for the delayed turn off. example: "5, '0:05', {'minutes': 5}" - -yeelight_set_mode: - description: Set a operation mode. - fields: - entity_id: - description: Name of the light entity. - example: 'light.yeelight' - mode: - description: Operation mode. Valid values are 'last', 'normal', 'rgb', 'hsv', 'color_flow', 'moonlight'. - example: 'moonlight' - -yeelight_start_flow: - description: Start a custom flow, using transitions from https://yeelight.readthedocs.io/en/stable/yeelight.html#flow-objects - fields: - entity_id: - description: Name of the light entity. - example: 'light.yeelight' - count: - description: The number of times to run this flow (0 to run forever). - example: 0 - action: - description: The action to take after the flow stops. Can be 'recover', 'stay', 'off'. (default 'recover') - example: 'stay' - transitions: - description: Array of transitions, for desired effect. Examples https://yeelight.readthedocs.io/en/stable/flow.html - example: '[{ "TemperatureTransition": [1900, 1000, 80] }, { "TemperatureTransition": [1900, 1000, 10] }]' diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index d8c1f23bcbb2a5..32e3c5f69e3b6f 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -1 +1,357 @@ -"""The yeelight component.""" +""" +Support for Xiaomi Yeelight Wifi color bulb. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/yeelight/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +from homeassistant.components.discovery import SERVICE_YEELIGHT +from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_SCAN_INTERVAL, \ + CONF_HOST, ATTR_ENTITY_ID, CONF_LIGHTS +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.helpers import discovery +from homeassistant.helpers.discovery import load_platform +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.service import extract_entity_ids + +REQUIREMENTS = ['yeelight==0.4.3'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "yeelight" +DATA_YEELIGHT = DOMAIN +DATA_UPDATED = '{}_data_updated'.format(DOMAIN) + +DEFAULT_NAME = 'Yeelight' +DEFAULT_TRANSITION = 350 + +CONF_MODEL = 'model' +CONF_TRANSITION = 'transition' +CONF_SAVE_ON_CHANGE = 'save_on_change' +CONF_MODE_MUSIC = 'use_music_mode' +CONF_FLOW_PARAMS = 'flow_params' +CONF_CUSTOM_EFFECTS = 'custom_effects' + +ATTR_MODE = 'mode' +ATTR_COUNT = 'count' +ATTR_ACTION = 'action' +ATTR_TRANSITIONS = 'transitions' + +ACTION_RECOVER = 'recover' +ACTION_STAY = 'stay' +ACTION_OFF = 'off' + +MODE_MOONLIGHT = 'moonlight' +MODE_DAYLIGHT = 'normal' + +SCAN_INTERVAL = timedelta(seconds=30) + +YEELIGHT_RGB_TRANSITION = 'RGBTransition' +YEELIGHT_HSV_TRANSACTION = 'HSVTransition' +YEELIGHT_TEMPERATURE_TRANSACTION = 'TemperatureTransition' +YEELIGHT_SLEEP_TRANSACTION = 'SleepTransition' + +SERVICE_SET_MODE = 'set_mode' +SERVICE_START_FLOW = 'start_flow' + +YEELIGHT_FLOW_TRANSITION_SCHEMA = { + vol.Optional(ATTR_COUNT, default=0): cv.positive_int, + vol.Optional(ATTR_ACTION, default=ACTION_RECOVER): + vol.Any(ACTION_RECOVER, ACTION_OFF, ACTION_STAY), + vol.Required(ATTR_TRANSITIONS): [{ + vol.Exclusive(YEELIGHT_RGB_TRANSITION, CONF_TRANSITION): + vol.All(cv.ensure_list, [cv.positive_int]), + vol.Exclusive(YEELIGHT_HSV_TRANSACTION, CONF_TRANSITION): + vol.All(cv.ensure_list, [cv.positive_int]), + vol.Exclusive(YEELIGHT_TEMPERATURE_TRANSACTION, CONF_TRANSITION): + vol.All(cv.ensure_list, [cv.positive_int]), + vol.Exclusive(YEELIGHT_SLEEP_TRANSACTION, CONF_TRANSITION): + vol.All(cv.ensure_list, [cv.positive_int]), + }] +} + +DEVICE_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.positive_int, + vol.Optional(CONF_MODE_MUSIC, default=False): cv.boolean, + vol.Optional(CONF_SAVE_ON_CHANGE, default=False): cv.boolean, + vol.Optional(CONF_MODEL): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_CUSTOM_EFFECTS): [{ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_FLOW_PARAMS): YEELIGHT_FLOW_TRANSITION_SCHEMA + }] + }), +}, extra=vol.ALLOW_EXTRA) + +YEELIGHT_SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, +}) + +NIGHTLIGHT_SUPPORTED_MODELS = [ + "ceiling3", + 'ceiling4' +] + +UPDATE_REQUEST_PROPERTIES = [ + "power", + "bright", + "ct", + "rgb", + "hue", + "sat", + "color_mode", + "flowing", + "music_on", + "nl_br", + "active_mode", +] + + +def _transitions_config_parser(transitions): + """Parse transitions config into initialized objects.""" + import yeelight + + transition_objects = [] + for transition_config in transitions: + transition, params = list(transition_config.items())[0] + transition_objects.append(getattr(yeelight, transition)(*params)) + + return transition_objects + + +def _parse_custom_effects(effects_config): + import yeelight + + effects = {} + for config in effects_config: + params = config[CONF_FLOW_PARAMS] + action = yeelight.Flow.actions[params[ATTR_ACTION]] + transitions = _transitions_config_parser( + params[ATTR_TRANSITIONS]) + + effects[config[CONF_NAME]] = { + ATTR_COUNT: params[ATTR_COUNT], + ATTR_ACTION: action, + ATTR_TRANSITIONS: transitions + } + + return effects + + +def setup(hass, config): + """Set up the Yeelight bulbs.""" + from yeelight.enums import PowerMode + + conf = config[DOMAIN] + yeelight_data = hass.data[DATA_YEELIGHT] = { + CONF_DEVICES: {}, + CONF_LIGHTS: {}, + } + + def device_discovered(service, info): + _LOGGER.debug("Adding autodetected %s", info['hostname']) + + device_type = info['device_type'] + + name = "yeelight_%s_%s" % (device_type, + info['properties']['mac']) + ipaddr = info[CONF_HOST] + device_config = DEVICE_SCHEMA({ + CONF_NAME: name, + CONF_MODEL: device_type + }) + + _setup_device(hass, config, ipaddr, device_config) + + discovery.listen(hass, SERVICE_YEELIGHT, device_discovered) + + def async_update(event): + for device in yeelight_data[CONF_DEVICES].values(): + device.update() + + async_track_time_interval( + hass, async_update, conf[CONF_SCAN_INTERVAL] + ) + + def service_handler(service): + """Dispatch service calls to target entities.""" + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + + entity_ids = extract_entity_ids(hass, service) + target_devices = [dev.device for dev in + yeelight_data[CONF_LIGHTS].values() + if dev.entity_id in entity_ids] + + for target_device in target_devices: + if service.service == SERVICE_SET_MODE: + target_device.set_mode(**params) + elif service.service == SERVICE_START_FLOW: + params[ATTR_TRANSITIONS] = \ + _transitions_config_parser(params[ATTR_TRANSITIONS]) + target_device.start_flow(**params) + + service_schema_set_mode = YEELIGHT_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_MODE): + vol.In([mode.name.lower() for mode in PowerMode]) + }) + hass.services.register( + DOMAIN, SERVICE_SET_MODE, service_handler, + schema=service_schema_set_mode) + + service_schema_start_flow = YEELIGHT_SERVICE_SCHEMA.extend( + YEELIGHT_FLOW_TRANSITION_SCHEMA + ) + hass.services.register( + DOMAIN, SERVICE_START_FLOW, service_handler, + schema=service_schema_start_flow) + + for ipaddr, device_config in conf[CONF_DEVICES].items(): + _LOGGER.debug("Adding configured %s", device_config[CONF_NAME]) + _setup_device(hass, config, ipaddr, device_config) + + return True + + +def _setup_device(hass, hass_config, ipaddr, device_config): + devices = hass.data[DATA_YEELIGHT][CONF_DEVICES] + + if ipaddr in devices: + return + + device = YeelightDevice(hass, ipaddr, device_config) + + devices[ipaddr] = device + + platform_config = device_config.copy() + platform_config[CONF_HOST] = ipaddr + platform_config[CONF_CUSTOM_EFFECTS] = _parse_custom_effects( + hass_config[DATA_YEELIGHT].get(CONF_CUSTOM_EFFECTS, {}) + ) + + load_platform(hass, LIGHT_DOMAIN, DOMAIN, platform_config, hass_config) + + +class YeelightDevice: + """Represents single Yeelight device.""" + + def __init__(self, hass, ipaddr, config): + """Initialize device.""" + self._hass = hass + self._config = config + self._ipaddr = ipaddr + self._name = config.get(CONF_NAME) + self._model = config.get(CONF_MODEL) + self._bulb_device = None + + @property + def bulb(self): + """Return bulb device.""" + import yeelight + if self._bulb_device is None: + try: + self._bulb_device = yeelight.Bulb(self._ipaddr, + model=self._model) + # force init for type + self._update_properties() + + except yeelight.BulbException as ex: + _LOGGER.error("Failed to connect to bulb %s, %s: %s", + self._ipaddr, self._name, ex) + + return self._bulb_device + + def _update_properties(self): + self._bulb_device.get_properties(UPDATE_REQUEST_PROPERTIES) + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def config(self): + """Return device config.""" + return self._config + + @property + def ipaddr(self): + """Return ip address.""" + return self._ipaddr + + @property + def is_nightlight_enabled(self) -> bool: + """Return true / false if nightlight is currently enabled.""" + if self._bulb_device is None: + return False + + return self.bulb.last_properties.get('active_mode') == '1' + + def turn_on(self, duration=DEFAULT_TRANSITION): + """Turn on device.""" + import yeelight + + try: + self._bulb_device.turn_on(duration=duration) + except yeelight.BulbException as ex: + _LOGGER.error("Unable to turn the bulb on: %s", ex) + return + + self.update() + + def turn_off(self, duration=DEFAULT_TRANSITION): + """Turn off device.""" + import yeelight + + try: + self._bulb_device.turn_off(duration=duration) + except yeelight.BulbException as ex: + _LOGGER.error("Unable to turn the bulb on: %s", ex) + return + + self.update() + + def update(self): + """Read new properties from the device.""" + if not self.bulb: + return + + self._update_properties() + dispatcher_send(self._hass, DATA_UPDATED, self._ipaddr) + + def set_mode(self, mode: str): + """Set a power mode.""" + import yeelight + + try: + self.bulb.set_power_mode(yeelight.enums.PowerMode[mode.upper()]) + except yeelight.BulbException as ex: + _LOGGER.error("Unable to set the power mode: %s", ex) + + self.update() + + def start_flow(self, transitions, count=0, action=ACTION_RECOVER): + """Start flow.""" + import yeelight + + try: + flow = yeelight.Flow( + count=count, + action=yeelight.Flow.actions[action], + transitions=transitions) + + self.bulb.start_flow(flow) + except yeelight.BulbException as ex: + _LOGGER.error("Unable to set effect: %s", ex) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 18a0bf750a1351..8c7a94d3020659 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -1,99 +1,26 @@ -""" -Support for Xiaomi Yeelight Wifi color bulb. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.yeelight/ -""" +"""Light platform support for yeelight.""" import logging -import voluptuous as vol - +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, color_temperature_kelvin_to_mired as kelvin_to_mired) -from homeassistant.const import CONF_DEVICES, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_DEVICES, CONF_LIGHTS +from homeassistant.core import callback from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_COLOR_TEMP, ATTR_FLASH, FLASH_SHORT, FLASH_LONG, ATTR_EFFECT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_TRANSITION, SUPPORT_COLOR_TEMP, SUPPORT_FLASH, - SUPPORT_EFFECT, Light, PLATFORM_SCHEMA, ATTR_ENTITY_ID, DOMAIN) -import homeassistant.helpers.config_validation as cv + SUPPORT_EFFECT, Light) import homeassistant.util.color as color_util +from homeassistant.components.yeelight import ( + CONF_TRANSITION, DATA_YEELIGHT, CONF_MODE_MUSIC, + CONF_SAVE_ON_CHANGE, CONF_CUSTOM_EFFECTS, DATA_UPDATED) -REQUIREMENTS = ['yeelight==0.4.3'] +DEPENDENCIES = ['yeelight'] _LOGGER = logging.getLogger(__name__) -LEGACY_DEVICE_TYPE_MAP = { - 'color1': 'rgb', - 'mono1': 'white', - 'strip1': 'strip', - 'bslamp1': 'bedside', - 'ceiling1': 'ceiling', -} - -DEFAULT_NAME = 'Yeelight' -DEFAULT_TRANSITION = 350 - -CONF_MODEL = 'model' -CONF_TRANSITION = 'transition' -CONF_SAVE_ON_CHANGE = 'save_on_change' -CONF_MODE_MUSIC = 'use_music_mode' -CONF_CUSTOM_EFFECTS = 'custom_effects' -CONF_FLOW_PARAMS = 'flow_params' - -DATA_KEY = 'light.yeelight' - -ATTR_MODE = 'mode' -ATTR_COUNT = 'count' -ATTR_ACTION = 'action' -ATTR_TRANSITIONS = 'transitions' - -ACTION_RECOVER = 'recover' -ACTION_STAY = 'stay' -ACTION_OFF = 'off' - -YEELIGHT_RGB_TRANSITION = 'RGBTransition' -YEELIGHT_HSV_TRANSACTION = 'HSVTransition' -YEELIGHT_TEMPERATURE_TRANSACTION = 'TemperatureTransition' -YEELIGHT_SLEEP_TRANSACTION = 'SleepTransition' - -YEELIGHT_SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, -}) - -YEELIGHT_FLOW_TRANSITION_SCHEMA = { - vol.Optional(ATTR_COUNT, default=0): cv.positive_int, - vol.Optional(ATTR_ACTION, default=ACTION_RECOVER): - vol.Any(ACTION_RECOVER, ACTION_OFF, ACTION_STAY), - vol.Required(ATTR_TRANSITIONS): [{ - vol.Exclusive(YEELIGHT_RGB_TRANSITION, CONF_TRANSITION): - vol.All(cv.ensure_list, [cv.positive_int]), - vol.Exclusive(YEELIGHT_HSV_TRANSACTION, CONF_TRANSITION): - vol.All(cv.ensure_list, [cv.positive_int]), - vol.Exclusive(YEELIGHT_TEMPERATURE_TRANSACTION, CONF_TRANSITION): - vol.All(cv.ensure_list, [cv.positive_int]), - vol.Exclusive(YEELIGHT_SLEEP_TRANSACTION, CONF_TRANSITION): - vol.All(cv.ensure_list, [cv.positive_int]), - }] -} - -DEVICE_SCHEMA = vol.Schema({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.positive_int, - vol.Optional(CONF_MODE_MUSIC, default=False): cv.boolean, - vol.Optional(CONF_SAVE_ON_CHANGE, default=False): cv.boolean, - vol.Optional(CONF_MODEL): cv.string, -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, - vol.Optional(CONF_CUSTOM_EFFECTS): [{ - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_FLOW_PARAMS): YEELIGHT_FLOW_TRANSITION_SCHEMA - }] -}) - SUPPORT_YEELIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_FLASH) @@ -143,9 +70,6 @@ EFFECT_TWITTER, EFFECT_STOP] -SERVICE_SET_MODE = 'yeelight_set_mode' -SERVICE_START_FLOW = 'yeelight_start_flow' - def _cmd(func): """Define a wrapper to catch exceptions from the bulb.""" @@ -160,117 +84,39 @@ def _wrap(self, *args, **kwargs): return _wrap -def _parse_custom_effects(effects_config): - import yeelight - - effects = {} - for config in effects_config: - params = config[CONF_FLOW_PARAMS] - action = yeelight.Flow.actions[params[ATTR_ACTION]] - transitions = YeelightLight.transitions_config_parser( - params[ATTR_TRANSITIONS]) - - effects[config[CONF_NAME]] = { - ATTR_COUNT: params[ATTR_COUNT], - ATTR_ACTION: action, - ATTR_TRANSITIONS: transitions - } - - return effects - - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Yeelight bulbs.""" - from yeelight.enums import PowerMode - - if DATA_KEY not in hass.data: - hass.data[DATA_KEY] = {} + if not discovery_info: + return - lights = [] - if discovery_info is not None: - _LOGGER.debug("Adding autodetected %s", discovery_info['hostname']) + yeelight_data = hass.data[DATA_YEELIGHT] + ipaddr = discovery_info[CONF_HOST] + device = yeelight_data[CONF_DEVICES][ipaddr] + _LOGGER.debug("Adding %s", device.name) - device_type = discovery_info['device_type'] - legacy_device_type = LEGACY_DEVICE_TYPE_MAP.get(device_type, - device_type) + custom_effects = discovery_info[CONF_CUSTOM_EFFECTS] + light = YeelightLight(device, custom_effects=custom_effects) - # Not using hostname, as it seems to vary. - name = "yeelight_%s_%s" % (legacy_device_type, - discovery_info['properties']['mac']) - device = {'name': name, 'ipaddr': discovery_info['host']} - - light = YeelightLight(device, DEVICE_SCHEMA({CONF_MODEL: device_type})) - lights.append(light) - hass.data[DATA_KEY][name] = light - else: - for ipaddr, device_config in config[CONF_DEVICES].items(): - name = device_config[CONF_NAME] - _LOGGER.debug("Adding configured %s", name) - - device = {'name': name, 'ipaddr': ipaddr} - - if CONF_CUSTOM_EFFECTS in config: - custom_effects = \ - _parse_custom_effects(config[CONF_CUSTOM_EFFECTS]) - else: - custom_effects = None - - light = YeelightLight(device, device_config, - custom_effects=custom_effects) - lights.append(light) - hass.data[DATA_KEY][name] = light - - add_entities(lights, True) - - def service_handler(service): - """Dispatch service calls to target entities.""" - params = {key: value for key, value in service.data.items() - if key != ATTR_ENTITY_ID} - entity_ids = service.data.get(ATTR_ENTITY_ID) - target_devices = [dev for dev in hass.data[DATA_KEY].values() - if dev.entity_id in entity_ids] - - for target_device in target_devices: - if service.service == SERVICE_SET_MODE: - target_device.set_mode(**params) - elif service.service == SERVICE_START_FLOW: - target_device.start_flow(**params) - - service_schema_set_mode = YEELIGHT_SERVICE_SCHEMA.extend({ - vol.Required(ATTR_MODE): - vol.In([mode.name.lower() for mode in PowerMode]) - }) - hass.services.register( - DOMAIN, SERVICE_SET_MODE, service_handler, - schema=service_schema_set_mode) - - service_schema_start_flow = YEELIGHT_SERVICE_SCHEMA.extend( - YEELIGHT_FLOW_TRANSITION_SCHEMA - ) - hass.services.register( - DOMAIN, SERVICE_START_FLOW, service_handler, - schema=service_schema_start_flow) + yeelight_data[CONF_LIGHTS][ipaddr] = light + add_entities([light], True) class YeelightLight(Light): """Representation of a Yeelight light.""" - def __init__(self, device, config, custom_effects=None): + def __init__(self, device, custom_effects=None): """Initialize the Yeelight light.""" - self.config = config - self._name = device['name'] - self._ipaddr = device['ipaddr'] + self.config = device.config + self._device = device self._supported_features = SUPPORT_YEELIGHT self._available = False - self._bulb_device = None self._brightness = None self._color_temp = None self._is_on = None self._hs = None - self._model = config.get('model') self._min_mireds = None self._max_mireds = None @@ -279,6 +125,22 @@ def __init__(self, device, config, custom_effects=None): else: self._custom_effects = {} + @callback + def _schedule_immediate_update(self, ipaddr): + if ipaddr == self.device.ipaddr: + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + + @property + def should_poll(self): + """No polling needed.""" + return False + @property def available(self) -> bool: """Return if bulb is available.""" @@ -302,7 +164,7 @@ def color_temp(self) -> int: @property def name(self) -> str: """Return the name of the device if any.""" - return self._name + return self.device.name @property def is_on(self) -> bool: @@ -363,27 +225,26 @@ def hs_color(self) -> tuple: @property def _properties(self) -> dict: - if self._bulb_device is None: + if self._bulb is None: return {} - return self._bulb_device.last_properties + return self._bulb.last_properties + + @property + def device(self): + """Return yeelight device.""" + return self._device # F821: https://github.com/PyCQA/pyflakes/issues/373 @property def _bulb(self) -> 'yeelight.Bulb': # noqa: F821 - import yeelight - if self._bulb_device is None: - try: - self._bulb_device = yeelight.Bulb(self._ipaddr, - model=self._model) - self._bulb_device.get_properties() # force init for type + bulb = self.device.bulb - self._available = True - except yeelight.BulbException as ex: - self._available = False - _LOGGER.error("Failed to connect to bulb %s, %s: %s", - self._ipaddr, self._name, ex) + if bulb: + self._available = True + return bulb - return self._bulb_device + self._available = False + return None def set_music_mode(self, mode) -> None: """Set the music mode on or off.""" @@ -396,12 +257,13 @@ def update(self) -> None: """Update properties from the bulb.""" import yeelight try: - self._bulb.get_properties() - - if self._bulb_device.bulb_type == yeelight.BulbType.Color: + if self._bulb.bulb_type == yeelight.BulbType.Color: self._supported_features = SUPPORT_YEELIGHT_RGB - elif self._bulb_device.bulb_type == yeelight.BulbType.WhiteTemp: - self._supported_features = SUPPORT_YEELIGHT_WHITE_TEMP + elif self._bulb.bulb_type == yeelight.BulbType.WhiteTemp: + if self._device.is_nightlight_enabled: + self._supported_features = SUPPORT_YEELIGHT + else: + self._supported_features = SUPPORT_YEELIGHT_WHITE_TEMP if self._min_mireds is None: model_specs = self._bulb.get_model_specs() @@ -412,7 +274,11 @@ def update(self) -> None: self._is_on = self._properties.get('power') == 'on' - bright = self._properties.get('bright', None) + if self._device.is_nightlight_enabled: + bright = self._properties.get('nl_br', None) + else: + bright = self._properties.get('bright', None) + if bright: self._brightness = round(255 * (int(bright) / 100)) @@ -552,11 +418,7 @@ def turn_on(self, **kwargs) -> None: if ATTR_TRANSITION in kwargs: # passed kwarg overrides config duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s - try: - self._bulb.turn_on(duration=duration) - except yeelight.BulbException as ex: - _LOGGER.error("Unable to turn the bulb on: %s", ex) - return + self.device.turn_on(duration=duration) if self.config[CONF_MODE_MUSIC] and not self._bulb.music_mode: try: @@ -588,46 +450,8 @@ def turn_on(self, **kwargs) -> None: def turn_off(self, **kwargs) -> None: """Turn off.""" - import yeelight duration = int(self.config[CONF_TRANSITION]) # in ms if ATTR_TRANSITION in kwargs: # passed kwarg overrides config duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s - try: - self._bulb.turn_off(duration=duration) - except yeelight.BulbException as ex: - _LOGGER.error("Unable to turn the bulb off: %s", ex) - - def set_mode(self, mode: str): - """Set a power mode.""" - import yeelight - try: - self._bulb.set_power_mode(yeelight.enums.PowerMode[mode.upper()]) - self.async_schedule_update_ha_state(True) - except yeelight.BulbException as ex: - _LOGGER.error("Unable to set the power mode: %s", ex) - - @staticmethod - def transitions_config_parser(transitions): - """Parse transitions config into initialized objects.""" - import yeelight - - transition_objects = [] - for transition_config in transitions: - transition, params = list(transition_config.items())[0] - transition_objects.append(getattr(yeelight, transition)(*params)) - return transition_objects - - def start_flow(self, transitions, count=0, action=ACTION_RECOVER): - """Start flow.""" - import yeelight - - try: - flow = yeelight.Flow( - count=count, - action=yeelight.Flow.actions[action], - transitions=self.transitions_config_parser(transitions)) - - self._bulb.start_flow(flow) - except yeelight.BulbException as ex: - _LOGGER.error("Unable to set effect: %s", ex) + self.device.turn_off(duration=duration) diff --git a/homeassistant/components/yeelight/services.yaml b/homeassistant/components/yeelight/services.yaml new file mode 100644 index 00000000000000..14dcfb27a4d54a --- /dev/null +++ b/homeassistant/components/yeelight/services.yaml @@ -0,0 +1,25 @@ +set_mode: + description: Set a operation mode. + fields: + entity_id: + description: Name of the light entity. + example: 'light.yeelight' + mode: + description: Operation mode. Valid values are 'last', 'normal', 'rgb', 'hsv', 'color_flow', 'moonlight'. + example: 'moonlight' + +start_flow: + description: Start a custom flow, using transitions from https://yeelight.readthedocs.io/en/stable/yeelight.html#flow-objects + fields: + entity_id: + description: Name of the light entity. + example: 'light.yeelight' + count: + description: The number of times to run this flow (0 to run forever). + example: 0 + action: + description: The action to take after the flow stops. Can be 'recover', 'stay', 'off'. (default 'recover') + example: 'stay' + transitions: + description: Array of transitions, for desired effect. Examples https://yeelight.readthedocs.io/en/stable/flow.html + example: '[{ "TemperatureTransition": [1900, 1000, 80] }, { "TemperatureTransition": [1900, 1000, 10] }]' diff --git a/requirements_all.txt b/requirements_all.txt index cd74cbc0dd483d..ea91ef5e9f40c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1813,7 +1813,7 @@ yahooweather==0.10 # homeassistant.components.yale_smart_alarm.alarm_control_panel yalesmartalarmclient==0.1.6 -# homeassistant.components.yeelight.light +# homeassistant.components.yeelight yeelight==0.4.3 # homeassistant.components.yeelightsunflower.light From 6988fe783cd780c742825894d00eb056d3c7e622 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 24 Mar 2019 16:16:50 +0100 Subject: [PATCH 03/69] Axis config flow (#18543) * Initial draft * Add tests for init Fix hound comments * Add tests for device Change parameter handling to make device easier to test * Remove superfluous functionality per Martins request * Fix hound comments * Embedded platforms * Fix device import * Config flow retry * Options default values will be set automatically to options in config entry before component can be used * Clean up init Add populate options Fix small issues in config flow Add tests covering init * Improve device tests * Add config flow tests * Fix hound comments * Rebase miss * Initial tests for binary sensors * Clean up More binary sensor tests * Hound comments * Add camera tests * Fix initial state of sensors * Bump dependency to v17 * Fix pylint and flake8 * Fix comments --- .coveragerc | 1 - .../components/axis/.translations/en.json | 26 ++ homeassistant/components/axis/__init__.py | 268 +++------------ .../components/axis/binary_sensor.py | 83 ++--- homeassistant/components/axis/camera.py | 69 ++-- homeassistant/components/axis/config_flow.py | 202 +++++++++++ homeassistant/components/axis/const.py | 12 + homeassistant/components/axis/device.py | 127 +++++++ homeassistant/components/axis/errors.py | 22 ++ homeassistant/components/axis/services.yaml | 15 - homeassistant/components/axis/strings.json | 26 ++ .../components/discovery/__init__.py | 2 +- homeassistant/config_entries.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/axis/__init__.py | 1 + tests/components/axis/test_binary_sensor.py | 102 ++++++ tests/components/axis/test_camera.py | 73 ++++ tests/components/axis/test_config_flow.py | 319 ++++++++++++++++++ tests/components/axis/test_device.py | 152 +++++++++ tests/components/axis/test_init.py | 97 ++++++ 22 files changed, 1284 insertions(+), 320 deletions(-) create mode 100644 homeassistant/components/axis/.translations/en.json create mode 100644 homeassistant/components/axis/config_flow.py create mode 100644 homeassistant/components/axis/const.py create mode 100644 homeassistant/components/axis/device.py create mode 100644 homeassistant/components/axis/errors.py delete mode 100644 homeassistant/components/axis/services.yaml create mode 100644 homeassistant/components/axis/strings.json create mode 100644 tests/components/axis/__init__.py create mode 100644 tests/components/axis/test_binary_sensor.py create mode 100644 tests/components/axis/test_camera.py create mode 100644 tests/components/axis/test_config_flow.py create mode 100644 tests/components/axis/test_device.py create mode 100644 tests/components/axis/test_init.py diff --git a/.coveragerc b/.coveragerc index 67b0c9f76a939e..42e7d84dc099bf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,7 +36,6 @@ omit = homeassistant/components/arlo/* homeassistant/components/asterisk_mbox/* homeassistant/components/august/* - homeassistant/components/axis/* homeassistant/components/bbb_gpio/* homeassistant/components/arest/binary_sensor.py homeassistant/components/concord232/binary_sensor.py diff --git a/homeassistant/components/axis/.translations/en.json b/homeassistant/components/axis/.translations/en.json new file mode 100644 index 00000000000000..3c528dfbb16112 --- /dev/null +++ b/homeassistant/components/axis/.translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "title": "Axis device", + "step": { + "user": { + "title": "Set up Axis device", + "data": { + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port" + } + } + }, + "error": { + "already_configured": "Device is already configured", + "device_unavailable": "Device is not available", + "faulty_credentials": "Bad user credentials" + }, + "abort": { + "already_configured": "Device is already configured", + "bad_config_file": "Bad data from config file", + "link_local_address": "Link local addresses are not supported" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index df723272a7acd7..324c2cf369e8a5 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -1,262 +1,76 @@ """Support for Axis devices.""" -import logging import voluptuous as vol -from homeassistant.components.discovery import SERVICE_AXIS +from homeassistant import config_entries from homeassistant.const import ( - ATTR_LOCATION, CONF_EVENT, CONF_HOST, CONF_INCLUDE, - CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_TRIGGER_TIME, CONF_USERNAME, + CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_TRIGGER_TIME, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import discovery -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['axis==16'] +from .config_flow import configured_devices, DEVICE_SCHEMA +from .const import CONF_CAMERA, CONF_EVENTS, DEFAULT_TRIGGER_TIME, DOMAIN +from .device import AxisNetworkDevice, get_device -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'axis' -CONFIG_FILE = 'axis.conf' - -EVENT_TYPES = ['motion', 'vmd3', 'pir', 'sound', - 'daynight', 'tampering', 'input'] - -PLATFORMS = ['camera'] - -AXIS_INCLUDE = EVENT_TYPES + PLATFORMS - -AXIS_DEFAULT_HOST = '192.168.0.90' -AXIS_DEFAULT_USERNAME = 'root' -AXIS_DEFAULT_PASSWORD = 'pass' -DEFAULT_PORT = 80 - -DEVICE_SCHEMA = vol.Schema({ - vol.Required(CONF_INCLUDE): - vol.All(cv.ensure_list, [vol.In(AXIS_INCLUDE)]), - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_HOST, default=AXIS_DEFAULT_HOST): cv.string, - vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, - vol.Optional(CONF_TRIGGER_TIME, default=0): cv.positive_int, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(ATTR_LOCATION, default=''): cv.string, -}) +REQUIREMENTS = ['axis==17'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: cv.schema_with_slug_keys(DEVICE_SCHEMA), }, extra=vol.ALLOW_EXTRA) -SERVICE_VAPIX_CALL = 'vapix_call' -SERVICE_VAPIX_CALL_RESPONSE = 'vapix_call_response' -SERVICE_CGI = 'cgi' -SERVICE_ACTION = 'action' -SERVICE_PARAM = 'param' -SERVICE_DEFAULT_CGI = 'param.cgi' -SERVICE_DEFAULT_ACTION = 'update' - -SERVICE_SCHEMA = vol.Schema({ - vol.Required(CONF_NAME): cv.string, - vol.Required(SERVICE_PARAM): cv.string, - vol.Optional(SERVICE_CGI, default=SERVICE_DEFAULT_CGI): cv.string, - vol.Optional(SERVICE_ACTION, default=SERVICE_DEFAULT_ACTION): cv.string, -}) - - -def request_configuration(hass, config, name, host, serialnumber): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - def configuration_callback(callback_data): - """Call when configuration is submitted.""" - if CONF_INCLUDE not in callback_data: - configurator.notify_errors( - request_id, "Functionality mandatory.") - return False - - callback_data[CONF_INCLUDE] = callback_data[CONF_INCLUDE].split() - callback_data[CONF_HOST] = host - - if CONF_NAME not in callback_data: - callback_data[CONF_NAME] = name - - try: - device_config = DEVICE_SCHEMA(callback_data) - except vol.Invalid: - configurator.notify_errors( - request_id, "Bad input, please check spelling.") - return False - - if setup_device(hass, config, device_config): - config_file = load_json(hass.config.path(CONFIG_FILE)) - config_file[serialnumber] = dict(device_config) - save_json(hass.config.path(CONFIG_FILE), config_file) - configurator.request_done(request_id) - else: - configurator.notify_errors( - request_id, "Failed to register, please try again.") - return False - title = '{} ({})'.format(name, host) - request_id = configurator.request_config( - title, configuration_callback, - description='Functionality: ' + str(AXIS_INCLUDE), - entity_picture="/static/images/logo_axis.png", - link_name='Axis platform documentation', - link_url='https://home-assistant.io/components/axis/', - submit_caption="Confirm", - fields=[ - {'id': CONF_NAME, - 'name': "Device name", - 'type': 'text'}, - {'id': CONF_USERNAME, - 'name': "User name", - 'type': 'text'}, - {'id': CONF_PASSWORD, - 'name': 'Password', - 'type': 'password'}, - {'id': CONF_INCLUDE, - 'name': "Device functionality (space separated list)", - 'type': 'text'}, - {'id': ATTR_LOCATION, - 'name': "Physical location of device (optional)", - 'type': 'text'}, - {'id': CONF_PORT, - 'name': "HTTP port (default=80)", - 'type': 'number'}, - {'id': CONF_TRIGGER_TIME, - 'name': "Sensor update interval (optional)", - 'type': 'number'}, - ] - ) - - -def setup(hass, config): +async def async_setup(hass, config): """Set up for Axis devices.""" - hass.data[DOMAIN] = {} + if DOMAIN in config: - def _shutdown(call): - """Stop the event stream on shutdown.""" - for serialnumber, device in hass.data[DOMAIN].items(): - _LOGGER.info("Stopping event stream for %s.", serialnumber) - device.stop() + for device_name, device_config in config[DOMAIN].items(): - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + if CONF_NAME not in device_config: + device_config[CONF_NAME] = device_name - def axis_device_discovered(service, discovery_info): - """Call when axis devices has been found.""" - host = discovery_info[CONF_HOST] - name = discovery_info['hostname'] - serialnumber = discovery_info['properties']['macaddress'] + if device_config[CONF_HOST] not in configured_devices(hass): + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data=device_config + )) - if serialnumber not in hass.data[DOMAIN]: - config_file = load_json(hass.config.path(CONFIG_FILE)) - if serialnumber in config_file: - # Device config previously saved to file - try: - device_config = DEVICE_SCHEMA(config_file[serialnumber]) - device_config[CONF_HOST] = host - except vol.Invalid as err: - _LOGGER.error("Bad data from %s. %s", CONFIG_FILE, err) - return False - if not setup_device(hass, config, device_config): - _LOGGER.error( - "Couldn't set up %s", device_config[CONF_NAME]) - else: - # New device, create configuration request for UI - request_configuration(hass, config, name, host, serialnumber) - else: - # Device already registered, but on a different IP - device = hass.data[DOMAIN][serialnumber] - device.config.host = host - dispatcher_send(hass, DOMAIN + '_' + device.name + '_new_ip', host) + return True - # Register discovery service - discovery.listen(hass, SERVICE_AXIS, axis_device_discovered) - if DOMAIN in config: - for device in config[DOMAIN]: - device_config = config[DOMAIN][device] - if CONF_NAME not in device_config: - device_config[CONF_NAME] = device - if not setup_device(hass, config, device_config): - _LOGGER.error("Couldn't set up %s", device_config[CONF_NAME]) +async def async_setup_entry(hass, config_entry): + """Set up the Axis component.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} - def vapix_service(call): - """Service to send a message.""" - for device in hass.data[DOMAIN].values(): - if device.name == call.data[CONF_NAME]: - response = device.vapix.do_request( - call.data[SERVICE_CGI], - call.data[SERVICE_ACTION], - call.data[SERVICE_PARAM]) - hass.bus.fire(SERVICE_VAPIX_CALL_RESPONSE, response) - return True - _LOGGER.info("Couldn't find device %s", call.data[CONF_NAME]) - return False + if not config_entry.options: + await async_populate_options(hass, config_entry) - # Register service with Home Assistant. - hass.services.register( - DOMAIN, SERVICE_VAPIX_CALL, vapix_service, schema=SERVICE_SCHEMA) - return True + device = AxisNetworkDevice(hass, config_entry) + if not await device.async_setup(): + return False -def setup_device(hass, config, device_config): - """Set up an Axis device.""" - import axis + hass.data[DOMAIN][device.serial] = device - def signal_callback(action, event): - """Call to configure events when initialized on event stream.""" - if action == 'add': - event_config = { - CONF_EVENT: event, - CONF_NAME: device_config[CONF_NAME], - ATTR_LOCATION: device_config[ATTR_LOCATION], - CONF_TRIGGER_TIME: device_config[CONF_TRIGGER_TIME] - } - component = event.event_platform - discovery.load_platform( - hass, component, DOMAIN, event_config, config) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.shutdown) - event_types = [ - event - for event in device_config[CONF_INCLUDE] - if event in EVENT_TYPES - ] + return True - device = axis.AxisDevice( - loop=hass.loop, host=device_config[CONF_HOST], - username=device_config[CONF_USERNAME], - password=device_config[CONF_PASSWORD], - port=device_config[CONF_PORT], web_proto='http', - event_types=event_types, signal=signal_callback) - try: - hass.data[DOMAIN][device.vapix.serial_number] = device +async def async_populate_options(hass, config_entry): + """Populate default options for device.""" + from axis.vapix import VAPIX_IMAGE_FORMAT - except axis.Unauthorized: - _LOGGER.error("Credentials for %s are faulty", - device_config[CONF_HOST]) - return False + device = await get_device(hass, config_entry.data[CONF_DEVICE]) - except axis.RequestError: - return False + supported_formats = device.vapix.get_param(VAPIX_IMAGE_FORMAT) - device.name = device_config[CONF_NAME] + camera = bool(supported_formats) - for component in device_config[CONF_INCLUDE]: - if component == 'camera': - camera_config = { - CONF_NAME: device_config[CONF_NAME], - CONF_HOST: device_config[CONF_HOST], - CONF_PORT: device_config[CONF_PORT], - CONF_USERNAME: device_config[CONF_USERNAME], - CONF_PASSWORD: device_config[CONF_PASSWORD] - } - discovery.load_platform( - hass, component, DOMAIN, camera_config, config) + options = { + CONF_CAMERA: camera, + CONF_EVENTS: True, + CONF_TRIGGER_TIME: DEFAULT_TRIGGER_TIME + } - if event_types: - hass.add_job(device.start) - return True + hass.config_entries.async_update_entry(config_entry, options=options) diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index 11014dc4bc97cd..ec4c27ea34357b 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -1,86 +1,87 @@ """Support for Axis binary sensors.""" + from datetime import timedelta -import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.const import ( - ATTR_LOCATION, CONF_EVENT, CONF_NAME, CONF_TRIGGER_TIME) +from homeassistant.const import CONF_MAC, CONF_TRIGGER_TIME from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -DEPENDENCIES = ['axis'] +from .const import DOMAIN as AXIS_DOMAIN, LOGGER + +DEPENDENCIES = [AXIS_DOMAIN] + -_LOGGER = logging.getLogger(__name__) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a Axis binary sensor.""" + serial_number = config_entry.data[CONF_MAC] + device = hass.data[AXIS_DOMAIN][serial_number] + @callback + def async_add_sensor(event): + """Add binary sensor from Axis device.""" + async_add_entities([AxisBinarySensor(event, device)], True) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Axis binary devices.""" - add_entities([AxisBinarySensor(discovery_info)], True) + device.listeners.append( + async_dispatcher_connect(hass, 'axis_add_sensor', async_add_sensor)) class AxisBinarySensor(BinarySensorDevice): """Representation of a binary Axis event.""" - def __init__(self, event_config): + def __init__(self, event, device): """Initialize the Axis binary sensor.""" - self.axis_event = event_config[CONF_EVENT] - self.device_name = event_config[CONF_NAME] - self.location = event_config[ATTR_LOCATION] - self.delay = event_config[CONF_TRIGGER_TIME] + self.event = event + self.device = device + self.delay = device.config_entry.options[CONF_TRIGGER_TIME] self.remove_timer = None async def async_added_to_hass(self): """Subscribe sensors events.""" - self.axis_event.callback = self._update_callback + self.event.register_callback(self.update_callback) - def _update_callback(self): + def update_callback(self): """Update the sensor's state, if needed.""" + delay = self.device.config_entry.options[CONF_TRIGGER_TIME] + if self.remove_timer is not None: self.remove_timer() self.remove_timer = None - if self.delay == 0 or self.is_on: + if delay == 0 or self.is_on: self.schedule_update_ha_state() - else: # Run timer to delay updating the state - @callback - def _delay_update(now): - """Timer callback for sensor update.""" - _LOGGER.debug("%s called delayed (%s sec) update", - self.name, self.delay) - self.async_schedule_update_ha_state() - self.remove_timer = None - - self.remove_timer = async_track_point_in_utc_time( - self.hass, _delay_update, - utcnow() + timedelta(seconds=self.delay)) + return + + @callback + def _delay_update(now): + """Timer callback for sensor update.""" + LOGGER.debug("%s called delayed (%s sec) update", self.name, delay) + self.async_schedule_update_ha_state() + self.remove_timer = None + + self.remove_timer = async_track_point_in_utc_time( + self.hass, _delay_update, + utcnow() + timedelta(seconds=delay)) @property def is_on(self): """Return true if event is active.""" - return self.axis_event.is_tripped + return self.event.is_tripped @property def name(self): """Return the name of the event.""" - return '{}_{}_{}'.format( - self.device_name, self.axis_event.event_type, self.axis_event.id) + return '{} {} {}'.format( + self.device.name, self.event.event_type, self.event.id) @property def device_class(self): """Return the class of the event.""" - return self.axis_event.event_class + return self.event.event_class @property def should_poll(self): """No polling needed.""" return False - - @property - def device_state_attributes(self): - """Return the state attributes of the event.""" - attr = {} - - attr[ATTR_LOCATION] = self.location - - return attr diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index adf380eee4364b..60dab841048d2d 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -1,58 +1,59 @@ """Support for Axis camera streaming.""" -import logging from homeassistant.components.mjpeg.camera import ( CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera, filter_urllib3_logging) from homeassistant.const import ( - CONF_AUTHENTICATION, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_USERNAME, HTTP_DIGEST_AUTHENTICATION) -from homeassistant.helpers.dispatcher import dispatcher_connect + CONF_AUTHENTICATION, CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME, + CONF_PASSWORD, CONF_PORT, CONF_USERNAME, HTTP_DIGEST_AUTHENTICATION) +from homeassistant.helpers.dispatcher import async_dispatcher_connect -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN as AXIS_DOMAIN -DOMAIN = 'axis' -DEPENDENCIES = [DOMAIN] +DEPENDENCIES = [AXIS_DOMAIN] +AXIS_IMAGE = 'http://{}:{}/axis-cgi/jpg/image.cgi' +AXIS_VIDEO = 'http://{}:{}/axis-cgi/mjpg/video.cgi' -def _get_image_url(host, port, mode): - """Set the URL to get the image.""" - if mode == 'mjpeg': - return 'http://{}:{}/axis-cgi/mjpg/video.cgi'.format(host, port) - if mode == 'single': - return 'http://{}:{}/axis-cgi/jpg/image.cgi'.format(host, port) - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Axis camera.""" +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Axis camera video stream.""" filter_urllib3_logging() - camera_config = { - CONF_NAME: discovery_info[CONF_NAME], - CONF_USERNAME: discovery_info[CONF_USERNAME], - CONF_PASSWORD: discovery_info[CONF_PASSWORD], - CONF_MJPEG_URL: _get_image_url( - discovery_info[CONF_HOST], str(discovery_info[CONF_PORT]), - 'mjpeg'), - CONF_STILL_IMAGE_URL: _get_image_url( - discovery_info[CONF_HOST], str(discovery_info[CONF_PORT]), - 'single'), + serial_number = config_entry.data[CONF_MAC] + device = hass.data[AXIS_DOMAIN][serial_number] + + config = { + CONF_NAME: config_entry.data[CONF_NAME], + CONF_USERNAME: config_entry.data[CONF_DEVICE][CONF_USERNAME], + CONF_PASSWORD: config_entry.data[CONF_DEVICE][CONF_PASSWORD], + CONF_MJPEG_URL: AXIS_VIDEO.format( + config_entry.data[CONF_DEVICE][CONF_HOST], + config_entry.data[CONF_DEVICE][CONF_PORT]), + CONF_STILL_IMAGE_URL: AXIS_IMAGE.format( + config_entry.data[CONF_DEVICE][CONF_HOST], + config_entry.data[CONF_DEVICE][CONF_PORT]), CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, } - add_entities([AxisCamera( - hass, camera_config, str(discovery_info[CONF_PORT]))]) + async_add_entities([AxisCamera(config, device)]) class AxisCamera(MjpegCamera): """Representation of a Axis camera.""" - def __init__(self, hass, config, port): + def __init__(self, config, device): """Initialize Axis Communications camera component.""" super().__init__(config) - self.port = port - dispatcher_connect( - hass, DOMAIN + '_' + config[CONF_NAME] + '_new_ip', self._new_ip) + self.device_config = config + self.device = device + self.port = device.config_entry.data[CONF_DEVICE][CONF_PORT] + self.unsub_dispatcher = None + + async def async_added_to_hass(self): + """Subscribe camera events.""" + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, 'axis_{}_new_ip'.format(self.device.name), self._new_ip) def _new_ip(self, host): """Set new IP for video stream.""" - self._mjpeg_url = _get_image_url(host, self.port, 'mjpeg') - self._still_image_url = _get_image_url(host, self.port, 'single') + self._mjpeg_url = AXIS_VIDEO.format(host, self.port) + self._still_image_url = AXIS_IMAGE.format(host, self.port) diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py new file mode 100644 index 00000000000000..24c286b140a659 --- /dev/null +++ b/homeassistant/components/axis/config_flow.py @@ -0,0 +1,202 @@ +"""Config flow to configure Axis devices.""" + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_USERNAME) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.util.json import load_json + +from .const import CONF_MODEL, DOMAIN +from .device import get_device +from .errors import AlreadyConfigured, AuthenticationRequired, CannotConnect + +CONFIG_FILE = 'axis.conf' + +EVENT_TYPES = ['motion', 'vmd3', 'pir', 'sound', + 'daynight', 'tampering', 'input'] + +PLATFORMS = ['camera'] + +AXIS_INCLUDE = EVENT_TYPES + PLATFORMS + +AXIS_DEFAULT_HOST = '192.168.0.90' +AXIS_DEFAULT_USERNAME = 'root' +AXIS_DEFAULT_PASSWORD = 'pass' +DEFAULT_PORT = 80 + +DEVICE_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HOST, default=AXIS_DEFAULT_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}, extra=vol.ALLOW_EXTRA) + + +@callback +def configured_devices(hass): + """Return a set of the configured devices.""" + return set(entry.data[CONF_DEVICE][CONF_HOST] for entry + in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class AxisFlowHandler(config_entries.ConfigFlow): + """Handle a Axis config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize the Axis config flow.""" + self.device_config = {} + self.model = None + self.name = None + self.serial_number = None + + self.discovery_schema = {} + self.import_schema = {} + + async def async_step_user(self, user_input=None): + """Handle a Axis config flow start. + + Manage device specific parameters. + """ + from axis.vapix import VAPIX_MODEL_ID, VAPIX_SERIAL_NUMBER + errors = {} + + if user_input is not None: + try: + if user_input[CONF_HOST] in configured_devices(self.hass): + raise AlreadyConfigured + + self.device_config = { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD] + } + device = await get_device(self.hass, self.device_config) + + self.serial_number = device.vapix.get_param( + VAPIX_SERIAL_NUMBER) + self.model = device.vapix.get_param(VAPIX_MODEL_ID) + + return await self._create_entry() + + except AlreadyConfigured: + errors['base'] = 'already_configured' + + except AuthenticationRequired: + errors['base'] = 'faulty_credentials' + + except CannotConnect: + errors['base'] = 'device_unavailable' + + data = self.import_schema or self.discovery_schema or { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int + } + + return self.async_show_form( + step_id='user', + description_placeholders=self.device_config, + data_schema=vol.Schema(data), + errors=errors + ) + + async def _create_entry(self): + """Create entry for device. + + Generate a name to be used as a prefix for device entities. + """ + if self.name is None: + same_model = [ + entry.data[CONF_NAME] for entry + in self.hass.config_entries.async_entries(DOMAIN) + if entry.data[CONF_MODEL] == self.model + ] + + self.name = "{}".format(self.model) + for idx in range(len(same_model) + 1): + self.name = "{} {}".format(self.model, idx) + if self.name not in same_model: + break + + data = { + CONF_DEVICE: self.device_config, + CONF_NAME: self.name, + CONF_MAC: self.serial_number, + CONF_MODEL: self.model, + } + + title = "{} - {}".format(self.model, self.serial_number) + return self.async_create_entry( + title=title, + data=data + ) + + async def async_step_discovery(self, discovery_info): + """Prepare configuration for a discovered Axis device. + + This flow is triggered by the discovery component. + """ + if discovery_info[CONF_HOST] in configured_devices(self.hass): + return self.async_abort(reason='already_configured') + + if discovery_info[CONF_HOST].startswith('169.254'): + return self.async_abort(reason='link_local_address') + + config_file = await self.hass.async_add_executor_job( + load_json, self.hass.config.path(CONFIG_FILE)) + + serialnumber = discovery_info['properties']['macaddress'] + + if serialnumber not in config_file: + self.discovery_schema = { + vol.Required( + CONF_HOST, default=discovery_info[CONF_HOST]): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_PORT, default=discovery_info[CONF_PORT]): int + } + return await self.async_step_user() + + try: + device_config = DEVICE_SCHEMA(config_file[serialnumber]) + device_config[CONF_HOST] = discovery_info[CONF_HOST] + + if CONF_NAME not in device_config: + device_config[CONF_NAME] = discovery_info['hostname'] + + except vol.Invalid: + return self.async_abort(reason='bad_config_file') + + return await self.async_step_import(device_config) + + async def async_step_import(self, import_config): + """Import a Axis device as a config entry. + + This flow is triggered by `async_setup` for configured devices. + This flow is also triggered by `async_step_discovery`. + + This will execute for any Axis device that contains a complete + configuration. + """ + self.name = import_config[CONF_NAME] + + self.import_schema = { + vol.Required(CONF_HOST, default=import_config[CONF_HOST]): str, + vol.Required( + CONF_USERNAME, default=import_config[CONF_USERNAME]): str, + vol.Required( + CONF_PASSWORD, default=import_config[CONF_PASSWORD]): str, + vol.Required(CONF_PORT, default=import_config[CONF_PORT]): int + } + return await self.async_step_user(user_input=import_config) diff --git a/homeassistant/components/axis/const.py b/homeassistant/components/axis/const.py new file mode 100644 index 00000000000000..c6cd697612983e --- /dev/null +++ b/homeassistant/components/axis/const.py @@ -0,0 +1,12 @@ +"""Constants for the Axis component.""" +import logging + +LOGGER = logging.getLogger('homeassistant.components.axis') + +DOMAIN = 'axis' + +CONF_CAMERA = 'camera' +CONF_EVENTS = 'events' +CONF_MODEL = 'model' + +DEFAULT_TRIGGER_TIME = 0 diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py new file mode 100644 index 00000000000000..02591e348a5dbf --- /dev/null +++ b/homeassistant/components/axis/device.py @@ -0,0 +1,127 @@ +"""Axis network device abstraction.""" + +import asyncio +import async_timeout + +from homeassistant.const import ( + CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_USERNAME) +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import CONF_CAMERA, CONF_EVENTS, CONF_MODEL, LOGGER +from .errors import AuthenticationRequired, CannotConnect + + +class AxisNetworkDevice: + """Manages a Axis device.""" + + def __init__(self, hass, config_entry): + """Initialize the device.""" + self.hass = hass + self.config_entry = config_entry + self.available = True + + self.api = None + self.fw_version = None + self.product_type = None + + self.listeners = [] + + @property + def host(self): + """Return the host of this device.""" + return self.config_entry.data[CONF_DEVICE][CONF_HOST] + + @property + def model(self): + """Return the model of this device.""" + return self.config_entry.data[CONF_MODEL] + + @property + def name(self): + """Return the name of this device.""" + return self.config_entry.data[CONF_NAME] + + @property + def serial(self): + """Return the mac of this device.""" + return self.config_entry.data[CONF_MAC] + + async def async_setup(self): + """Set up the device.""" + from axis.vapix import VAPIX_FW_VERSION, VAPIX_PROD_TYPE + + hass = self.hass + + try: + self.api = await get_device( + hass, self.config_entry.data[CONF_DEVICE], + event_types='on', signal_callback=self.async_signal_callback) + + except CannotConnect: + raise ConfigEntryNotReady + + except Exception: # pylint: disable=broad-except + LOGGER.error( + 'Unknown error connecting with Axis device on %s', self.host) + return False + + self.fw_version = self.api.vapix.get_param(VAPIX_FW_VERSION) + self.product_type = self.api.vapix.get_param(VAPIX_PROD_TYPE) + + if self.config_entry.options[CONF_CAMERA]: + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, 'camera')) + + if self.config_entry.options[CONF_EVENTS]: + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, 'binary_sensor')) + self.api.start() + + return True + + @callback + def async_signal_callback(self, action, event): + """Call to configure events when initialized on event stream.""" + if action == 'add': + async_dispatcher_send(self.hass, 'axis_add_sensor', event) + + @callback + def shutdown(self, event): + """Stop the event stream.""" + self.api.stop() + + +async def get_device(hass, config, event_types=None, signal_callback=None): + """Create a Axis device.""" + import axis + + device = axis.AxisDevice( + loop=hass.loop, host=config[CONF_HOST], + username=config[CONF_USERNAME], + password=config[CONF_PASSWORD], + port=config[CONF_PORT], web_proto='http', + event_types=event_types, signal=signal_callback) + + try: + with async_timeout.timeout(15): + await hass.async_add_executor_job(device.vapix.load_params) + return device + + except axis.Unauthorized: + LOGGER.warning("Connected to device at %s but not registered.", + config[CONF_HOST]) + raise AuthenticationRequired + + except (asyncio.TimeoutError, axis.RequestError): + LOGGER.error("Error connecting to the Axis device at %s", + config[CONF_HOST]) + raise CannotConnect + + except axis.AxisException: + LOGGER.exception('Unknown Axis communication error occurred') + raise AuthenticationRequired diff --git a/homeassistant/components/axis/errors.py b/homeassistant/components/axis/errors.py new file mode 100644 index 00000000000000..56105b28b1bfda --- /dev/null +++ b/homeassistant/components/axis/errors.py @@ -0,0 +1,22 @@ +"""Errors for the Axis component.""" +from homeassistant.exceptions import HomeAssistantError + + +class AxisException(HomeAssistantError): + """Base class for Axis exceptions.""" + + +class AlreadyConfigured(AxisException): + """Device is already configured.""" + + +class AuthenticationRequired(AxisException): + """Unknown error occurred.""" + + +class CannotConnect(AxisException): + """Unable to connect to the device.""" + + +class UserLevel(AxisException): + """User level too low.""" diff --git a/homeassistant/components/axis/services.yaml b/homeassistant/components/axis/services.yaml deleted file mode 100644 index 03db5ce7af8a43..00000000000000 --- a/homeassistant/components/axis/services.yaml +++ /dev/null @@ -1,15 +0,0 @@ -vapix_call: - description: Configure device using Vapix parameter management. - fields: - name: - description: Name of device to Configure. [Required] - example: M1065-W - cgi: - description: Which cgi to call on device. [Optional] Default is 'param.cgi' - example: 'applications/control.cgi' - action: - description: What type of call. [Optional] Default is 'update' - example: 'start' - param: - description: What parameter to operate on. [Required] - example: 'package=VideoMotionDetection' \ No newline at end of file diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json new file mode 100644 index 00000000000000..3c528dfbb16112 --- /dev/null +++ b/homeassistant/components/axis/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "title": "Axis device", + "step": { + "user": { + "title": "Set up Axis device", + "data": { + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port" + } + } + }, + "error": { + "already_configured": "Device is already configured", + "device_unavailable": "Device is not available", + "faulty_credentials": "Bad user credentials" + }, + "abort": { + "already_configured": "Device is already configured", + "bad_config_file": "Bad data from config file", + "link_local_address": "Link local addresses are not supported" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 1fb727642bc50d..ecbbe7ea5e0657 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -52,6 +52,7 @@ SERVICE_XIAOMI_GW = 'xiaomi_gw' CONFIG_ENTRY_HANDLERS = { + SERVICE_AXIS: 'axis', SERVICE_DAIKIN: 'daikin', SERVICE_DECONZ: 'deconz', 'esphome': 'esphome', @@ -69,7 +70,6 @@ SERVICE_NETGEAR: ('device_tracker', None), SERVICE_WEMO: ('wemo', None), SERVICE_HASSIO: ('hassio', None), - SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_ENIGMA2: ('media_player', 'enigma2'), SERVICE_ROKU: ('roku', None), diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e00d7204a793bc..df635807abe39c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -143,6 +143,7 @@ async def async_step_discovery(info): # Components that have config flows. In future we will auto-generate this list. FLOWS = [ 'ambient_station', + 'axis', 'cast', 'daikin', 'deconz', diff --git a/requirements_all.txt b/requirements_all.txt index ea91ef5e9f40c8..14e845074e6981 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -186,7 +186,7 @@ av==6.1.2 # avion==0.10 # homeassistant.components.axis -axis==16 +axis==17 # homeassistant.components.tts.baidu baidu-aip==1.6.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b532b7b386d530..731f7fa9d22115 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -56,6 +56,9 @@ apns2==0.3.0 # homeassistant.components.stream av==6.1.2 +# homeassistant.components.axis +axis==17 + # homeassistant.components.zha bellows-homeassistant==0.7.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fa6a8429ff3218..3c605ef7ae33b6 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -46,6 +46,7 @@ 'aiounifi', 'apns2', 'av', + 'axis', 'caldav', 'coinmarketcap', 'defusedxml', diff --git a/tests/components/axis/__init__.py b/tests/components/axis/__init__.py new file mode 100644 index 00000000000000..c7e0f05a81477f --- /dev/null +++ b/tests/components/axis/__init__.py @@ -0,0 +1 @@ +"""Tests for the Axis component.""" diff --git a/tests/components/axis/test_binary_sensor.py b/tests/components/axis/test_binary_sensor.py new file mode 100644 index 00000000000000..9ca8b81793ba6a --- /dev/null +++ b/tests/components/axis/test_binary_sensor.py @@ -0,0 +1,102 @@ +"""Axis binary sensor platform tests.""" + +from unittest.mock import Mock + +from homeassistant import config_entries +from homeassistant.components import axis +from homeassistant.setup import async_setup_component + +import homeassistant.components.binary_sensor as binary_sensor + +EVENTS = [ + { + 'operation': 'Initialized', + 'topic': 'tns1:Device/tnsaxis:Sensor/PIR', + 'source': 'sensor', + 'source_idx': '0', + 'type': 'state', + 'value': '0' + }, + { + 'operation': 'Initialized', + 'topic': 'tnsaxis:CameraApplicationPlatform/VMD/Camera1Profile1', + 'type': 'active', + 'value': '1' + } +] + +ENTRY_CONFIG = { + axis.CONF_DEVICE: { + axis.config_flow.CONF_HOST: '1.2.3.4', + axis.config_flow.CONF_USERNAME: 'user', + axis.config_flow.CONF_PASSWORD: 'pass', + axis.config_flow.CONF_PORT: 80 + }, + axis.config_flow.CONF_MAC: '1234ABCD', + axis.config_flow.CONF_MODEL: 'model', + axis.config_flow.CONF_NAME: 'model 0' +} + +ENTRY_OPTIONS = { + axis.CONF_CAMERA: False, + axis.CONF_EVENTS: True, + axis.CONF_TRIGGER_TIME: 0 +} + + +async def setup_device(hass): + """Load the Axis binary sensor platform.""" + from axis import AxisDevice + loop = Mock() + + config_entry = config_entries.ConfigEntry( + 1, axis.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test', + config_entries.CONN_CLASS_LOCAL_PUSH, options=ENTRY_OPTIONS) + device = axis.AxisNetworkDevice(hass, config_entry) + device.api = AxisDevice(loop=loop, **config_entry.data[axis.CONF_DEVICE], + signal=device.async_signal_callback) + hass.data[axis.DOMAIN] = {device.serial: device} + + await hass.config_entries.async_forward_entry_setup( + config_entry, 'binary_sensor') + # To flush out the service call to update the group + await hass.async_block_till_done() + + return device + + +async def test_platform_manually_configured(hass): + """Test that nothing happens when platform is manually configured.""" + assert await async_setup_component(hass, binary_sensor.DOMAIN, { + 'binary_sensor': { + 'platform': axis.DOMAIN + } + }) is True + + assert axis.DOMAIN not in hass.data + + +async def test_no_binary_sensors(hass): + """Test that no sensors in Axis results in no sensor entities.""" + await setup_device(hass) + + assert len(hass.states.async_all()) == 0 + + +async def test_binary_sensors(hass): + """Test that sensors are loaded properly.""" + device = await setup_device(hass) + + for event in EVENTS: + device.api.stream.event.manage_event(event) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 2 + + pir = hass.states.get('binary_sensor.model_0_pir_0') + assert pir.state == 'off' + assert pir.name == 'model 0 PIR 0' + + vmd4 = hass.states.get('binary_sensor.model_0_vmd4_camera1profile1') + assert vmd4.state == 'on' + assert vmd4.name == 'model 0 VMD4 Camera1Profile1' diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py new file mode 100644 index 00000000000000..c585ada631978f --- /dev/null +++ b/tests/components/axis/test_camera.py @@ -0,0 +1,73 @@ +"""Axis camera platform tests.""" + +from unittest.mock import Mock + +from homeassistant import config_entries +from homeassistant.components import axis +from homeassistant.setup import async_setup_component + +import homeassistant.components.camera as camera + + +ENTRY_CONFIG = { + axis.CONF_DEVICE: { + axis.config_flow.CONF_HOST: '1.2.3.4', + axis.config_flow.CONF_USERNAME: 'user', + axis.config_flow.CONF_PASSWORD: 'pass', + axis.config_flow.CONF_PORT: 80 + }, + axis.config_flow.CONF_MAC: '1234ABCD', + axis.config_flow.CONF_MODEL: 'model', + axis.config_flow.CONF_NAME: 'model 0' +} + +ENTRY_OPTIONS = { + axis.CONF_CAMERA: False, + axis.CONF_EVENTS: True, + axis.CONF_TRIGGER_TIME: 0 +} + + +async def setup_device(hass): + """Load the Axis binary sensor platform.""" + from axis import AxisDevice + loop = Mock() + + config_entry = config_entries.ConfigEntry( + 1, axis.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test', + config_entries.CONN_CLASS_LOCAL_PUSH, options=ENTRY_OPTIONS) + device = axis.AxisNetworkDevice(hass, config_entry) + device.api = AxisDevice(loop=loop, **config_entry.data[axis.CONF_DEVICE], + signal=device.async_signal_callback) + hass.data[axis.DOMAIN] = {device.serial: device} + + await hass.config_entries.async_forward_entry_setup( + config_entry, 'camera') + # To flush out the service call to update the group + await hass.async_block_till_done() + + return device + + +async def test_platform_manually_configured(hass): + """Test that nothing happens when platform is manually configured.""" + assert await async_setup_component(hass, camera.DOMAIN, { + 'camera': { + 'platform': axis.DOMAIN + } + }) is True + + assert axis.DOMAIN not in hass.data + + +async def test_camera(hass): + """Test that Axis camera platform is loaded properly.""" + await setup_device(hass) + + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + cam = hass.states.get('camera.model_0') + assert cam.state == 'idle' + assert cam.name == 'model 0' diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py new file mode 100644 index 00000000000000..7e18b36c6a6c27 --- /dev/null +++ b/tests/components/axis/test_config_flow.py @@ -0,0 +1,319 @@ +"""Test Axis config flow.""" +from unittest.mock import Mock, patch + +from homeassistant.components import axis +from homeassistant.components.axis import config_flow + +from tests.common import mock_coro, MockConfigEntry + +import axis as axis_lib + + +async def test_configured_devices(hass): + """Test that configured devices works as expected.""" + result = config_flow.configured_devices(hass) + + assert not result + + entry = MockConfigEntry(domain=axis.DOMAIN, + data={axis.CONF_DEVICE: {axis.CONF_HOST: ''}}) + entry.add_to_hass(hass) + + result = config_flow.configured_devices(hass) + + assert len(result) == 1 + + +async def test_flow_works(hass): + """Test that config flow works.""" + flow = config_flow.AxisFlowHandler() + flow.hass = hass + + with patch('axis.AxisDevice') as mock_device: + def mock_constructor( + loop, host, username, password, port, web_proto, event_types, + signal): + """Fake the controller constructor.""" + mock_device.loop = loop + mock_device.host = host + mock_device.username = username + mock_device.password = password + mock_device.port = port + return mock_device + + def mock_get_param(param): + """Fake get param method.""" + return param + + mock_device.side_effect = mock_constructor + mock_device.vapix.load_params.return_value = Mock() + mock_device.vapix.get_param.side_effect = mock_get_param + + result = await flow.async_step_user(user_input={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 81 + }) + + assert result['type'] == 'create_entry' + assert result['title'] == '{} - {}'.format( + axis_lib.vapix.VAPIX_MODEL_ID, axis_lib.vapix.VAPIX_SERIAL_NUMBER) + assert result['data'] == { + axis.CONF_DEVICE: { + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 81 + }, + config_flow.CONF_MAC: axis_lib.vapix.VAPIX_SERIAL_NUMBER, + config_flow.CONF_MODEL: axis_lib.vapix.VAPIX_MODEL_ID, + config_flow.CONF_NAME: 'Brand.ProdNbr 0' + } + + +async def test_flow_fails_already_configured(hass): + """Test that config flow fails on already configured device.""" + flow = config_flow.AxisFlowHandler() + flow.hass = hass + + entry = MockConfigEntry(domain=axis.DOMAIN, data={axis.CONF_DEVICE: { + axis.CONF_HOST: '1.2.3.4' + }}) + entry.add_to_hass(hass) + + result = await flow.async_step_user(user_input={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 81 + }) + + assert result['errors'] == {'base': 'already_configured'} + + +async def test_flow_fails_faulty_credentials(hass): + """Test that config flow fails on faulty credentials.""" + flow = config_flow.AxisFlowHandler() + flow.hass = hass + + with patch('homeassistant.components.axis.config_flow.get_device', + side_effect=config_flow.AuthenticationRequired): + result = await flow.async_step_user(user_input={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 81 + }) + + assert result['errors'] == {'base': 'faulty_credentials'} + + +async def test_flow_fails_device_unavailable(hass): + """Test that config flow fails on device unavailable.""" + flow = config_flow.AxisFlowHandler() + flow.hass = hass + + with patch('homeassistant.components.axis.config_flow.get_device', + side_effect=config_flow.CannotConnect): + result = await flow.async_step_user(user_input={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 81 + }) + + assert result['errors'] == {'base': 'device_unavailable'} + + +async def test_flow_create_entry(hass): + """Test that create entry can generate a name without other entries.""" + flow = config_flow.AxisFlowHandler() + flow.hass = hass + flow.model = 'model' + + result = await flow._create_entry() + + assert result['data'][config_flow.CONF_NAME] == 'model 0' + + +async def test_flow_create_entry_more_entries(hass): + """Test that create entry can generate a name with other entries.""" + entry = MockConfigEntry( + domain=axis.DOMAIN, data={config_flow.CONF_NAME: 'model 0', + config_flow.CONF_MODEL: 'model'}) + entry.add_to_hass(hass) + entry2 = MockConfigEntry( + domain=axis.DOMAIN, data={config_flow.CONF_NAME: 'model 1', + config_flow.CONF_MODEL: 'model'}) + entry2.add_to_hass(hass) + + flow = config_flow.AxisFlowHandler() + flow.hass = hass + flow.model = 'model' + + result = await flow._create_entry() + + assert result['data'][config_flow.CONF_NAME] == 'model 2' + + +async def test_discovery_flow(hass): + """Test that discovery for new devices work.""" + flow = config_flow.AxisFlowHandler() + flow.hass = hass + + with patch.object(axis, 'get_device', return_value=mock_coro(Mock())): + result = await flow.async_step_discovery(discovery_info={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_PORT: 80, + 'properties': {'macaddress': '1234'} + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_discovery_flow_known_device(hass): + """Test that discovery for known devices work. + + This is legacy support from devices registered with configurator. + """ + flow = config_flow.AxisFlowHandler() + flow.hass = hass + + with patch('homeassistant.components.axis.config_flow.load_json', + return_value={'1234ABCD': { + config_flow.CONF_HOST: '2.3.4.5', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 80}}), \ + patch('axis.AxisDevice') as mock_device: + def mock_constructor( + loop, host, username, password, port, web_proto, event_types, + signal): + """Fake the controller constructor.""" + mock_device.loop = loop + mock_device.host = host + mock_device.username = username + mock_device.password = password + mock_device.port = port + return mock_device + + def mock_get_param(param): + """Fake get param method.""" + return param + + mock_device.side_effect = mock_constructor + mock_device.vapix.load_params.return_value = Mock() + mock_device.vapix.get_param.side_effect = mock_get_param + + result = await flow.async_step_discovery(discovery_info={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_PORT: 80, + 'hostname': 'name', + 'properties': {'macaddress': '1234ABCD'} + }) + + assert result['type'] == 'create_entry' + + +async def test_discovery_flow_already_configured(hass): + """Test that discovery doesn't setup already configured devices.""" + flow = config_flow.AxisFlowHandler() + flow.hass = hass + + entry = MockConfigEntry(domain=axis.DOMAIN, data={axis.CONF_DEVICE: { + axis.CONF_HOST: '1.2.3.4' + }}) + entry.add_to_hass(hass) + + result = await flow.async_step_discovery(discovery_info={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 81 + }) + print(result) + assert result['type'] == 'abort' + + +async def test_discovery_flow_link_local_address(hass): + """Test that discovery doesn't setup devices with link local addresses.""" + flow = config_flow.AxisFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info={ + config_flow.CONF_HOST: '169.254.3.4' + }) + + assert result['type'] == 'abort' + + +async def test_discovery_flow_bad_config_file(hass): + """Test that discovery with bad config files abort.""" + flow = config_flow.AxisFlowHandler() + flow.hass = hass + + with patch('homeassistant.components.axis.config_flow.load_json', + return_value={'1234ABCD': { + config_flow.CONF_HOST: '2.3.4.5', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 80}}), \ + patch('homeassistant.components.axis.config_flow.DEVICE_SCHEMA', + side_effect=config_flow.vol.Invalid('')): + result = await flow.async_step_discovery(discovery_info={ + config_flow.CONF_HOST: '1.2.3.4', + 'properties': {'macaddress': '1234ABCD'} + }) + + assert result['type'] == 'abort' + + +async def test_import_flow_works(hass): + """Test that import flow works.""" + flow = config_flow.AxisFlowHandler() + flow.hass = hass + + with patch('axis.AxisDevice') as mock_device: + def mock_constructor( + loop, host, username, password, port, web_proto, event_types, + signal): + """Fake the controller constructor.""" + mock_device.loop = loop + mock_device.host = host + mock_device.username = username + mock_device.password = password + mock_device.port = port + return mock_device + + def mock_get_param(param): + """Fake get param method.""" + return param + + mock_device.side_effect = mock_constructor + mock_device.vapix.load_params.return_value = Mock() + mock_device.vapix.get_param.side_effect = mock_get_param + + result = await flow.async_step_import(import_config={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 81, + config_flow.CONF_NAME: 'name' + }) + + assert result['type'] == 'create_entry' + assert result['title'] == '{} - {}'.format( + axis_lib.vapix.VAPIX_MODEL_ID, axis_lib.vapix.VAPIX_SERIAL_NUMBER) + assert result['data'] == { + axis.CONF_DEVICE: { + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 81 + }, + config_flow.CONF_MAC: axis_lib.vapix.VAPIX_SERIAL_NUMBER, + config_flow.CONF_MODEL: axis_lib.vapix.VAPIX_MODEL_ID, + config_flow.CONF_NAME: 'name' + } diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py new file mode 100644 index 00000000000000..2a0a7d6391cb02 --- /dev/null +++ b/tests/components/axis/test_device.py @@ -0,0 +1,152 @@ +"""Test Axis device.""" +from unittest.mock import Mock, patch + +import pytest + +from tests.common import mock_coro + +from homeassistant.components.axis import device, errors + +DEVICE_DATA = { + device.CONF_HOST: '1.2.3.4', + device.CONF_USERNAME: 'username', + device.CONF_PASSWORD: 'password', + device.CONF_PORT: 1234 +} + +ENTRY_OPTIONS = { + device.CONF_CAMERA: True, + device.CONF_EVENTS: ['pir'], +} + +ENTRY_CONFIG = { + device.CONF_DEVICE: DEVICE_DATA, + device.CONF_MAC: 'mac', + device.CONF_MODEL: 'model', + device.CONF_NAME: 'name' +} + + +async def test_device_setup(): + """Successful setup.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + entry.options = ENTRY_OPTIONS + api = Mock() + + axis_device = device.AxisNetworkDevice(hass, entry) + + assert axis_device.host == DEVICE_DATA[device.CONF_HOST] + assert axis_device.model == ENTRY_CONFIG[device.CONF_MODEL] + assert axis_device.name == ENTRY_CONFIG[device.CONF_NAME] + assert axis_device.serial == ENTRY_CONFIG[device.CONF_MAC] + + with patch.object(device, 'get_device', return_value=mock_coro(api)): + assert await axis_device.async_setup() is True + + assert axis_device.api is api + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 2 + assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ + (entry, 'camera') + assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \ + (entry, 'binary_sensor') + + +async def test_device_not_accessible(): + """Failed setup schedules a retry of setup.""" + hass = Mock() + hass.data = dict() + entry = Mock() + entry.data = ENTRY_CONFIG + entry.options = ENTRY_OPTIONS + + axis_device = device.AxisNetworkDevice(hass, entry) + + with patch.object(device, 'get_device', + side_effect=errors.CannotConnect), \ + pytest.raises(device.ConfigEntryNotReady): + await axis_device.async_setup() + + assert not hass.helpers.event.async_call_later.mock_calls + + +async def test_device_unknown_error(): + """Unknown errors are handled.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + entry.options = ENTRY_OPTIONS + + axis_device = device.AxisNetworkDevice(hass, entry) + + with patch.object(device, 'get_device', side_effect=Exception): + assert await axis_device.async_setup() is False + + assert not hass.helpers.event.async_call_later.mock_calls + + +async def test_new_event_sends_signal(hass): + """Make sure that new event send signal.""" + entry = Mock() + entry.data = ENTRY_CONFIG + + axis_device = device.AxisNetworkDevice(hass, entry) + + with patch.object(device, 'async_dispatcher_send') as mock_dispatch_send: + axis_device.async_signal_callback(action='add', event='event') + await hass.async_block_till_done() + + assert len(mock_dispatch_send.mock_calls) == 1 + assert len(mock_dispatch_send.mock_calls[0]) == 3 + + +async def test_shutdown(): + """Successful shutdown.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + axis_device = device.AxisNetworkDevice(hass, entry) + axis_device.api = Mock() + + axis_device.shutdown(None) + + assert len(axis_device.api.stop.mock_calls) == 1 + + +async def test_get_device(hass): + """Successful call.""" + with patch('axis.vapix.Vapix.load_params', + return_value=mock_coro()): + assert await device.get_device(hass, DEVICE_DATA) + + +async def test_get_device_fails(hass): + """Device unauthorized yields authentication required error.""" + import axis + + with patch('axis.vapix.Vapix.load_params', + side_effect=axis.Unauthorized), \ + pytest.raises(errors.AuthenticationRequired): + await device.get_device(hass, DEVICE_DATA) + + +async def test_get_device_device_unavailable(hass): + """Device unavailable yields cannot connect error.""" + import axis + + with patch('axis.vapix.Vapix.load_params', + side_effect=axis.RequestError), \ + pytest.raises(errors.CannotConnect): + await device.get_device(hass, DEVICE_DATA) + + +async def test_get_device_unknown_error(hass): + """Device yield unknown error.""" + import axis + + with patch('axis.vapix.Vapix.load_params', + side_effect=axis.AxisException), \ + pytest.raises(errors.AuthenticationRequired): + await device.get_device(hass, DEVICE_DATA) diff --git a/tests/components/axis/test_init.py b/tests/components/axis/test_init.py new file mode 100644 index 00000000000000..0586ffd96f6640 --- /dev/null +++ b/tests/components/axis/test_init.py @@ -0,0 +1,97 @@ +"""Test Axis component setup process.""" +from unittest.mock import Mock, patch + +from homeassistant.setup import async_setup_component +from homeassistant.components import axis + +from tests.common import mock_coro, MockConfigEntry + + +async def test_setup(hass): + """Test configured options for a device are loaded via config entry.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(axis, 'configured_devices', return_value={}): + + assert await async_setup_component(hass, axis.DOMAIN, { + axis.DOMAIN: { + 'device_name': { + axis.CONF_HOST: '1.2.3.4', + axis.config_flow.CONF_PORT: 80, + } + } + }) + + assert len(mock_config_entries.flow.mock_calls) == 1 + + +async def test_setup_device_already_configured(hass): + """Test already configured device does not configure a second.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(axis, 'configured_devices', return_value={'1.2.3.4'}): + + assert await async_setup_component(hass, axis.DOMAIN, { + axis.DOMAIN: { + 'device_name': { + axis.CONF_HOST: '1.2.3.4' + } + } + }) + + assert not mock_config_entries.flow.mock_calls + + +async def test_setup_no_config(hass): + """Test setup without configuration.""" + assert await async_setup_component(hass, axis.DOMAIN, {}) + assert axis.DOMAIN not in hass.data + + +async def test_setup_entry(hass): + """Test successful setup of entry.""" + entry = MockConfigEntry( + domain=axis.DOMAIN, data={axis.device.CONF_MAC: '0123'}) + + mock_device = Mock() + mock_device.async_setup.return_value = mock_coro(True) + mock_device.serial.return_value = '1' + + with patch.object(axis, 'AxisNetworkDevice') as mock_device_class, \ + patch.object( + axis, 'async_populate_options', return_value=mock_coro(True)): + mock_device_class.return_value = mock_device + + assert await axis.async_setup_entry(hass, entry) + + assert len(hass.data[axis.DOMAIN]) == 1 + + +async def test_setup_entry_fails(hass): + """Test successful setup of entry.""" + entry = MockConfigEntry( + domain=axis.DOMAIN, data={axis.device.CONF_MAC: '0123'}, options=True) + + mock_device = Mock() + mock_device.async_setup.return_value = mock_coro(False) + + with patch.object(axis, 'AxisNetworkDevice') as mock_device_class: + mock_device_class.return_value = mock_device + + assert not await axis.async_setup_entry(hass, entry) + + assert not hass.data[axis.DOMAIN] + + +async def test_populate_options(hass): + """Test successful populate options.""" + entry = MockConfigEntry(domain=axis.DOMAIN, data={'device': {}}) + entry.add_to_hass(hass) + + with patch.object(axis, 'get_device', return_value=mock_coro(Mock())): + + await axis.async_populate_options(hass, entry) + + assert entry.options == { + axis.CONF_CAMERA: True, + axis.CONF_EVENTS: True, + axis.CONF_TRIGGER_TIME: axis.DEFAULT_TRIGGER_TIME + } From ed93c3b2c172012586ac3521fb50336f704c1ef9 Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Sun, 24 Mar 2019 13:37:31 -0400 Subject: [PATCH 04/69] Fix pressure in dark sky and openweathermap and add pressure utility (#21210) --- homeassistant/components/darksky/weather.py | 10 ++- .../components/openweathermap/weather.py | 10 ++- homeassistant/const.py | 8 +++ homeassistant/util/pressure.py | 51 ++++++++++++++ homeassistant/util/unit_system.py | 52 ++++++++------- tests/helpers/test_template.py | 3 +- tests/util/test_pressure.py | 66 +++++++++++++++++++ tests/util/test_unit_system.py | 48 ++++++++++++-- 8 files changed, 212 insertions(+), 36 deletions(-) create mode 100644 homeassistant/util/pressure.py create mode 100644 tests/util/test_pressure.py diff --git a/homeassistant/components/darksky/weather.py b/homeassistant/components/darksky/weather.py index d5cbcb4785ae96..5b3db4312bfb10 100644 --- a/homeassistant/components/darksky/weather.py +++ b/homeassistant/components/darksky/weather.py @@ -12,10 +12,10 @@ ATTR_FORECAST_WIND_SPEED, PLATFORM_SCHEMA, WeatherEntity) from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME, - TEMP_CELSIUS, TEMP_FAHRENHEIT) + PRESSURE_HPA, PRESSURE_INHG, TEMP_CELSIUS, TEMP_FAHRENHEIT) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle - +from homeassistant.util.pressure import convert as convert_pressure REQUIREMENTS = ['python-forecastio==1.4.0'] _LOGGER = logging.getLogger(__name__) @@ -131,7 +131,11 @@ def ozone(self): @property def pressure(self): """Return the pressure.""" - return self._ds_currently.get('pressure') + pressure = self._ds_currently.get('pressure') + if 'us' in self._dark_sky.units: + return round( + convert_pressure(pressure, PRESSURE_HPA, PRESSURE_INHG), 2) + return pressure @property def visibility(self): diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 58016dd3e2ccb9..8a37bc97575180 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -10,10 +10,10 @@ ATTR_FORECAST_WIND_SPEED, PLATFORM_SCHEMA, WeatherEntity) from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME, - STATE_UNKNOWN, TEMP_CELSIUS) + PRESSURE_HPA, PRESSURE_INHG, STATE_UNKNOWN, TEMP_CELSIUS) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle - +from homeassistant.util.pressure import convert as convert_pressure REQUIREMENTS = ['pyowm==2.10.0'] _LOGGER = logging.getLogger(__name__) @@ -114,7 +114,11 @@ def temperature_unit(self): @property def pressure(self): """Return the pressure.""" - return self.data.get_pressure().get('press') + pressure = self.data.get_pressure().get('press') + if self.hass.config.units.name == 'imperial': + return round( + convert_pressure(pressure, PRESSURE_HPA, PRESSURE_INHG), 2) + return pressure @property def humidity(self): diff --git a/homeassistant/const.py b/homeassistant/const.py index f825e066f76daa..2d2f00f1e16072 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -343,6 +343,13 @@ LENGTH_YARD = 'yd' # type: str LENGTH_MILES = 'mi' # type: str +# Pressure units +PRESSURE_PA = 'Pa' # type: str +PRESSURE_HPA = 'hPa' # type: str +PRESSURE_MBAR = 'mbar' # type: str +PRESSURE_INHG = 'inHg' # type: str +PRESSURE_PSI = 'psi' # type: str + # Volume units VOLUME_LITERS = 'L' # type: str VOLUME_MILLILITERS = 'mL' # type: str @@ -455,6 +462,7 @@ LENGTH = 'length' # type: str MASS = 'mass' # type: str +PRESSURE = 'pressure' # type: str VOLUME = 'volume' # type: str TEMPERATURE = 'temperature' # type: str SPEED_MS = 'speed_ms' # type: str diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py new file mode 100644 index 00000000000000..ecfa6344d29291 --- /dev/null +++ b/homeassistant/util/pressure.py @@ -0,0 +1,51 @@ +"""Pressure util functions.""" + +import logging +from numbers import Number + +from homeassistant.const import ( + PRESSURE_PA, + PRESSURE_HPA, + PRESSURE_MBAR, + PRESSURE_INHG, + PRESSURE_PSI, + UNIT_NOT_RECOGNIZED_TEMPLATE, + PRESSURE, +) + +_LOGGER = logging.getLogger(__name__) + +VALID_UNITS = [ + PRESSURE_PA, + PRESSURE_HPA, + PRESSURE_MBAR, + PRESSURE_INHG, + PRESSURE_PSI, +] + +UNIT_CONVERSION = { + PRESSURE_PA: 1, + PRESSURE_HPA: 1 / 100, + PRESSURE_MBAR: 1 / 100, + PRESSURE_INHG: 1 / 3386.389, + PRESSURE_PSI: 1 / 6894.757, +} + + +def convert(value: float, unit_1: str, unit_2: str) -> float: + """Convert one unit of measurement to another.""" + if unit_1 not in VALID_UNITS: + raise ValueError( + UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_1, PRESSURE)) + if unit_2 not in VALID_UNITS: + raise ValueError( + UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_2, PRESSURE)) + + if not isinstance(value, Number): + raise TypeError('{} is not of numeric type'.format(value)) + + if unit_1 == unit_2 or unit_1 not in VALID_UNITS: + return value + + pascals = value / UNIT_CONVERSION[unit_1] + return pascals * UNIT_CONVERSION[unit_2] diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 5f6d202b5e940a..8e506dfca2ea2f 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -5,27 +5,19 @@ from numbers import Number from homeassistant.const import ( - TEMP_CELSIUS, TEMP_FAHRENHEIT, LENGTH_CENTIMETERS, LENGTH_METERS, - LENGTH_KILOMETERS, LENGTH_INCHES, LENGTH_FEET, LENGTH_YARD, LENGTH_MILES, - VOLUME_LITERS, VOLUME_MILLILITERS, VOLUME_GALLONS, VOLUME_FLUID_OUNCE, + TEMP_CELSIUS, TEMP_FAHRENHEIT, LENGTH_MILES, LENGTH_KILOMETERS, + PRESSURE_PA, PRESSURE_PSI, VOLUME_LITERS, VOLUME_GALLONS, MASS_GRAMS, MASS_KILOGRAMS, MASS_OUNCES, MASS_POUNDS, - CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, LENGTH, MASS, VOLUME, - TEMPERATURE, UNIT_NOT_RECOGNIZED_TEMPLATE) + CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, LENGTH, MASS, PRESSURE, + VOLUME, TEMPERATURE, UNIT_NOT_RECOGNIZED_TEMPLATE) from homeassistant.util import temperature as temperature_util from homeassistant.util import distance as distance_util +from homeassistant.util import pressure as pressure_util from homeassistant.util import volume as volume_util _LOGGER = logging.getLogger(__name__) -LENGTH_UNITS = [ - LENGTH_MILES, - LENGTH_YARD, - LENGTH_FEET, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_CENTIMETERS, -] +LENGTH_UNITS = distance_util.VALID_UNITS MASS_UNITS = [ MASS_POUNDS, @@ -34,12 +26,9 @@ MASS_GRAMS, ] -VOLUME_UNITS = [ - VOLUME_GALLONS, - VOLUME_FLUID_OUNCE, - VOLUME_LITERS, - VOLUME_MILLILITERS, -] +PRESSURE_UNITS = pressure_util.VALID_UNITS + +VOLUME_UNITS = volume_util.VALID_UNITS TEMPERATURE_UNITS = [ TEMP_FAHRENHEIT, @@ -57,6 +46,8 @@ def is_valid_unit(unit: str, unit_type: str) -> bool: units = MASS_UNITS elif unit_type == VOLUME: units = VOLUME_UNITS + elif unit_type == PRESSURE: + units = PRESSURE_UNITS else: return False @@ -67,7 +58,7 @@ class UnitSystem: """A container for units of measure.""" def __init__(self, name: str, temperature: str, length: str, - volume: str, mass: str) -> None: + volume: str, mass: str, pressure: str) -> None: """Initialize the unit system object.""" errors = \ ', '.join(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit, unit_type) @@ -75,7 +66,8 @@ def __init__(self, name: str, temperature: str, length: str, (temperature, TEMPERATURE), (length, LENGTH), (volume, VOLUME), - (mass, MASS), ] + (mass, MASS), + (pressure, PRESSURE), ] if not is_valid_unit(unit, unit_type)) # type: str if errors: @@ -85,6 +77,7 @@ def __init__(self, name: str, temperature: str, length: str, self.temperature_unit = temperature self.length_unit = length self.mass_unit = mass + self.pressure_unit = pressure self.volume_unit = volume @property @@ -109,6 +102,14 @@ def length(self, length: Optional[float], from_unit: str) -> float: return distance_util.convert(length, from_unit, self.length_unit) + def pressure(self, pressure: Optional[float], from_unit: str) -> float: + """Convert the given pressure to this unit system.""" + if not isinstance(pressure, Number): + raise TypeError('{} is not a numeric value.'.format(str(pressure))) + + return pressure_util.convert(pressure, from_unit, + self.pressure_unit) + def volume(self, volume: Optional[float], from_unit: str) -> float: """Convert the given volume to this unit system.""" if not isinstance(volume, Number): @@ -121,13 +122,16 @@ def as_dict(self) -> dict: return { LENGTH: self.length_unit, MASS: self.mass_unit, + PRESSURE: self.pressure_unit, TEMPERATURE: self.temperature_unit, VOLUME: self.volume_unit } METRIC_SYSTEM = UnitSystem(CONF_UNIT_SYSTEM_METRIC, TEMP_CELSIUS, - LENGTH_KILOMETERS, VOLUME_LITERS, MASS_GRAMS) + LENGTH_KILOMETERS, VOLUME_LITERS, MASS_GRAMS, + PRESSURE_PA) IMPERIAL_SYSTEM = UnitSystem(CONF_UNIT_SYSTEM_IMPERIAL, TEMP_FAHRENHEIT, - LENGTH_MILES, VOLUME_GALLONS, MASS_POUNDS) + LENGTH_MILES, VOLUME_GALLONS, MASS_POUNDS, + PRESSURE_PSI) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 3febd4037ad099..73fe36af26d46a 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -15,6 +15,7 @@ LENGTH_METERS, TEMP_CELSIUS, MASS_GRAMS, + PRESSURE_PA, VOLUME_LITERS, MATCH_ALL, ) @@ -33,7 +34,7 @@ def setUp(self): self.hass = get_test_home_assistant() self.hass.config.units = UnitSystem('custom', TEMP_CELSIUS, LENGTH_METERS, VOLUME_LITERS, - MASS_GRAMS) + MASS_GRAMS, PRESSURE_PA) # pylint: disable=invalid-name def tearDown(self): diff --git a/tests/util/test_pressure.py b/tests/util/test_pressure.py new file mode 100644 index 00000000000000..a3e6efb37541a8 --- /dev/null +++ b/tests/util/test_pressure.py @@ -0,0 +1,66 @@ +"""Test homeassistant pressure utility functions.""" +import unittest +import pytest + +from homeassistant.const import (PRESSURE_PA, PRESSURE_HPA, PRESSURE_MBAR, + PRESSURE_INHG, PRESSURE_PSI) +import homeassistant.util.pressure as pressure_util + +INVALID_SYMBOL = 'bob' +VALID_SYMBOL = PRESSURE_PA + + +class TestPressureUtil(unittest.TestCase): + """Test the pressure utility functions.""" + + def test_convert_same_unit(self): + """Test conversion from any unit to same unit.""" + assert pressure_util.convert(2, PRESSURE_PA, PRESSURE_PA) == 2 + assert pressure_util.convert(3, PRESSURE_HPA, PRESSURE_HPA) == 3 + assert pressure_util.convert(4, PRESSURE_MBAR, PRESSURE_MBAR) == 4 + assert pressure_util.convert(5, PRESSURE_INHG, PRESSURE_INHG) == 5 + + def test_convert_invalid_unit(self): + """Test exception is thrown for invalid units.""" + with pytest.raises(ValueError): + pressure_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) + + with pytest.raises(ValueError): + pressure_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) + + def test_convert_nonnumeric_value(self): + """Test exception is thrown for nonnumeric type.""" + with pytest.raises(TypeError): + pressure_util.convert('a', PRESSURE_HPA, PRESSURE_INHG) + + def test_convert_from_hpascals(self): + """Test conversion from hPA to other units.""" + hpascals = 1000 + self.assertAlmostEqual( + pressure_util.convert(hpascals, PRESSURE_HPA, PRESSURE_PSI), + 14.5037743897) + self.assertAlmostEqual( + pressure_util.convert(hpascals, PRESSURE_HPA, PRESSURE_INHG), + 29.5299801647) + self.assertAlmostEqual( + pressure_util.convert(hpascals, PRESSURE_HPA, PRESSURE_PA), + 100000) + self.assertAlmostEqual( + pressure_util.convert(hpascals, PRESSURE_HPA, PRESSURE_MBAR), + 1000) + + def test_convert_from_inhg(self): + """Test conversion from inHg to other units.""" + inhg = 30 + self.assertAlmostEqual( + pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_PSI), + 14.7346266155) + self.assertAlmostEqual( + pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_HPA), + 1015.9167) + self.assertAlmostEqual( + pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_PA), + 101591.67) + self.assertAlmostEqual( + pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_MBAR), + 1015.9167) diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index 31b2d49b4eca3f..533ce3c0a15373 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -10,10 +10,12 @@ LENGTH_METERS, LENGTH_KILOMETERS, MASS_GRAMS, + PRESSURE_PA, VOLUME_LITERS, TEMP_CELSIUS, LENGTH, MASS, + PRESSURE, TEMPERATURE, VOLUME ) @@ -30,19 +32,23 @@ def test_invalid_units(self): """Test errors are raised when invalid units are passed in.""" with pytest.raises(ValueError): UnitSystem(SYSTEM_NAME, INVALID_UNIT, LENGTH_METERS, VOLUME_LITERS, - MASS_GRAMS) + MASS_GRAMS, PRESSURE_PA) with pytest.raises(ValueError): UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, INVALID_UNIT, VOLUME_LITERS, - MASS_GRAMS) + MASS_GRAMS, PRESSURE_PA) with pytest.raises(ValueError): UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, INVALID_UNIT, - MASS_GRAMS) + MASS_GRAMS, PRESSURE_PA) with pytest.raises(ValueError): UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, VOLUME_LITERS, - INVALID_UNIT) + INVALID_UNIT, PRESSURE_PA) + + with pytest.raises(ValueError): + UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, VOLUME_LITERS, + MASS_GRAMS, INVALID_UNIT) def test_invalid_value(self): """Test no conversion happens if value is non-numeric.""" @@ -50,6 +56,10 @@ def test_invalid_value(self): METRIC_SYSTEM.length('25a', LENGTH_KILOMETERS) with pytest.raises(TypeError): METRIC_SYSTEM.temperature('50K', TEMP_CELSIUS) + with pytest.raises(TypeError): + METRIC_SYSTEM.volume('50L', VOLUME_LITERS) + with pytest.raises(TypeError): + METRIC_SYSTEM.pressure('50Pa', PRESSURE_PA) def test_as_dict(self): """Test that the as_dict() method returns the expected dictionary.""" @@ -57,7 +67,8 @@ def test_as_dict(self): LENGTH: LENGTH_KILOMETERS, TEMPERATURE: TEMP_CELSIUS, VOLUME: VOLUME_LITERS, - MASS: MASS_GRAMS + MASS: MASS_GRAMS, + PRESSURE: PRESSURE_PA } assert expected == METRIC_SYSTEM.as_dict() @@ -108,12 +119,39 @@ def test_length_to_imperial(self): assert 3.106855 == \ IMPERIAL_SYSTEM.length(5, METRIC_SYSTEM.length_unit) + def test_pressure_same_unit(self): + """Test no conversion happens if to unit is same as from unit.""" + assert 5 == \ + METRIC_SYSTEM.pressure(5, METRIC_SYSTEM.pressure_unit) + + def test_pressure_unknown_unit(self): + """Test no conversion happens if unknown unit.""" + with pytest.raises(ValueError): + METRIC_SYSTEM.pressure(5, 'K') + + def test_pressure_to_metric(self): + """Test pressure conversion to metric system.""" + assert 25 == \ + METRIC_SYSTEM.pressure(25, METRIC_SYSTEM.pressure_unit) + self.assertAlmostEqual( + METRIC_SYSTEM.pressure(14.7, IMPERIAL_SYSTEM.pressure_unit), + 101352.932, places=1) + + def test_pressure_to_imperial(self): + """Test pressure conversion to imperial system.""" + assert 77 == \ + IMPERIAL_SYSTEM.pressure(77, IMPERIAL_SYSTEM.pressure_unit) + self.assertAlmostEqual( + IMPERIAL_SYSTEM.pressure(101352.932, METRIC_SYSTEM.pressure_unit), + 14.7, places=4) + def test_properties(self): """Test the unit properties are returned as expected.""" assert LENGTH_KILOMETERS == METRIC_SYSTEM.length_unit assert TEMP_CELSIUS == METRIC_SYSTEM.temperature_unit assert MASS_GRAMS == METRIC_SYSTEM.mass_unit assert VOLUME_LITERS == METRIC_SYSTEM.volume_unit + assert PRESSURE_PA == METRIC_SYSTEM.pressure_unit def test_is_metric(self): """Test the is metric flag.""" From 89f82031630f1d8e6662dffa60e60d4db00ec090 Mon Sep 17 00:00:00 2001 From: Yu Date: Mon, 25 Mar 2019 01:56:17 +0800 Subject: [PATCH 05/69] Fix xiaomi aqara cube with lumi.acpartner.v3 gateway (#22130) --- homeassistant/components/xiaomi_aqara/binary_sensor.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 7eb72e91eef714..56818c51b817b3 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -476,18 +476,24 @@ def parse_data(self, data, raw_data): self._last_action = data[self._data_key] if 'rotate' in data: + action_value = float(data['rotate'] + if isinstance(data['rotate'], int) + else data['rotate'].replace(",", ".")) self._hass.bus.fire('xiaomi_aqara.cube_action', { 'entity_id': self.entity_id, 'action_type': 'rotate', - 'action_value': float(data['rotate'].replace(",", ".")) + 'action_value': action_value }) self._last_action = 'rotate' if 'rotate_degree' in data: + action_value = float(data['rotate_degree'] + if isinstance(data['rotate_degree'], int) + else data['rotate_degree'].replace(",", ".")) self._hass.bus.fire('xiaomi_aqara.cube_action', { 'entity_id': self.entity_id, 'action_type': 'rotate', - 'action_value': float(data['rotate_degree'].replace(",", ".")) + 'action_value': action_value }) self._last_action = 'rotate' From 8d1cf553de9d170b36e95ef81b99fdd81b867d34 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 24 Mar 2019 19:27:32 +0100 Subject: [PATCH 06/69] Support deCONZ library with exception handling (#21952) --- homeassistant/components/deconz/__init__.py | 5 +- .../components/deconz/config_flow.py | 45 ++++++++--- homeassistant/components/deconz/errors.py | 18 +++++ homeassistant/components/deconz/gateway.py | 42 +++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/deconz/test_climate.py | 13 ++-- tests/components/deconz/test_config_flow.py | 77 ++++++++++++------- tests/components/deconz/test_gateway.py | 37 +++++++-- tests/components/deconz/test_init.py | 14 +++- 10 files changed, 190 insertions(+), 65 deletions(-) create mode 100644 homeassistant/components/deconz/errors.py diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index d107cba8f7b71e..957bb5691108aa 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -12,7 +12,7 @@ from .const import DEFAULT_PORT, DOMAIN, _LOGGER from .gateway import DeconzGateway -REQUIREMENTS = ['pydeconz==52'] +REQUIREMENTS = ['pydeconz==53'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -124,8 +124,7 @@ async def async_refresh_devices(call): scenes = set(gateway.api.scenes.keys()) sensors = set(gateway.api.sensors.keys()) - if not await gateway.api.async_load_parameters(): - return + await gateway.api.async_load_parameters() gateway.async_add_device_callback( 'group', [group diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 8f90f303fcaad6..cabb5b46ece5bc 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -1,4 +1,6 @@ """Config flow to configure deCONZ component.""" +import asyncio +import async_timeout import voluptuous as vol from homeassistant import config_entries @@ -32,15 +34,12 @@ def __init__(self): self.deconz_config = {} async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - return await self.async_step_init(user_input) - - async def async_step_init(self, user_input=None): """Handle a deCONZ config flow start. Only allows one instance to be set up. If only one bridge is found go to link step. If more than one bridge is found let user choose bridge to link. + If no bridge is found allow user to manually input configuration. """ from pydeconz.utils import async_discovery @@ -52,11 +51,18 @@ async def async_step_init(self, user_input=None): if bridge[CONF_HOST] == user_input[CONF_HOST]: self.deconz_config = bridge return await self.async_step_link() + self.deconz_config = user_input return await self.async_step_link() session = aiohttp_client.async_get_clientsession(self.hass) - self.bridges = await async_discovery(session) + + try: + with async_timeout.timeout(10): + self.bridges = await async_discovery(session) + + except asyncio.TimeoutError: + self.bridges = [] if len(self.bridges) == 1: self.deconz_config = self.bridges[0] @@ -64,8 +70,10 @@ async def async_step_init(self, user_input=None): if len(self.bridges) > 1: hosts = [] + for bridge in self.bridges: hosts.append(bridge[CONF_HOST]) + return self.async_show_form( step_id='init', data_schema=vol.Schema({ @@ -74,7 +82,7 @@ async def async_step_init(self, user_input=None): ) return self.async_show_form( - step_id='user', + step_id='init', data_schema=vol.Schema({ vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): int, @@ -83,18 +91,27 @@ async def async_step_init(self, user_input=None): async def async_step_link(self, user_input=None): """Attempt to link with the deCONZ bridge.""" + from pydeconz.errors import ResponseError, RequestError from pydeconz.utils import async_get_api_key errors = {} if user_input is not None: if configured_hosts(self.hass): return self.async_abort(reason='one_instance_only') + session = aiohttp_client.async_get_clientsession(self.hass) - api_key = await async_get_api_key(session, **self.deconz_config) - if api_key: + + try: + with async_timeout.timeout(10): + api_key = await async_get_api_key( + session, **self.deconz_config) + + except (ResponseError, RequestError, asyncio.TimeoutError): + errors['base'] = 'no_key' + + else: self.deconz_config[CONF_API_KEY] = api_key return await self.async_step_options() - errors['base'] = 'no_key' return self.async_show_form( step_id='link', @@ -117,8 +134,14 @@ async def async_step_options(self, user_input=None): if CONF_BRIDGEID not in self.deconz_config: session = aiohttp_client.async_get_clientsession(self.hass) - self.deconz_config[CONF_BRIDGEID] = await async_get_bridgeid( - session, **self.deconz_config) + try: + with async_timeout.timeout(10): + self.deconz_config[CONF_BRIDGEID] = \ + await async_get_bridgeid( + session, **self.deconz_config) + + except asyncio.TimeoutError: + return self.async_abort(reason='no_bridges') return self.async_create_entry( title='deCONZ-' + self.deconz_config[CONF_BRIDGEID], diff --git a/homeassistant/components/deconz/errors.py b/homeassistant/components/deconz/errors.py new file mode 100644 index 00000000000000..be13e579ce0950 --- /dev/null +++ b/homeassistant/components/deconz/errors.py @@ -0,0 +1,18 @@ +"""Errors for the deCONZ component.""" +from homeassistant.exceptions import HomeAssistantError + + +class DeconzException(HomeAssistantError): + """Base class for deCONZ exceptions.""" + + +class AlreadyConfigured(DeconzException): + """Gateway is already configured.""" + + +class AuthenticationRequired(DeconzException): + """Unknown error occurred.""" + + +class CannotConnect(DeconzException): + """Unable to connect to the gateway.""" diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 829485e1e9245f..6629d4eec14557 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -1,6 +1,9 @@ """Representation of a deCONZ gateway.""" +import asyncio +import async_timeout + from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.const import CONF_EVENT, CONF_ID +from homeassistant.const import CONF_EVENT, CONF_HOST, CONF_ID from homeassistant.core import EventOrigin, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import ( @@ -10,6 +13,7 @@ from .const import ( _LOGGER, DECONZ_REACHABLE, CONF_ALLOW_CLIP_SENSOR, NEW_DEVICE, NEW_SENSOR, SUPPORTED_PLATFORMS) +from .errors import AuthenticationRequired, CannotConnect class DeconzGateway: @@ -26,18 +30,23 @@ def __init__(self, hass, config_entry): self.events = [] self.listeners = [] - async def async_setup(self, tries=0): + async def async_setup(self): """Set up a deCONZ gateway.""" hass = self.hass - self.api = await get_gateway( - hass, self.config_entry.data, self.async_add_device_callback, - self.async_connection_status_callback - ) + try: + self.api = await get_gateway( + hass, self.config_entry.data, self.async_add_device_callback, + self.async_connection_status_callback + ) - if not self.api: + except CannotConnect: raise ConfigEntryNotReady + except Exception: # pylint: disable=broad-except + _LOGGER.error('Error connecting with deCONZ gateway.') + return False + for component in SUPPORTED_PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup( @@ -113,17 +122,26 @@ async def async_reset(self): async def get_gateway(hass, config, async_add_device_callback, async_connection_status_callback): """Create a gateway object and verify configuration.""" - from pydeconz import DeconzSession + from pydeconz import DeconzSession, errors session = aiohttp_client.async_get_clientsession(hass) + deconz = DeconzSession(hass.loop, session, **config, async_add_device=async_add_device_callback, connection_status=async_connection_status_callback) - result = await deconz.async_load_parameters() - - if result: + try: + with async_timeout.timeout(10): + await deconz.async_load_parameters() return deconz - return result + + except errors.Unauthorized: + _LOGGER.warning("Invalid key for deCONZ at %s.", config[CONF_HOST]) + raise AuthenticationRequired + + except (asyncio.TimeoutError, errors.RequestError): + _LOGGER.error( + "Error connecting to deCONZ gateway at %s", config[CONF_HOST]) + raise CannotConnect class DeconzEvent: diff --git a/requirements_all.txt b/requirements_all.txt index 14e845074e6981..15ae90ebe0b986 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1001,7 +1001,7 @@ pydaikin==1.1.0 pydanfossair==0.0.7 # homeassistant.components.deconz -pydeconz==52 +pydeconz==53 # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 731f7fa9d22115..05a14e18fc08c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -200,7 +200,7 @@ pyHS100==0.3.4 pyblackbird==0.5 # homeassistant.components.deconz -pydeconz==52 +pydeconz==53 # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index fa274f1d676cab..953bb776419887 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -46,11 +46,14 @@ async def setup_gateway(hass, data, allow_clip_sensor=True): """Load the deCONZ sensor platform.""" from pydeconz import DeconzSession - session = Mock(put=asynctest.CoroutineMock( - return_value=Mock(status=200, - json=asynctest.CoroutineMock(), - text=asynctest.CoroutineMock(), - ) + response = Mock( + status=200, json=asynctest.CoroutineMock(), + text=asynctest.CoroutineMock()) + response.content_type = 'application/json' + + session = Mock( + put=asynctest.CoroutineMock( + return_value=response ) ) diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 9e1d6a2fca1e64..20c74a8288310d 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -1,7 +1,8 @@ """Tests for deCONZ config flow.""" -import pytest +from unittest.mock import patch + +import asyncio -import voluptuous as vol from homeassistant.components.deconz import config_flow from tests.common import MockConfigEntry @@ -12,15 +13,17 @@ async def test_flow_works(hass, aioclient_mock): """Test that config flow works.""" aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': 80} - ]) + ], headers={'content-type': 'application/json'}) aioclient_mock.post('http://1.2.3.4:80/api', json=[ {"success": {"username": "1234567890ABCDEF"}} - ]) + ], headers={'content-type': 'application/json'}) flow = config_flow.DeconzFlowHandler() flow.hass = hass + await flow.async_step_user() await flow.async_step_link(user_input={}) + result = await flow.async_step_options( user_input={'allow_clip_sensor': True, 'allow_deconz_groups': True}) @@ -41,35 +44,53 @@ async def test_flow_already_registered_bridge(hass): MockConfigEntry(domain='deconz', data={ 'host': '1.2.3.4' }).add_to_hass(hass) + flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_init() + result = await flow.async_step_user() assert result['type'] == 'abort' +async def test_flow_bridge_discovery_fails(hass, aioclient_mock): + """Test config flow works when discovery fails.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + with patch('pydeconz.utils.async_discovery', + side_effect=asyncio.TimeoutError): + result = await flow.async_step_user() + + assert result['type'] == 'form' + assert result['step_id'] == 'init' + + async def test_flow_no_discovered_bridges(hass, aioclient_mock): """Test config flow discovers no bridges.""" - aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[]) + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[], + headers={'content-type': 'application/json'}) + flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_init() + result = await flow.async_step_user() assert result['type'] == 'form' - assert result['step_id'] == 'user' + assert result['step_id'] == 'init' async def test_flow_one_bridge_discovered(hass, aioclient_mock): """Test config flow discovers one bridge.""" aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': 80} - ]) + ], headers={'content-type': 'application/json'}) + flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_init() + result = await flow.async_step_user() assert result['type'] == 'form' assert result['step_id'] == 'link' + assert flow.deconz_config['host'] == '1.2.3.4' async def test_flow_two_bridges_discovered(hass, aioclient_mock): @@ -77,19 +98,14 @@ async def test_flow_two_bridges_discovered(hass, aioclient_mock): aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ {'id': 'id1', 'internalipaddress': '1.2.3.4', 'internalport': 80}, {'id': 'id2', 'internalipaddress': '5.6.7.8', 'internalport': 80} - ]) + ], headers={'content-type': 'application/json'}) + flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_init() - assert result['type'] == 'form' - assert result['step_id'] == 'init' - - with pytest.raises(vol.Invalid): - assert result['data_schema']({'host': '0.0.0.0'}) - - result['data_schema']({'host': '1.2.3.4'}) - result['data_schema']({'host': '5.6.7.8'}) + result = await flow.async_step_user() + assert result['data_schema']({'host': '1.2.3.4'}) + assert result['data_schema']({'host': '5.6.7.8'}) async def test_flow_two_bridges_selection(hass, aioclient_mock): @@ -101,7 +117,7 @@ async def test_flow_two_bridges_selection(hass, aioclient_mock): {'bridgeid': 'id2', 'host': '5.6.7.8', 'port': 80} ] - result = await flow.async_step_init(user_input={'host': '1.2.3.4'}) + result = await flow.async_step_user(user_input={'host': '1.2.3.4'}) assert result['type'] == 'form' assert result['step_id'] == 'link' assert flow.deconz_config['host'] == '1.2.3.4' @@ -110,25 +126,28 @@ async def test_flow_two_bridges_selection(hass, aioclient_mock): async def test_flow_manual_configuration(hass, aioclient_mock): """Test config flow with manual input.""" aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[]) + flow = config_flow.DeconzFlowHandler() flow.hass = hass user_input = {'host': '1.2.3.4', 'port': 80} - result = await flow.async_step_init(user_input) + result = await flow.async_step_user(user_input) assert result['type'] == 'form' assert result['step_id'] == 'link' assert flow.deconz_config == user_input -async def test_link_no_api_key(hass, aioclient_mock): +async def test_link_no_api_key(hass): """Test config flow should abort if no API key was possible to retrieve.""" - aioclient_mock.post('http://1.2.3.4:80/api', json=[]) flow = config_flow.DeconzFlowHandler() flow.hass = hass flow.deconz_config = {'host': '1.2.3.4', 'port': 80} - result = await flow.async_step_link(user_input={}) + with patch('pydeconz.utils.async_get_api_key', + side_effect=pydeconz.errors.ResponseError): + result = await flow.async_step_link(user_input={}) + assert result['type'] == 'form' assert result['step_id'] == 'link' assert result['errors'] == {'base': 'no_key'} @@ -143,6 +162,7 @@ async def test_link_already_registered_bridge(hass): MockConfigEntry(domain='deconz', data={ 'host': '1.2.3.4' }).add_to_hass(hass) + flow = config_flow.DeconzFlowHandler() flow.hass = hass flow.deconz_config = {'host': '1.2.3.4', 'port': 80} @@ -155,6 +175,7 @@ async def test_bridge_discovery(hass): """Test a bridge being discovered.""" flow = config_flow.DeconzFlowHandler() flow.hass = hass + result = await flow.async_step_discovery({ 'host': '1.2.3.4', 'port': 80, @@ -222,14 +243,18 @@ async def test_import_with_api_key(hass): async def test_options(hass, aioclient_mock): """Test that options work and that bridgeid can be requested.""" aioclient_mock.get('http://1.2.3.4:80/api/1234567890ABCDEF/config', - json={"bridgeid": "id"}) + json={"bridgeid": "id"}, + headers={'content-type': 'application/json'}) + flow = config_flow.DeconzFlowHandler() flow.hass = hass flow.deconz_config = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + result = await flow.async_step_options( user_input={'allow_clip_sensor': False, 'allow_deconz_groups': False}) + assert result['type'] == 'create_entry' assert result['title'] == 'deCONZ-id' assert result['data'] == { diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index d73f225b2acfb7..6006ff668986ab 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -4,10 +4,13 @@ import pytest from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.components.deconz import gateway +from homeassistant.components.deconz import errors, gateway from tests.common import mock_coro +import pydeconz + + ENTRY_CONFIG = { "host": "1.2.3.4", "port": 80, @@ -62,11 +65,25 @@ async def test_gateway_retry(): deconz_gateway = gateway.DeconzGateway(hass, entry) with patch.object( - gateway, 'get_gateway', return_value=mock_coro(False) - ), pytest.raises(ConfigEntryNotReady): + gateway, 'get_gateway', side_effect=errors.CannotConnect), \ + pytest.raises(ConfigEntryNotReady): await deconz_gateway.async_setup() +async def test_gateway_setup_fails(): + """Retry setup.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + deconz_gateway = gateway.DeconzGateway(hass, entry) + + with patch.object(gateway, 'get_gateway', side_effect=Exception): + result = await deconz_gateway.async_setup() + + assert not result + + async def test_connection_status(hass): """Make sure that connection status triggers a dispatcher send.""" entry = Mock() @@ -170,10 +187,20 @@ async def test_get_gateway(hass): assert await gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) -async def test_get_gateway_fails(hass): +async def test_get_gateway_fails_unauthorized(hass): + """Failed call.""" + with patch('pydeconz.DeconzSession.async_load_parameters', + side_effect=pydeconz.errors.Unauthorized), \ + pytest.raises(errors.AuthenticationRequired): + assert await gateway.get_gateway( + hass, ENTRY_CONFIG, Mock(), Mock()) is False + + +async def test_get_gateway_fails_cannot_connect(hass): """Failed call.""" with patch('pydeconz.DeconzSession.async_load_parameters', - return_value=mock_coro(False)): + side_effect=pydeconz.errors.RequestError), \ + pytest.raises(errors.CannotConnect): assert await gateway.get_gateway( hass, ENTRY_CONFIG, Mock(), Mock()) is False diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index cbba47eb431548..e0afadccc81581 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -1,6 +1,7 @@ """Test deCONZ component setup process.""" from unittest.mock import Mock, patch +import asyncio import pytest import voluptuous as vol @@ -76,13 +77,22 @@ async def test_setup_entry_already_registered_bridge(hass): assert await deconz.async_setup_entry(hass, {}) is False +async def test_setup_entry_fails(hass): + """Test setup entry fails if deCONZ is not available.""" + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + with patch('pydeconz.DeconzSession.async_load_parameters', + side_effect=Exception): + await deconz.async_setup_entry(hass, entry) + + async def test_setup_entry_no_available_bridge(hass): """Test setup entry fails if deCONZ is not available.""" entry = Mock() entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} with patch( 'pydeconz.DeconzSession.async_load_parameters', - return_value=mock_coro(False) + side_effect=asyncio.TimeoutError ), pytest.raises(ConfigEntryNotReady): await deconz.async_setup_entry(hass, entry) @@ -185,6 +195,7 @@ async def test_service_refresh_devices(hass): }) entry.add_to_hass(hass) mock_registry = Mock() + with patch.object(deconz, 'DeconzGateway') as mock_gateway, \ patch('homeassistant.helpers.device_registry.async_get_registry', return_value=mock_coro(mock_registry)): @@ -196,6 +207,7 @@ async def test_service_refresh_devices(hass): await hass.services.async_call( 'deconz', 'device_refresh', service_data={}) await hass.async_block_till_done() + with patch.object(hass.data[deconz.DOMAIN].api, 'async_load_parameters', return_value=mock_coro(False)): await hass.services.async_call( From eabb68ad7d5399f386a317126f4d48faf0067124 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 24 Mar 2019 20:00:29 +0100 Subject: [PATCH 07/69] Do not warn when creating an empty database (#22343) --- homeassistant/components/recorder/migration.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 972862e7a9c0da..f81dd9e736f4dd 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -19,6 +19,11 @@ def migrate_schema(instance): SchemaChanges.change_id.desc()).first() current_version = getattr(res, 'schema_version', None) + if current_version is None: + current_version = _inspect_schema_version(instance.engine, session) + _LOGGER.debug("No schema version found. Inspected version: %s", + current_version) + if current_version == SCHEMA_VERSION: # Clean up if old migration left file if os.path.isfile(progress_path): @@ -32,11 +37,6 @@ def migrate_schema(instance): _LOGGER.warning("Database is about to upgrade. Schema version: %s", current_version) - if current_version is None: - current_version = _inspect_schema_version(instance.engine, session) - _LOGGER.debug("No schema version found. Inspected version: %s", - current_version) - try: for version in range(current_version, SCHEMA_VERSION): new_version = version + 1 From d5732c4dba13c7ed5090ff3026222f9963aa04a9 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sun, 24 Mar 2019 20:14:35 +0100 Subject: [PATCH 08/69] Add color support to Philips Moonlight (#22204) --- homeassistant/components/xiaomi_miio/light.py | 99 ++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index ecf7b12e4ac1b8..ec07a557342cca 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -9,8 +9,9 @@ import voluptuous as vol from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_ENTITY_ID, DOMAIN, PLATFORM_SCHEMA, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, Light) + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_COLOR_TEMP, ATTR_ENTITY_ID, DOMAIN, + PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, + Light) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -774,7 +775,99 @@ def hs_color(self) -> tuple: @property def supported_features(self): """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_COLOR_TEMP in kwargs: + color_temp = kwargs[ATTR_COLOR_TEMP] + percent_color_temp = self.translate( + color_temp, self.max_mireds, + self.min_mireds, CCT_MIN, CCT_MAX) + + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = ceil(100 * brightness / 255.0) + + if ATTR_HS_COLOR in kwargs: + hs_color = kwargs[ATTR_HS_COLOR] + rgb = color.color_hs_to_RGB(*hs_color) + + if ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR in kwargs: + _LOGGER.debug( + "Setting brightness and color: " + "%s %s%%, %s", + brightness, percent_brightness, rgb) + + result = await self._try_command( + "Setting brightness and color failed: " + "%s bri, %s color", + self._light.set_brightness_and_rgb, + percent_brightness, rgb) + + if result: + self._hs_color = hs_color + self._brightness = brightness + + elif ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs: + _LOGGER.debug( + "Setting brightness and color temperature: " + "%s %s%%, %s mireds, %s%% cct", + brightness, percent_brightness, + color_temp, percent_color_temp) + + result = await self._try_command( + "Setting brightness and color temperature failed: " + "%s bri, %s cct", + self._light.set_brightness_and_color_temperature, + percent_brightness, percent_color_temp) + + if result: + self._color_temp = color_temp + self._brightness = brightness + + elif ATTR_HS_COLOR in kwargs: + _LOGGER.debug( + "Setting color: %s", rgb) + + result = await self._try_command( + "Setting color failed: %s", + self._light.set_rgb, rgb) + + if result: + self._hs_color = hs_color + + elif ATTR_COLOR_TEMP in kwargs: + _LOGGER.debug( + "Setting color temperature: " + "%s mireds, %s%% cct", + color_temp, percent_color_temp) + + result = await self._try_command( + "Setting color temperature failed: %s cct", + self._light.set_color_temperature, percent_color_temp) + + if result: + self._color_temp = color_temp + + elif ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = ceil(100 * brightness / 255.0) + + _LOGGER.debug( + "Setting brightness: %s %s%%", + brightness, percent_brightness) + + result = await self._try_command( + "Setting brightness failed: %s", + self._light.set_brightness, percent_brightness) + + if result: + self._brightness = brightness + + else: + await self._try_command( + "Turning the light on failed.", self._light.on) async def async_update(self): """Fetch state from the device.""" From 7421156dfc5d4a17384a708c623c28ad73a737a6 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sun, 24 Mar 2019 20:15:29 +0100 Subject: [PATCH 09/69] Add support for the power socket of the Xiaomi AC Partner V3 (#22205) --- .../components/xiaomi_miio/switch.py | 80 +++++++++++++++---- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index f330030922d0a2..d1acce02e47ca9 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -36,6 +36,7 @@ 'chuangmi.plug.v2', 'chuangmi.plug.v3', 'chuangmi.plug.hmi205', + 'lumi.acpartner.v3', ]), }) @@ -150,6 +151,13 @@ async def async_setup_platform(hass, config, async_add_entities, device = XiaomiPlugGenericSwitch(name, plug, model, unique_id) devices.append(device) hass.data[DATA_KEY][host] = device + elif model in ['lumi.acpartner.v3']: + from miio import AirConditioningCompanionV3 + plug = AirConditioningCompanionV3(host, token) + device = XiaomiAirConditioningCompanionSwitch(name, plug, model, + unique_id) + devices.append(device) + hass.data[DATA_KEY][host] = device else: _LOGGER.error( 'Unsupported device found! Please create an issue at ' @@ -294,9 +302,7 @@ async def async_update(self): self._available = True self._state = state.is_on - self._state_attrs.update({ - ATTR_TEMPERATURE: state.temperature - }) + self._state_attrs[ATTR_TEMPERATURE] = state.temperature except DeviceException as ex: self._available = False @@ -342,9 +348,7 @@ def __init__(self, name, plug, model, unique_id): else: self._device_features = FEATURE_FLAGS_POWER_STRIP_V1 - self._state_attrs.update({ - ATTR_LOAD_POWER: None, - }) + self._state_attrs[ATTR_LOAD_POWER] = None if self._device_features & FEATURE_SET_POWER_MODE == 1: self._state_attrs[ATTR_POWER_MODE] = None @@ -418,13 +422,9 @@ def __init__(self, name, plug, model, unique_id, channel_usb): if self._model == MODEL_PLUG_V3: self._device_features = FEATURE_FLAGS_PLUG_V3 - self._state_attrs.update({ - ATTR_WIFI_LED: None, - }) + self._state_attrs[ATTR_WIFI_LED] = None if self._channel_usb is False: - self._state_attrs.update({ - ATTR_LOAD_POWER: None, - }) + self._state_attrs[ATTR_LOAD_POWER] = None async def async_turn_on(self, **kwargs): """Turn a channel on.""" @@ -471,9 +471,7 @@ async def async_update(self): else: self._state = state.is_on - self._state_attrs.update({ - ATTR_TEMPERATURE: state.temperature - }) + self._state_attrs[ATTR_TEMPERATURE] = state.temperature if state.wifi_led: self._state_attrs[ATTR_WIFI_LED] = state.wifi_led @@ -484,3 +482,55 @@ async def async_update(self): except DeviceException as ex: self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) + + +class XiaomiAirConditioningCompanionSwitch(XiaomiPlugGenericSwitch): + """Representation of a Xiaomi AirConditioning Companion.""" + + def __init__(self, name, plug, model, unique_id): + """Initialize the acpartner switch.""" + super().__init__(name, plug, model, unique_id) + + self._state_attrs.update({ + ATTR_TEMPERATURE: None, + ATTR_LOAD_POWER: None, + }) + + async def async_turn_on(self, **kwargs): + """Turn the socket on.""" + result = await self._try_command( + "Turning the socket on failed.", self._plug.socket_on) + + if result: + self._state = True + self._skip_update = True + + async def async_turn_off(self, **kwargs): + """Turn the socket off.""" + result = await self._try_command( + "Turning the socket off failed.", self._plug.socket_off) + + if result: + self._state = False + self._skip_update = True + + async def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + + try: + state = await self.hass.async_add_executor_job(self._plug.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True + self._state = state.power_socket == 'on' + self._state_attrs[ATTR_LOAD_POWER] = state.load_power + + except DeviceException as ex: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) From 88df2e0ea5c527b0321ec2fa7efc5eaa2d6f2a68 Mon Sep 17 00:00:00 2001 From: ktnrg45 <38207570+ktnrg45@users.noreply.github.com> Date: Sun, 24 Mar 2019 17:08:59 -0700 Subject: [PATCH 10/69] Fix ps4 no creds with additional device (#22300) * Fix no creds with additional device. * Update config_flow.py --- homeassistant/components/ps4/config_flow.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index 482c7383d89d76..148b0ae6d84c53 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -79,7 +79,11 @@ async def async_step_link(self, user_input=None): # If entry exists check that devices found aren't configured. if self.hass.config_entries.async_entries(DOMAIN): + creds = {} for entry in self.hass.config_entries.async_entries(DOMAIN): + # Retrieve creds from entry + creds['data'] = entry.data[CONF_TOKEN] + # Retrieve device data from entry conf_devices = entry.data['devices'] for c_device in conf_devices: if c_device['host'] in device_list: @@ -88,6 +92,11 @@ async def async_step_link(self, user_input=None): # If list is empty then all devices are configured. if not device_list: return self.async_abort(reason='devices_configured') + # Add existing creds for linking. Should be only 1. + if not creds: + # Abort if creds is missing. + return self.async_abort(reason='credential_error') + self.creds = creds['data'] # Login to PS4 with user data. if user_input is not None: From 0ae38aece87591d9ea973feab309c916fd7e6acd Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Sun, 24 Mar 2019 20:13:20 -0400 Subject: [PATCH 11/69] Prefer TCP for RTSP streams (#22338) ## Description: For RTSP streams, set the `prefer_tcp` FFMPEG flag. This should resolve some of the "green feed" issues that some users are reporting, likely due to packets being lost over UDP on their network. Resources: [FFMPEG protocols documentation](https://ffmpeg.org/ffmpeg-protocols.html#rtsp) ## Checklist: - [x] The code change is tested and works locally. - [x] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** - [x] There is no commented out code in this PR. --- homeassistant/components/stream/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 3f715af0e047d4..c881ec1276a96f 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -44,6 +44,11 @@ def request_stream(hass, stream_source, *, fmt='hls', if options is None: options = {} + # For RTSP streams, prefer TCP + if isinstance(stream_source, str) \ + and stream_source[:7] == 'rtsp://' and not options: + options['rtsp_flags'] = 'prefer_tcp' + try: streams = hass.data[DOMAIN][ATTR_STREAMS] stream = streams.get(stream_source) From dc64634e21870169bed81dee3bf41e8652264f88 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 24 Mar 2019 17:15:07 -0700 Subject: [PATCH 12/69] Set Onkyo reset log to debug instead of info (#22369) Onkyo logs this message somewhat frequently, and its spammy, so lets make it a debug message instead of info. See also: #20081 --- homeassistant/components/onkyo/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index df30c7e0782789..2fb284bb24a6a3 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -179,7 +179,7 @@ def command(self, command): except (ValueError, OSError, AttributeError, AssertionError): if self._receiver.command_socket: self._receiver.command_socket = None - _LOGGER.info("Resetting connection to %s", self._name) + _LOGGER.debug("Resetting connection to %s", self._name) else: _LOGGER.info("%s is disconnected. Attempting to reconnect", self._name) From d2a83c2732af5d9df221321f73aac4d7a943d5af Mon Sep 17 00:00:00 2001 From: cgtobi Date: Mon, 25 Mar 2019 01:30:21 +0100 Subject: [PATCH 13/69] Upgrade netatmo smart_home module (#22365) --- homeassistant/components/netatmo/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 2e580627543d38..2036e55b3a88ad 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -12,7 +12,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pyatmo==1.8'] +REQUIREMENTS = ['pyatmo==1.9'] DEPENDENCIES = ['webhook'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 15ae90ebe0b986..8a65d124f1c638 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -949,7 +949,7 @@ pyalarmdotcom==0.3.2 pyarlo==0.2.3 # homeassistant.components.netatmo -pyatmo==1.8 +pyatmo==1.9 # homeassistant.components.apple_tv pyatv==0.3.12 From 0d46e2c0b5bda6d2d3def81cfe053fa13548d6ed Mon Sep 17 00:00:00 2001 From: shanbs Date: Mon, 25 Mar 2019 01:31:22 +0100 Subject: [PATCH 14/69] Fix the crash due to absence of the "default_home" in HomeData from pyatmo (netatmo/climate) (#22363) --- homeassistant/components/netatmo/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 2d8b06dd466d0d..d0537c5912b18a 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -315,6 +315,8 @@ def setup(self): self.home_id = self.homedata.gethomeId(self.home) except TypeError: _LOGGER.error("Error when getting home data.") + except AttributeError: + _LOGGER.error("No default_home in HomeData.") except pyatmo.NoDevice: _LOGGER.debug("No thermostat devices available.") From 1b0e523a60fd75d3b5b1996b9f40433e6b94a8a8 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 25 Mar 2019 01:40:27 +0100 Subject: [PATCH 15/69] Add support for 'image' media type (#22353) --- .../components/dlna_dmr/media_player.py | 38 ++++++++++++------- .../components/media_player/const.py | 1 + .../components/media_player/services.yaml | 2 +- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index aae2e9b6af9cfd..9cf42bfec603f6 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -17,6 +17,9 @@ from homeassistant.components.media_player import ( MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.components.media_player.const import ( + MEDIA_TYPE_CHANNEL, MEDIA_TYPE_EPISODE, MEDIA_TYPE_IMAGE, + MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET) @@ -51,20 +54,25 @@ }) HOME_ASSISTANT_UPNP_CLASS_MAPPING = { - 'music': 'object.item.audioItem', - 'tvshow': 'object.item.videoItem', - 'video': 'object.item.videoItem', - 'episode': 'object.item.videoItem', - 'channel': 'object.item.videoItem', - 'playlist': 'object.item.playlist', + MEDIA_TYPE_MUSIC: 'object.item.audioItem', + MEDIA_TYPE_TVSHOW: 'object.item.videoItem', + MEDIA_TYPE_MOVIE: 'object.item.videoItem', + MEDIA_TYPE_VIDEO: 'object.item.videoItem', + MEDIA_TYPE_EPISODE: 'object.item.videoItem', + MEDIA_TYPE_CHANNEL: 'object.item.videoItem', + MEDIA_TYPE_IMAGE: 'object.item.imageItem', + MEDIA_TYPE_PLAYLIST: 'object.item.playlist', } +UPNP_CLASS_DEFAULT = 'object.item' HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING = { - 'music': 'audio/*', - 'tvshow': 'video/*', - 'video': 'video/*', - 'episode': 'video/*', - 'channel': 'video/*', - 'playlist': 'playlist/*', + MEDIA_TYPE_MUSIC: 'audio/*', + MEDIA_TYPE_TVSHOW: 'video/*', + MEDIA_TYPE_MOVIE: 'video/*', + MEDIA_TYPE_VIDEO: 'video/*', + MEDIA_TYPE_EPISODE: 'video/*', + MEDIA_TYPE_CHANNEL: 'video/*', + MEDIA_TYPE_IMAGE: 'image/*', + MEDIA_TYPE_PLAYLIST: 'playlist/*', } @@ -319,8 +327,10 @@ async def async_media_seek(self, position): async def async_play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" title = "Home Assistant" - mime_type = HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING[media_type] - upnp_class = HOME_ASSISTANT_UPNP_CLASS_MAPPING[media_type] + mime_type = HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING.get(media_type, + media_type) + upnp_class = HOME_ASSISTANT_UPNP_CLASS_MAPPING.get(media_type, + UPNP_CLASS_DEFAULT) # Stop current playing media if self._device.can_stop: diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index bf7e6b4e0cef6a..54e28d2d17e3d4 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -36,6 +36,7 @@ MEDIA_TYPE_EPISODE = 'episode' MEDIA_TYPE_CHANNEL = 'channel' MEDIA_TYPE_PLAYLIST = 'playlist' +MEDIA_TYPE_IMAGE = 'image' MEDIA_TYPE_URL = 'url' SERVICE_CLEAR_PLAYLIST = 'clear_playlist' diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 3c91f19469b2b3..c9da38d3657f2c 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -131,7 +131,7 @@ play_media: description: The ID of the content to play. Platform dependent. example: 'https://home-assistant.io/images/cast/splash.png' media_content_type: - description: The type of the content to play. Must be one of music, tvshow, video, episode, channel or playlist + description: The type of the content to play. Must be one of image, music, tvshow, video, episode, channel or playlist example: 'music' select_source: From 7f940423ade4d14568889608134c28805db8d0b5 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Mon, 25 Mar 2019 01:40:43 +0100 Subject: [PATCH 16/69] Warn user about HTML5 GCM deprecation (#22351) * Warn user about GCM deprecation * Fixing hound * Fixing typo * Fixing Travis fail --- homeassistant/components/notify/html5.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index f9bf36e61f0936..0e99727e81bfb2 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -45,8 +45,23 @@ ATTR_VAPID_PRV_KEY = 'vapid_prv_key' ATTR_VAPID_EMAIL = 'vapid_email' + +def gcm_api_deprecated(value): + """Warn user that GCM API config is deprecated.""" + if not value: + return value + + _LOGGER.warning( + "Configuring html5_push_notifications via the GCM api" + " has been deprecated and will stop working after April 11," + " 2019. Use the VAPID configuration instead. For instructions," + " see https://www.home-assistant.io/components/notify.html5/") + return value + + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(ATTR_GCM_SENDER_ID): cv.string, + vol.Optional(ATTR_GCM_SENDER_ID): + vol.All(cv.string, gcm_api_deprecated), vol.Optional(ATTR_GCM_API_KEY): cv.string, vol.Optional(ATTR_VAPID_PUB_KEY): cv.string, vol.Optional(ATTR_VAPID_PRV_KEY): cv.string, From adca598172403d0233070326a4ffc59be0558af1 Mon Sep 17 00:00:00 2001 From: dilruacs Date: Mon, 25 Mar 2019 01:41:16 +0100 Subject: [PATCH 17/69] Turn Panasonic Viera TV on without WOL (#22084) * Turn the TV on via remote * Turn the TV on via remote * Use turn_on() from panasonic-viera==0.3.2 * make power option configurable * add app_power as argument * formatting --- .../panasonic_viera/media_player.py | 20 ++++++++++++++----- requirements_all.txt | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index e6546f7c1e2767..f1ac0cd90d461a 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -19,12 +19,15 @@ CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['panasonic_viera==0.3.1', 'wakeonlan==1.1.6'] +REQUIREMENTS = ['panasonic_viera==0.3.2', 'wakeonlan==1.1.6'] _LOGGER = logging.getLogger(__name__) +CONF_APP_POWER = 'app_power' + DEFAULT_NAME = 'Panasonic Viera TV' DEFAULT_PORT = 55000 +DEFAULT_APP_POWER = False SUPPORT_VIERATV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ @@ -37,6 +40,7 @@ vol.Optional(CONF_MAC): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_APP_POWER, default=DEFAULT_APP_POWER): cv.boolean, }) @@ -47,6 +51,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): mac = config.get(CONF_MAC) name = config.get(CONF_NAME) port = config.get(CONF_PORT) + app_power = config.get(CONF_APP_POWER) if discovery_info: _LOGGER.debug('%s', discovery_info) @@ -59,20 +64,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): else: uuid = None remote = RemoteControl(host, port) - add_entities([PanasonicVieraTVDevice(mac, name, remote, host, uuid)]) + add_entities([PanasonicVieraTVDevice( + mac, name, remote, host, app_power, uuid)]) return True host = config.get(CONF_HOST) remote = RemoteControl(host, port) - add_entities([PanasonicVieraTVDevice(mac, name, remote, host)]) + add_entities([PanasonicVieraTVDevice(mac, name, remote, host, app_power)]) return True class PanasonicVieraTVDevice(MediaPlayerDevice): """Representation of a Panasonic Viera TV.""" - def __init__(self, mac, name, remote, host, uuid=None): + def __init__(self, mac, name, remote, host, app_power, uuid=None): """Initialize the Panasonic device.""" import wakeonlan # Save a reference to the imported class @@ -86,6 +92,7 @@ def __init__(self, mac, name, remote, host, uuid=None): self._remote = remote self._host = host self._volume = 0 + self._app_power = app_power @property def unique_id(self) -> str: @@ -134,7 +141,7 @@ def is_volume_muted(self): @property def supported_features(self): """Flag media player features that are supported.""" - if self._mac: + if self._mac or self._app_power: return SUPPORT_VIERATV | SUPPORT_TURN_ON return SUPPORT_VIERATV @@ -143,6 +150,9 @@ def turn_on(self): if self._mac: self._wol.send_magic_packet(self._mac, ip_address=self._host) self._state = STATE_ON + elif self._app_power: + self._remote.turn_on() + self._state = STATE_ON def turn_off(self): """Turn off media player.""" diff --git a/requirements_all.txt b/requirements_all.txt index 8a65d124f1c638..1891d2a385ab9e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -802,7 +802,7 @@ paho-mqtt==1.4.0 panacotta==0.1 # homeassistant.components.panasonic_viera.media_player -panasonic_viera==0.3.1 +panasonic_viera==0.3.2 # homeassistant.components.dunehd.media_player pdunehd==1.3 From d1f75fcf320852ecd9e2fddacb79deeb46cb7bc9 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 25 Mar 2019 01:46:15 +0100 Subject: [PATCH 18/69] Properly connect sensors to hub (#21414) * Properly connect sensors to hub Refs #20958 * Don't connect (merge) with main device * Provide manufacturer * Linting * Do connect upnp-sensors to main device * Linting * Fix requirements_all.txt --- homeassistant/components/upnp/device.py | 5 ++++- homeassistant/components/upnp/sensor.py | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 6bbf0a3dd53758..5ebe2a78d0d585 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -23,7 +23,10 @@ def __init__(self, igd_device): async def async_discover(cls, hass: HomeAssistantType): """Discovery UPNP/IGD devices.""" _LOGGER.debug('Discovering UPnP/IGD devices') - local_ip = hass.data[DOMAIN]['config'].get(CONF_LOCAL_IP) + local_ip = None + if DOMAIN in hass.data and \ + 'config' in hass.data[DOMAIN]: + local_ip = hass.data[DOMAIN]['config'].get(CONF_LOCAL_IP) if local_ip: local_ip = IPv4Address(local_ip) diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 5f544e1a134fb3..708ef314ab4e28 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -8,8 +8,10 @@ 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 from .const import DOMAIN as DOMAIN_UPNP, SIGNAL_REMOVE_SENSOR @@ -46,7 +48,9 @@ KBYTE = 1024 -async def async_setup_platform(hass, config, async_add_entities, +async def async_setup_platform(hass: HomeAssistantType, + config, + async_add_entities, discovery_info=None): """Old way of setting up UPnP/IGD sensors.""" _LOGGER.debug('async_setup_platform: config: %s, discovery: %s', @@ -111,8 +115,11 @@ def device_info(self): 'identifiers': { (DOMAIN_UPNP, self.unique_id) }, + 'connections': { + (dr.CONNECTION_UPNP, self._device.udn) + }, 'name': self.name, - 'via_hub': (DOMAIN_UPNP, self._device.udn), + 'manufacturer': self._device.manufacturer, } From b6987a1235765e3c201386513129fe40bdcb157b Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Mon, 25 Mar 2019 01:57:53 +0100 Subject: [PATCH 19/69] Add support for Tfiac Climate component (#21823) ## Description: Add support for AC-models that follows the Tfiac protocol. Built together with @mellado. **Pull request in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) with documentation (if applicable):** home-assistant/home-assistant.io#8910 ## Example entry for `configuration.yaml` (if applicable): ```yaml climate: platform: tfiac host: 192.168.10.26 ``` ## Checklist: - [x] The code change is tested and works locally. - [x] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** - [x] There is no commented out code in this PR. If user exposed functionality or configuration variables are added/changed: - [x] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) If the code communicates with devices, web services, or third-party tools: - [x] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]). - [x] New dependencies are only imported inside functions that use them ([example][ex-import]). - [x] New or updated dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`. - [x] New files were added to `.coveragerc`. [ex-requir]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard/__init__.py#L14 [ex-import]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard/__init__.py#L23 Co-authored-by: Robbie Trencheny --- .coveragerc | 1 + CODEOWNERS | 3 +- homeassistant/components/tfiac/__init__.py | 1 + homeassistant/components/tfiac/climate.py | 185 +++++++++++++++++++++ requirements_all.txt | 3 + 5 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tfiac/__init__.py create mode 100644 homeassistant/components/tfiac/climate.py diff --git a/.coveragerc b/.coveragerc index 42e7d84dc099bf..ec6aad90628d2a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -82,6 +82,7 @@ omit = homeassistant/components/proliphix/climate.py homeassistant/components/radiotherm/climate.py homeassistant/components/sensibo/climate.py + homeassistant/components/tfiac/climate.py homeassistant/components/touchline/climate.py homeassistant/components/venstar/climate.py homeassistant/components/zhong_hong/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index e880177380f0e5..717da8b219ef0b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -241,15 +241,16 @@ homeassistant/components/tautulli/sensor.py @ludeeus homeassistant/components/tellduslive/* @fredrike homeassistant/components/template/cover.py @PhracturedBlue homeassistant/components/tesla/* @zabuldon +homeassistant/components/tfiac/* @fredrike @mellado homeassistant/components/thethingsnetwork/* @fabaff homeassistant/components/threshold/binary_sensor.py @fabaff homeassistant/components/tibber/* @danielhiversen homeassistant/components/tile/device_tracker.py @bachya homeassistant/components/time_date/sensor.py @fabaff +homeassistant/components/toon/* @frenck homeassistant/components/tplink/* @rytilahti homeassistant/components/traccar/device_tracker.py @ludeeus homeassistant/components/tradfri/* @ggravlingen -homeassistant/components/toon/* @frenck # U homeassistant/components/uber/sensor.py @robbiet480 diff --git a/homeassistant/components/tfiac/__init__.py b/homeassistant/components/tfiac/__init__.py new file mode 100644 index 00000000000000..bb097a7edd0d6b --- /dev/null +++ b/homeassistant/components/tfiac/__init__.py @@ -0,0 +1 @@ +"""The tfiac component.""" diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py new file mode 100644 index 00000000000000..44fa19098236c8 --- /dev/null +++ b/homeassistant/components/tfiac/climate.py @@ -0,0 +1,185 @@ +"""Climate platform that offers a climate device for the TFIAC protocol.""" +from concurrent import futures +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.components.climate.const import ( + STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT, + SUPPORT_FAN_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE, + SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, TEMP_FAHRENHEIT +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pytfiac==0.3'] + +SCAN_INTERVAL = timedelta(seconds=60) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, +}) + +_LOGGER = logging.getLogger(__name__) + +MIN_TEMP = 61 +MAX_TEMP = 88 +OPERATION_MAP = { + STATE_HEAT: 'heat', + STATE_AUTO: 'selfFeel', + STATE_DRY: 'dehumi', + STATE_FAN_ONLY: 'fan', + STATE_COOL: 'cool', +} +OPERATION_MAP_REV = { + v: k for k, v in OPERATION_MAP.items()} +FAN_LIST = ['Auto', 'Low', 'Middle', 'High'] +SWING_LIST = [ + 'Off', + 'Vertical', + 'Horizontal', + 'Both', +] + +CURR_TEMP = 'current_temp' +TARGET_TEMP = 'target_temp' +OPERATION_MODE = 'operation' +FAN_MODE = 'fan_mode' +SWING_MODE = 'swing_mode' +ON_MODE = 'is_on' + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the TFIAC climate device.""" + from pytfiac import Tfiac + + tfiac_client = Tfiac(config[CONF_HOST]) + try: + await tfiac_client.update() + except futures.TimeoutError: + _LOGGER.error("Unable to connect to %s", config[CONF_HOST]) + return + async_add_devices([TfiacClimate(hass, tfiac_client)]) + + +class TfiacClimate(ClimateDevice): + """TFIAC class.""" + + def __init__(self, hass, client): + """Init class.""" + self._client = client + self._available = True + + @property + def available(self): + """Return if the device is available.""" + return self._available + + async def async_update(self): + """Update status via socket polling.""" + try: + await self._client.update() + self._available = True + except futures.TimeoutError: + self._available = False + + @property + def supported_features(self): + """Return the list of supported features.""" + return (SUPPORT_FAN_MODE | SUPPORT_ON_OFF | SUPPORT_OPERATION_MODE + | SUPPORT_SWING_MODE | SUPPORT_TARGET_TEMPERATURE) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return MIN_TEMP + + @property + def max_temp(self): + """Return the maximum temperature.""" + return MAX_TEMP + + @property + def name(self): + """Return the name of the climate device.""" + return self._client.name + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._client.status['target_temp'] + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._client.status['current_temp'] + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + operation = self._client.status['operation'] + return OPERATION_MAP_REV.get(operation, operation) + + @property + def is_on(self): + """Return true if on.""" + return self._client.status[ON_MODE] == 'on' + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return sorted(OPERATION_MAP) + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._client.status['fan_mode'] + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return FAN_LIST + + @property + def current_swing_mode(self): + """Return the swing setting.""" + return self._client.status['swing_mode'] + + @property + def swing_list(self): + """List of available swing modes.""" + return SWING_LIST + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + if kwargs.get(ATTR_TEMPERATURE) is not None: + await self._client.set_state(TARGET_TEMP, + kwargs.get(ATTR_TEMPERATURE)) + + async def async_set_operation_mode(self, operation_mode): + """Set new operation mode.""" + await self._client.set_state(OPERATION_MODE, + OPERATION_MAP[operation_mode]) + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + await self._client.set_state(FAN_MODE, fan_mode) + + async def async_set_swing_mode(self, swing_mode): + """Set new swing mode.""" + await self._client.set_swing(swing_mode) + + async def async_turn_on(self): + """Turn device on.""" + await self._client.set_state(ON_MODE, 'on') + + async def async_turn_off(self): + """Turn device off.""" + await self._client.set_state(ON_MODE, 'off') diff --git a/requirements_all.txt b/requirements_all.txt index 1891d2a385ab9e..80274fdbfd3719 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,6 +1299,9 @@ pytautulli==0.5.0 # homeassistant.components.liveboxplaytv.media_player pyteleloisirs==3.4 +# homeassistant.components.tfiac.climate +pytfiac==0.3 + # homeassistant.components.thinkingcleaner.sensor # homeassistant.components.thinkingcleaner.switch pythinkingcleaner==0.0.3 From 1aee7a1673ac43497369f97234d4c07f36b11ddf Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sun, 24 Mar 2019 17:58:20 -0700 Subject: [PATCH 20/69] Add aws component and consolidate aws notify platform (#22240) * Add aws component * Move notify config under aws component * Add basic tests for aws component * Add deprecated warning for notify.aws_* * Add more tests --- homeassistant/components/aws/__init__.py | 147 +++++++++ homeassistant/components/aws/config_flow.py | 22 ++ homeassistant/components/aws/const.py | 13 + homeassistant/components/aws/notify.py | 278 ++++++++++++++++++ homeassistant/components/notify/aws_lambda.py | 6 + homeassistant/components/notify/aws_sns.py | 6 + homeassistant/components/notify/aws_sqs.py | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/aws/__init__.py | 1 + tests/components/aws/test_init.py | 199 +++++++++++++ 12 files changed, 685 insertions(+) create mode 100644 homeassistant/components/aws/__init__.py create mode 100644 homeassistant/components/aws/config_flow.py create mode 100644 homeassistant/components/aws/const.py create mode 100644 homeassistant/components/aws/notify.py create mode 100644 tests/components/aws/__init__.py create mode 100644 tests/components/aws/test_init.py diff --git a/homeassistant/components/aws/__init__.py b/homeassistant/components/aws/__init__.py new file mode 100644 index 00000000000000..bd1f6b550909a8 --- /dev/null +++ b/homeassistant/components/aws/__init__.py @@ -0,0 +1,147 @@ +"""Support for Amazon Web Services (AWS).""" +import asyncio +import logging +from collections import OrderedDict + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ATTR_CREDENTIALS, CONF_NAME, CONF_PROFILE_NAME +from homeassistant.helpers import config_validation as cv, discovery + +# Loading the config flow file will register the flow +from . import config_flow # noqa +from .const import ( + CONF_ACCESS_KEY_ID, + CONF_SECRET_ACCESS_KEY, + DATA_CONFIG, + DATA_HASS_CONFIG, + DATA_SESSIONS, + DOMAIN, + CONF_NOTIFY, +) +from .notify import PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA + +REQUIREMENTS = ["aiobotocore==0.10.2"] + +_LOGGER = logging.getLogger(__name__) + +AWS_CREDENTIAL_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, + } +) + +DEFAULT_CREDENTIAL = [{CONF_NAME: "default", CONF_PROFILE_NAME: "default"}] + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional( + ATTR_CREDENTIALS, default=DEFAULT_CREDENTIAL + ): vol.All(cv.ensure_list, [AWS_CREDENTIAL_SCHEMA]), + vol.Optional(CONF_NOTIFY): vol.All( + cv.ensure_list, [NOTIFY_PLATFORM_SCHEMA] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up AWS component.""" + hass.data[DATA_HASS_CONFIG] = config + + conf = config.get(DOMAIN) + if conf is None: + # create a default conf using default profile + conf = CONFIG_SCHEMA({ATTR_CREDENTIALS: DEFAULT_CREDENTIAL}) + + hass.data[DATA_CONFIG] = conf + hass.data[DATA_SESSIONS] = OrderedDict() + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Load a config entry. + + Validate and save sessions per aws credential. + """ + config = hass.data.get(DATA_HASS_CONFIG) + conf = hass.data.get(DATA_CONFIG) + + if entry.source == config_entries.SOURCE_IMPORT: + if conf is None: + # user removed config from configuration.yaml, abort setup + hass.async_create_task( + hass.config_entries.async_remove(entry.entry_id) + ) + return False + + if conf != entry.data: + # user changed config from configuration.yaml, use conf to setup + hass.config_entries.async_update_entry(entry, data=conf) + + if conf is None: + conf = CONFIG_SCHEMA({DOMAIN: entry.data})[DOMAIN] + + validation = True + tasks = [] + for cred in conf.get(ATTR_CREDENTIALS): + tasks.append(_validate_aws_credentials(hass, cred)) + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + for index, result in enumerate(results): + name = conf[ATTR_CREDENTIALS][index][CONF_NAME] + if isinstance(result, Exception): + _LOGGER.error( + "Validating credential [%s] failed: %s", + name, result, exc_info=result + ) + validation = False + else: + hass.data[DATA_SESSIONS][name] = result + + # No entry support for notify component yet + for notify_config in conf.get(CONF_NOTIFY, []): + discovery.load_platform(hass, "notify", DOMAIN, notify_config, config) + + return validation + + +async def _validate_aws_credentials(hass, credential): + """Validate AWS credential config.""" + import aiobotocore + + aws_config = credential.copy() + del aws_config[CONF_NAME] + + profile = aws_config.get(CONF_PROFILE_NAME) + + if profile is not None: + session = aiobotocore.AioSession(profile=profile, loop=hass.loop) + del aws_config[CONF_PROFILE_NAME] + if CONF_ACCESS_KEY_ID in aws_config: + del aws_config[CONF_ACCESS_KEY_ID] + if CONF_SECRET_ACCESS_KEY in aws_config: + del aws_config[CONF_SECRET_ACCESS_KEY] + else: + session = aiobotocore.AioSession(loop=hass.loop) + + async with session.create_client("iam", **aws_config) as client: + await client.get_user() + + return session diff --git a/homeassistant/components/aws/config_flow.py b/homeassistant/components/aws/config_flow.py new file mode 100644 index 00000000000000..c21f2a94137f6a --- /dev/null +++ b/homeassistant/components/aws/config_flow.py @@ -0,0 +1,22 @@ +"""Config flow for AWS component.""" + +from homeassistant import config_entries + +from .const import DOMAIN + + +@config_entries.HANDLERS.register(DOMAIN) +class AWSFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + async def async_step_import(self, user_input): + """Import a config entry.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return self.async_create_entry( + title="configuration.yaml", data=user_input + ) diff --git a/homeassistant/components/aws/const.py b/homeassistant/components/aws/const.py new file mode 100644 index 00000000000000..c8b0eed8b6bbe3 --- /dev/null +++ b/homeassistant/components/aws/const.py @@ -0,0 +1,13 @@ +"""Constant for AWS component.""" +DOMAIN = "aws" +DATA_KEY = DOMAIN +DATA_CONFIG = "aws_config" +DATA_HASS_CONFIG = "aws_hass_config" +DATA_SESSIONS = "aws_sessions" + +CONF_REGION = "region_name" +CONF_ACCESS_KEY_ID = "aws_access_key_id" +CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" +CONF_PROFILE_NAME = "profile_name" +CONF_CREDENTIAL_NAME = "credential_name" +CONF_NOTIFY = "notify" diff --git a/homeassistant/components/aws/notify.py b/homeassistant/components/aws/notify.py new file mode 100644 index 00000000000000..020d92200b98ef --- /dev/null +++ b/homeassistant/components/aws/notify.py @@ -0,0 +1,278 @@ +"""AWS platform for notify component.""" +import asyncio +import logging +import json +import base64 + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_PLATFORM, CONF_NAME, ATTR_CREDENTIALS +from homeassistant.components.notify import ( + ATTR_TARGET, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + BaseNotificationService, + PLATFORM_SCHEMA, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.json import JSONEncoder + +from .const import ( + CONF_ACCESS_KEY_ID, + CONF_CREDENTIAL_NAME, + CONF_PROFILE_NAME, + CONF_REGION, + CONF_SECRET_ACCESS_KEY, + DATA_SESSIONS, +) + +DEPENDENCIES = ["aws"] + +_LOGGER = logging.getLogger(__name__) + +CONF_CONTEXT = "context" +CONF_SERVICE = "service" + +SUPPORTED_SERVICES = ["lambda", "sns", "sqs"] + + +def _in_avilable_region(config): + """Check if region is available.""" + import aiobotocore + + session = aiobotocore.get_session() + available_regions = session.get_available_regions(config[CONF_SERVICE]) + if config[CONF_REGION] not in available_regions: + raise vol.Invalid( + "Region {} is not available for {} service, mustin {}".format( + config[CONF_REGION], config[CONF_SERVICE], available_regions + ) + ) + return config + + +PLATFORM_SCHEMA = vol.Schema( + vol.All( + PLATFORM_SCHEMA.extend( + { + # override notify.PLATFORM_SCHEMA.CONF_PLATFORM to Optional + # we don't need this field when we use discovery + vol.Optional(CONF_PLATFORM): cv.string, + vol.Required(CONF_SERVICE): vol.All( + cv.string, vol.Lower, vol.In(SUPPORTED_SERVICES) + ), + vol.Required(CONF_REGION): vol.All(cv.string, vol.Lower), + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive( + CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS + ): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, + vol.Exclusive( + CONF_CREDENTIAL_NAME, ATTR_CREDENTIALS + ): cv.string, + vol.Optional(CONF_CONTEXT): vol.Coerce(dict), + }, + extra=vol.PREVENT_EXTRA, + ), + _in_avilable_region, + ) +) + + +async def async_get_service(hass, config, discovery_info=None): + """Get the AWS notification service.""" + import aiobotocore + + session = None + + if discovery_info is not None: + conf = discovery_info + else: + conf = config + + service = conf[CONF_SERVICE] + region_name = conf[CONF_REGION] + + aws_config = conf.copy() + + del aws_config[CONF_SERVICE] + del aws_config[CONF_REGION] + if CONF_PLATFORM in aws_config: + del aws_config[CONF_PLATFORM] + if CONF_NAME in aws_config: + del aws_config[CONF_NAME] + if CONF_CONTEXT in aws_config: + del aws_config[CONF_CONTEXT] + + if not aws_config: + # no platform config, use aws component config instead + if hass.data[DATA_SESSIONS]: + session = list(hass.data[DATA_SESSIONS].values())[0] + else: + raise ValueError( + "No available aws session for {}".format(config[CONF_NAME]) + ) + + if session is None: + credential_name = aws_config.get(CONF_CREDENTIAL_NAME) + if credential_name is not None: + session = hass.data[DATA_SESSIONS].get(credential_name) + if session is None: + _LOGGER.warning( + "No available aws session for %s", credential_name + ) + del aws_config[CONF_CREDENTIAL_NAME] + + if session is None: + profile = aws_config.get(CONF_PROFILE_NAME) + if profile is not None: + session = aiobotocore.AioSession(profile=profile, loop=hass.loop) + del aws_config[CONF_PROFILE_NAME] + else: + session = aiobotocore.AioSession(loop=hass.loop) + + aws_config[CONF_REGION] = region_name + + if service == "lambda": + context_str = json.dumps( + {"custom": conf.get(CONF_CONTEXT, {})}, cls=JSONEncoder + ) + context_b64 = base64.b64encode(context_str.encode("utf-8")) + context = context_b64.decode("utf-8") + return AWSLambda(session, aws_config, context) + + if service == "sns": + return AWSSNS(session, aws_config) + + if service == "sqs": + return AWSSQS(session, aws_config) + + raise ValueError("Unsupported service {}".format(service)) + + +class AWSNotify(BaseNotificationService): + """Implement the notification service for the AWS service.""" + + def __init__(self, session, aws_config): + """Initialize the service.""" + self.session = session + self.aws_config = aws_config + + def send_message(self, message, **kwargs): + """Send notification.""" + raise NotImplementedError("Please call async_send_message()") + + async def async_send_message(self, message="", **kwargs): + """Send notification.""" + targets = kwargs.get(ATTR_TARGET) + + if not targets: + raise HomeAssistantError("At least one target is required") + + +class AWSLambda(AWSNotify): + """Implement the notification service for the AWS Lambda service.""" + + service = "lambda" + + def __init__(self, session, aws_config, context): + """Initialize the service.""" + super().__init__(session, aws_config) + self.context = context + + async def async_send_message(self, message="", **kwargs): + """Send notification to specified LAMBDA ARN.""" + await super().async_send_message(message, **kwargs) + + cleaned_kwargs = dict((k, v) for k, v in kwargs.items() if v) + payload = {"message": message} + payload.update(cleaned_kwargs) + json_payload = json.dumps(payload) + + async with self.session.create_client( + self.service, **self.aws_config + ) as client: + tasks = [] + for target in kwargs.get(ATTR_TARGET, []): + tasks.append( + client.invoke( + FunctionName=target, + Payload=json_payload, + ClientContext=self.context, + ) + ) + + if tasks: + await asyncio.gather(*tasks) + + +class AWSSNS(AWSNotify): + """Implement the notification service for the AWS SNS service.""" + + service = "sns" + + async def async_send_message(self, message="", **kwargs): + """Send notification to specified SNS ARN.""" + await super().async_send_message(message, **kwargs) + + message_attributes = { + k: {"StringValue": json.dumps(v), "DataType": "String"} + for k, v in kwargs.items() + if v + } + subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + + async with self.session.create_client( + self.service, **self.aws_config + ) as client: + tasks = [] + for target in kwargs.get(ATTR_TARGET, []): + tasks.append( + client.publish( + TargetArn=target, + Message=message, + Subject=subject, + MessageAttributes=message_attributes, + ) + ) + + if tasks: + await asyncio.gather(*tasks) + + +class AWSSQS(AWSNotify): + """Implement the notification service for the AWS SQS service.""" + + service = "sqs" + + async def async_send_message(self, message="", **kwargs): + """Send notification to specified SQS ARN.""" + await super().async_send_message(message, **kwargs) + + cleaned_kwargs = dict((k, v) for k, v in kwargs.items() if v) + message_body = {"message": message} + message_body.update(cleaned_kwargs) + json_body = json.dumps(message_body) + message_attributes = {} + for key, val in cleaned_kwargs.items(): + message_attributes[key] = { + "StringValue": json.dumps(val), + "DataType": "String", + } + + async with self.session.create_client( + self.service, **self.aws_config + ) as client: + tasks = [] + for target in kwargs.get(ATTR_TARGET, []): + tasks.append( + client.send_message( + QueueUrl=target, + MessageBody=json_body, + MessageAttributes=message_attributes, + ) + ) + + if tasks: + await asyncio.gather(*tasks) diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index e605f82c3f15aa..8f639a653c3bf8 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -38,6 +38,12 @@ def get_service(hass, config, discovery_info=None): """Get the AWS Lambda notification service.""" + _LOGGER.warning( + "aws_lambda notify platform is deprecated, please replace it" + " with aws component. This config will become invalid in version 0.92." + " See https://www.home-assistant.io/components/aws/ for details." + ) + context_str = json.dumps({'custom': config[CONF_CONTEXT]}, cls=JSONEncoder) context_b64 = base64.b64encode(context_str.encode('utf-8')) context = context_b64.decode('utf-8') diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py index 9363576fc1aced..7fa0e25b32a21a 100644 --- a/homeassistant/components/notify/aws_sns.py +++ b/homeassistant/components/notify/aws_sns.py @@ -35,6 +35,12 @@ def get_service(hass, config, discovery_info=None): """Get the AWS SNS notification service.""" + _LOGGER.warning( + "aws_sns notify platform is deprecated, please replace it" + " with aws component. This config will become invalid in version 0.92." + " See https://www.home-assistant.io/components/aws/ for details." + ) + import boto3 aws_config = config.copy() diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py index ed22147cfedc3a..927824299398b3 100644 --- a/homeassistant/components/notify/aws_sqs.py +++ b/homeassistant/components/notify/aws_sqs.py @@ -33,6 +33,12 @@ def get_service(hass, config, discovery_info=None): """Get the AWS SQS notification service.""" + _LOGGER.warning( + "aws_sqs notify platform is deprecated, please replace it" + " with aws component. This config will become invalid in version 0.92." + " See https://www.home-assistant.io/components/aws/ for details." + ) + import boto3 aws_config = config.copy() diff --git a/requirements_all.txt b/requirements_all.txt index 80274fdbfd3719..de994fa9122131 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -105,6 +105,9 @@ aioasuswrt==1.1.21 # homeassistant.components.automatic.device_tracker aioautomatic==0.6.5 +# homeassistant.components.aws +aiobotocore==0.10.2 + # homeassistant.components.dnsip.sensor aiodns==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05a14e18fc08c1..fe026a3813cfec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -40,6 +40,9 @@ aioambient==0.1.3 # homeassistant.components.automatic.device_tracker aioautomatic==0.6.5 +# homeassistant.components.aws +aiobotocore==0.10.2 + # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 3c605ef7ae33b6..5d21681aedace1 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -41,6 +41,7 @@ TEST_REQUIREMENTS = ( 'aioambient', 'aioautomatic', + 'aiobotocore', 'aiohttp_cors', 'aiohue', 'aiounifi', diff --git a/tests/components/aws/__init__.py b/tests/components/aws/__init__.py new file mode 100644 index 00000000000000..270922b1e1ed9e --- /dev/null +++ b/tests/components/aws/__init__.py @@ -0,0 +1 @@ +"""Tests for the aws component.""" diff --git a/tests/components/aws/test_init.py b/tests/components/aws/test_init.py new file mode 100644 index 00000000000000..89dd9deaa0ab8d --- /dev/null +++ b/tests/components/aws/test_init.py @@ -0,0 +1,199 @@ +"""Tests for the aws component config and setup.""" +from asynctest import patch as async_patch, MagicMock, CoroutineMock + +from homeassistant.components import aws +from homeassistant.setup import async_setup_component + + +class MockAioSession: + """Mock AioSession.""" + + def __init__(self, *args, **kwargs): + """Init a mock session.""" + + def create_client(self, *args, **kwargs): # pylint: disable=no-self-use + """Create a mocked client.""" + return MagicMock( + __aenter__=CoroutineMock(return_value=CoroutineMock( + get_user=CoroutineMock(), # iam + invoke=CoroutineMock(), # lambda + publish=CoroutineMock(), # sns + send_message=CoroutineMock(), # sqs + )), + __aexit__=CoroutineMock() + ) + + +async def test_empty_config(hass): + """Test a default config will be create for empty config.""" + with async_patch('aiobotocore.AioSession', new=MockAioSession): + await async_setup_component(hass, 'aws', { + 'aws': {} + }) + await hass.async_block_till_done() + + sessions = hass.data[aws.DATA_SESSIONS] + assert sessions is not None + assert len(sessions) == 1 + assert isinstance(sessions.get('default'), MockAioSession) + + +async def test_empty_credential(hass): + """Test a default config will be create for empty credential section.""" + with async_patch('aiobotocore.AioSession', new=MockAioSession): + await async_setup_component(hass, 'aws', { + 'aws': { + 'notify': [{ + 'service': 'lambda', + 'name': 'New Lambda Test', + 'region_name': 'us-east-1', + }] + } + }) + await hass.async_block_till_done() + + sessions = hass.data[aws.DATA_SESSIONS] + assert sessions is not None + assert len(sessions) == 1 + assert isinstance(sessions.get('default'), MockAioSession) + + assert hass.services.has_service('notify', 'new_lambda_test') is True + await hass.services.async_call( + 'notify', + 'new_lambda_test', + {'message': 'test', 'target': 'ARN'}, + blocking=True + ) + + +async def test_profile_credential(hass): + """Test credentials with profile name.""" + with async_patch('aiobotocore.AioSession', new=MockAioSession): + await async_setup_component(hass, 'aws', { + 'aws': { + 'credentials': { + 'name': 'test', + 'profile_name': 'test-profile', + }, + 'notify': [{ + 'service': 'sns', + 'credential_name': 'test', + 'name': 'SNS Test', + 'region_name': 'us-east-1', + }] + } + }) + await hass.async_block_till_done() + + sessions = hass.data[aws.DATA_SESSIONS] + assert sessions is not None + assert len(sessions) == 1 + assert isinstance(sessions.get('test'), MockAioSession) + + assert hass.services.has_service('notify', 'sns_test') is True + await hass.services.async_call( + 'notify', + 'sns_test', + {'title': 'test', 'message': 'test', 'target': 'ARN'}, + blocking=True + ) + + +async def test_access_key_credential(hass): + """Test credentials with access key.""" + with async_patch('aiobotocore.AioSession', new=MockAioSession): + await async_setup_component(hass, 'aws', { + 'aws': { + 'credentials': [ + { + 'name': 'test', + 'profile_name': 'test-profile', + }, + { + 'name': 'key', + 'aws_access_key_id': 'test-key', + 'aws_secret_access_key': 'test-secret', + }, + ], + 'notify': [{ + 'service': 'sns', + 'credential_name': 'key', + 'name': 'SNS Test', + 'region_name': 'us-east-1', + }] + } + }) + await hass.async_block_till_done() + + sessions = hass.data[aws.DATA_SESSIONS] + assert sessions is not None + assert len(sessions) == 2 + assert isinstance(sessions.get('key'), MockAioSession) + + assert hass.services.has_service('notify', 'sns_test') is True + await hass.services.async_call( + 'notify', + 'sns_test', + {'title': 'test', 'message': 'test', 'target': 'ARN'}, + blocking=True + ) + + +async def test_notify_credential(hass): + """Test notify service can use access key directly.""" + with async_patch('aiobotocore.AioSession', new=MockAioSession): + await async_setup_component(hass, 'aws', { + 'aws': { + 'notify': [{ + 'service': 'sqs', + 'credential_name': 'test', + 'name': 'SQS Test', + 'region_name': 'us-east-1', + 'aws_access_key_id': 'some-key', + 'aws_secret_access_key': 'some-secret', + }] + } + }) + await hass.async_block_till_done() + + sessions = hass.data[aws.DATA_SESSIONS] + assert sessions is not None + assert len(sessions) == 1 + assert isinstance(sessions.get('default'), MockAioSession) + + assert hass.services.has_service('notify', 'sqs_test') is True + await hass.services.async_call( + 'notify', + 'sqs_test', + {'message': 'test', 'target': 'ARN'}, + blocking=True + ) + + +async def test_notify_credential_profile(hass): + """Test notify service can use profile directly.""" + with async_patch('aiobotocore.AioSession', new=MockAioSession): + await async_setup_component(hass, 'aws', { + 'aws': { + 'notify': [{ + 'service': 'sqs', + 'name': 'SQS Test', + 'region_name': 'us-east-1', + 'profile_name': 'test', + }] + } + }) + await hass.async_block_till_done() + + sessions = hass.data[aws.DATA_SESSIONS] + assert sessions is not None + assert len(sessions) == 1 + assert isinstance(sessions.get('default'), MockAioSession) + + assert hass.services.has_service('notify', 'sqs_test') is True + await hass.services.async_call( + 'notify', + 'sqs_test', + {'message': 'test', 'target': 'ARN'}, + blocking=True + ) From 548371e94c4c47634bfb187878e438190945dc53 Mon Sep 17 00:00:00 2001 From: karlkar Date: Mon, 25 Mar 2019 01:59:12 +0100 Subject: [PATCH 21/69] Check if mac is set when more than 2 gateways (#21834) * Check if mac is set when more than 2 gateways When more than 2 gateways mac is required for each of them. Now voluptuous will require it. * fix line length * remove trailing whitespace * Make it more readable --- .../components/xiaomi_aqara/__init__.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 66fc1fa13dda1e..e98655f9d76de4 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -61,8 +61,7 @@ }) -GATEWAY_CONFIG = vol.Schema({ - vol.Optional(CONF_MAC, default=None): vol.Any(GW_MAC, None), +GATEWAY_CONFIG_MAC_OPT = vol.Schema({ vol.Optional(CONF_KEY): vol.All(cv.string, vol.Length(min=16, max=16)), vol.Optional(CONF_HOST): cv.string, @@ -70,6 +69,14 @@ vol.Optional(CONF_DISABLE, default=False): cv.boolean, }) +GATEWAY_CONFIG_MAC_REQ = vol.Schema({ + vol.Required(CONF_KEY): + vol.All(cv.string, vol.Length(min=16, max=16)), + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=9898): cv.port, + vol.Optional(CONF_DISABLE, default=False): cv.boolean, +}) + def _fix_conf_defaults(config): """Update some configuration defaults.""" @@ -89,7 +96,10 @@ def _fix_conf_defaults(config): CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_GATEWAYS, default={}): - vol.All(cv.ensure_list, [GATEWAY_CONFIG], [_fix_conf_defaults]), + vol.All(cv.ensure_list, vol.Any( + vol.All([GATEWAY_CONFIG_MAC_OPT], vol.Length(max=1)), + vol.All([GATEWAY_CONFIG_MAC_REQ], vol.Length(min=2)) + ), [_fix_conf_defaults]), vol.Optional(CONF_INTERFACE, default='any'): cv.string, vol.Optional(CONF_DISCOVERY_RETRY, default=3): cv.positive_int }) From f272ed3b9142abcb29405988b9446def31443cb7 Mon Sep 17 00:00:00 2001 From: Moritz Fey Date: Mon, 25 Mar 2019 02:10:49 +0100 Subject: [PATCH 22/69] Add 'method' parameter to forgiving_round method (#21708) * Add 'method' parameter to forgiving_round method Fixes #21707 * fix rounding behavior in round() filter * add test cases for new rounding behaviour --- homeassistant/helpers/template.py | 12 ++++++++++-- tests/helpers/test_template.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index d79c68ffd5e45f..24275c87061703 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -442,10 +442,18 @@ def _resolve_state(self, entity_id_or_state): return None -def forgiving_round(value, precision=0): +def forgiving_round(value, precision=0, method="common"): """Round accepted strings.""" try: - value = round(float(value), precision) + # support rounding methods like jinja + multiplier = float(10 ** precision) + if method == "ceil": + value = math.ceil(float(value) * multiplier) / multiplier + elif method == "floor": + value = math.floor(float(value) * multiplier) / multiplier + else: + # if method is common or something else, use common rounding + value = round(float(value), precision) return int(value) if precision == 0 else value except (ValueError, TypeError): # If value can't be converted to float diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 73fe36af26d46a..e0aeb09976d1b8 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -97,6 +97,16 @@ def test_rounding_value(self): '{{ states.sensor.temperature.state | multiply(10) | round }}', self.hass).render() + assert '12.7' == \ + template.Template( + '{{ states.sensor.temperature.state | round(1, "floor") }}', + self.hass).render() + + assert '12.8' == \ + template.Template( + '{{ states.sensor.temperature.state | round(1, "ceil") }}', + self.hass).render() + def test_rounding_value_get_original_value_on_error(self): """Test rounding value get original value on error.""" assert 'None' == \ From c59d45caa35afd7a69165c6c7563dd4ecbfb8417 Mon Sep 17 00:00:00 2001 From: Nick Horvath Date: Sun, 24 Mar 2019 21:13:06 -0400 Subject: [PATCH 23/69] Expose detailed Ecobee equipment status (#20767) * ecobee: expose detailed equipment status * Fix #18244 for ecobee by moving current_operation property to current_operation_mode which is more accurate and defining current_operation properly, thanks @ZetaPhoenix * fix docstring and lint issue * Revert "fix docstring and lint issue" This reverts commit d3a645f075f8a4017756f5eae05f00f05e2556cf. * Revert "Fix #18244 for ecobee by moving current_operation property to current_operation_mode which is more accurate and defining current_operation properly, thanks @ZetaPhoenix" This reverts commit bfd90551ef759b2c160d69da0778df89251e156b. --- homeassistant/components/ecobee/climate.py | 1 + tests/components/ecobee/test_climate.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index bfc67e7cfafc2d..44a3800afa958f 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -279,6 +279,7 @@ def device_state_attributes(self): "fan": self.fan, "climate_mode": self.mode, "operation": operation, + "equipment_running": status, "climate_list": self.climate_list, "fan_min_on_time": self.fan_min_on_time } diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index f4412b5d564e33..3215a9d5b4c68c 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -183,7 +183,8 @@ def test_device_state_attributes(self): 'fan': 'off', 'fan_min_on_time': 10, 'climate_mode': 'Climate1', - 'operation': 'heat'} == \ + 'operation': 'heat', + 'equipment_running': 'heatPump2'} == \ self.thermostat.device_state_attributes self.ecobee['equipmentStatus'] = 'auxHeat2' @@ -192,7 +193,8 @@ def test_device_state_attributes(self): 'fan': 'off', 'fan_min_on_time': 10, 'climate_mode': 'Climate1', - 'operation': 'heat'} == \ + 'operation': 'heat', + 'equipment_running': 'auxHeat2'} == \ self.thermostat.device_state_attributes self.ecobee['equipmentStatus'] = 'compCool1' assert {'actual_humidity': 15, @@ -200,7 +202,8 @@ def test_device_state_attributes(self): 'fan': 'off', 'fan_min_on_time': 10, 'climate_mode': 'Climate1', - 'operation': 'cool'} == \ + 'operation': 'cool', + 'equipment_running': 'compCool1'} == \ self.thermostat.device_state_attributes self.ecobee['equipmentStatus'] = '' assert {'actual_humidity': 15, @@ -208,7 +211,8 @@ def test_device_state_attributes(self): 'fan': 'off', 'fan_min_on_time': 10, 'climate_mode': 'Climate1', - 'operation': 'idle'} == \ + 'operation': 'idle', + 'equipment_running': ''} == \ self.thermostat.device_state_attributes self.ecobee['equipmentStatus'] = 'Unknown' @@ -217,7 +221,8 @@ def test_device_state_attributes(self): 'fan': 'off', 'fan_min_on_time': 10, 'climate_mode': 'Climate1', - 'operation': 'Unknown'} == \ + 'operation': 'Unknown', + 'equipment_running': 'Unknown'} == \ self.thermostat.device_state_attributes def test_is_away_mode_on(self): From 324a7c7875d0d1bb79df16205526886d8a1382db Mon Sep 17 00:00:00 2001 From: fabtesta Date: Mon, 25 Mar 2019 02:13:56 +0100 Subject: [PATCH 24/69] Add ClickSend "caller" option (#20780) * removed "from" parameter from ClickSend API call Removed "from" parameter from API call to let ClickSend choose a random number and be more compliant with some cellular networks that do not allow incoming calls from the same recipient number. * fixed "line too long (94 > 79 characters)" houndci code review fixed "line too long (94 > 79 characters)" houndci code review * Added new component optional parameter "caller". If defined is used to override default logic that uses recipient phone number in "from" API call parameter * Removed default value for CALLER parameter. If not defined then will take the RECIPIENT value. --- homeassistant/components/notify/clicksend_tts.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/notify/clicksend_tts.py b/homeassistant/components/notify/clicksend_tts.py index 1481fea1783cdd..2a7730c4a27c7c 100644 --- a/homeassistant/components/notify/clicksend_tts.py +++ b/homeassistant/components/notify/clicksend_tts.py @@ -27,6 +27,7 @@ CONF_LANGUAGE = 'language' CONF_VOICE = 'voice' +CONF_CALLER = 'caller' DEFAULT_LANGUAGE = 'en-us' DEFAULT_VOICE = 'female' @@ -38,6 +39,7 @@ vol.Required(CONF_RECIPIENT): cv.string, vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): cv.string, vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): cv.string, + vol.Optional(CONF_CALLER): cv.string, }) @@ -60,10 +62,13 @@ def __init__(self, config): self.recipient = config.get(CONF_RECIPIENT) self.language = config.get(CONF_LANGUAGE) self.voice = config.get(CONF_VOICE) + self.caller = config.get(CONF_CALLER) + if self.caller is None: + self.caller = self.recipient def send_message(self, message="", **kwargs): """Send a voice call to a user.""" - data = ({'messages': [{'source': 'hass.notify', 'from': self.recipient, + data = ({'messages': [{'source': 'hass.notify', 'from': self.caller, 'to': self.recipient, 'body': message, 'lang': self.language, 'voice': self.voice}]}) api_url = "{}/voice/send".format(BASE_API_URL) From af4b85d39d22b07930f1572520797cd551b26695 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 25 Mar 2019 01:21:04 +0000 Subject: [PATCH 25/69] Give HomeKit locks better names by default (#22333) ## Description: This is a follow up to #22171. There we set the name of an entity based on the `accessory-information` homekit service, rather than using the zeroconf/avahi name metadata. Unfortunately Lock also sets its name from zeroconf directly, rather than picking it up from the base class. This test updates it to be like the other homekit entities and use the base class. (This is from my ongoing homekit_controller configentry branch). ## Checklist: - [x] The code change is tested and works locally. - [x] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** - [x] There is no commented out code in this PR. --- homeassistant/components/homekit_controller/lock.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index a2aac5767bdf61..b084d7525d3b48 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -41,7 +41,6 @@ def __init__(self, accessory, discovery_info): """Initialise the Lock.""" super().__init__(accessory, discovery_info) self._state = None - self._name = discovery_info['model'] self._battery_level = None def get_characteristic_types(self): @@ -60,11 +59,6 @@ def _update_lock_mechanism_current_state(self, value): def _update_battery_level(self, value): self._battery_level = value - @property - def name(self): - """Return the name of this device.""" - return self._name - @property def is_locked(self): """Return true if device is locked.""" From 96133f5e6bf19a8f6585aba07f89e8005937a61e Mon Sep 17 00:00:00 2001 From: zewelor Date: Mon, 25 Mar 2019 08:50:47 +0100 Subject: [PATCH 26/69] Improve yeelight component (#22347) --- homeassistant/components/yeelight/__init__.py | 90 ++----------------- homeassistant/components/yeelight/light.py | 85 ++++++++++++++++-- 2 files changed, 88 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 32e3c5f69e3b6f..ed4e704a6a51be 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -1,23 +1,18 @@ -""" -Support for Xiaomi Yeelight Wifi color bulb. +"""Support for Xiaomi Yeelight WiFi color bulb.""" -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/yeelight/ -""" import logging from datetime import timedelta import voluptuous as vol from homeassistant.components.discovery import SERVICE_YEELIGHT from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_SCAN_INTERVAL, \ - CONF_HOST, ATTR_ENTITY_ID, CONF_LIGHTS + CONF_HOST, ATTR_ENTITY_ID from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.helpers import discovery from homeassistant.helpers.discovery import load_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers.event import track_time_interval REQUIREMENTS = ['yeelight==0.4.3'] @@ -37,7 +32,6 @@ CONF_FLOW_PARAMS = 'flow_params' CONF_CUSTOM_EFFECTS = 'custom_effects' -ATTR_MODE = 'mode' ATTR_COUNT = 'count' ATTR_ACTION = 'action' ATTR_TRANSITIONS = 'transitions' @@ -56,9 +50,6 @@ YEELIGHT_TEMPERATURE_TRANSACTION = 'TemperatureTransition' YEELIGHT_SLEEP_TRANSACTION = 'SleepTransition' -SERVICE_SET_MODE = 'set_mode' -SERVICE_START_FLOW = 'start_flow' - YEELIGHT_FLOW_TRANSITION_SCHEMA = { vol.Optional(ATTR_COUNT, default=0): cv.positive_int, vol.Optional(ATTR_ACTION, default=ACTION_RECOVER): @@ -152,13 +143,8 @@ def _parse_custom_effects(effects_config): def setup(hass, config): """Set up the Yeelight bulbs.""" - from yeelight.enums import PowerMode - conf = config[DOMAIN] - yeelight_data = hass.data[DATA_YEELIGHT] = { - CONF_DEVICES: {}, - CONF_LIGHTS: {}, - } + yeelight_data = hass.data[DATA_YEELIGHT] = {} def device_discovered(service, info): _LOGGER.debug("Adding autodetected %s", info['hostname']) @@ -177,47 +163,14 @@ def device_discovered(service, info): discovery.listen(hass, SERVICE_YEELIGHT, device_discovered) - def async_update(event): - for device in yeelight_data[CONF_DEVICES].values(): + def update(event): + for device in yeelight_data.values(): device.update() - async_track_time_interval( - hass, async_update, conf[CONF_SCAN_INTERVAL] + track_time_interval( + hass, update, conf[CONF_SCAN_INTERVAL] ) - def service_handler(service): - """Dispatch service calls to target entities.""" - params = {key: value for key, value in service.data.items() - if key != ATTR_ENTITY_ID} - - entity_ids = extract_entity_ids(hass, service) - target_devices = [dev.device for dev in - yeelight_data[CONF_LIGHTS].values() - if dev.entity_id in entity_ids] - - for target_device in target_devices: - if service.service == SERVICE_SET_MODE: - target_device.set_mode(**params) - elif service.service == SERVICE_START_FLOW: - params[ATTR_TRANSITIONS] = \ - _transitions_config_parser(params[ATTR_TRANSITIONS]) - target_device.start_flow(**params) - - service_schema_set_mode = YEELIGHT_SERVICE_SCHEMA.extend({ - vol.Required(ATTR_MODE): - vol.In([mode.name.lower() for mode in PowerMode]) - }) - hass.services.register( - DOMAIN, SERVICE_SET_MODE, service_handler, - schema=service_schema_set_mode) - - service_schema_start_flow = YEELIGHT_SERVICE_SCHEMA.extend( - YEELIGHT_FLOW_TRANSITION_SCHEMA - ) - hass.services.register( - DOMAIN, SERVICE_START_FLOW, service_handler, - schema=service_schema_start_flow) - for ipaddr, device_config in conf[CONF_DEVICES].items(): _LOGGER.debug("Adding configured %s", device_config[CONF_NAME]) _setup_device(hass, config, ipaddr, device_config) @@ -226,7 +179,7 @@ def service_handler(service): def _setup_device(hass, hass_config, ipaddr, device_config): - devices = hass.data[DATA_YEELIGHT][CONF_DEVICES] + devices = hass.data[DATA_YEELIGHT] if ipaddr in devices: return @@ -330,28 +283,3 @@ def update(self): self._update_properties() dispatcher_send(self._hass, DATA_UPDATED, self._ipaddr) - - def set_mode(self, mode: str): - """Set a power mode.""" - import yeelight - - try: - self.bulb.set_power_mode(yeelight.enums.PowerMode[mode.upper()]) - except yeelight.BulbException as ex: - _LOGGER.error("Unable to set the power mode: %s", ex) - - self.update() - - def start_flow(self, transitions, count=0, action=ACTION_RECOVER): - """Start flow.""" - import yeelight - - try: - flow = yeelight.Flow( - count=count, - action=yeelight.Flow.actions[action], - transitions=transitions) - - self.bulb.start_flow(flow) - except yeelight.BulbException as ex: - _LOGGER.error("Unable to set effect: %s", ex) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 8c7a94d3020659..d208d1f72b0a08 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -1,11 +1,13 @@ """Light platform support for yeelight.""" import logging +import voluptuous as vol from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service import extract_entity_ids from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, color_temperature_kelvin_to_mired as kelvin_to_mired) -from homeassistant.const import CONF_HOST, CONF_DEVICES, CONF_LIGHTS +from homeassistant.const import CONF_HOST, ATTR_ENTITY_ID from homeassistant.core import callback from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_COLOR_TEMP, @@ -15,7 +17,10 @@ import homeassistant.util.color as color_util from homeassistant.components.yeelight import ( CONF_TRANSITION, DATA_YEELIGHT, CONF_MODE_MUSIC, - CONF_SAVE_ON_CHANGE, CONF_CUSTOM_EFFECTS, DATA_UPDATED) + CONF_SAVE_ON_CHANGE, CONF_CUSTOM_EFFECTS, DATA_UPDATED, + YEELIGHT_SERVICE_SCHEMA, DOMAIN, ATTR_TRANSITIONS, + YEELIGHT_FLOW_TRANSITION_SCHEMA, _transitions_config_parser, + ACTION_RECOVER) DEPENDENCIES = ['yeelight'] @@ -33,6 +38,11 @@ SUPPORT_EFFECT | SUPPORT_COLOR_TEMP) +ATTR_MODE = 'mode' + +SERVICE_SET_MODE = 'set_mode' +SERVICE_START_FLOW = 'start_flow' + EFFECT_DISCO = "Disco" EFFECT_TEMP = "Slow Temp" EFFECT_STROBE = "Strobe epilepsy!" @@ -86,20 +96,57 @@ def _wrap(self, *args, **kwargs): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Yeelight bulbs.""" + from yeelight.enums import PowerMode + + data_key = '{}_lights'.format(DATA_YEELIGHT) + if not discovery_info: return - yeelight_data = hass.data[DATA_YEELIGHT] - ipaddr = discovery_info[CONF_HOST] - device = yeelight_data[CONF_DEVICES][ipaddr] + if data_key not in hass.data: + hass.data[data_key] = [] + + device = hass.data[DATA_YEELIGHT][discovery_info[CONF_HOST]] _LOGGER.debug("Adding %s", device.name) custom_effects = discovery_info[CONF_CUSTOM_EFFECTS] light = YeelightLight(device, custom_effects=custom_effects) - yeelight_data[CONF_LIGHTS][ipaddr] = light + hass.data[data_key].append(light) add_entities([light], True) + def service_handler(service): + """Dispatch service calls to target entities.""" + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + + entity_ids = extract_entity_ids(hass, service) + target_devices = [light for light in hass.data[data_key] + if light.entity_id in entity_ids] + + for target_device in target_devices: + if service.service == SERVICE_SET_MODE: + target_device.set_mode(**params) + elif service.service == SERVICE_START_FLOW: + params[ATTR_TRANSITIONS] = \ + _transitions_config_parser(params[ATTR_TRANSITIONS]) + target_device.start_flow(**params) + + service_schema_set_mode = YEELIGHT_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_MODE): + vol.In([mode.name.lower() for mode in PowerMode]) + }) + hass.services.register( + DOMAIN, SERVICE_SET_MODE, service_handler, + schema=service_schema_set_mode) + + service_schema_start_flow = YEELIGHT_SERVICE_SCHEMA.extend( + YEELIGHT_FLOW_TRANSITION_SCHEMA + ) + hass.services.register( + DOMAIN, SERVICE_START_FLOW, service_handler, + schema=service_schema_start_flow) + class YeelightLight(Light): """Representation of a Yeelight light.""" @@ -455,3 +502,29 @@ def turn_off(self, **kwargs) -> None: duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s self.device.turn_off(duration=duration) + + def set_mode(self, mode: str): + """Set a power mode.""" + import yeelight + + try: + self._bulb.set_power_mode(yeelight.enums.PowerMode[mode.upper()]) + except yeelight.BulbException as ex: + _LOGGER.error("Unable to set the power mode: %s", ex) + + self.device.update() + + def start_flow(self, transitions, count=0, action=ACTION_RECOVER): + """Start flow.""" + import yeelight + + try: + flow = yeelight.Flow( + count=count, + action=yeelight.Flow.actions[action], + transitions=transitions) + + self._bulb.start_flow(flow) + self.device.update() + except yeelight.BulbException as ex: + _LOGGER.error("Unable to set effect: %s", ex) From 17a96c6d9b80db1bd14e9ac4a68314d47339081c Mon Sep 17 00:00:00 2001 From: ktnrg45 <38207570+ktnrg45@users.noreply.github.com> Date: Mon, 25 Mar 2019 05:25:15 -0700 Subject: [PATCH 27/69] Improve PS4 media art fetching and config flow (#22167) * improved config flow * Added errors, docs url * Added errors, docs url * Added manual config mode * Add tests for manual/auto host input * fix inline docs * fix inline docs * Changed region list * Added deprecated region message * removed DEFAULT_REGION * Added close method * Fixes * Update const.py * Update const.py * Update const.py * Update test_config_flow.py * Added invalid pin errors * Update strings.json * Update strings.json * bump pyps4 to 0.5.0 * Bump pyps4 0.5.0 * Bump pyps4 to 0.5.0 * test fixes * pylint * Change error reference * remove pin messages * remove pin messages * Update en.json * remove pin tests * fix tests * update vol * Vol fix * Update config_flow.py * Add migration for v1 entry * lint * fixes * typo * fix * Update config_flow.py * Fix vol * Executor job for io method. * Update __init__.py * blank line * Update __init__.py * Update tests/components/ps4/test_config_flow.py Co-Authored-By: ktnrg45 <38207570+ktnrg45@users.noreply.github.com> --- .../components/ps4/.translations/en.json | 19 ++- homeassistant/components/ps4/__init__.py | 45 ++++++- homeassistant/components/ps4/config_flow.py | 114 ++++++++++++------ homeassistant/components/ps4/const.py | 6 +- homeassistant/components/ps4/media_player.py | 11 +- homeassistant/components/ps4/strings.json | 19 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ps4/test_config_flow.py | 84 ++++++++++--- 9 files changed, 229 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/ps4/.translations/en.json b/homeassistant/components/ps4/.translations/en.json index c0b476ff4e2aae..662f6fb6116d75 100644 --- a/homeassistant/components/ps4/.translations/en.json +++ b/homeassistant/components/ps4/.translations/en.json @@ -4,18 +4,27 @@ "credential_error": "Error fetching credentials.", "devices_configured": "All devices found are already configured.", "no_devices_found": "No PlayStation 4 devices found on the network.", - "port_987_bind_error": "Could not bind to port 987.", - "port_997_bind_error": "Could not bind to port 997." + "port_987_bind_error": "Could not bind to port 987. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info.", + "port_997_bind_error": "Could not bind to port 997. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info." }, "error": { "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct.", - "not_ready": "PlayStation 4 is not on or connected to network." + "not_ready": "PlayStation 4 is not on or connected to network.", + "no_ipaddress": "Enter the IP Address of the PlayStation 4 you would like to configure." }, "step": { "creds": { "description": "Credentials needed. Press 'Submit' and then in the PS4 2nd Screen App, refresh devices and select the 'Home-Assistant' device to continue.", "title": "PlayStation 4" }, + "mode": { + "data": { + "mode": "Config Mode", + "ip_address": "IP Address (Leave empty if using Auto Discovery)." + }, + "description": "Select mode for configuration. The IP Address field can be left blank if selecting Auto Discovery, as devices will be automatically discovered.", + "title": "PlayStation 4" + }, "link": { "data": { "code": "PIN", @@ -23,10 +32,10 @@ "name": "Name", "region": "Region" }, - "description": "Enter your PlayStation 4 information. For 'PIN', navigate to 'Settings' on your PlayStation 4 console. Then navigate to 'Mobile App Connection Settings' and select 'Add Device'. Enter the PIN that is displayed.", + "description": "Enter your PlayStation 4 information. For 'PIN', navigate to 'Settings' on your PlayStation 4 console. Then navigate to 'Mobile App Connection Settings' and select 'Add Device'. Enter the PIN that is displayed. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info.", "title": "PlayStation 4" } }, "title": "PlayStation 4" } -} \ No newline at end of file +} diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index edefb5e47098eb..d5833ae1673296 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -6,13 +6,15 @@ """ import logging -from .config_flow import ( # noqa pylint: disable=unused-import - PlayStation4FlowHandler) +from homeassistant.const import CONF_REGION +from homeassistant.util import location + +from .config_flow import PlayStation4FlowHandler # noqa: pylint: disable=unused-import from .const import DOMAIN # noqa: pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyps4-homeassistant==0.4.8'] +REQUIREMENTS = ['pyps4-homeassistant==0.5.0'] async def async_setup(hass, config): @@ -32,3 +34,40 @@ async def async_unload_entry(hass, entry): await hass.config_entries.async_forward_entry_unload( entry, 'media_player') return True + + +async def async_migrate_entry(hass, entry): + """Migrate old entry.""" + from pyps4_homeassistant.media_art import COUNTRIES + + config_entries = hass.config_entries + data = entry.data + version = entry.version + + reason = {1: "Region codes have changed"} # From 0.89 + + # Migrate Version 1 -> Version 2 + if version == 1: + loc = await hass.async_add_executor_job(location.detect_location_info) + if loc: + country = loc.country_name + if country in COUNTRIES: + for device in data['devices']: + device[CONF_REGION] = country + entry.version = 2 + config_entries.async_update_entry(entry, data=data) + _LOGGER.info( + "PlayStation 4 Config Updated: \ + Region changed to: %s", country) + return True + + msg = """{} for the PlayStation 4 Integration. + Please remove the PS4 Integration and re-configure + [here](/config/integrations).""".format(reason[version]) + + hass.components.persistent_notification.async_create( + title="PlayStation 4 Integration Configuration Requires Update", + message=msg, + notification_id='config_entry_migration' + ) + return False diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index 148b0ae6d84c53..1b184a3774fc35 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -8,10 +8,14 @@ from homeassistant.const import ( CONF_CODE, CONF_HOST, CONF_IP_ADDRESS, CONF_NAME, CONF_REGION, CONF_TOKEN) -from .const import DEFAULT_NAME, DEFAULT_REGION, DOMAIN, REGIONS +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) +CONF_MODE = 'Config Mode' +CONF_AUTO = "Auto Discover" +CONF_MANUAL = "Manual Entry" + UDP_PORT = 987 TCP_PORT = 997 PORT_MSG = {UDP_PORT: 'port_987_bind_error', TCP_PORT: 'port_997_bind_error'} @@ -21,7 +25,7 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): """Handle a PlayStation 4 config flow.""" - VERSION = 1 + VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL def __init__(self): @@ -34,6 +38,8 @@ def __init__(self): self.host = None self.region = None self.pin = None + self.m_device = None + self.device_list = [] async def async_step_user(self, user_input=None): """Handle a user config flow.""" @@ -46,7 +52,7 @@ async def async_step_user(self, user_input=None): return self.async_abort(reason=reason) # Skip Creds Step if a device is configured. if self.hass.config_entries.async_entries(DOMAIN): - return await self.async_step_link() + return await self.async_step_mode() return await self.async_step_creds() async def async_step_creds(self, user_input=None): @@ -56,53 +62,82 @@ async def async_step_creds(self, user_input=None): self.helper.get_creds) if self.creds is not None: - return await self.async_step_link() + return await self.async_step_mode() return self.async_abort(reason='credential_error') return self.async_show_form( step_id='creds') - async def async_step_link(self, user_input=None): - """Prompt user input. Create or edit entry.""" + async def async_step_mode(self, user_input=None): + """Prompt for mode.""" errors = {} + mode = [CONF_AUTO, CONF_MANUAL] + + if user_input is not None: + if user_input[CONF_MODE] == CONF_MANUAL: + try: + device = user_input[CONF_IP_ADDRESS] + if device: + self.m_device = device + except KeyError: + errors[CONF_IP_ADDRESS] = 'no_ipaddress' + if not errors: + return await self.async_step_link() - # Search for device. - devices = await self.hass.async_add_executor_job( - self.helper.has_devices) + mode_schema = OrderedDict() + mode_schema[vol.Required( + CONF_MODE, default=CONF_AUTO)] = vol.In(list(mode)) + mode_schema[vol.Optional(CONF_IP_ADDRESS)] = str - # Abort if can't find device. - if not devices: - return self.async_abort(reason='no_devices_found') + return self.async_show_form( + step_id='mode', + data_schema=vol.Schema(mode_schema), + errors=errors, + ) - device_list = [ - device['host-ip'] for device in devices] + async def async_step_link(self, user_input=None): + """Prompt user input. Create or edit entry.""" + from pyps4_homeassistant.media_art import COUNTRIES + regions = sorted(COUNTRIES.keys()) + errors = {} - # If entry exists check that devices found aren't configured. - if self.hass.config_entries.async_entries(DOMAIN): - creds = {} - for entry in self.hass.config_entries.async_entries(DOMAIN): - # Retrieve creds from entry - creds['data'] = entry.data[CONF_TOKEN] - # Retrieve device data from entry - conf_devices = entry.data['devices'] - for c_device in conf_devices: - if c_device['host'] in device_list: - # Remove configured device from search list. - device_list.remove(c_device['host']) - # If list is empty then all devices are configured. - if not device_list: - return self.async_abort(reason='devices_configured') - # Add existing creds for linking. Should be only 1. - if not creds: - # Abort if creds is missing. - return self.async_abort(reason='credential_error') - self.creds = creds['data'] + if user_input is None: + # Search for device. + devices = await self.hass.async_add_executor_job( + self.helper.has_devices, self.m_device) + + # Abort if can't find device. + if not devices: + return self.async_abort(reason='no_devices_found') + + self.device_list = [device['host-ip'] for device in devices] + + # If entry exists check that devices found aren't configured. + if self.hass.config_entries.async_entries(DOMAIN): + creds = {} + for entry in self.hass.config_entries.async_entries(DOMAIN): + # Retrieve creds from entry + creds['data'] = entry.data[CONF_TOKEN] + # Retrieve device data from entry + conf_devices = entry.data['devices'] + for c_device in conf_devices: + if c_device['host'] in self.device_list: + # Remove configured device from search list. + self.device_list.remove(c_device['host']) + # If list is empty then all devices are configured. + if not self.device_list: + return self.async_abort(reason='devices_configured') + # Add existing creds for linking. Should be only 1. + if not creds: + # Abort if creds is missing. + return self.async_abort(reason='credential_error') + self.creds = creds['data'] # Login to PS4 with user data. if user_input is not None: self.region = user_input[CONF_REGION] self.name = user_input[CONF_NAME] - self.pin = user_input[CONF_CODE] + self.pin = str(user_input[CONF_CODE]) self.host = user_input[CONF_IP_ADDRESS] is_ready, is_login = await self.hass.async_add_executor_job( @@ -130,10 +165,11 @@ async def async_step_link(self, user_input=None): # Show User Input form. link_schema = OrderedDict() - link_schema[vol.Required(CONF_IP_ADDRESS)] = vol.In(list(device_list)) - link_schema[vol.Required( - CONF_REGION, default=DEFAULT_REGION)] = vol.In(list(REGIONS)) - link_schema[vol.Required(CONF_CODE)] = str + link_schema[vol.Required(CONF_IP_ADDRESS)] = vol.In( + list(self.device_list)) + link_schema[vol.Required(CONF_REGION)] = vol.In(list(regions)) + link_schema[vol.Required(CONF_CODE)] = vol.All( + vol.Strip, vol.Length(min=8, max=8), vol.Coerce(int)) link_schema[vol.Required(CONF_NAME, default=DEFAULT_NAME)] = str return self.async_show_form( diff --git a/homeassistant/components/ps4/const.py b/homeassistant/components/ps4/const.py index 0618ca9675f8db..bbf654530b0081 100644 --- a/homeassistant/components/ps4/const.py +++ b/homeassistant/components/ps4/const.py @@ -1,5 +1,7 @@ """Constants for PlayStation 4.""" DEFAULT_NAME = "PlayStation 4" -DEFAULT_REGION = "R1" +DEFAULT_REGION = "United States" DOMAIN = 'ps4' -REGIONS = ('R1', 'R2', 'R3', 'R4', 'R5') + +# Deprecated used for logger/backwards compatibility from 0.89 +REGIONS = ['R1', 'R2', 'R3', 'R4', 'R5'] diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 60b656a469dcbc..e2f0004f80e9dd 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -20,7 +20,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json -from .const import DOMAIN as PS4_DOMAIN +from .const import DOMAIN as PS4_DOMAIN, REGIONS as deprecated_regions DEPENDENCIES = ['ps4'] @@ -142,6 +142,12 @@ def update(self): self._games = self.load_games() if self._games is not None: self._source_list = list(sorted(self._games.values())) + # Non-Breaking although data returned may be inaccurate. + if self._region in deprecated_regions: + _LOGGER.info("""Region: %s has been deprecated. + Please remove PS4 integration + and Re-configure again to utilize + current regions""", self._region) except socket.timeout: status = None if status is not None: @@ -275,6 +281,8 @@ def get_device_info(self, status): async def async_will_remove_from_hass(self): """Remove Entity from Hass.""" + # Close TCP Socket + await self.hass.async_add_executor_job(self._ps4.close) self.hass.data[PS4_DATA].devices.remove(self) @property @@ -320,6 +328,7 @@ def media_content_id(self): @property def media_content_type(self): """Content type of current playing media.""" + # No MEDIA_TYPE_GAME attr as of 0.90. return MEDIA_TYPE_MUSIC @property diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index 5f4e2a7c8b4186..d8fdc9e18dbcd4 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -6,9 +6,17 @@ "title": "PlayStation 4", "description": "Credentials needed. Press 'Submit' and then in the PS4 2nd Screen App, refresh devices and select the 'Home-Assistant' device to continue." }, + "mode": { + "title": "PlayStation 4", + "description": "Select mode for configuration. The IP Address field can be left blank if selecting Auto Discovery, as devices will be automatically discovered.", + "data": { + "mode": "Config Mode", + "ip_address": "IP Address (Leave empty if using Auto Discovery)." + }, + }, "link": { "title": "PlayStation 4", - "description": "Enter your PlayStation 4 information. For 'PIN', navigate to 'Settings' on your PlayStation 4 console. Then navigate to 'Mobile App Connection Settings' and select 'Add Device'. Enter the PIN that is displayed.", + "description": "Enter your PlayStation 4 information. For 'PIN', navigate to 'Settings' on your PlayStation 4 console. Then navigate to 'Mobile App Connection Settings' and select 'Add Device'. Enter the PIN that is displayed. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info.", "data": { "region": "Region", "name": "Name", @@ -19,14 +27,15 @@ }, "error": { "not_ready": "PlayStation 4 is not on or connected to network.", - "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct." + "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct.", + "no_ipaddress": "Enter the IP Address of the PlayStation 4 you would like to configure." }, "abort": { "credential_error": "Error fetching credentials.", "no_devices_found": "No PlayStation 4 devices found on the network.", - "devices_configured": "All devices found are already configured.", - "port_987_bind_error": "Could not bind to port 987.", - "port_997_bind_error": "Could not bind to port 997." + "devices_configured": "All devices found are already configured.", + "port_987_bind_error": "Could not bind to port 987. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info.", + "port_997_bind_error": "Could not bind to port 997. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info." } } } diff --git a/requirements_all.txt b/requirements_all.txt index de994fa9122131..8603d7a91de70c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1232,7 +1232,7 @@ pypoint==1.1.1 pypollencom==2.2.3 # homeassistant.components.ps4 -pyps4-homeassistant==0.4.8 +pyps4-homeassistant==0.5.0 # homeassistant.components.qwikswitch pyqwikswitch==0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe026a3813cfec..1a8298d196b00b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -230,7 +230,7 @@ pyopenuv==1.0.9 pyotp==2.2.6 # homeassistant.components.ps4 -pyps4-homeassistant==0.4.8 +pyps4-homeassistant==0.5.0 # homeassistant.components.qwikswitch pyqwikswitch==0.8 diff --git a/tests/components/ps4/test_config_flow.py b/tests/components/ps4/test_config_flow.py index 271db46d856b47..06fe1ef65da8f7 100644 --- a/tests/components/ps4/test_config_flow.py +++ b/tests/components/ps4/test_config_flow.py @@ -44,6 +44,9 @@ MOCK_UDP_PORT = int(987) MOCK_TCP_PORT = int(997) +MOCK_AUTO = {"Config Mode": 'Auto Discover'} +MOCK_MANUAL = {"Config Mode": 'Manual Entry', CONF_IP_ADDRESS: MOCK_HOST} + async def test_full_flow_implementation(hass): """Test registering an implementation and flow works.""" @@ -58,13 +61,18 @@ async def test_full_flow_implementation(hass): assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'creds' - # Step Creds results with form in Step Link. + # Step Creds results with form in Step Mode. with patch('pyps4_homeassistant.Helper.get_creds', - return_value=MOCK_CREDS), \ - patch('pyps4_homeassistant.Helper.has_devices', - return_value=[{'host-ip': MOCK_HOST}]): + return_value=MOCK_CREDS): result = await flow.async_step_creds({}) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mode' + + # Step Mode with User Input which is not manual, results in Step Link. + with patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}]): + result = await flow.async_step_mode(MOCK_AUTO) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'link' # User Input results in created entry. @@ -78,6 +86,8 @@ async def test_full_flow_implementation(hass): assert result['data']['devices'] == [MOCK_DEVICE] assert result['title'] == MOCK_TITLE + await hass.async_block_till_done() + # Add entry using result data. mock_data = { CONF_TOKEN: result['data'][CONF_TOKEN], @@ -104,14 +114,19 @@ async def test_multiple_flow_implementation(hass): assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'creds' - # Step Creds results with form in Step Link. + # Step Creds results with form in Step Mode. with patch('pyps4_homeassistant.Helper.get_creds', - return_value=MOCK_CREDS), \ - patch('pyps4_homeassistant.Helper.has_devices', - return_value=[{'host-ip': MOCK_HOST}, - {'host-ip': MOCK_HOST_ADDITIONAL}]): + return_value=MOCK_CREDS): result = await flow.async_step_creds({}) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mode' + + # Step Mode with User Input which is not manual, results in Step Link. + with patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]): + result = await flow.async_step_mode(MOCK_AUTO) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'link' # User Input results in created entry. @@ -142,7 +157,7 @@ async def test_multiple_flow_implementation(hass): # Test additional flow. - # User Step Started, results in Step Link: + # User Step Started, results in Step Mode: with patch('pyps4_homeassistant.Helper.port_bind', return_value=None), \ patch('pyps4_homeassistant.Helper.has_devices', @@ -150,6 +165,14 @@ async def test_multiple_flow_implementation(hass): {'host-ip': MOCK_HOST_ADDITIONAL}]): result = await flow.async_step_user() assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mode' + + # Step Mode with User Input which is not manual, results in Step Link. + with patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]): + result = await flow.async_step_mode(MOCK_AUTO) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'link' # Step Link @@ -158,12 +181,14 @@ async def test_multiple_flow_implementation(hass): {'host-ip': MOCK_HOST_ADDITIONAL}]), \ patch('pyps4_homeassistant.Helper.link', return_value=(True, True)): - result = await flow.async_step_link(user_input=MOCK_CONFIG_ADDITIONAL) + result = await flow.async_step_link(MOCK_CONFIG_ADDITIONAL) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result['data'][CONF_TOKEN] == MOCK_CREDS assert len(result['data']['devices']) == 1 assert result['title'] == MOCK_TITLE + await hass.async_block_till_done() + mock_data = { CONF_TOKEN: result['data'][CONF_TOKEN], 'devices': result['data']['devices']} @@ -230,7 +255,7 @@ async def test_additional_device(hass): {'host-ip': MOCK_HOST_ADDITIONAL}]), \ patch('pyps4_homeassistant.Helper.link', return_value=(True, True)): - result = await flow.async_step_link(user_input=MOCK_CONFIG_ADDITIONAL) + result = await flow.async_step_link(MOCK_CONFIG_ADDITIONAL) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result['data'][CONF_TOKEN] == MOCK_CREDS assert len(result['data']['devices']) == 1 @@ -249,12 +274,26 @@ async def test_no_devices_found_abort(hass): flow = ps4.PlayStation4FlowHandler() flow.hass = hass - with patch('pyps4_homeassistant.Helper.has_devices', return_value=None): - result = await flow.async_step_link(MOCK_CONFIG) + with patch('pyps4_homeassistant.Helper.has_devices', return_value=[]): + result = await flow.async_step_link() assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'no_devices_found' +async def test_manual_mode(hass): + """Test host specified in manual mode is passed to Step Link.""" + flow = ps4.PlayStation4FlowHandler() + flow.hass = hass + + # Step Mode with User Input: manual, results in Step Link. + with patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': flow.m_device}]): + result = await flow.async_step_mode(MOCK_MANUAL) + assert flow.m_device == MOCK_HOST + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + + async def test_credential_abort(hass): """Test that failure to get credentials aborts flow.""" flow = ps4.PlayStation4FlowHandler() @@ -266,8 +305,8 @@ async def test_credential_abort(hass): assert result['reason'] == 'credential_error' -async def test_invalid_pin_error(hass): - """Test that invalid pin throws an error.""" +async def test_wrong_pin_error(hass): + """Test that incorrect pin throws an error.""" flow = ps4.PlayStation4FlowHandler() flow.hass = hass @@ -294,3 +333,16 @@ async def test_device_connection_error(hass): assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'link' assert result['errors'] == {'base': 'not_ready'} + + +async def test_manual_mode_no_ip_error(hass): + """Test no IP specified in manual mode throws an error.""" + flow = ps4.PlayStation4FlowHandler() + flow.hass = hass + + mock_input = {"Config Mode": 'Manual Entry'} + + result = await flow.async_step_mode(mock_input) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mode' + assert result['errors'] == {CONF_IP_ADDRESS: 'no_ipaddress'} From c8048e1aff7063e0301a208783a9fc939d05a100 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Mon, 25 Mar 2019 05:37:10 -0700 Subject: [PATCH 28/69] Allow for custom turn on/off commands (#22354) --- .../components/androidtv/media_player.py | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 1d186484f40543..2db3110f2d9d46 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -40,6 +40,8 @@ CONF_ADB_SERVER_PORT = 'adb_server_port' CONF_APPS = 'apps' CONF_GET_SOURCES = 'get_sources' +CONF_TURN_ON_COMMAND = 'turn_on_command' +CONF_TURN_OFF_COMMAND = 'turn_off_command' DEFAULT_NAME = 'Android TV' DEFAULT_PORT = 5555 @@ -79,7 +81,9 @@ def has_adb_files(value): cv.port, vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean, vol.Optional(CONF_APPS, default=dict()): - vol.Schema({cv.string: cv.string}) + vol.Schema({cv.string: cv.string}), + vol.Optional(CONF_TURN_ON_COMMAND): cv.string, + vol.Optional(CONF_TURN_OFF_COMMAND): cv.string }) # Translate from `AndroidTV` / `FireTV` reported state to HA state. @@ -136,12 +140,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): else: if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: device = AndroidTVDevice(aftv, config[CONF_NAME], - config[CONF_APPS]) + config[CONF_APPS], + config.get(CONF_TURN_ON_COMMAND), + config.get(CONF_TURN_OFF_COMMAND)) device_name = config[CONF_NAME] if CONF_NAME in config \ else 'Android TV' else: device = FireTVDevice(aftv, config[CONF_NAME], config[CONF_APPS], - config[CONF_GET_SOURCES]) + config[CONF_GET_SOURCES], + config.get(CONF_TURN_ON_COMMAND), + config.get(CONF_TURN_OFF_COMMAND)) device_name = config[CONF_NAME] if CONF_NAME in config \ else 'Fire TV' @@ -199,7 +207,8 @@ def _adb_exception_catcher(self, *args, **kwargs): class ADBDevice(MediaPlayerDevice): """Representation of an Android TV or Fire TV device.""" - def __init__(self, aftv, name, apps): + def __init__(self, aftv, name, apps, turn_on_command, + turn_off_command): """Initialize the Android TV / Fire TV device.""" from androidtv.constants import APPS, KEYS @@ -209,6 +218,9 @@ def __init__(self, aftv, name, apps): self._apps.update(apps) self._keys = KEYS + self.turn_on_command = turn_on_command + self.turn_off_command = turn_off_command + # ADB exceptions to catch if not self.aftv.adb_server_ip: # Using "python-adb" (Python ADB implementation) @@ -278,12 +290,18 @@ def media_play_pause(self): @adb_decorator() def turn_on(self): """Turn on the device.""" - self.aftv.turn_on() + if self.turn_on_command: + self.aftv.adb_shell(self.turn_on_command) + else: + self.aftv.turn_on() @adb_decorator() def turn_off(self): """Turn off the device.""" - self.aftv.turn_off() + if self.turn_off_command: + self.aftv.adb_shell(self.turn_off_command) + else: + self.aftv.turn_off() @adb_decorator() def media_previous_track(self): @@ -311,9 +329,11 @@ def adb_command(self, cmd): class AndroidTVDevice(ADBDevice): """Representation of an Android TV device.""" - def __init__(self, aftv, name, apps): + def __init__(self, aftv, name, apps, turn_on_command, + turn_off_command): """Initialize the Android TV device.""" - super().__init__(aftv, name, apps) + super().__init__(aftv, name, apps, turn_on_command, + turn_off_command) self._device = None self._muted = None @@ -392,9 +412,11 @@ def volume_up(self): class FireTVDevice(ADBDevice): """Representation of a Fire TV device.""" - def __init__(self, aftv, name, apps, get_sources): + def __init__(self, aftv, name, apps, get_sources, + turn_on_command, turn_off_command): """Initialize the Fire TV device.""" - super().__init__(aftv, name, apps) + super().__init__(aftv, name, apps, turn_on_command, + turn_off_command) self._get_sources = get_sources self._running_apps = None From f99795705439b8fddf8d50369cc0414d21c1ca01 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 25 Mar 2019 15:28:34 +0000 Subject: [PATCH 29/69] Remove unused const (#22383) --- homeassistant/components/homekit_controller/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index b16f82de7f6482..0cb9ecbfc0789b 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -28,7 +28,6 @@ _LOGGER = logging.getLogger(__name__) -REQUEST_TIMEOUT = 5 # seconds RETRY_INTERVAL = 60 # seconds PAIRING_FILE = "pairing.json" From b57d809dadaeb9b80b96f797917bb02613bbbbda Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 25 Mar 2019 17:43:15 +0100 Subject: [PATCH 30/69] Update hass-nabucasa & fix state (#22385) * Update hass-nabucasa & fix state * Fix lint --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/binary_sensor.py | 12 ++++++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_binary_sensor.py | 3 +++ 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 9517971b16d8d1..76a768385f8508 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -24,7 +24,7 @@ CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD) from .prefs import CloudPreferences -REQUIREMENTS = ['hass-nabucasa==0.10'] +REQUIREMENTS = ['hass-nabucasa==0.11'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index 874c3420c5844e..19a6528e3218f5 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -1,6 +1,7 @@ """Support for Home Assistant Cloud binary sensors.""" +import asyncio + from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN @@ -8,6 +9,9 @@ DEPENDENCIES = ['cloud'] +WAIT_UNTIL_CHANGE = 3 + + async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): """Set up the cloud binary sensors.""" @@ -58,10 +62,10 @@ def should_poll(self) -> bool: async def async_added_to_hass(self): """Register update dispatcher.""" - @callback - def async_state_update(data): + async def async_state_update(data): """Update callback.""" - self.async_write_ha_state() + await asyncio.sleep(WAIT_UNTIL_CHANGE) + self.async_schedule_update_ha_state() self._unsub_dispatcher = async_dispatcher_connect( self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update) diff --git a/requirements_all.txt b/requirements_all.txt index 8603d7a91de70c..020ed3248a5a52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -521,7 +521,7 @@ habitipy==0.2.0 hangups==0.4.6 # homeassistant.components.cloud -hass-nabucasa==0.10 +hass-nabucasa==0.11 # homeassistant.components.mqtt.server hbmqtt==0.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a8298d196b00b..4bd50b713e2fc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -120,7 +120,7 @@ ha-ffmpeg==1.11 hangups==0.4.6 # homeassistant.components.cloud -hass-nabucasa==0.10 +hass-nabucasa==0.11 # homeassistant.components.mqtt.server hbmqtt==0.9.4 diff --git a/tests/components/cloud/test_binary_sensor.py b/tests/components/cloud/test_binary_sensor.py index 938829b809bdb6..f6d8783a609a71 100644 --- a/tests/components/cloud/test_binary_sensor.py +++ b/tests/components/cloud/test_binary_sensor.py @@ -7,6 +7,9 @@ async def test_remote_connection_sensor(hass): """Test the remote connection sensor.""" + from homeassistant.components.cloud import binary_sensor as bin_sensor + bin_sensor.WAIT_UNTIL_CHANGE = 0 + assert await async_setup_component(hass, 'cloud', {'cloud': {}}) cloud = hass.data['cloud'] = Mock() cloud.remote.certificate = None From f1a0ad9e4ab7bdc8ab76ba8bcf6f1eb7a39dadb5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 25 Mar 2019 10:04:35 -0700 Subject: [PATCH 31/69] Frontend indicate require admin (#22272) * Allow panels to indicate they are meant for admins * Panels to indicate when they require admin access * Do not return admin-only panels to non-admin users * Fix flake8 --- homeassistant/components/config/__init__.py | 2 +- homeassistant/components/frontend/__init__.py | 26 ++++++++++++------ homeassistant/components/hassio/__init__.py | 1 + .../components/panel_custom/__init__.py | 10 +++++-- .../components/panel_iframe/__init__.py | 5 +++- tests/components/frontend/test_init.py | 27 ++++++++++++++++++- tests/components/hassio/test_init.py | 23 ++++++++++++++++ tests/components/panel_custom/test_init.py | 2 ++ tests/components/panel_iframe/test_init.py | 8 +++++- 9 files changed, 90 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index efabd03b58608c..0366dfa2b8bfeb 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -32,7 +32,7 @@ async def async_setup(hass, config): """Set up the config component.""" await hass.components.frontend.async_register_built_in_panel( - 'config', 'config', 'hass:settings') + 'config', 'config', 'hass:settings', require_admin=True) async def setup_panel(panel_name): """Set up a panel.""" diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 30b9d350df6d1d..5a10b60f12f3ad 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -123,14 +123,18 @@ class Panel: # Config to pass to the webcomponent config = None + # If the panel should only be visible to admins + require_admin = False + def __init__(self, component_name, sidebar_title, sidebar_icon, - frontend_url_path, config): + frontend_url_path, config, require_admin): """Initialize a built-in panel.""" self.component_name = component_name self.sidebar_title = sidebar_title self.sidebar_icon = sidebar_icon self.frontend_url_path = frontend_url_path or component_name self.config = config + self.require_admin = require_admin @callback def async_register_index_routes(self, router, index_view): @@ -150,16 +154,18 @@ def to_response(self): 'title': self.sidebar_title, 'config': self.config, 'url_path': self.frontend_url_path, + 'require_admin': self.require_admin, } @bind_hass async def async_register_built_in_panel(hass, component_name, sidebar_title=None, sidebar_icon=None, - frontend_url_path=None, config=None): + frontend_url_path=None, config=None, + require_admin=False): """Register a built-in panel.""" panel = Panel(component_name, sidebar_title, sidebar_icon, - frontend_url_path, config) + frontend_url_path, config, require_admin) panels = hass.data.get(DATA_PANELS) if panels is None: @@ -247,9 +253,11 @@ def async_finalize_panel(panel): await asyncio.wait( [async_register_built_in_panel(hass, panel) for panel in ( - 'dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt', 'kiosk', 'states', 'profile')], - loop=hass.loop) + 'kiosk', 'states', 'profile')], loop=hass.loop) + await asyncio.wait( + [async_register_built_in_panel(hass, panel, require_admin=True) + for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', + 'dev-template', 'dev-mqtt')], loop=hass.loop) hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel @@ -478,9 +486,11 @@ def websocket_get_panels(hass, connection, msg): Async friendly. """ + user_is_admin = connection.user.is_admin panels = { - panel: connection.hass.data[DATA_PANELS][panel].to_response() - for panel in connection.hass.data[DATA_PANELS]} + panel_key: panel.to_response() + for panel_key, panel in connection.hass.data[DATA_PANELS].items() + if user_is_admin or not panel.require_admin} connection.send_message(websocket_api.result_message( msg['id'], panels)) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 7f85c8cfc3fc66..7e47ac152e32c9 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -189,6 +189,7 @@ async def async_setup(hass, config): sidebar_icon='hass:home-assistant', js_url='/api/hassio/app/entrypoint.js', embed_iframe=True, + require_admin=True, ) await hassio.update_hass_api(config.get('http', {}), refresh_token.token) diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index 2fce5d9857c99d..7fe2191f4c497c 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -23,6 +23,7 @@ CONF_EMBED_IFRAME = 'embed_iframe' CONF_TRUST_EXTERNAL_SCRIPT = 'trust_external_script' CONF_URL_EXCLUSIVE_GROUP = 'url_exclusive_group' +CONF_REQUIRE_ADMIN = 'require_admin' MSG_URL_CONFLICT = \ 'Pass in only one of webcomponent_path, module_url or js_url' @@ -52,6 +53,7 @@ default=DEFAULT_EMBED_IFRAME): cv.boolean, vol.Optional(CONF_TRUST_EXTERNAL_SCRIPT, default=DEFAULT_TRUST_EXTERNAL): cv.boolean, + vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, })]) }, extra=vol.ALLOW_EXTRA) @@ -77,7 +79,9 @@ async def async_register_panel( # Should user be asked for confirmation when loading external source trust_external=DEFAULT_TRUST_EXTERNAL, # Configuration to be passed to the panel - config=None): + config=None, + # If your panel should only be shown to admin users + require_admin=False): """Register a new custom panel.""" if js_url is None and html_url is None and module_url is None: raise ValueError('Either js_url, module_url or html_url is required.') @@ -115,7 +119,8 @@ async def async_register_panel( sidebar_title=sidebar_title, sidebar_icon=sidebar_icon, frontend_url_path=frontend_url_path, - config=config + config=config, + require_admin=require_admin, ) @@ -134,6 +139,7 @@ async def async_setup(hass, config): 'config': panel.get(CONF_CONFIG), 'trust_external': panel[CONF_TRUST_EXTERNAL_SCRIPT], 'embed_iframe': panel[CONF_EMBED_IFRAME], + 'require_admin': panel[CONF_REQUIRE_ADMIN], } panel_path = panel.get(CONF_WEBCOMPONENT_PATH) diff --git a/homeassistant/components/panel_iframe/__init__.py b/homeassistant/components/panel_iframe/__init__.py index b82f9fa9789421..9319dfcc6adb27 100644 --- a/homeassistant/components/panel_iframe/__init__.py +++ b/homeassistant/components/panel_iframe/__init__.py @@ -12,6 +12,7 @@ CONF_RELATIVE_URL_ERROR_MSG = "Invalid relative URL. Absolute path required." CONF_RELATIVE_URL_REGEX = r'\A/' +CONF_REQUIRE_ADMIN = 'require_admin' CONFIG_SCHEMA = vol.Schema({ DOMAIN: cv.schema_with_slug_keys( @@ -19,6 +20,7 @@ # pylint: disable=no-value-for-parameter vol.Optional(CONF_TITLE): cv.string, vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, vol.Required(CONF_URL): vol.Any( vol.Match( CONF_RELATIVE_URL_REGEX, @@ -34,6 +36,7 @@ async def async_setup(hass, config): for url_path, info in config[DOMAIN].items(): await hass.components.frontend.async_register_built_in_panel( 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), - url_path, {'url': info[CONF_URL]}) + url_path, {'url': info[CONF_URL]}, + require_admin=info[CONF_REQUIRE_ADMIN]) return True diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index b1b9a70d5945ae..e4ed2c15ecb699 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -249,7 +249,7 @@ async def test_get_panels(hass, hass_ws_client): """Test get_panels command.""" await async_setup_component(hass, 'frontend') await hass.components.frontend.async_register_built_in_panel( - 'map', 'Map', 'mdi:tooltip-account') + 'map', 'Map', 'mdi:tooltip-account', require_admin=True) client = await hass_ws_client(hass) await client.send_json({ @@ -266,6 +266,31 @@ async def test_get_panels(hass, hass_ws_client): assert msg['result']['map']['url_path'] == 'map' assert msg['result']['map']['icon'] == 'mdi:tooltip-account' assert msg['result']['map']['title'] == 'Map' + assert msg['result']['map']['require_admin'] is True + + +async def test_get_panels_non_admin(hass, hass_ws_client, hass_admin_user): + """Test get_panels command.""" + hass_admin_user.groups = [] + await async_setup_component(hass, 'frontend') + await hass.components.frontend.async_register_built_in_panel( + 'map', 'Map', 'mdi:tooltip-account', require_admin=True) + await hass.components.frontend.async_register_built_in_panel( + 'history', 'History', 'mdi:history') + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'get_panels', + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert 'history' in msg['result'] + assert 'map' not in msg['result'] async def test_get_translations(hass, hass_ws_client): diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 1326805fc93944..ba642b698f7afb 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -8,6 +8,7 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.setup import async_setup_component from homeassistant.components.hassio import STORAGE_KEY +from homeassistant.components import frontend from tests.common import mock_coro @@ -44,6 +45,28 @@ def test_setup_api_ping(hass, aioclient_mock): assert hass.components.hassio.is_hassio() +async def test_setup_api_panel(hass, aioclient_mock): + """Test setup with API ping.""" + assert await async_setup_component(hass, 'frontend', {}) + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component(hass, 'hassio', {}) + assert result + + panels = hass.data[frontend.DATA_PANELS] + + assert panels.get('hassio').to_response() == { + 'component_name': 'custom', + 'icon': 'hass:home-assistant', + 'title': 'Hass.io', + 'url_path': 'hassio', + 'require_admin': True, + 'config': {'_panel_custom': {'embed_iframe': True, + 'js_url': '/api/hassio/app/entrypoint.js', + 'name': 'hassio-main', + 'trust_external': False}}, + } + + @asyncio.coroutine def test_setup_api_push_api_data(hass, aioclient_mock): """Test setup with API push.""" diff --git a/tests/components/panel_custom/test_init.py b/tests/components/panel_custom/test_init.py index c265324179dc45..8c95f96085ac2c 100644 --- a/tests/components/panel_custom/test_init.py +++ b/tests/components/panel_custom/test_init.py @@ -130,6 +130,7 @@ async def test_module_webcomponent(hass): }, 'embed_iframe': True, 'trust_external_script': True, + 'require_admin': True, } } @@ -145,6 +146,7 @@ async def test_module_webcomponent(hass): panel = panels['nice_url'] + assert panel.require_admin assert panel.config == { 'hello': 'world', '_panel_custom': { diff --git a/tests/components/panel_iframe/test_init.py b/tests/components/panel_iframe/test_init.py index cb868f64b58789..da7878399d42b9 100644 --- a/tests/components/panel_iframe/test_init.py +++ b/tests/components/panel_iframe/test_init.py @@ -41,11 +41,13 @@ def test_correct_config(self): 'icon': 'mdi:network-wireless', 'title': 'Router', 'url': 'http://192.168.1.1', + 'require_admin': True, }, 'weather': { 'icon': 'mdi:weather', 'title': 'Weather', 'url': 'https://www.wunderground.com/us/ca/san-diego', + 'require_admin': True, }, 'api': { 'icon': 'mdi:weather', @@ -67,7 +69,8 @@ def test_correct_config(self): 'config': {'url': 'http://192.168.1.1'}, 'icon': 'mdi:network-wireless', 'title': 'Router', - 'url_path': 'router' + 'url_path': 'router', + 'require_admin': True, } assert panels.get('weather').to_response() == { @@ -76,6 +79,7 @@ def test_correct_config(self): 'icon': 'mdi:weather', 'title': 'Weather', 'url_path': 'weather', + 'require_admin': True, } assert panels.get('api').to_response() == { @@ -84,6 +88,7 @@ def test_correct_config(self): 'icon': 'mdi:weather', 'title': 'Api', 'url_path': 'api', + 'require_admin': False, } assert panels.get('ftp').to_response() == { @@ -92,4 +97,5 @@ def test_correct_config(self): 'icon': 'mdi:weather', 'title': 'FTP', 'url_path': 'ftp', + 'require_admin': False, } From a59487a438f653063c254a7133dc6c2297877e3c Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 25 Mar 2019 20:25:49 +0300 Subject: [PATCH 32/69] Fix TpLink Device Tracker initialize error (#22349) Catch RequestException instead of ConnectionError In some cases TpLinkDeviceScanner throws various successors of RequestException. They should be caught. --- homeassistant/components/tplink/device_tracker.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tplink/device_tracker.py b/homeassistant/components/tplink/device_tracker.py index 78f16a82d56069..33a5d5f32f8310 100644 --- a/homeassistant/components/tplink/device_tracker.py +++ b/homeassistant/components/tplink/device_tracker.py @@ -77,8 +77,8 @@ def __init__(self, config): self.last_results = {} self.success_init = self._update_info() - except requests.exceptions.ConnectionError: - _LOGGER.debug("ConnectionError in TplinkDeviceScanner") + except requests.exceptions.RequestException: + _LOGGER.debug("RequestException in %s", __class__.__name__) def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -123,8 +123,8 @@ def __init__(self, config): self.success_init = False try: self.success_init = self._update_info() - except requests.exceptions.ConnectionError: - _LOGGER.debug("ConnectionError in Tplink1DeviceScanner") + except requests.exceptions.RequestException: + _LOGGER.debug("RequestException in %s", __class__.__name__) def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" From 2731777c7eb183426d309332dbfe10fd5d19531f Mon Sep 17 00:00:00 2001 From: lapy Date: Mon, 25 Mar 2019 17:52:53 +0000 Subject: [PATCH 33/69] Add traccar events (#22348) * Add import_events(), small refactoring, traccar_id I isolated the code that imports traccar tracking data and added a new function that imports the traccar events to make them run in parallel and reduce delay. The events that are imported in hass, will be fired with the prefix "traccar_". Furthermore a traccar_id is now imported in hass entities, useful for matching the traccar hass entities with the traccar hass events in the most accurate way. * bump pytraccar version * Code format fix * Code format fix 2 * Code format fix 3 * Implement requested changes * Add new traccar dependency * Fix line too long * Update device_tracker.py * Update requirements_all.txt --- .../components/traccar/device_tracker.py | 85 +++++++++++++++++-- requirements_all.txt | 3 +- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 1447f7c896c76d..e3ac14279414c9 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -4,7 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.traccar/ """ -from datetime import timedelta +from datetime import datetime, timedelta import logging import voluptuous as vol @@ -13,14 +13,15 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL, CONF_PASSWORD, CONF_USERNAME, ATTR_BATTERY_LEVEL, - CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS) + CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS, + CONF_EVENT) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import slugify -REQUIREMENTS = ['pytraccar==0.3.0'] +REQUIREMENTS = ['pytraccar==0.5.0', 'stringcase==1.2.0'] _LOGGER = logging.getLogger(__name__) @@ -30,6 +31,25 @@ ATTR_MOTION = 'motion' ATTR_SPEED = 'speed' ATTR_TRACKER = 'tracker' +ATTR_TRACCAR_ID = 'traccar_id' + +EVENT_DEVICE_MOVING = 'device_moving' +EVENT_COMMAND_RESULT = 'command_result' +EVENT_DEVICE_FUEL_DROP = 'device_fuel_drop' +EVENT_GEOFENCE_ENTER = 'geofence_enter' +EVENT_DEVICE_OFFLINE = 'device_offline' +EVENT_DRIVER_CHANGED = 'driver_changed' +EVENT_GEOFENCE_EXIT = 'geofence_exit' +EVENT_DEVICE_OVERSPEED = 'device_overspeed' +EVENT_DEVICE_ONLINE = 'device_online' +EVENT_DEVICE_STOPPED = 'device_stopped' +EVENT_MAINTENANCE = 'maintenance' +EVENT_ALARM = 'alarm' +EVENT_TEXT_MESSAGE = 'text_message' +EVENT_DEVICE_UNKNOWN = 'device_unknown' +EVENT_IGNITION_OFF = 'ignition_off' +EVENT_IGNITION_ON = 'ignition_on' +EVENT_ALL_EVENTS = 'all_events' DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL @@ -43,6 +63,25 @@ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EVENT, + default=[]): vol.All(cv.ensure_list, + [vol.Any(EVENT_DEVICE_MOVING, + EVENT_COMMAND_RESULT, + EVENT_DEVICE_FUEL_DROP, + EVENT_GEOFENCE_ENTER, + EVENT_DEVICE_OFFLINE, + EVENT_DRIVER_CHANGED, + EVENT_GEOFENCE_EXIT, + EVENT_DEVICE_OVERSPEED, + EVENT_DEVICE_ONLINE, + EVENT_DEVICE_STOPPED, + EVENT_MAINTENANCE, + EVENT_ALARM, + EVENT_TEXT_MESSAGE, + EVENT_DEVICE_UNKNOWN, + EVENT_IGNITION_OFF, + EVENT_IGNITION_ON, + EVENT_ALL_EVENTS)]), }) @@ -58,7 +97,7 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): scanner = TraccarScanner( api, hass, async_see, config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), - config[CONF_MONITORED_CONDITIONS]) + config[CONF_MONITORED_CONDITIONS], config[CONF_EVENT]) return await scanner.async_init() @@ -66,8 +105,12 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): class TraccarScanner: """Define an object to retrieve Traccar data.""" - def __init__(self, api, hass, async_see, scan_interval, custom_attributes): + def __init__(self, api, hass, async_see, scan_interval, + custom_attributes, + event_types): """Initialize.""" + from stringcase import camelcase + self._event_types = {camelcase(evt): evt for evt in event_types} self._custom_attributes = custom_attributes self._scan_interval = scan_interval self._async_see = async_see @@ -89,6 +132,12 @@ async def _async_update(self, now=None): """Update info from Traccar.""" _LOGGER.debug('Updating device data.') await self._api.get_device_info(self._custom_attributes) + self._hass.async_create_task(self.import_device_data()) + if self._event_types: + self._hass.async_create_task(self.import_events()) + + async def import_device_data(self): + """Import device data from Traccar.""" for devicename in self._api.device_info: device = self._api.device_info[devicename] attr = {} @@ -105,6 +154,8 @@ async def _async_update(self, now=None): attr[ATTR_BATTERY_LEVEL] = device['battery'] if device.get('motion') is not None: attr[ATTR_MOTION] = device['motion'] + if device.get('traccar_id') is not None: + attr[ATTR_TRACCAR_ID] = device['traccar_id'] for custom_attr in self._custom_attributes: if device.get(custom_attr) is not None: attr[custom_attr] = device[custom_attr] @@ -112,3 +163,27 @@ async def _async_update(self, now=None): dev_id=slugify(device['device_id']), gps=(device.get('latitude'), device.get('longitude')), attributes=attr) + + async def import_events(self): + """Import events from Traccar.""" + device_ids = [device['id'] for device in self._api.devices] + end_interval = datetime.utcnow() + start_interval = end_interval - self._scan_interval + events = await self._api.get_events( + device_ids=device_ids, + from_time=start_interval, + to_time=end_interval, + event_types=self._event_types.keys()) + if events is not None: + for event in events: + device_name = next(( + 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"]), { + 'device_traccar_id': event['deviceId'], + 'device_name': device_name, + 'type': event['type'], + 'serverTime': event['serverTime'], + 'attributes': event['attributes'] + }) diff --git a/requirements_all.txt b/requirements_all.txt index 020ed3248a5a52..7fd0c906ed7fd7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1433,7 +1433,7 @@ pytile==2.0.6 pytouchline==0.7 # homeassistant.components.traccar.device_tracker -pytraccar==0.3.0 +pytraccar==0.5.0 # homeassistant.components.trackr.device_tracker pytrackr==0.0.5 @@ -1653,6 +1653,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.thermoworks_smoke.sensor +# homeassistant.components.traccar.device_tracker stringcase==1.2.0 # homeassistant.components.ecovacs From 6a74c403c0ee6f9252ccfa434b29bcf2761c647d Mon Sep 17 00:00:00 2001 From: zewelor Date: Mon, 25 Mar 2019 19:06:43 +0100 Subject: [PATCH 34/69] Update python yeelight and add nightlight mode sensor (#22345) --- homeassistant/components/yeelight/__init__.py | 29 +++++----- .../components/yeelight/binary_sensor.py | 57 +++++++++++++++++++ requirements_all.txt | 2 +- 3 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/yeelight/binary_sensor.py diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index ed4e704a6a51be..8a5c1e81a93fcc 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -8,13 +8,15 @@ from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_SCAN_INTERVAL, \ CONF_HOST, ATTR_ENTITY_ID from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.binary_sensor import DOMAIN as \ + BINARY_SENSOR_DOMAIN from homeassistant.helpers import discovery from homeassistant.helpers.discovery import load_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_time_interval -REQUIREMENTS = ['yeelight==0.4.3'] +REQUIREMENTS = ['yeelight==0.4.4'] _LOGGER = logging.getLogger(__name__) @@ -40,9 +42,6 @@ ACTION_STAY = 'stay' ACTION_OFF = 'off' -MODE_MOONLIGHT = 'moonlight' -MODE_DAYLIGHT = 'normal' - SCAN_INTERVAL = timedelta(seconds=30) YEELIGHT_RGB_TRANSITION = 'RGBTransition' @@ -90,11 +89,6 @@ vol.Required(ATTR_ENTITY_ID): cv.entity_ids, }) -NIGHTLIGHT_SUPPORTED_MODELS = [ - "ceiling3", - 'ceiling4' -] - UPDATE_REQUEST_PROPERTIES = [ "power", "bright", @@ -103,8 +97,7 @@ "hue", "sat", "color_mode", - "flowing", - "music_on", + "bg_power", "nl_br", "active_mode", ] @@ -195,6 +188,8 @@ def _setup_device(hass, hass_config, ipaddr, device_config): ) load_platform(hass, LIGHT_DOMAIN, DOMAIN, platform_config, hass_config) + load_platform(hass, BINARY_SENSOR_DOMAIN, DOMAIN, platform_config, + hass_config) class YeelightDevice: @@ -218,7 +213,7 @@ def bulb(self): self._bulb_device = yeelight.Bulb(self._ipaddr, model=self._model) # force init for type - self._update_properties() + self.update() except yeelight.BulbException as ex: _LOGGER.error("Failed to connect to bulb %s, %s: %s", @@ -226,9 +221,6 @@ def bulb(self): return self._bulb_device - def _update_properties(self): - self._bulb_device.get_properties(UPDATE_REQUEST_PROPERTIES) - @property def name(self): """Return the name of the device if any.""" @@ -252,6 +244,11 @@ def is_nightlight_enabled(self) -> bool: return self.bulb.last_properties.get('active_mode') == '1' + @property + def is_nightlight_supported(self) -> bool: + """Return true / false if nightlight is supported.""" + return self.bulb.get_model_specs().get('night_light', False) + def turn_on(self, duration=DEFAULT_TRANSITION): """Turn on device.""" import yeelight @@ -281,5 +278,5 @@ def update(self): if not self.bulb: return - self._update_properties() + self._bulb_device.get_properties(UPDATE_REQUEST_PROPERTIES) dispatcher_send(self._hass, DATA_UPDATED, self._ipaddr) diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py new file mode 100644 index 00000000000000..cf7bbc5244ecc0 --- /dev/null +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -0,0 +1,57 @@ +"""Sensor platform support for yeelight.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.yeelight import DATA_YEELIGHT, DATA_UPDATED + +DEPENDENCIES = ['yeelight'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Yeelight sensors.""" + if not discovery_info: + return + + device = hass.data[DATA_YEELIGHT][discovery_info['host']] + + if device.is_nightlight_supported: + _LOGGER.debug("Adding nightlight mode sensor for %s", device.name) + add_entities([YeelightNightlightModeSensor(device)]) + + +class YeelightNightlightModeSensor(BinarySensorDevice): + """Representation of a Yeelight nightlight mode sensor.""" + + def __init__(self, device): + """Initialize nightlight mode sensor.""" + self._device = device + + @callback + def _schedule_immediate_update(self, ipaddr): + if ipaddr == self._device.ipaddr: + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the sensor.""" + return "{} nightlight".format(self._device.name) + + @property + def is_on(self): + """Return true if nightlight mode is on.""" + return self._device.is_nightlight_enabled diff --git a/requirements_all.txt b/requirements_all.txt index 7fd0c906ed7fd7..f68146903098b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1821,7 +1821,7 @@ yahooweather==0.10 yalesmartalarmclient==0.1.6 # homeassistant.components.yeelight -yeelight==0.4.3 +yeelight==0.4.4 # homeassistant.components.yeelightsunflower.light yeelightsunflower==0.0.10 From 6ffe9ad4736653507ed777a4de142cae29d36f04 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Mon, 25 Mar 2019 19:37:31 +0100 Subject: [PATCH 35/69] updated pydaikin (#22382) --- homeassistant/components/daikin/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 04cf8a584bf8cf..c757185a5cffe4 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -17,7 +17,7 @@ from . import config_flow # noqa pylint_disable=unused-import from .const import KEY_HOST -REQUIREMENTS = ['pydaikin==1.1.0'] +REQUIREMENTS = ['pydaikin==1.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f68146903098b8..1b954257e54d81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -998,7 +998,7 @@ pycsspeechtts==1.0.2 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==1.1.0 +pydaikin==1.2.0 # homeassistant.components.danfoss_air pydanfossair==0.0.7 From 5ad3e75a4d4cb7a4100d9d718c5dea9c55797735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Maggioni?= Date: Mon, 25 Mar 2019 20:45:13 +0100 Subject: [PATCH 36/69] Support for Plex sensor with enforced SSL (#21432) --- homeassistant/components/plex/sensor.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 46766d75010b18..eaf73ceb566ff9 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import PLATFORM_SCHEMA from homeassistant.const import ( CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT, CONF_TOKEN, - CONF_SSL) + CONF_SSL, CONF_VERIFY_SSL) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -26,6 +26,7 @@ DEFAULT_NAME = 'Plex' DEFAULT_PORT = 32400 DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) @@ -38,6 +39,7 @@ vol.Optional(CONF_SERVER): cv.string, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, }) @@ -59,7 +61,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: add_entities([PlexSensor( name, plex_url, plex_user, plex_password, plex_server, - plex_token)], True) + plex_token, config.get(CONF_VERIFY_SSL))], True) except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized, plexapi.exceptions.NotFound) as error: _LOGGER.error(error) @@ -70,23 +72,30 @@ class PlexSensor(Entity): """Representation of a Plex now playing sensor.""" def __init__(self, name, plex_url, plex_user, plex_password, - plex_server, plex_token): + plex_server, plex_token, verify_ssl): """Initialize the sensor.""" from plexapi.myplex import MyPlexAccount from plexapi.server import PlexServer + from requests import Session self._name = name self._state = 0 self._now_playing = [] + cert_session = None + if not verify_ssl: + _LOGGER.info("Ignoring SSL verification") + cert_session = Session() + cert_session.verify = False + if plex_token: - self._server = PlexServer(plex_url, plex_token) + self._server = PlexServer(plex_url, plex_token, cert_session) elif plex_user and plex_password: user = MyPlexAccount(plex_user, plex_password) server = plex_server if plex_server else user.resources()[0].name self._server = user.resource(server).connect() else: - self._server = PlexServer(plex_url) + self._server = PlexServer(plex_url, None, cert_session) @property def name(self): From 42c27e5b720862a861440cd171728cab7e208482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9-Marc=20Simard?= Date: Tue, 26 Mar 2019 00:51:49 -0400 Subject: [PATCH 37/69] Search GTFS departures across midnight (#20992) --- homeassistant/components/gtfs/sensor.py | 215 ++++++++++++++++++------ 1 file changed, 164 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 25b352c14545df..8eb5c623725afa 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -8,6 +8,7 @@ import logging import datetime import threading +from typing import Optional import voluptuous as vol @@ -25,6 +26,7 @@ CONF_DESTINATION = 'destination' CONF_ORIGIN = 'origin' CONF_OFFSET = 'offset' +CONF_TOMORROW = 'include_tomorrow' DEFAULT_NAME = 'GTFS Sensor' DEFAULT_PATH = 'gtfs' @@ -47,65 +49,162 @@ vol.Required(CONF_DATA): cv.string, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_OFFSET, default=0): cv.time_period, + vol.Optional(CONF_TOMORROW, default=False): cv.boolean, }) -def get_next_departure(sched, start_station_id, end_station_id, offset): +def get_next_departure(sched, start_station_id, end_station_id, offset, + include_tomorrow=False) -> Optional[dict]: """Get the next departure for the given schedule.""" origin_station = sched.stops_by_id(start_station_id)[0] destination_station = sched.stops_by_id(end_station_id)[0] now = datetime.datetime.now() + offset - day_name = now.strftime('%A').lower() - now_str = now.strftime('%H:%M:%S') - today = now.strftime(dt_util.DATE_STR_FORMAT) + now_date = now.strftime(dt_util.DATE_STR_FORMAT) + yesterday = now - datetime.timedelta(days=1) + yesterday_date = yesterday.strftime(dt_util.DATE_STR_FORMAT) + tomorrow = now + datetime.timedelta(days=1) + tomorrow_date = tomorrow.strftime(dt_util.DATE_STR_FORMAT) from sqlalchemy.sql import text - sql_query = text(""" - SELECT trip.trip_id, trip.route_id, - time(origin_stop_time.arrival_time) AS origin_arrival_time, - time(origin_stop_time.departure_time) AS origin_depart_time, - origin_stop_time.drop_off_type AS origin_drop_off_type, - origin_stop_time.pickup_type AS origin_pickup_type, - origin_stop_time.shape_dist_traveled AS origin_dist_traveled, - origin_stop_time.stop_headsign AS origin_stop_headsign, - origin_stop_time.stop_sequence AS origin_stop_sequence, - time(destination_stop_time.arrival_time) AS dest_arrival_time, - time(destination_stop_time.departure_time) AS dest_depart_time, - destination_stop_time.drop_off_type AS dest_drop_off_type, - destination_stop_time.pickup_type AS dest_pickup_type, - destination_stop_time.shape_dist_traveled AS dest_dist_traveled, - destination_stop_time.stop_headsign AS dest_stop_headsign, - destination_stop_time.stop_sequence AS dest_stop_sequence - FROM trips trip - INNER JOIN calendar calendar - ON trip.service_id = calendar.service_id - INNER JOIN stop_times origin_stop_time - ON trip.trip_id = origin_stop_time.trip_id - INNER JOIN stops start_station - ON origin_stop_time.stop_id = start_station.stop_id - INNER JOIN stop_times destination_stop_time - ON trip.trip_id = destination_stop_time.trip_id - INNER JOIN stops end_station - ON destination_stop_time.stop_id = end_station.stop_id - WHERE calendar.{day_name} = 1 - AND origin_depart_time > time(:now_str) - AND start_station.stop_id = :origin_station_id - AND end_station.stop_id = :end_station_id - AND origin_stop_sequence < dest_stop_sequence - AND calendar.start_date <= :today - AND calendar.end_date >= :today - ORDER BY origin_stop_time.departure_time - LIMIT 1 - """.format(day_name=day_name)) - result = sched.engine.execute(sql_query, now_str=now_str, + # Fetch all departures for yesterday, today and optionally tomorrow, + # up to an overkill maximum in case of a departure every minute for those + # days. + limit = 24 * 60 * 60 * 2 + tomorrow_select = tomorrow_where = tomorrow_order = '' + if include_tomorrow: + limit = limit / 2 * 3 + tomorrow_name = tomorrow.strftime('%A').lower() + tomorrow_select = "calendar.{} AS tomorrow,".format(tomorrow_name) + tomorrow_where = "OR calendar.{} = 1".format(tomorrow_name) + tomorrow_order = "calendar.{} DESC,".format(tomorrow_name) + + sql_query = """ + SELECT trip.trip_id, trip.route_id, + time(origin_stop_time.arrival_time) AS origin_arrival_time, + time(origin_stop_time.departure_time) AS origin_depart_time, + date(origin_stop_time.departure_time) AS origin_departure_date, + origin_stop_time.drop_off_type AS origin_drop_off_type, + origin_stop_time.pickup_type AS origin_pickup_type, + origin_stop_time.shape_dist_traveled AS origin_dist_traveled, + origin_stop_time.stop_headsign AS origin_stop_headsign, + origin_stop_time.stop_sequence AS origin_stop_sequence, + time(destination_stop_time.arrival_time) AS dest_arrival_time, + time(destination_stop_time.departure_time) AS dest_depart_time, + destination_stop_time.drop_off_type AS dest_drop_off_type, + destination_stop_time.pickup_type AS dest_pickup_type, + destination_stop_time.shape_dist_traveled AS dest_dist_traveled, + destination_stop_time.stop_headsign AS dest_stop_headsign, + destination_stop_time.stop_sequence AS dest_stop_sequence, + calendar.{yesterday_name} AS yesterday, + calendar.{today_name} AS today, + {tomorrow_select} + calendar.start_date AS start_date, + calendar.end_date AS end_date + FROM trips trip + INNER JOIN calendar calendar + ON trip.service_id = calendar.service_id + INNER JOIN stop_times origin_stop_time + ON trip.trip_id = origin_stop_time.trip_id + INNER JOIN stops start_station + ON origin_stop_time.stop_id = start_station.stop_id + INNER JOIN stop_times destination_stop_time + ON trip.trip_id = destination_stop_time.trip_id + INNER JOIN stops end_station + ON destination_stop_time.stop_id = end_station.stop_id + WHERE (calendar.{yesterday_name} = 1 + OR calendar.{today_name} = 1 + {tomorrow_where} + ) + AND start_station.stop_id = :origin_station_id + AND end_station.stop_id = :end_station_id + AND origin_stop_sequence < dest_stop_sequence + AND calendar.start_date <= :today + AND calendar.end_date >= :today + ORDER BY calendar.{yesterday_name} DESC, + calendar.{today_name} DESC, + {tomorrow_order} + origin_stop_time.departure_time + LIMIT :limit + """.format(yesterday_name=yesterday.strftime('%A').lower(), + today_name=now.strftime('%A').lower(), + tomorrow_select=tomorrow_select, + tomorrow_where=tomorrow_where, + tomorrow_order=tomorrow_order) + result = sched.engine.execute(text(sql_query), origin_station_id=origin_station.id, end_station_id=destination_station.id, - today=today) - item = {} + today=now_date, + limit=limit) + + # Create lookup timetable for today and possibly tomorrow, taking into + # account any departures from yesterday scheduled after midnight, + # as long as all departures are within the calendar date range. + timetable = {} + yesterday_start = today_start = tomorrow_start = None + yesterday_last = today_last = None for row in result: - item = row + if row['yesterday'] == 1 and yesterday_date >= row['start_date']: + extras = { + 'day': 'yesterday', + 'first': None, + 'last': False, + } + if yesterday_start is None: + yesterday_start = row['origin_departure_date'] + if yesterday_start != row['origin_departure_date']: + idx = '{} {}'.format(now_date, + row['origin_depart_time']) + timetable[idx] = {**row, **extras} + yesterday_last = idx + + if row['today'] == 1: + extras = { + 'day': 'today', + 'first': False, + 'last': False, + } + if today_start is None: + today_start = row['origin_departure_date'] + extras['first'] = True + if today_start == row['origin_departure_date']: + idx_prefix = now_date + else: + idx_prefix = tomorrow_date + idx = '{} {}'.format(idx_prefix, row['origin_depart_time']) + timetable[idx] = {**row, **extras} + today_last = idx + + if 'tomorrow' in row and row['tomorrow'] == 1 and tomorrow_date <= \ + row['end_date']: + extras = { + 'day': 'tomorrow', + 'first': False, + 'last': None, + } + if tomorrow_start is None: + tomorrow_start = row['origin_departure_date'] + extras['first'] = True + if tomorrow_start == row['origin_departure_date']: + idx = '{} {}'.format(tomorrow_date, + row['origin_depart_time']) + timetable[idx] = {**row, **extras} + + # Flag last departures. + for idx in [yesterday_last, today_last]: + if idx is not None: + timetable[idx]['last'] = True + + _LOGGER.debug("Timetable: %s", sorted(timetable.keys())) + + item = {} + for key in sorted(timetable.keys()): + if dt_util.parse_datetime(key) > now: + item = timetable[key] + _LOGGER.debug("Departure found for station %s @ %s -> %s", + start_station_id, key, item) + break if item == {}: return None @@ -119,7 +218,7 @@ def get_next_departure(sched, start_station_id, end_station_id, offset): origin_arrival.strftime(dt_util.DATE_STR_FORMAT), item['origin_arrival_time']) - origin_depart_time = '{} {}'.format(today, item['origin_depart_time']) + origin_depart_time = '{} {}'.format(now_date, item['origin_depart_time']) dest_arrival = now if item['dest_arrival_time'] < item['origin_depart_time']: @@ -162,6 +261,9 @@ def get_next_departure(sched, start_station_id, end_station_id, offset): return { 'trip_id': item['trip_id'], + 'day': item['day'], + 'first': item['first'], + 'last': item['last'], 'trip': sched.trips_by_id(item['trip_id'])[0], 'route': route, 'agency': sched.agencies_by_id(route.agency_id)[0], @@ -182,6 +284,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): destination = config.get(CONF_DESTINATION) name = config.get(CONF_NAME) offset = config.get(CONF_OFFSET) + include_tomorrow = config.get(CONF_TOMORROW) if not os.path.exists(gtfs_dir): os.makedirs(gtfs_dir) @@ -203,17 +306,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None): pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data)) add_entities([ - GTFSDepartureSensor(gtfs, name, origin, destination, offset)]) + GTFSDepartureSensor(gtfs, name, origin, destination, offset, + include_tomorrow)]) class GTFSDepartureSensor(Entity): """Implementation of an GTFS departures sensor.""" - def __init__(self, pygtfs, name, origin, destination, offset): + def __init__(self, pygtfs, name, origin, destination, offset, + include_tomorrow) -> None: """Initialize the sensor.""" self._pygtfs = pygtfs self.origin = origin self.destination = destination + self._include_tomorrow = include_tomorrow self._offset = offset self._custom_name = name self._icon = ICON @@ -252,10 +358,13 @@ def update(self): """Get the latest data from GTFS and update the states.""" with self.lock: self._departure = get_next_departure( - self._pygtfs, self.origin, self.destination, self._offset) + self._pygtfs, self.origin, self.destination, self._offset, + self._include_tomorrow) if not self._departure: self._state = None - self._attributes = {'Info': 'No more departures today'} + self._attributes = {} + self._attributes['Info'] = "No more departures" if \ + self._include_tomorrow else "No more departures today" if self._name == '': self._name = (self._custom_name or DEFAULT_NAME) return @@ -284,8 +393,12 @@ def update(self): self._icon = ICONS.get(route.route_type, ICON) # Build attributes - self._attributes = {} self._attributes['arrival'] = arrival_time + self._attributes['day'] = self._departure['day'] + if self._departure['first'] is not None: + self._attributes['first'] = self._departure['first'] + if self._departure['last'] is not None: + self._attributes['last'] = self._departure['last'] self._attributes['offset'] = self._offset.seconds / 60 def dict_for_table(resource): From 0c4380a78d85be42a455afc444bf889308e41565 Mon Sep 17 00:00:00 2001 From: uchagani Date: Tue, 26 Mar 2019 01:36:39 -0400 Subject: [PATCH 38/69] remove config sections from hass.config.components (#22370) * remove config sections from hass.config.components * fix tests --- homeassistant/components/config/__init__.py | 1 - tests/components/config/test_init.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 0366dfa2b8bfeb..7807c52737091e 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -46,7 +46,6 @@ async def setup_panel(panel_name): if success: key = '{}.{}'.format(DOMAIN, panel_name) hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: key}) - hass.config.components.add(key) @callback def component_loaded(event): diff --git a/tests/components/config/test_init.py b/tests/components/config/test_init.py index 57ea7e7a492a40..41a0fb089b56d4 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -29,7 +29,6 @@ def test_load_on_demand_already_loaded(hass, aiohttp_client): yield from async_setup_component(hass, 'config', {}) yield from hass.async_block_till_done() - assert 'config.zwave' in hass.config.components assert stp.called @@ -47,5 +46,4 @@ def test_load_on_demand_on_load(hass, aiohttp_client): hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: 'zwave'}) yield from hass.async_block_till_done() - assert 'config.zwave' in hass.config.components assert stp.called From 8aef8c6bb425b56c21a7d81c1a54810674f86551 Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Mon, 25 Mar 2019 23:37:59 -0700 Subject: [PATCH 39/69] Update ring_doorbell to 0.2.3 (#22395) --- homeassistant/components/ring/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 94f3be305fa699..74da7a9d542909 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -7,7 +7,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['ring_doorbell==0.2.2'] +REQUIREMENTS = ['ring_doorbell==0.2.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 1b954257e54d81..ff1a18d9f6d9f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1520,7 +1520,7 @@ rfk101py==0.0.1 rflink==0.0.37 # homeassistant.components.ring -ring_doorbell==0.2.2 +ring_doorbell==0.2.3 # homeassistant.components.ritassist.device_tracker ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bd50b713e2fc8..8f88e5bb67b94d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -276,7 +276,7 @@ restrictedpython==4.0b8 rflink==0.0.37 # homeassistant.components.ring -ring_doorbell==0.2.2 +ring_doorbell==0.2.3 # homeassistant.components.yamaha.media_player rxv==0.6.0 From b2ba9d07ca052bb142adfaefb052b67e74b7c5c0 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 26 Mar 2019 06:40:28 +0000 Subject: [PATCH 40/69] Fix unavailable state for homekit locks and covers (#22390) --- homeassistant/components/homekit_controller/cover.py | 10 ---------- homeassistant/components/homekit_controller/lock.py | 5 ----- 2 files changed, 15 deletions(-) diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 4db1246b992ffe..7a4fa486ff9dff 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -82,11 +82,6 @@ def _update_door_state_current(self, value): def _update_obstruction_detected(self, value): self._obstruction_detected = value - @property - def available(self): - """Return True if entity is available.""" - return self._state is not None - @property def supported_features(self): """Flag supported features.""" @@ -146,11 +141,6 @@ def __init__(self, accessory, discovery_info): self._obstruction_detected = None self.lock_state = None - @property - def available(self): - """Return True if entity is available.""" - return self._state is not None - def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" # pylint: disable=import-error diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index b084d7525d3b48..ac1bd8f88dacd0 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -64,11 +64,6 @@ def is_locked(self): """Return true if device is locked.""" return self._state == STATE_LOCKED - @property - def available(self): - """Return True if entity is available.""" - return self._state is not None - async def async_lock(self, **kwargs): """Lock the device.""" await self._set_lock_state(STATE_LOCKED) From 73b38572f0231ded1cb59e0d8cb7f4953bbf3e2e Mon Sep 17 00:00:00 2001 From: Nick Whyte Date: Tue, 26 Mar 2019 17:43:24 +1100 Subject: [PATCH 41/69] Add infer_arming_state option to ness alarm (#22379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add infer_arming_state option to ness alarm * actually use config value * 🤦‍♂️ --- homeassistant/components/ness_alarm/__init__.py | 11 +++++++++-- requirements_all.txt | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py index 653ade806ecd97..4e8c8293c2d25a 100644 --- a/homeassistant/components/ness_alarm/__init__.py +++ b/homeassistant/components/ness_alarm/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['nessclient==0.9.14'] +REQUIREMENTS = ['nessclient==0.9.15'] _LOGGER = logging.getLogger(__name__) @@ -22,6 +22,7 @@ CONF_DEVICE_HOST = 'host' CONF_DEVICE_PORT = 'port' +CONF_INFER_ARMING_STATE = 'infer_arming_state' CONF_ZONES = 'zones' CONF_ZONE_NAME = 'name' CONF_ZONE_TYPE = 'type' @@ -29,6 +30,7 @@ ATTR_OUTPUT_ID = 'output_id' DEFAULT_ZONES = [] DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=1) +DEFAULT_INFER_ARMING_STATE = False SIGNAL_ZONE_CHANGED = 'ness_alarm.zone_changed' SIGNAL_ARMING_STATE_CHANGED = 'ness_alarm.arming_state_changed' @@ -50,6 +52,9 @@ vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_ZONES, default=DEFAULT_ZONES): vol.All(cv.ensure_list, [ZONE_SCHEMA]), + vol.Optional(CONF_INFER_ARMING_STATE, + default=DEFAULT_INFER_ARMING_STATE): + cv.boolean }), }, extra=vol.ALLOW_EXTRA) @@ -74,9 +79,11 @@ async def async_setup(hass, config): host = conf[CONF_DEVICE_HOST] port = conf[CONF_DEVICE_PORT] scan_interval = conf[CONF_SCAN_INTERVAL] + infer_arming_state = conf[CONF_INFER_ARMING_STATE] client = Client(host=host, port=port, loop=hass.loop, - update_interval=scan_interval.total_seconds()) + update_interval=scan_interval.total_seconds(), + infer_arming_state=infer_arming_state) hass.data[DATA_NESS] = client async def _close(event): diff --git a/requirements_all.txt b/requirements_all.txt index ff1a18d9f6d9f3..15db65c160833e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -735,7 +735,7 @@ nad_receiver==0.0.11 ndms2_client==0.0.6 # homeassistant.components.ness_alarm -nessclient==0.9.14 +nessclient==0.9.15 # homeassistant.components.netdata.sensor netdata==0.1.2 From 79445a7ccc82b994f05a11f14c38ca32badcbbaa Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 26 Mar 2019 07:43:58 +0100 Subject: [PATCH 42/69] deCONZ support Xiaomi vibration sensor (#22366) * Martin pointed out in previous PR that no ending '.' in logging * Add support for Xiaomi vibration sensor --- homeassistant/components/deconz/__init__.py | 2 +- homeassistant/components/deconz/binary_sensor.py | 10 +++++++++- homeassistant/components/deconz/gateway.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 957bb5691108aa..8bdd946e2ef898 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -12,7 +12,7 @@ from .const import DEFAULT_PORT, DOMAIN, _LOGGER from .gateway import DeconzGateway -REQUIREMENTS = ['pydeconz==53'] +REQUIREMENTS = ['pydeconz==54'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index cb68b842f4af1e..2b0c2037248042 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -11,6 +11,10 @@ DEPENDENCIES = ['deconz'] +ATTR_ORIENTATION = 'orientation' +ATTR_TILTANGLE = 'tiltangle' +ATTR_VIBRATIONSTRENGTH = 'vibrationstrength' + async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): @@ -74,7 +78,7 @@ def icon(self): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - from pydeconz.sensor import PRESENCE + from pydeconz.sensor import PRESENCE, VIBRATION attr = {} if self._device.battery: attr[ATTR_BATTERY_LEVEL] = self._device.battery @@ -82,4 +86,8 @@ def device_state_attributes(self): attr[ATTR_ON] = self._device.on if self._device.type in PRESENCE and self._device.dark is not None: attr[ATTR_DARK] = self._device.dark + elif self._device.type in VIBRATION: + attr[ATTR_ORIENTATION] = self._device.orientation + attr[ATTR_TILTANGLE] = self._device.tiltangle + attr[ATTR_VIBRATIONSTRENGTH] = self._device.vibrationstrength return attr diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 6629d4eec14557..11fb247a6f456e 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -44,7 +44,7 @@ async def async_setup(self): raise ConfigEntryNotReady except Exception: # pylint: disable=broad-except - _LOGGER.error('Error connecting with deCONZ gateway.') + _LOGGER.error('Error connecting with deCONZ gateway') return False for component in SUPPORTED_PLATFORMS: @@ -135,7 +135,7 @@ async def get_gateway(hass, config, async_add_device_callback, return deconz except errors.Unauthorized: - _LOGGER.warning("Invalid key for deCONZ at %s.", config[CONF_HOST]) + _LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST]) raise AuthenticationRequired except (asyncio.TimeoutError, errors.RequestError): diff --git a/requirements_all.txt b/requirements_all.txt index 15db65c160833e..401d22cd551371 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1004,7 +1004,7 @@ pydaikin==1.2.0 pydanfossair==0.0.7 # homeassistant.components.deconz -pydeconz==53 +pydeconz==54 # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f88e5bb67b94d..17c993bf5f7312 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -203,7 +203,7 @@ pyHS100==0.3.4 pyblackbird==0.5 # homeassistant.components.deconz -pydeconz==53 +pydeconz==54 # homeassistant.components.zwave pydispatcher==2.0.5 From 6fa8fdf5558d21dc5ca7e718d141c6549f5e1a97 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Tue, 26 Mar 2019 07:46:00 +0100 Subject: [PATCH 43/69] Fix data_key of the xiaomi_aqara cover for LAN protocol v2 (#22358) --- homeassistant/components/xiaomi_aqara/cover.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index f4bf1f269b5cfc..cd9190dca351f6 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -17,8 +17,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for device in gateway.devices['cover']: model = device['model'] if model == 'curtain': + if 'proto' not in device or int(device['proto'][0:1]) == 1: + data_key = 'status' + else: + data_key = 'curtain_status' devices.append(XiaomiGenericCover(device, "Curtain", - 'status', gateway)) + data_key, gateway)) add_entities(devices) From a62c1169592526f09e32248ccc45c3eeffc78ef5 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 26 Mar 2019 06:49:51 +0000 Subject: [PATCH 44/69] Remove get_serial helper that is no longer needed. (#22368) --- .../components/homekit_controller/__init__.py | 38 ++++++------------- .../homekit_controller/alarm_control_panel.py | 4 +- .../homekit_controller/binary_sensor.py | 4 +- .../components/homekit_controller/climate.py | 4 +- .../components/homekit_controller/const.py | 1 - .../components/homekit_controller/cover.py | 4 +- .../components/homekit_controller/light.py | 4 +- .../components/homekit_controller/lock.py | 4 +- .../components/homekit_controller/sensor.py | 4 +- .../components/homekit_controller/switch.py | 4 +- 10 files changed, 27 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 0cb9ecbfc0789b..5e470e1540baa6 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -11,8 +11,7 @@ from .connection import get_accessory_information from .const import ( - CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, KNOWN_ACCESSORIES, - KNOWN_DEVICES + CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, KNOWN_DEVICES ) @@ -33,25 +32,6 @@ PAIRING_FILE = "pairing.json" -def get_serial(accessory): - """Obtain the serial number of a HomeKit device.""" - # pylint: disable=import-error - from homekit.model.services import ServicesTypes - from homekit.model.characteristics import CharacteristicsTypes - - for service in accessory['services']: - if ServicesTypes.get_short(service['type']) != \ - 'accessory-information': - continue - for characteristic in service['characteristics']: - ctype = CharacteristicsTypes.get_short( - characteristic['type']) - if ctype != 'serial-number': - continue - return characteristic['value'] - return None - - def escape_characteristic_name(char_name): """Escape any dash or dots in a characteristics name.""" return char_name.replace('-', '_').replace('.', '_') @@ -75,6 +55,10 @@ def __init__(self, hass, host, port, model, hkid, config_num, config): self.configurator = hass.components.configurator self._connection_warning_logged = False + # This just tracks aid/iid pairs so we know if a HK service has been + # mapped to a HA entity. + self.entities = [] + self.pairing_lock = asyncio.Lock(loop=hass.loop) self.pairing = self.controller.pairings.get(hkid) @@ -100,15 +84,16 @@ def accessory_setup(self): self.hass, RETRY_INTERVAL, lambda _: self.accessory_setup()) return for accessory in data: - serial = get_serial(accessory) - if serial in self.hass.data[KNOWN_ACCESSORIES]: - continue - self.hass.data[KNOWN_ACCESSORIES][serial] = self aid = accessory['aid'] for service in accessory['services']: + iid = service['iid'] + if (aid, iid) in self.entities: + # Don't add the same entity again + continue + devtype = ServicesTypes.get_short(service['type']) _LOGGER.debug("Found %s", devtype) - service_info = {'serial': serial, + service_info = {'serial': self.hkid, 'aid': aid, 'iid': service['iid'], 'model': self.model, @@ -381,7 +366,6 @@ def discovery_dispatch(service, discovery_info): device = HKDevice(hass, host, port, model, hkid, config_num, config) hass.data[KNOWN_DEVICES][hkid] = device - hass.data[KNOWN_ACCESSORIES] = {} hass.data[KNOWN_DEVICES] = {} discovery.listen(hass, SERVICE_HOMEKIT, discovery_dispatch) return True diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index 9bc15aad75dcb0..f9bc25f4237e0c 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -6,7 +6,7 @@ ATTR_BATTERY_LEVEL, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) -from . import KNOWN_ACCESSORIES, HomeKitEntity +from . import KNOWN_DEVICES, HomeKitEntity DEPENDENCIES = ['homekit_controller'] @@ -34,7 +34,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Homekit Alarm Control Panel support.""" if discovery_info is None: return - accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] add_entities([HomeKitAlarmControlPanel(accessory, discovery_info)], True) diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index 7fcc5b4e833231..2bd03b18932146 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -3,7 +3,7 @@ from homeassistant.components.binary_sensor import BinarySensorDevice -from . import KNOWN_ACCESSORIES, HomeKitEntity +from . import KNOWN_DEVICES, HomeKitEntity DEPENDENCIES = ['homekit_controller'] @@ -13,7 +13,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Homekit motion sensor support.""" if discovery_info is not None: - accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] add_entities([HomeKitMotionSensor(accessory, discovery_info)], True) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 243b795e792fd9..67f1fb72bcfbb5 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -7,7 +7,7 @@ SUPPORT_TARGET_TEMPERATURE) from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS -from . import KNOWN_ACCESSORIES, HomeKitEntity +from . import KNOWN_DEVICES, HomeKitEntity DEPENDENCIES = ['homekit_controller'] @@ -29,7 +29,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Homekit climate.""" if discovery_info is not None: - accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] add_entities([HomeKitClimateDevice(accessory, discovery_info)], True) diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 873f6b343d21f8..90a105b0ad9c41 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -1,7 +1,6 @@ """Constants for the homekit_controller component.""" DOMAIN = 'homekit_controller' -KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) KNOWN_DEVICES = "{}-devices".format(DOMAIN) CONTROLLER = "{}-controller".format(DOMAIN) diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 7a4fa486ff9dff..26b7613ed2b6d5 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -8,7 +8,7 @@ from homeassistant.const import ( STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING) -from . import KNOWN_ACCESSORIES, HomeKitEntity +from . import KNOWN_DEVICES, HomeKitEntity STATE_STOPPED = 'stopped' @@ -41,7 +41,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up HomeKit Cover support.""" if discovery_info is None: return - accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] if discovery_info['device-type'] == 'garage-door-opener': add_entities([HomeKitGarageDoorCover(accessory, discovery_info)], diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index db8fd332c0cf97..cb9259df4a992d 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -5,7 +5,7 @@ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light) -from . import KNOWN_ACCESSORIES, HomeKitEntity +from . import KNOWN_DEVICES, HomeKitEntity DEPENDENCIES = ['homekit_controller'] @@ -15,7 +15,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Homekit lighting.""" if discovery_info is not None: - accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] add_entities([HomeKitLight(accessory, discovery_info)], True) diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index ac1bd8f88dacd0..0d0275fda164e7 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -5,7 +5,7 @@ from homeassistant.const import ( ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED) -from . import KNOWN_ACCESSORIES, HomeKitEntity +from . import KNOWN_DEVICES, HomeKitEntity DEPENDENCIES = ['homekit_controller'] @@ -30,7 +30,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Homekit Lock support.""" if discovery_info is None: return - accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] add_entities([HomeKitLock(accessory, discovery_info)], True) diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 955a1a7927e0e5..8cbc8f248bafe0 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -1,7 +1,7 @@ """Support for Homekit sensors.""" from homeassistant.const import TEMP_CELSIUS -from . import KNOWN_ACCESSORIES, HomeKitEntity +from . import KNOWN_DEVICES, HomeKitEntity DEPENDENCIES = ['homekit_controller'] @@ -16,7 +16,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Homekit sensor support.""" if discovery_info is not None: - accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] devtype = discovery_info['device-type'] if devtype == 'humidity': diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index ba19413d4115c6..34e83c06526758 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -3,7 +3,7 @@ from homeassistant.components.switch import SwitchDevice -from . import KNOWN_ACCESSORIES, HomeKitEntity +from . import KNOWN_DEVICES, HomeKitEntity DEPENDENCIES = ['homekit_controller'] @@ -15,7 +15,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Homekit switch support.""" if discovery_info is not None: - accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] add_entities([HomeKitSwitch(accessory, discovery_info)], True) From e85b089effbf584af3ca10eb118dd613f8509f27 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Mon, 25 Mar 2019 23:53:36 -0700 Subject: [PATCH 45/69] Set default parallel_update value should base on async_update (#22149) * Set default parallel_update value should base on async_update * Set default parallel_update value should base on async_update * Delay the parallel_update_semaphore creation * Remove outdated comment --- homeassistant/helpers/entity_platform.py | 43 +++-- tests/helpers/test_entity.py | 226 +++++++++++++++-------- tests/helpers/test_entity_platform.py | 98 +++++++--- 3 files changed, 244 insertions(+), 123 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 87cc4d4fd90edf..a092c89405e742 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -27,7 +27,6 @@ def __init__(self, *, hass, logger, domain, platform_name, platform, domain: str platform_name: str scan_interval: timedelta - parallel_updates: int entity_namespace: str async_entities_added_callback: @callback method """ @@ -52,22 +51,21 @@ def __init__(self, *, hass, logger, domain, platform_name, platform, # which powers entity_component.add_entities if platform is None: self.parallel_updates = None + self.parallel_updates_semaphore = None return - # Async platforms do all updates in parallel by default - if hasattr(platform, 'async_setup_platform'): - default_parallel_updates = 0 - else: - default_parallel_updates = 1 - - parallel_updates = getattr(platform, 'PARALLEL_UPDATES', - default_parallel_updates) + self.parallel_updates = getattr(platform, 'PARALLEL_UPDATES', None) + # semaphore will be created on demand + self.parallel_updates_semaphore = None - if parallel_updates: - self.parallel_updates = asyncio.Semaphore( - parallel_updates, loop=hass.loop) - else: - self.parallel_updates = None + def _get_parallel_updates_semaphore(self): + """Get or create a semaphore for parallel updates.""" + if self.parallel_updates_semaphore is None: + self.parallel_updates_semaphore = asyncio.Semaphore( + self.parallel_updates if self.parallel_updates else 1, + loop=self.hass.loop + ) + return self.parallel_updates_semaphore async def async_setup(self, platform_config, discovery_info=None): """Set up the platform from a config file.""" @@ -240,7 +238,22 @@ async def _async_add_entity(self, entity, update_before_add, entity.hass = self.hass entity.platform = self - entity.parallel_updates = self.parallel_updates + + # Async entity + # PARALLEL_UPDATE == None: entity.parallel_updates = None + # PARALLEL_UPDATE == 0: entity.parallel_updates = None + # PARALLEL_UPDATE > 0: entity.parallel_updates = Semaphore(p) + # Sync entity + # PARALLEL_UPDATE == None: entity.parallel_updates = Semaphore(1) + # PARALLEL_UPDATE == 0: entity.parallel_updates = None + # PARALLEL_UPDATE > 0: entity.parallel_updates = Semaphore(p) + if hasattr(entity, 'async_update') and not self.parallel_updates: + entity.parallel_updates = None + elif (not hasattr(entity, 'async_update') + and self.parallel_updates == 0): + entity.parallel_updates = None + else: + entity.parallel_updates = self._get_parallel_updates_semaphore() # Update properties before we generate the entity_id if update_before_add: diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index d79f84d416d268..383cd05a009754 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1,6 +1,7 @@ """Test the entity helper.""" # pylint: disable=protected-access import asyncio +import threading from datetime import timedelta from unittest.mock import MagicMock, patch, PropertyMock @@ -225,11 +226,10 @@ def async_update(): assert update_call is True -@asyncio.coroutine -def test_async_parallel_updates_with_zero(hass): +async def test_async_parallel_updates_with_zero(hass): """Test parallel updates with 0 (disabled).""" updates = [] - test_lock = asyncio.Event(loop=hass.loop) + test_lock = asyncio.Event() class AsyncEntity(entity.Entity): @@ -239,37 +239,73 @@ def __init__(self, entity_id, count): self.hass = hass self._count = count - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Test update.""" updates.append(self._count) - yield from test_lock.wait() + await test_lock.wait() ent_1 = AsyncEntity("sensor.test_1", 1) ent_2 = AsyncEntity("sensor.test_2", 2) - ent_1.async_schedule_update_ha_state(True) - ent_2.async_schedule_update_ha_state(True) + try: + ent_1.async_schedule_update_ha_state(True) + ent_2.async_schedule_update_ha_state(True) - while True: - if len(updates) == 2: - break - yield from asyncio.sleep(0, loop=hass.loop) + while True: + if len(updates) >= 2: + break + await asyncio.sleep(0) - assert len(updates) == 2 - assert updates == [1, 2] + assert len(updates) == 2 + assert updates == [1, 2] + finally: + test_lock.set() - test_lock.set() +async def test_async_parallel_updates_with_zero_on_sync_update(hass): + """Test parallel updates with 0 (disabled).""" + updates = [] + test_lock = threading.Event() + + class AsyncEntity(entity.Entity): -@asyncio.coroutine -def test_async_parallel_updates_with_one(hass): + def __init__(self, entity_id, count): + """Initialize Async test entity.""" + self.entity_id = entity_id + self.hass = hass + self._count = count + + def update(self): + """Test update.""" + updates.append(self._count) + if not test_lock.wait(timeout=1): + # if timeout populate more data to fail the test + updates.append(self._count) + + ent_1 = AsyncEntity("sensor.test_1", 1) + ent_2 = AsyncEntity("sensor.test_2", 2) + + try: + ent_1.async_schedule_update_ha_state(True) + ent_2.async_schedule_update_ha_state(True) + + while True: + if len(updates) >= 2: + break + await asyncio.sleep(0) + + assert len(updates) == 2 + assert updates == [1, 2] + finally: + test_lock.set() + await asyncio.sleep(0) + + +async def test_async_parallel_updates_with_one(hass): """Test parallel updates with 1 (sequential).""" updates = [] - test_lock = asyncio.Lock(loop=hass.loop) - test_semaphore = asyncio.Semaphore(1, loop=hass.loop) - - yield from test_lock.acquire() + test_lock = asyncio.Lock() + test_semaphore = asyncio.Semaphore(1) class AsyncEntity(entity.Entity): @@ -280,59 +316,71 @@ def __init__(self, entity_id, count): self._count = count self.parallel_updates = test_semaphore - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Test update.""" updates.append(self._count) - yield from test_lock.acquire() + await test_lock.acquire() ent_1 = AsyncEntity("sensor.test_1", 1) ent_2 = AsyncEntity("sensor.test_2", 2) ent_3 = AsyncEntity("sensor.test_3", 3) - ent_1.async_schedule_update_ha_state(True) - ent_2.async_schedule_update_ha_state(True) - ent_3.async_schedule_update_ha_state(True) + await test_lock.acquire() - while True: - if len(updates) == 1: - break - yield from asyncio.sleep(0, loop=hass.loop) + try: + ent_1.async_schedule_update_ha_state(True) + ent_2.async_schedule_update_ha_state(True) + ent_3.async_schedule_update_ha_state(True) - assert len(updates) == 1 - assert updates == [1] + while True: + if len(updates) >= 1: + break + await asyncio.sleep(0) - test_lock.release() + assert len(updates) == 1 + assert updates == [1] - while True: - if len(updates) == 2: - break - yield from asyncio.sleep(0, loop=hass.loop) + updates.clear() + test_lock.release() + await asyncio.sleep(0) - assert len(updates) == 2 - assert updates == [1, 2] + while True: + if len(updates) >= 1: + break + await asyncio.sleep(0) - test_lock.release() + assert len(updates) == 1 + assert updates == [2] - while True: - if len(updates) == 3: - break - yield from asyncio.sleep(0, loop=hass.loop) + updates.clear() + test_lock.release() + await asyncio.sleep(0) - assert len(updates) == 3 - assert updates == [1, 2, 3] + while True: + if len(updates) >= 1: + break + await asyncio.sleep(0) - test_lock.release() + assert len(updates) == 1 + assert updates == [3] + updates.clear() + test_lock.release() + await asyncio.sleep(0) -@asyncio.coroutine -def test_async_parallel_updates_with_two(hass): + finally: + # we may have more than one lock need to release in case test failed + for _ in updates: + test_lock.release() + await asyncio.sleep(0) + test_lock.release() + + +async def test_async_parallel_updates_with_two(hass): """Test parallel updates with 2 (parallel).""" updates = [] - test_lock = asyncio.Lock(loop=hass.loop) - test_semaphore = asyncio.Semaphore(2, loop=hass.loop) - - yield from test_lock.acquire() + test_lock = asyncio.Lock() + test_semaphore = asyncio.Semaphore(2) class AsyncEntity(entity.Entity): @@ -354,34 +402,48 @@ def async_update(self): ent_3 = AsyncEntity("sensor.test_3", 3) ent_4 = AsyncEntity("sensor.test_4", 4) - ent_1.async_schedule_update_ha_state(True) - ent_2.async_schedule_update_ha_state(True) - ent_3.async_schedule_update_ha_state(True) - ent_4.async_schedule_update_ha_state(True) - - while True: - if len(updates) == 2: - break - yield from asyncio.sleep(0, loop=hass.loop) - - assert len(updates) == 2 - assert updates == [1, 2] - - test_lock.release() - yield from asyncio.sleep(0, loop=hass.loop) - test_lock.release() - - while True: - if len(updates) == 4: - break - yield from asyncio.sleep(0, loop=hass.loop) - - assert len(updates) == 4 - assert updates == [1, 2, 3, 4] - - test_lock.release() - yield from asyncio.sleep(0, loop=hass.loop) - test_lock.release() + await test_lock.acquire() + + try: + + ent_1.async_schedule_update_ha_state(True) + ent_2.async_schedule_update_ha_state(True) + ent_3.async_schedule_update_ha_state(True) + ent_4.async_schedule_update_ha_state(True) + + while True: + if len(updates) >= 2: + break + await asyncio.sleep(0) + + assert len(updates) == 2 + assert updates == [1, 2] + + updates.clear() + test_lock.release() + await asyncio.sleep(0) + test_lock.release() + await asyncio.sleep(0) + + while True: + if len(updates) >= 2: + break + await asyncio.sleep(0) + + assert len(updates) == 2 + assert updates == [3, 4] + + updates.clear() + test_lock.release() + await asyncio.sleep(0) + test_lock.release() + await asyncio.sleep(0) + finally: + # we may have more than one lock need to release in case test failed + for _ in updates: + test_lock.release() + await asyncio.sleep(0) + test_lock.release() @asyncio.coroutine diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index e985771e486b61..6cf0bb0eeeb89c 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -251,23 +251,46 @@ def async_update(self): assert entity_ids[0] == "test_domain.living_room" -@asyncio.coroutine -def test_parallel_updates_async_platform(hass): - """Warn we log when platform setup takes a long time.""" +async def test_parallel_updates_async_platform(hass): + """Test async platform does not have parallel_updates limit by default.""" platform = MockPlatform() - @asyncio.coroutine - def mock_update(*args, **kwargs): - pass + loader.set_component(hass, 'test_domain.platform', platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + component._platforms = {} + + await component.async_setup({ + DOMAIN: { + 'platform': 'platform', + } + }) + + handle = list(component._platforms.values())[-1] + assert handle.parallel_updates is None + + class AsyncEntity(MockEntity): + """Mock entity that has async_update.""" + + async def async_update(self): + pass + + entity = AsyncEntity() + await handle.async_add_entities([entity]) + assert entity.parallel_updates is None - platform.async_setup_platform = mock_update + +async def test_parallel_updates_async_platform_with_constant(hass): + """Test async platform can set parallel_updates limit.""" + platform = MockPlatform() + platform.PARALLEL_UPDATES = 2 loader.set_component(hass, 'test_domain.platform', platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} - yield from component.async_setup({ + await component.async_setup({ DOMAIN: { 'platform': 'platform', } @@ -275,56 +298,79 @@ def mock_update(*args, **kwargs): handle = list(component._platforms.values())[-1] - assert handle.parallel_updates is None + assert handle.parallel_updates == 2 + class AsyncEntity(MockEntity): + """Mock entity that has async_update.""" -@asyncio.coroutine -def test_parallel_updates_async_platform_with_constant(hass): - """Warn we log when platform setup takes a long time.""" - platform = MockPlatform() + async def async_update(self): + pass - @asyncio.coroutine - def mock_update(*args, **kwargs): - pass + entity = AsyncEntity() + await handle.async_add_entities([entity]) + assert entity.parallel_updates is not None + assert entity.parallel_updates._value == 2 - platform.async_setup_platform = mock_update - platform.PARALLEL_UPDATES = 1 + +async def test_parallel_updates_sync_platform(hass): + """Test sync platform parallel_updates default set to 1.""" + platform = MockPlatform() loader.set_component(hass, 'test_domain.platform', platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} - yield from component.async_setup({ + await component.async_setup({ DOMAIN: { 'platform': 'platform', } }) handle = list(component._platforms.values())[-1] + assert handle.parallel_updates is None - assert handle.parallel_updates is not None + class SyncEntity(MockEntity): + """Mock entity that has update.""" + async def update(self): + pass + + entity = SyncEntity() + await handle.async_add_entities([entity]) + assert entity.parallel_updates is not None + assert entity.parallel_updates._value == 1 -@asyncio.coroutine -def test_parallel_updates_sync_platform(hass): - """Warn we log when platform setup takes a long time.""" - platform = MockPlatform(setup_platform=lambda *args: None) + +async def test_parallel_updates_sync_platform_with_constant(hass): + """Test sync platform can set parallel_updates limit.""" + platform = MockPlatform() + platform.PARALLEL_UPDATES = 2 loader.set_component(hass, 'test_domain.platform', platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} - yield from component.async_setup({ + await component.async_setup({ DOMAIN: { 'platform': 'platform', } }) handle = list(component._platforms.values())[-1] + assert handle.parallel_updates == 2 + + class SyncEntity(MockEntity): + """Mock entity that has update.""" + + async def update(self): + pass - assert handle.parallel_updates is not None + entity = SyncEntity() + await handle.async_add_entities([entity]) + assert entity.parallel_updates is not None + assert entity.parallel_updates._value == 2 @asyncio.coroutine From baa4945944c2221212d123536ad6a7831b0b2d41 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Tue, 26 Mar 2019 03:39:09 -0400 Subject: [PATCH 46/69] reset unsub to None on timeout (#22404) --- homeassistant/components/stream/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 665803d38ebcab..59c0a6b650fd46 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -128,6 +128,7 @@ def put(self, segment: Segment) -> None: @callback def _timeout(self, _now=None): """Handle stream timeout.""" + self._unsub = None if self._stream.keepalive: self.idle = True self._stream.check_idle() From bad0a8b342cb0b5a722ef6a357247009f58e4c9d Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Tue, 26 Mar 2019 08:31:29 -0400 Subject: [PATCH 47/69] Camera Preferences + Preload Stream (#22339) * initial commit for camera preferences and preload stream * cleanup and add tests * respect camera preferences on each request stream call * return the new prefs after update --- homeassistant/components/camera/__init__.py | 68 +++++++++-- homeassistant/components/camera/const.py | 6 + homeassistant/components/camera/prefs.py | 60 ++++++++++ homeassistant/components/local_file/camera.py | 3 +- .../components/logi_circle/camera.py | 3 +- homeassistant/components/onvif/camera.py | 3 +- homeassistant/components/push/camera.py | 3 +- homeassistant/components/stream/__init__.py | 3 + tests/components/camera/common.py | 14 ++- tests/components/camera/test_init.py | 109 ++++++++++++++++-- tests/components/local_file/test_camera.py | 2 +- 11 files changed, 252 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/camera/const.py create mode 100644 homeassistant/components/camera/prefs.py diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 046b6d3947c064..cdd8a844389a54 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -20,7 +20,7 @@ from homeassistant.core import callback from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, \ - SERVICE_TURN_ON + SERVICE_TURN_ON, EVENT_HOMEASSISTANT_START from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.helpers.entity import Entity @@ -37,7 +37,9 @@ from homeassistant.components import websocket_api import homeassistant.helpers.config_validation as cv -DOMAIN = 'camera' +from .const import DOMAIN, DATA_CAMERA_PREFS +from .prefs import CameraPreferences + DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -68,7 +70,6 @@ TOKEN_CHANGE_INTERVAL = timedelta(minutes=5) _RND = SystemRandom() -FALLBACK_STREAM_INTERVAL = 1 # seconds MIN_STREAM_INTERVAL = 0.5 # seconds CAMERA_SERVICE_SCHEMA = vol.Schema({ @@ -103,12 +104,14 @@ class Image: async def async_request_stream(hass, entity_id, fmt): """Request a stream for a camera entity.""" camera = _get_camera_from_entity_id(hass, entity_id) + camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) if not camera.stream_source: raise HomeAssistantError("{} does not support play stream service" .format(camera.entity_id)) - return request_stream(hass, camera.stream_source, fmt=fmt) + return request_stream(hass, camera.stream_source, fmt=fmt, + keepalive=camera_prefs.preload_stream) @bind_hass @@ -197,6 +200,10 @@ async def async_setup(hass, config): component = hass.data[DOMAIN] = \ EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + prefs = CameraPreferences(hass) + await prefs.async_initialize() + hass.data[DATA_CAMERA_PREFS] = prefs + hass.http.register_view(CameraImageView(component)) hass.http.register_view(CameraMjpegStream(component)) hass.components.websocket_api.async_register_command( @@ -204,9 +211,21 @@ async def async_setup(hass, config): SCHEMA_WS_CAMERA_THUMBNAIL ) hass.components.websocket_api.async_register_command(ws_camera_stream) + hass.components.websocket_api.async_register_command(websocket_get_prefs) + hass.components.websocket_api.async_register_command( + websocket_update_prefs) await component.async_setup(config) + @callback + def preload_stream(event): + for camera in component.entities: + camera_prefs = prefs.get(camera.entity_id) + if camera.stream_source and camera_prefs.preload_stream: + request_stream(hass, camera.stream_source, keepalive=True) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, preload_stream) + @callback def update_tokens(time): """Update tokens of the entities.""" @@ -522,14 +541,17 @@ async def ws_camera_stream(hass, connection, msg): Async friendly. """ try: - camera = _get_camera_from_entity_id(hass, msg['entity_id']) + entity_id = msg['entity_id'] + camera = _get_camera_from_entity_id(hass, entity_id) + camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) if not camera.stream_source: raise HomeAssistantError("{} does not support play stream service" .format(camera.entity_id)) fmt = msg['format'] - url = request_stream(hass, camera.stream_source, fmt=fmt) + url = request_stream(hass, camera.stream_source, fmt=fmt, + keepalive=camera_prefs.preload_stream) connection.send_result(msg['id'], {'url': url}) except HomeAssistantError as ex: _LOGGER.error(ex) @@ -537,6 +559,36 @@ async def ws_camera_stream(hass, connection, msg): msg['id'], 'start_stream_failed', str(ex)) +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'camera/get_prefs', + vol.Required('entity_id'): cv.entity_id, +}) +async def websocket_get_prefs(hass, connection, msg): + """Handle request for account info.""" + prefs = hass.data[DATA_CAMERA_PREFS].get(msg['entity_id']) + connection.send_result(msg['id'], prefs.as_dict()) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'camera/update_prefs', + vol.Required('entity_id'): cv.entity_id, + vol.Optional('preload_stream'): bool, +}) +async def websocket_update_prefs(hass, connection, msg): + """Handle request for account info.""" + prefs = hass.data[DATA_CAMERA_PREFS] + + changes = dict(msg) + changes.pop('id') + changes.pop('type') + entity_id = changes.pop('entity_id') + await prefs.async_update(entity_id, **changes) + + connection.send_result(msg['id'], prefs.get(entity_id).as_dict()) + + async def async_handle_snapshot_service(camera, service): """Handle snapshot services calls.""" hass = camera.hass @@ -573,10 +625,12 @@ async def async_handle_play_stream_service(camera, service_call): .format(camera.entity_id)) hass = camera.hass + camera_prefs = hass.data[DATA_CAMERA_PREFS].get(camera.entity_id) fmt = service_call.data[ATTR_FORMAT] entity_ids = service_call.data[ATTR_MEDIA_PLAYER] - url = request_stream(hass, camera.stream_source, fmt=fmt) + url = request_stream(hass, camera.stream_source, fmt=fmt, + keepalive=camera_prefs.preload_stream) data = { ATTR_ENTITY_ID: entity_ids, ATTR_MEDIA_CONTENT_ID: "{}{}".format(hass.config.api.base_url, url), diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py new file mode 100644 index 00000000000000..f87ca47460e644 --- /dev/null +++ b/homeassistant/components/camera/const.py @@ -0,0 +1,6 @@ +"""Constants for Camera component.""" +DOMAIN = 'camera' + +DATA_CAMERA_PREFS = 'camera_prefs' + +PREF_PRELOAD_STREAM = 'preload_stream' diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py new file mode 100644 index 00000000000000..927929bdf6eef0 --- /dev/null +++ b/homeassistant/components/camera/prefs.py @@ -0,0 +1,60 @@ +"""Preference management for camera component.""" +from .const import DOMAIN, PREF_PRELOAD_STREAM + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +_UNDEF = object() + + +class CameraEntityPreferences: + """Handle preferences for camera entity.""" + + def __init__(self, prefs): + """Initialize prefs.""" + self._prefs = prefs + + def as_dict(self): + """Return dictionary version.""" + return self._prefs + + @property + def preload_stream(self): + """Return if stream is loaded on hass start.""" + return self._prefs.get(PREF_PRELOAD_STREAM, False) + + +class CameraPreferences: + """Handle camera preferences.""" + + def __init__(self, hass): + """Initialize camera prefs.""" + self._hass = hass + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._prefs = None + + async def async_initialize(self): + """Finish initializing the preferences.""" + prefs = await self._store.async_load() + + if prefs is None: + prefs = {} + + self._prefs = prefs + + async def async_update(self, entity_id, *, preload_stream=_UNDEF, + stream_options=_UNDEF): + """Update camera preferences.""" + if not self._prefs.get(entity_id): + self._prefs[entity_id] = {} + + for key, value in ( + (PREF_PRELOAD_STREAM, preload_stream), + ): + if value is not _UNDEF: + self._prefs[entity_id][key] = value + + await self._store.async_save(self._prefs) + + def get(self, entity_id): + """Get preferences for an entity.""" + return CameraEntityPreferences(self._prefs.get(entity_id, {})) diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index d306509b76258a..56780d16f5683a 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -12,7 +12,8 @@ from homeassistant.const import CONF_NAME from homeassistant.components.camera import ( - Camera, CAMERA_SERVICE_SCHEMA, DOMAIN, PLATFORM_SCHEMA) + Camera, CAMERA_SERVICE_SCHEMA, PLATFORM_SCHEMA) +from homeassistant.components.camera.const import DOMAIN from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 814475d04de916..ff6f14431d5819 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -6,8 +6,9 @@ import voluptuous as vol from homeassistant.components.camera import ( - ATTR_ENTITY_ID, ATTR_FILENAME, CAMERA_SERVICE_SCHEMA, DOMAIN, + ATTR_ENTITY_ID, ATTR_FILENAME, CAMERA_SERVICE_SCHEMA, PLATFORM_SCHEMA, SUPPORT_ON_OFF, Camera) +from homeassistant.components.camera.const import DOMAIN from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, CONF_SCAN_INTERVAL, STATE_OFF, STATE_ON) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index da0bae7c50bacb..f3b25e3a1285a5 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -13,7 +13,8 @@ from homeassistant.const import ( CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, ATTR_ENTITY_ID) -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA, DOMAIN +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.camera.const import DOMAIN from homeassistant.components.ffmpeg import ( DATA_FFMPEG, CONF_EXTRA_ARGUMENTS) import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 36c4a3109baba2..5490cd1508cbd8 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -14,7 +14,8 @@ import async_timeout from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\ - STATE_IDLE, STATE_RECORDING, DOMAIN + STATE_IDLE, STATE_RECORDING +from homeassistant.components.camera.const import DOMAIN from homeassistant.core import callback from homeassistant.const import CONF_NAME, CONF_TIMEOUT, CONF_WEBHOOK_ID from homeassistant.helpers import config_validation as cv diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index c881ec1276a96f..a68f1c47dbf413 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -56,6 +56,9 @@ def request_stream(hass, stream_source, *, fmt='hls', stream = Stream(hass, stream_source, options=options, keepalive=keepalive) streams[stream_source] = stream + else: + # Update keepalive option on existing stream + stream.keepalive = keepalive # Add provider stream.add_provider(fmt) diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index 21f7244bd299c9..bebb991a7af90a 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -4,7 +4,9 @@ components. Instead call the service directly. """ from homeassistant.components.camera import ( - ATTR_FILENAME, DOMAIN, SERVICE_ENABLE_MOTION, SERVICE_SNAPSHOT) + ATTR_FILENAME, SERVICE_ENABLE_MOTION, SERVICE_SNAPSHOT) +from homeassistant.components.camera.const import ( + DOMAIN, DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, \ SERVICE_TURN_ON from homeassistant.core import callback @@ -45,3 +47,13 @@ def async_snapshot(hass, filename, entity_id=None): hass.async_add_job(hass.services.async_call( DOMAIN, SERVICE_SNAPSHOT, data)) + + +def mock_camera_prefs(hass, entity_id, prefs={}): + """Fixture for cloud component.""" + prefs_to_set = { + PREF_PRELOAD_STREAM: True, + } + prefs_to_set.update(prefs) + hass.data[DATA_CAMERA_PREFS]._prefs[entity_id] = prefs_to_set + return prefs_to_set diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 0359d14df63596..701a368283083a 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -1,13 +1,17 @@ """The tests for the camera component.""" import asyncio import base64 +import io from unittest.mock import patch, mock_open, PropertyMock import pytest from homeassistant.setup import setup_component, async_setup_component -from homeassistant.const import (ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, EVENT_HOMEASSISTANT_START) from homeassistant.components import camera, http +from homeassistant.components.camera.const import DOMAIN, PREF_PRELOAD_STREAM +from homeassistant.components.camera.prefs import CameraEntityPreferences from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.exceptions import HomeAssistantError from homeassistant.util.async_ import run_coroutine_threadsafe @@ -16,7 +20,6 @@ get_test_home_assistant, get_test_instance_port, assert_setup_component, mock_coro) from tests.components.camera import common -from tests.components.stream.common import generate_h264_video @pytest.fixture @@ -41,6 +44,12 @@ def mock_stream(hass): })) +@pytest.fixture +def setup_camera_prefs(hass): + """Initialize HTTP API.""" + return common.mock_camera_prefs(hass, 'camera.demo_camera') + + class TestSetupCamera: """Test class for setup camera.""" @@ -146,7 +155,7 @@ def test_snapshot_service(hass, mock_camera): assert mock_write.mock_calls[0][1][0] == b'Test' -async def test_webocket_camera_thumbnail(hass, hass_ws_client, mock_camera): +async def test_websocket_camera_thumbnail(hass, hass_ws_client, mock_camera): """Test camera_thumbnail websocket command.""" await async_setup_component(hass, 'camera') @@ -167,8 +176,8 @@ async def test_webocket_camera_thumbnail(hass, hass_ws_client, mock_camera): base64.b64encode(b'Test').decode('utf-8') -async def test_webocket_stream_no_source(hass, hass_ws_client, - mock_camera, mock_stream): +async def test_websocket_stream_no_source(hass, hass_ws_client, + mock_camera, mock_stream): """Test camera/stream websocket command.""" await async_setup_component(hass, 'camera') @@ -191,8 +200,8 @@ async def test_webocket_stream_no_source(hass, hass_ws_client, assert not msg['success'] -async def test_webocket_camera_stream(hass, hass_ws_client, hass_client, - mock_camera, mock_stream): +async def test_websocket_camera_stream(hass, hass_ws_client, + mock_camera, mock_stream): """Test camera/stream websocket command.""" await async_setup_component(hass, 'camera') @@ -201,7 +210,7 @@ async def test_webocket_camera_stream(hass, hass_ws_client, hass_client, ) as mock_request_stream, \ patch('homeassistant.components.demo.camera.DemoCamera.stream_source', new_callable=PropertyMock) as mock_stream_source: - mock_stream_source.return_value = generate_h264_video() + mock_stream_source.return_value = io.BytesIO() # Request playlist through WebSocket client = await hass_ws_client(hass) await client.send_json({ @@ -219,6 +228,44 @@ async def test_webocket_camera_stream(hass, hass_ws_client, hass_client, assert msg['result']['url'][-13:] == 'playlist.m3u8' +async def test_websocket_get_prefs(hass, hass_ws_client, + mock_camera): + """Test get camera preferences websocket command.""" + await async_setup_component(hass, 'camera') + + # Request preferences through websocket + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 7, + 'type': 'camera/get_prefs', + 'entity_id': 'camera.demo_camera', + }) + msg = await client.receive_json() + + # Assert WebSocket response + assert msg['success'] + + +async def test_websocket_update_prefs(hass, hass_ws_client, + mock_camera, setup_camera_prefs): + """Test updating preference.""" + await async_setup_component(hass, 'camera') + assert setup_camera_prefs[PREF_PRELOAD_STREAM] + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 8, + 'type': 'camera/update_prefs', + 'entity_id': 'camera.demo_camera', + 'preload_stream': False, + }) + response = await client.receive_json() + + assert response['success'] + assert not setup_camera_prefs[PREF_PRELOAD_STREAM] + assert response['result'][PREF_PRELOAD_STREAM] == \ + setup_camera_prefs[PREF_PRELOAD_STREAM] + + async def test_play_stream_service_no_source(hass, mock_camera, mock_stream): """Test camera play_stream service.""" data = { @@ -243,10 +290,54 @@ async def test_handle_play_stream_service(hass, mock_camera, mock_stream): ) as mock_request_stream, \ patch('homeassistant.components.demo.camera.DemoCamera.stream_source', new_callable=PropertyMock) as mock_stream_source: - mock_stream_source.return_value = generate_h264_video() + mock_stream_source.return_value = io.BytesIO() # Call service await hass.services.async_call( camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True) # So long as we request the stream, the rest should be covered # by the play_media service tests. assert mock_request_stream.called + + +async def test_no_preload_stream(hass, mock_stream): + """Test camera preload preference.""" + demo_prefs = CameraEntityPreferences({ + PREF_PRELOAD_STREAM: False, + }) + with patch('homeassistant.components.camera.request_stream' + ) as mock_request_stream, \ + patch('homeassistant.components.camera.prefs.CameraPreferences.get', + return_value=demo_prefs), \ + patch('homeassistant.components.demo.camera.DemoCamera.stream_source', + new_callable=PropertyMock) as mock_stream_source: + mock_stream_source.return_value = io.BytesIO() + await async_setup_component(hass, 'camera', { + DOMAIN: { + 'platform': 'demo' + } + }) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert not mock_request_stream.called + + +async def test_preload_stream(hass, mock_stream): + """Test camera preload preference.""" + demo_prefs = CameraEntityPreferences({ + PREF_PRELOAD_STREAM: True, + }) + with patch('homeassistant.components.camera.request_stream' + ) as mock_request_stream, \ + patch('homeassistant.components.camera.prefs.CameraPreferences.get', + return_value=demo_prefs), \ + patch('homeassistant.components.demo.camera.DemoCamera.stream_source', + new_callable=PropertyMock) as mock_stream_source: + mock_stream_source.return_value = io.BytesIO() + await async_setup_component(hass, 'camera', { + DOMAIN: { + 'platform': 'demo' + } + }) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert mock_request_stream.called diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index 3d70e3f77a7916..a96f9768be4b67 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -2,7 +2,7 @@ import asyncio from unittest import mock -from homeassistant.components.camera import DOMAIN +from homeassistant.components.camera.const import DOMAIN from homeassistant.components.local_file.camera import ( SERVICE_UPDATE_FILE_PATH) from homeassistant.setup import async_setup_component From 65432ba5523ec8385bfd40a968a72726ad6b150f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 26 Mar 2019 05:38:33 -0700 Subject: [PATCH 48/69] Move core stuff into Home Assistant integration (#22407) * Move core stuff into Home Assistant integration * Lint --- homeassistant/bootstrap.py | 10 +- homeassistant/components/__init__.py | 135 +---------------- homeassistant/components/config/customize.py | 2 +- homeassistant/components/hassio/__init__.py | 2 +- .../components/homeassistant/__init__.py | 137 ++++++++++++++++++ .../scene.py} | 2 +- tests/components/conversation/test_init.py | 13 +- tests/components/cover/test_init.py | 2 +- tests/components/emulated_hue/test_hue_api.py | 7 +- .../generic_thermostat/test_climate.py | 3 +- .../google_assistant/test_google_assistant.py | 4 +- tests/components/homeassistant/__init__.py | 1 + .../{init => homeassistant}/test_init.py | 20 +-- tests/components/init/__init__.py | 1 - tests/helpers/test_state.py | 6 +- 15 files changed, 175 insertions(+), 170 deletions(-) create mode 100644 homeassistant/components/homeassistant/__init__.py rename homeassistant/components/{scene/homeassistant.py => homeassistant/scene.py} (98%) create mode 100644 tests/components/homeassistant/__init__.py rename tests/components/{init => homeassistant}/test_init.py (94%) delete mode 100644 tests/components/init/__init__.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index d532d9cdb8685b..a3b1d6d305e368 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -9,10 +9,10 @@ import voluptuous as vol -from homeassistant import ( - core, config as conf_util, config_entries, components as core_components, - loader) -from homeassistant.components import persistent_notification +from homeassistant import core, config as conf_util, config_entries, loader +from homeassistant.components import ( + persistent_notification, homeassistant as core_component +) from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.setup import async_setup_component from homeassistant.util.logging import AsyncHandler @@ -139,7 +139,7 @@ async def async_from_config_dict(config: Dict[str, Any], pass # setup components - res = await core_components.async_setup(hass, config) + res = await core_component.async_setup(hass, config) if not res: _LOGGER.error("Home Assistant core failed to initialize. " "Further initialization aborted") diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 533811e275d5cd..88cd44f4bf2762 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -7,33 +7,12 @@ format ".". - Each component should publish services only under its own domain. """ -import asyncio -import itertools as it import logging -from typing import Awaitable -import voluptuous as vol - -import homeassistant.core as ha -import homeassistant.config as conf_util -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.service import async_extract_entity_ids -from homeassistant.helpers import intent -from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, - SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, - RESTART_EXIT_CODE) -from homeassistant.helpers import config_validation as cv +from homeassistant.core import split_entity_id _LOGGER = logging.getLogger(__name__) -SERVICE_RELOAD_CORE_CONFIG = 'reload_core_config' -SERVICE_CHECK_CONFIG = 'check_config' -SERVICE_UPDATE_ENTITY = 'update_entity' -SCHEMA_UPDATE_ENTITY = vol.Schema({ - ATTR_ENTITY_ID: cv.entity_ids -}) - def is_on(hass, entity_id=None): """Load up the module to call the is_on method. @@ -46,7 +25,7 @@ def is_on(hass, entity_id=None): entity_ids = hass.states.entity_ids() for ent_id in entity_ids: - domain = ha.split_entity_id(ent_id)[0] + domain = split_entity_id(ent_id)[0] try: component = getattr(hass.components, domain) @@ -64,113 +43,3 @@ def is_on(hass, entity_id=None): return True return False - - -async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: - """Set up general services related to Home Assistant.""" - async def async_handle_turn_service(service): - """Handle calls to homeassistant.turn_on/off.""" - entity_ids = await async_extract_entity_ids(hass, service) - - # Generic turn on/off method requires entity id - if not entity_ids: - _LOGGER.error( - "homeassistant/%s cannot be called without entity_id", - service.service) - return - - # Group entity_ids by domain. groupby requires sorted data. - by_domain = it.groupby(sorted(entity_ids), - lambda item: ha.split_entity_id(item)[0]) - - tasks = [] - - for domain, ent_ids in by_domain: - # We want to block for all calls and only return when all calls - # have been processed. If a service does not exist it causes a 10 - # second delay while we're blocking waiting for a response. - # But services can be registered on other HA instances that are - # listening to the bus too. So as an in between solution, we'll - # block only if the service is defined in the current HA instance. - blocking = hass.services.has_service(domain, service.service) - - # Create a new dict for this call - data = dict(service.data) - - # ent_ids is a generator, convert it to a list. - data[ATTR_ENTITY_ID] = list(ent_ids) - - tasks.append(hass.services.async_call( - domain, service.service, data, blocking)) - - await asyncio.wait(tasks, loop=hass.loop) - - hass.services.async_register( - ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service) - hass.services.async_register( - ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) - hass.services.async_register( - ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) - hass.helpers.intent.async_register(intent.ServiceIntentHandler( - intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on")) - hass.helpers.intent.async_register(intent.ServiceIntentHandler( - intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, - "Turned {} off")) - hass.helpers.intent.async_register(intent.ServiceIntentHandler( - intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}")) - - async def async_handle_core_service(call): - """Service handler for handling core services.""" - if call.service == SERVICE_HOMEASSISTANT_STOP: - hass.async_create_task(hass.async_stop()) - return - - try: - errors = await conf_util.async_check_ha_config_file(hass) - except HomeAssistantError: - return - - if errors: - _LOGGER.error(errors) - hass.components.persistent_notification.async_create( - "Config error. See dev-info panel for details.", - "Config validating", "{0}.check_config".format(ha.DOMAIN)) - return - - if call.service == SERVICE_HOMEASSISTANT_RESTART: - hass.async_create_task(hass.async_stop(RESTART_EXIT_CODE)) - - async def async_handle_update_service(call): - """Service handler for updating an entity.""" - tasks = [hass.helpers.entity_component.async_update_entity(entity) - for entity in call.data[ATTR_ENTITY_ID]] - - if tasks: - await asyncio.wait(tasks) - - hass.services.async_register( - ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service) - hass.services.async_register( - ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service) - hass.services.async_register( - ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service) - hass.services.async_register( - ha.DOMAIN, SERVICE_UPDATE_ENTITY, async_handle_update_service, - schema=SCHEMA_UPDATE_ENTITY) - - async def async_handle_reload_config(call): - """Service handler for reloading core config.""" - try: - conf = await conf_util.async_hass_config_yaml(hass) - except HomeAssistantError as err: - _LOGGER.error(err) - return - - # auth only processed during startup - await conf_util.async_process_ha_core_config( - hass, conf.get(ha.DOMAIN) or {}) - - hass.services.async_register( - ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config) - - return True diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py index bb774ae7ef8e0d..85e9c0e6886706 100644 --- a/homeassistant/components/config/customize.py +++ b/homeassistant/components/config/customize.py @@ -1,5 +1,5 @@ """Provide configuration end points for Customize.""" -from homeassistant.components import SERVICE_RELOAD_CORE_CONFIG +from homeassistant.components.homeassistant import SERVICE_RELOAD_CORE_CONFIG from homeassistant.config import DATA_CUSTOMIZE from homeassistant.core import DOMAIN import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 7e47ac152e32c9..90e120d8b0eafe 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.components import SERVICE_CHECK_CONFIG +from homeassistant.components.homeassistant import SERVICE_CHECK_CONFIG import homeassistant.config as conf_util from homeassistant.const import ( ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py new file mode 100644 index 00000000000000..ef01d133cff623 --- /dev/null +++ b/homeassistant/components/homeassistant/__init__.py @@ -0,0 +1,137 @@ +"""Integration providing core pieces of infrastructure.""" +import asyncio +import itertools as it +import logging +from typing import Awaitable + +import voluptuous as vol + +import homeassistant.core as ha +import homeassistant.config as conf_util +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service import async_extract_entity_ids +from homeassistant.helpers import intent +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, + SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, + RESTART_EXIT_CODE) +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) +DOMAIN = ha.DOMAIN +SERVICE_RELOAD_CORE_CONFIG = 'reload_core_config' +SERVICE_CHECK_CONFIG = 'check_config' +SERVICE_UPDATE_ENTITY = 'update_entity' +SCHEMA_UPDATE_ENTITY = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids +}) + + +async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: + """Set up general services related to Home Assistant.""" + async def async_handle_turn_service(service): + """Handle calls to homeassistant.turn_on/off.""" + entity_ids = await async_extract_entity_ids(hass, service) + + # Generic turn on/off method requires entity id + if not entity_ids: + _LOGGER.error( + "homeassistant/%s cannot be called without entity_id", + service.service) + return + + # Group entity_ids by domain. groupby requires sorted data. + by_domain = it.groupby(sorted(entity_ids), + lambda item: ha.split_entity_id(item)[0]) + + tasks = [] + + for domain, ent_ids in by_domain: + # We want to block for all calls and only return when all calls + # have been processed. If a service does not exist it causes a 10 + # second delay while we're blocking waiting for a response. + # But services can be registered on other HA instances that are + # listening to the bus too. So as an in between solution, we'll + # block only if the service is defined in the current HA instance. + blocking = hass.services.has_service(domain, service.service) + + # Create a new dict for this call + data = dict(service.data) + + # ent_ids is a generator, convert it to a list. + data[ATTR_ENTITY_ID] = list(ent_ids) + + tasks.append(hass.services.async_call( + domain, service.service, data, blocking)) + + await asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register( + ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service) + hass.services.async_register( + ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) + hass.services.async_register( + ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, + "Turned {} off")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}")) + + async def async_handle_core_service(call): + """Service handler for handling core services.""" + if call.service == SERVICE_HOMEASSISTANT_STOP: + hass.async_create_task(hass.async_stop()) + return + + try: + errors = await conf_util.async_check_ha_config_file(hass) + except HomeAssistantError: + return + + if errors: + _LOGGER.error(errors) + hass.components.persistent_notification.async_create( + "Config error. See dev-info panel for details.", + "Config validating", "{0}.check_config".format(ha.DOMAIN)) + return + + if call.service == SERVICE_HOMEASSISTANT_RESTART: + hass.async_create_task(hass.async_stop(RESTART_EXIT_CODE)) + + async def async_handle_update_service(call): + """Service handler for updating an entity.""" + tasks = [hass.helpers.entity_component.async_update_entity(entity) + for entity in call.data[ATTR_ENTITY_ID]] + + if tasks: + await asyncio.wait(tasks) + + hass.services.async_register( + ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service) + hass.services.async_register( + ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service) + hass.services.async_register( + ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service) + hass.services.async_register( + ha.DOMAIN, SERVICE_UPDATE_ENTITY, async_handle_update_service, + schema=SCHEMA_UPDATE_ENTITY) + + async def async_handle_reload_config(call): + """Service handler for reloading core config.""" + try: + conf = await conf_util.async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error(err) + return + + # auth only processed during startup + await conf_util.async_process_ha_core_config( + hass, conf.get(ha.DOMAIN) or {}) + + hass.services.async_register( + ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config) + + return True diff --git a/homeassistant/components/scene/homeassistant.py b/homeassistant/components/homeassistant/scene.py similarity index 98% rename from homeassistant/components/scene/homeassistant.py rename to homeassistant/components/homeassistant/scene.py index 86af6c34694a00..617b56241108da 100644 --- a/homeassistant/components/scene/homeassistant.py +++ b/homeassistant/components/homeassistant/scene.py @@ -9,8 +9,8 @@ from homeassistant.core import State import homeassistant.helpers.config_validation as cv from homeassistant.helpers.state import HASS_DOMAIN, async_reproduce_state +from homeassistant.components.scene import STATES, Scene -from . import STATES, Scene PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): HASS_DOMAIN, diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 2aa1f499a768ed..812456a3594a2d 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -5,7 +5,6 @@ from homeassistant.core import DOMAIN as HASS_DOMAIN from homeassistant.setup import async_setup_component from homeassistant.components import conversation -import homeassistant.components as component from homeassistant.components.cover import (SERVICE_OPEN_COVER) from homeassistant.helpers import intent @@ -16,7 +15,7 @@ async def test_calling_intent(hass): """Test calling an intent from a conversation.""" intents = async_mock_intent(hass, 'OrderBeer') - result = await component.async_setup(hass, {}) + result = await async_setup_component(hass, 'homeassistant', {}) assert result result = await async_setup_component(hass, 'conversation', { @@ -146,7 +145,7 @@ async def async_handle(self, intent): @pytest.mark.parametrize('sentence', ('turn on kitchen', 'turn kitchen on')) async def test_turn_on_intent(hass, sentence): """Test calling the turn on intent.""" - result = await component.async_setup(hass, {}) + result = await async_setup_component(hass, 'homeassistant', {}) assert result result = await async_setup_component(hass, 'conversation', {}) @@ -197,7 +196,7 @@ async def test_cover_intents_loading(hass): @pytest.mark.parametrize('sentence', ('turn off kitchen', 'turn kitchen off')) async def test_turn_off_intent(hass, sentence): """Test calling the turn on intent.""" - result = await component.async_setup(hass, {}) + result = await async_setup_component(hass, 'homeassistant', {}) assert result result = await async_setup_component(hass, 'conversation', {}) @@ -222,7 +221,7 @@ async def test_turn_off_intent(hass, sentence): @pytest.mark.parametrize('sentence', ('toggle kitchen', 'kitchen toggle')) async def test_toggle_intent(hass, sentence): """Test calling the turn on intent.""" - result = await component.async_setup(hass, {}) + result = await async_setup_component(hass, 'homeassistant', {}) assert result result = await async_setup_component(hass, 'conversation', {}) @@ -246,7 +245,7 @@ async def test_toggle_intent(hass, sentence): async def test_http_api(hass, hass_client): """Test the HTTP conversation API.""" - result = await component.async_setup(hass, {}) + result = await async_setup_component(hass, 'homeassistant', {}) assert result result = await async_setup_component(hass, 'conversation', {}) @@ -270,7 +269,7 @@ async def test_http_api(hass, hass_client): async def test_http_api_wrong_data(hass, hass_client): """Test the HTTP conversation API.""" - result = await component.async_setup(hass, {}) + result = await async_setup_component(hass, 'homeassistant', {}) assert result result = await async_setup_component(hass, 'conversation', {}) diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 5df492d3d47149..09cb5752b552bc 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -2,7 +2,7 @@ from homeassistant.components.cover import (SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER) -from homeassistant.components import intent +from homeassistant.helpers import intent import homeassistant.components as comps from tests.common import async_mock_service diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 8be99a02148662..08001b0ebab522 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -8,8 +8,7 @@ import pytest from tests.common import get_test_instance_port -from homeassistant import core, const, setup -import homeassistant.components as core_components +from homeassistant import const, setup from homeassistant.components import ( fan, http, light, script, emulated_hue, media_player, cover, climate) from homeassistant.components.emulated_hue import Config @@ -33,8 +32,8 @@ def hass_hue(loop, hass): """Set up a Home Assistant instance for these tests.""" # We need to do this to get access to homeassistant/turn_(on,off) - loop.run_until_complete( - core_components.async_setup(hass, {core.DOMAIN: {}})) + loop.run_until_complete(setup.async_setup_component( + hass, 'homeassistant', {})) loop.run_until_complete(setup.async_setup_component( hass, http.DOMAIN, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 1d532f4757cff3..49d49fdd3d404b 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -23,7 +23,6 @@ from homeassistant.components import input_boolean, switch from homeassistant.components.climate.const import ( ATTR_OPERATION_MODE, STATE_HEAT, STATE_COOL, DOMAIN) -import homeassistant.components as comps from tests.common import assert_setup_component, mock_restore_cache from tests.components.climate import common @@ -68,7 +67,7 @@ def setup_comp_1(hass): """Initialize components.""" hass.config.units = METRIC_SYSTEM assert hass.loop.run_until_complete( - comps.async_setup(hass, {}) + async_setup_component(hass, 'homeassistant', {}) ) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 69964a11fdcbf1..60df4a8e79c8ff 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -8,7 +8,7 @@ from homeassistant import core, const, setup from homeassistant.components import ( - fan, cover, light, switch, lock, async_setup, media_player) + fan, cover, light, switch, lock, media_player) from homeassistant.components.climate import const as climate from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.components import google_assistant as ga @@ -56,7 +56,7 @@ def assistant_client(loop, hass, aiohttp_client): def hass_fixture(loop, hass): """Set up a Home Assistant instance for these tests.""" # We need to do this to get access to homeassistant/turn_(on,off) - loop.run_until_complete(async_setup(hass, {core.DOMAIN: {}})) + loop.run_until_complete(setup.async_setup_component(hass, core.DOMAIN, {})) loop.run_until_complete( setup.async_setup_component(hass, light.DOMAIN, { diff --git a/tests/components/homeassistant/__init__.py b/tests/components/homeassistant/__init__.py new file mode 100644 index 00000000000000..334751e6de5613 --- /dev/null +++ b/tests/components/homeassistant/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant integration to provide core functionality.""" diff --git a/tests/components/init/test_init.py b/tests/components/homeassistant/test_init.py similarity index 94% rename from tests/components/init/test_init.py rename to tests/components/homeassistant/test_init.py index da06927db3a0c1..b72589e60e3238 100644 --- a/tests/components/init/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -12,7 +12,8 @@ SERVICE_HOMEASSISTANT_STOP, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE) import homeassistant.components as comps -from homeassistant.components import ( +from homeassistant.setup import async_setup_component +from homeassistant.components.homeassistant import ( SERVICE_CHECK_CONFIG, SERVICE_RELOAD_CORE_CONFIG) import homeassistant.helpers.intent as intent from homeassistant.exceptions import HomeAssistantError @@ -97,7 +98,8 @@ def setUp(self): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() assert run_coroutine_threadsafe( - comps.async_setup(self.hass, {}), self.hass.loop + async_setup_component(self.hass, 'homeassistant', {}), + self.hass.loop ).result() self.hass.states.set('light.Bowl', STATE_ON) @@ -186,7 +188,7 @@ def test_reload_core_conf(self): assert state.attributes.get('hello') == 'world' @patch('homeassistant.config.os.path.isfile', Mock(return_value=True)) - @patch('homeassistant.components._LOGGER.error') + @patch('homeassistant.components.homeassistant._LOGGER.error') @patch('homeassistant.config.async_process_ha_core_config') def test_reload_core_with_wrong_conf(self, mock_process, mock_error): """Test reload core conf service.""" @@ -244,7 +246,7 @@ def test_check_config(self, mock_check, mock_stop): async def test_turn_on_intent(hass): """Test HassTurnOn intent.""" - result = await comps.async_setup(hass, {}) + result = await async_setup_component(hass, 'homeassistant', {}) assert result hass.states.async_set('light.test_light', 'off') @@ -265,7 +267,7 @@ async def test_turn_on_intent(hass): async def test_turn_off_intent(hass): """Test HassTurnOff intent.""" - result = await comps.async_setup(hass, {}) + result = await async_setup_component(hass, 'homeassistant', {}) assert result hass.states.async_set('light.test_light', 'on') @@ -286,7 +288,7 @@ async def test_turn_off_intent(hass): async def test_toggle_intent(hass): """Test HassToggle intent.""" - result = await comps.async_setup(hass, {}) + result = await async_setup_component(hass, 'homeassistant', {}) assert result hass.states.async_set('light.test_light', 'off') @@ -310,7 +312,7 @@ async def test_turn_on_multiple_intent(hass): This tests that matching finds the proper entity among similar names. """ - result = await comps.async_setup(hass, {}) + result = await async_setup_component(hass, 'homeassistant', {}) assert result hass.states.async_set('light.test_light', 'off') @@ -333,7 +335,7 @@ async def test_turn_on_multiple_intent(hass): async def test_turn_on_to_not_block_for_domains_without_service(hass): """Test if turn_on is blocking domain with no service.""" - await comps.async_setup(hass, {}) + await async_setup_component(hass, 'homeassistant', {}) async_mock_service(hass, 'light', SERVICE_TURN_ON) hass.states.async_set('light.Bowl', STATE_ON) hass.states.async_set('light.Ceiling', STATE_OFF) @@ -359,7 +361,7 @@ async def test_turn_on_to_not_block_for_domains_without_service(hass): async def test_entity_update(hass): """Test being able to call entity update.""" - await comps.async_setup(hass, {}) + await async_setup_component(hass, 'homeassistant', {}) with patch('homeassistant.helpers.entity_component.async_update_entity', return_value=mock_coro()) as mock_update: diff --git a/tests/components/init/__init__.py b/tests/components/init/__init__.py deleted file mode 100644 index b935cf060c8170..00000000000000 --- a/tests/components/init/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the init component.""" diff --git a/tests/helpers/test_state.py b/tests/helpers/test_state.py index 5c04f085c86226..10b053528ab6fd 100644 --- a/tests/helpers/test_state.py +++ b/tests/helpers/test_state.py @@ -5,7 +5,7 @@ from unittest.mock import patch import homeassistant.core as ha -import homeassistant.components as core_components +from homeassistant.setup import async_setup_component from homeassistant.const import (SERVICE_TURN_ON, SERVICE_TURN_OFF) from homeassistant.util.async_ import run_coroutine_threadsafe from homeassistant.util import dt as dt_util @@ -88,8 +88,8 @@ class TestStateHelpers(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Run when tests are started.""" self.hass = get_test_home_assistant() - run_coroutine_threadsafe(core_components.async_setup( - self.hass, {}), self.hass.loop).result() + run_coroutine_threadsafe(async_setup_component( + self.hass, 'homeassistant', {}), self.hass.loop).result() def tearDown(self): # pylint: disable=invalid-name """Stop when tests are finished.""" From 77e7b63f4a2e5ef272c41ae72ef007c4892c7b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 26 Mar 2019 14:02:10 +0100 Subject: [PATCH 49/69] Tibber add support for Watty (#22397) --- homeassistant/components/tibber/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index eeae1587099b01..135437801d934b 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.9.9'] +REQUIREMENTS = ['pyTibber==0.10.0'] DOMAIN = 'tibber' diff --git a/requirements_all.txt b/requirements_all.txt index 401d22cd551371..8ad56528f4f0ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -925,7 +925,7 @@ pyRFXtrx==0.23 # pySwitchmate==0.4.5 # homeassistant.components.tibber -pyTibber==0.9.9 +pyTibber==0.10.0 # homeassistant.components.dlink.switch pyW215==0.6.0 From 3cca3c37f04098fa76c7dd4edba6d81d5bd65a57 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 26 Mar 2019 09:17:43 -0400 Subject: [PATCH 50/69] zha fixes (#22381) --- homeassistant/components/zha/api.py | 21 ++++++++----------- .../components/zha/core/channels/general.py | 11 +++++++--- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 4b4546821a7c75..8bfcbc705dc7cb 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -310,11 +310,10 @@ async def websocket_read_zigbee_cluster_attributes(hass, connection, msg): cluster_id = msg[ATTR_CLUSTER_ID] cluster_type = msg[ATTR_CLUSTER_TYPE] attribute = msg[ATTR_ATTRIBUTE] - manufacturer = None - # only use manufacturer code for manufacturer clusters - if cluster_id >= MFG_CLUSTER_ID_START: - manufacturer = msg.get(ATTR_MANUFACTURER) or None + manufacturer = msg.get(ATTR_MANUFACTURER) or None zha_device = zha_gateway.get_device(ieee) + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code success = failure = None if zha_device is not None: cluster = zha_device.async_get_cluster( @@ -476,11 +475,10 @@ async def set_zigbee_cluster_attributes(service): cluster_type = service.data.get(ATTR_CLUSTER_TYPE) attribute = service.data.get(ATTR_ATTRIBUTE) value = service.data.get(ATTR_VALUE) - manufacturer = None - # only use manufacturer code for manufacturer clusters - if cluster_id >= MFG_CLUSTER_ID_START: - manufacturer = service.data.get(ATTR_MANUFACTURER) or None + manufacturer = service.data.get(ATTR_MANUFACTURER) or None zha_device = zha_gateway.get_device(ieee) + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code response = None if zha_device is not None: response = await zha_device.write_zigbee_attribute( @@ -517,11 +515,10 @@ async def issue_zigbee_cluster_command(service): command = service.data.get(ATTR_COMMAND) command_type = service.data.get(ATTR_COMMAND_TYPE) args = service.data.get(ATTR_ARGS) - manufacturer = None - # only use manufacturer code for manufacturer clusters - if cluster_id >= MFG_CLUSTER_ID_START: - manufacturer = service.data.get(ATTR_MANUFACTURER) or None + manufacturer = service.data.get(ATTR_MANUFACTURER) or None zha_device = zha_gateway.get_device(ieee) + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code response = None if zha_device is not None: response = await zha_device.issue_cluster_command( diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index bf0f1044efb67c..061541d4dae4e5 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -7,7 +7,7 @@ import logging from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send -from . import ZigbeeChannel, parse_and_log_command +from . import ZigbeeChannel, parse_and_log_command, MAINS_POWERED from ..helpers import get_attr_id_by_name from ..const import ( SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, @@ -64,9 +64,14 @@ async def async_initialize(self, from_cache): async def async_update(self): """Initialize channel.""" - _LOGGER.debug("Attempting to update onoff state") + from_cache = not self.device.power_source == MAINS_POWERED + _LOGGER.debug( + "%s is attempting to update onoff state - from cache: %s", + self._unique_id, + from_cache + ) self._state = bool( - await self.get_attribute_value(self.ON_OFF, from_cache=False)) + await self.get_attribute_value(self.ON_OFF, from_cache=from_cache)) await super().async_update() From 2cebf9ef71c88e3db5ddcfab7a78cbba20f83e15 Mon Sep 17 00:00:00 2001 From: zewelor Date: Tue, 26 Mar 2019 14:18:53 +0100 Subject: [PATCH 51/69] Fix yeelight state update (#22373) --- homeassistant/components/yeelight/__init__.py | 4 ---- homeassistant/components/yeelight/light.py | 5 +++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 8a5c1e81a93fcc..7318b088ab4da3 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -259,8 +259,6 @@ def turn_on(self, duration=DEFAULT_TRANSITION): _LOGGER.error("Unable to turn the bulb on: %s", ex) return - self.update() - def turn_off(self, duration=DEFAULT_TRANSITION): """Turn off device.""" import yeelight @@ -271,8 +269,6 @@ def turn_off(self, duration=DEFAULT_TRANSITION): _LOGGER.error("Unable to turn the bulb on: %s", ex) return - self.update() - def update(self): """Read new properties from the device.""" if not self.bulb: diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index d208d1f72b0a08..22e5d9cc9cefed 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -494,6 +494,7 @@ def turn_on(self, **kwargs) -> None: except yeelight.BulbException as ex: _LOGGER.error("Unable to set the defaults: %s", ex) return + self.device.update() def turn_off(self, **kwargs) -> None: """Turn off.""" @@ -502,6 +503,7 @@ def turn_off(self, **kwargs) -> None: duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s self.device.turn_off(duration=duration) + self.device.update() def set_mode(self, mode: str): """Set a power mode.""" @@ -509,11 +511,10 @@ def set_mode(self, mode: str): try: self._bulb.set_power_mode(yeelight.enums.PowerMode[mode.upper()]) + self.device.update() except yeelight.BulbException as ex: _LOGGER.error("Unable to set the power mode: %s", ex) - self.device.update() - def start_flow(self, transitions, count=0, action=ACTION_RECOVER): """Start flow.""" import yeelight From c71e5ed588db54e4a6fbe0ddaed69b8f7b540d31 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 26 Mar 2019 09:20:50 -0400 Subject: [PATCH 52/69] Changed busy error to warning (#22398) --- homeassistant/components/radiotherm/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index bad20884536f41..4132d3c27c743f 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -246,8 +246,8 @@ def update(self): try: data = self.device.tstat['raw'] except radiotherm.validate.RadiothermTstatError: - _LOGGER.error('%s (%s) was busy (invalid value returned)', - self._name, self.device.host) + _LOGGER.warning('%s (%s) was busy (invalid value returned)', + self._name, self.device.host) return current_temp = data['temp'] From a27e821e8b73499d640604e3039a649c37c48b5a Mon Sep 17 00:00:00 2001 From: cgtobi Date: Tue, 26 Mar 2019 15:34:16 +0100 Subject: [PATCH 53/69] Migrate tts (#22403) * Migrate tts * Migrate tts tests * Update requirements * Fix path to demo mp3 --- .../{tts/amazon_polly.py => amazon_polly/tts.py} | 0 .../components/{tts/baidu.py => baidu/tts.py} | 0 homeassistant/components/demo/mailbox.py | 2 +- .../components/{tts/demo.mp3 => demo/tts.mp3} | Bin .../components/{tts/demo.py => demo/tts.py} | 4 ++-- .../components/{tts/marytts.py => marytts/tts.py} | 2 +- .../{tts/microsoft.py => microsoft/tts.py} | 0 .../components/{tts/picotts.py => picotts/tts.py} | 0 .../components/{tts/voicerss.py => voicerss/tts.py} | 2 +- .../{tts/yandextts.py => yandextts/tts.py} | 2 +- requirements_all.txt | 7 ------- tests/components/marytts/__init__.py | 1 + .../{tts/test_marytts.py => marytts/test_tts.py} | 2 +- tests/components/tts/test_init.py | 8 ++++---- tests/components/voicerss/__init__.py | 1 + .../{tts/test_voicerss.py => voicerss/test_tts.py} | 2 +- tests/components/yandextts/__init__.py | 1 + .../test_yandextts.py => yandextts/test_tts.py} | 2 +- 18 files changed, 16 insertions(+), 20 deletions(-) rename homeassistant/components/{tts/amazon_polly.py => amazon_polly/tts.py} (100%) rename homeassistant/components/{tts/baidu.py => baidu/tts.py} (100%) rename homeassistant/components/{tts/demo.mp3 => demo/tts.mp3} (100%) rename homeassistant/components/{tts/demo.py => demo/tts.py} (90%) rename homeassistant/components/{tts/marytts.py => marytts/tts.py} (97%) rename homeassistant/components/{tts/microsoft.py => microsoft/tts.py} (100%) rename homeassistant/components/{tts/picotts.py => picotts/tts.py} (100%) rename homeassistant/components/{tts/voicerss.py => voicerss/tts.py} (98%) rename homeassistant/components/{tts/yandextts.py => yandextts/tts.py} (98%) create mode 100644 tests/components/marytts/__init__.py rename tests/components/{tts/test_marytts.py => marytts/test_tts.py} (98%) create mode 100644 tests/components/voicerss/__init__.py rename tests/components/{tts/test_voicerss.py => voicerss/test_tts.py} (99%) create mode 100644 tests/components/yandextts/__init__.py rename tests/components/{tts/test_yandextts.py => yandextts/test_tts.py} (99%) diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/amazon_polly/tts.py similarity index 100% rename from homeassistant/components/tts/amazon_polly.py rename to homeassistant/components/amazon_polly/tts.py diff --git a/homeassistant/components/tts/baidu.py b/homeassistant/components/baidu/tts.py similarity index 100% rename from homeassistant/components/tts/baidu.py rename to homeassistant/components/baidu/tts.py diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py index 885988adb6b512..fcffc44eefb834 100644 --- a/homeassistant/components/demo/mailbox.py +++ b/homeassistant/components/demo/mailbox.py @@ -63,7 +63,7 @@ async def async_get_media(self, msgid): raise StreamError("Message not found") audio_path = os.path.join( - os.path.dirname(__file__), '..', 'tts', 'demo.mp3') + os.path.dirname(__file__), 'tts.mp3') with open(audio_path, 'rb') as file: return file.read() diff --git a/homeassistant/components/tts/demo.mp3 b/homeassistant/components/demo/tts.mp3 similarity index 100% rename from homeassistant/components/tts/demo.mp3 rename to homeassistant/components/demo/tts.mp3 diff --git a/homeassistant/components/tts/demo.py b/homeassistant/components/demo/tts.py similarity index 90% rename from homeassistant/components/tts/demo.py rename to homeassistant/components/demo/tts.py index 6784e7cea61c4e..1498472ef9f29c 100644 --- a/homeassistant/components/tts/demo.py +++ b/homeassistant/components/demo/tts.py @@ -8,7 +8,7 @@ import voluptuous as vol -from . import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider SUPPORT_LANGUAGES = [ 'en', 'de' @@ -51,7 +51,7 @@ def supported_options(self): def get_tts_audio(self, message, language, options=None): """Load TTS from demo.""" - filename = os.path.join(os.path.dirname(__file__), 'demo.mp3') + filename = os.path.join(os.path.dirname(__file__), 'tts.mp3') try: with open(filename, 'rb') as voice: data = voice.read() diff --git a/homeassistant/components/tts/marytts.py b/homeassistant/components/marytts/tts.py similarity index 97% rename from homeassistant/components/tts/marytts.py rename to homeassistant/components/marytts/tts.py index 971d3fb5705d7b..8f6a46b0c3ebdb 100644 --- a/homeassistant/components/tts/marytts.py +++ b/homeassistant/components/marytts/tts.py @@ -16,7 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from . import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tts/microsoft.py b/homeassistant/components/microsoft/tts.py similarity index 100% rename from homeassistant/components/tts/microsoft.py rename to homeassistant/components/microsoft/tts.py diff --git a/homeassistant/components/tts/picotts.py b/homeassistant/components/picotts/tts.py similarity index 100% rename from homeassistant/components/tts/picotts.py rename to homeassistant/components/picotts/tts.py diff --git a/homeassistant/components/tts/voicerss.py b/homeassistant/components/voicerss/tts.py similarity index 98% rename from homeassistant/components/tts/voicerss.py rename to homeassistant/components/voicerss/tts.py index 3676dff3bc641b..20e0ee11db3928 100644 --- a/homeassistant/components/tts/voicerss.py +++ b/homeassistant/components/voicerss/tts.py @@ -15,7 +15,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from . import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tts/yandextts.py b/homeassistant/components/yandextts/tts.py similarity index 98% rename from homeassistant/components/tts/yandextts.py rename to homeassistant/components/yandextts/tts.py index aecba2925ddfe6..281839a2d74cb6 100644 --- a/homeassistant/components/tts/yandextts.py +++ b/homeassistant/components/yandextts/tts.py @@ -15,7 +15,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from . import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 8ad56528f4f0ae..f726f57471b8d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -191,9 +191,6 @@ av==6.1.2 # homeassistant.components.axis axis==17 -# homeassistant.components.tts.baidu -baidu-aip==1.6.6 - # homeassistant.components.modem_callerid.sensor basicmodem==0.7 @@ -236,7 +233,6 @@ blockchain==1.4.4 # homeassistant.components.notify.aws_lambda # homeassistant.components.notify.aws_sns # homeassistant.components.notify.aws_sqs -# homeassistant.components.tts.amazon_polly boto3==1.9.16 # homeassistant.scripts.credstash @@ -991,9 +987,6 @@ pycomfoconnect==0.3 # homeassistant.components.coolmaster.climate pycoolmasternet==0.0.4 -# homeassistant.components.tts.microsoft -pycsspeechtts==1.0.2 - # homeassistant.components.cups.sensor # pycups==1.9.73 diff --git a/tests/components/marytts/__init__.py b/tests/components/marytts/__init__.py new file mode 100644 index 00000000000000..061776a1398697 --- /dev/null +++ b/tests/components/marytts/__init__.py @@ -0,0 +1 @@ +"""The tests for marytts tts platforms.""" diff --git a/tests/components/tts/test_marytts.py b/tests/components/marytts/test_tts.py similarity index 98% rename from tests/components/tts/test_marytts.py rename to tests/components/marytts/test_tts.py index 7520ba2fbaad8e..24915dd85c8dcf 100644 --- a/tests/components/tts/test_marytts.py +++ b/tests/components/marytts/test_tts.py @@ -11,7 +11,7 @@ from tests.common import ( get_test_home_assistant, assert_setup_component, mock_service) -from .test_init import mutagen_mock # noqa +from tests.components.tts.test_init import mutagen_mock # noqa class TestTTSMaryTTSPlatform: diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 4786370f24f655..140a938201bc35 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -9,7 +9,7 @@ import homeassistant.components.http as http import homeassistant.components.tts as tts -from homeassistant.components.tts.demo import DemoProvider +from homeassistant.components.demo.tts import DemoProvider from homeassistant.components.media_player.const import ( SERVICE_PLAY_MEDIA, MEDIA_TYPE_MUSIC, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP) @@ -229,7 +229,7 @@ def test_setup_component_and_test_service_with_service_options(self): "265944c108cbb00b2a621be5930513e03a0bb2cd_de_{0}_demo.mp3".format( opt_hash))) - @patch('homeassistant.components.tts.demo.DemoProvider.default_options', + @patch('homeassistant.components.demo.tts.DemoProvider.default_options', new_callable=PropertyMock(return_value={'voice': 'alex'})) def test_setup_component_and_test_with_service_options_def(self, def_mock): """Set up the demo platform and call service with default options.""" @@ -519,7 +519,7 @@ def test_setup_component_test_with_cache_dir(self): with assert_setup_component(1, tts.DOMAIN): setup_component(self.hass, tts.DOMAIN, config) - with patch('homeassistant.components.tts.demo.DemoProvider.' + with patch('homeassistant.components.demo.tts.DemoProvider.' 'get_tts_audio', return_value=(None, None)): self.hass.services.call(tts.DOMAIN, 'demo_say', { tts.ATTR_MESSAGE: "I person is on front of your door.", @@ -531,7 +531,7 @@ def test_setup_component_test_with_cache_dir(self): "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" \ "_en_-_demo.mp3".format(self.hass.config.api.base_url) - @patch('homeassistant.components.tts.demo.DemoProvider.get_tts_audio', + @patch('homeassistant.components.demo.tts.DemoProvider.get_tts_audio', return_value=(None, None)) def test_setup_component_test_with_error_on_get_tts(self, tts_mock): """Set up demo platform with wrong get_tts_audio.""" diff --git a/tests/components/voicerss/__init__.py b/tests/components/voicerss/__init__.py new file mode 100644 index 00000000000000..9c037a14465bbe --- /dev/null +++ b/tests/components/voicerss/__init__.py @@ -0,0 +1 @@ +"""The tests for VoiceRSS tts platforms.""" diff --git a/tests/components/tts/test_voicerss.py b/tests/components/voicerss/test_tts.py similarity index 99% rename from tests/components/tts/test_voicerss.py rename to tests/components/voicerss/test_tts.py index af4bdf3976c39d..cd0e20cb9fab3a 100644 --- a/tests/components/tts/test_voicerss.py +++ b/tests/components/voicerss/test_tts.py @@ -11,7 +11,7 @@ from tests.common import ( get_test_home_assistant, assert_setup_component, mock_service) -from .test_init import mutagen_mock # noqa +from tests.components.tts.test_init import mutagen_mock # noqa class TestTTSVoiceRSSPlatform: diff --git a/tests/components/yandextts/__init__.py b/tests/components/yandextts/__init__.py new file mode 100644 index 00000000000000..54968b3605fa88 --- /dev/null +++ b/tests/components/yandextts/__init__.py @@ -0,0 +1 @@ +"""The tests for YandexTTS tts platforms.""" diff --git a/tests/components/tts/test_yandextts.py b/tests/components/yandextts/test_tts.py similarity index 99% rename from tests/components/tts/test_yandextts.py rename to tests/components/yandextts/test_tts.py index 70c75b1f2ed471..dd382271338cc6 100644 --- a/tests/components/tts/test_yandextts.py +++ b/tests/components/yandextts/test_tts.py @@ -10,7 +10,7 @@ from tests.common import ( get_test_home_assistant, assert_setup_component, mock_service) -from .test_init import mutagen_mock # noqa +from tests.components.tts.test_init import mutagen_mock # noqa class TestTTSYandexPlatform: From 3fddf5df08d6a3afd1b8d8cc7c88d643ec0d4050 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 26 Mar 2019 15:38:25 +0100 Subject: [PATCH 54/69] Enable hass.io panel without ping (#22388) * Enable hass.io panel without ping * fix tests --- homeassistant/components/hassio/__init__.py | 3 +-- homeassistant/components/hassio/handler.py | 2 +- tests/components/hassio/test_init.py | 7 ++++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 90e120d8b0eafe..073974200a0935 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -145,8 +145,7 @@ async def async_setup(hass, config): hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) if not await hassio.is_connected(): - _LOGGER.error("Not connected with Hass.io") - return False + _LOGGER.warning("Not connected with Hass.io / system to busy!") store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) data = await store.async_load() diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 7eb3245c0df5d6..7eddc639690a02 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -62,7 +62,7 @@ def is_connected(self): This method return a coroutine. """ - return self.send_command("/supervisor/ping", method="get") + return self.send_command("/supervisor/ping", method="get", timeout=15) @_api_data def get_homeassistant_info(self): diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index ba642b698f7afb..0c651aa0c5a9aa 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -219,15 +219,16 @@ def test_fail_setup_without_environ_var(hass): @asyncio.coroutine -def test_fail_setup_cannot_connect(hass): +def test_fail_setup_cannot_connect(hass, caplog): """Fail setup if cannot connect.""" with patch.dict(os.environ, MOCK_ENVIRON), \ patch('homeassistant.components.hassio.HassIO.is_connected', Mock(return_value=mock_coro(None))): result = yield from async_setup_component(hass, 'hassio', {}) - assert not result + assert result - assert not hass.components.hassio.is_hassio() + assert hass.components.hassio.is_hassio() + assert "Not connected with Hass.io / system to busy!" in caplog.text @asyncio.coroutine From 133ae63ed08480cb34d8a77546589d1a220da56e Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 26 Mar 2019 14:39:05 +0000 Subject: [PATCH 55/69] Add missing append (#22414) --- homeassistant/components/homekit_controller/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 5e470e1540baa6..0ed208af979e12 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -102,6 +102,7 @@ def accessory_setup(self): if component is not None: discovery.load_platform(self.hass, component, DOMAIN, service_info, self.config) + self.entities.append((aid, iid)) def device_config_callback(self, callback_data): """Handle initial pairing.""" From 7519e8d4175659607cf113171943d80024eee49e Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Tue, 26 Mar 2019 07:48:26 -0700 Subject: [PATCH 56/69] Update translate, fix dev build error (#22419) --- .../components/axis/.translations/en.json | 30 +++++++++---------- .../components/ps4/.translations/en.json | 22 +++++++------- homeassistant/components/ps4/strings.json | 2 +- .../tellduslive/.translations/en.json | 1 - .../components/upnp/.translations/en.json | 15 ---------- .../components/zha/.translations/en.json | 1 + 6 files changed, 28 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/axis/.translations/en.json b/homeassistant/components/axis/.translations/en.json index 3c528dfbb16112..6c5933dfd97263 100644 --- a/homeassistant/components/axis/.translations/en.json +++ b/homeassistant/components/axis/.translations/en.json @@ -1,26 +1,26 @@ { "config": { - "title": "Axis device", + "abort": { + "already_configured": "Device is already configured", + "bad_config_file": "Bad data from config file", + "link_local_address": "Link local addresses are not supported" + }, + "error": { + "already_configured": "Device is already configured", + "device_unavailable": "Device is not available", + "faulty_credentials": "Bad user credentials" + }, "step": { "user": { - "title": "Set up Axis device", "data": { "host": "Host", - "username": "Username", "password": "Password", - "port": "Port" - } + "port": "Port", + "username": "Username" + }, + "title": "Set up Axis device" } }, - "error": { - "already_configured": "Device is already configured", - "device_unavailable": "Device is not available", - "faulty_credentials": "Bad user credentials" - }, - "abort": { - "already_configured": "Device is already configured", - "bad_config_file": "Bad data from config file", - "link_local_address": "Link local addresses are not supported" - } + "title": "Axis device" } } \ No newline at end of file diff --git a/homeassistant/components/ps4/.translations/en.json b/homeassistant/components/ps4/.translations/en.json index 662f6fb6116d75..8949e77b4cceff 100644 --- a/homeassistant/components/ps4/.translations/en.json +++ b/homeassistant/components/ps4/.translations/en.json @@ -9,22 +9,14 @@ }, "error": { "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct.", - "not_ready": "PlayStation 4 is not on or connected to network.", - "no_ipaddress": "Enter the IP Address of the PlayStation 4 you would like to configure." + "no_ipaddress": "Enter the IP Address of the PlayStation 4 you would like to configure.", + "not_ready": "PlayStation 4 is not on or connected to network." }, "step": { "creds": { "description": "Credentials needed. Press 'Submit' and then in the PS4 2nd Screen App, refresh devices and select the 'Home-Assistant' device to continue.", "title": "PlayStation 4" }, - "mode": { - "data": { - "mode": "Config Mode", - "ip_address": "IP Address (Leave empty if using Auto Discovery)." - }, - "description": "Select mode for configuration. The IP Address field can be left blank if selecting Auto Discovery, as devices will be automatically discovered.", - "title": "PlayStation 4" - }, "link": { "data": { "code": "PIN", @@ -34,8 +26,16 @@ }, "description": "Enter your PlayStation 4 information. For 'PIN', navigate to 'Settings' on your PlayStation 4 console. Then navigate to 'Mobile App Connection Settings' and select 'Add Device'. Enter the PIN that is displayed. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info.", "title": "PlayStation 4" + }, + "mode": { + "data": { + "ip_address": "IP Address (Leave empty if using Auto Discovery).", + "mode": "Config Mode" + }, + "description": "Select mode for configuration. The IP Address field can be left blank if selecting Auto Discovery, as devices will be automatically discovered.", + "title": "PlayStation 4" } }, "title": "PlayStation 4" } -} +} \ No newline at end of file diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index d8fdc9e18dbcd4..ea69d8c7a8c873 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -12,7 +12,7 @@ "data": { "mode": "Config Mode", "ip_address": "IP Address (Leave empty if using Auto Discovery)." - }, + } }, "link": { "title": "PlayStation 4", diff --git a/homeassistant/components/tellduslive/.translations/en.json b/homeassistant/components/tellduslive/.translations/en.json index c2b00561858746..4ed9ef597f489d 100644 --- a/homeassistant/components/tellduslive/.translations/en.json +++ b/homeassistant/components/tellduslive/.translations/en.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "all_configured": "TelldusLive is already configured", "already_setup": "TelldusLive is already configured", "authorize_url_fail": "Unknown error generating an authorize url.", "authorize_url_timeout": "Timeout generating authorize url.", diff --git a/homeassistant/components/upnp/.translations/en.json b/homeassistant/components/upnp/.translations/en.json index 632d5112f1ae2e..91e4f6b7c52574 100644 --- a/homeassistant/components/upnp/.translations/en.json +++ b/homeassistant/components/upnp/.translations/en.json @@ -1,28 +1,13 @@ { "config": { "abort": { - "already_configured": "UPnP/IGD is already configured", - "incomplete_device": "Ignoring incomplete UPnP device", - "no_devices_discovered": "No UPnP/IGDs discovered", "no_devices_found": "No UPnP/IGD devices found on the network.", - "no_sensors_or_port_mapping": "Enable at least sensors or port mapping", "single_instance_allowed": "Only a single configuration of UPnP/IGD is necessary." }, "step": { "confirm": { "description": "Do you want to set up UPnP/IGD?", "title": "UPnP/IGD" - }, - "init": { - "title": "UPnP/IGD" - }, - "user": { - "data": { - "enable_port_mapping": "Enable port mapping for Home Assistant", - "enable_sensors": "Add traffic sensors", - "igd": "UPnP/IGD" - }, - "title": "Configuration options for the UPnP/IGD" } }, "title": "UPnP/IGD" diff --git a/homeassistant/components/zha/.translations/en.json b/homeassistant/components/zha/.translations/en.json index f0da251f5eb643..82489ac258ec14 100644 --- a/homeassistant/components/zha/.translations/en.json +++ b/homeassistant/components/zha/.translations/en.json @@ -12,6 +12,7 @@ "radio_type": "Radio Type", "usb_path": "USB Device Path" }, + "description": "", "title": "ZHA" } }, From afa99c91893957b60df80bf83bd306a1dc7cdd43 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 26 Mar 2019 16:06:11 +0100 Subject: [PATCH 57/69] Use dispatcher for netgear_lte state updates (#22328) * Use dispatcher for netgear_lte state updates * Also dispatch unavailable state --- .../components/netgear_lte/__init__.py | 18 ++++++++++++++---- homeassistant/components/netgear_lte/sensor.py | 17 ++++++++++++++--- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 34330426e34232..730c3851a2dea5 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -15,7 +15,8 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_create_clientsession -from homeassistant.util import Throttle +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval from . import sensor_types @@ -23,7 +24,8 @@ _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=10) +DISPATCHER_NETGEAR_LTE = 'netgear_lte_update' DOMAIN = 'netgear_lte' DATA_KEY = 'netgear_lte' @@ -56,6 +58,7 @@ class ModemData: """Class for modem state.""" + hass = attr.ib() host = attr.ib() modem = attr.ib() @@ -64,7 +67,6 @@ class ModemData: usage = attr.ib(init=False, default=None) connected = attr.ib(init=False, default=True) - @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Call the API to update the data.""" import eternalegypt @@ -83,6 +85,8 @@ async def async_update(self): self.unread_count = None self.usage = None + async_dispatcher_send(self.hass, DISPATCHER_NETGEAR_LTE) + @attr.s class LTEData: @@ -143,7 +147,7 @@ async def _setup_lte(hass, lte_config): websession = hass.data[DATA_KEY].websession modem = eternalegypt.Modem(hostname=host, websession=websession) - modem_data = ModemData(host, modem) + modem_data = ModemData(hass, host, modem) try: await _login(hass, modem_data, password) @@ -172,6 +176,12 @@ async def cleanup(event): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + async def _update(now): + """Periodic update.""" + await modem_data.async_update() + + async_track_time_interval(hass, _update, SCAN_INTERVAL) + async def _retry_login(hass, modem_data, password): """Sleep and retry setup.""" diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 774cdc5536a1fd..a13f5fbfaa7749 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -6,8 +6,9 @@ from homeassistant.components.sensor import DOMAIN from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import CONF_MONITORED_CONDITIONS, DATA_KEY +from . import CONF_MONITORED_CONDITIONS, DATA_KEY, DISPATCHER_NETGEAR_LTE from .sensor_types import SENSOR_SMS, SENSOR_USAGE DEPENDENCIES = ['netgear_lte'] @@ -36,7 +37,7 @@ async def async_setup_platform( elif sensor_type == SENSOR_USAGE: sensors.append(UsageSensor(modem_data, sensor_type)) - async_add_entities(sensors, True) + async_add_entities(sensors) @attr.s @@ -46,10 +47,20 @@ class LTESensor(Entity): modem_data = attr.ib() sensor_type = attr.ib() + async def async_added_to_hass(self): + """Register callback.""" + async_dispatcher_connect( + self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state) + async def async_update(self): - """Update state.""" + """Force update of state.""" await self.modem_data.async_update() + @property + def should_poll(self): + """Return that the sensor should not be polled.""" + return False + @property def unique_id(self): """Return a unique ID like 'usage_5TG365AB0078V'.""" From 7e3567319f767765dd899af8b01e64e6f757b247 Mon Sep 17 00:00:00 2001 From: Finbarr Brady Date: Tue, 26 Mar 2019 20:13:56 +0000 Subject: [PATCH 58/69] ciscomobilityexpress pypi version update (#22431) * Bump pip * Bump ciscomobilityexpress to 0.1.4 --- .../components/cisco_mobility_express/device_tracker.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py index 60f8761aeeb909..c5e2b4284d32a0 100644 --- a/homeassistant/components/cisco_mobility_express/device_tracker.py +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -10,7 +10,7 @@ CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL) -REQUIREMENTS = ['ciscomobilityexpress==0.1.2'] +REQUIREMENTS = ['ciscomobilityexpress==0.1.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f726f57471b8d4..fc8d7357d9231e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -268,7 +268,7 @@ buienradar==0.91 caldav==0.5.0 # homeassistant.components.cisco_mobility_express.device_tracker -ciscomobilityexpress==0.1.2 +ciscomobilityexpress==0.1.4 # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 From 80250add9e80f53a5e92bca5c7ac2dbaf06319c6 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 26 Mar 2019 22:42:43 +0100 Subject: [PATCH 59/69] Update homeassistant-pyozw to 0.1.3 (#22433) --- homeassistant/components/zwave/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 604f03387bd6ec..4abaaa312109b8 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -37,7 +37,7 @@ from .util import (check_node_schema, check_value_schema, node_name, check_has_unique_id, is_node_parsed) -REQUIREMENTS = ['pydispatcher==2.0.5', 'homeassistant-pyozw==0.1.2'] +REQUIREMENTS = ['pydispatcher==2.0.5', 'homeassistant-pyozw==0.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index fc8d7357d9231e..a5642a989d7887 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -550,7 +550,7 @@ holidays==0.9.10 home-assistant-frontend==20190321.0 # homeassistant.components.zwave -homeassistant-pyozw==0.1.2 +homeassistant-pyozw==0.1.3 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From 02b12ec1b9cfd6246095e09af286d3a5c65e9a90 Mon Sep 17 00:00:00 2001 From: Finbarr Brady Date: Tue, 26 Mar 2019 21:49:53 +0000 Subject: [PATCH 60/69] Adding conf for deep standby, wake and specific source bouquet of Enigma2 (#22393) * - adding deep standby, conf for wake and specify source bouquet * set defaults to strings * bump pip * bump pip * bump pip * bump pip * bump pip * bump pip --- .../components/enigma2/media_player.py | 20 +++++++++++++++++-- requirements_all.txt | 2 +- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 40101120f129d9..0b6f995be97123 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -14,7 +14,7 @@ STATE_OFF, STATE_ON, STATE_PLAYING, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['openwebifpy==1.2.7'] +REQUIREMENTS = ['openwebifpy==3.1.0'] _LOGGER = logging.getLogger(__name__) @@ -24,6 +24,9 @@ ATTR_MEDIA_START_TIME = 'media_start_time' CONF_USE_CHANNEL_ICON = "use_channel_icon" +CONF_DEEP_STANDBY = "deep_standby" +CONF_MAC_ADDRESS = "mac_address" +CONF_SOURCE_BOUQUET = "source_bouquet" DEFAULT_NAME = 'Enigma2 Media Player' DEFAULT_PORT = 80 @@ -31,6 +34,9 @@ DEFAULT_USE_CHANNEL_ICON = False DEFAULT_USERNAME = 'root' DEFAULT_PASSWORD = 'dreambox' +DEFAULT_DEEP_STANDBY = False +DEFAULT_MAC_ADDRESS = '' +DEFAULT_SOURCE_BOUQUET = '' SUPPORTED_ENIGMA2 = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_OFF | SUPPORT_NEXT_TRACK | SUPPORT_STOP | \ @@ -46,6 +52,10 @@ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_USE_CHANNEL_ICON, default=DEFAULT_USE_CHANNEL_ICON): cv.boolean, + vol.Optional(CONF_DEEP_STANDBY, default=DEFAULT_DEEP_STANDBY): cv.boolean, + vol.Optional(CONF_MAC_ADDRESS, default=DEFAULT_MAC_ADDRESS): cv.string, + vol.Optional(CONF_SOURCE_BOUQUET, + default=DEFAULT_SOURCE_BOUQUET): cv.string, }) @@ -62,6 +72,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config[CONF_PASSWORD] = DEFAULT_PASSWORD config[CONF_SSL] = DEFAULT_SSL config[CONF_USE_CHANNEL_ICON] = DEFAULT_USE_CHANNEL_ICON + config[CONF_MAC_ADDRESS] = DEFAULT_MAC_ADDRESS + config[CONF_DEEP_STANDBY] = DEFAULT_DEEP_STANDBY + config[CONF_SOURCE_BOUQUET] = DEFAULT_SOURCE_BOUQUET from openwebif.api import CreateDevice device = \ @@ -70,7 +83,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), is_https=config.get(CONF_SSL), - prefer_picon=config.get(CONF_USE_CHANNEL_ICON)) + prefer_picon=config.get(CONF_USE_CHANNEL_ICON), + mac_address=config.get(CONF_MAC_ADDRESS), + turn_off_to_deep=config.get(CONF_DEEP_STANDBY), + source_bouquet=config.get(CONF_SOURCE_BOUQUET)) add_devices([Enigma2Device(config[CONF_NAME], device)], True) diff --git a/requirements_all.txt b/requirements_all.txt index a5642a989d7887..75e3ece960eef4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -785,7 +785,7 @@ openhomedevice==0.4.2 opensensemap-api==0.1.5 # homeassistant.components.enigma2.media_player -openwebifpy==1.2.7 +openwebifpy==3.1.0 # homeassistant.components.luci.device_tracker openwrt-luci-rpc==1.0.5 From 176653681259d2aa56449f8e37d89db8058c4355 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 26 Mar 2019 15:24:28 -0700 Subject: [PATCH 61/69] Fix test name (#22421) --- tests/components/hassio/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 0c651aa0c5a9aa..fc4661e7544bd9 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -219,8 +219,8 @@ def test_fail_setup_without_environ_var(hass): @asyncio.coroutine -def test_fail_setup_cannot_connect(hass, caplog): - """Fail setup if cannot connect.""" +def test_warn_when_cannot_connect(hass, caplog): + """Fail warn when we cannot connect.""" with patch.dict(os.environ, MOCK_ENVIRON), \ patch('homeassistant.components.hassio.HassIO.is_connected', Mock(return_value=mock_coro(None))): From 19d99ddf57578fc7daed3dd60bbbbaa07fcdb7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Lov=C3=A9n?= Date: Wed, 27 Mar 2019 00:18:32 +0100 Subject: [PATCH 62/69] Lower severity level of log messages from http.view (#21091) --- homeassistant/components/http/view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index daac9fef74823e..d68cabebacf39f 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -102,8 +102,8 @@ async def handle(request): else: raise HTTPUnauthorized() - _LOGGER.info('Serving %s to %s (auth: %s)', - request.path, request.get(KEY_REAL_IP), authenticated) + _LOGGER.debug('Serving %s to %s (auth: %s)', + request.path, request.get(KEY_REAL_IP), authenticated) try: result = handler(request, **request.match_info) From a55afa8119dd7ff6f70d10fbcec7f73bd7176a93 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 27 Mar 2019 07:55:05 +0100 Subject: [PATCH 63/69] Update ha-ffmpeg 2.0 (#22427) --- homeassistant/components/amcrest/camera.py | 5 +++-- homeassistant/components/arlo/camera.py | 5 +++-- homeassistant/components/canary/camera.py | 7 ++++--- homeassistant/components/ffmpeg/__init__.py | 2 +- homeassistant/components/ffmpeg/camera.py | 7 ++++--- .../components/ffmpeg_motion/binary_sensor.py | 2 +- homeassistant/components/ffmpeg_noise/binary_sensor.py | 2 +- homeassistant/components/onvif/camera.py | 7 ++++--- homeassistant/components/ring/camera.py | 7 ++++--- homeassistant/components/xiaomi/camera.py | 7 ++++--- homeassistant/components/yi/camera.py | 7 ++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../test_ffmpeg.py => ffmpeg/test_sensor.py} | 10 ++++++---- 14 files changed, 41 insertions(+), 31 deletions(-) rename tests/components/{binary_sensor/test_ffmpeg.py => ffmpeg/test_sensor.py} (94%) diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 7ba3ea04bf5563..35d5e18fdd3505 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -78,7 +78,7 @@ async def handle_async_mjpeg_stream(self, request): self.hass, request, stream_coro) # streaming via ffmpeg - from haffmpeg import CameraMjpeg + from haffmpeg.camera import CameraMjpeg streaming_url = self._camera.rtsp_url(typeno=self._resolution) stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) @@ -86,8 +86,9 @@ async def handle_async_mjpeg_stream(self, request): streaming_url, extra_cmd=self._ffmpeg_arguments) try: + stream_reader = await stream.get_reader() return await async_aiohttp_proxy_stream( - self.hass, request, stream, + self.hass, request, stream_reader, self._ffmpeg.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py index 43ccabb7390b98..95d11318bf7cbf 100644 --- a/homeassistant/components/arlo/camera.py +++ b/homeassistant/components/arlo/camera.py @@ -83,7 +83,7 @@ def _update_callback(self): async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" - from haffmpeg import CameraMjpeg + from haffmpeg.camera import CameraMjpeg video = self._camera.last_video if not video: error_msg = \ @@ -97,8 +97,9 @@ async def handle_async_mjpeg_stream(self, request): video.video_url, extra_cmd=self._ffmpeg_arguments) try: + stream_reader = await stream.get_reader() return await async_aiohttp_proxy_stream( - self.hass, request, stream, + self.hass, request, stream_reader, self._ffmpeg.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index c54565d6fde990..63c27d31d9339c 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -80,7 +80,7 @@ async def async_camera_image(self): """Return a still image response from the camera.""" self.renew_live_stream_session() - from haffmpeg import ImageFrame, IMAGE_JPEG + from haffmpeg.tools import ImageFrame, IMAGE_JPEG ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) image = await asyncio.shield(ffmpeg.get_image( self._live_stream_session.live_stream_url, @@ -93,15 +93,16 @@ async def handle_async_mjpeg_stream(self, request): if self._live_stream_session is None: return - from haffmpeg import CameraMjpeg + from haffmpeg.camera import CameraMjpeg stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) await stream.open_camera( self._live_stream_session.live_stream_url, extra_cmd=self._ffmpeg_arguments) try: + stream_reader = await stream.get_reader() return await async_aiohttp_proxy_stream( - self.hass, request, stream, + self.hass, request, stream_reader, self._ffmpeg.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 7b7e3a81294088..05bc1d991678d0 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -12,7 +12,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['ha-ffmpeg==1.11'] +REQUIREMENTS = ['ha-ffmpeg==2.0'] DOMAIN = 'ffmpeg' diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index 4272a3d6029dd9..dbb51bf27c7caf 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -48,7 +48,7 @@ def __init__(self, hass, config): async def async_camera_image(self): """Return a still image response from the camera.""" - from haffmpeg import ImageFrame, IMAGE_JPEG + from haffmpeg.tools import ImageFrame, IMAGE_JPEG ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) image = await asyncio.shield(ffmpeg.get_image( @@ -58,15 +58,16 @@ async def async_camera_image(self): async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" - from haffmpeg import CameraMjpeg + from haffmpeg.camera import CameraMjpeg stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) await stream.open_camera( self._input, extra_cmd=self._extra_arguments) try: + stream_reader = await stream.get_reader() return await async_aiohttp_proxy_stream( - self.hass, request, stream, + self.hass, request, stream_reader, self._manager.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py index d0e597e13c01a4..8183b0e66a61a9 100644 --- a/homeassistant/components/ffmpeg_motion/binary_sensor.py +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -86,7 +86,7 @@ class FFmpegMotion(FFmpegBinarySensor): def __init__(self, hass, manager, config): """Initialize FFmpeg motion binary sensor.""" - from haffmpeg import SensorMotion + from haffmpeg.sensor import SensorMotion super().__init__(config) self.ffmpeg = SensorMotion( diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py index 070c8c61b00be9..56edf1ddfd6a2e 100644 --- a/homeassistant/components/ffmpeg_noise/binary_sensor.py +++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py @@ -55,7 +55,7 @@ class FFmpegNoise(FFmpegBinarySensor): def __init__(self, hass, manager, config): """Initialize FFmpeg noise binary sensor.""" - from haffmpeg import SensorNoise + from haffmpeg.sensor import SensorNoise super().__init__(config) self.ffmpeg = SensorNoise( diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index f3b25e3a1285a5..36f1b18eebf0e7 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -190,7 +190,7 @@ async def async_added_to_hass(self): async def async_camera_image(self): """Return a still image response from the camera.""" - from haffmpeg import ImageFrame, IMAGE_JPEG + from haffmpeg.tools import ImageFrame, IMAGE_JPEG if not self._input: await self.hass.async_add_job(self.obtain_input_uri) @@ -207,7 +207,7 @@ async def async_camera_image(self): async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" - from haffmpeg import CameraMjpeg + from haffmpeg.camera import CameraMjpeg if not self._input: await self.hass.async_add_job(self.obtain_input_uri) @@ -221,8 +221,9 @@ async def handle_async_mjpeg_stream(self, request): self._input, extra_cmd=self._ffmpeg_arguments) try: + stream_reader = await stream.get_reader() return await async_aiohttp_proxy_stream( - self.hass, request, stream, + self.hass, request, stream_reader, ffmpeg_manager.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index efcdf8599a9bb8..8970e61b1a1fa0 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -115,7 +115,7 @@ def device_state_attributes(self): async def async_camera_image(self): """Return a still image response from the camera.""" - from haffmpeg import ImageFrame, IMAGE_JPEG + from haffmpeg.tools import ImageFrame, IMAGE_JPEG ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) if self._video_url is None: @@ -128,7 +128,7 @@ async def async_camera_image(self): async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" - from haffmpeg import CameraMjpeg + from haffmpeg.camera import CameraMjpeg if self._video_url is None: return @@ -138,8 +138,9 @@ async def handle_async_mjpeg_stream(self, request): self._video_url, extra_cmd=self._ffmpeg_arguments) try: + stream_reader = await stream.get_reader() return await async_aiohttp_proxy_stream( - self.hass, request, stream, + self.hass, request, stream_reader, self._ffmpeg.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index 93e9dd4a07c8e0..d8cd59129abb59 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -138,7 +138,7 @@ def get_latest_video_url(self): async def async_camera_image(self): """Return a still image response from the camera.""" - from haffmpeg import ImageFrame, IMAGE_JPEG + from haffmpeg.tools import ImageFrame, IMAGE_JPEG url = await self.hass.async_add_job(self.get_latest_video_url) if url != self._last_url: @@ -152,15 +152,16 @@ async def async_camera_image(self): async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" - from haffmpeg import CameraMjpeg + from haffmpeg.camera import CameraMjpeg stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) await stream.open_camera( self._last_url, extra_cmd=self._extra_arguments) try: + stream_reader = await stream.get_reader() return await async_aiohttp_proxy_stream( - self.hass, request, stream, + self.hass, request, stream_reader, self._manager.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index 7d731d2a433975..c60d4971fb843c 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -118,7 +118,7 @@ async def _get_latest_video_url(self): async def async_camera_image(self): """Return a still image response from the camera.""" - from haffmpeg import ImageFrame, IMAGE_JPEG + from haffmpeg.tools import ImageFrame, IMAGE_JPEG url = await self._get_latest_video_url() if url and url != self._last_url: @@ -135,7 +135,7 @@ async def async_camera_image(self): async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" - from haffmpeg import CameraMjpeg + from haffmpeg.camera import CameraMjpeg if not self._is_on: return @@ -145,8 +145,9 @@ async def handle_async_mjpeg_stream(self, request): self._last_url, extra_cmd=self._extra_arguments) try: + stream_reader = await stream.get_reader() return await async_aiohttp_proxy_stream( - self.hass, request, stream, + self.hass, request, stream_reader, self._manager.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/requirements_all.txt b/requirements_all.txt index 75e3ece960eef4..4b4b2570d50c64 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -505,7 +505,7 @@ greenwavereality==0.5.1 gstreamer-player==1.1.2 # homeassistant.components.ffmpeg -ha-ffmpeg==1.11 +ha-ffmpeg==2.0 # homeassistant.components.philips_js.media_player ha-philipsjs==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17c993bf5f7312..08466d922b86c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -114,7 +114,7 @@ geojson_client==0.3 georss_client==0.5 # homeassistant.components.ffmpeg -ha-ffmpeg==1.11 +ha-ffmpeg==2.0 # homeassistant.components.hangouts hangups==0.4.6 diff --git a/tests/components/binary_sensor/test_ffmpeg.py b/tests/components/ffmpeg/test_sensor.py similarity index 94% rename from tests/components/binary_sensor/test_ffmpeg.py rename to tests/components/ffmpeg/test_sensor.py index 2c17207af321b9..d1fd6124b4cb4d 100644 --- a/tests/components/binary_sensor/test_ffmpeg.py +++ b/tests/components/ffmpeg/test_sensor.py @@ -33,7 +33,8 @@ def test_setup_component(self): assert self.hass.data['ffmpeg'].binary == 'ffmpeg' assert self.hass.states.get('binary_sensor.ffmpeg_noise') is not None - @patch('haffmpeg.SensorNoise.open_sensor', return_value=mock_coro()) + @patch('haffmpeg.sensor.SensorNoise.open_sensor', + return_value=mock_coro()) def test_setup_component_start(self, mock_start): """Set up ffmpeg component.""" with assert_setup_component(1, 'binary_sensor'): @@ -48,7 +49,7 @@ def test_setup_component_start(self, mock_start): entity = self.hass.states.get('binary_sensor.ffmpeg_noise') assert entity.state == 'unavailable' - @patch('haffmpeg.SensorNoise') + @patch('haffmpeg.sensor.SensorNoise') def test_setup_component_start_callback(self, mock_ffmpeg): """Set up ffmpeg component.""" with assert_setup_component(1, 'binary_sensor'): @@ -95,7 +96,8 @@ def test_setup_component(self): assert self.hass.data['ffmpeg'].binary == 'ffmpeg' assert self.hass.states.get('binary_sensor.ffmpeg_motion') is not None - @patch('haffmpeg.SensorMotion.open_sensor', return_value=mock_coro()) + @patch('haffmpeg.sensor.SensorMotion.open_sensor', + return_value=mock_coro()) def test_setup_component_start(self, mock_start): """Set up ffmpeg component.""" with assert_setup_component(1, 'binary_sensor'): @@ -110,7 +112,7 @@ def test_setup_component_start(self, mock_start): entity = self.hass.states.get('binary_sensor.ffmpeg_motion') assert entity.state == 'unavailable' - @patch('haffmpeg.SensorMotion') + @patch('haffmpeg.sensor.SensorMotion') def test_setup_component_start_callback(self, mock_ffmpeg): """Set up ffmpeg component.""" with assert_setup_component(1, 'binary_sensor'): From fa9a6f072eb1c87c5d329b8ff7c1c11acb388f9d Mon Sep 17 00:00:00 2001 From: zewelor Date: Wed, 27 Mar 2019 08:02:30 +0100 Subject: [PATCH 64/69] Add myself as codeowner for yeelight component (#22438) --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 717da8b219ef0b..d95f2f8f9463ed 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -278,7 +278,7 @@ homeassistant/components/xiaomi_tv/media_player.py @fattdev # Y homeassistant/components/yamaha_musiccast/* @jalmeroth -homeassistant/components/yeelight/light.py @rytilahti +homeassistant/components/yeelight/* @rytilahti @zewelor homeassistant/components/yeelightsunflower/light.py @lindsaymarkward homeassistant/components/yi/camera.py @bachya From 6540114ec51103fe20858139c06cec8e9cc69ef1 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 27 Mar 2019 07:17:10 -0400 Subject: [PATCH 65/69] Update ZHA component CODEOWNERS (#22452) --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index d95f2f8f9463ed..e9d7a652a66600 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -284,6 +284,7 @@ homeassistant/components/yi/camera.py @bachya # Z homeassistant/components/zeroconf/* @robbiet480 +homeassistant/components/zha/* @dmulcahey @adminiuga homeassistant/components/zoneminder/* @rohankapoorcom # Other code From 4de2efd07fe49a09dbdc125d4acfba92e298ae33 Mon Sep 17 00:00:00 2001 From: zewelor Date: Wed, 27 Mar 2019 13:39:55 +0100 Subject: [PATCH 66/69] Add support for yeelight ceiling ambilight (#22346) --- homeassistant/components/yeelight/__init__.py | 28 +++- homeassistant/components/yeelight/light.py | 120 ++++++++++++++---- 2 files changed, 119 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 7318b088ab4da3..4171005d9fcc89 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -91,6 +91,7 @@ UPDATE_REQUEST_PROPERTIES = [ "power", + "main_power", "bright", "ct", "rgb", @@ -98,6 +99,13 @@ "sat", "color_mode", "bg_power", + "bg_lmode", + "bg_flowing", + "bg_ct", + "bg_bright", + "bg_hue", + "bg_sat", + "bg_rgb", "nl_br", "active_mode", ] @@ -249,22 +257,34 @@ def is_nightlight_supported(self) -> bool: """Return true / false if nightlight is supported.""" return self.bulb.get_model_specs().get('night_light', False) - def turn_on(self, duration=DEFAULT_TRANSITION): + @property + def is_ambilight_supported(self) -> bool: + """Return true / false if ambilight is supported.""" + return self.bulb.get_model_specs().get('background_light', False) + + def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn on device.""" import yeelight + if not light_type: + light_type = yeelight.enums.LightType.Main + try: - self._bulb_device.turn_on(duration=duration) + self._bulb_device.turn_on(duration=duration, light_type=light_type) except yeelight.BulbException as ex: _LOGGER.error("Unable to turn the bulb on: %s", ex) return - def turn_off(self, duration=DEFAULT_TRANSITION): + def turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn off device.""" import yeelight + if not light_type: + light_type = yeelight.enums.LightType.Main + try: - self._bulb_device.turn_off(duration=duration) + self._bulb_device.turn_off(duration=duration, + light_type=light_type) except yeelight.BulbException as ex: _LOGGER.error("Unable to turn the bulb on: %s", ex) return diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 22e5d9cc9cefed..cc3810c49685e2 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -110,10 +110,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.debug("Adding %s", device.name) custom_effects = discovery_info[CONF_CUSTOM_EFFECTS] - light = YeelightLight(device, custom_effects=custom_effects) - hass.data[data_key].append(light) - add_entities([light], True) + lights = [YeelightLight(device, custom_effects=custom_effects)] + + if device.is_ambilight_supported: + lights.append( + YeelightAmbientLight(device, custom_effects=custom_effects)) + + hass.data[data_key] += lights + add_entities(lights, True) def service_handler(service): """Dispatch service calls to target entities.""" @@ -243,9 +248,16 @@ def custom_effects_names(self): """Return list with custom effects names.""" return list(self.custom_effects.keys()) + @property + def light_type(self): + """Return light type.""" + import yeelight + return yeelight.enums.LightType.Main + def _get_hs_from_properties(self): - rgb = self._properties.get('rgb', None) - color_mode = self._properties.get('color_mode', None) + rgb = self._get_property('rgb') + color_mode = self._get_property('color_mode') + if not rgb or not color_mode: return None @@ -254,8 +266,9 @@ def _get_hs_from_properties(self): temp_in_k = mired_to_kelvin(self._color_temp) return color_util.color_temperature_to_hs(temp_in_k) if color_mode == 3: # hsv - hue = int(self._properties.get('hue')) - sat = int(self._properties.get('sat')) + hue = int(self._get_property('hue')) + sat = int(self._get_property('sat')) + return (hue / 360 * 65536, sat / 100 * 255) rgb = int(rgb) @@ -276,11 +289,18 @@ def _properties(self) -> dict: return {} return self._bulb.last_properties + def _get_property(self, prop, default=None): + return self._properties.get(prop, default) + @property def device(self): """Return yeelight device.""" return self._device + @property + def _is_nightlight_enabled(self): + return self.device.is_nightlight_enabled + # F821: https://github.com/PyCQA/pyflakes/issues/373 @property def _bulb(self) -> 'yeelight.Bulb': # noqa: F821 @@ -304,32 +324,41 @@ def update(self) -> None: """Update properties from the bulb.""" import yeelight try: - if self._bulb.bulb_type == yeelight.BulbType.Color: + bulb_type = self._bulb.bulb_type + + if bulb_type == yeelight.BulbType.Color: + self._supported_features = SUPPORT_YEELIGHT_RGB + elif self.light_type == yeelight.enums.LightType.Ambient: self._supported_features = SUPPORT_YEELIGHT_RGB - elif self._bulb.bulb_type == yeelight.BulbType.WhiteTemp: - if self._device.is_nightlight_enabled: + elif bulb_type in (yeelight.BulbType.WhiteTemp, + yeelight.BulbType.WhiteTempMood): + if self._is_nightlight_enabled: self._supported_features = SUPPORT_YEELIGHT else: self._supported_features = SUPPORT_YEELIGHT_WHITE_TEMP - if self._min_mireds is None: + if self.min_mireds is None: model_specs = self._bulb.get_model_specs() self._min_mireds = \ kelvin_to_mired(model_specs['color_temp']['max']) self._max_mireds = \ kelvin_to_mired(model_specs['color_temp']['min']) - self._is_on = self._properties.get('power') == 'on' + if bulb_type == yeelight.BulbType.WhiteTempMood: + self._is_on = self._get_property('main_power') == 'on' + else: + self._is_on = self._get_property('power') == 'on' - if self._device.is_nightlight_enabled: - bright = self._properties.get('nl_br', None) + if self._is_nightlight_enabled: + bright = self._get_property('nl_br', None) else: - bright = self._properties.get('bright', None) + bright = self._get_property('bright', None) if bright: self._brightness = round(255 * (int(bright) / 100)) - temp_in_k = self._properties.get('ct', None) + temp_in_k = self._get_property('ct') + if temp_in_k: self._color_temp = kelvin_to_mired(int(temp_in_k)) @@ -347,14 +376,16 @@ def set_brightness(self, brightness, duration) -> None: if brightness: _LOGGER.debug("Setting brightness: %s", brightness) self._bulb.set_brightness(brightness / 255 * 100, - duration=duration) + duration=duration, + light_type=self.light_type) @_cmd def set_rgb(self, rgb, duration) -> None: """Set bulb's color.""" if rgb and self.supported_features & SUPPORT_COLOR: _LOGGER.debug("Setting RGB: %s", rgb) - self._bulb.set_rgb(rgb[0], rgb[1], rgb[2], duration=duration) + self._bulb.set_rgb(rgb[0], rgb[1], rgb[2], duration=duration, + light_type=self.light_type) @_cmd def set_colortemp(self, colortemp, duration) -> None: @@ -363,7 +394,8 @@ def set_colortemp(self, colortemp, duration) -> None: temp_in_k = mired_to_kelvin(colortemp) _LOGGER.debug("Setting color temp: %s K", temp_in_k) - self._bulb.set_color_temp(temp_in_k, duration=duration) + self._bulb.set_color_temp(temp_in_k, duration=duration, + light_type=self.light_type) @_cmd def set_default(self) -> None: @@ -401,7 +433,7 @@ def set_flash(self, flash) -> None: flow = Flow(count=count, transitions=transitions) try: - self._bulb.start_flow(flow) + self._bulb.start_flow(flow, light_type=self.light_type) except BulbException as ex: _LOGGER.error("Unable to set flash: %s", ex) @@ -415,7 +447,7 @@ def set_effect(self, effect) -> None: police2, christmas, rgb, randomloop, lsd, slowdown) if effect == EFFECT_STOP: - self._bulb.stop_flow() + self._bulb.stop_flow(light_type=self.light_type) return effects_map = { @@ -447,7 +479,7 @@ def set_effect(self, effect) -> None: flow = Flow(count=2, transitions=pulse(0, 172, 237)) try: - self._bulb.start_flow(flow) + self._bulb.start_flow(flow, light_type=self.light_type) except BulbException as ex: _LOGGER.error("Unable to set effect: %s", ex) @@ -465,7 +497,7 @@ def turn_on(self, **kwargs) -> None: if ATTR_TRANSITION in kwargs: # passed kwarg overrides config duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s - self.device.turn_on(duration=duration) + self.device.turn_on(duration=duration, light_type=self.light_type) if self.config[CONF_MODE_MUSIC] and not self._bulb.music_mode: try: @@ -502,7 +534,7 @@ def turn_off(self, **kwargs) -> None: if ATTR_TRANSITION in kwargs: # passed kwarg overrides config duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s - self.device.turn_off(duration=duration) + self.device.turn_off(duration=duration, light_type=self.light_type) self.device.update() def set_mode(self, mode: str): @@ -525,7 +557,45 @@ def start_flow(self, transitions, count=0, action=ACTION_RECOVER): action=yeelight.Flow.actions[action], transitions=transitions) - self._bulb.start_flow(flow) + self._bulb.start_flow(flow, light_type=self.light_type) self.device.update() except yeelight.BulbException as ex: _LOGGER.error("Unable to set effect: %s", ex) + + +class YeelightAmbientLight(YeelightLight): + """Representation of a Yeelight ambient light.""" + + PROPERTIES_MAPPING = { + "color_mode": "bg_lmode", + "main_power": "bg_power", + } + + def __init__(self, *args, **kwargs): + """Initialize the Yeelight Ambient light.""" + super().__init__(*args, **kwargs) + self._min_mireds = kelvin_to_mired(6500) + self._max_mireds = kelvin_to_mired(1700) + + @property + def name(self) -> str: + """Return the name of the device if any.""" + return "{} ambilight".format(self.device.name) + + @property + def light_type(self): + """Return light type.""" + import yeelight + return yeelight.enums.LightType.Ambient + + @property + def _is_nightlight_enabled(self): + return False + + def _get_property(self, prop, default=None): + bg_prop = self.PROPERTIES_MAPPING.get(prop) + + if not bg_prop: + bg_prop = "bg_" + prop + + return self._properties.get(bg_prop, default) From 646c4a71375c8af8efedd9dc6217c5064fba3083 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Wed, 27 Mar 2019 22:06:20 +0800 Subject: [PATCH 67/69] Bootstrap to start registry loading early (#22321) * Registries store directly in data on loading. * Loading registries concurent with stage 1. * Removed comments --- homeassistant/bootstrap.py | 7 ++++++ homeassistant/helpers/area_registry.py | 25 +++++++++++++------ homeassistant/helpers/device_registry.py | 31 +++++++++++++++--------- homeassistant/helpers/entity_registry.py | 30 +++++++++++++++-------- tests/common.py | 18 +++----------- 5 files changed, 67 insertions(+), 44 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index a3b1d6d305e368..435ec317985315 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -1,4 +1,5 @@ """Provide methods to bootstrap a Home Assistant instance.""" +import asyncio import logging import logging.handlers import os @@ -157,6 +158,12 @@ async def async_from_config_dict(config: Dict[str, Any], await hass.async_block_till_done() + # Kick off loading the registries. They don't need to be awaited. + asyncio.gather( + hass.helpers.device_registry.async_get_registry(), + hass.helpers.entity_registry.async_get_registry(), + hass.helpers.area_registry.async_get_registry()) + # stage 1 for component in components: if component in FIRST_INIT_COMPONENT: diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 644d14cf869764..adf5410516de15 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -1,6 +1,7 @@ """Provide a way to connect devices to one physical location.""" import logging import uuid +from asyncio import Event from collections import OrderedDict from typing import MutableMapping # noqa: F401 from typing import Iterable, Optional, cast @@ -9,6 +10,7 @@ from homeassistant.core import callback from homeassistant.loader import bind_hass + from .typing import HomeAssistantType _LOGGER = logging.getLogger(__name__) @@ -133,14 +135,21 @@ def _data_to_save(self) -> dict: @bind_hass async def async_get_registry(hass: HomeAssistantType) -> AreaRegistry: """Return area registry instance.""" - task = hass.data.get(DATA_REGISTRY) + reg_or_evt = hass.data.get(DATA_REGISTRY) + + if not reg_or_evt: + evt = hass.data[DATA_REGISTRY] = Event() + + reg = AreaRegistry(hass) + await reg.async_load() - if task is None: - async def _load_reg() -> AreaRegistry: - registry = AreaRegistry(hass) - await registry.async_load() - return registry + hass.data[DATA_REGISTRY] = reg + evt.set() + return reg - task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg()) + if isinstance(reg_or_evt, Event): + evt = reg_or_evt + await evt.wait() + return cast(AreaRegistry, hass.data.get(DATA_REGISTRY)) - return cast(AreaRegistry, await task) + return cast(AreaRegistry, reg_or_evt) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 1ea6c400208c41..25c9933fd1116e 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1,15 +1,17 @@ """Provide a way to connect entities belonging to one device.""" import logging import uuid -from typing import List, Optional - +from asyncio import Event from collections import OrderedDict +from typing import List, Optional, cast import attr from homeassistant.core import callback from homeassistant.loader import bind_hass +from .typing import HomeAssistantType + _LOGGER = logging.getLogger(__name__) _UNDEF = object() @@ -273,19 +275,26 @@ def async_clear_area_id(self, area_id: str) -> None: @bind_hass -async def async_get_registry(hass) -> DeviceRegistry: +async def async_get_registry(hass: HomeAssistantType) -> DeviceRegistry: """Return device registry instance.""" - task = hass.data.get(DATA_REGISTRY) + reg_or_evt = hass.data.get(DATA_REGISTRY) + + if not reg_or_evt: + evt = hass.data[DATA_REGISTRY] = Event() + + reg = DeviceRegistry(hass) + await reg.async_load() - if task is None: - async def _load_reg(): - registry = DeviceRegistry(hass) - await registry.async_load() - return registry + hass.data[DATA_REGISTRY] = reg + evt.set() + return reg - task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg()) + if isinstance(reg_or_evt, Event): + evt = reg_or_evt + await evt.wait() + return cast(DeviceRegistry, hass.data.get(DATA_REGISTRY)) - return await task + return cast(DeviceRegistry, reg_or_evt) @callback diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index c0a0dfaa7d92b7..be50d11d17d220 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -7,10 +7,11 @@ registered. Registering a new entity while a timer is in progress resets the timer. """ +from asyncio import Event from collections import OrderedDict from itertools import chain import logging -from typing import Optional, List +from typing import List, Optional, cast import weakref import attr @@ -20,6 +21,8 @@ from homeassistant.util import ensure_unique_string, slugify from homeassistant.util.yaml import load_yaml +from .typing import HomeAssistantType + PATH_REGISTRY = 'entity_registry.yaml' DATA_REGISTRY = 'entity_registry' SAVE_DELAY = 10 @@ -277,19 +280,26 @@ def async_clear_config_entry(self, config_entry): @bind_hass -async def async_get_registry(hass) -> EntityRegistry: +async def async_get_registry(hass: HomeAssistantType) -> EntityRegistry: """Return entity registry instance.""" - task = hass.data.get(DATA_REGISTRY) + reg_or_evt = hass.data.get(DATA_REGISTRY) + + if not reg_or_evt: + evt = hass.data[DATA_REGISTRY] = Event() + + reg = EntityRegistry(hass) + await reg.async_load() - if task is None: - async def _load_reg(): - registry = EntityRegistry(hass) - await registry.async_load() - return registry + hass.data[DATA_REGISTRY] = reg + evt.set() + return reg - task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg()) + if isinstance(reg_or_evt, Event): + evt = reg_or_evt + await evt.wait() + return cast(EntityRegistry, hass.data.get(DATA_REGISTRY)) - return await task + return cast(EntityRegistry, reg_or_evt) @callback diff --git a/tests/common.py b/tests/common.py index 8681db1b4f3c00..9fe5375ad7cc25 100644 --- a/tests/common.py +++ b/tests/common.py @@ -327,11 +327,7 @@ def mock_registry(hass, mock_entries=None): registry = entity_registry.EntityRegistry(hass) registry.entities = mock_entries or OrderedDict() - async def _get_reg(): - return registry - - hass.data[entity_registry.DATA_REGISTRY] = \ - hass.loop.create_task(_get_reg()) + hass.data[entity_registry.DATA_REGISTRY] = registry return registry @@ -340,11 +336,7 @@ def mock_area_registry(hass, mock_entries=None): registry = area_registry.AreaRegistry(hass) registry.areas = mock_entries or OrderedDict() - async def _get_reg(): - return registry - - hass.data[area_registry.DATA_REGISTRY] = \ - hass.loop.create_task(_get_reg()) + hass.data[area_registry.DATA_REGISTRY] = registry return registry @@ -353,11 +345,7 @@ def mock_device_registry(hass, mock_entries=None): registry = device_registry.DeviceRegistry(hass) registry.devices = mock_entries or OrderedDict() - async def _get_reg(): - return registry - - hass.data[device_registry.DATA_REGISTRY] = \ - hass.loop.create_task(_get_reg()) + hass.data[device_registry.DATA_REGISTRY] = registry return registry From 52437f6246b8fe592807f45a3933d796fe9ebfbc Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 27 Mar 2019 18:25:01 +0100 Subject: [PATCH 68/69] Axis devices support device registry (#22367) * Add support for device registry * Fix test --- homeassistant/components/axis/__init__.py | 2 ++ homeassistant/components/axis/binary_sensor.py | 13 +++++++++++++ homeassistant/components/axis/camera.py | 12 ++++++++++++ homeassistant/components/axis/device.py | 17 ++++++++++++++++- tests/components/axis/test_init.py | 1 + 5 files changed, 44 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index 324c2cf369e8a5..53087f2682c862 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -52,6 +52,8 @@ async def async_setup_entry(hass, config_entry): hass.data[DOMAIN][device.serial] = device + await device.async_update_device_registry() + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.shutdown) return True diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index ec4c27ea34357b..3a9583f64193c9 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -81,7 +81,20 @@ def device_class(self): """Return the class of the event.""" return self.event.event_class + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return '{}-{}-{}'.format( + self.device.serial, self.event.topic, self.event.id) + @property def should_poll(self): """No polling needed.""" return False + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + 'identifiers': {(AXIS_DOMAIN, self.device.serial)} + } diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 60dab841048d2d..45801257d00463 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -57,3 +57,15 @@ def _new_ip(self, host): """Set new IP for video stream.""" self._mjpeg_url = AXIS_VIDEO.format(host, self.port) self._still_image_url = AXIS_IMAGE.format(host, self.port) + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return '{}-camera'.format(self.device.serial) + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + 'identifiers': {(AXIS_DOMAIN, self.device.serial)} + } diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 02591e348a5dbf..0d7d9348870be3 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -8,9 +8,10 @@ CONF_USERNAME) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import CONF_CAMERA, CONF_EVENTS, CONF_MODEL, LOGGER +from .const import CONF_CAMERA, CONF_EVENTS, CONF_MODEL, DOMAIN, LOGGER from .errors import AuthenticationRequired, CannotConnect @@ -49,6 +50,20 @@ def serial(self): """Return the mac of this device.""" return self.config_entry.data[CONF_MAC] + async def async_update_device_registry(self): + """Update device registry.""" + device_registry = await \ + self.hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, self.serial)}, + identifiers={(DOMAIN, self.serial)}, + manufacturer='Axis Communications AB', + model="{} {}".format(self.model, self.product_type), + name=self.name, + sw_version=self.fw_version + ) + async def async_setup(self): """Set up the device.""" from axis.vapix import VAPIX_FW_VERSION, VAPIX_PROD_TYPE diff --git a/tests/components/axis/test_init.py b/tests/components/axis/test_init.py index 0586ffd96f6640..c1c4c06f6acd5a 100644 --- a/tests/components/axis/test_init.py +++ b/tests/components/axis/test_init.py @@ -53,6 +53,7 @@ async def test_setup_entry(hass): mock_device = Mock() mock_device.async_setup.return_value = mock_coro(True) + mock_device.async_update_device_registry.return_value = mock_coro(True) mock_device.serial.return_value = '1' with patch.object(axis, 'AxisNetworkDevice') as mock_device_class, \ From 29ad3961e581d3591ce0963a7fa01672abadedf7 Mon Sep 17 00:00:00 2001 From: Leonardo Merza Date: Wed, 27 Mar 2019 13:40:39 -0400 Subject: [PATCH 69/69] Use voluptuous error string for websocket validation error (#21883) * use voluptuous error string to websocket validation error * added exception logging to websocket error * add detailed message to websocket validation error * add error message to websocket validation error * Add humanize error for websocket invalid vol error * Add humanize error for websocket invalid vol error * Add humanize error for websocket invalid vol error --- .../components/websocket_api/connection.py | 4 ++-- tests/components/websocket_api/test_init.py | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index d65ba4c54d848c..c09e8c4c6e2965 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -93,11 +93,11 @@ def async_handle_exception(self, msg, err): err_message = 'Unauthorized' elif isinstance(err, vol.Invalid): code = const.ERR_INVALID_FORMAT - err_message = 'Invalid format' + err_message = vol.humanize.humanize_error(msg, err) else: - self.logger.exception('Error handling message: %s', msg) code = const.ERR_UNKNOWN_ERROR err_message = 'Unknown error' + self.logger.exception('Error handling message: %s', err_message) self.send_message( messages.error_message(msg['id'], code, err_message)) diff --git a/tests/components/websocket_api/test_init.py b/tests/components/websocket_api/test_init.py index 272ac3870edce1..08ea655fdf0d3f 100644 --- a/tests/components/websocket_api/test_init.py +++ b/tests/components/websocket_api/test_init.py @@ -4,6 +4,7 @@ from aiohttp import WSMsgType import pytest +import voluptuous as vol from homeassistant.components.websocket_api import const, messages @@ -90,3 +91,26 @@ async def test_handler_failing(hass, websocket_client): assert msg['type'] == const.TYPE_RESULT assert not msg['success'] assert msg['error']['code'] == const.ERR_UNKNOWN_ERROR + + +async def test_invalid_vol(hass, websocket_client): + """Test a command that raises invalid vol error.""" + hass.components.websocket_api.async_register_command( + 'bla', Mock(side_effect=TypeError), + messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + 'type': 'bla', + vol.Required('test_config'): str + })) + + await websocket_client.send_json({ + 'id': 5, + 'type': 'bla', + 'test_config': 5 + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == const.TYPE_RESULT + assert not msg['success'] + assert msg['error']['code'] == const.ERR_INVALID_FORMAT + assert 'expected str for dictionary value' in msg['error']['message']