diff --git a/.coveragerc b/.coveragerc index 3f8d0e6959b..857b6b90a22 100644 --- a/.coveragerc +++ b/.coveragerc @@ -122,12 +122,17 @@ omit = homeassistant/components/*/ecovacs.py homeassistant/components/esphome/__init__.py - homeassistant/components/*/esphome.py + homeassistant/components/esphome/binary_sensor.py + homeassistant/components/esphome/cover.py + homeassistant/components/esphome/fan.py + homeassistant/components/esphome/light.py + homeassistant/components/esphome/sensor.py + homeassistant/components/esphome/switch.py homeassistant/components/eufy.py homeassistant/components/*/eufy.py - homeassistant/components/fibaro.py + homeassistant/components/fibaro/__init__.py homeassistant/components/*/fibaro.py homeassistant/components/gc100.py @@ -234,7 +239,7 @@ omit = homeassistant/components/lutron_caseta.py homeassistant/components/*/lutron_caseta.py - homeassistant/components/*/mailgun.py + homeassistant/components/mailgun/notify.py homeassistant/components/matrix.py homeassistant/components/*/matrix.py @@ -276,7 +281,8 @@ omit = homeassistant/components/*/opentherm_gw.py homeassistant/components/openuv/__init__.py - homeassistant/components/*/openuv.py + homeassistant/components/openuv/binary_sensor.py + homeassistant/components/openuv/sensor.py homeassistant/components/plum_lightpad.py homeassistant/components/*/plum_lightpad.py @@ -298,7 +304,9 @@ omit = homeassistant/components/*/raincloud.py homeassistant/components/rainmachine/__init__.py - homeassistant/components/*/rainmachine.py + homeassistant/components/rainmachine/binary_sensor.py + homeassistant/components/rainmachine/sensor.py + homeassistant/components/rainmachine/switch.py homeassistant/components/raspihats.py homeassistant/components/*/raspihats.py @@ -308,6 +316,9 @@ omit = homeassistant/components/rfxtrx.py homeassistant/components/*/rfxtrx.py + homeassistant/components/roku.py + homeassistant/components/*/roku.py + homeassistant/components/rpi_gpio.py homeassistant/components/*/rpi_gpio.py @@ -327,7 +338,7 @@ omit = homeassistant/components/*/sense.py homeassistant/components/simplisafe/__init__.py - homeassistant/components/*/simplisafe.py + homeassistant/components/simplisafe/alarm_control_panel.py homeassistant/components/sisyphus.py homeassistant/components/*/sisyphus.py @@ -424,8 +435,14 @@ omit = homeassistant/components/*/zabbix.py homeassistant/components/zha/__init__.py + homeassistant/components/zha/binary_sensor.py homeassistant/components/zha/const.py homeassistant/components/zha/event.py + homeassistant/components/zha/fan.py + homeassistant/components/zha/light.py + homeassistant/components/zha/sensor.py + homeassistant/components/zha/switch.py + homeassistant/components/zha/api.py homeassistant/components/zha/entities/* homeassistant/components/zha/helpers.py homeassistant/components/*/zha.py @@ -519,7 +536,6 @@ omit = homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/google_maps.py homeassistant/components/device_tracker/googlehome.py - homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/hitron_coda.py homeassistant/components/device_tracker/huawei_router.py homeassistant/components/device_tracker/icloud.py @@ -637,7 +653,6 @@ omit = homeassistant/components/media_player/pioneer.py homeassistant/components/media_player/pjlink.py homeassistant/components/media_player/plex.py - homeassistant/components/media_player/roku.py homeassistant/components/media_player/russound_rio.py homeassistant/components/media_player/russound_rnet.py homeassistant/components/media_player/snapcast.py diff --git a/.gitignore b/.gitignore index c2b0d964a62..91b8d024aed 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ venv .venv Pipfile* share/* +Scripts/ # vimmy stuff *.swp diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 79a65508287..00000000000 --- a/.isort.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[settings] -multi_line_output=4 diff --git a/CODEOWNERS b/CODEOWNERS index 018fbed67f0..cfb83919b9c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -185,7 +185,6 @@ homeassistant/components/edp_redy.py @abmantis homeassistant/components/eight_sleep.py @mezz64 homeassistant/components/*/eight_sleep.py @mezz64 homeassistant/components/esphome/*.py @OttoWinter -homeassistant/components/*/esphome.py @OttoWinter # H homeassistant/components/hive.py @Rendili @KJonline @@ -219,7 +218,6 @@ homeassistant/components/*/ness_alarm.py @nickw444 # O homeassistant/components/openuv/* @bachya -homeassistant/components/*/openuv.py @bachya # P homeassistant/components/point/* @fredrike @@ -231,13 +229,11 @@ homeassistant/components/*/qwikswitch.py @kellerza # R homeassistant/components/rainmachine/* @bachya -homeassistant/components/*/rainmachine.py @bachya homeassistant/components/*/random.py @fabaff homeassistant/components/*/rfxtrx.py @danielhiversen # S homeassistant/components/simplisafe/* @bachya -homeassistant/components/*/simplisafe.py @bachya # T homeassistant/components/tahoma.py @philklei diff --git a/Dockerfile b/Dockerfile index 4cd4f3e8871..0dcd0f666c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,6 @@ LABEL maintainer="Paulus Schoutsen " VOLUME /config -RUN mkdir -p /usr/src/app WORKDIR /usr/src/app # Copy build scripts diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 2c5a76d2c90..f5605886628 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -1,6 +1,7 @@ """Home Assistant auth provider.""" import base64 from collections import OrderedDict +import logging from typing import Any, Dict, List, Optional, cast import bcrypt @@ -51,6 +52,15 @@ class Data: self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY, private=True) self._data = None # type: Optional[Dict[str, Any]] + self.is_legacy = False + + @callback + def normalize_username(self, username: str) -> str: + """Normalize a username based on the mode.""" + if self.is_legacy: + return username + + return username.strip() async def async_load(self) -> None: """Load stored data.""" @@ -61,6 +71,20 @@ class Data: 'users': [] } + for user in data['users']: + username = user['username'] + + # check if we have unstripped usernames + if username != username.strip(): + self.is_legacy = True + + logging.getLogger(__name__).warning( + "Home Assistant auth provider is running in legacy mode " + "because we detected usernames that start or end in a " + "space. Please change the username.") + + break + self._data = data @property @@ -73,6 +97,7 @@ class Data: Raises InvalidAuth if auth invalid. """ + username = self.normalize_username(username) dummy = b'$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO' found = None @@ -105,7 +130,10 @@ class Data: def add_auth(self, username: str, password: str) -> None: """Add a new authenticated user/pass.""" - if any(user['username'] == username for user in self.users): + username = self.normalize_username(username) + + if any(self.normalize_username(user['username']) == username + for user in self.users): raise InvalidUser self.users.append({ @@ -116,9 +144,11 @@ class Data: @callback def async_remove_auth(self, username: str) -> None: """Remove authentication.""" + username = self.normalize_username(username) + index = None for i, user in enumerate(self.users): - if user['username'] == username: + if self.normalize_username(user['username']) == username: index = i break @@ -132,8 +162,10 @@ class Data: Raises InvalidUser if user cannot be found. """ + username = self.normalize_username(username) + for user in self.users: - if user['username'] == username: + if self.normalize_username(user['username']) == username: user['password'] = self.hash_password( new_password, True).decode() break @@ -178,10 +210,15 @@ class HassAuthProvider(AuthProvider): async def async_get_or_create_credentials( self, flow_result: Dict[str, str]) -> Credentials: """Get credentials based on the flow result.""" - username = flow_result['username'] + if self.data is None: + await self.async_initialize() + assert self.data is not None + + norm_username = self.data.normalize_username + username = norm_username(flow_result['username']) for credential in await self.async_credentials(): - if credential.data['username'] == username: + if norm_username(credential.data['username']) == username: return credential # Create new credentials. diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 99bc026a532..8a1a39a726f 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -18,7 +18,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['abodepy==0.14.0'] +REQUIREMENTS = ['abodepy==0.15.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 015a12dcfa9..360236790f8 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -46,8 +46,8 @@ CONFIG_SCHEMA = vol.Schema({ SCHEMA_SERVICE_WRITE_DATA_BY_NAME = vol.Schema({ vol.Required(CONF_ADS_TYPE): - vol.In([ADSTYPE_INT, ADSTYPE_UINT, ADSTYPE_BYTE]), - vol.Required(CONF_ADS_VALUE): cv.match_all, + vol.In([ADSTYPE_INT, ADSTYPE_UINT, ADSTYPE_BYTE, ADSTYPE_BOOL]), + vol.Required(CONF_ADS_VALUE): vol.Coerce(int), vol.Required(CONF_ADS_VAR): cv.string, }) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index ad8520118b4..7f3dc2ac8f5 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -21,6 +21,8 @@ from homeassistant.helpers.entity_component import EntityComponent DOMAIN = 'alarm_control_panel' SCAN_INTERVAL = timedelta(seconds=30) ATTR_CHANGED_BY = 'changed_by' +FORMAT_TEXT = 'text' +FORMAT_NUMBER = 'number' ENTITY_ID_FORMAT = DOMAIN + '.{}' diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index 25496dff0eb..16e82280433 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -99,7 +99,7 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): @property def code_format(self): """Return one or more digits/characters.""" - return 'Number' + return alarm.FORMAT_NUMBER @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py index 9b07dc41690..03cf9c1ddf8 100644 --- a/homeassistant/components/alarm_control_panel/alarmdotcom.py +++ b/homeassistant/components/alarm_control_panel/alarmdotcom.py @@ -81,8 +81,8 @@ class AlarmDotCom(alarm.AlarmControlPanel): if self._code is None: return None if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/arlo.py b/homeassistant/components/alarm_control_panel/arlo.py index 8842c710a05..66f11fab83f 100644 --- a/homeassistant/components/alarm_control_panel/arlo.py +++ b/homeassistant/components/alarm_control_panel/arlo.py @@ -17,7 +17,7 @@ from homeassistant.components.arlo import ( DATA_ARLO, CONF_ATTRIBUTION, SIGNAL_UPDATE_ARLO) from homeassistant.const import ( ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED) + STATE_ALARM_DISARMED, STATE_ALARM_ARMED_NIGHT) _LOGGER = logging.getLogger(__name__) @@ -25,6 +25,7 @@ ARMED = 'armed' CONF_HOME_MODE_NAME = 'home_mode_name' CONF_AWAY_MODE_NAME = 'away_mode_name' +CONF_NIGHT_MODE_NAME = 'night_mode_name' DEPENDENCIES = ['arlo'] @@ -35,6 +36,7 @@ ICON = 'mdi:security' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOME_MODE_NAME, default=ARMED): cv.string, vol.Optional(CONF_AWAY_MODE_NAME, default=ARMED): cv.string, + vol.Optional(CONF_NIGHT_MODE_NAME, default=ARMED): cv.string, }) @@ -47,21 +49,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): home_mode_name = config.get(CONF_HOME_MODE_NAME) away_mode_name = config.get(CONF_AWAY_MODE_NAME) + night_mode_name = config.get(CONF_NIGHT_MODE_NAME) base_stations = [] for base_station in arlo.base_stations: base_stations.append(ArloBaseStation(base_station, home_mode_name, - away_mode_name)) + away_mode_name, night_mode_name)) add_entities(base_stations, True) class ArloBaseStation(AlarmControlPanel): """Representation of an Arlo Alarm Control Panel.""" - def __init__(self, data, home_mode_name, away_mode_name): + def __init__(self, data, home_mode_name, away_mode_name, night_mode_name): """Initialize the alarm control panel.""" self._base_station = data self._home_mode_name = home_mode_name self._away_mode_name = away_mode_name + self._night_mode_name = night_mode_name self._state = None @property @@ -105,6 +109,10 @@ class ArloBaseStation(AlarmControlPanel): """Send arm home command. Uses custom mode.""" self._base_station.mode = self._home_mode_name + async def async_alarm_arm_night(self, code=None): + """Send arm night command. Uses custom mode.""" + self._base_station.mode = self._night_mode_name + @property def name(self): """Return the name of the base station.""" @@ -128,4 +136,6 @@ class ArloBaseStation(AlarmControlPanel): return STATE_ALARM_ARMED_HOME if mode == self._away_mode_name: return STATE_ALARM_ARMED_AWAY + if mode == self._night_mode_name: + return STATE_ALARM_ARMED_NIGHT return mode diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index e3c2b4a7ec7..015b3cfce33 100644 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -80,7 +80,7 @@ class Concord232Alarm(alarm.AlarmControlPanel): @property def code_format(self): """Return the characters if code is defined.""" - return 'Number' + return alarm.FORMAT_NUMBER @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/elkm1.py b/homeassistant/components/alarm_control_panel/elkm1.py index 7b8d2e4ac42..c6405f953fd 100644 --- a/homeassistant/components/alarm_control_panel/elkm1.py +++ b/homeassistant/components/alarm_control_panel/elkm1.py @@ -116,7 +116,7 @@ class ElkArea(ElkEntity, alarm.AlarmControlPanel): @property def code_format(self): """Return the alarm code format.""" - return '^[0-9]{4}([0-9]{2})?$' + return alarm.FORMAT_NUMBER @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index f0f3d2a43f7..9b772d9bdf0 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -104,7 +104,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel): """Regex for code format or None if no code is required.""" if self._code: return None - return 'Number' + return alarm.FORMAT_NUMBER @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/homekit_controller.py b/homeassistant/components/alarm_control_panel/homekit_controller.py new file mode 100644 index 00000000000..cc760a851cf --- /dev/null +++ b/homeassistant/components/alarm_control_panel/homekit_controller.py @@ -0,0 +1,117 @@ +""" +Support for Homekit Alarm Control Panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.homekit_controller/ +""" +import logging + +from homeassistant.components.homekit_controller import (HomeKitEntity, + KNOWN_ACCESSORIES) +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.const import ( + STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED) +from homeassistant.const import ATTR_BATTERY_LEVEL + +DEPENDENCIES = ['homekit_controller'] + +ICON = 'mdi:security' + +_LOGGER = logging.getLogger(__name__) + +CURRENT_STATE_MAP = { + 0: STATE_ALARM_ARMED_HOME, + 1: STATE_ALARM_ARMED_AWAY, + 2: STATE_ALARM_ARMED_NIGHT, + 3: STATE_ALARM_DISARMED, + 4: STATE_ALARM_TRIGGERED +} + +TARGET_STATE_MAP = { + STATE_ALARM_ARMED_HOME: 0, + STATE_ALARM_ARMED_AWAY: 1, + STATE_ALARM_ARMED_NIGHT: 2, + STATE_ALARM_DISARMED: 3, +} + + +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']] + add_entities([HomeKitAlarmControlPanel(accessory, discovery_info)], + True) + + +class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): + """Representation of a Homekit Alarm Control Panel.""" + + def __init__(self, *args): + """Initialise the Alarm Control Panel.""" + super().__init__(*args) + self._state = None + self._battery_level = None + + def update_characteristics(self, characteristics): + """Synchronise the Alarm Control Panel state with Home Assistant.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + for characteristic in characteristics: + ctype = characteristic['type'] + ctype = CharacteristicsTypes.get_short(ctype) + if ctype == "security-system-state.current": + self._chars['security-system-state.current'] = \ + characteristic['iid'] + self._state = CURRENT_STATE_MAP[characteristic['value']] + elif ctype == "security-system-state.target": + self._chars['security-system-state.target'] = \ + characteristic['iid'] + elif ctype == "battery-level": + self._chars['battery-level'] = characteristic['iid'] + self._battery_level = characteristic['value'] + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self.set_alarm_state(STATE_ALARM_DISARMED, code) + + def alarm_arm_away(self, code=None): + """Send arm command.""" + self.set_alarm_state(STATE_ALARM_ARMED_AWAY, code) + + def alarm_arm_home(self, code=None): + """Send stay command.""" + self.set_alarm_state(STATE_ALARM_ARMED_HOME, code) + + def alarm_arm_night(self, code=None): + """Send night command.""" + self.set_alarm_state(STATE_ALARM_ARMED_NIGHT, code) + + def set_alarm_state(self, state, code=None): + """Send state command.""" + characteristics = [{'aid': self._aid, + 'iid': self._chars['security-system-state.target'], + 'value': TARGET_STATE_MAP[state]}] + self.put_characteristics(characteristics) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + if self._battery_level is None: + return None + + return { + ATTR_BATTERY_LEVEL: self._battery_level, + } diff --git a/homeassistant/components/alarm_control_panel/ialarm.py b/homeassistant/components/alarm_control_panel/ialarm.py index 6115edf406e..df975ef00ac 100644 --- a/homeassistant/components/alarm_control_panel/ialarm.py +++ b/homeassistant/components/alarm_control_panel/ialarm.py @@ -82,8 +82,8 @@ class IAlarmPanel(alarm.AlarmControlPanel): if self._code is None: return None if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/ifttt.py b/homeassistant/components/alarm_control_panel/ifttt.py index 49c5dc488c0..fe9c96a0083 100644 --- a/homeassistant/components/alarm_control_panel/ifttt.py +++ b/homeassistant/components/alarm_control_panel/ifttt.py @@ -129,8 +129,8 @@ class IFTTTAlarmPanel(alarm.AlarmControlPanel): if self._code is None: return None if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 0bbbd0689e2..a36a38f596f 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -207,8 +207,8 @@ class ManualAlarm(alarm.AlarmControlPanel, RestoreEntity): if self._code is None: return None if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index fc59ac4d088..693c15fa424 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -241,8 +241,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): if self._code is None: return None if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/alarm_control_panel/ness_alarm.py b/homeassistant/components/alarm_control_panel/ness_alarm.py index ec52ef51e2f..ee3a0c213cb 100644 --- a/homeassistant/components/alarm_control_panel/ness_alarm.py +++ b/homeassistant/components/alarm_control_panel/ness_alarm.py @@ -59,7 +59,7 @@ class NessAlarmPanel(alarm.AlarmControlPanel): @property def code_format(self): """Return the regex for code format or None if no code is required.""" - return 'Number' + return alarm.FORMAT_NUMBER @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/nx584.py b/homeassistant/components/alarm_control_panel/nx584.py index 1b3e86c4ca6..c84872d0b25 100644 --- a/homeassistant/components/alarm_control_panel/nx584.py +++ b/homeassistant/components/alarm_control_panel/nx584.py @@ -70,7 +70,7 @@ class NX584Alarm(alarm.AlarmControlPanel): @property def code_format(self): """Return one or more digits/characters.""" - return 'Number' + return alarm.FORMAT_NUMBER @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/satel_integra.py b/homeassistant/components/alarm_control_panel/satel_integra.py index c4e42855d8a..b704677800f 100644 --- a/homeassistant/components/alarm_control_panel/satel_integra.py +++ b/homeassistant/components/alarm_control_panel/satel_integra.py @@ -64,7 +64,7 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanel): @property def code_format(self): """Return the regex for code format or None if no code is required.""" - return 'Number' + return alarm.FORMAT_NUMBER @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py index f5a631df390..6b381ef5a47 100644 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -61,7 +61,7 @@ class VerisureAlarm(alarm.AlarmControlPanel): @property def code_format(self): """Return one or more digits/characters.""" - return 'Number' + return alarm.FORMAT_NUMBER @property def changed_by(self): diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py index 759a2185047..3a18281e49b 100644 --- a/homeassistant/components/alert.py +++ b/homeassistant/components/alert.py @@ -46,9 +46,7 @@ ALERT_SCHEMA = vol.Schema({ vol.Required(CONF_NOTIFIERS): cv.ensure_list}) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: ALERT_SCHEMA, - }), + DOMAIN: cv.schema_with_slug_keys(ALERT_SCHEMA), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 0bfa01a83ca..8491268dfd6 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -72,6 +72,6 @@ async def async_setup(hass, config): pass else: smart_home_config = smart_home_config or SMART_HOME_SCHEMA({}) - smart_home.async_setup(hass, smart_home_config) + await smart_home.async_setup(hass, smart_home_config) return True diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 1558a1bf218..7240912883a 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -27,8 +27,9 @@ from homeassistant.const import ( CONF_NAME, SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_UNLOCK, SERVICE_VOLUME_SET, STATE_LOCKED, STATE_ON, STATE_UNLOCKED, - TEMP_CELSIUS, TEMP_FAHRENHEIT, MATCH_ALL) + SERVICE_UNLOCK, SERVICE_VOLUME_SET, STATE_LOCKED, STATE_ON, + STATE_UNAVAILABLE, STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT, + MATCH_ALL) import homeassistant.core as ha import homeassistant.util.color as color_util from homeassistant.util.decorator import Registry @@ -393,6 +394,37 @@ class _AlexaInterface: } +class _AlexaEndpointHealth(_AlexaInterface): + """Implements Alexa.EndpointHealth. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it + """ + + def __init__(self, hass, entity): + super().__init__(entity) + self.hass = hass + + def name(self): + return 'Alexa.EndpointHealth' + + def properties_supported(self): + return [{'name': 'connectivity'}] + + def properties_proactively_reported(self): + return False + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'connectivity': + raise _UnsupportedProperty(name) + + if self.entity.state == STATE_UNAVAILABLE: + return {'value': 'UNREACHABLE'} + return {'value': 'OK'} + + class _AlexaPowerController(_AlexaInterface): """Implements Alexa.PowerController. @@ -769,7 +801,8 @@ class _GenericCapabilities(_AlexaEntity): return [_DisplayCategory.OTHER] def interfaces(self): - return [_AlexaPowerController(self.entity)] + return [_AlexaPowerController(self.entity), + _AlexaEndpointHealth(self.hass, self.entity)] @ENTITY_ADAPTERS.register(switch.DOMAIN) @@ -778,7 +811,8 @@ class _SwitchCapabilities(_AlexaEntity): return [_DisplayCategory.SWITCH] def interfaces(self): - return [_AlexaPowerController(self.entity)] + return [_AlexaPowerController(self.entity), + _AlexaEndpointHealth(self.hass, self.entity)] @ENTITY_ADAPTERS.register(climate.DOMAIN) @@ -792,6 +826,7 @@ class _ClimateCapabilities(_AlexaEntity): yield _AlexaPowerController(self.entity) yield _AlexaThermostatController(self.hass, self.entity) yield _AlexaTemperatureSensor(self.hass, self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) @ENTITY_ADAPTERS.register(cover.DOMAIN) @@ -804,6 +839,7 @@ class _CoverCapabilities(_AlexaEntity): supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & cover.SUPPORT_SET_POSITION: yield _AlexaPercentageController(self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) @ENTITY_ADAPTERS.register(light.DOMAIN) @@ -821,6 +857,7 @@ class _LightCapabilities(_AlexaEntity): yield _AlexaColorController(self.entity) if supported & light.SUPPORT_COLOR_TEMP: yield _AlexaColorTemperatureController(self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) @ENTITY_ADAPTERS.register(fan.DOMAIN) @@ -833,6 +870,7 @@ class _FanCapabilities(_AlexaEntity): supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & fan.SUPPORT_SET_SPEED: yield _AlexaPercentageController(self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) @ENTITY_ADAPTERS.register(lock.DOMAIN) @@ -841,7 +879,8 @@ class _LockCapabilities(_AlexaEntity): return [_DisplayCategory.SMARTLOCK] def interfaces(self): - return [_AlexaLockController(self.entity)] + return [_AlexaLockController(self.entity), + _AlexaEndpointHealth(self.hass, self.entity)] @ENTITY_ADAPTERS.register(media_player.DOMAIN) @@ -851,6 +890,7 @@ class _MediaPlayerCapabilities(_AlexaEntity): def interfaces(self): yield _AlexaPowerController(self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & media_player.SUPPORT_VOLUME_SET: @@ -913,6 +953,7 @@ class _SensorCapabilities(_AlexaEntity): TEMP_CELSIUS, ): yield _AlexaTemperatureSensor(self.hass, self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) @ENTITY_ADAPTERS.register(binary_sensor.DOMAIN) @@ -934,6 +975,8 @@ class _BinarySensorCapabilities(_AlexaEntity): elif sensor_type is self.TYPE_MOTION: yield _AlexaMotionSensor(self.hass, self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) + def get_type(self): """Return the type of binary sensor.""" attrs = self.entity.attributes @@ -993,8 +1036,7 @@ class Config: self.entity_config = entity_config or {} -@ha.callback -def async_setup(hass, config): +async def async_setup(hass, config): """Activate Smart Home functionality of Alexa component. This is optional, triggered by having a `smart_home:` sub-section in the @@ -1020,8 +1062,7 @@ def async_setup(hass, config): hass.http.register_view(SmartHomeView(smart_home_config)) if AUTH_KEY in hass.data: - hass.loop.create_task( - async_enable_proactive_mode(hass, smart_home_config)) + await async_enable_proactive_mode(hass, smart_home_config) async def async_enable_proactive_mode(hass, smart_home_config): @@ -1337,8 +1378,7 @@ async def async_send_changereport_message(hass, config, alexa_entity): return headers = { - "Authorization": "Bearer {}".format(token), - "Content-Type": "application/json;charset=UTF-8" + "Authorization": "Bearer {}".format(token) } endpoint = alexa_entity.entity_id() @@ -1359,14 +1399,14 @@ async def async_send_changereport_message(hass, config, alexa_entity): payload=payload) message.set_endpoint_full(token, endpoint) - message_str = json.dumps(message.serialize()) + message_serialized = message.serialize() try: session = aiohttp_client.async_get_clientsession(hass) with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): response = await session.post(config.endpoint, headers=headers, - data=message_str, + json=message_serialized, allow_redirects=True) except (asyncio.TimeoutError, aiohttp.ClientError): @@ -1375,7 +1415,7 @@ async def async_send_changereport_message(hass, config, alexa_entity): response_text = await response.text() - _LOGGER.debug("Sent: %s", message_str) + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) _LOGGER.debug("Received (%s): %s", response.status, response_text) if response.status != 202: diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py index 25e7a59f47c..8dedb1f640a 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -26,6 +26,8 @@ CONF_SSH_KEY = 'ssh_key' CONF_REQUIRE_IP = 'require_ip' DEFAULT_SSH_PORT = 22 SECRET_GROUP = 'Password or SSH Key' +CONF_SENSORS = 'sensors' +SENSOR_TYPES = ['upload_speed', 'download_speed', 'download', 'upload'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -37,7 +39,9 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_REQUIRE_IP, default=True): cv.boolean, vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, - vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile + vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile, + vol.Optional(CONF_SENSORS): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)]), }), }, extra=vol.ALLOW_EXTRA) @@ -62,7 +66,8 @@ async def async_setup(hass, config): hass.data[DATA_ASUSWRT] = api hass.async_create_task(async_load_platform( - hass, 'sensor', DOMAIN, {}, config)) + hass, 'sensor', DOMAIN, config[DOMAIN].get(CONF_SENSORS), config)) hass.async_create_task(async_load_platform( hass, 'device_tracker', DOMAIN, {}, config)) + return True diff --git a/homeassistant/components/auth/.translations/de.json b/homeassistant/components/auth/.translations/de.json index 4ef4a5bf9e8..06da3cde1a1 100644 --- a/homeassistant/components/auth/.translations/de.json +++ b/homeassistant/components/auth/.translations/de.json @@ -9,11 +9,11 @@ }, "step": { "init": { - "description": "Bitte w\u00e4hle einen der Benachrichtigungsdienste:", + "description": "Bitte w\u00e4hlen Sie einen der Benachrichtigungsdienste:", "title": "Einmal Passwort f\u00fcr Notify einrichten" }, "setup": { - "description": "Ein Einmal-Passwort wurde per ** notify gesendet. {notify_service} **. Bitte gebe es unten ein:", + "description": "Ein Einmal-Passwort wurde per **notify.{notify_service}** gesendet. Bitte geben Sie es unten ein:", "title": "\u00dcberpr\u00fcfe das Setup" } }, diff --git a/homeassistant/components/auth/.translations/et.json b/homeassistant/components/auth/.translations/et.json new file mode 100644 index 00000000000..290f4ee12a9 --- /dev/null +++ b/homeassistant/components/auth/.translations/et.json @@ -0,0 +1,7 @@ +{ + "mfa_setup": { + "totp": { + "title": "" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 6c9b04f9fa2..836901cde30 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -375,8 +375,6 @@ def _async_get_action(hass, config, name): async def action(entity_id, variables, context): """Execute an action.""" _LOGGER.info('Executing %s', name) - hass.components.logbook.async_log_entry( - name, 'has been triggered', DOMAIN, entity_id) try: await script_obj.async_run(variables, context) diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py index 537646fefc1..33ef00da380 100644 --- a/homeassistant/components/automation/geo_location.py +++ b/homeassistant/components/automation/geo_location.py @@ -1,9 +1,9 @@ """ -Offer geo location automation rules. +Offer geolocation automation rules. For more details about this automation trigger, please refer to the documentation at -https://home-assistant.io/docs/automation/trigger/#geo-location-trigger +https://home-assistant.io/docs/automation/trigger/#geolocation-trigger """ import voluptuous as vol diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 116bfbdbc97..d57e190490f 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -13,30 +13,18 @@ from homeassistant.const import CONF_AT, CONF_PLATFORM from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_change -CONF_HOURS = 'hours' -CONF_MINUTES = 'minutes' -CONF_SECONDS = 'seconds' - _LOGGER = logging.getLogger(__name__) -TRIGGER_SCHEMA = vol.All(vol.Schema({ +TRIGGER_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'time', - CONF_AT: cv.time, - CONF_HOURS: vol.Any(vol.Coerce(int), vol.Coerce(str)), - CONF_MINUTES: vol.Any(vol.Coerce(int), vol.Coerce(str)), - CONF_SECONDS: vol.Any(vol.Coerce(int), vol.Coerce(str)), -}), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, CONF_SECONDS, CONF_AT)) + vol.Required(CONF_AT): cv.time, +}) async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - if CONF_AT in config: - at_time = config.get(CONF_AT) - hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second - else: - hours = config.get(CONF_HOURS) - minutes = config.get(CONF_MINUTES) - seconds = config.get(CONF_SECONDS) + at_time = config.get(CONF_AT) + hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second @callback def time_automation_listener(now): diff --git a/homeassistant/components/automation/time_pattern.py b/homeassistant/components/automation/time_pattern.py new file mode 100644 index 00000000000..8b6e907f7b8 --- /dev/null +++ b/homeassistant/components/automation/time_pattern.py @@ -0,0 +1,53 @@ +""" +Offer time listening automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/docs/automation/trigger/#time-trigger +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_time_change + +CONF_HOURS = 'hours' +CONF_MINUTES = 'minutes' +CONF_SECONDS = 'seconds' + +_LOGGER = logging.getLogger(__name__) + +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'time_pattern', + CONF_HOURS: vol.Any(vol.Coerce(int), vol.Coerce(str)), + CONF_MINUTES: vol.Any(vol.Coerce(int), vol.Coerce(str)), + CONF_SECONDS: vol.Any(vol.Coerce(int), vol.Coerce(str)), +}), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, CONF_SECONDS)) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + hours = config.get(CONF_HOURS) + minutes = config.get(CONF_MINUTES) + seconds = config.get(CONF_SECONDS) + + # If larger units are specified, default the smaller units to zero + if minutes is None and hours is not None: + minutes = 0 + if seconds is None and minutes is not None: + seconds = 0 + + @callback + def time_automation_listener(now): + """Listen for time changes and calls action.""" + hass.async_run_job(action, { + 'trigger': { + 'platform': 'time_pattern', + 'now': now, + }, + }) + + return async_track_time_change(hass, time_automation_listener, + hour=hours, minute=minutes, second=seconds) diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index 26fe41724f9..fd2e603445c 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -50,9 +50,7 @@ DEVICE_SCHEMA = vol.Schema({ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: DEVICE_SCHEMA, - }), + DOMAIN: cv.schema_with_slug_keys(DEVICE_SCHEMA), }, extra=vol.ALLOW_EXTRA) SERVICE_VAPIX_CALL = 'vapix_call' diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py deleted file mode 100644 index b9fdb08e068..00000000000 --- a/homeassistant/components/binary_sensor/deconz.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -Support for deCONZ binary sensor. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.deconz/ -""" -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.deconz.const import ( - ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DECONZ_REACHABLE, - DOMAIN as DECONZ_DOMAIN) -from homeassistant.const import ATTR_BATTERY_LEVEL -from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -DEPENDENCIES = ['deconz'] - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Old way of setting up deCONZ binary sensors.""" - pass - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the deCONZ binary sensor.""" - gateway = hass.data[DECONZ_DOMAIN] - - @callback - def async_add_sensor(sensors): - """Add binary sensor from deCONZ.""" - from pydeconz.sensor import DECONZ_BINARY_SENSOR - entities = [] - allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) - for sensor in sensors: - if sensor.type in DECONZ_BINARY_SENSOR and \ - not (not allow_clip_sensor and sensor.type.startswith('CLIP')): - entities.append(DeconzBinarySensor(sensor, gateway)) - async_add_entities(entities, True) - - gateway.listeners.append( - async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) - - async_add_sensor(gateway.api.sensors.values()) - - -class DeconzBinarySensor(BinarySensorDevice): - """Representation of a binary sensor.""" - - def __init__(self, sensor, gateway): - """Set up sensor and add update callback to get data from websocket.""" - self._sensor = sensor - self.gateway = gateway - self.unsub_dispatcher = None - - async def async_added_to_hass(self): - """Subscribe sensors events.""" - self._sensor.register_async_callback(self.async_update_callback) - self.gateway.deconz_ids[self.entity_id] = self._sensor.deconz_id - self.unsub_dispatcher = async_dispatcher_connect( - self.hass, DECONZ_REACHABLE, self.async_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect sensor object when removed.""" - if self.unsub_dispatcher is not None: - self.unsub_dispatcher() - self._sensor.remove_callback(self.async_update_callback) - self._sensor = None - - @callback - def async_update_callback(self, reason): - """Update the sensor's state. - - If reason is that state is updated, - or reachable has changed or battery has changed. - """ - if reason['state'] or \ - 'reachable' in reason['attr'] or \ - 'battery' in reason['attr'] or \ - 'on' in reason['attr']: - self.async_schedule_update_ha_state() - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._sensor.is_tripped - - @property - def name(self): - """Return the name of the sensor.""" - return self._sensor.name - - @property - def unique_id(self): - """Return a unique identifier for this sensor.""" - return self._sensor.uniqueid - - @property - def device_class(self): - """Return the class of the sensor.""" - return self._sensor.sensor_class - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return self._sensor.sensor_icon - - @property - def available(self): - """Return True if sensor is available.""" - return self.gateway.available and self._sensor.reachable - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_state_attributes(self): - """Return the state attributes of the sensor.""" - from pydeconz.sensor import PRESENCE - attr = {} - if self._sensor.battery: - attr[ATTR_BATTERY_LEVEL] = self._sensor.battery - if self._sensor.on is not None: - attr[ATTR_ON] = self._sensor.on - if self._sensor.type in PRESENCE and self._sensor.dark is not None: - attr[ATTR_DARK] = self._sensor.dark - return attr - - @property - def device_info(self): - """Return a device description for device registry.""" - if (self._sensor.uniqueid is None or - self._sensor.uniqueid.count(':') != 7): - return None - serial = self._sensor.uniqueid.split('-', 1)[0] - bridgeid = self.gateway.api.config.bridgeid - return { - 'connections': {(CONNECTION_ZIGBEE, serial)}, - 'identifiers': {(DECONZ_DOMAIN, serial)}, - 'manufacturer': self._sensor.manufacturer, - 'model': self._sensor.modelid, - 'name': self._sensor.name, - 'sw_version': self._sensor.swversion, - 'via_hub': (DECONZ_DOMAIN, bridgeid), - } diff --git a/homeassistant/components/binary_sensor/fibaro.py b/homeassistant/components/binary_sensor/fibaro.py index 8af2bde10ad..1934580c58e 100644 --- a/homeassistant/components/binary_sensor/fibaro.py +++ b/homeassistant/components/binary_sensor/fibaro.py @@ -9,7 +9,7 @@ import logging from homeassistant.components.binary_sensor import ( BinarySensorDevice, ENTITY_ID_FORMAT) from homeassistant.components.fibaro import ( - FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + FIBARO_DEVICES, FibaroDevice) from homeassistant.const import (CONF_DEVICE_CLASS, CONF_ICON) DEPENDENCIES = ['fibaro'] @@ -33,17 +33,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return add_entities( - [FibaroBinarySensor(device, hass.data[FIBARO_CONTROLLER]) + [FibaroBinarySensor(device) for device in hass.data[FIBARO_DEVICES]['binary_sensor']], True) class FibaroBinarySensor(FibaroDevice, BinarySensorDevice): """Representation of a Fibaro Binary Sensor.""" - def __init__(self, fibaro_device, controller): + def __init__(self, fibaro_device): """Initialize the binary_sensor.""" self._state = None - super().__init__(fibaro_device, controller) + super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) stype = None devconf = fibaro_device.device_config diff --git a/homeassistant/components/binary_sensor/hive.py b/homeassistant/components/binary_sensor/hive.py index 68f32641872..e114e67f90f 100644 --- a/homeassistant/components/binary_sensor/hive.py +++ b/homeassistant/components/binary_sensor/hive.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.hive/ """ from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.hive import DATA_HIVE +from homeassistant.components.hive import DATA_HIVE, DOMAIN DEPENDENCIES = ['hive'] @@ -35,9 +35,24 @@ class HiveBinarySensorEntity(BinarySensorDevice): self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) - + self._unique_id = '{}-{}'.format(self.node_id, self.device_type) self.session.entities.append(self) + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self): + """Return device information.""" + return { + 'identifiers': { + (DOMAIN, self.unique_id) + }, + 'name': self.name + } + def handle_update(self, updatesource): """Handle the new update request.""" if '{}.{}'.format(self.device_type, self.node_id) not in updatesource: diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index d5f8b16e0c1..605ab24a264 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -41,7 +41,7 @@ SENSOR_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA), }) diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 4773e88f5df..8f3ff5d798e 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -51,7 +51,7 @@ SENSOR_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA), }) diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index fc8207f83b7..551ca835e78 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_NAME, WEEKDAYS from homeassistant.components.binary_sensor import BinarySensorDevice import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['holidays==0.9.8'] +REQUIREMENTS = ['holidays==0.9.9'] _LOGGER = logging.getLogger(__name__) @@ -26,6 +26,7 @@ ALL_COUNTRIES = [ 'Canada', 'CA', 'Colombia', 'CO', 'Croatia', 'HR', 'Czech', 'CZ', 'Denmark', 'DK', 'England', 'EuropeanCentralBank', 'ECB', 'TAR', 'Finland', 'FI', 'France', 'FRA', 'Germany', 'DE', 'Hungary', 'HU', + 'Honduras', 'HUD', 'India', 'IND', 'Ireland', 'Isle of Man', 'Italy', 'IT', 'Japan', 'JP', 'Mexico', 'MX', 'Netherlands', 'NL', 'NewZealand', 'NZ', 'Northern Ireland', 'Norway', 'NO', 'Polish', 'PL', 'Portugal', 'PT', diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index de259c718f4..afffb71bae5 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -107,7 +107,7 @@ class XiaomiBinarySensor(XiaomiDevice, BinarySensorDevice): def update(self): """Update the sensor state.""" - _LOGGER.debug('Updating xiaomi sensor by polling') + _LOGGER.debug('Updating xiaomi sensor (%s) by polling', self._sid) self._get_from_hub(self._sid) @@ -178,7 +178,28 @@ class XiaomiMotionSensor(XiaomiBinarySensor): self.async_schedule_update_ha_state() def parse_data(self, data, raw_data): - """Parse data sent by gateway.""" + """Parse data sent by gateway. + + Polling (proto v1, firmware version 1.4.1_159.0143) + + >> { "cmd":"read","sid":"158..."} + << {'model': 'motion', 'sid': '158...', 'short_id': 26331, + 'cmd': 'read_ack', 'data': '{"voltage":3005}'} + + Multicast messages (proto v1, firmware version 1.4.1_159.0143) + + << {'model': 'motion', 'sid': '158...', 'short_id': 26331, + 'cmd': 'report', 'data': '{"status":"motion"}'} + << {'model': 'motion', 'sid': '158...', 'short_id': 26331, + 'cmd': 'report', 'data': '{"no_motion":"120"}'} + << {'model': 'motion', 'sid': '158...', 'short_id': 26331, + 'cmd': 'report', 'data': '{"no_motion":"180"}'} + << {'model': 'motion', 'sid': '158...', 'short_id': 26331, + 'cmd': 'report', 'data': '{"no_motion":"300"}'} + << {'model': 'motion', 'sid': '158...', 'short_id': 26331, + 'cmd': 'heartbeat', 'data': '{"voltage":3005}'} + + """ if raw_data['cmd'] == 'heartbeat': _LOGGER.debug( 'Skipping heartbeat of the motion sensor. ' @@ -187,8 +208,7 @@ class XiaomiMotionSensor(XiaomiBinarySensor): '11631#issuecomment-357507744).') return - self._should_poll = False - if NO_MOTION in data: # handle push from the hub + if NO_MOTION in data: self._no_motion_since = data[NO_MOTION] self._state = False return True @@ -203,26 +223,20 @@ class XiaomiMotionSensor(XiaomiBinarySensor): self._unsub_set_no_motion() self._unsub_set_no_motion = async_call_later( self._hass, - 180, + 120, self._async_set_no_motion ) - else: - self._should_poll = True - if self.entity_id is not None: - self._hass.bus.fire('xiaomi_aqara.motion', { - 'entity_id': self.entity_id - }) + + if self.entity_id is not None: + self._hass.bus.fire('xiaomi_aqara.motion', { + 'entity_id': self.entity_id + }) self._no_motion_since = 0 if self._state: return False self._state = True return True - if value == NO_MOTION: - if not self._state: - return False - self._state = False - return True class XiaomiDoorSensor(XiaomiBinarySensor): diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index a56885a22a9..ac2a4574b9c 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) -REQUIREMENTS = ['blinkpy==0.11.0'] +REQUIREMENTS = ['blinkpy==0.11.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index e5a0d672756..8afd71abc26 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -7,6 +7,7 @@ https://www.home-assistant.io/components/camera.proxy/ import asyncio import logging +from datetime import timedelta import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, Camera @@ -18,7 +19,7 @@ from homeassistant.util.async_ import run_coroutine_threadsafe import homeassistant.util.dt as dt_util from homeassistant.components.camera import async_get_still_stream -REQUIREMENTS = ['pillow==5.3.0'] +REQUIREMENTS = ['pillow==5.4.1'] _LOGGER = logging.getLogger(__name__) @@ -206,7 +207,7 @@ class ProxyCamera(Camera): self._cache_images = bool( config.get(CONF_IMAGE_REFRESH_RATE) or config.get(CONF_CACHE_IMAGES)) - self._last_image_time = 0 + self._last_image_time = dt_util.utc_from_timestamp(0) self._last_image = None self._headers = ( {HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password} @@ -223,7 +224,8 @@ class ProxyCamera(Camera): now = dt_util.utcnow() if (self._image_refresh_rate and - now < self._last_image_time + self._image_refresh_rate): + now < self._last_image_time + + timedelta(seconds=self._image_refresh_rate)): return self._last_image self._last_image_time = now diff --git a/homeassistant/components/camera/xiaomi.py b/homeassistant/components/camera/xiaomi.py index 916a4cb9a90..207dd17ed9b 100644 --- a/homeassistant/components/camera/xiaomi.py +++ b/homeassistant/components/camera/xiaomi.py @@ -107,7 +107,7 @@ class XiaomiCamera(Camera): _LOGGER.warning("There don't appear to be any folders") return False - first_dir = dirs[-1] + first_dir = latest_dir = dirs[-1] try: ftp.cwd(first_dir) except error_perm as exc: diff --git a/homeassistant/components/camera/zoneminder.py b/homeassistant/components/camera/zoneminder.py index 9bde4ac099b..8556fbf1e35 100644 --- a/homeassistant/components/camera/zoneminder.py +++ b/homeassistant/components/camera/zoneminder.py @@ -19,17 +19,18 @@ DEPENDENCIES = ['zoneminder'] def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ZoneMinder cameras.""" filter_urllib3_logging() - zm_client = hass.data[ZONEMINDER_DOMAIN] - - monitors = zm_client.get_monitors() - if not monitors: - _LOGGER.warning("Could not fetch monitors from ZoneMinder") - return - cameras = [] - for monitor in monitors: - _LOGGER.info("Initializing camera %s", monitor.id) - cameras.append(ZoneMinderCamera(monitor, zm_client.verify_ssl)) + for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): + monitors = zm_client.get_monitors() + if not monitors: + _LOGGER.warning( + "Could not fetch monitors from ZoneMinder host: %s" + ) + return + + for monitor in monitors: + _LOGGER.info("Initializing camera %s", monitor.id) + cameras.append(ZoneMinderCamera(monitor, zm_client.verify_ssl)) add_entities(cameras) diff --git a/homeassistant/components/cast/.translations/et.json b/homeassistant/components/cast/.translations/et.json new file mode 100644 index 00000000000..987c54955f2 --- /dev/null +++ b/homeassistant/components/cast/.translations/et.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "confirm": { + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/hive.py b/homeassistant/components/climate/hive.py index 37289d45c45..87d426d6f05 100644 --- a/homeassistant/components/climate/hive.py +++ b/homeassistant/components/climate/hive.py @@ -8,7 +8,7 @@ from homeassistant.components.climate import ( ClimateDevice, STATE_AUTO, STATE_HEAT, STATE_OFF, STATE_ON, SUPPORT_AUX_HEAT, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from homeassistant.components.hive import DATA_HIVE +from homeassistant.components.hive import DATA_HIVE, DOMAIN DEPENDENCIES = ['hive'] HIVE_TO_HASS_STATE = {'SCHEDULE': STATE_AUTO, 'MANUAL': STATE_HEAT, @@ -44,6 +44,7 @@ class HiveClimateEntity(ClimateDevice): self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) + self._unique_id = '{}-{}'.format(self.node_id, self.device_type) if self.device_type == "Heating": self.modes = [STATE_AUTO, STATE_HEAT, STATE_OFF] @@ -52,6 +53,21 @@ class HiveClimateEntity(ClimateDevice): self.session.entities.append(self) + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self): + """Return device information.""" + return { + 'identifiers': { + (DOMAIN, self.unique_id) + }, + 'name': self.name + } + @property def supported_features(self): """Return the list of supported features.""" diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index 8532c611d25..bf1cf5bf345 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -19,7 +19,8 @@ from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ClimateDevice, DOMAIN, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, - SUPPORT_ON_OFF) + SUPPORT_ON_OFF, STATE_HEAT, STATE_COOL, STATE_FAN_ONLY, STATE_DRY, + STATE_AUTO) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -57,6 +58,16 @@ FIELD_TO_FLAG = { 'on': SUPPORT_ON_OFF, } +SENSIBO_TO_HA = { + "cool": STATE_COOL, + "heat": STATE_HEAT, + "fan": STATE_FAN_ONLY, + "auto": STATE_AUTO, + "dry": STATE_DRY +} + +HA_TO_SENSIBO = {value: key for key, value in SENSIBO_TO_HA.items()} + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -129,9 +140,10 @@ class SensiboClimate(ClimateDevice): self._ac_states = data['acState'] self._status = data['connectionStatus']['isAlive'] capabilities = data['remoteCapabilities'] - self._operations = sorted(capabilities['modes'].keys()) - self._current_capabilities = capabilities[ - 'modes'][self.current_operation] + self._operations = [SENSIBO_TO_HA[mode] for mode + in capabilities['modes']] + self._current_capabilities = \ + capabilities['modes'][self._ac_states['mode']] temperature_unit_key = data.get('temperatureUnit') or \ self._ac_states.get('temperatureUnit') if temperature_unit_key: @@ -186,7 +198,7 @@ class SensiboClimate(ClimateDevice): @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" - return self._ac_states['mode'] + return SENSIBO_TO_HA.get(self._ac_states['mode']) @property def current_humidity(self): @@ -293,7 +305,8 @@ class SensiboClimate(ClimateDevice): """Set new target operation mode.""" with async_timeout.timeout(TIMEOUT): await self._client.async_set_ac_state_property( - self._id, 'mode', operation_mode, self._ac_states) + self._id, 'mode', HA_TO_SENSIBO[operation_mode], + self._ac_states) async def async_set_swing_mode(self, swing_mode): """Set new target swing operation.""" diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 7d633a4b2ac..ed24fe48d40 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -2,6 +2,7 @@ import asyncio import logging import pprint +import random import uuid from aiohttp import hdrs, client_exceptions, WSMsgType @@ -107,9 +108,11 @@ class CloudIoT: self.tries += 1 try: - # Sleep 2^tries seconds between retries - self.retry_task = hass.async_create_task(asyncio.sleep( - 2**min(9, self.tries), loop=hass.loop)) + # Sleep 2^tries + 0…tries*3 seconds between retries + self.retry_task = hass.async_create_task( + asyncio.sleep(2**min(9, self.tries) + + random.randint(0, self.tries * 3), + loop=hass.loop)) yield from self.retry_task self.retry_task = None except asyncio.CancelledError: @@ -313,15 +316,20 @@ def async_handle_google_actions(hass, cloud, payload): @HANDLERS.register('cloud') -@asyncio.coroutine -def async_handle_cloud(hass, cloud, payload): +async def async_handle_cloud(hass, cloud, payload): """Handle an incoming IoT message for cloud component.""" action = payload['action'] if action == 'logout': - yield from cloud.logout() + # Log out of Home Assistant Cloud + await cloud.logout() _LOGGER.error("You have been logged out from Home Assistant cloud: %s", payload['reason']) + elif action == 'refresh_auth': + # Refresh the auth token between now and payload['seconds'] + hass.helpers.event.async_call_later( + random.randint(0, payload['seconds']), + lambda now: auth_api.check_token(cloud)) else: _LOGGER.warning("Received unknown cloud action: %s", action) diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index cd3a29df2b6..aeef2818f63 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -37,8 +37,8 @@ SERVICE_SCHEMA = vol.Schema({ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: vol.Any({ + DOMAIN: cv.schema_with_slug_keys( + vol.Any({ vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): cv.positive_int, @@ -46,7 +46,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_RESTORE, default=True): cv.boolean, vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, }, None) - }) + ) }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/cover/command_line.py b/homeassistant/components/cover/command_line.py index bebf78b1db6..4f4fca1b27a 100644 --- a/homeassistant/components/cover/command_line.py +++ b/homeassistant/components/cover/command_line.py @@ -27,7 +27,7 @@ COVER_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), + vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA), }) diff --git a/homeassistant/components/cover/fibaro.py b/homeassistant/components/cover/fibaro.py index dc82087f802..d47dbb20315 100644 --- a/homeassistant/components/cover/fibaro.py +++ b/homeassistant/components/cover/fibaro.py @@ -9,7 +9,7 @@ import logging from homeassistant.components.cover import ( CoverDevice, ENTITY_ID_FORMAT, ATTR_POSITION, ATTR_TILT_POSITION) from homeassistant.components.fibaro import ( - FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + FIBARO_DEVICES, FibaroDevice) DEPENDENCIES = ['fibaro'] @@ -22,16 +22,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return add_entities( - [FibaroCover(device, hass.data[FIBARO_CONTROLLER]) for + [FibaroCover(device) for device in hass.data[FIBARO_DEVICES]['cover']], True) class FibaroCover(FibaroDevice, CoverDevice): """Representation a Fibaro Cover.""" - def __init__(self, fibaro_device, controller): + def __init__(self, fibaro_device): """Initialize the Vera device.""" - super().__init__(fibaro_device, controller) + super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) @staticmethod diff --git a/homeassistant/components/cover/garadget.py b/homeassistant/components/cover/garadget.py index 03756a971bc..28be3dc6b82 100644 --- a/homeassistant/components/cover/garadget.py +++ b/homeassistant/components/cover/garadget.py @@ -47,7 +47,7 @@ COVER_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), + vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA), }) diff --git a/homeassistant/components/cover/homekit_controller.py b/homeassistant/components/cover/homekit_controller.py new file mode 100644 index 00000000000..cd3bc511291 --- /dev/null +++ b/homeassistant/components/cover/homekit_controller.py @@ -0,0 +1,305 @@ +""" +Support for Homekit Cover. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.homekit_controller/ +""" +import logging + +from homeassistant.components.homekit_controller import (HomeKitEntity, + KNOWN_ACCESSORIES) +from homeassistant.components.cover import ( + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, + SUPPORT_OPEN_TILT, SUPPORT_CLOSE_TILT, SUPPORT_SET_TILT_POSITION, + ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.const import ( + STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING) + +STATE_STOPPED = 'stopped' + +DEPENDENCIES = ['homekit_controller'] + +_LOGGER = logging.getLogger(__name__) + +CURRENT_GARAGE_STATE_MAP = { + 0: STATE_OPEN, + 1: STATE_CLOSED, + 2: STATE_OPENING, + 3: STATE_CLOSING, + 4: STATE_STOPPED +} + +TARGET_GARAGE_STATE_MAP = { + STATE_OPEN: 0, + STATE_CLOSED: 1, + STATE_STOPPED: 2 +} + +CURRENT_WINDOW_STATE_MAP = { + 0: STATE_OPENING, + 1: STATE_CLOSING, + 2: STATE_STOPPED +} + + +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']] + + if discovery_info['device-type'] == 'garage-door-opener': + add_entities([HomeKitGarageDoorCover(accessory, discovery_info)], + True) + else: + add_entities([HomeKitWindowCover(accessory, discovery_info)], + True) + + +class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): + """Representation of a HomeKit Garage Door.""" + + def __init__(self, accessory, discovery_info): + """Initialise the Cover.""" + super().__init__(accessory, discovery_info) + self._name = None + self._state = None + self._obstruction_detected = None + self.lock_state = None + + @property + def device_class(self): + """Define this cover as a garage door.""" + return 'garage' + + def update_characteristics(self, characteristics): + """Synchronise the Cover state with Home Assistant.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + for characteristic in characteristics: + ctype = characteristic['type'] + ctype = CharacteristicsTypes.get_short(ctype) + if ctype == "door-state.current": + self._chars['door-state.current'] = \ + characteristic['iid'] + self._state = CURRENT_GARAGE_STATE_MAP[characteristic['value']] + elif ctype == "door-state.target": + self._chars['door-state.target'] = \ + characteristic['iid'] + elif ctype == "obstruction-detected": + self._chars['obstruction-detected'] = characteristic['iid'] + self._obstruction_detected = characteristic['value'] + elif ctype == "name": + self._chars['name'] = characteristic['iid'] + self._name = characteristic['value'] + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def available(self): + """Return True if entity is available.""" + return self._state is not None + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self._state == STATE_CLOSED + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self._state == STATE_CLOSING + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self._state == STATE_OPENING + + def open_cover(self, **kwargs): + """Send open command.""" + self.set_door_state(STATE_OPEN) + + def close_cover(self, **kwargs): + """Send close command.""" + self.set_door_state(STATE_CLOSED) + + def set_door_state(self, state): + """Send state command.""" + characteristics = [{'aid': self._aid, + 'iid': self._chars['door-state.target'], + 'value': TARGET_GARAGE_STATE_MAP[state]}] + self.put_characteristics(characteristics) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + if self._obstruction_detected is None: + return None + + return { + 'obstruction-detected': self._obstruction_detected, + } + + +class HomeKitWindowCover(HomeKitEntity, CoverDevice): + """Representation of a HomeKit Window or Window Covering.""" + + def __init__(self, accessory, discovery_info): + """Initialise the Cover.""" + super().__init__(accessory, discovery_info) + self._name = None + self._state = None + self._position = None + self._tilt_position = None + self._hold = None + 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 update_characteristics(self, characteristics): + """Synchronise the Cover state with Home Assistant.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + for characteristic in characteristics: + ctype = characteristic['type'] + ctype = CharacteristicsTypes.get_short(ctype) + if ctype == "position.state": + self._chars['position.state'] = \ + characteristic['iid'] + if 'value' in characteristic: + self._state = \ + CURRENT_WINDOW_STATE_MAP[characteristic['value']] + elif ctype == "position.current": + self._chars['position.current'] = \ + characteristic['iid'] + self._position = characteristic['value'] + elif ctype == "position.target": + self._chars['position.target'] = \ + characteristic['iid'] + elif ctype == "position.hold": + self._chars['position.hold'] = characteristic['iid'] + if 'value' in characteristic: + self._hold = characteristic['value'] + elif ctype == "vertical-tilt.current": + self._chars['vertical-tilt.current'] = characteristic['iid'] + if characteristic['value'] is not None: + self._tilt_position = characteristic['value'] + elif ctype == "horizontal-tilt.current": + self._chars['horizontal-tilt.current'] = characteristic['iid'] + if characteristic['value'] is not None: + self._tilt_position = characteristic['value'] + elif ctype == "vertical-tilt.target": + self._chars['vertical-tilt.target'] = \ + characteristic['iid'] + elif ctype == "horizontal-tilt.target": + self._chars['vertical-tilt.target'] = \ + characteristic['iid'] + elif ctype == "obstruction-detected": + self._chars['obstruction-detected'] = characteristic['iid'] + self._obstruction_detected = characteristic['value'] + elif ctype == "name": + self._chars['name'] = characteristic['iid'] + if 'value' in characteristic: + self._name = characteristic['value'] + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = ( + SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION) + + if self._tilt_position is not None: + supported_features |= ( + SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | + SUPPORT_SET_TILT_POSITION) + + return supported_features + + @property + def current_cover_position(self): + """Return the current position of cover.""" + return self._position + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self._position == 0 + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self._state == STATE_CLOSING + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self._state == STATE_OPENING + + def open_cover(self, **kwargs): + """Send open command.""" + self.set_cover_position(position=100) + + def close_cover(self, **kwargs): + """Send close command.""" + self.set_cover_position(position=0) + + def set_cover_position(self, **kwargs): + """Send position command.""" + position = kwargs[ATTR_POSITION] + characteristics = [{'aid': self._aid, + 'iid': self._chars['position.target'], + 'value': position}] + self.put_characteristics(characteristics) + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt.""" + return self._tilt_position + + def set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + tilt_position = kwargs[ATTR_TILT_POSITION] + if 'vertical-tilt.target' in self._chars: + characteristics = [{'aid': self._aid, + 'iid': self._chars['vertical-tilt.target'], + 'value': tilt_position}] + self.put_characteristics(characteristics) + elif 'horizontal-tilt.target' in self._chars: + characteristics = [{'aid': self._aid, + 'iid': + self._chars['horizontal-tilt.target'], + 'value': tilt_position}] + self.put_characteristics(characteristics) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + state_attributes = {} + if self._obstruction_detected is not None: + state_attributes['obstruction-detected'] = \ + self._obstruction_detected + + if self._hold is not None: + state_attributes['hold-position'] = \ + self._hold + + return state_attributes diff --git a/homeassistant/components/cover/opengarage.py b/homeassistant/components/cover/opengarage.py index 19a87c5bf7c..664d2e291ac 100644 --- a/homeassistant/components/cover/opengarage.py +++ b/homeassistant/components/cover/opengarage.py @@ -46,7 +46,7 @@ COVER_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), + vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA), }) diff --git a/homeassistant/components/cover/scsgate.py b/homeassistant/components/cover/scsgate.py index a6f09c7237d..2d85c1fe3c3 100644 --- a/homeassistant/components/cover/scsgate.py +++ b/homeassistant/components/cover/scsgate.py @@ -18,7 +18,8 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['scsgate'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): vol.Schema({cv.slug: scsgate.SCSGATE_SCHEMA}), + vol.Required(CONF_DEVICES): + cv.schema_with_slug_keys(scsgate.SCSGATE_SCHEMA), }) diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index f64e4ae7a3f..1d3642a6036 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -67,7 +67,7 @@ COVER_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), + vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA), }) diff --git a/homeassistant/components/cover/velbus.py b/homeassistant/components/cover/velbus.py index a8501778884..7e5099cecf8 100644 --- a/homeassistant/components/cover/velbus.py +++ b/homeassistant/components/cover/velbus.py @@ -26,7 +26,7 @@ COVER_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), + vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA), }) DEPENDENCIES = ['velbus'] diff --git a/homeassistant/components/daikin/.translations/de.json b/homeassistant/components/daikin/.translations/de.json new file mode 100644 index 00000000000..0a09c7b5cfa --- /dev/null +++ b/homeassistant/components/daikin/.translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "device_fail": "Unerwarteter Fehler beim Erstellen des Ger\u00e4ts.", + "device_timeout": "Zeit\u00fcberschreitung beim Verbinden mit dem Ger\u00e4t." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Geben Sie die IP-Adresse Ihrer Daikin AC ein.", + "title": "Daikin AC konfigurieren" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/hu.json b/homeassistant/components/daikin/.translations/hu.json new file mode 100644 index 00000000000..623fab6828a --- /dev/null +++ b/homeassistant/components/daikin/.translations/hu.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Kiszolg\u00e1l\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/nl.json b/homeassistant/components/daikin/.translations/nl.json new file mode 100644 index 00000000000..683bb61dd44 --- /dev/null +++ b/homeassistant/components/daikin/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "device_fail": "Onverwachte fout bij het aanmaken van een apparaat.", + "device_timeout": "Time-out voor verbinding met het apparaat." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Voer het IP-adres van uw Daikin AC in.", + "title": "Daikin AC instellen" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/no.json b/homeassistant/components/daikin/.translations/no.json new file mode 100644 index 00000000000..806106c5e52 --- /dev/null +++ b/homeassistant/components/daikin/.translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "device_fail": "Uventet feil under oppretting av enheten.", + "device_timeout": "Tidsavbrudd for tilkobling til enheten." + }, + "step": { + "user": { + "data": { + "host": "Vert" + }, + "description": "Angi IP-adressen til din Daikin AC.", + "title": "Konfigurer Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/pl.json b/homeassistant/components/daikin/.translations/pl.json new file mode 100644 index 00000000000..49c5a497667 --- /dev/null +++ b/homeassistant/components/daikin/.translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "device_fail": "Nieoczekiwany b\u0142\u0105d tworzenia urz\u0105dzenia.", + "device_timeout": "Limit czasu pod\u0142\u0105czenia do urz\u0105dzenia." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Wprowad\u017a adres IP Daikin AC.", + "title": "Konfiguracja Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/pt-BR.json b/homeassistant/components/daikin/.translations/pt-BR.json new file mode 100644 index 00000000000..58c5a9c77b2 --- /dev/null +++ b/homeassistant/components/daikin/.translations/pt-BR.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "device_fail": "Erro inesperado ao criar dispositivo.", + "device_timeout": "Excedido tempo limite conectando ao dispositivo" + }, + "step": { + "user": { + "description": "Digite o endere\u00e7o IP do seu AC Daikin.", + "title": "Configurar o AC Daikin" + } + }, + "title": "AC Daikin" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/pt.json b/homeassistant/components/daikin/.translations/pt.json new file mode 100644 index 00000000000..34b4c86e77d --- /dev/null +++ b/homeassistant/components/daikin/.translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "device_fail": "Erro inesperado ao criar dispositivo.", + "device_timeout": "Tempo excedido a tentar ligar ao dispositivo." + }, + "step": { + "user": { + "data": { + "host": "Servidor" + }, + "description": "Introduza o endere\u00e7o IP do seu Daikin AC.", + "title": "Configurar o Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index 645daa56f6b..dce2c7a6704 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "Host", - "port": "Port (Standartwert : '80')" + "port": "Port" }, "title": "Definiere das deCONZ-Gateway" }, diff --git a/homeassistant/components/deconz/.translations/et.json b/homeassistant/components/deconz/.translations/et.json new file mode 100644 index 00000000000..93c54b3915c --- /dev/null +++ b/homeassistant/components/deconz/.translations/et.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "init": { + "data": { + "host": "", + "port": "" + } + } + }, + "title": "deCONZ Zigbee l\u00fc\u00fcs" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json index bc7a2cbd861..cea6f8ef4dd 100644 --- a/homeassistant/components/deconz/.translations/sl.json +++ b/homeassistant/components/deconz/.translations/sl.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "Gostitelj", - "port": "Vrata (privzeta vrednost: '80')" + "port": "Vrata" }, "title": "Dolo\u010dite deCONZ prehod" }, @@ -28,6 +28,6 @@ "title": "Dodatne mo\u017enosti konfiguracije za deCONZ" } }, - "title": "deCONZ" + "title": "deCONZ Zigbee prehod" } } \ No newline at end of file diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py new file mode 100644 index 00000000000..286b310c1a9 --- /dev/null +++ b/homeassistant/components/deconz/binary_sensor.py @@ -0,0 +1,89 @@ +""" +Support for deCONZ binary sensor. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.deconz/ +""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import ATTR_BATTERY_LEVEL +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DECONZ_DOMAIN) +from .deconz_device import DeconzDevice + +DEPENDENCIES = ['deconz'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up deCONZ binary sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the deCONZ binary sensor.""" + gateway = hass.data[DECONZ_DOMAIN] + + @callback + def async_add_sensor(sensors): + """Add binary sensor from deCONZ.""" + from pydeconz.sensor import DECONZ_BINARY_SENSOR + entities = [] + allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) + for sensor in sensors: + if sensor.type in DECONZ_BINARY_SENSOR and \ + not (not allow_clip_sensor and sensor.type.startswith('CLIP')): + entities.append(DeconzBinarySensor(sensor, gateway)) + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) + + async_add_sensor(gateway.api.sensors.values()) + + +class DeconzBinarySensor(DeconzDevice, BinarySensorDevice): + """Representation of a deCONZ binary sensor.""" + + @callback + def async_update_callback(self, reason): + """Update the sensor's state. + + If reason is that state is updated, + or reachable has changed or battery has changed. + """ + if reason['state'] or \ + 'reachable' in reason['attr'] or \ + 'battery' in reason['attr'] or \ + 'on' in reason['attr']: + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._device.is_tripped + + @property + def device_class(self): + """Return the class of the sensor.""" + return self._device.sensor_class + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._device.sensor_icon + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + from pydeconz.sensor import PRESENCE + attr = {} + if self._device.battery: + attr[ATTR_BATTERY_LEVEL] = self._device.battery + if self._device.on is not None: + 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 + return attr diff --git a/homeassistant/components/cover/deconz.py b/homeassistant/components/deconz/cover.py similarity index 56% rename from homeassistant/components/cover/deconz.py rename to homeassistant/components/deconz/cover.py index be60997869c..99bdd20a295 100644 --- a/homeassistant/components/cover/deconz.py +++ b/homeassistant/components/deconz/cover.py @@ -4,16 +4,15 @@ Support for deCONZ covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.deconz/ """ -from homeassistant.components.deconz.const import ( - COVER_TYPES, DAMPERS, DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN, - WINDOW_COVERS) from homeassistant.components.cover import ( ATTR_POSITION, CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_STOP, SUPPORT_SET_POSITION) from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .const import COVER_TYPES, DAMPERS, DOMAIN as DECONZ_DOMAIN, WINDOW_COVERS +from .deconz_device import DeconzDevice + DEPENDENCIES = ['deconz'] ZIGBEE_SPEC = ['lumi.curtain'] @@ -50,67 +49,36 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_cover(gateway.api.lights.values()) -class DeconzCover(CoverDevice): +class DeconzCover(DeconzDevice, CoverDevice): """Representation of a deCONZ cover.""" - def __init__(self, cover, gateway): + def __init__(self, device, gateway): """Set up cover and add update callback to get data from websocket.""" - self._cover = cover - self.gateway = gateway - self.unsub_dispatcher = None + super().__init__(device, gateway) self._features = SUPPORT_OPEN self._features |= SUPPORT_CLOSE self._features |= SUPPORT_STOP self._features |= SUPPORT_SET_POSITION - async def async_added_to_hass(self): - """Subscribe to covers events.""" - self._cover.register_async_callback(self.async_update_callback) - self.gateway.deconz_ids[self.entity_id] = self._cover.deconz_id - self.unsub_dispatcher = async_dispatcher_connect( - self.hass, DECONZ_REACHABLE, self.async_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect cover object when removed.""" - if self.unsub_dispatcher is not None: - self.unsub_dispatcher() - self._cover.remove_callback(self.async_update_callback) - self._cover = None - - @callback - def async_update_callback(self, reason): - """Update the cover's state.""" - self.async_schedule_update_ha_state() - @property def current_cover_position(self): """Return the current position of the cover.""" if self.is_closed: return 0 - return int(self._cover.brightness / 255 * 100) + return int(self._device.brightness / 255 * 100) @property def is_closed(self): """Return if the cover is closed.""" - return not self._cover.state - - @property - def name(self): - """Return the name of the cover.""" - return self._cover.name - - @property - def unique_id(self): - """Return a unique identifier for this cover.""" - return self._cover.uniqueid + return not self._device.state @property def device_class(self): """Return the class of the cover.""" - if self._cover.type in DAMPERS: + if self._device.type in DAMPERS: return 'damper' - if self._cover.type in WINDOW_COVERS: + if self._device.type in WINDOW_COVERS: return 'window' @property @@ -118,16 +86,6 @@ class DeconzCover(CoverDevice): """Flag supported features.""" return self._features - @property - def available(self): - """Return True if light is available.""" - return self.gateway.available and self._cover.reachable - - @property - def should_poll(self): - """No polling needed.""" - return False - async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] @@ -135,7 +93,7 @@ class DeconzCover(CoverDevice): if position > 0: data['on'] = True data['bri'] = int(position / 100 * 255) - await self._cover.async_set_state(data) + await self._device.async_set_state(data) async def async_open_cover(self, **kwargs): """Open cover.""" @@ -150,25 +108,7 @@ class DeconzCover(CoverDevice): async def async_stop_cover(self, **kwargs): """Stop cover.""" data = {'bri_inc': 0} - await self._cover.async_set_state(data) - - @property - def device_info(self): - """Return a device description for device registry.""" - if (self._cover.uniqueid is None or - self._cover.uniqueid.count(':') != 7): - return None - serial = self._cover.uniqueid.split('-', 1)[0] - bridgeid = self.gateway.api.config.bridgeid - return { - 'connections': {(CONNECTION_ZIGBEE, serial)}, - 'identifiers': {(DECONZ_DOMAIN, serial)}, - 'manufacturer': self._cover.manufacturer, - 'model': self._cover.modelid, - 'name': self._cover.name, - 'sw_version': self._cover.swversion, - 'via_hub': (DECONZ_DOMAIN, bridgeid), - } + await self._device.async_set_state(data) class DeconzCoverZigbeeSpec(DeconzCover): @@ -177,12 +117,12 @@ class DeconzCoverZigbeeSpec(DeconzCover): @property def current_cover_position(self): """Return the current position of the cover.""" - return 100 - int(self._cover.brightness / 255 * 100) + return 100 - int(self._device.brightness / 255 * 100) @property def is_closed(self): """Return if the cover is closed.""" - return self._cover.state + return self._device.state async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" @@ -191,4 +131,4 @@ class DeconzCoverZigbeeSpec(DeconzCover): if position < 100: data['on'] = True data['bri'] = 255 - int(position / 100 * 255) - await self._cover.async_set_state(data) + await self._device.async_set_state(data) diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py new file mode 100644 index 00000000000..bfcbd158b9f --- /dev/null +++ b/homeassistant/components/deconz/deconz_device.py @@ -0,0 +1,74 @@ +"""Base class for deCONZ devices.""" +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN + + +class DeconzDevice(Entity): + """Representation of a deCONZ device.""" + + def __init__(self, device, gateway): + """Set up device and add update callback to get data from websocket.""" + self._device = device + self.gateway = gateway + self.unsub_dispatcher = None + + async def async_added_to_hass(self): + """Subscribe to device events.""" + self._device.register_async_callback(self.async_update_callback) + self.gateway.deconz_ids[self.entity_id] = self._device.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() + self._device.remove_callback(self.async_update_callback) + self._device = None + + @callback + def async_update_callback(self, reason): + """Update the device's state.""" + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return self._device.uniqueid + + @property + def available(self): + """Return True if device is available.""" + return self.gateway.available and self._device.reachable + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_info(self): + """Return a device description for device registry.""" + if (self._device.uniqueid is None or + self._device.uniqueid.count(':') != 7): + return None + serial = self._device.uniqueid.split('-', 1)[0] + bridgeid = self.gateway.api.config.bridgeid + return { + 'connections': {(CONNECTION_ZIGBEE, serial)}, + 'identifiers': {(DECONZ_DOMAIN, serial)}, + 'manufacturer': self._device.manufacturer, + 'model': self._device.modelid, + 'name': self._device.name, + 'sw_version': self._device.swversion, + 'via_hub': (DECONZ_DOMAIN, bridgeid), + } diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/deconz/light.py similarity index 60% rename from homeassistant/components/light/deconz.py rename to homeassistant/components/deconz/light.py index ae2d241d81f..f7c777b8100 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/deconz/light.py @@ -4,19 +4,20 @@ Support for deCONZ light. For more details about this component, please refer to the documentation at https://home-assistant.io/components/light.deconz/ """ -from homeassistant.components.deconz.const import ( - CONF_ALLOW_DECONZ_GROUPS, DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN, - COVER_TYPES, SWITCH_TYPES) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_TRANSITION, Light) from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util +from .const import ( + CONF_ALLOW_DECONZ_GROUPS, DOMAIN as DECONZ_DOMAIN, COVER_TYPES, + SWITCH_TYPES) +from .deconz_device import DeconzDevice + DEPENDENCIES = ['deconz'] @@ -59,51 +60,30 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_group(gateway.api.groups.values()) -class DeconzLight(Light): +class DeconzLight(DeconzDevice, Light): """Representation of a deCONZ light.""" - def __init__(self, light, gateway): + def __init__(self, device, gateway): """Set up light and add update callback to get data from websocket.""" - self._light = light - self.gateway = gateway - self.unsub_dispatcher = None + super().__init__(device, gateway) self._features = SUPPORT_BRIGHTNESS self._features |= SUPPORT_FLASH self._features |= SUPPORT_TRANSITION - if self._light.ct is not None: + if self._device.ct is not None: self._features |= SUPPORT_COLOR_TEMP - if self._light.xy is not None: + if self._device.xy is not None: self._features |= SUPPORT_COLOR - if self._light.effect is not None: + if self._device.effect is not None: self._features |= SUPPORT_EFFECT - async def async_added_to_hass(self): - """Subscribe to lights events.""" - self._light.register_async_callback(self.async_update_callback) - self.gateway.deconz_ids[self.entity_id] = self._light.deconz_id - self.unsub_dispatcher = async_dispatcher_connect( - self.hass, DECONZ_REACHABLE, self.async_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect light object when removed.""" - if self.unsub_dispatcher is not None: - self.unsub_dispatcher() - self._light.remove_callback(self.async_update_callback) - self._light = None - - @callback - def async_update_callback(self, reason): - """Update the light's state.""" - self.async_schedule_update_ha_state() - @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._light.brightness + return self._device.brightness @property def effect_list(self): @@ -113,48 +93,28 @@ class DeconzLight(Light): @property def color_temp(self): """Return the CT color value.""" - if self._light.colormode != 'ct': + if self._device.colormode != 'ct': return None - return self._light.ct + return self._device.ct @property def hs_color(self): """Return the hs color value.""" - if self._light.colormode in ('xy', 'hs') and self._light.xy: - return color_util.color_xy_to_hs(*self._light.xy) + if self._device.colormode in ('xy', 'hs') and self._device.xy: + return color_util.color_xy_to_hs(*self._device.xy) return None @property def is_on(self): """Return true if light is on.""" - return self._light.state - - @property - def name(self): - """Return the name of the light.""" - return self._light.name - - @property - def unique_id(self): - """Return a unique identifier for this light.""" - return self._light.uniqueid + return self._device.state @property def supported_features(self): """Flag supported features.""" return self._features - @property - def available(self): - """Return True if light is available.""" - return self.gateway.available and self._light.reachable - - @property - def should_poll(self): - """No polling needed.""" - return False - async def async_turn_on(self, **kwargs): """Turn on light.""" data = {'on': True} @@ -185,7 +145,7 @@ class DeconzLight(Light): else: data['effect'] = 'none' - await self._light.async_set_state(data) + await self._device.async_set_state(data) async def async_turn_off(self, **kwargs): """Turn off light.""" @@ -203,31 +163,13 @@ class DeconzLight(Light): data['alert'] = 'lselect' del data['on'] - await self._light.async_set_state(data) + await self._device.async_set_state(data) @property def device_state_attributes(self): """Return the device state attributes.""" attributes = {} - attributes['is_deconz_group'] = self._light.type == 'LightGroup' - if self._light.type == 'LightGroup': - attributes['all_on'] = self._light.all_on + attributes['is_deconz_group'] = self._device.type == 'LightGroup' + if self._device.type == 'LightGroup': + attributes['all_on'] = self._device.all_on return attributes - - @property - def device_info(self): - """Return a device description for device registry.""" - if (self._light.uniqueid is None or - self._light.uniqueid.count(':') != 7): - return None - serial = self._light.uniqueid.split('-', 1)[0] - bridgeid = self.gateway.api.config.bridgeid - return { - 'connections': {(CONNECTION_ZIGBEE, serial)}, - 'identifiers': {(DECONZ_DOMAIN, serial)}, - 'manufacturer': self._light.manufacturer, - 'model': self._light.modelid, - 'name': self._light.name, - 'sw_version': self._light.swversion, - 'via_hub': (DECONZ_DOMAIN, bridgeid), - } diff --git a/homeassistant/components/scene/deconz.py b/homeassistant/components/deconz/scene.py similarity index 100% rename from homeassistant/components/scene/deconz.py rename to homeassistant/components/deconz/scene.py diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py new file mode 100644 index 00000000000..1913e3d5087 --- /dev/null +++ b/homeassistant/components/deconz/sensor.py @@ -0,0 +1,153 @@ +""" +Support for deCONZ sensor. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.deconz/ +""" +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import slugify + +from .const import ( + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DECONZ_DOMAIN) +from .deconz_device import DeconzDevice + +DEPENDENCIES = ['deconz'] + +ATTR_CURRENT = 'current' +ATTR_DAYLIGHT = 'daylight' +ATTR_EVENT_ID = 'event_id' + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up deCONZ sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the deCONZ sensors.""" + gateway = hass.data[DECONZ_DOMAIN] + + @callback + def async_add_sensor(sensors): + """Add sensors from deCONZ.""" + from pydeconz.sensor import DECONZ_SENSOR, SWITCH as DECONZ_REMOTE + entities = [] + allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) + for sensor in sensors: + if sensor.type in DECONZ_SENSOR and \ + not (not allow_clip_sensor and sensor.type.startswith('CLIP')): + if sensor.type in DECONZ_REMOTE: + if sensor.battery: + entities.append(DeconzBattery(sensor, gateway)) + else: + entities.append(DeconzSensor(sensor, gateway)) + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) + + async_add_sensor(gateway.api.sensors.values()) + + +class DeconzSensor(DeconzDevice): + """Representation of a deCONZ sensor.""" + + @callback + def async_update_callback(self, reason): + """Update the sensor's state. + + If reason is that state is updated, + or reachable has changed or battery has changed. + """ + if reason['state'] or \ + 'reachable' in reason['attr'] or \ + 'battery' in reason['attr'] or \ + 'on' in reason['attr']: + self.async_schedule_update_ha_state() + + @property + def state(self): + """Return the state of the sensor.""" + return self._device.state + + @property + def device_class(self): + """Return the class of the sensor.""" + return self._device.sensor_class + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._device.sensor_icon + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this sensor.""" + return self._device.sensor_unit + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + from pydeconz.sensor import LIGHTLEVEL + attr = {} + if self._device.battery: + attr[ATTR_BATTERY_LEVEL] = self._device.battery + if self._device.on is not None: + attr[ATTR_ON] = self._device.on + if self._device.type in LIGHTLEVEL and self._device.dark is not None: + attr[ATTR_DARK] = self._device.dark + if self.unit_of_measurement == 'Watts': + attr[ATTR_CURRENT] = self._device.current + attr[ATTR_VOLTAGE] = self._device.voltage + if self._device.sensor_class == 'daylight': + attr[ATTR_DAYLIGHT] = self._device.daylight + return attr + + +class DeconzBattery(DeconzDevice): + """Battery class for when a device is only represented as an event.""" + + def __init__(self, device, gateway): + """Register dispatcher callback for update of battery state.""" + super().__init__(device, gateway) + + self._name = '{} {}'.format(self._device.name, 'Battery Level') + self._unit_of_measurement = "%" + + @callback + def async_update_callback(self, reason): + """Update the battery's state, if needed.""" + if 'reachable' in reason['attr'] or 'battery' in reason['attr']: + self.async_schedule_update_ha_state() + + @property + def state(self): + """Return the state of the battery.""" + return self._device.battery + + @property + def name(self): + """Return the name of the battery.""" + return self._name + + @property + def device_class(self): + """Return the class of the sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes of the battery.""" + attr = { + ATTR_EVENT_ID: slugify(self._device.name), + } + return attr diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py new file mode 100644 index 00000000000..64d93389670 --- /dev/null +++ b/homeassistant/components/deconz/switch.py @@ -0,0 +1,83 @@ +""" +Support for deCONZ switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.deconz/ +""" +from homeassistant.components.switch import SwitchDevice +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS, SIRENS +from .deconz_device import DeconzDevice + + +DEPENDENCIES = ['deconz'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up deCONZ switches.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up switches for deCONZ component. + + Switches are based same device class as lights in deCONZ. + """ + gateway = hass.data[DECONZ_DOMAIN] + + @callback + def async_add_switch(lights): + """Add switch from deCONZ.""" + entities = [] + for light in lights: + if light.type in POWER_PLUGS: + entities.append(DeconzPowerPlug(light, gateway)) + elif light.type in SIRENS: + entities.append(DeconzSiren(light, gateway)) + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect(hass, 'deconz_new_light', async_add_switch)) + + async_add_switch(gateway.api.lights.values()) + + +class DeconzPowerPlug(DeconzDevice, SwitchDevice): + """Representation of a deCONZ power plug.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self._device.state + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + data = {'on': True} + await self._device.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + data = {'on': False} + await self._device.async_set_state(data) + + +class DeconzSiren(DeconzDevice, SwitchDevice): + """Representation of a deCONZ siren.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self._device.alert == 'lselect' + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + data = {'alert': 'lselect'} + await self._device.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + data = {'alert': 'none'} + await self._device.async_set_state(data) diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index a07fdfdcf81..825ef04ccc5 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -15,7 +15,7 @@ import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pygatt==3.2.0'] +REQUIREMENTS = ['pygatt[GATTTOOL]==3.2.0'] BLE_PREFIX = 'BLE_' MIN_SEEN_NEW = 5 diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py index 1f95414541c..c324f3c2757 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -19,7 +19,7 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify, dt as dt_util -REQUIREMENTS = ['locationsharinglib==3.0.9'] +REQUIREMENTS = ['locationsharinglib==3.0.11'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/googlehome.py b/homeassistant/components/device_tracker/googlehome.py index dabb92a0751..daa36d1d2c7 100644 --- a/homeassistant/components/device_tracker/googlehome.py +++ b/homeassistant/components/device_tracker/googlehome.py @@ -89,5 +89,7 @@ class GoogleHomeDeviceScanner(DeviceScanner): devices[uuid]['btle_mac_address'] = device['mac_address'] devices[uuid]['ghname'] = ghname devices[uuid]['source_type'] = 'bluetooth' + if device['name']: + devices[uuid]['btle_name'] = device['name'] await self.scanner.clear_scan_result() self.last_results = devices diff --git a/homeassistant/components/device_tracker/gpslogger.py b/homeassistant/components/device_tracker/gpslogger.py index f39684aa834..e0d9b37bf84 100644 --- a/homeassistant/components/device_tracker/gpslogger.py +++ b/homeassistant/components/device_tracker/gpslogger.py @@ -5,104 +5,28 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.gpslogger/ """ import logging -from hmac import compare_digest -from aiohttp.web import Request, HTTPUnauthorized -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_PASSWORD, HTTP_UNPROCESSABLE_ENTITY -) -from homeassistant.components.http import ( - CONF_API_PASSWORD, HomeAssistantView -) -# pylint: disable=unused-import -from homeassistant.components.device_tracker import ( # NOQA - DOMAIN, PLATFORM_SCHEMA -) +from homeassistant.components.gpslogger import TRACKER_UPDATE +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['http'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_PASSWORD): cv.string, -}) +DEPENDENCIES = ['gpslogger'] async def async_setup_scanner(hass: HomeAssistantType, config: ConfigType, async_see, discovery_info=None): - """Set up an endpoint for the GPSLogger application.""" - hass.http.register_view(GPSLoggerView(async_see, config)) - - return True - - -class GPSLoggerView(HomeAssistantView): - """View to handle GPSLogger requests.""" - - url = '/api/gpslogger' - name = 'api:gpslogger' - - def __init__(self, async_see, config): - """Initialize GPSLogger url endpoints.""" - self.async_see = async_see - self._password = config.get(CONF_PASSWORD) - # this component does not require external authentication if - # password is set - self.requires_auth = self._password is None - - async def get(self, request: Request): - """Handle for GPSLogger message received as GET.""" - hass = request.app['hass'] - data = request.query - - if self._password is not None: - authenticated = CONF_API_PASSWORD in data and compare_digest( - self._password, - data[CONF_API_PASSWORD] - ) - if not authenticated: - raise HTTPUnauthorized() - - if 'latitude' not in data or 'longitude' not in data: - return ('Latitude and longitude not specified.', - HTTP_UNPROCESSABLE_ENTITY) - - if 'device' not in data: - _LOGGER.error("Device id not specified") - return ('Device id not specified.', - HTTP_UNPROCESSABLE_ENTITY) - - device = data['device'].replace('-', '') - gps_location = (data['latitude'], data['longitude']) - accuracy = 200 - battery = -1 - - if 'accuracy' in data: - accuracy = int(float(data['accuracy'])) - if 'battery' in data: - battery = float(data['battery']) - - attrs = {} - if 'speed' in data: - attrs['speed'] = float(data['speed']) - if 'direction' in data: - attrs['direction'] = float(data['direction']) - if 'altitude' in data: - attrs['altitude'] = float(data['altitude']) - if 'provider' in data: - attrs['provider'] = data['provider'] - if 'activity' in data: - attrs['activity'] = data['activity'] - - hass.async_create_task(self.async_see( + """Set up an endpoint for the GPSLogger device tracker.""" + async def _set_location(device, gps_location, battery, accuracy, attrs): + """Fire HA event to set location.""" + await async_see( dev_id=device, - gps=gps_location, battery=battery, + gps=gps_location, + battery=battery, gps_accuracy=accuracy, attributes=attrs - )) + ) - return 'Setting location for {}'.format(device) + async_dispatcher_connect(hass, TRACKER_UPDATE, _set_location) + return True diff --git a/homeassistant/components/device_tracker/linksys_ap.py b/homeassistant/components/device_tracker/linksys_ap.py index 5fa33583567..5638db4caaf 100644 --- a/homeassistant/components/device_tracker/linksys_ap.py +++ b/homeassistant/components/device_tracker/linksys_ap.py @@ -19,7 +19,7 @@ from homeassistant.const import ( INTERFACES = 2 DEFAULT_TIMEOUT = 10 -REQUIREMENTS = ['beautifulsoup4==4.6.3'] +REQUIREMENTS = ['beautifulsoup4==4.7.1'] _LOGGER = logging.getLogger(__name__) @@ -81,13 +81,14 @@ class LinksysAPDeviceScanner(DeviceScanner): request = self._make_request(interface) self.last_results.extend( [x.find_all('td')[1].text - for x in BS(request.content, "html.parser") + for x in BS(request.content, 'html.parser') .find_all(class_='section-row')] ) return True def _make_request(self, unit=0): + """Create a request to get the data.""" # No, the '&&' is not a typo - this is expected by the web interface. login = base64.b64encode(bytes(self.username, 'utf8')).decode('ascii') pwd = base64.b64encode(bytes(self.password, 'utf8')).decode('ascii') diff --git a/homeassistant/components/device_tracker/locative.py b/homeassistant/components/device_tracker/locative.py index aa91f0d3d71..e7a63077a3a 100644 --- a/homeassistant/components/device_tracker/locative.py +++ b/homeassistant/components/device_tracker/locative.py @@ -4,106 +4,25 @@ Support for the Locative platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.locative/ """ -from functools import partial import logging -from homeassistant.const import ( - ATTR_LATITUDE, ATTR_LONGITUDE, STATE_NOT_HOME, HTTP_UNPROCESSABLE_ENTITY) -from homeassistant.components.http import HomeAssistantView -# pylint: disable=unused-import -from homeassistant.components.device_tracker import ( # NOQA - DOMAIN, PLATFORM_SCHEMA) +from homeassistant.components.locative import TRACKER_UPDATE +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['http'] -URL = '/api/locative' +DEPENDENCIES = ['locative'] -def setup_scanner(hass, config, see, discovery_info=None): - """Set up an endpoint for the Locative application.""" - hass.http.register_view(LocativeView(see)) +async def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up an endpoint for the Locative device tracker.""" + async def _set_location(device, gps_location, location_name): + """Fire HA event to set location.""" + await async_see( + dev_id=device, + gps=gps_location, + location_name=location_name + ) + async_dispatcher_connect(hass, TRACKER_UPDATE, _set_location) return True - - -class LocativeView(HomeAssistantView): - """View to handle Locative requests.""" - - url = URL - name = 'api:locative' - - def __init__(self, see): - """Initialize Locative URL endpoints.""" - self.see = see - - async def get(self, request): - """Locative message received as GET.""" - res = await self._handle(request.app['hass'], request.query) - return res - - async def post(self, request): - """Locative message received.""" - data = await request.post() - res = await self._handle(request.app['hass'], data) - return res - - async def _handle(self, hass, data): - """Handle locative request.""" - if 'latitude' not in data or 'longitude' not in data: - return ('Latitude and longitude not specified.', - HTTP_UNPROCESSABLE_ENTITY) - - if 'device' not in data: - _LOGGER.error('Device id not specified.') - return ('Device id not specified.', - HTTP_UNPROCESSABLE_ENTITY) - - if 'trigger' not in data: - _LOGGER.error('Trigger is not specified.') - return ('Trigger is not specified.', - HTTP_UNPROCESSABLE_ENTITY) - - if 'id' not in data and data['trigger'] != 'test': - _LOGGER.error('Location id not specified.') - return ('Location id not specified.', - HTTP_UNPROCESSABLE_ENTITY) - - device = data['device'].replace('-', '') - location_name = data.get('id', data['trigger']).lower() - direction = data['trigger'] - gps_location = (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]) - - if direction == 'enter': - await hass.async_add_job( - partial(self.see, dev_id=device, location_name=location_name, - gps=gps_location)) - return 'Setting location to {}'.format(location_name) - - if direction == 'exit': - current_state = hass.states.get( - '{}.{}'.format(DOMAIN, device)) - - if current_state is None or current_state.state == location_name: - location_name = STATE_NOT_HOME - await hass.async_add_job( - partial(self.see, dev_id=device, - location_name=location_name, gps=gps_location)) - return 'Setting location to not home' - - # Ignore the message if it is telling us to exit a zone that we - # aren't currently in. This occurs when a zone is entered - # before the previous zone was exited. The enter message will - # be sent first, then the exit message will be sent second. - return 'Ignoring exit from {} (already in {})'.format( - location_name, current_state) - - if direction == 'test': - # In the app, a test message can be sent. Just return something to - # the user to let them know that it works. - return 'Received test message.' - - _LOGGER.error('Received unidentified message from Locative: %s', - direction) - return ('Received unidentified message: {}'.format(direction), - HTTP_UNPROCESSABLE_ENTITY) diff --git a/homeassistant/components/device_tracker/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index 8b10bc2b9bb..705dc9968c9 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -14,7 +14,7 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up the MySensors device scanner.""" new_devices = mysensors.setup_mysensors_platform( hass, DOMAIN, discovery_info, MySensorsDeviceScanner, - device_args=(async_see, )) + device_args=(hass, async_see)) if not new_devices: return False @@ -37,12 +37,13 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): class MySensorsDeviceScanner(mysensors.device.MySensorsDevice): """Represent a MySensors scanner.""" - def __init__(self, async_see, *args): + def __init__(self, hass, async_see, *args): """Set up instance.""" super().__init__(*args) self.async_see = async_see + self.hass = hass - async def async_update_callback(self): + async def _async_update_callback(self): """Update the device.""" await self.async_update() node = self.gateway.sensors[self.node_id] diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 40a6f48d889..7c6efc82ef9 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['pysnmp==4.4.6'] +REQUIREMENTS = ['pysnmp==4.4.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 627d2092a11..0c0c908d1e0 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD from homeassistant.const import CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pyunifi==2.13'] +REQUIREMENTS = ['pyunifi==2.16'] _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' diff --git a/homeassistant/components/device_tracker/unifi_direct.py b/homeassistant/components/device_tracker/unifi_direct.py index 3b5dcc8bac2..bd90099e45c 100644 --- a/homeassistant/components/device_tracker/unifi_direct.py +++ b/homeassistant/components/device_tracker/unifi_direct.py @@ -131,6 +131,6 @@ def _response_to_json(response): active_clients[client.get("mac")] = client return active_clients - except ValueError: + except (ValueError, TypeError): _LOGGER.error("Failed to decode response from AP.") return {} diff --git a/homeassistant/components/dialogflow/.translations/ca.json b/homeassistant/components/dialogflow/.translations/ca.json index f6dfc9399c2..0967b1c158e 100644 --- a/homeassistant/components/dialogflow/.translations/ca.json +++ b/homeassistant/components/dialogflow/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar Dialogflow?", - "title": "Configuraci\u00f3 del Webhook de Dialogflow" + "title": "Configuraci\u00f3 del Webhook Dialogflow" } }, "title": "Dialogflow" diff --git a/homeassistant/components/dialogflow/.translations/de.json b/homeassistant/components/dialogflow/.translations/de.json index e10d890b501..f585799391e 100644 --- a/homeassistant/components/dialogflow/.translations/de.json +++ b/homeassistant/components/dialogflow/.translations/de.json @@ -1,7 +1,15 @@ { "config": { + "abort": { + "not_internet_accessible": "Ihre Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Dialogflow-Nachrichten empfangen zu k\u00f6nnen.", + "one_instance_allowed": "Nur eine einzige Instanz ist notwendig." + }, + "create_entry": { + "default": "Um Ereignisse an den Home Assistant zu senden, m\u00fcssen Sie [Webhook-Integration von Dialogflow]({dialogflow_url}) einrichten. \n\nF\u00fcllen Sie die folgenden Informationen aus: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhaltstyp: application / json \n\nWeitere Informationen finden Sie in der [Dokumentation]({docs_url})." + }, "step": { "user": { + "description": "M\u00f6chten Sie Dialogflow wirklich einrichten?", "title": "Dialogflow Webhook einrichten" } }, diff --git a/homeassistant/components/dialogflow/.translations/es.json b/homeassistant/components/dialogflow/.translations/es.json index 892f0c5bfd0..ee07635de4a 100644 --- a/homeassistant/components/dialogflow/.translations/es.json +++ b/homeassistant/components/dialogflow/.translations/es.json @@ -4,6 +4,9 @@ "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Dialogflow.", "one_instance_allowed": "Solo una instancia es necesaria." }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, necesitas configurar [Integracion de flujos de dialogo de webhook]({dialogflow_url}).\n\nRellena la siguiente informaci\u00f3n:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nVer [Documentaci\u00f3n]({docs_url}) para mas detalles." + }, "step": { "user": { "description": "\u00bfEst\u00e1 seguro de que desea configurar Dialogflow?" diff --git a/homeassistant/components/dialogflow/.translations/nl.json b/homeassistant/components/dialogflow/.translations/nl.json index 5a28d6be9ac..9871df0d262 100644 --- a/homeassistant/components/dialogflow/.translations/nl.json +++ b/homeassistant/components/dialogflow/.translations/nl.json @@ -4,6 +4,9 @@ "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Dialogflow-berichten te ontvangen.", "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." }, + "create_entry": { + "default": "Om evenementen naar de Home Assistant te verzenden, moet u [webhookintegratie van Dialogflow]({dialogflow_url}) instellen. \n\n Vul de volgende info in: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nZie [de documentatie]({docs_url}) voor verdere informatie." + }, "step": { "user": { "description": "Weet u zeker dat u Dialogflow wilt instellen?", diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index f87395520bb..d8198ba3033 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -47,6 +47,7 @@ SERVICE_OCTOPRINT = 'octoprint' SERVICE_FREEBOX = 'freebox' SERVICE_IGD = 'igd' SERVICE_DLNA_DMR = 'dlna_dmr' +SERVICE_ROKU = 'roku' CONFIG_ENTRY_HANDLERS = { SERVICE_DAIKIN: 'daikin', @@ -67,6 +68,7 @@ SERVICE_HANDLERS = { SERVICE_HASSIO: ('hassio', None), SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), + SERVICE_ROKU: ('roku', None), SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), SERVICE_SABNZBD: ('sabnzbd', None), @@ -76,7 +78,6 @@ SERVICE_HANDLERS = { SERVICE_FREEBOX: ('freebox', None), 'panasonic_viera': ('media_player', 'panasonic_viera'), 'plex_mediaserver': ('media_player', 'plex'), - 'roku': ('media_player', 'roku'), 'yamaha': ('media_player', 'yamaha'), 'logitech_mediaserver': ('media_player', 'squeezebox'), 'directv': ('media_player', 'directv'), diff --git a/homeassistant/components/doorbird.py b/homeassistant/components/doorbird.py index 42d14205e75..28747bbe8be 100644 --- a/homeassistant/components/doorbird.py +++ b/homeassistant/components/doorbird.py @@ -6,15 +6,16 @@ https://home-assistant.io/components/doorbird/ """ import logging +from urllib.error import HTTPError import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.const import CONF_HOST, CONF_USERNAME, \ CONF_PASSWORD, CONF_NAME, CONF_DEVICES, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv -from homeassistant.util import slugify +from homeassistant.util import slugify, dt as dt_util -REQUIREMENTS = ['doorbirdpy==2.0.4'] +REQUIREMENTS = ['doorbirdpy==2.0.6'] _LOGGER = logging.getLogger(__name__) @@ -25,6 +26,7 @@ API_URL = '/api/{}'.format(DOMAIN) CONF_CUSTOM_URL = 'hass_url_override' CONF_DOORBELL_EVENTS = 'doorbell_events' CONF_DOORBELL_NUMS = 'doorbell_numbers' +CONF_RELAY_NUMS = 'relay_numbers' CONF_MOTION_EVENTS = 'motion_events' CONF_TOKEN = 'token' @@ -37,6 +39,10 @@ SENSOR_TYPES = { 'name': 'Motion', 'device_class': 'motion', }, + 'relay': { + 'name': 'Relay', + 'device_class': 'relay', + } } RESET_DEVICE_FAVORITES = 'doorbird_reset_favorites' @@ -47,6 +53,8 @@ DEVICE_SCHEMA = vol.Schema({ vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_DOORBELL_NUMS, default=[1]): vol.All( cv.ensure_list, [cv.positive_int]), + vol.Optional(CONF_RELAY_NUMS, default=[1]): vol.All( + cv.ensure_list, [cv.positive_int]), vol.Optional(CONF_CUSTOM_URL): cv.string, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): @@ -80,6 +88,7 @@ def setup(hass, config): username = doorstation_config.get(CONF_USERNAME) password = doorstation_config.get(CONF_PASSWORD) doorbell_nums = doorstation_config.get(CONF_DOORBELL_NUMS) + relay_nums = doorstation_config.get(CONF_RELAY_NUMS) custom_url = doorstation_config.get(CONF_CUSTOM_URL) events = doorstation_config.get(CONF_MONITORED_CONDITIONS) name = (doorstation_config.get(CONF_NAME) @@ -90,7 +99,7 @@ def setup(hass, config): if status[0]: doorstation = ConfiguredDoorBird(device, name, events, custom_url, - doorbell_nums, token) + doorbell_nums, relay_nums, token) doorstations.append(doorstation) _LOGGER.info('Connected to DoorBird "%s" as %s@%s', doorstation.name, username, device_ip) @@ -105,7 +114,18 @@ def setup(hass, config): # Subscribe to doorbell or motion events if events: - doorstation.update_schedule(hass) + try: + doorstation.update_schedule(hass) + except HTTPError: + hass.components.persistent_notification.create( + 'Doorbird configuration failed. Please verify that API ' + 'Operator permission is enabled for the Doorbird user. ' + 'A restart will be required once permissions have been ' + 'verified.', + title='Doorbird Configuration Failure', + notification_id='doorbird_schedule_error') + + return False hass.data[DOMAIN] = doorstations @@ -148,13 +168,15 @@ def handle_event(event): class ConfiguredDoorBird(): """Attach additional information to pass along with configured device.""" - def __init__(self, device, name, events, custom_url, doorbell_nums, token): + def __init__(self, device, name, events, custom_url, doorbell_nums, + relay_nums, token): """Initialize configured device.""" self._name = name self._device = device self._custom_url = custom_url self._monitored_events = events self._doorbell_nums = doorbell_nums + self._relay_nums = relay_nums self._token = token @property @@ -218,9 +240,9 @@ class ConfiguredDoorBird(): # Register HA URL as webhook if not already, then get the ID if not self.webhook_is_registered(hass_url): - self.device.change_favorite('http', - 'Home Assistant on {} ({} events)' - .format(hass_url, event), hass_url) + self.device.change_favorite('http', 'Home Assistant ({} events)' + .format(event), hass_url) + fav_id = self.get_webhook_id(hass_url) if not fav_id: @@ -239,6 +261,11 @@ class ConfiguredDoorBird(): entry = self.device.get_schedule_entry(event, str(doorbell)) entry.output.append(output) self.device.change_schedule(entry) + elif event == 'relay': + # Repeat edit for each monitored doorbell number + for relay in self._relay_nums: + entry = self.device.get_schedule_entry(event, str(relay)) + entry.output.append(output) else: entry = self.device.get_schedule_entry(event) entry.output.append(output) @@ -303,6 +330,16 @@ class ConfiguredDoorBird(): return None + def get_event_data(self): + """Get data to pass along with HA event.""" + return { + 'timestamp': dt_util.utcnow().isoformat(), + 'live_video_url': self._device.live_video_url, + 'live_image_url': self._device.live_image_url, + 'rtsp_live_video_url': self._device.rtsp_live_video_url, + 'html5_viewer_url': self._device.html5_viewer_url + } + class DoorBirdRequestView(HomeAssistantView): """Provide a page for the device to call.""" @@ -330,7 +367,14 @@ class DoorBirdRequestView(HomeAssistantView): if request_token == '' or not authenticated: return web.Response(status=401, text='Unauthorized') - hass.bus.async_fire('{}_{}'.format(DOMAIN, sensor)) + doorstation = get_doorstation_by_slug(hass, sensor) + + if doorstation: + event_data = doorstation.get_event_data() + else: + event_data = {} + + hass.bus.async_fire('{}_{}'.format(DOMAIN, sensor), event_data) return web.Response(status=200, text='OK') diff --git a/homeassistant/components/emulated_roku/.translations/ca.json b/homeassistant/components/emulated_roku/.translations/ca.json new file mode 100644 index 00000000000..bdd38b8538c --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "El nom ja existeix" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP d'advert\u00e8ncies", + "advertise_port": "Port d'advert\u00e8ncies", + "host_ip": "IP de l'amfitri\u00f3", + "listen_port": "Port d'escolta", + "name": "Nom", + "upnp_bind_multicast": "Enlla\u00e7ar multicast (true/false)" + }, + "title": "Configuraci\u00f3 del servidor" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/en.json b/homeassistant/components/emulated_roku/.translations/en.json new file mode 100644 index 00000000000..376252966a3 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Name already exists" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Advertise IP", + "advertise_port": "Advertise port", + "host_ip": "Host IP", + "listen_port": "Listen port", + "name": "Name", + "upnp_bind_multicast": "Bind multicast (True/False)" + }, + "title": "Define server configuration" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/et.json b/homeassistant/components/emulated_roku/.translations/et.json new file mode 100644 index 00000000000..e284f6c3732 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/et.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host_ip": "", + "name": "Nimi" + } + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/ko.json b/homeassistant/components/emulated_roku/.translations/ko.json new file mode 100644 index 00000000000..54c3e079386 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "advertise_ip": "\uad11\uace0 IP", + "advertise_port": "\uad11\uace0 \ud3ec\ud2b8", + "host_ip": "\ud638\uc2a4\ud2b8 IP", + "listen_port": "\uc218\uc2e0 \ud3ec\ud2b8", + "name": "\uc774\ub984", + "upnp_bind_multicast": "\uba40\ud2f0 \uce90\uc2a4\ud2b8 \ubc14\uc778\ub4dc (\ucc38/\uac70\uc9d3)" + }, + "title": "\uc11c\ubc84 \uad6c\uc131 \uc815\uc758" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/no.json b/homeassistant/components/emulated_roku/.translations/no.json new file mode 100644 index 00000000000..e83497599ca --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Navnet eksisterer allerede" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Annonser IP", + "advertise_port": "Annonser port", + "host_ip": "Vert IP", + "listen_port": "Lytte port", + "name": "Navn", + "upnp_bind_multicast": "Bind multicast (True/False)" + }, + "title": "Definer serverkonfigurasjon" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/ru.json b/homeassistant/components/emulated_roku/.translations/ru.json new file mode 100644 index 00000000000..611b5647233 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "name_exists": "\u0418\u043c\u044f \u0443\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442" + }, + "step": { + "user": { + "data": { + "host_ip": "\u0425\u043e\u0441\u0442", + "listen_port": "\u041f\u043e\u0440\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "title": "EmulatedRoku" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py new file mode 100644 index 00000000000..8ebaa5e4b26 --- /dev/null +++ b/homeassistant/components/emulated_roku/__init__.py @@ -0,0 +1,84 @@ +""" +Support for Roku API emulation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/emulated_roku/ +""" +import voluptuous as vol + +from homeassistant import config_entries, util +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv + +from .binding import EmulatedRoku +from .config_flow import configured_servers +from .const import ( + CONF_ADVERTISE_IP, CONF_ADVERTISE_PORT, CONF_HOST_IP, CONF_LISTEN_PORT, + CONF_SERVERS, CONF_UPNP_BIND_MULTICAST, DOMAIN) + +REQUIREMENTS = ['emulated_roku==0.1.7'] + +SERVER_CONFIG_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_LISTEN_PORT): cv.port, + vol.Optional(CONF_HOST_IP): cv.string, + vol.Optional(CONF_ADVERTISE_IP): cv.string, + vol.Optional(CONF_ADVERTISE_PORT): cv.port, + vol.Optional(CONF_UPNP_BIND_MULTICAST): cv.boolean +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_SERVERS): + vol.All(cv.ensure_list, [SERVER_CONFIG_SCHEMA]), + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the emulated roku component.""" + conf = config.get(DOMAIN) + + if conf is None: + return True + + existing_servers = configured_servers(hass) + + for entry in conf[CONF_SERVERS]: + if entry[CONF_NAME] not in existing_servers: + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + data=entry + )) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up an emulated roku server from a config entry.""" + config = config_entry.data + + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + name = config[CONF_NAME] + listen_port = config[CONF_LISTEN_PORT] + host_ip = config.get(CONF_HOST_IP) or util.get_local_ip() + advertise_ip = config.get(CONF_ADVERTISE_IP) + advertise_port = config.get(CONF_ADVERTISE_PORT) + upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST) + + server = EmulatedRoku(hass, name, host_ip, listen_port, + advertise_ip, advertise_port, upnp_bind_multicast) + + hass.data[DOMAIN][name] = server + + return await server.setup() + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + name = entry.data[CONF_NAME] + server = hass.data[DOMAIN].pop(name) + return await server.unload() diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py new file mode 100644 index 00000000000..cd42560288d --- /dev/null +++ b/homeassistant/components/emulated_roku/binding.py @@ -0,0 +1,147 @@ +"""Bridge between emulated_roku and Home Assistant.""" +import logging + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import CoreState, EventOrigin + +LOGGER = logging.getLogger('homeassistant.components.emulated_roku') + +EVENT_ROKU_COMMAND = 'roku_command' + +ATTR_COMMAND_TYPE = 'type' +ATTR_SOURCE_NAME = 'source_name' +ATTR_KEY = 'key' +ATTR_APP_ID = 'app_id' + +ROKU_COMMAND_KEYDOWN = 'keydown' +ROKU_COMMAND_KEYUP = 'keyup' +ROKU_COMMAND_KEYPRESS = 'keypress' +ROKU_COMMAND_LAUNCH = 'launch' + + +class EmulatedRoku: + """Manages an emulated_roku server.""" + + def __init__(self, hass, name, host_ip, listen_port, + advertise_ip, advertise_port, upnp_bind_multicast): + """Initialize the properties.""" + self.hass = hass + + self.roku_usn = name + self.host_ip = host_ip + self.listen_port = listen_port + + self.advertise_port = advertise_port + self.advertise_ip = advertise_ip + + self.bind_multicast = upnp_bind_multicast + + self._api_server = None + + self._unsub_start_listener = None + self._unsub_stop_listener = None + + async def setup(self): + """Start the emulated_roku server.""" + from emulated_roku import EmulatedRokuServer, \ + EmulatedRokuCommandHandler + + class EventCommandHandler(EmulatedRokuCommandHandler): + """emulated_roku command handler to turn commands into events.""" + + def __init__(self, hass): + self.hass = hass + + def on_keydown(self, roku_usn, key): + """Handle keydown event.""" + self.hass.bus.async_fire(EVENT_ROKU_COMMAND, { + ATTR_SOURCE_NAME: roku_usn, + ATTR_COMMAND_TYPE: ROKU_COMMAND_KEYDOWN, + ATTR_KEY: key + }, EventOrigin.local) + + def on_keyup(self, roku_usn, key): + """Handle keyup event.""" + self.hass.bus.async_fire(EVENT_ROKU_COMMAND, { + ATTR_SOURCE_NAME: roku_usn, + ATTR_COMMAND_TYPE: ROKU_COMMAND_KEYUP, + ATTR_KEY: key + }, EventOrigin.local) + + def on_keypress(self, roku_usn, key): + """Handle keypress event.""" + self.hass.bus.async_fire(EVENT_ROKU_COMMAND, { + ATTR_SOURCE_NAME: roku_usn, + ATTR_COMMAND_TYPE: ROKU_COMMAND_KEYPRESS, + ATTR_KEY: key + }, EventOrigin.local) + + def launch(self, roku_usn, app_id): + """Handle launch event.""" + self.hass.bus.async_fire(EVENT_ROKU_COMMAND, { + ATTR_SOURCE_NAME: roku_usn, + ATTR_COMMAND_TYPE: ROKU_COMMAND_LAUNCH, + ATTR_APP_ID: app_id + }, EventOrigin.local) + + LOGGER.debug("Intializing emulated_roku %s on %s:%s", + self.roku_usn, self.host_ip, self.listen_port) + + handler = EventCommandHandler(self.hass) + + self._api_server = EmulatedRokuServer( + self.hass.loop, handler, + self.roku_usn, self.host_ip, self.listen_port, + advertise_ip=self.advertise_ip, + advertise_port=self.advertise_port, + bind_multicast=self.bind_multicast + ) + + async def emulated_roku_stop(event): + """Wrap the call to emulated_roku.close.""" + LOGGER.debug("Stopping emulated_roku %s", self.roku_usn) + self._unsub_stop_listener = None + await self._api_server.close() + + async def emulated_roku_start(event): + """Wrap the call to emulated_roku.start.""" + try: + LOGGER.debug("Starting emulated_roku %s", self.roku_usn) + self._unsub_start_listener = None + await self._api_server.start() + except OSError: + LOGGER.exception("Failed to start Emulated Roku %s on %s:%s", + self.roku_usn, self.host_ip, self.listen_port) + # clean up inconsistent state on errors + await emulated_roku_stop(None) + else: + self._unsub_stop_listener = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + emulated_roku_stop) + + # start immediately if already running + if self.hass.state == CoreState.running: + await emulated_roku_start(None) + else: + self._unsub_start_listener = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, + emulated_roku_start) + + return True + + async def unload(self): + """Unload the emulated_roku server.""" + LOGGER.debug("Unloading emulated_roku %s", self.roku_usn) + + if self._unsub_start_listener: + self._unsub_start_listener() + self._unsub_start_listener = None + + if self._unsub_stop_listener: + self._unsub_stop_listener() + self._unsub_stop_listener = None + + await self._api_server.close() + + return True diff --git a/homeassistant/components/emulated_roku/config_flow.py b/homeassistant/components/emulated_roku/config_flow.py new file mode 100644 index 00000000000..f2d56f84681 --- /dev/null +++ b/homeassistant/components/emulated_roku/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow to configure emulated_roku component.""" + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.core import callback + +from .const import CONF_LISTEN_PORT, DEFAULT_NAME, DEFAULT_PORT, DOMAIN + + +@callback +def configured_servers(hass): + """Return a set of the configured servers.""" + return set(entry.data[CONF_NAME] for entry + in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class EmulatedRokuFlowHandler(config_entries.ConfigFlow): + """Handle an emulated_roku config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + name = user_input[CONF_NAME] + + if name in configured_servers(self.hass): + return self.async_abort(reason='name_exists') + + return self.async_create_entry( + title=name, + data=user_input + ) + + servers_num = len(configured_servers(self.hass)) + + if servers_num: + default_name = "{} {}".format(DEFAULT_NAME, servers_num + 1) + default_port = DEFAULT_PORT + servers_num + else: + default_name = DEFAULT_NAME + default_port = DEFAULT_PORT + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_NAME, + default=default_name): str, + vol.Required(CONF_LISTEN_PORT, + default=default_port): vol.Coerce(int) + }), + errors=errors + ) + + async def async_step_import(self, import_config): + """Handle a flow import.""" + return await self.async_step_user(import_config) diff --git a/homeassistant/components/emulated_roku/const.py b/homeassistant/components/emulated_roku/const.py new file mode 100644 index 00000000000..f4a034e31ac --- /dev/null +++ b/homeassistant/components/emulated_roku/const.py @@ -0,0 +1,13 @@ +"""Constants for the emulated_roku component.""" + +DOMAIN = 'emulated_roku' + +CONF_SERVERS = 'servers' +CONF_LISTEN_PORT = 'listen_port' +CONF_HOST_IP = 'host_ip' +CONF_ADVERTISE_IP = 'advertise_ip' +CONF_ADVERTISE_PORT = 'advertise_port' +CONF_UPNP_BIND_MULTICAST = 'upnp_bind_multicast' + +DEFAULT_NAME = "Home Assistant" +DEFAULT_PORT = 8060 diff --git a/homeassistant/components/emulated_roku/strings.json b/homeassistant/components/emulated_roku/strings.json new file mode 100644 index 00000000000..376252966a3 --- /dev/null +++ b/homeassistant/components/emulated_roku/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Name already exists" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Advertise IP", + "advertise_port": "Advertise port", + "host_ip": "Host IP", + "listen_port": "Listen port", + "name": "Name", + "upnp_bind_multicast": "Bind multicast (True/False)" + }, + "title": "Define server configuration" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/de.json b/homeassistant/components/esphome/.translations/de.json new file mode 100644 index 00000000000..191d930eb96 --- /dev/null +++ b/homeassistant/components/esphome/.translations/de.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "ESP ist bereits konfiguriert" + }, + "error": { + "connection_error": "Keine Verbindung zum ESP m\u00f6glich. Achten Sie darauf, dass Ihre YAML-Datei eine Zeile 'api:' enth\u00e4lt.", + "invalid_password": "Ung\u00fcltiges Passwort!", + "resolve_error": "Adresse des ESP kann nicht aufgel\u00f6st werden. Wenn dieser Fehler weiterhin besteht, legen Sie eine statische IP-Adresse fest: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "step": { + "authenticate": { + "data": { + "password": "Passwort" + }, + "description": "Bitte geben Sie das Passwort der ESPhome-Konfiguration ein:", + "title": "Passwort eingeben" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Bitte geben Sie die Verbindungseinstellungen Ihres [ESPHome](https://esphomelib.com/)-Knotens ein.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/fr.json b/homeassistant/components/esphome/.translations/fr.json new file mode 100644 index 00000000000..a021f1fe9f4 --- /dev/null +++ b/homeassistant/components/esphome/.translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "invalid_password": "Mot de passe invalide !" + }, + "step": { + "authenticate": { + "data": { + "password": "Mot de passe" + }, + "title": "Entrer votre mot de passe" + }, + "user": { + "data": { + "host": "H\u00f4te", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/hu.json b/homeassistant/components/esphome/.translations/hu.json new file mode 100644 index 00000000000..7fe5da59de6 --- /dev/null +++ b/homeassistant/components/esphome/.translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "invalid_password": "\u00c9rv\u00e9nytelen jelsz\u00f3!" + }, + "step": { + "authenticate": { + "data": { + "password": "Jelsz\u00f3" + }, + "title": "Adja meg a jelsz\u00f3t" + }, + "user": { + "data": { + "host": "Kiszolg\u00e1l\u00f3", + "port": "Port" + }, + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/id.json b/homeassistant/components/esphome/.translations/id.json new file mode 100644 index 00000000000..837d18d27ad --- /dev/null +++ b/homeassistant/components/esphome/.translations/id.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "ESP sudah dikonfigurasi" + }, + "step": { + "authenticate": { + "data": { + "password": "Kata kunci" + }, + "description": "Silakan masukkan kata kunci yang Anda atur di konfigurasi Anda.", + "title": "Masukkan kata kunci" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/ko.json b/homeassistant/components/esphome/.translations/ko.json index dc1fcaf0bd1..514acbbbf18 100644 --- a/homeassistant/components/esphome/.translations/ko.json +++ b/homeassistant/components/esphome/.translations/ko.json @@ -13,7 +13,7 @@ "data": { "password": "\ube44\ubc00\ubc88\ud638" }, - "description": "ESP \uc5d0 \uad6c\uc131\ud55c \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "description": "ESP \uc5d0\uc11c \uc124\uc815\ud55c \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", "title": "\ube44\ubc00\ubc88\ud638 \uc785\ub825" }, "user": { diff --git a/homeassistant/components/esphome/.translations/nl.json b/homeassistant/components/esphome/.translations/nl.json new file mode 100644 index 00000000000..89831979d89 --- /dev/null +++ b/homeassistant/components/esphome/.translations/nl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "ESP is al geconfigureerd" + }, + "error": { + "connection_error": "Kan geen verbinding maken met ESP. Zorg ervoor dat uw YAML-bestand een regel 'api:' bevat.", + "invalid_password": "Ongeldig wachtwoord!", + "resolve_error": "Kan het adres van de ESP niet vinden. Als deze fout aanhoudt, stel dan een statisch IP-adres in: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "step": { + "authenticate": { + "data": { + "password": "Wachtwoord" + }, + "description": "Voer het wachtwoord in dat u in uw configuratie hebt ingesteld.", + "title": "Voer wachtwoord in" + }, + "user": { + "data": { + "host": "Host", + "port": "Poort" + }, + "description": "Voer de verbindingsinstellingen in van uw [ESPHome](https://esphomelib.com/) node.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/no.json b/homeassistant/components/esphome/.translations/no.json new file mode 100644 index 00000000000..5f166eac74a --- /dev/null +++ b/homeassistant/components/esphome/.translations/no.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "ESP er allerede konfigurert" + }, + "error": { + "connection_error": "Kan ikke koble til ESP. Kontroller at YAML filen din inneholder en \"api:\" linje.", + "invalid_password": "Ugyldig passord!", + "resolve_error": "Kan ikke l\u00f8se adressen til ESP. Hvis denne feilen vedvarer, m\u00e5 du [angi en statisk IP-adresse](https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips)" + }, + "step": { + "authenticate": { + "data": { + "password": "Passord" + }, + "description": "Vennligst skriv inn passordet du har angitt i din konfigurasjon.", + "title": "Skriv Inn Passord" + }, + "user": { + "data": { + "host": "Vert", + "port": "Port" + }, + "description": "Vennligst skriv inn tilkoblingsinnstillinger for din [ESPHome](https://esphomelib.com/) node.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/pl.json b/homeassistant/components/esphome/.translations/pl.json new file mode 100644 index 00000000000..4f2a8b0e1bb --- /dev/null +++ b/homeassistant/components/esphome/.translations/pl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "ESP jest ju\u017c skonfigurowany" + }, + "error": { + "connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z ESP. Upewnij si\u0119, \u017ce Tw\u00f3j plik YAML zawiera lini\u0119 \"api:\".", + "invalid_password": "Nieprawid\u0142owe has\u0142o!", + "resolve_error": "Nie mo\u017cna rozpozna\u0107 adresu ESP. Je\u015bli ten b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, nale\u017cy ustawi\u0107 statyczny adres IP: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "step": { + "authenticate": { + "data": { + "password": "Has\u0142o" + }, + "description": "Wprowad\u017a has\u0142o ustawione w konfiguracji.", + "title": "Wprowad\u017a has\u0142o" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Wprowad\u017a ustawienia po\u0142\u0105czenia swojego [ESPHome] (https://esphomelib.com/) w\u0119z\u0142a.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/pt-BR.json b/homeassistant/components/esphome/.translations/pt-BR.json new file mode 100644 index 00000000000..87adc69021c --- /dev/null +++ b/homeassistant/components/esphome/.translations/pt-BR.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "O ESP j\u00e1 est\u00e1 configurado" + }, + "error": { + "connection_error": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao ESP. Por favor, verifique se o seu arquivo YAML cont\u00e9m uma linha 'api:'.", + "invalid_password": "Senha inv\u00e1lida!", + "resolve_error": "N\u00e3o \u00e9 poss\u00edvel resolver o endere\u00e7o do ESP. Se este erro persistir, por favor, defina um endere\u00e7o IP est\u00e1tico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "step": { + "authenticate": { + "data": { + "password": "Senha" + }, + "description": "Por favor, digite a senha que voc\u00ea definiu em sua configura\u00e7\u00e3o.", + "title": "Digite a senha" + }, + "user": { + "data": { + "port": "Porta" + }, + "description": "Por favor insira as configura\u00e7\u00f5es de conex\u00e3o de seu n\u00f3 de [ESPHome] (https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/pt.json b/homeassistant/components/esphome/.translations/pt.json new file mode 100644 index 00000000000..70e21e14666 --- /dev/null +++ b/homeassistant/components/esphome/.translations/pt.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "O ESP j\u00e1 est\u00e1 configurado" + }, + "error": { + "connection_error": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao ESP. Por favor, verifique se o seu arquivo YAML cont\u00e9m uma linha 'api:'.", + "invalid_password": "Palavra-passe inv\u00e1lida", + "resolve_error": "N\u00e3o \u00e9 poss\u00edvel resolver o endere\u00e7o do ESP. Se este erro persistir, defina um endere\u00e7o IP est\u00e1tico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "step": { + "authenticate": { + "data": { + "password": "Palavra-passe" + }, + "description": "Por favor, insira a palavra-passe que colocou na configura\u00e7\u00e3o", + "title": "Palavra-passe" + }, + "user": { + "data": { + "host": "Servidor", + "port": "Porta" + }, + "description": "Por favor, insira as configura\u00e7\u00f5es de liga\u00e7\u00e3o ao seu n\u00f3 [ESPHome] (https://esphomelib.com/).", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/ru.json b/homeassistant/components/esphome/.translations/ru.json index 4b227bd1565..fc74825e188 100644 --- a/homeassistant/components/esphome/.translations/ru.json +++ b/homeassistant/components/esphome/.translations/ru.json @@ -6,7 +6,7 @@ "error": { "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a ESP. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0432\u0430\u0448 YAML-\u0444\u0430\u0439\u043b \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 \u0441\u0442\u0440\u043e\u043a\u0443 'api:'.", "invalid_password": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c!", - "resolve_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0430\u0434\u0440\u0435\u0441 ESP. \u0415\u0441\u043b\u0438 \u044d\u0442\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0432\u0442\u043e\u0440\u044f\u0435\u0442\u0441\u044f, \u0443\u043a\u0430\u0436\u0438\u0442\u0435 [\u0441\u0442\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 IP-\u0430\u0434\u0440\u0435\u0441](https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips)" + "resolve_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0430\u0434\u0440\u0435\u0441 ESP. \u0415\u0441\u043b\u0438 \u044d\u0442\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0432\u0442\u043e\u0440\u044f\u0435\u0442\u0441\u044f, \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 IP-\u0430\u0434\u0440\u0435\u0441: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "step": { "authenticate": { diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 70d92250564..1ff2c10c828 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -10,7 +10,7 @@ from homeassistant import const from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, \ EVENT_HOMEASSISTANT_STOP -from homeassistant.core import callback, Event +from homeassistant.core import callback, Event, State import homeassistant.helpers.device_registry as dr from homeassistant.exceptions import TemplateError from homeassistant.helpers import template @@ -31,7 +31,7 @@ if TYPE_CHECKING: ServiceCall DOMAIN = 'esphome' -REQUIREMENTS = ['aioesphomeapi==1.4.1'] +REQUIREMENTS = ['aioesphomeapi==1.4.2'] DISPATCHER_UPDATE_ENTITY = 'esphome_{entry_id}_update_{component_key}_{key}' @@ -208,11 +208,11 @@ async def async_setup_entry(hass: HomeAssistantType, domain, service_name, service_data, blocking=True)) async def send_home_assistant_state(entity_id: str, _, - new_state: Optional[str]) -> None: + new_state: Optional[State]) -> None: """Forward Home Assistant states to ESPHome.""" if new_state is None: return - await cli.send_home_assistant_state(entity_id, new_state) + await cli.send_home_assistant_state(entity_id, new_state.state) @callback def async_on_state_subscription(entity_id: str) -> None: @@ -481,7 +481,7 @@ class EsphomeEntity(Entity): self._remove_callbacks.append( async_dispatcher_connect(self.hass, DISPATCHER_REMOVE_ENTITY.format(**kwargs), - self.async_schedule_update_ha_state) + self.async_remove) ) self._remove_callbacks.append( diff --git a/homeassistant/components/binary_sensor/esphome.py b/homeassistant/components/esphome/binary_sensor.py similarity index 100% rename from homeassistant/components/binary_sensor/esphome.py rename to homeassistant/components/esphome/binary_sensor.py diff --git a/homeassistant/components/cover/esphome.py b/homeassistant/components/esphome/cover.py similarity index 93% rename from homeassistant/components/cover/esphome.py rename to homeassistant/components/esphome/cover.py index 97f082f8be5..14fce3fb4eb 100644 --- a/homeassistant/components/cover/esphome.py +++ b/homeassistant/components/esphome/cover.py @@ -8,7 +8,6 @@ from homeassistant.components.cover import CoverDevice, SUPPORT_CLOSE, \ from homeassistant.components.esphome import EsphomeEntity, \ platform_async_setup_entry from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_CLOSED, STATE_OPEN from homeassistant.helpers.typing import HomeAssistantType if TYPE_CHECKING: @@ -33,12 +32,6 @@ async def async_setup_entry(hass: HomeAssistantType, ) -COVER_STATE_INT_TO_STR = { - 0: STATE_OPEN, - 1: STATE_CLOSED -} - - class EsphomeCover(EsphomeEntity, CoverDevice): """A cover implementation for ESPHome.""" @@ -65,7 +58,7 @@ class EsphomeCover(EsphomeEntity, CoverDevice): """Return if the cover is closed or not.""" if self._state is None: return None - return COVER_STATE_INT_TO_STR[self._state.state] + return bool(self._state.state) async def async_open_cover(self, **kwargs) -> None: """Open the cover.""" diff --git a/homeassistant/components/fan/esphome.py b/homeassistant/components/esphome/fan.py similarity index 93% rename from homeassistant/components/fan/esphome.py rename to homeassistant/components/esphome/fan.py index a2a3d6263f8..49e2401545b 100644 --- a/homeassistant/components/fan/esphome.py +++ b/homeassistant/components/esphome/fan.py @@ -91,6 +91,8 @@ class EsphomeFan(EsphomeEntity, FanEntity): """Return the current speed.""" if self._state is None: return None + if not self._static_info.supports_speed: + return None return FAN_SPEED_INT_TO_STR[self._state.speed] @property @@ -98,11 +100,15 @@ class EsphomeFan(EsphomeEntity, FanEntity): """Return the oscillation state.""" if self._state is None: return None + if not self._static_info.supports_oscillation: + return None return self._state.oscillating @property - def speed_list(self) -> List[str]: + def speed_list(self) -> Optional[List[str]]: """Get the list of available speeds.""" + if not self._static_info.supports_speed: + return None return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] @property diff --git a/homeassistant/components/light/esphome.py b/homeassistant/components/esphome/light.py similarity index 100% rename from homeassistant/components/light/esphome.py rename to homeassistant/components/esphome/light.py diff --git a/homeassistant/components/sensor/esphome.py b/homeassistant/components/esphome/sensor.py similarity index 100% rename from homeassistant/components/sensor/esphome.py rename to homeassistant/components/esphome/sensor.py diff --git a/homeassistant/components/switch/esphome.py b/homeassistant/components/esphome/switch.py similarity index 100% rename from homeassistant/components/switch/esphome.py rename to homeassistant/components/esphome/switch.py diff --git a/homeassistant/components/fan/template.py b/homeassistant/components/fan/template.py index a2f33d40e48..d9182b79a40 100644 --- a/homeassistant/components/fan/template.py +++ b/homeassistant/components/fan/template.py @@ -62,7 +62,7 @@ FAN_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ - vol.Required(CONF_FANS): vol.Schema({cv.slug: FAN_SCHEMA}), + vol.Required(CONF_FANS): cv.schema_with_slug_keys(FAN_SCHEMA), }) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index e6349782cd1..e0d51279bbf 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -26,10 +26,23 @@ DEFAULT_NAME = 'Xiaomi Miio Device' DATA_KEY = 'fan.xiaomi_miio' CONF_MODEL = 'model' -MODEL_AIRPURIFIER_PRO = 'zhimi.airpurifier.v6' +MODEL_AIRPURIFIER_V1 = 'zhimi.airpurifier.v1' +MODEL_AIRPURIFIER_V2 = 'zhimi.airpurifier.v2' MODEL_AIRPURIFIER_V3 = 'zhimi.airpurifier.v3' +MODEL_AIRPURIFIER_V5 = 'zhimi.airpurifier.v5' +MODEL_AIRPURIFIER_PRO = 'zhimi.airpurifier.v6' +MODEL_AIRPURIFIER_PRO_V7 = 'zhimi.airpurifier.v7' +MODEL_AIRPURIFIER_M1 = 'zhimi.airpurifier.m1' +MODEL_AIRPURIFIER_M2 = 'zhimi.airpurifier.m2' +MODEL_AIRPURIFIER_MA1 = 'zhimi.airpurifier.ma1' +MODEL_AIRPURIFIER_MA2 = 'zhimi.airpurifier.ma2' +MODEL_AIRPURIFIER_SA1 = 'zhimi.airpurifier.sa1' +MODEL_AIRPURIFIER_SA2 = 'zhimi.airpurifier.sa2' +MODEL_AIRPURIFIER_MC1 = 'zhimi.airpurifier.mc1' + MODEL_AIRHUMIDIFIER_V1 = 'zhimi.humidifier.v1' MODEL_AIRHUMIDIFIER_CA = 'zhimi.humidifier.ca1' + MODEL_AIRFRESH_VA2 = 'zhimi.airfresh.va2' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -37,21 +50,22 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MODEL): vol.In( - ['zhimi.airpurifier.m1', - 'zhimi.airpurifier.m2', - 'zhimi.airpurifier.ma1', - 'zhimi.airpurifier.ma2', - 'zhimi.airpurifier.sa1', - 'zhimi.airpurifier.sa2', - 'zhimi.airpurifier.v1', - 'zhimi.airpurifier.v2', - 'zhimi.airpurifier.v3', - 'zhimi.airpurifier.v5', - 'zhimi.airpurifier.v6', - 'zhimi.airpurifier.mc1', - 'zhimi.humidifier.v1', - 'zhimi.humidifier.ca1', - 'zhimi.airfresh.va2', + [MODEL_AIRPURIFIER_V1, + MODEL_AIRPURIFIER_V2, + MODEL_AIRPURIFIER_V3, + MODEL_AIRPURIFIER_V5, + MODEL_AIRPURIFIER_PRO, + MODEL_AIRPURIFIER_PRO_V7, + MODEL_AIRPURIFIER_M1, + MODEL_AIRPURIFIER_M2, + MODEL_AIRPURIFIER_MA1, + MODEL_AIRPURIFIER_MA2, + MODEL_AIRPURIFIER_SA1, + MODEL_AIRPURIFIER_SA2, + MODEL_AIRPURIFIER_MC1, + MODEL_AIRHUMIDIFIER_V1, + MODEL_AIRHUMIDIFIER_CA, + MODEL_AIRFRESH_VA2, ]), }) @@ -97,7 +111,7 @@ ATTR_TRANS_LEVEL = 'trans_level' ATTR_HARDWARE_VERSION = 'hardware_version' # Air Humidifier CA -ATTR_SPEED = 'speed' +ATTR_MOTOR_SPEED = 'motor_speed' ATTR_DEPTH = 'depth' ATTR_DRY = 'dry' @@ -117,25 +131,41 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { ATTR_LED: 'led', ATTR_MOTOR_SPEED: 'motor_speed', ATTR_AVERAGE_AIR_QUALITY_INDEX: 'average_aqi', - ATTR_PURIFY_VOLUME: 'purify_volume', ATTR_LEARN_MODE: 'learn_mode', - ATTR_SLEEP_TIME: 'sleep_time', - ATTR_SLEEP_LEARN_COUNT: 'sleep_mode_learn_count', ATTR_EXTRA_FEATURES: 'extra_features', ATTR_TURBO_MODE_SUPPORTED: 'turbo_mode_supported', - ATTR_AUTO_DETECT: 'auto_detect', - ATTR_USE_TIME: 'use_time', ATTR_BUTTON_PRESSED: 'button_pressed', } AVAILABLE_ATTRIBUTES_AIRPURIFIER = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, + ATTR_PURIFY_VOLUME: 'purify_volume', + ATTR_SLEEP_TIME: 'sleep_time', + ATTR_SLEEP_LEARN_COUNT: 'sleep_mode_learn_count', + ATTR_AUTO_DETECT: 'auto_detect', + ATTR_USE_TIME: 'use_time', ATTR_BUZZER: 'buzzer', ATTR_LED_BRIGHTNESS: 'led_brightness', ATTR_SLEEP_MODE: 'sleep_mode', } AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO = { + **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, + ATTR_PURIFY_VOLUME: 'purify_volume', + ATTR_USE_TIME: 'use_time', + ATTR_FILTER_RFID_PRODUCT_ID: 'filter_rfid_product_id', + ATTR_FILTER_RFID_TAG: 'filter_rfid_tag', + ATTR_FILTER_TYPE: 'filter_type', + ATTR_ILLUMINANCE: 'illuminance', + ATTR_MOTOR2_SPEED: 'motor2_speed', + ATTR_VOLUME: 'volume', + # perhaps supported but unconfirmed + ATTR_AUTO_DETECT: 'auto_detect', + ATTR_SLEEP_TIME: 'sleep_time', + ATTR_SLEEP_LEARN_COUNT: 'sleep_mode_learn_count', +} + +AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, ATTR_FILTER_RFID_PRODUCT_ID: 'filter_rfid_product_id', ATTR_FILTER_RFID_TAG: 'filter_rfid_tag', @@ -193,7 +223,7 @@ AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER = { AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA = { **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON, - ATTR_SPEED: 'speed', + ATTR_MOTOR_SPEED: 'speed', ATTR_DEPTH: 'depth', ATTR_DRY: 'dry', } @@ -218,6 +248,7 @@ AVAILABLE_ATTRIBUTES_AIRFRESH = { OPERATION_MODES_AIRPURIFIER = ['Auto', 'Silent', 'Favorite', 'Idle'] OPERATION_MODES_AIRPURIFIER_PRO = ['Auto', 'Silent', 'Favorite'] +OPERATION_MODES_AIRPURIFIER_PRO_V7 = OPERATION_MODES_AIRPURIFIER_PRO OPERATION_MODES_AIRPURIFIER_V3 = ['Auto', 'Silent', 'Favorite', 'Idle', 'Medium', 'High', 'Strong'] OPERATION_MODES_AIRFRESH = ['Auto', 'Silent', 'Interval', 'Low', @@ -238,10 +269,8 @@ FEATURE_SET_EXTRA_FEATURES = 512 FEATURE_SET_TARGET_HUMIDITY = 1024 FEATURE_SET_DRY = 2048 -FEATURE_FLAGS_GENERIC = (FEATURE_SET_BUZZER | - FEATURE_SET_CHILD_LOCK) - -FEATURE_FLAGS_AIRPURIFIER = (FEATURE_FLAGS_GENERIC | +FEATURE_FLAGS_AIRPURIFIER = (FEATURE_SET_BUZZER | + FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED | FEATURE_SET_LED_BRIGHTNESS | FEATURE_SET_FAVORITE_LEVEL | @@ -255,17 +284,25 @@ FEATURE_FLAGS_AIRPURIFIER_PRO = (FEATURE_SET_CHILD_LOCK | FEATURE_SET_AUTO_DETECT | FEATURE_SET_VOLUME) -FEATURE_FLAGS_AIRPURIFIER_V3 = (FEATURE_FLAGS_GENERIC | +FEATURE_FLAGS_AIRPURIFIER_PRO_V7 = (FEATURE_SET_CHILD_LOCK | + FEATURE_SET_LED | + FEATURE_SET_FAVORITE_LEVEL | + FEATURE_SET_VOLUME) + +FEATURE_FLAGS_AIRPURIFIER_V3 = (FEATURE_SET_BUZZER | + FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED) -FEATURE_FLAGS_AIRHUMIDIFIER = (FEATURE_FLAGS_GENERIC | +FEATURE_FLAGS_AIRHUMIDIFIER = (FEATURE_SET_BUZZER | + FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED_BRIGHTNESS | FEATURE_SET_TARGET_HUMIDITY) FEATURE_FLAGS_AIRHUMIDIFIER_CA = (FEATURE_FLAGS_AIRHUMIDIFIER | FEATURE_SET_DRY) -FEATURE_FLAGS_AIRFRESH = (FEATURE_FLAGS_GENERIC | +FEATURE_FLAGS_AIRFRESH = (FEATURE_SET_BUZZER | + FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED | FEATURE_SET_LED_BRIGHTNESS | FEATURE_RESET_FILTER | @@ -445,7 +482,7 @@ class XiaomiGenericDevice(FanEntity): self._state_attrs = { ATTR_MODEL: self._model, } - self._device_features = FEATURE_FLAGS_GENERIC + self._device_features = FEATURE_SET_CHILD_LOCK self._skip_update = False @property @@ -577,6 +614,11 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO self._speed_list = OPERATION_MODES_AIRPURIFIER_PRO + elif self._model == MODEL_AIRPURIFIER_PRO_V7: + self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO_V7 + self._available_attributes = \ + AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 + self._speed_list = OPERATION_MODES_AIRPURIFIER_PRO_V7 elif self._model == MODEL_AIRPURIFIER_V3: self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro/__init__.py similarity index 85% rename from homeassistant/components/fibaro.py rename to homeassistant/components/fibaro/__init__.py index d506f6c471d..715cc036265 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro/__init__.py @@ -11,8 +11,8 @@ from typing import Optional import voluptuous as vol from homeassistant.const import ( - ATTR_ARMED, ATTR_BATTERY_LEVEL, CONF_DEVICE_CLASS, - CONF_EXCLUDE, CONF_ICON, CONF_PASSWORD, CONF_URL, CONF_USERNAME, + ATTR_ARMED, ATTR_BATTERY_LEVEL, CONF_DEVICE_CLASS, CONF_EXCLUDE, + CONF_ICON, CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_WHITE_VALUE, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv @@ -24,10 +24,11 @@ REQUIREMENTS = ['fiblary3==0.1.7'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'fibaro' FIBARO_DEVICES = 'fibaro_devices' -FIBARO_CONTROLLER = 'fibaro_controller' +FIBARO_CONTROLLERS = 'fibaro_controllers' ATTR_CURRENT_POWER_W = "current_power_w" ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" CONF_PLUGINS = "plugins" +CONF_GATEWAYS = 'gateways' CONF_DIMMING = "dimming" CONF_COLOR = "color" CONF_RESET_COLOR = "reset_color" @@ -65,15 +66,20 @@ DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ FIBARO_ID_LIST_SCHEMA = vol.Schema([cv.string]) +GATEWAY_CONFIG = vol.Schema({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_URL): cv.url, + vol.Optional(CONF_PLUGINS, default=False): cv.boolean, + vol.Optional(CONF_EXCLUDE, default=[]): FIBARO_ID_LIST_SCHEMA, + vol.Optional(CONF_DEVICE_CONFIG, default={}): + vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}) +}, extra=vol.ALLOW_EXTRA) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_URL): cv.url, - vol.Optional(CONF_PLUGINS, default=False): cv.boolean, - vol.Optional(CONF_EXCLUDE, default=[]): FIBARO_ID_LIST_SCHEMA, - vol.Optional(CONF_DEVICE_CONFIG, default={}): - vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}) + vol.Required(CONF_GATEWAYS): + vol.All(cv.ensure_list, [GATEWAY_CONFIG]) }) }, extra=vol.ALLOW_EXTRA) @@ -81,20 +87,23 @@ CONFIG_SCHEMA = vol.Schema({ class FibaroController(): """Initiate Fibaro Controller Class.""" - def __init__(self, username, password, url, import_plugins, config): + def __init__(self, config): """Initialize the Fibaro controller.""" from fiblary3.client.v4.client import Client as FibaroClient - self._client = FibaroClient(url, username, password) + + self._client = FibaroClient(config[CONF_URL], + config[CONF_USERNAME], + config[CONF_PASSWORD]) self._scene_map = None # Whether to import devices from plugins - self._import_plugins = import_plugins + self._import_plugins = config[CONF_PLUGINS] self._device_config = config[CONF_DEVICE_CONFIG] self._room_map = None # Mapping roomId to room object self._device_map = None # Mapping deviceId to device object self.fibaro_devices = None # List of devices by type self._callbacks = {} # Update value callbacks by deviceId self._state_handler = None # Fiblary's StateHandler object - self._excluded_devices = config.get(CONF_EXCLUDE, []) + self._excluded_devices = config[CONF_EXCLUDE] self.hub_serial = None # Unique serial number of the hub def connect(self): @@ -167,12 +176,11 @@ class FibaroController(): def _map_device_to_type(device): """Map device to HA device type.""" # Use our lookup table to identify device type + device_type = None if 'type' in device: device_type = FIBARO_TYPEMAP.get(device.type) - elif 'baseType' in device: + if device_type is None and 'baseType' in device: device_type = FIBARO_TYPEMAP.get(device.baseType) - else: - device_type = None # We can also identify device type by its capabilities if device_type is None: @@ -200,6 +208,7 @@ class FibaroController(): for device in scenes: if not device.visible: continue + device.fibaro_controller = self if device.roomID == 0: room_name = 'Unknown' else: @@ -220,6 +229,7 @@ class FibaroController(): self.fibaro_devices = defaultdict(list) for device in devices: try: + device.fibaro_controller = self if device.roomID == 0: room_name = 'Unknown' else: @@ -242,33 +252,43 @@ class FibaroController(): self.hub_serial, device.id) self._device_map[device.id] = device self.fibaro_devices[device.mapped_type].append(device) - else: - _LOGGER.debug("%s (%s, %s) not used", - device.ha_id, device.type, - device.baseType) + _LOGGER.debug("%s (%s, %s) -> %s. Prop: %s Actions: %s", + device.ha_id, device.type, + device.baseType, device.mapped_type, + str(device.properties), str(device.actions)) except (KeyError, ValueError): pass -def setup(hass, config): +def setup(hass, base_config): """Set up the Fibaro Component.""" - hass.data[FIBARO_CONTROLLER] = controller = \ - FibaroController(config[DOMAIN][CONF_USERNAME], - config[DOMAIN][CONF_PASSWORD], - config[DOMAIN][CONF_URL], - config[DOMAIN][CONF_PLUGINS], - config[DOMAIN]) + gateways = base_config[DOMAIN][CONF_GATEWAYS] + hass.data[FIBARO_CONTROLLERS] = {} def stop_fibaro(event): """Stop Fibaro Thread.""" _LOGGER.info("Shutting down Fibaro connection") - hass.data[FIBARO_CONTROLLER].disable_state_handler() + for controller in hass.data[FIBARO_CONTROLLERS].values(): + controller.disable_state_handler() - if controller.connect(): - hass.data[FIBARO_DEVICES] = controller.fibaro_devices + hass.data[FIBARO_DEVICES] = {} + for component in FIBARO_COMPONENTS: + hass.data[FIBARO_DEVICES][component] = [] + + for gateway in gateways: + controller = FibaroController(gateway) + if controller.connect(): + hass.data[FIBARO_CONTROLLERS][controller.hub_serial] = controller + for component in FIBARO_COMPONENTS: + hass.data[FIBARO_DEVICES][component].extend( + controller.fibaro_devices[component]) + + if hass.data[FIBARO_CONTROLLERS]: for component in FIBARO_COMPONENTS: - discovery.load_platform(hass, component, DOMAIN, {}, config) - controller.enable_state_handler() + discovery.load_platform(hass, component, DOMAIN, {}, + base_config) + for controller in hass.data[FIBARO_CONTROLLERS].values(): + controller.enable_state_handler() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_fibaro) return True @@ -278,10 +298,10 @@ def setup(hass, config): class FibaroDevice(Entity): """Representation of a Fibaro device entity.""" - def __init__(self, fibaro_device, controller): + def __init__(self, fibaro_device): """Initialize the device.""" self.fibaro_device = fibaro_device - self.controller = controller + self.controller = fibaro_device.fibaro_controller self._name = fibaro_device.friendly_name self.ha_id = fibaro_device.ha_id diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 026311f1397..f5cc33b63a0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20190109.1'] +REQUIREMENTS = ['home-assistant-frontend==20190121.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 495c9e1744b..4e05c5b41fe 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -1,5 +1,5 @@ """ -Geo Location component. +Geolocation component. For more details about this component, please refer to the documentation at https://home-assistant.io/components/geo_location/ @@ -22,21 +22,21 @@ DOMAIN = 'geo_location' ENTITY_ID_FORMAT = DOMAIN + '.{}' -GROUP_NAME_ALL_EVENTS = 'All Geo Location Events' +GROUP_NAME_ALL_EVENTS = 'All Geolocation Events' SCAN_INTERVAL = timedelta(seconds=60) async def async_setup(hass, config): - """Set up the Geo Location component.""" + """Set up the Geolocation component.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_EVENTS) await component.async_setup(config) return True -class GeoLocationEvent(Entity): - """This represents an external event with an associated geo location.""" +class GeolocationEvent(Entity): + """This represents an external event with an associated geolocation.""" @property def state(self): diff --git a/homeassistant/components/geo_location/demo.py b/homeassistant/components/geo_location/demo.py index 6c5cc2fe147..0e7274e7a0a 100644 --- a/homeassistant/components/geo_location/demo.py +++ b/homeassistant/components/geo_location/demo.py @@ -1,5 +1,5 @@ """ -Demo platform for the geo location component. +Demo platform for the geolocation component. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ @@ -10,7 +10,7 @@ from math import cos, pi, radians, sin import random from typing import Optional -from homeassistant.components.geo_location import GeoLocationEvent +from homeassistant.components.geo_location import GeolocationEvent from homeassistant.helpers.event import track_time_interval _LOGGER = logging.getLogger(__name__) @@ -30,15 +30,15 @@ SOURCE = 'demo' def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Demo geo locations.""" + """Set up the Demo geolocations.""" DemoManager(hass, add_entities) class DemoManager: - """Device manager for demo geo location events.""" + """Device manager for demo geolocation events.""" def __init__(self, hass, add_entities): - """Initialise the demo geo location event manager.""" + """Initialise the demo geolocation event manager.""" self._hass = hass self._add_entities = add_entities self._managed_devices = [] @@ -62,7 +62,7 @@ class DemoManager: cos(radians(home_latitude)) event_name = random.choice(EVENT_NAMES) - return DemoGeoLocationEvent(event_name, radius_in_km, latitude, + return DemoGeolocationEvent(event_name, radius_in_km, latitude, longitude, DEFAULT_UNIT_OF_MEASUREMENT) def _init_regular_updates(self): @@ -90,8 +90,8 @@ class DemoManager: self._add_entities(new_devices) -class DemoGeoLocationEvent(GeoLocationEvent): - """This represents a demo geo location event.""" +class DemoGeolocationEvent(GeolocationEvent): + """This represents a demo geolocation event.""" def __init__(self, name, distance, latitude, longitude, unit_of_measurement): @@ -114,7 +114,7 @@ class DemoGeoLocationEvent(GeoLocationEvent): @property def should_poll(self): - """No polling needed for a demo geo location event.""" + """No polling needed for a demo geolocation event.""" return False @property diff --git a/homeassistant/components/geo_location/geo_json_events.py b/homeassistant/components/geo_location/geo_json_events.py index 4d8c3b68edd..cbfe605e722 100644 --- a/homeassistant/components/geo_location/geo_json_events.py +++ b/homeassistant/components/geo_location/geo_json_events.py @@ -11,7 +11,7 @@ from typing import Optional import voluptuous as vol from homeassistant.components.geo_location import ( - PLATFORM_SCHEMA, GeoLocationEvent) + PLATFORM_SCHEMA, GeolocationEvent) from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL, EVENT_HOMEASSISTANT_START, CONF_LATITUDE, CONF_LONGITUDE) @@ -108,7 +108,7 @@ class GeoJsonFeedEntityManager: dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) -class GeoJsonLocationEvent(GeoLocationEvent): +class GeoJsonLocationEvent(GeolocationEvent): """This represents an external event with GeoJSON data.""" def __init__(self, feed_manager, external_id): diff --git a/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py b/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py index 5681e4a53ac..e0974ed415d 100644 --- a/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py +++ b/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py @@ -11,7 +11,7 @@ from typing import Optional import voluptuous as vol from homeassistant.components.geo_location import ( - PLATFORM_SCHEMA, GeoLocationEvent) + PLATFORM_SCHEMA, GeolocationEvent) from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LOCATION, CONF_RADIUS, CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START, CONF_LATITUDE, CONF_LONGITUDE) @@ -129,7 +129,7 @@ class NswRuralFireServiceFeedEntityManager: dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) -class NswRuralFireServiceLocationEvent(GeoLocationEvent): +class NswRuralFireServiceLocationEvent(GeolocationEvent): """This represents an external event with NSW Rural Fire Service data.""" def __init__(self, feed_manager, external_id): diff --git a/homeassistant/components/geo_location/usgs_earthquakes_feed.py b/homeassistant/components/geo_location/usgs_earthquakes_feed.py index f835fecfeb4..6a7bbba4464 100644 --- a/homeassistant/components/geo_location/usgs_earthquakes_feed.py +++ b/homeassistant/components/geo_location/usgs_earthquakes_feed.py @@ -11,7 +11,7 @@ from typing import Optional import voluptuous as vol from homeassistant.components.geo_location import ( - PLATFORM_SCHEMA, GeoLocationEvent) + PLATFORM_SCHEMA, GeolocationEvent) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_RADIUS, CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START, CONF_LATITUDE, CONF_LONGITUDE) @@ -148,7 +148,7 @@ class UsgsEarthquakesFeedEntityManager: dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) -class UsgsEarthquakesEvent(GeoLocationEvent): +class UsgsEarthquakesEvent(GeolocationEvent): """This represents an external event with USGS Earthquake data.""" def __init__(self, feed_manager, external_id): diff --git a/homeassistant/components/geofency/.translations/ca.json b/homeassistant/components/geofency/.translations/ca.json new file mode 100644 index 00000000000..125ca51399a --- /dev/null +++ b/homeassistant/components/geofency/.translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Geofency.", + "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." + }, + "create_entry": { + "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar l'opci\u00f3 webhook de Geofency.\n\nCompleta la seg\u00fcent informaci\u00f3:\n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." + }, + "step": { + "user": { + "description": "Est\u00e0s segur que vols configurar el Webhook Geofency?", + "title": "Configuraci\u00f3 del Webhook Geofency" + } + }, + "title": "Webhook Geofency" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/en.json b/homeassistant/components/geofency/.translations/en.json new file mode 100644 index 00000000000..27b6335c6f9 --- /dev/null +++ b/homeassistant/components/geofency/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency.", + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in Geofency.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + }, + "step": { + "user": { + "description": "Are you sure you want to set up the Geofency Webhook?", + "title": "Set up the Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/ko.json b/homeassistant/components/geofency/.translations/ko.json new file mode 100644 index 00000000000..8a857acbdc6 --- /dev/null +++ b/homeassistant/components/geofency/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Geofency \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.", + "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + }, + "create_entry": { + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 Geofency \uc5d0\uc11c Webhook \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + }, + "step": { + "user": { + "description": "Geofency Webhook \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Geofency Webhook \uc124\uc815" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/lb.json b/homeassistant/components/geofency/.translations/lb.json new file mode 100644 index 00000000000..490026b366d --- /dev/null +++ b/homeassistant/components/geofency/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Geofency Noriichten z'empf\u00e4nken.", + "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg." + }, + "create_entry": { + "default": "Fir Evenementer un Home Assistant ze sch\u00e9cken, muss den Webhook Feature am Geofency ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nLiest [Dokumentatioun]({docs_url}) fir w\u00e9ider D\u00e9tailer." + }, + "step": { + "user": { + "description": "S\u00e9cher fir Geofency Webhook anzeriichten?", + "title": "Geofency Webhook ariichten" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/no.json b/homeassistant/components/geofency/.translations/no.json new file mode 100644 index 00000000000..4409616cef4 --- /dev/null +++ b/homeassistant/components/geofency/.translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta meldinger fra Geofency.", + "one_instance_allowed": "Kun en enkelt forekomst er n\u00f8dvendig." + }, + "create_entry": { + "default": "For \u00e5 kunne sende hendelser til Home Assistant, m\u00e5 du sette opp webhook-funksjonen i Geofency. \n\n Fyll ut f\u00f8lgende informasjon: \n\n - URL: `{webhook_url}` \n - Metode: POST \n\n Se [dokumentasjonen]({docs_url}) for ytterligere detaljer." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil konfigurere Geofency Webhook?", + "title": "Sett opp Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/ru.json b/homeassistant/components/geofency/.translations/ru.json new file mode 100644 index 00000000000..34290b35f42 --- /dev/null +++ b/homeassistant/components/geofency/.translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 Geofency.", + "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "create_entry": { + "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0432\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c webhooks \u0434\u043b\u044f Geofency.\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Geofency Webhook?", + "title": "Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 92f8f475e65..093ebaa2fd3 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -7,11 +7,12 @@ https://home-assistant.io/components/geofency/ import logging import voluptuous as vol +from aiohttp import web import homeassistant.helpers.config_validation as cv -from homeassistant.components.http import HomeAssistantView from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME, \ - ATTR_LATITUDE, ATTR_LONGITUDE + ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, HTTP_OK, ATTR_NAME +from homeassistant.helpers import config_entry_flow from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import slugify @@ -19,7 +20,7 @@ from homeassistant.util import slugify _LOGGER = logging.getLogger(__name__) DOMAIN = 'geofency' -DEPENDENCIES = ['http'] +DEPENDENCIES = ['webhook'] CONF_MOBILE_BEACONS = 'mobile_beacons' @@ -32,25 +33,44 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +ATTR_ADDRESS = 'address' +ATTR_BEACON_ID = 'beaconUUID' ATTR_CURRENT_LATITUDE = 'currentLatitude' ATTR_CURRENT_LONGITUDE = 'currentLongitude' +ATTR_DEVICE = 'device' +ATTR_ENTRY = 'entry' BEACON_DEV_PREFIX = 'beacon' LOCATION_ENTRY = '1' LOCATION_EXIT = '0' -URL = '/api/geofency' - TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) +def _address(value: str) -> str: + r"""Coerce address by replacing '\n' with ' '.""" + return value.replace('\n', ' ') + + +WEBHOOK_SCHEMA = vol.Schema({ + vol.Required(ATTR_ADDRESS): vol.All(cv.string, _address), + vol.Required(ATTR_DEVICE): vol.All(cv.string, slugify), + vol.Required(ATTR_ENTRY): vol.Any(LOCATION_ENTRY, LOCATION_EXIT), + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Required(ATTR_NAME): vol.All(cv.string, slugify), + vol.Optional(ATTR_CURRENT_LATITUDE): cv.latitude, + vol.Optional(ATTR_CURRENT_LONGITUDE): cv.longitude, + vol.Optional(ATTR_BEACON_ID): cv.string +}, extra=vol.ALLOW_EXTRA) + + async def async_setup(hass, hass_config): """Set up the Geofency component.""" config = hass_config[DOMAIN] mobile_beacons = config[CONF_MOBILE_BEACONS] hass.data[DOMAIN] = [slugify(beacon) for beacon in mobile_beacons] - hass.http.register_view(GeofencyView(hass.data[DOMAIN])) hass.async_create_task( async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config) @@ -58,89 +78,76 @@ async def async_setup(hass, hass_config): return True -class GeofencyView(HomeAssistantView): - """View to handle Geofency requests.""" - - url = URL - name = 'api:geofency' - - def __init__(self, mobile_beacons): - """Initialize Geofency url endpoints.""" - self.mobile_beacons = mobile_beacons - - async def post(self, request): - """Handle Geofency requests.""" - data = await request.post() - hass = request.app['hass'] - - data = self._validate_data(data) - if not data: - return "Invalid data", HTTP_UNPROCESSABLE_ENTITY - - if self._is_mobile_beacon(data): - return await self._set_location(hass, data, None) - if data['entry'] == LOCATION_ENTRY: - location_name = data['name'] - else: - location_name = STATE_NOT_HOME - if ATTR_CURRENT_LATITUDE in data: - data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] - data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] - - return await self._set_location(hass, data, location_name) - - @staticmethod - def _validate_data(data): - """Validate POST payload.""" - data = data.copy() - - required_attributes = ['address', 'device', 'entry', - 'latitude', 'longitude', 'name'] - - valid = True - for attribute in required_attributes: - if attribute not in data: - valid = False - _LOGGER.error("'%s' not specified in message", attribute) - - if not valid: - return {} - - data['address'] = data['address'].replace('\n', ' ') - data['device'] = slugify(data['device']) - data['name'] = slugify(data['name']) - - gps_attributes = [ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_CURRENT_LATITUDE, ATTR_CURRENT_LONGITUDE] - - for attribute in gps_attributes: - if attribute in data: - data[attribute] = float(data[attribute]) - - return data - - def _is_mobile_beacon(self, data): - """Check if we have a mobile beacon.""" - return 'beaconUUID' in data and data['name'] in self.mobile_beacons - - @staticmethod - def _device_name(data): - """Return name of device tracker.""" - if 'beaconUUID' in data: - return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) - return data['device'] - - async def _set_location(self, hass, data, location_name): - """Fire HA event to set location.""" - device = self._device_name(data) - - async_dispatcher_send( - hass, - TRACKER_UPDATE, - device, - (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), - location_name, - data +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook from Geofency.""" + try: + data = WEBHOOK_SCHEMA(dict(await request.post())) + except vol.MultipleInvalid as error: + return web.Response( + body=error.error_message, + status=HTTP_UNPROCESSABLE_ENTITY ) - return "Setting location for {}".format(device) + if _is_mobile_beacon(data, hass.data[DOMAIN]): + return _set_location(hass, data, None) + if data['entry'] == LOCATION_ENTRY: + location_name = data['name'] + else: + location_name = STATE_NOT_HOME + if ATTR_CURRENT_LATITUDE in data: + data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] + data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] + + return _set_location(hass, data, location_name) + + +def _is_mobile_beacon(data, mobile_beacons): + """Check if we have a mobile beacon.""" + return ATTR_BEACON_ID in data and data['name'] in mobile_beacons + + +def _device_name(data): + """Return name of device tracker.""" + if ATTR_BEACON_ID in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) + return data['device'] + + +def _set_location(hass, data, location_name): + """Fire HA event to set location.""" + device = _device_name(data) + + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name, + data + ) + + return web.Response( + body="Setting location for {}".format(device), + status=HTTP_OK + ) + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + DOMAIN, 'Geofency', entry.data[CONF_WEBHOOK_ID], handle_webhook) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + return True + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Geofency Webhook', + { + 'docs_url': 'https://www.home-assistant.io/components/geofency/' + } +) diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/geofency/device_tracker.py similarity index 94% rename from homeassistant/components/device_tracker/geofency.py rename to homeassistant/components/geofency/device_tracker.py index cec494f322c..af11194c1d6 100644 --- a/homeassistant/components/device_tracker/geofency.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -1,5 +1,5 @@ """ -Support for the Geofency platform. +Support for the Geofency device tracker platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.geofency/ diff --git a/homeassistant/components/geofency/strings.json b/homeassistant/components/geofency/strings.json new file mode 100644 index 00000000000..e67af592c16 --- /dev/null +++ b/homeassistant/components/geofency/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Geofency Webhook", + "step": { + "user": { + "title": "Set up the Geofency Webhook", + "description": "Are you sure you want to set up the Geofency Webhook?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in Geofency.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/en.json b/homeassistant/components/gpslogger/.translations/en.json new file mode 100644 index 00000000000..d5641ef5db8 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "GPSLogger Webhook", + "step": { + "user": { + "title": "Set up the GPSLogger Webhook", + "description": "Are you sure you want to set up the GPSLogger Webhook?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from GPSLogger." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in GPSLogger.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py new file mode 100644 index 00000000000..4d1a5708331 --- /dev/null +++ b/homeassistant/components/gpslogger/__init__.py @@ -0,0 +1,120 @@ +""" +Support for GPSLogger. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/gpslogger/ +""" +import logging + +import voluptuous as vol +from aiohttp import web + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ATTR_BATTERY +from homeassistant.components.device_tracker.tile import ATTR_ALTITUDE +from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, \ + HTTP_OK, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID +from homeassistant.helpers import config_entry_flow +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'gpslogger' +DEPENDENCIES = ['webhook'] + +TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) + +ATTR_ACCURACY = 'accuracy' +ATTR_ACTIVITY = 'activity' +ATTR_DEVICE = 'device' +ATTR_DIRECTION = 'direction' +ATTR_PROVIDER = 'provider' +ATTR_SPEED = 'speed' + +DEFAULT_ACCURACY = 200 +DEFAULT_BATTERY = -1 + + +def _id(value: str) -> str: + """Coerce id by removing '-'.""" + return value.replace('-', '') + + +WEBHOOK_SCHEMA = vol.Schema({ + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Required(ATTR_DEVICE): _id, + vol.Optional(ATTR_ACCURACY, default=DEFAULT_ACCURACY): vol.Coerce(float), + vol.Optional(ATTR_BATTERY, default=DEFAULT_BATTERY): vol.Coerce(float), + vol.Optional(ATTR_SPEED): vol.Coerce(float), + vol.Optional(ATTR_DIRECTION): vol.Coerce(float), + vol.Optional(ATTR_ALTITUDE): vol.Coerce(float), + vol.Optional(ATTR_PROVIDER): cv.string, + vol.Optional(ATTR_ACTIVITY): cv.string +}) + + +async def async_setup(hass, hass_config): + """Set up the GPSLogger component.""" + hass.async_create_task( + async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config) + ) + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook with GPSLogger request.""" + try: + data = WEBHOOK_SCHEMA(dict(await request.post())) + except vol.MultipleInvalid as error: + return web.Response( + body=error.error_message, + status=HTTP_UNPROCESSABLE_ENTITY + ) + + attrs = { + ATTR_SPEED: data.get(ATTR_SPEED), + ATTR_DIRECTION: data.get(ATTR_DIRECTION), + ATTR_ALTITUDE: data.get(ATTR_ALTITUDE), + ATTR_PROVIDER: data.get(ATTR_PROVIDER), + ATTR_ACTIVITY: data.get(ATTR_ACTIVITY) + } + + device = data[ATTR_DEVICE] + + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + data[ATTR_BATTERY], + data[ATTR_ACCURACY], + attrs + ) + + return web.Response( + body='Setting location for {}'.format(device), + status=HTTP_OK + ) + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + DOMAIN, 'GPSLogger', entry.data[CONF_WEBHOOK_ID], handle_webhook) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + return True + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'GPSLogger Webhook', + { + 'docs_url': 'https://www.home-assistant.io/components/gpslogger/' + } +) diff --git a/homeassistant/components/gpslogger/strings.json b/homeassistant/components/gpslogger/strings.json new file mode 100644 index 00000000000..d5641ef5db8 --- /dev/null +++ b/homeassistant/components/gpslogger/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "GPSLogger Webhook", + "step": { + "user": { + "title": "Set up the GPSLogger Webhook", + "description": "Are you sure you want to set up the GPSLogger Webhook?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from GPSLogger." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in GPSLogger.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/greeneye_monitor.py b/homeassistant/components/greeneye_monitor.py index f5c51da88be..c1e2f285772 100644 --- a/homeassistant/components/greeneye_monitor.py +++ b/homeassistant/components/greeneye_monitor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['greeneye_monitor==0.1'] +REQUIREMENTS = ['greeneye_monitor==1.0'] _LOGGER = logging.getLogger(__name__) @@ -81,7 +81,15 @@ CHANNEL_SCHEMA = vol.Schema({ CHANNELS_SCHEMA = vol.All(cv.ensure_list, [CHANNEL_SCHEMA]) MONITOR_SCHEMA = vol.Schema({ - vol.Required(CONF_SERIAL_NUMBER): cv.positive_int, + vol.Required(CONF_SERIAL_NUMBER): + vol.All( + cv.string, + vol.Length( + min=8, + max=8, + msg="GEM serial number must be specified as an 8-character " + "string (including leading zeroes)."), + vol.Coerce(int)), vol.Optional(CONF_CHANNELS, default=[]): CHANNELS_SCHEMA, vol.Optional( CONF_TEMPERATURE_SENSORS, diff --git a/homeassistant/components/hangouts/.translations/de.json b/homeassistant/components/hangouts/.translations/de.json index e0f18b6cccf..c8e84983fb6 100644 --- a/homeassistant/components/hangouts/.translations/de.json +++ b/homeassistant/components/hangouts/.translations/de.json @@ -5,7 +5,7 @@ "unknown": "Ein unbekannter Fehler ist aufgetreten." }, "error": { - "invalid_2fa": "Ung\u00fcltige 2-Faktor Authentifizierung, bitte versuche es erneut.", + "invalid_2fa": "Ung\u00fcltige 2-Faktor Authentifizierung, bitte versuchen Sie es erneut.", "invalid_2fa_method": "Ung\u00fcltige 2FA Methode (mit Telefon verifizieren)", "invalid_login": "Ung\u00fcltige Daten, bitte erneut versuchen." }, diff --git a/homeassistant/components/hangouts/.translations/et.json b/homeassistant/components/hangouts/.translations/et.json new file mode 100644 index 00000000000..4bd26876ac6 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "invalid_login": "Vale Kasutajanimi, palun proovige uuesti." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA PIN" + }, + "title": "Kaheastmeline autentimine" + }, + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + } + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/pt-BR.json b/homeassistant/components/hangouts/.translations/pt-BR.json index 00c533311fc..444edc40838 100644 --- a/homeassistant/components/hangouts/.translations/pt-BR.json +++ b/homeassistant/components/hangouts/.translations/pt-BR.json @@ -11,6 +11,9 @@ }, "step": { "2fa": { + "data": { + "2fa": "Pin 2FA" + }, "description": "Vazio", "title": "Autentica\u00e7\u00e3o de 2 Fatores" }, diff --git a/homeassistant/components/hangouts/.translations/sl.json b/homeassistant/components/hangouts/.translations/sl.json index d7555335820..db85f338b67 100644 --- a/homeassistant/components/hangouts/.translations/sl.json +++ b/homeassistant/components/hangouts/.translations/sl.json @@ -6,7 +6,7 @@ }, "error": { "invalid_2fa": "Neveljavna 2FA avtorizacija, prosimo, poskusite znova.", - "invalid_2fa_method": "Neveljavna 2FA metoda (preveri na telefonu).", + "invalid_2fa_method": "Neveljavna 2FA Metoda (Preverite na Telefonu).", "invalid_login": "Neveljavna Prijava, prosimo, poskusite znova." }, "step": { @@ -15,7 +15,7 @@ "2fa": "2FA Pin" }, "description": "prazno", - "title": "2-faktorska avtorizacija" + "title": "Dvofaktorska avtorizacija" }, "user": { "data": { diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index c539169ebe3..964f94bfb41 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -10,3 +10,5 @@ ATTR_USERNAME = 'username' ATTR_PASSWORD = 'password' X_HASSIO = 'X-HASSIO-KEY' +X_HASS_USER_ID = 'X-HASS-USER-ID' +X_HASS_IS_ADMIN = 'X-HASS-IS-ADMIN' diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index be2806716a7..6b8004f7664 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -17,7 +17,7 @@ from aiohttp.web_exceptions import HTTPBadGateway from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView -from .const import X_HASSIO +from .const import X_HASSIO, X_HASS_USER_ID, X_HASS_IS_ADMIN _LOGGER = logging.getLogger(__name__) @@ -75,9 +75,16 @@ class HassIOView(HomeAssistantView): read_timeout = _get_timeout(path) hass = request.app['hass'] + data = None + headers = { + X_HASSIO: os.environ.get('HASSIO_TOKEN', ""), + } + user = request.get('hass_user') + if user is not None: + headers[X_HASS_USER_ID] = request['hass_user'].id + headers[X_HASS_IS_ADMIN] = str(int(request['hass_user'].is_admin)) + try: - data = None - headers = {X_HASSIO: os.environ.get('HASSIO_TOKEN', "")} with async_timeout.timeout(10, loop=hass.loop): data = await request.read() if data: diff --git a/homeassistant/components/history_graph.py b/homeassistant/components/history_graph.py index fa7d615dce2..7d9db379705 100644 --- a/homeassistant/components/history_graph.py +++ b/homeassistant/components/history_graph.py @@ -34,7 +34,7 @@ GRAPH_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({cv.slug: GRAPH_SCHEMA}) + DOMAIN: cv.schema_with_slug_keys(GRAPH_SCHEMA) }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/hive.py b/homeassistant/components/hive.py index aa662fc2fb6..09319849933 100644 --- a/homeassistant/components/hive.py +++ b/homeassistant/components/hive.py @@ -12,7 +12,7 @@ from homeassistant.const import (CONF_PASSWORD, CONF_SCAN_INTERVAL, import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ['pyhiveapi==0.2.14'] +REQUIREMENTS = ['pyhiveapi==0.2.17'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'hive' diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 09068773e4e..6fdde7ddd50 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -24,6 +24,11 @@ HOMEKIT_ACCESSORY_DISPATCH = { 'outlet': 'switch', 'switch': 'switch', 'thermostat': 'climate', + 'security-system': 'alarm_control_panel', + 'garage-door-opener': 'cover', + 'window': 'cover', + 'window-covering': 'cover', + 'lock-mechanism': 'lock' } HOMEKIT_IGNORE = [ @@ -113,11 +118,13 @@ class HKDevice(): self.hass.data[KNOWN_ACCESSORIES][serial] = self aid = accessory['aid'] for service in accessory['services']: - service_info = {'serial': serial, - 'aid': aid, - 'iid': service['iid']} devtype = ServicesTypes.get_short(service['type']) _LOGGER.debug("Found %s", devtype) + service_info = {'serial': serial, + 'aid': aid, + 'iid': service['iid'], + 'model': self.model, + 'device-type': devtype} component = HOMEKIT_ACCESSORY_DISPATCH.get(devtype, None) if component is not None: discovery.load_platform(self.hass, component, DOMAIN, diff --git a/homeassistant/components/homematicip_cloud/.translations/et.json b/homeassistant/components/homematicip_cloud/.translations/et.json new file mode 100644 index 00000000000..7aedd80b5d0 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/et.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "invalid_pin": "Vale PIN, palun proovige uuesti" + }, + "step": { + "init": { + "data": { + "hapid": "P\u00e4\u00e4supunkti ID (SGTIN)", + "pin": "PIN-kood (valikuline)" + } + } + }, + "title": "HomematicIP Pilv" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 05c5c970d2e..700e6274c35 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -19,7 +19,7 @@ from .const import ( from .device import HomematicipGenericDevice # noqa: F401 from .hap import HomematicipAuth, HomematicipHAP # noqa: F401 -REQUIREMENTS = ['homematicip==0.9.8'] +REQUIREMENTS = ['homematicip==0.10.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 6cd211613ce..a515fcd198e 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -92,8 +92,8 @@ def setup_auth(app, trusted_networks, api_password): for user in users: if user.is_owner: request['hass_user'] = user + authenticated = True break - authenticated = True request[KEY_AUTHENTICATED] = authenticated return await handler(request) diff --git a/homeassistant/components/huawei_lte.py b/homeassistant/components/huawei_lte.py index 1f35f86ccb2..9d223df3344 100644 --- a/homeassistant/components/huawei_lte.py +++ b/homeassistant/components/huawei_lte.py @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) # https://github.com/quandyfactory/dicttoxml/issues/60 logging.getLogger('dicttoxml').setLevel(logging.WARNING) -REQUIREMENTS = ['huawei-lte-api==1.1.1'] +REQUIREMENTS = ['huawei-lte-api==1.1.3'] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) diff --git a/homeassistant/components/hue/.translations/et.json b/homeassistant/components/hue/.translations/et.json new file mode 100644 index 00000000000..6bad10ed067 --- /dev/null +++ b/homeassistant/components/hue/.translations/et.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "unknown": "Ilmnes tundmatu viga" + }, + "step": { + "init": { + "data": { + "host": "" + } + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 9c28d08054b..b10e5bb29de 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -19,7 +19,7 @@ from .bridge import HueBridge # Loading the config flow file will register the flow from .config_flow import configured_hosts -REQUIREMENTS = ['aiohue==1.5.0'] +REQUIREMENTS = ['aiohue==1.8.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/hue/light.py similarity index 95% rename from homeassistant/components/light/hue.py rename to homeassistant/components/hue/light.py index 28a2d79de13..7a1449e00c6 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/hue/light.py @@ -221,9 +221,16 @@ class HueLight(Light): if is_group: self.is_osram = False self.is_philips = False + self.gamut_typ = 'None' + self.gamut = None else: self.is_osram = light.manufacturername == 'OSRAM' self.is_philips = light.manufacturername == 'Philips' + self.gamut_typ = self.light.colorgamuttype + self.gamut = self.light.colorgamut + if not self.gamut: + err_msg = 'Can not get color gamut of light "%s"' + _LOGGER.warning(err_msg, self.name) @property def unique_id(self): @@ -256,7 +263,7 @@ class HueLight(Light): source = self.light.action if self.is_group else self.light.state if mode in ('xy', 'hs') and 'xy' in source: - return color.color_xy_to_hs(*source['xy']) + return color.color_xy_to_hs(*source['xy'], self.gamut) return None @@ -290,6 +297,11 @@ class HueLight(Light): """Flag supported features.""" return SUPPORT_HUE.get(self.light.type, SUPPORT_HUE_EXTENDED) + @property + def effect(self): + """Return the current effect.""" + return self.light.state.get('effect', None) + @property def effect_list(self): """Return the list of supported effects.""" @@ -331,7 +343,9 @@ class HueLight(Light): # Philips hue bulb models respond differently to hue/sat # requests, so we convert to XY first to ensure a consistent # color. - command['xy'] = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) + xy_color = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], + self.gamut) + command['xy'] = xy_color elif ATTR_COLOR_TEMP in kwargs: temp = kwargs[ATTR_COLOR_TEMP] command['ct'] = max(self.min_mireds, min(temp, self.max_mireds)) diff --git a/homeassistant/components/ifttt/.translations/ca.json b/homeassistant/components/ifttt/.translations/ca.json index 597328a2ee4..ff4cf67c23b 100644 --- a/homeassistant/components/ifttt/.translations/ca.json +++ b/homeassistant/components/ifttt/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar IFTTT?", - "title": "Configuraci\u00f3 de la miniaplicaci\u00f3 Webhook de IFTTT" + "title": "Configuraci\u00f3 de la miniaplicaci\u00f3 Webhook IFTTT" } }, "title": "IFTTT" diff --git a/homeassistant/components/ifttt/.translations/et.json b/homeassistant/components/ifttt/.translations/et.json new file mode 100644 index 00000000000..8c4c45f9c89 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/et.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/.translations/pt-BR.json b/homeassistant/components/ifttt/.translations/pt-BR.json new file mode 100644 index 00000000000..4e72fc58b4b --- /dev/null +++ b/homeassistant/components/ifttt/.translations/pt-BR.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o IFTTT?", + "title": "Configurar o IFTTT Webhook Applet" + } + }, + "title": "IFTTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index b081f117919..823f9d2657d 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -264,9 +264,9 @@ def get_discovery_info(component_setup, groups, controller_id): 'ihc_id': ihc_id, 'ctrl_id': controller_id, 'product': { - 'name': product.attrib['name'], - 'note': product.attrib['note'], - 'position': product.attrib['position']}, + 'name': product.get('name') or '', + 'note': product.get('note') or '', + 'position': product.get('position') or ''}, 'product_cfg': product_cfg} discovery_data[name] = device return discovery_data diff --git a/homeassistant/components/image_processing/tensorflow.py b/homeassistant/components/image_processing/tensorflow.py index 6172963525e..939b5a821cb 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -20,7 +20,7 @@ from homeassistant.core import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.4', 'pillow==5.3.0', 'protobuf==3.6.1'] +REQUIREMENTS = ['numpy==1.15.4', 'pillow==5.4.1', 'protobuf==3.6.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index dfb41ddf617..2b9a5d9e193 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -154,8 +154,9 @@ def setup(hass, config): return try: - if (whitelist_e and state.entity_id not in whitelist_e) or \ - (whitelist_d and state.domain not in whitelist_d): + if ((whitelist_e or whitelist_d) + and state.entity_id not in whitelist_e + and state.domain not in whitelist_d): return _include_state = _include_value = False diff --git a/homeassistant/components/input_boolean.py b/homeassistant/components/input_boolean.py index 541e38202fc..896de61130c 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -30,13 +30,13 @@ SERVICE_SCHEMA = vol.Schema({ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: vol.Any({ + DOMAIN: cv.schema_with_slug_keys( + vol.Any({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_INITIAL): cv.boolean, vol.Optional(CONF_ICON): cv.icon, }, None) - }) + ) }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/input_datetime.py b/homeassistant/components/input_datetime.py index 6ac9a24d044..63dcc364c9c 100644 --- a/homeassistant/components/input_datetime.py +++ b/homeassistant/components/input_datetime.py @@ -46,14 +46,15 @@ def has_date_or_time(conf): CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: vol.All({ + DOMAIN: cv.schema_with_slug_keys( + vol.All({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_HAS_DATE, default=False): cv.boolean, vol.Optional(CONF_HAS_TIME, default=False): cv.boolean, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_INITIAL): cv.string, - }, has_date_or_time)}) + }, has_date_or_time) + ) }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/input_number.py b/homeassistant/components/input_number.py index b6c6eab3cf5..8cfa7abaf20 100644 --- a/homeassistant/components/input_number.py +++ b/homeassistant/components/input_number.py @@ -63,8 +63,8 @@ def _cv_input_number(cfg): CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: vol.All({ + DOMAIN: cv.schema_with_slug_keys( + vol.All({ vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_MIN): vol.Coerce(float), vol.Required(CONF_MAX): vol.Coerce(float), @@ -76,7 +76,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_MODE, default=MODE_SLIDER): vol.In([MODE_BOX, MODE_SLIDER]), }, _cv_input_number) - }) + ) }, required=True, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/input_select.py b/homeassistant/components/input_select.py index cc9a73bf915..fc858e75397 100644 --- a/homeassistant/components/input_select.py +++ b/homeassistant/components/input_select.py @@ -64,14 +64,15 @@ def _cv_input_select(cfg): CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: vol.All({ + DOMAIN: cv.schema_with_slug_keys( + vol.All({ vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_OPTIONS): vol.All(cv.ensure_list, vol.Length(min=1), [cv.string]), vol.Optional(CONF_INITIAL): cv.string, vol.Optional(CONF_ICON): cv.icon, - }, _cv_input_select)}) + }, _cv_input_select) + ) }, required=True, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py index 8ac64b398f4..580337a3af3 100644 --- a/homeassistant/components/input_text.py +++ b/homeassistant/components/input_text.py @@ -55,8 +55,8 @@ def _cv_input_text(cfg): CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: vol.All({ + DOMAIN: cv.schema_with_slug_keys( + vol.All({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_MIN, default=0): vol.Coerce(int), vol.Optional(CONF_MAX, default=100): vol.Coerce(int), @@ -67,7 +67,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_MODE, default=MODE_TEXT): vol.In([MODE_TEXT, MODE_PASSWORD]), }, _cv_input_text) - }) + ) }, required=True, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/ios/.translations/et.json b/homeassistant/components/ios/.translations/et.json new file mode 100644 index 00000000000..987c54955f2 --- /dev/null +++ b/homeassistant/components/ios/.translations/et.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "confirm": { + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected.py b/homeassistant/components/konnected.py index a6fe4be65fa..0cba8552346 100644 --- a/homeassistant/components/konnected.py +++ b/homeassistant/components/konnected.py @@ -96,7 +96,7 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -DEPENDENCIES = ['http', 'discovery'] +DEPENDENCIES = ['http'] ENDPOINT_ROOT = '/api/konnected' UPDATE_ENDPOINT = (ENDPOINT_ROOT + r'/device/{device_id:[a-zA-Z0-9]+}') diff --git a/homeassistant/components/lifx/.translations/pt-BR.json b/homeassistant/components/lifx/.translations/pt-BR.json new file mode 100644 index 00000000000..e5f88b5384e --- /dev/null +++ b/homeassistant/components/lifx/.translations/pt-BR.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo LIFX encontrado na rede.", + "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do LIFX \u00e9 poss\u00edvel." + }, + "step": { + "confirm": { + "description": "Voc\u00ea quer configurar o LIFX?", + "title": "LIFX" + } + }, + "title": "LIFX" + } +} \ No newline at end of file diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py index 6f2842524a2..9b3b3850f39 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -12,7 +12,7 @@ from functools import partial from homeassistant.const import ( CONF_WHITE_VALUE) from homeassistant.components.fibaro import ( - FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice, + FIBARO_DEVICES, FibaroDevice, CONF_DIMMING, CONF_COLOR, CONF_RESET_COLOR) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, ENTITY_ID_FORMAT, @@ -50,14 +50,14 @@ async def async_setup_platform(hass, return async_add_entities( - [FibaroLight(device, hass.data[FIBARO_CONTROLLER]) + [FibaroLight(device) for device in hass.data[FIBARO_DEVICES]['light']], True) class FibaroLight(FibaroDevice, Light): """Representation of a Fibaro Light, including dimmable.""" - def __init__(self, fibaro_device, controller): + def __init__(self, fibaro_device): """Initialize the light.""" self._brightness = None self._color = (0, 0) @@ -81,7 +81,7 @@ class FibaroLight(FibaroDevice, Light): if devconf.get(CONF_WHITE_VALUE, supports_white_v): self._supported_flags |= SUPPORT_WHITE_VALUE - super().__init__(fibaro_device, controller) + super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) @property diff --git a/homeassistant/components/light/hive.py b/homeassistant/components/light/hive.py index eada16bbab9..c2bb95f40da 100644 --- a/homeassistant/components/light/hive.py +++ b/homeassistant/components/light/hive.py @@ -4,7 +4,7 @@ Support for the Hive devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.hive/ """ -from homeassistant.components.hive import DATA_HIVE +from homeassistant.components.hive import DATA_HIVE, DOMAIN from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, @@ -37,8 +37,24 @@ class HiveDeviceLight(Light): self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) + self._unique_id = '{}-{}'.format(self.node_id, self.device_type) self.session.entities.append(self) + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self): + """Return device information.""" + return { + 'identifiers': { + (DOMAIN, self.unique_id) + }, + 'name': self.name + } + def handle_update(self, updatesource): """Handle the new update request.""" if '{}.{}'.format(self.device_type, self.node_id) not in updatesource: diff --git a/homeassistant/components/light/homematicip_cloud.py b/homeassistant/components/light/homematicip_cloud.py index 764ead62169..2837edbd5b7 100644 --- a/homeassistant/components/light/homematicip_cloud.py +++ b/homeassistant/components/light/homematicip_cloud.py @@ -29,14 +29,17 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the HomematicIP Cloud lights from a config entry.""" - from homematicip.aio.device import AsyncBrandSwitchMeasuring, AsyncDimmer + from homematicip.aio.device import AsyncBrandSwitchMeasuring, AsyncDimmer,\ + AsyncPluggableDimmer, AsyncBrandDimmer, AsyncFullFlushDimmer home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): devices.append(HomematicipLightMeasuring(home, device)) - elif isinstance(device, AsyncDimmer): + elif isinstance(device, + (AsyncDimmer, AsyncPluggableDimmer, + AsyncBrandDimmer, AsyncFullFlushDimmer)): devices.append(HomematicipDimmer(home, device)) if devices: diff --git a/homeassistant/components/light/rpi_gpio_pwm.py b/homeassistant/components/light/rpi_gpio_pwm.py index 25c72c247ee..a3fe0f6b71e 100644 --- a/homeassistant/components/light/rpi_gpio_pwm.py +++ b/homeassistant/components/light/rpi_gpio_pwm.py @@ -8,14 +8,15 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_ON from homeassistant.components.light import ( Light, ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_TRANSITION, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util +from homeassistant.helpers.restore_state import RestoreEntity -REQUIREMENTS = ['pwmled==1.3.0'] +REQUIREMENTS = ['pwmled==1.4.1'] _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,7 @@ CONF_LED_TYPE_RGB = 'rgb' CONF_LED_TYPE_RGBW = 'rgbw' CONF_LED_TYPES = [CONF_LED_TYPE_SIMPLE, CONF_LED_TYPE_RGB, CONF_LED_TYPE_RGBW] +DEFAULT_BRIGHTNESS = 255 DEFAULT_COLOR = [0, 0] SUPPORT_SIMPLE_LED = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) @@ -95,7 +97,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(leds) -class PwmSimpleLed(Light): +class PwmSimpleLed(Light, RestoreEntity): """Representation of a simple one-color PWM LED.""" def __init__(self, led, name): @@ -103,7 +105,18 @@ class PwmSimpleLed(Light): self._led = led self._name = name self._is_on = False - self._brightness = 255 + self._brightness = DEFAULT_BRIGHTNESS + + async def async_added_to_hass(self): + """Handle entity about to be added to hass event.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state: + self._is_on = (last_state.state == STATE_ON) + self._brightness = last_state.attributes.get('brightness', + DEFAULT_BRIGHTNESS) + self._led.set(is_on=self._is_on, + brightness=_from_hass_brightness(self._brightness)) @property def should_poll(self): @@ -169,6 +182,14 @@ class PwmRgbLed(PwmSimpleLed): super().__init__(led, name) self._color = DEFAULT_COLOR + async def async_added_to_hass(self): + """Handle entity about to be added to hass event.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state: + self._color = last_state.attributes.get('hs_color', DEFAULT_COLOR) + self._led.set(color=_from_hass_color(self._color)) + @property def hs_color(self): """Return the color property.""" diff --git a/homeassistant/components/light/scsgate.py b/homeassistant/components/light/scsgate.py index 4a18bc99672..c218e194791 100644 --- a/homeassistant/components/light/scsgate.py +++ b/homeassistant/components/light/scsgate.py @@ -19,7 +19,8 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['scsgate'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): vol.Schema({cv.slug: scsgate.SCSGATE_SCHEMA}), + vol.Required(CONF_DEVICES): + cv.schema_with_slug_keys(scsgate.SCSGATE_SCHEMA), }) diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index 2447dabe3c7..bf930dd1b38 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -44,7 +44,7 @@ LIGHT_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_LIGHTS): vol.Schema({cv.slug: LIGHT_SCHEMA}), + vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_SCHEMA), }) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index a26a2eb828a..50e92f15e3c 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -339,6 +339,8 @@ class TradfriLight(Light): # pylint: disable=import-error from pytradfri.error import PytradfriError if exc: + self._available = False + self.async_schedule_update_ha_state() _LOGGER.warning("Observation failed for %s", self._name, exc_info=exc) diff --git a/homeassistant/components/locative/.translations/en.json b/homeassistant/components/locative/.translations/en.json new file mode 100644 index 00000000000..b2a538a0fa5 --- /dev/null +++ b/homeassistant/components/locative/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Locative Webhook", + "step": { + "user": { + "title": "Set up the Locative Webhook", + "description": "Are you sure you want to set up the Locative Webhook?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency." + }, + "create_entry": { + "default": "To send locations to Home Assistant, you will need to setup the webhook feature in the Locative app.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py new file mode 100644 index 00000000000..1cc47270ba3 --- /dev/null +++ b/homeassistant/components/locative/__init__.py @@ -0,0 +1,157 @@ +""" +Support for Locative. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/locative/ +""" +import logging +from typing import Dict + +import voluptuous as vol +from aiohttp import web + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import \ + DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, ATTR_LATITUDE, \ + ATTR_LONGITUDE, STATE_NOT_HOME, CONF_WEBHOOK_ID, ATTR_ID, HTTP_OK +from homeassistant.helpers import config_entry_flow +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'locative' +DEPENDENCIES = ['webhook'] + +TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) + + +ATTR_DEVICE_ID = 'device' +ATTR_TRIGGER = 'trigger' + + +def _id(value: str) -> str: + """Coerce id by removing '-'.""" + return value.replace('-', '') + + +def _validate_test_mode(obj: Dict) -> Dict: + """Validate that id is provided outside of test mode.""" + if ATTR_ID not in obj and obj[ATTR_TRIGGER] != 'test': + raise vol.Invalid('Location id not specified') + return obj + + +WEBHOOK_SCHEMA = vol.All( + vol.Schema({ + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_TRIGGER): cv.string, + vol.Optional(ATTR_ID): vol.All(cv.string, _id) + }), + _validate_test_mode +) + + +async def async_setup(hass, hass_config): + """Set up the Locative component.""" + hass.async_create_task( + async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config) + ) + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook from Locative.""" + try: + data = WEBHOOK_SCHEMA(dict(await request.post())) + except vol.MultipleInvalid as error: + return web.Response( + body=error.error_message, + status=HTTP_UNPROCESSABLE_ENTITY + ) + + device = data[ATTR_DEVICE_ID] + location_name = data.get(ATTR_ID, data[ATTR_TRIGGER]).lower() + direction = data[ATTR_TRIGGER] + gps_location = (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]) + + if direction == 'enter': + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + gps_location, + location_name + ) + return web.Response( + body='Setting location to {}'.format(location_name), + status=HTTP_OK + ) + + if direction == 'exit': + current_state = hass.states.get( + '{}.{}'.format(DEVICE_TRACKER_DOMAIN, device)) + + if current_state is None or current_state.state == location_name: + location_name = STATE_NOT_HOME + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + gps_location, + location_name + ) + return web.Response( + body='Setting location to not home', + status=HTTP_OK + ) + + # Ignore the message if it is telling us to exit a zone that we + # aren't currently in. This occurs when a zone is entered + # before the previous zone was exited. The enter message will + # be sent first, then the exit message will be sent second. + return web.Response( + body='Ignoring exit from {} (already in {})'.format( + location_name, current_state + ), + status=HTTP_OK + ) + + if direction == 'test': + # In the app, a test message can be sent. Just return something to + # the user to let them know that it works. + return web.Response( + body='Received test message.', + status=HTTP_OK + ) + + _LOGGER.error('Received unidentified message from Locative: %s', + direction) + return web.Response( + body='Received unidentified message: {}'.format(direction), + status=HTTP_UNPROCESSABLE_ENTITY + ) + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + DOMAIN, 'Locative', entry.data[CONF_WEBHOOK_ID], handle_webhook) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + return True + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Locative Webhook', + { + 'docs_url': 'https://www.home-assistant.io/components/locative/' + } +) diff --git a/homeassistant/components/locative/strings.json b/homeassistant/components/locative/strings.json new file mode 100644 index 00000000000..b2a538a0fa5 --- /dev/null +++ b/homeassistant/components/locative/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Locative Webhook", + "step": { + "user": { + "title": "Set up the Locative Webhook", + "description": "Are you sure you want to set up the Locative Webhook?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency." + }, + "create_entry": { + "default": "To send locations to Home Assistant, you will need to setup the webhook feature in the Locative app.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lock/homekit_controller.py b/homeassistant/components/lock/homekit_controller.py new file mode 100644 index 00000000000..910567ed182 --- /dev/null +++ b/homeassistant/components/lock/homekit_controller.py @@ -0,0 +1,111 @@ +""" +Support for HomeKit Controller locks. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.homekit_controller/ +""" + +import logging + +from homeassistant.components.homekit_controller import (HomeKitEntity, + KNOWN_ACCESSORIES) +from homeassistant.components.lock import LockDevice +from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED, + ATTR_BATTERY_LEVEL) + +DEPENDENCIES = ['homekit_controller'] + +_LOGGER = logging.getLogger(__name__) + +STATE_JAMMED = 'jammed' + +CURRENT_STATE_MAP = { + 0: STATE_UNLOCKED, + 1: STATE_LOCKED, + 2: STATE_JAMMED, + 3: None, +} + +TARGET_STATE_MAP = { + STATE_UNLOCKED: 0, + STATE_LOCKED: 1 +} + + +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']] + add_entities([HomeKitLock(accessory, discovery_info)], + True) + + +class HomeKitLock(HomeKitEntity, LockDevice): + """Representation of a HomeKit Controller Lock.""" + + 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 update_characteristics(self, characteristics): + """Synchronise the Lock state with Home Assistant.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + for characteristic in characteristics: + ctype = characteristic['type'] + ctype = CharacteristicsTypes.get_short(ctype) + if ctype == "lock-mechanism.current-state": + self._chars['lock-mechanism.current-state'] = \ + characteristic['iid'] + self._state = CURRENT_STATE_MAP[characteristic['value']] + elif ctype == "lock-mechanism.target-state": + self._chars['lock-mechanism.target-state'] = \ + characteristic['iid'] + elif ctype == "battery-level": + self._chars['battery-level'] = characteristic['iid'] + self._battery_level = characteristic['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.""" + return self._state == STATE_LOCKED + + @property + def available(self): + """Return True if entity is available.""" + return self._state is not None + + def lock(self, **kwargs): + """Lock the device.""" + self._set_lock_state(STATE_LOCKED) + + def unlock(self, **kwargs): + """Unlock the device.""" + self._set_lock_state(STATE_UNLOCKED) + + def _set_lock_state(self, state): + """Send state command.""" + characteristics = [{'aid': self._aid, + 'iid': self._chars['lock-mechanism.target-state'], + 'value': TARGET_STATE_MAP[state]}] + self.put_characteristics(characteristics) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + if self._battery_level is None: + return None + + return { + ATTR_BATTERY_LEVEL: self._battery_level, + } diff --git a/homeassistant/components/lock/nuki.py b/homeassistant/components/lock/nuki.py index 689ec31fc7c..8af798e31e0 100644 --- a/homeassistant/components/lock/nuki.py +++ b/homeassistant/components/lock/nuki.py @@ -15,7 +15,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import extract_entity_ids -REQUIREMENTS = ['pynuki==1.3.1'] +REQUIREMENTS = ['pynuki==1.3.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index b4bb233c9cc..77afe688c2e 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -32,17 +32,19 @@ POLYCONTROL_DANALOCK_V2_BTZE_LOCK = (POLYCONTROL, DANALOCK_V2_BTZE) WORKAROUND_V2BTZE = 1 WORKAROUND_DEVICE_STATE = 2 WORKAROUND_TRACK_MESSAGE = 4 +WORKAROUND_ALARM_TYPE = 8 DEVICE_MAPPINGS = { POLYCONTROL_DANALOCK_V2_BTZE_LOCK: WORKAROUND_V2BTZE, # Kwikset 914TRL ZW500 (0x0090, 0x440): WORKAROUND_DEVICE_STATE, + (0x0090, 0x446): WORKAROUND_DEVICE_STATE, # Yale YRD210 (0x0129, 0x0209): WORKAROUND_DEVICE_STATE, (0x0129, 0xAA00): WORKAROUND_DEVICE_STATE, (0x0129, 0x0000): WORKAROUND_DEVICE_STATE, # Yale YRD220 (as reported by adrum in PR #17386) - (0x0109, 0x0000): WORKAROUND_DEVICE_STATE, + (0x0109, 0x0000): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, # Schlage BE469 (0x003B, 0x5044): WORKAROUND_DEVICE_STATE | WORKAROUND_TRACK_MESSAGE, # Schlage FE599NX @@ -236,6 +238,7 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): self._state_workaround = False self._track_message_workaround = False self._previous_message = None + self._alarm_type_workaround = False # Enable appropriate workaround flags for our device # Make sure that we have values for the key before converting to int @@ -256,6 +259,10 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): if workaround & WORKAROUND_TRACK_MESSAGE: self._track_message_workaround = True _LOGGER.debug("Message tracking workaround enabled") + if workaround & WORKAROUND_ALARM_TYPE: + self._alarm_type_workaround = True + _LOGGER.debug( + "Alarm Type device state workaround enabled") self.update_properties() def update_properties(self): @@ -310,6 +317,12 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): if not alarm_type: return + + if self._alarm_type_workaround: + self._state = LOCK_STATUS.get(str(alarm_type)) + _LOGGER.debug("workaround: lock state set to %s -- alarm type: %s", + self._state, str(alarm_type)) + if alarm_type == 21: self._lock_status = '{}{}'.format( LOCK_ALARM_TYPE.get(str(alarm_type)), diff --git a/homeassistant/components/luftdaten/.translations/de.json b/homeassistant/components/luftdaten/.translations/de.json index 136b907df81..46d75a6b73b 100644 --- a/homeassistant/components/luftdaten/.translations/de.json +++ b/homeassistant/components/luftdaten/.translations/de.json @@ -1,15 +1,17 @@ { "config": { "error": { - "communication_error": "Keine Kommunikation mit Lufdaten API m\u00f6glich", + "communication_error": "Keine Kommunikation mit Luftdaten API m\u00f6glich", "invalid_sensor": "Sensor nicht verf\u00fcgbar oder ung\u00fcltig", "sensor_exists": "Sensor bereits registriert" }, "step": { "user": { "data": { - "show_on_map": "Auf Karte anzeigen" - } + "show_on_map": "Auf Karte anzeigen", + "station_id": "Luftdaten-Sensor-ID" + }, + "title": "Luftdaten einrichten" } }, "title": "Luftdaten" diff --git a/homeassistant/components/luftdaten/.translations/pt-BR.json b/homeassistant/components/luftdaten/.translations/pt-BR.json new file mode 100644 index 00000000000..796d7c04fbb --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/pt-BR.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "show_on_map": "Mostrar no mapa", + "station_id": "ID do Sensor Luftdaten" + }, + "title": "Definir Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/pt.json b/homeassistant/components/luftdaten/.translations/pt.json index 6a242c441af..0402f352c5c 100644 --- a/homeassistant/components/luftdaten/.translations/pt.json +++ b/homeassistant/components/luftdaten/.translations/pt.json @@ -8,9 +8,12 @@ "step": { "user": { "data": { - "show_on_map": "Mostrar no mapa" - } + "show_on_map": "Mostrar no mapa", + "station_id": "Luftdaten Sensor ID" + }, + "title": "Definir Luftdaten" } - } + }, + "title": "" } } \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/ca.json b/homeassistant/components/mailgun/.translations/ca.json index f43467de7d9..8815360f7b4 100644 --- a/homeassistant/components/mailgun/.translations/ca.json +++ b/homeassistant/components/mailgun/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar Mailgun?", - "title": "Configuraci\u00f3 del Webhook de Mailgun" + "title": "Configuraci\u00f3 del Webhook Mailgun" } }, "title": "Mailgun" diff --git a/homeassistant/components/mailgun/.translations/de.json b/homeassistant/components/mailgun/.translations/de.json new file mode 100644 index 00000000000..306757cd528 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Ihre Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Mailgun-Nachrichten empfangen zu k\u00f6nnen.", + "one_instance_allowed": "Nur eine einzige Instanz ist notwendig." + }, + "create_entry": { + "default": "Um Ereignisse an den Home Assistant zu senden, m\u00fcssen Sie [Webhooks mit Mailgun]({mailgun_url}) einrichten. \n\n F\u00fcllen Sie die folgenden Informationen aus: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhaltstyp: application / json \n\n Lesen Sie in der [Dokumentation]({docs_url}) wie Sie Automationen f\u00fcr die Verarbeitung eingehender Daten konfigurieren." + }, + "step": { + "user": { + "description": "M\u00f6chten Sie Mailgun wirklich einrichten?", + "title": "Mailgun-Webhook einrichten" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/notify/mailgun.py b/homeassistant/components/mailgun/notify.py similarity index 98% rename from homeassistant/components/notify/mailgun.py rename to homeassistant/components/mailgun/notify.py index 56b0ab7e333..d4052678d36 100644 --- a/homeassistant/components/notify/mailgun.py +++ b/homeassistant/components/mailgun/notify.py @@ -1,5 +1,5 @@ """ -Support for the Mailgun mail service. +Support for the Mailgun mail notification service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.mailgun/ diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 749935fda2b..940f2dd79ca 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.12.17'] +REQUIREMENTS = ['youtube_dl==2019.01.10'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/anthemav.py b/homeassistant/components/media_player/anthemav.py index f1954a1d37e..0a9f208dae4 100644 --- a/homeassistant/components/media_player/anthemav.py +++ b/homeassistant/components/media_player/anthemav.py @@ -59,7 +59,6 @@ async def async_setup_platform(hass, config, async_add_entities, _LOGGER.debug("dump_devicedata: %s", device.dump_avrdata) _LOGGER.debug("dump_conndata: %s", avr.dump_conndata) - _LOGGER.debug("dump_rawdata: %s", avr.protocol.dump_rawdata) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.avr.close) async_add_entities([device]) diff --git a/homeassistant/components/media_player/nad.py b/homeassistant/components/media_player/nad.py index 5fff8831617..57dca116f6b 100644 --- a/homeassistant/components/media_player/nad.py +++ b/homeassistant/components/media_player/nad.py @@ -8,53 +8,87 @@ import logging import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.media_player import ( PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON -import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['nad_receiver==0.0.9'] +REQUIREMENTS = ['nad_receiver==0.0.11'] _LOGGER = logging.getLogger(__name__) +DEFAULT_TYPE = 'RS232' +DEFAULT_SERIAL_PORT = '/dev/ttyUSB0' +DEFAULT_PORT = 53 DEFAULT_NAME = 'NAD Receiver' DEFAULT_MIN_VOLUME = -92 DEFAULT_MAX_VOLUME = -20 +DEFAULT_VOLUME_STEP = 4 SUPPORT_NAD = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_VOLUME_STEP | \ - SUPPORT_SELECT_SOURCE + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_VOLUME_STEP | \ + SUPPORT_SELECT_SOURCE -CONF_SERIAL_PORT = 'serial_port' +CONF_TYPE = 'type' +CONF_SERIAL_PORT = 'serial_port' # for NADReceiver +CONF_HOST = 'host' # for NADReceiverTelnet +CONF_PORT = 'port' # for NADReceiverTelnet CONF_MIN_VOLUME = 'min_volume' CONF_MAX_VOLUME = 'max_volume' -CONF_SOURCE_DICT = 'sources' +CONF_VOLUME_STEP = 'volume_step' # for NADReceiverTCP +CONF_SOURCE_DICT = 'sources' # for NADReceiver SOURCE_DICT_SCHEMA = vol.Schema({ vol.Range(min=1, max=10): cv.string }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SERIAL_PORT): cv.string, + vol.Optional(CONF_TYPE, default=DEFAULT_TYPE): + vol.In(['RS232', 'Telnet', 'TCP']), + vol.Optional(CONF_SERIAL_PORT, default=DEFAULT_SERIAL_PORT): + cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MIN_VOLUME, default=DEFAULT_MIN_VOLUME): int, vol.Optional(CONF_MAX_VOLUME, default=DEFAULT_MAX_VOLUME): int, vol.Optional(CONF_SOURCE_DICT, default={}): SOURCE_DICT_SCHEMA, + vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): int, }) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the NAD platform.""" - from nad_receiver import NADReceiver - add_entities([NAD( - config.get(CONF_NAME), - NADReceiver(config.get(CONF_SERIAL_PORT)), - config.get(CONF_MIN_VOLUME), - config.get(CONF_MAX_VOLUME), - config.get(CONF_SOURCE_DICT) - )], True) + if config.get(CONF_TYPE) == 'RS232': + from nad_receiver import NADReceiver + add_entities([NAD( + config.get(CONF_NAME), + NADReceiver(config.get(CONF_SERIAL_PORT)), + config.get(CONF_MIN_VOLUME), + config.get(CONF_MAX_VOLUME), + config.get(CONF_SOURCE_DICT) + )], True) + elif config.get(CONF_TYPE) == 'Telnet': + from nad_receiver import NADReceiverTelnet + add_entities([NAD( + config.get(CONF_NAME), + NADReceiverTelnet(config.get(CONF_HOST), + config.get(CONF_PORT)), + config.get(CONF_MIN_VOLUME), + config.get(CONF_MAX_VOLUME), + config.get(CONF_SOURCE_DICT) + )], True) + else: + from nad_receiver import NADReceiverTCP + add_entities([NADtcp( + config.get(CONF_NAME), + NADReceiverTCP(config.get(CONF_HOST)), + config.get(CONF_MIN_VOLUME), + config.get(CONF_MAX_VOLUME), + config.get(CONF_VOLUME_STEP), + )], True) class NAD(MediaPlayerDevice): @@ -73,24 +107,6 @@ class NAD(MediaPlayerDevice): self._volume = self._state = self._mute = self._source = None - def calc_volume(self, decibel): - """ - Calculate the volume given the decibel. - - Return the volume (0..1). - """ - return abs(self._min_volume - decibel) / abs( - self._min_volume - self._max_volume) - - def calc_db(self, volume): - """ - Calculate the decibel given the volume. - - Return the dB. - """ - return self._min_volume + round( - abs(self._min_volume - self._max_volume) * volume) - @property def name(self): """Return the name of the device.""" @@ -101,22 +117,6 @@ class NAD(MediaPlayerDevice): """Return the state of the device.""" return self._state - def update(self): - """Retrieve latest state.""" - if self._nad_receiver.main_power('?') == 'Off': - self._state = STATE_OFF - else: - self._state = STATE_ON - - if self._nad_receiver.main_mute('?') == 'Off': - self._mute = False - else: - self._mute = True - - self._volume = self.calc_volume(self._nad_receiver.main_volume('?')) - self._source = self._source_dict.get( - self._nad_receiver.main_source('?')) - @property def volume_level(self): """Volume level of the media player (0..1).""" @@ -152,6 +152,13 @@ class NAD(MediaPlayerDevice): """Set volume level, range 0..1.""" self._nad_receiver.main_volume('=', self.calc_db(volume)) + def mute_volume(self, mute): + """Mute (true) or unmute (false) media player.""" + if mute: + self._nad_receiver.main_mute('=', 'On') + else: + self._nad_receiver.main_mute('=', 'Off') + def select_source(self, source): """Select input source.""" self._nad_receiver.main_source('=', self._reverse_mapping.get(source)) @@ -166,9 +173,162 @@ class NAD(MediaPlayerDevice): """List of available input sources.""" return sorted(list(self._reverse_mapping.keys())) + def update(self): + """Retrieve latest state.""" + if self._nad_receiver.main_power('?') == 'Off': + self._state = STATE_OFF + else: + self._state = STATE_ON + + if self._nad_receiver.main_mute('?') == 'Off': + self._mute = False + else: + self._mute = True + + self._volume = self.calc_volume(self._nad_receiver.main_volume('?')) + self._source = self._source_dict.get( + self._nad_receiver.main_source('?')) + + def calc_volume(self, decibel): + """ + Calculate the volume given the decibel. + + Return the volume (0..1). + """ + return abs(self._min_volume - decibel) / abs( + self._min_volume - self._max_volume) + + def calc_db(self, volume): + """ + Calculate the decibel given the volume. + + Return the dB. + """ + return self._min_volume + round( + abs(self._min_volume - self._max_volume) * volume) + + +class NADtcp(MediaPlayerDevice): + """Representation of a NAD Digital amplifier.""" + + def __init__(self, name, nad_device, min_volume, max_volume, volume_step): + """Initialize the amplifier.""" + self._name = name + self._nad_receiver = nad_device + self._min_vol = (min_volume + 90) * 2 # from dB to nad vol (0-200) + self._max_vol = (max_volume + 90) * 2 # from dB to nad vol (0-200) + self._volume_step = volume_step + self._state = None + self._mute = None + self._nad_volume = None + self._volume = None + self._source = None + self._source_list = self._nad_receiver.available_sources() + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._mute + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_NAD + + def turn_off(self): + """Turn the media player off.""" + self._nad_receiver.power_off() + + def turn_on(self): + """Turn the media player on.""" + self._nad_receiver.power_on() + + def volume_up(self): + """Step volume up in the configured increments.""" + self._nad_receiver.set_volume(self._nad_volume + 2 * self._volume_step) + + def volume_down(self): + """Step volume down in the configured increments.""" + self._nad_receiver.set_volume(self._nad_volume - 2 * self._volume_step) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + nad_volume_to_set = \ + int(round(volume * (self._max_vol - self._min_vol) + + self._min_vol)) + self._nad_receiver.set_volume(nad_volume_to_set) + def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" if mute: - self._nad_receiver.main_mute('=', 'On') + self._nad_receiver.mute() else: - self._nad_receiver.main_mute('=', 'Off') + self._nad_receiver.unmute() + + def select_source(self, source): + """Select input source.""" + self._nad_receiver.select_source(source) + + @property + def source(self): + """Name of the current input source.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._nad_receiver.available_sources() + + def update(self): + """Get the latest details from the device.""" + try: + nad_status = self._nad_receiver.status() + except OSError: + return + if nad_status is None: + return + + # Update on/off state + if nad_status['power']: + self._state = STATE_ON + else: + self._state = STATE_OFF + + # Update current volume + self._volume = self.nad_vol_to_internal_vol(nad_status['volume']) + self._nad_volume = nad_status['volume'] + + # Update muted state + self._mute = nad_status['muted'] + + # Update current source + self._source = nad_status['source'] + + def nad_vol_to_internal_vol(self, nad_volume): + """Convert nad volume range (0-200) to internal volume range. + + Takes into account configured min and max volume. + """ + if nad_volume < self._min_vol: + volume_internal = 0.0 + elif nad_volume > self._max_vol: + volume_internal = 1.0 + else: + volume_internal = (nad_volume - self._min_vol) / \ + (self._max_vol - self._min_vol) + return volume_internal diff --git a/homeassistant/components/media_player/nadtcp.py b/homeassistant/components/media_player/nadtcp.py deleted file mode 100644 index dd3c5e26d7a..00000000000 --- a/homeassistant/components/media_player/nadtcp.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -Support for NAD digital amplifiers which can be remote controlled via tcp/ip. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/media_player.nadtcp/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, - MediaPlayerDevice) -from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['nad_receiver==0.0.9'] - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'NAD amplifier' -DEFAULT_MIN_VOLUME = -60 -DEFAULT_MAX_VOLUME = -10 -DEFAULT_VOLUME_STEP = 4 - -SUPPORT_NAD = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | SUPPORT_TURN_ON | \ - SUPPORT_TURN_OFF | SUPPORT_VOLUME_STEP | SUPPORT_SELECT_SOURCE - -CONF_MIN_VOLUME = 'min_volume' -CONF_MAX_VOLUME = 'max_volume' -CONF_VOLUME_STEP = 'volume_step' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MIN_VOLUME, default=DEFAULT_MIN_VOLUME): int, - vol.Optional(CONF_MAX_VOLUME, default=DEFAULT_MAX_VOLUME): int, - vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): int, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the NAD platform.""" - from nad_receiver import NADReceiverTCP - add_entities([NADtcp( - NADReceiverTCP(config.get(CONF_HOST)), - config.get(CONF_NAME), - config.get(CONF_MIN_VOLUME), - config.get(CONF_MAX_VOLUME), - config.get(CONF_VOLUME_STEP), - )], True) - - -class NADtcp(MediaPlayerDevice): - """Representation of a NAD Digital amplifier.""" - - def __init__(self, nad_device, name, min_volume, max_volume, volume_step): - """Initialize the amplifier.""" - self._name = name - self.nad_device = nad_device - self._min_vol = (min_volume + 90) * 2 # from dB to nad vol (0-200) - self._max_vol = (max_volume + 90) * 2 # from dB to nad vol (0-200) - self._volume_step = volume_step - self._state = None - self._mute = None - self._nad_volume = None - self._volume = None - self._source = None - self._source_list = self.nad_device.available_sources() - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - def update(self): - """Get the latest details from the device.""" - try: - nad_status = self.nad_device.status() - except OSError: - return - if nad_status is None: - return - - # Update on/off state - if nad_status['power']: - self._state = STATE_ON - else: - self._state = STATE_OFF - - # Update current volume - self._volume = self.nad_vol_to_internal_vol(nad_status['volume']) - self._nad_volume = nad_status['volume'] - - # Update muted state - self._mute = nad_status['muted'] - - # Update current source - self._source = nad_status['source'] - - def nad_vol_to_internal_vol(self, nad_volume): - """Convert nad volume range (0-200) to internal volume range. - - Takes into account configured min and max volume. - """ - if nad_volume < self._min_vol: - volume_internal = 0.0 - if nad_volume > self._max_vol: - volume_internal = 1.0 - else: - volume_internal = (nad_volume - self._min_vol) / \ - (self._max_vol - self._min_vol) - return volume_internal - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_NAD - - def turn_off(self): - """Turn the media player off.""" - self.nad_device.power_off() - - def turn_on(self): - """Turn the media player on.""" - self.nad_device.power_on() - - def volume_up(self): - """Step volume up in the configured increments.""" - self.nad_device.set_volume(self._nad_volume + 2 * self._volume_step) - - def volume_down(self): - """Step volume down in the configured increments.""" - self.nad_device.set_volume(self._nad_volume - 2 * self._volume_step) - - def set_volume_level(self, volume): - """Set volume level, range 0..1.""" - nad_volume_to_set = \ - int(round(volume * (self._max_vol - self._min_vol) + - self._min_vol)) - self.nad_device.set_volume(nad_volume_to_set) - - def mute_volume(self, mute): - """Mute (true) or unmute (false) media player.""" - if mute: - self.nad_device.mute() - else: - self.nad_device.unmute() - - def select_source(self, source): - """Select input source.""" - self.nad_device.select_source(source) - - @property - def source(self): - """Name of the current input source.""" - return self._source - - @property - def source_list(self): - """List of available input sources.""" - return self.nad_device.available_sources() - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._mute diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index fccca235193..20a6f42d729 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -1,79 +1,38 @@ """ -Support for the roku media player. +Support for the Roku media player. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.roku/ """ import logging - -import voluptuous as vol +import requests.exceptions from homeassistant.components.media_player import ( - MEDIA_TYPE_MOVIE, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PLAY, + MEDIA_TYPE_MOVIE, SUPPORT_NEXT_TRACK, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( CONF_HOST, STATE_HOME, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN) -import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-roku==3.1.5'] +DEPENDENCIES = ['roku'] -KNOWN_HOSTS = [] DEFAULT_PORT = 8060 -NOTIFICATION_ID = 'roku_notification' -NOTIFICATION_TITLE = 'Roku Media Player Setup' - _LOGGER = logging.getLogger(__name__) SUPPORT_ROKU = SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST): cv.string, -}) - -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): """Set up the Roku platform.""" - hosts = [] + if not discovery_info: + return - if discovery_info: - host = discovery_info.get('host') - - if host in KNOWN_HOSTS: - return - - _LOGGER.debug("Discovered Roku: %s", host) - hosts.append(discovery_info.get('host')) - - elif CONF_HOST in config: - hosts.append(config.get(CONF_HOST)) - - rokus = [] - for host in hosts: - new_roku = RokuDevice(host) - - try: - if new_roku.name is not None: - rokus.append(RokuDevice(host)) - KNOWN_HOSTS.append(host) - else: - _LOGGER.error("Unable to initialize roku at %s", host) - - except AttributeError: - _LOGGER.error("Unable to initialize roku at %s", host) - hass.components.persistent_notification.create( - 'Error: Unable to initialize roku at {}
' - 'Check its network connection or consider ' - 'using auto discovery.
' - 'You will need to restart hass after fixing.' - ''.format(config.get(CONF_HOST)), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - - add_entities(rokus) + host = discovery_info[CONF_HOST] + async_add_entities([RokuDevice(host)], True) class RokuDevice(MediaPlayerDevice): @@ -89,12 +48,8 @@ class RokuDevice(MediaPlayerDevice): self.current_app = None self._device_info = {} - self.update() - def update(self): """Retrieve latest state.""" - import requests.exceptions - try: self._device_info = self.roku.device_info self.ip_address = self.roku.host @@ -106,7 +61,6 @@ class RokuDevice(MediaPlayerDevice): self.current_app = None except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout): - pass def get_source_list(self): diff --git a/homeassistant/components/media_player/songpal.py b/homeassistant/components/media_player/songpal.py index c79470a82fe..165ef668a95 100644 --- a/homeassistant/components/media_player/songpal.py +++ b/homeassistant/components/media_player/songpal.py @@ -154,8 +154,8 @@ class SongpalDevice(MediaPlayerDevice): _LOGGER.debug("New active source: %s", self._active_source) await self.async_update_ha_state() else: - _LOGGER.warning("Got non-handled content change: %s", - content) + _LOGGER.debug("Got non-handled content change: %s", + content) async def _power_changed(power: PowerChange): _LOGGER.debug("Power changed: %s", power) diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index 47eaf599929..18b953a0372 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -47,8 +47,8 @@ CONF_SERVICE_DATA = 'service_data' OFF_STATES = [STATE_IDLE, STATE_OFF, STATE_UNAVAILABLE] -ATTRS_SCHEMA = vol.Schema({cv.slug: cv.string}) -CMD_SCHEMA = vol.Schema({cv.slug: cv.SERVICE_SCHEMA}) +ATTRS_SCHEMA = cv.schema_with_slug_keys(cv.string) +CMD_SCHEMA = cv.schema_with_slug_keys(cv.SERVICE_SCHEMA) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_NAME): cv.string, diff --git a/homeassistant/components/mqtt/.translations/et.json b/homeassistant/components/mqtt/.translations/et.json new file mode 100644 index 00000000000..4ba36fd3361 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/et.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "broker": { + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/pt-BR.json b/homeassistant/components/mqtt/.translations/pt-BR.json index 2de8003e6ed..bc55b7d8c61 100644 --- a/homeassistant/components/mqtt/.translations/pt-BR.json +++ b/homeassistant/components/mqtt/.translations/pt-BR.json @@ -16,6 +16,12 @@ }, "description": "Por favor, insira as informa\u00e7\u00f5es de conex\u00e3o do seu agente MQTT.", "title": "MQTT" + }, + "hassio_confirm": { + "data": { + "discovery": "Ativar descoberta" + }, + "description": "Deseja configurar o Home Assistant para se conectar ao broker MQTT fornecido pelo complemento hass.io {addon}?" } }, "title": "MQTT" diff --git a/homeassistant/components/mqtt/.translations/uk.json b/homeassistant/components/mqtt/.translations/uk.json index e747e774c45..747d190a56d 100644 --- a/homeassistant/components/mqtt/.translations/uk.json +++ b/homeassistant/components/mqtt/.translations/uk.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "\u0414\u043e\u0437\u0432\u043e\u043b\u0435\u043d\u043e \u043b\u0438\u0448\u0435 \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u044e MQTT." + }, "error": { "cannot_connect": "\u041d\u0435\u043c\u043e\u0436\u043b\u0438\u0432\u043e \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f \u0434\u043e \u0431\u0440\u043e\u043a\u0435\u0440\u0430." }, @@ -11,7 +14,8 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", "username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430" - } + }, + "description": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u0456\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0456\u044e \u043f\u0440\u043e \u0437'\u0454\u0434\u043d\u0430\u043d\u043d\u044f \u0432\u0430\u0448\u043e\u0433\u043e \u0431\u0440\u043e\u043a\u0435\u0440\u0430 MQTT." }, "hassio_confirm": { "data": { diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 2750f1c1585..fcaa05f7921 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -34,6 +34,7 @@ from homeassistant.loader import bind_hass from homeassistant.setup import async_prepare_setup_platform from homeassistant.util.async_ import ( run_callback_threadsafe, run_coroutine_threadsafe) +from homeassistant.util.logging import catch_log_exception # Loading the config flow file will register the flow from . import config_flow # noqa pylint: disable=unused-import @@ -80,6 +81,7 @@ CONF_CONNECTIONS = 'connections' CONF_MANUFACTURER = 'manufacturer' CONF_MODEL = 'model' CONF_SW_VERSION = 'sw_version' +CONF_VIA_HUB = 'via_hub' PROTOCOL_31 = '3.1' PROTOCOL_311 = '3.1.1' @@ -224,6 +226,7 @@ MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All(vol.Schema({ vol.Optional(CONF_MODEL): cv.string, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_SW_VERSION): cv.string, + vol.Optional(CONF_VIA_HUB): cv.string, }), validate_device_has_at_least_one_identifier) MQTT_JSON_ATTRS_SCHEMA = vol.Schema({ @@ -309,7 +312,11 @@ async def async_subscribe(hass: HomeAssistantType, topic: str, Call the return value to unsubscribe. """ async_remove = await hass.data[DATA_MQTT].async_subscribe( - topic, msg_callback, qos, encoding) + topic, catch_log_exception( + msg_callback, lambda topic, msg, qos: + "Exception in {} when handling msg on '{}': '{}'".format( + msg_callback.__name__, topic, msg)), + qos, encoding) return async_remove @@ -890,17 +897,12 @@ class MqttAttributes(Entity): class MqttAvailability(Entity): """Mixin used for platforms that report availability.""" - def __init__(self, availability_topic: Optional[str], qos: Optional[int], - payload_available: Optional[str], - payload_not_available: Optional[str]) -> None: + def __init__(self, config: dict) -> None: """Initialize the availability mixin.""" self._availability_sub_state = None self._available = False # type: bool - self._availability_topic = availability_topic - self._availability_qos = qos - self._payload_available = payload_available - self._payload_not_available = payload_not_available + self._avail_config = config async def async_added_to_hass(self) -> None: """Subscribe MQTT events. @@ -912,16 +914,9 @@ class MqttAvailability(Entity): async def availability_discovery_update(self, config: dict): """Handle updated discovery message.""" - self._availability_setup_from_config(config) + self._avail_config = config await self._availability_subscribe_topics() - def _availability_setup_from_config(self, config): - """(Re)Setup.""" - self._availability_topic = config.get(CONF_AVAILABILITY_TOPIC) - self._availability_qos = config.get(CONF_QOS) - self._payload_available = config.get(CONF_PAYLOAD_AVAILABLE) - self._payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - async def _availability_subscribe_topics(self): """(Re)Subscribe to topics.""" from .subscription import async_subscribe_topics @@ -931,9 +926,9 @@ class MqttAvailability(Entity): payload: SubscribePayloadType, qos: int) -> None: """Handle a new received MQTT availability message.""" - if payload == self._payload_available: + if payload == self._avail_config[CONF_PAYLOAD_AVAILABLE]: self._available = True - elif payload == self._payload_not_available: + elif payload == self._avail_config[CONF_PAYLOAD_NOT_AVAILABLE]: self._available = False self.async_schedule_update_ha_state() @@ -941,9 +936,9 @@ class MqttAvailability(Entity): self._availability_sub_state = await async_subscribe_topics( self.hass, self._availability_sub_state, {'availability_topic': { - 'topic': self._availability_topic, + 'topic': self._avail_config.get(CONF_AVAILABILITY_TOPIC), 'msg_callback': availability_message_received, - 'qos': self._availability_qos}}) + 'qos': self._avail_config[CONF_QOS]}}) async def async_will_remove_from_hass(self): """Unsubscribe when removed.""" @@ -954,7 +949,8 @@ class MqttAvailability(Entity): @property def available(self) -> bool: """Return if the device is available.""" - return self._availability_topic is None or self._available + availability_topic = self._avail_config.get(CONF_AVAILABILITY_TOPIC) + return availability_topic is None or self._available class MqttDiscoveryUpdate(Entity): @@ -972,7 +968,7 @@ class MqttDiscoveryUpdate(Entity): from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.mqtt.discovery import ( - ALREADY_DISCOVERED, MQTT_DISCOVERY_UPDATED) + MQTT_DISCOVERY_UPDATED, clear_discovery_hash) @callback def discovery_callback(payload): @@ -983,7 +979,7 @@ class MqttDiscoveryUpdate(Entity): # Empty payload: Remove component _LOGGER.info("Removing component: %s", self.entity_id) self.hass.async_create_task(self.async_remove()) - del self.hass.data[ALREADY_DISCOVERED][self._discovery_hash] + clear_discovery_hash(self.hass, self._discovery_hash) self._remove_signal() elif self._discovery_update: # Non-empty payload: Notify component @@ -1032,4 +1028,7 @@ class MqttEntityDeviceInfo(Entity): if CONF_SW_VERSION in self._device_config: info['sw_version'] = self._device_config[CONF_SW_VERSION] + if CONF_VIA_HUB in self._device_config: + info['via_hub'] = (DOMAIN, self._device_config[CONF_VIA_HUB]) + return info diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/mqtt/alarm_control_panel.py similarity index 89% rename from homeassistant/components/alarm_control_panel/mqtt.py rename to homeassistant/components/mqtt/alarm_control_panel.py index c943a513c45..5bd4117ecee 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -17,11 +17,11 @@ from homeassistant.const import ( STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType @@ -62,9 +62,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT alarm control panel dynamically through MQTT discovery.""" async def async_discover(discovery_payload): """Discover and add an MQTT alarm control panel.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, - discovery_payload[ATTR_DISCOVERY_HASH]) + try: + discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(alarm.DOMAIN, 'mqtt'), @@ -88,14 +94,9 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, self._unique_id = config.get(CONF_UNIQUE_ID) self._sub_state = None - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) - payload_available = config.get(CONF_PAYLOAD_AVAILABLE) - payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) + MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) @@ -165,8 +166,8 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, if code is None: return None if isinstance(code, str) and re.search('^\\d+$', code): - return 'Number' - return 'Any' + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT async def async_alarm_disarm(self, code=None): """Send disarm command. diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/mqtt/binary_sensor.py similarity index 89% rename from homeassistant/components/binary_sensor/mqtt.py rename to homeassistant/components/mqtt/binary_sensor.py index 0a09d051192..95886a46299 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -16,11 +16,10 @@ from homeassistant.const import ( CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS, CONF_DEVICE) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, - MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, - MqttEntityDeviceInfo, subscription) -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW + ATTR_DISCOVERY_HASH, CONF_QOS, CONF_STATE_TOPIC, MqttAttributes, + MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.helpers.event as evt @@ -63,9 +62,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT binary sensor dynamically through MQTT discovery.""" async def async_discover(discovery_payload): """Discover and add a MQTT binary sensor.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, - discovery_payload[ATTR_DISCOVERY_HASH]) + try: + discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(binary_sensor.DOMAIN, 'mqtt'), @@ -89,15 +94,10 @@ class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._sub_state = None self._delay_listener = None - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) - payload_available = config.get(CONF_PAYLOAD_AVAILABLE) - payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) MqttAttributes.__init__(self, config) - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) + MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) diff --git a/homeassistant/components/camera/mqtt.py b/homeassistant/components/mqtt/camera.py similarity index 100% rename from homeassistant/components/camera/mqtt.py rename to homeassistant/components/mqtt/camera.py diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/mqtt/climate.py similarity index 97% rename from homeassistant/components/climate/mqtt.py rename to homeassistant/components/mqtt/climate.py index 96de0709dc8..71950f9b1b7 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/mqtt/climate.py @@ -21,11 +21,10 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_DEVICE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_ON, STATE_OFF) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_QOS, CONF_RETAIN, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, - MQTT_BASE_PLATFORM_SCHEMA, MqttAvailability, MqttDiscoveryUpdate, - MqttEntityDeviceInfo, subscription) -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW + ATTR_DISCOVERY_HASH, CONF_QOS, CONF_RETAIN, MQTT_BASE_PLATFORM_SCHEMA, + MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType @@ -158,9 +157,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT climate device dynamically through MQTT discovery.""" async def async_discover(discovery_payload): """Discover and add a MQTT climate device.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, - discovery_payload[ATTR_DISCOVERY_HASH]) + try: + discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(hass, config, async_add_entities, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(climate.DOMAIN, 'mqtt'), @@ -203,14 +208,9 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, self._setup_from_config(config) - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) - payload_available = config.get(CONF_PAYLOAD_AVAILABLE) - payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) + MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/mqtt/cover.py similarity index 93% rename from homeassistant/components/cover/mqtt.py rename to homeassistant/components/mqtt/cover.py index 3926c84cb92..5ebe51a3bce 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/mqtt/cover.py @@ -20,11 +20,11 @@ from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN, CONF_DEVICE) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, - CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, - CONF_QOS, CONF_RETAIN, valid_publish_topic, valid_subscribe_topic, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW + ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, + CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, subscription) +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType @@ -91,12 +91,12 @@ def validate_options(value): PLATFORM_SCHEMA = vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_SET_POSITION_TOPIC): valid_publish_topic, + vol.Optional(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SET_POSITION_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_SET_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - vol.Optional(CONF_GET_POSITION_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_GET_POSITION_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): cv.string, @@ -109,8 +109,8 @@ PLATFORM_SCHEMA = vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_POSITION_CLOSED, default=DEFAULT_POSITION_CLOSED): int, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_TILT_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_TILT_STATUS_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_TILT_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_TILT_STATUS_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_TILT_CLOSED_POSITION, default=DEFAULT_TILT_CLOSED_POSITION): int, vol.Optional(CONF_TILT_OPEN_POSITION, @@ -136,9 +136,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT cover dynamically through MQTT discovery.""" async def async_discover(discovery_payload): """Discover and add an MQTT cover.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, - discovery_payload[ATTR_DISCOVERY_HASH]) + try: + discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(cover.DOMAIN, 'mqtt'), @@ -168,14 +174,9 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # Load config self._setup_from_config(config) - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) - payload_available = config.get(CONF_PAYLOAD_AVAILABLE) - payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) + MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 92e7ffdaf4f..fc8b9091763 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -25,7 +25,7 @@ TOPIC_MATCHER = re.compile( SUPPORTED_COMPONENTS = [ 'binary_sensor', 'camera', 'cover', 'fan', 'light', 'sensor', 'switch', 'lock', 'climate', - 'alarm_control_panel'] + 'alarm_control_panel', 'vacuum'] CONFIG_ENTRY_COMPONENTS = [ 'binary_sensor', @@ -38,6 +38,7 @@ CONFIG_ENTRY_COMPONENTS = [ 'climate', 'alarm_control_panel', 'fan', + 'vacuum', ] DEPRECATED_PLATFORM_TO_SCHEMA = { @@ -69,12 +70,25 @@ ABBREVIATIONS = { 'bri_stat_t': 'brightness_state_topic', 'bri_val_tpl': 'brightness_value_template', 'clr_temp_cmd_tpl': 'color_temp_command_template', + 'bat_lev_t': 'battery_level_topic', + 'bat_lev_tpl': 'battery_level_template', + 'chrg_t': 'charging_topic', + 'chrg_tpl': 'charging_template', 'clr_temp_cmd_t': 'color_temp_command_topic', 'clr_temp_stat_t': 'color_temp_state_topic', 'clr_temp_val_tpl': 'color_temp_value_template', + 'cln_t': 'cleaning_topic', + 'cln_tpl': 'cleaning_template', 'cmd_t': 'command_topic', 'curr_temp_t': 'current_temperature_topic', 'dev_cla': 'device_class', + 'dock_t': 'docked_topic', + 'dock_tpl': 'docked_template', + 'err_t': 'error_topic', + 'err_tpl': 'error_template', + 'fanspd_t': 'fan_speed_topic', + 'fanspd_tpl': 'fan_speed_template', + 'fanspd_lst': 'fan_speed_list', 'fx_cmd_t': 'effect_command_topic', 'fx_list': 'effect_list', 'fx_stat_t': 'effect_state_topic', @@ -124,6 +138,7 @@ ABBREVIATIONS = { 'rgb_cmd_t': 'rgb_command_topic', 'rgb_stat_t': 'rgb_state_topic', 'rgb_val_tpl': 'rgb_value_template', + 'send_cmd_t': 'send_command_topic', 'send_if_off': 'send_if_off', 'set_pos_tpl': 'set_position_template', 'set_pos_t': 'set_position_topic', @@ -137,6 +152,7 @@ ABBREVIATIONS = { 'stat_open': 'state_open', 'stat_t': 'state_topic', 'stat_val_tpl': 'state_value_template', + 'sup_feat': 'supported_features', 'swing_mode_cmd_t': 'swing_mode_command_topic', 'swing_mode_stat_tpl': 'swing_mode_state_template', 'swing_mode_stat_t': 'swing_mode_state_topic', @@ -164,6 +180,11 @@ ABBREVIATIONS = { } +def clear_discovery_hash(hass, discovery_hash): + """Clear entry in ALREADY_DISCOVERED list.""" + del hass.data[ALREADY_DISCOVERED][discovery_hash] + + async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, config_entry=None) -> bool: """Initialize of MQTT Discovery.""" diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/mqtt/fan.py similarity index 93% rename from homeassistant/components/fan/mqtt.py rename to homeassistant/components/mqtt/fan.py index 932a4584e2f..22f89a40e04 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/mqtt/fan.py @@ -14,9 +14,8 @@ from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, STATE_ON, STATE_OFF, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_DEVICE) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, - CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, - CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate, + ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, + CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -25,7 +24,8 @@ from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, FanEntity, SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, SPEED_OFF, ATTR_SPEED) -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) _LOGGER = logging.getLogger(__name__) @@ -86,23 +86,29 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT fan through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT fan dynamically through MQTT discovery.""" async def async_discover(discovery_payload): """Discover and add a MQTT fan.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, - discovery_payload[ATTR_DISCOVERY_HASH]) + try: + discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(fan.DOMAIN, 'mqtt'), async_discover) -async def _async_setup_entity(hass, config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT fan.""" async_add_entities([MqttFan( @@ -134,14 +140,9 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # Load config self._setup_from_config(config) - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) - payload_available = config.get(CONF_PAYLOAD_AVAILABLE) - payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) + MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) diff --git a/homeassistant/components/light/mqtt/__init__.py b/homeassistant/components/mqtt/light/__init__.py similarity index 100% rename from homeassistant/components/light/mqtt/__init__.py rename to homeassistant/components/mqtt/light/__init__.py diff --git a/homeassistant/components/light/mqtt/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py similarity index 95% rename from homeassistant/components/light/mqtt/schema_basic.py rename to homeassistant/components/mqtt/light/schema_basic.py index a263ed66d6d..fdfc1961db3 100644 --- a/homeassistant/components/light/mqtt/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -19,8 +19,7 @@ from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, STATE_ON, CONF_RGB, CONF_STATE, CONF_VALUE_TEMPLATE, CONF_WHITE_VALUE, CONF_XY) from homeassistant.components.mqtt import ( - CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, - CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.config_validation as cv @@ -134,7 +133,6 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, self._color_temp = None self._effect = None self._white_value = None - self._supported_features = 0 self._topic = None self._payload = None @@ -152,14 +150,9 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # Load config self._setup_from_config(config) - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) - payload_available = config.get(CONF_PAYLOAD_AVAILABLE) - payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) + MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) @@ -241,27 +234,6 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, self._optimistic_xy = \ optimistic or topic[CONF_XY_STATE_TOPIC] is None - self._supported_features = 0 - self._supported_features |= ( - topic[CONF_RGB_COMMAND_TOPIC] is not None and - (SUPPORT_COLOR | SUPPORT_BRIGHTNESS)) - self._supported_features |= ( - topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None and - SUPPORT_BRIGHTNESS) - self._supported_features |= ( - topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None and - SUPPORT_COLOR_TEMP) - self._supported_features |= ( - topic[CONF_EFFECT_COMMAND_TOPIC] is not None and - SUPPORT_EFFECT) - self._supported_features |= ( - topic[CONF_HS_COMMAND_TOPIC] is not None and SUPPORT_COLOR) - self._supported_features |= ( - topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None and - SUPPORT_WHITE_VALUE) - self._supported_features |= ( - topic[CONF_XY_COMMAND_TOPIC] is not None and SUPPORT_COLOR) - async def _subscribe_topics(self): """(Re)Subscribe to topics.""" topics = {} @@ -561,7 +533,28 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @property def supported_features(self): """Flag supported features.""" - return self._supported_features + supported_features = 0 + supported_features |= ( + self._topic[CONF_RGB_COMMAND_TOPIC] is not None and + (SUPPORT_COLOR | SUPPORT_BRIGHTNESS)) + supported_features |= ( + self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None and + SUPPORT_BRIGHTNESS) + supported_features |= ( + self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None and + SUPPORT_COLOR_TEMP) + supported_features |= ( + self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None and + SUPPORT_EFFECT) + supported_features |= ( + self._topic[CONF_HS_COMMAND_TOPIC] is not None and SUPPORT_COLOR) + supported_features |= ( + self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None and + SUPPORT_WHITE_VALUE) + supported_features |= ( + self._topic[CONF_XY_COMMAND_TOPIC] is not None and SUPPORT_COLOR) + + return supported_features async def async_turn_on(self, **kwargs): """Turn the device on. diff --git a/homeassistant/components/light/mqtt/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py similarity index 97% rename from homeassistant/components/light/mqtt/schema_json.py rename to homeassistant/components/mqtt/light/schema_json.py index f0888726d78..6c986cbf49f 100644 --- a/homeassistant/components/light/mqtt/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -16,8 +16,7 @@ from homeassistant.components.light import ( SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, Light) from homeassistant.components.mqtt import ( - CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, - CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) from homeassistant.const import ( CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_DEVICE, CONF_EFFECT, CONF_NAME, @@ -114,14 +113,9 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, # Load config self._setup_from_config(config) - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) - payload_available = config.get(CONF_PAYLOAD_AVAILABLE) - payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) + MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) diff --git a/homeassistant/components/light/mqtt/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py similarity index 96% rename from homeassistant/components/light/mqtt/schema_template.py rename to homeassistant/components/mqtt/light/schema_template.py index 9f04e9a6468..53423679050 100644 --- a/homeassistant/components/light/mqtt/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -17,8 +17,7 @@ from homeassistant.components.light import ( from homeassistant.const import ( CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, STATE_ON, STATE_OFF) from homeassistant.components.mqtt import ( - CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util @@ -101,14 +100,9 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # Load config self._setup_from_config(config) - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) - payload_available = config.get(CONF_PAYLOAD_AVAILABLE) - payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) + MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/mqtt/lock.py similarity index 58% rename from homeassistant/components/lock/mqtt.py rename to homeassistant/components/mqtt/lock.py index ad5ba34dbb0..e82498a9b12 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/mqtt/lock.py @@ -11,14 +11,14 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.lock import LockDevice from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, - MqttEntityDeviceInfo) + MqttEntityDeviceInfo, subscription) from homeassistant.const import ( CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE) from homeassistant.components import mqtt, lock -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType @@ -50,97 +50,100 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT lock panel through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT lock dynamically through MQTT discovery.""" async def async_discover(discovery_payload): """Discover and add an MQTT lock.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, - discovery_payload[ATTR_DISCOVERY_HASH]) + try: + discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(lock.DOMAIN, 'mqtt'), async_discover) -async def _async_setup_entity(hass, config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT Lock platform.""" - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - - async_add_entities([MqttLock( - config.get(CONF_NAME), - config.get(CONF_STATE_TOPIC), - config.get(CONF_COMMAND_TOPIC), - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_PAYLOAD_LOCK), - config.get(CONF_PAYLOAD_UNLOCK), - config.get(CONF_OPTIMISTIC), - value_template, - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_UNIQUE_ID), - config.get(CONF_DEVICE), - discovery_hash, - )]) + async_add_entities([MqttLock(config, discovery_hash)]) class MqttLock(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, LockDevice): """Representation of a lock that can be toggled using MQTT.""" - def __init__(self, name, state_topic, command_topic, qos, retain, - payload_lock, payload_unlock, optimistic, value_template, - availability_topic, payload_available, payload_not_available, - unique_id, device_config, discovery_hash): + def __init__(self, config, discovery_hash): """Initialize the lock.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - MqttEntityDeviceInfo.__init__(self, device_config) + self._config = config + self._unique_id = config.get(CONF_UNIQUE_ID) self._state = False - self._name = name - self._state_topic = state_topic - self._command_topic = command_topic - self._qos = qos - self._retain = retain - self._payload_lock = payload_lock - self._payload_unlock = payload_unlock - self._optimistic = optimistic - self._template = value_template - self._discovery_hash = discovery_hash - self._unique_id = unique_id + self._sub_state = None + self._optimistic = False + + device_config = config.get(CONF_DEVICE) + + MqttAvailability.__init__(self, config) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config) async def async_added_to_hass(self): """Subscribe to MQTT events.""" await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._config = config + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + value_template = self._config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = self.hass @callback def message_received(topic, payload, qos): """Handle new MQTT messages.""" - if self._template is not None: - payload = self._template.async_render_with_possible_json_value( + if value_template is not None: + payload = value_template.async_render_with_possible_json_value( payload) - if payload == self._payload_lock: + if payload == self._config[CONF_PAYLOAD_LOCK]: self._state = True - elif payload == self._payload_unlock: + elif payload == self._config[CONF_PAYLOAD_UNLOCK]: self._state = False self.async_schedule_update_ha_state() - if self._state_topic is None: + if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True else: - await mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._config.get(CONF_STATE_TOPIC), + 'msg_callback': message_received, + 'qos': self._config[CONF_QOS]}}) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + self._sub_state = await subscription.async_unsubscribe_topics( + self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) @property def should_poll(self): @@ -150,7 +153,7 @@ class MqttLock(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @property def name(self): """Return the name of the lock.""" - return self._name + return self._config[CONF_NAME] @property def unique_id(self): @@ -173,8 +176,10 @@ class MqttLock(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_lock, self._qos, - self._retain) + self.hass, self._config[CONF_COMMAND_TOPIC], + self._config[CONF_PAYLOAD_LOCK], + self._config[CONF_QOS], + self._config[CONF_RETAIN]) if self._optimistic: # Optimistically assume that switch has changed state. self._state = True @@ -186,8 +191,10 @@ class MqttLock(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_unlock, self._qos, - self._retain) + self.hass, self._config[CONF_COMMAND_TOPIC], + self._config[CONF_PAYLOAD_UNLOCK], + self._config[CONF_QOS], + self._config[CONF_RETAIN]) if self._optimistic: # Optimistically assume that switch has changed state. self._state = False diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/mqtt/sensor.py similarity index 91% rename from homeassistant/components/sensor/mqtt.py rename to homeassistant/components/mqtt/sensor.py index 49d090f7e1e..688352b1ef6 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/mqtt/sensor.py @@ -14,10 +14,10 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components import sensor from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_PAYLOAD_AVAILABLE, - CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_STATE_TOPIC, MqttAttributes, + ATTR_DISCOVERY_HASH, CONF_QOS, CONF_STATE_TOPIC, MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA from homeassistant.const import ( CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, @@ -66,9 +66,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT sensors dynamically through MQTT discovery.""" async def async_discover_sensor(discovery_payload): """Discover and add a discovered MQTT sensor.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, - discovery_payload[ATTR_DISCOVERY_HASH]) + try: + discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise async_dispatcher_connect(hass, MQTT_DISCOVERY_NEW.format(sensor.DOMAIN, 'mqtt'), @@ -94,10 +100,6 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._expiration_trigger = None self._attributes = None - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) - payload_available = config.get(CONF_PAYLOAD_AVAILABLE) - payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) if config.get(CONF_JSON_ATTRS): @@ -105,8 +107,7 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'deprecated, replace with "json_attributes_topic"') MqttAttributes.__init__(self, config) - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) + MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/mqtt/switch.py similarity index 89% rename from homeassistant/components/switch/mqtt.py rename to homeassistant/components/mqtt/switch.py index 494156ea8de..bc8eac86a6d 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/mqtt/switch.py @@ -10,11 +10,11 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, - CONF_AVAILABILITY_TOPIC, CONF_PAYLOAD_AVAILABLE, - CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, MqttAvailability, - MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW + ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, + CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, subscription) +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) from homeassistant.components.switch import SwitchDevice from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF, @@ -61,9 +61,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT switch dynamically through MQTT discovery.""" async def async_discover(discovery_payload): """Discover and add a MQTT switch.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, - discovery_payload[ATTR_DISCOVERY_HASH]) + try: + discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(switch.DOMAIN, 'mqtt'), @@ -94,14 +100,9 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # Load config self._setup_from_config(config) - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) - payload_available = config.get(CONF_PAYLOAD_AVAILABLE) - payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) + MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) diff --git a/homeassistant/components/vacuum/mqtt.py b/homeassistant/components/mqtt/vacuum.py similarity index 63% rename from homeassistant/components/vacuum/mqtt.py rename to homeassistant/components/mqtt/vacuum.py index a017745a715..612737c990d 100644 --- a/homeassistant/components/vacuum/mqtt.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -9,15 +9,20 @@ import logging import voluptuous as vol from homeassistant.components import mqtt -from homeassistant.components.mqtt import MqttAvailability +from homeassistant.components.mqtt import ( + ATTR_DISCOVERY_HASH, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, subscription) +from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW from homeassistant.components.vacuum import ( SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - VacuumDevice) -from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME + VacuumDevice, DOMAIN) +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_DEVICE) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.icon import icon_for_battery_level _LOGGER = logging.getLogger(__name__) @@ -89,6 +94,7 @@ CONF_FAN_SPEED_TEMPLATE = 'fan_speed_template' CONF_SET_FAN_SPEED_TOPIC = 'set_fan_speed_topic' CONF_FAN_SPEED_LIST = 'fan_speed_list' CONF_SEND_COMMAND_TOPIC = 'send_command_topic' +CONF_UNIQUE_ID = 'unique_id' DEFAULT_NAME = 'MQTT Vacuum' DEFAULT_RETAIN = False @@ -139,137 +145,43 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_SEND_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the vacuum.""" - name = config.get(CONF_NAME) - supported_feature_strings = config.get(CONF_SUPPORTED_FEATURES) - supported_features = strings_to_services(supported_feature_strings) - - qos = config.get(mqtt.CONF_QOS) - retain = config.get(mqtt.CONF_RETAIN) - - command_topic = config.get(mqtt.CONF_COMMAND_TOPIC) - payload_turn_on = config.get(CONF_PAYLOAD_TURN_ON) - payload_turn_off = config.get(CONF_PAYLOAD_TURN_OFF) - payload_return_to_base = config.get(CONF_PAYLOAD_RETURN_TO_BASE) - payload_stop = config.get(CONF_PAYLOAD_STOP) - payload_clean_spot = config.get(CONF_PAYLOAD_CLEAN_SPOT) - payload_locate = config.get(CONF_PAYLOAD_LOCATE) - payload_start_pause = config.get(CONF_PAYLOAD_START_PAUSE) - - battery_level_topic = config.get(CONF_BATTERY_LEVEL_TOPIC) - battery_level_template = config.get(CONF_BATTERY_LEVEL_TEMPLATE) - if battery_level_template: - battery_level_template.hass = hass - - charging_topic = config.get(CONF_CHARGING_TOPIC) - charging_template = config.get(CONF_CHARGING_TEMPLATE) - if charging_template: - charging_template.hass = hass - - cleaning_topic = config.get(CONF_CLEANING_TOPIC) - cleaning_template = config.get(CONF_CLEANING_TEMPLATE) - if cleaning_template: - cleaning_template.hass = hass - - docked_topic = config.get(CONF_DOCKED_TOPIC) - docked_template = config.get(CONF_DOCKED_TEMPLATE) - if docked_template: - docked_template.hass = hass - - error_topic = config.get(CONF_ERROR_TOPIC) - error_template = config.get(CONF_ERROR_TEMPLATE) - if error_template: - error_template.hass = hass - - fan_speed_topic = config.get(CONF_FAN_SPEED_TOPIC) - fan_speed_template = config.get(CONF_FAN_SPEED_TEMPLATE) - if fan_speed_template: - fan_speed_template.hass = hass - - set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) - fan_speed_list = config.get(CONF_FAN_SPEED_LIST) - - send_command_topic = config.get(CONF_SEND_COMMAND_TOPIC) - - availability_topic = config.get(mqtt.CONF_AVAILABILITY_TOPIC) - payload_available = config.get(mqtt.CONF_PAYLOAD_AVAILABLE) - payload_not_available = config.get(mqtt.CONF_PAYLOAD_NOT_AVAILABLE) - - async_add_entities([ - MqttVacuum( - name, supported_features, qos, retain, command_topic, - payload_turn_on, payload_turn_off, payload_return_to_base, - payload_stop, payload_clean_spot, payload_locate, - payload_start_pause, battery_level_topic, battery_level_template, - charging_topic, charging_template, cleaning_topic, - cleaning_template, docked_topic, docked_template, - error_topic, error_template, fan_speed_topic, - fan_speed_template, set_fan_speed_topic, fan_speed_list, - send_command_topic, availability_topic, payload_available, - payload_not_available - ), - ]) + """Set up MQTT vacuum through configuration.yaml.""" + await _async_setup_entity(config, async_add_entities, + discovery_info) -class MqttVacuum(MqttAvailability, VacuumDevice): +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up MQTT vacuum dynamically through MQTT discovery.""" + async def async_discover(discovery_payload): + """Discover and add a MQTT vacuum.""" + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, + discovery_payload[ATTR_DISCOVERY_HASH]) + + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format(DOMAIN, 'mqtt'), async_discover) + + +async def _async_setup_entity(config, async_add_entities, + discovery_hash=None): + """Set up the MQTT vacuum.""" + async_add_entities([MqttVacuum(config, discovery_hash)]) + + +# pylint: disable=too-many-ancestors +class MqttVacuum(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, + VacuumDevice): """Representation of a MQTT-controlled vacuum.""" - def __init__( - self, name, supported_features, qos, retain, command_topic, - payload_turn_on, payload_turn_off, payload_return_to_base, - payload_stop, payload_clean_spot, payload_locate, - payload_start_pause, battery_level_topic, battery_level_template, - charging_topic, charging_template, cleaning_topic, - cleaning_template, docked_topic, docked_template, - error_topic, error_template, fan_speed_topic, - fan_speed_template, set_fan_speed_topic, fan_speed_list, - send_command_topic, availability_topic, payload_available, - payload_not_available): + def __init__(self, config, discovery_info): """Initialize the vacuum.""" - super().__init__(availability_topic, qos, payload_available, - payload_not_available) - - self._name = name - self._supported_features = supported_features - self._qos = qos - self._retain = retain - - self._command_topic = command_topic - self._payload_turn_on = payload_turn_on - self._payload_turn_off = payload_turn_off - self._payload_return_to_base = payload_return_to_base - self._payload_stop = payload_stop - self._payload_clean_spot = payload_clean_spot - self._payload_locate = payload_locate - self._payload_start_pause = payload_start_pause - - self._battery_level_topic = battery_level_topic - self._battery_level_template = battery_level_template - - self._charging_topic = charging_topic - self._charging_template = charging_template - - self._cleaning_topic = cleaning_topic - self._cleaning_template = cleaning_template - - self._docked_topic = docked_topic - self._docked_template = docked_template - - self._error_topic = error_topic - self._error_template = error_template - - self._fan_speed_topic = fan_speed_topic - self._fan_speed_template = fan_speed_template - - self._set_fan_speed_topic = set_fan_speed_topic - self._fan_speed_list = fan_speed_list - self._send_command_topic = send_command_topic - self._cleaning = False self._charging = False self._docked = False @@ -277,45 +189,128 @@ class MqttVacuum(MqttAvailability, VacuumDevice): self._status = 'Unknown' self._battery_level = 0 self._fan_speed = 'unknown' + self._fan_speed_list = [] + self._sub_state = None + self._unique_id = config.get(CONF_UNIQUE_ID) + + # Load config + self._setup_from_config(config) + + device_config = config.get(CONF_DEVICE) + + MqttAvailability.__init__(self, config) + MqttDiscoveryUpdate.__init__(self, discovery_info, + self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config) + + def _setup_from_config(self, config): + self._name = config.get(CONF_NAME) + supported_feature_strings = config.get(CONF_SUPPORTED_FEATURES) + self._supported_features = strings_to_services( + supported_feature_strings + ) + self._fan_speed_list = config.get(CONF_FAN_SPEED_LIST) + self._qos = config.get(mqtt.CONF_QOS) + self._retain = config.get(mqtt.CONF_RETAIN) + + self._command_topic = config.get(mqtt.CONF_COMMAND_TOPIC) + self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) + self._send_command_topic = config.get(CONF_SEND_COMMAND_TOPIC) + + self._payloads = { + key: config.get(key) for key in ( + CONF_PAYLOAD_TURN_ON, + CONF_PAYLOAD_TURN_OFF, + CONF_PAYLOAD_RETURN_TO_BASE, + CONF_PAYLOAD_STOP, + CONF_PAYLOAD_CLEAN_SPOT, + CONF_PAYLOAD_LOCATE, + CONF_PAYLOAD_START_PAUSE + ) + } + self._state_topics = { + key: config.get(key) for key in ( + CONF_BATTERY_LEVEL_TOPIC, + CONF_CHARGING_TOPIC, + CONF_CLEANING_TOPIC, + CONF_DOCKED_TOPIC, + CONF_ERROR_TOPIC, + CONF_FAN_SPEED_TOPIC + ) + } + self._templates = { + key: config.get(key) for key in ( + CONF_BATTERY_LEVEL_TEMPLATE, + CONF_CHARGING_TEMPLATE, + CONF_CLEANING_TEMPLATE, + CONF_DOCKED_TEMPLATE, + CONF_ERROR_TEMPLATE, + CONF_FAN_SPEED_TEMPLATE + ) + } + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() async def async_added_to_hass(self): """Subscribe MQTT events.""" await super().async_added_to_hass() + await self._subscribe_topics() + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + for tpl in self._templates.values(): + if tpl is not None: + tpl.hass = self.hass @callback def message_received(topic, payload, qos): """Handle new MQTT message.""" - if topic == self._battery_level_topic and \ - self._battery_level_template: - battery_level = self._battery_level_template\ + if topic == self._state_topics[CONF_BATTERY_LEVEL_TOPIC] and \ + self._templates[CONF_BATTERY_LEVEL_TEMPLATE]: + battery_level = self._templates[CONF_BATTERY_LEVEL_TEMPLATE]\ .async_render_with_possible_json_value( payload, error_value=None) if battery_level is not None: self._battery_level = int(battery_level) - if topic == self._charging_topic and self._charging_template: - charging = self._charging_template\ + if topic == self._state_topics[CONF_CHARGING_TOPIC] and \ + self._templates[CONF_CHARGING_TEMPLATE]: + charging = self._templates[CONF_CHARGING_TEMPLATE]\ .async_render_with_possible_json_value( payload, error_value=None) if charging is not None: self._charging = cv.boolean(charging) - if topic == self._cleaning_topic and self._cleaning_template: - cleaning = self._cleaning_template \ + if topic == self._state_topics[CONF_CLEANING_TOPIC] and \ + self._templates[CONF_CLEANING_TEMPLATE]: + cleaning = self._templates[CONF_CLEANING_TEMPLATE]\ .async_render_with_possible_json_value( payload, error_value=None) if cleaning is not None: self._cleaning = cv.boolean(cleaning) - if topic == self._docked_topic and self._docked_template: - docked = self._docked_template \ + if topic == self._state_topics[CONF_DOCKED_TOPIC] and \ + self._templates[CONF_DOCKED_TEMPLATE]: + docked = self._templates[CONF_DOCKED_TEMPLATE]\ .async_render_with_possible_json_value( payload, error_value=None) if docked is not None: self._docked = cv.boolean(docked) - if topic == self._error_topic and self._error_template: - error = self._error_template \ + if topic == self._state_topics[CONF_ERROR_TOPIC] and \ + self._templates[CONF_ERROR_TEMPLATE]: + error = self._templates[CONF_ERROR_TEMPLATE]\ .async_render_with_possible_json_value( payload, error_value=None) if error is not None: @@ -333,8 +328,9 @@ class MqttVacuum(MqttAvailability, VacuumDevice): else: self._status = "Stopped" - if topic == self._fan_speed_topic and self._fan_speed_template: - fan_speed = self._fan_speed_template\ + if topic == self._state_topics[CONF_FAN_SPEED_TOPIC] and \ + self._templates[CONF_FAN_SPEED_TEMPLATE]: + fan_speed = self._templates[CONF_FAN_SPEED_TEMPLATE]\ .async_render_with_possible_json_value( payload, error_value=None) if fan_speed is not None: @@ -342,14 +338,17 @@ class MqttVacuum(MqttAvailability, VacuumDevice): self.async_schedule_update_ha_state() - topics_list = [topic for topic in (self._battery_level_topic, - self._charging_topic, - self._cleaning_topic, - self._docked_topic, - self._fan_speed_topic) if topic] - for topic in set(topics_list): - await self.hass.components.mqtt.async_subscribe( - topic, message_received, self._qos) + topics_list = {topic for topic in self._state_topics.values() if topic} + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + { + "topic{}".format(i): { + "topic": topic, + "msg_callback": message_received, + "qos": self._qos + } for i, topic in enumerate(topics_list) + } + ) @property def name(self): @@ -366,6 +365,11 @@ class MqttVacuum(MqttAvailability, VacuumDevice): """Return true if vacuum is on.""" return self._cleaning + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + @property def status(self): """Return a status string for the vacuum.""" @@ -417,7 +421,8 @@ class MqttVacuum(MqttAvailability, VacuumDevice): return mqtt.async_publish(self.hass, self._command_topic, - self._payload_turn_on, self._qos, self._retain) + self._payloads[CONF_PAYLOAD_TURN_ON], + self._qos, self._retain) self._status = 'Cleaning' self.async_schedule_update_ha_state() @@ -427,7 +432,8 @@ class MqttVacuum(MqttAvailability, VacuumDevice): return mqtt.async_publish(self.hass, self._command_topic, - self._payload_turn_off, self._qos, self._retain) + self._payloads[CONF_PAYLOAD_TURN_OFF], + self._qos, self._retain) self._status = 'Turning Off' self.async_schedule_update_ha_state() @@ -436,7 +442,8 @@ class MqttVacuum(MqttAvailability, VacuumDevice): if self.supported_features & SUPPORT_STOP == 0: return - mqtt.async_publish(self.hass, self._command_topic, self._payload_stop, + mqtt.async_publish(self.hass, self._command_topic, + self._payloads[CONF_PAYLOAD_STOP], self._qos, self._retain) self._status = 'Stopping the current task' self.async_schedule_update_ha_state() @@ -447,7 +454,8 @@ class MqttVacuum(MqttAvailability, VacuumDevice): return mqtt.async_publish(self.hass, self._command_topic, - self._payload_clean_spot, self._qos, self._retain) + self._payloads[CONF_PAYLOAD_CLEAN_SPOT], + self._qos, self._retain) self._status = "Cleaning spot" self.async_schedule_update_ha_state() @@ -457,7 +465,8 @@ class MqttVacuum(MqttAvailability, VacuumDevice): return mqtt.async_publish(self.hass, self._command_topic, - self._payload_locate, self._qos, self._retain) + self._payloads[CONF_PAYLOAD_LOCATE], + self._qos, self._retain) self._status = "Hi, I'm over here!" self.async_schedule_update_ha_state() @@ -467,7 +476,8 @@ class MqttVacuum(MqttAvailability, VacuumDevice): return mqtt.async_publish(self.hass, self._command_topic, - self._payload_start_pause, self._qos, self._retain) + self._payloads[CONF_PAYLOAD_START_PAUSE], + self._qos, self._retain) self._status = 'Pausing/Resuming cleaning...' self.async_schedule_update_ha_state() @@ -477,8 +487,8 @@ class MqttVacuum(MqttAvailability, VacuumDevice): return mqtt.async_publish(self.hass, self._command_topic, - self._payload_return_to_base, self._qos, - self._retain) + self._payloads[CONF_PAYLOAD_RETURN_TO_BASE], + self._qos, self._retain) self._status = 'Returning home...' self.async_schedule_update_ha_state() @@ -489,9 +499,8 @@ class MqttVacuum(MqttAvailability, VacuumDevice): if not self._fan_speed_list or fan_speed not in self._fan_speed_list: return - mqtt.async_publish( - self.hass, self._set_fan_speed_topic, fan_speed, self._qos, - self._retain) + mqtt.async_publish(self.hass, self._set_fan_speed_topic, + fan_speed, self._qos, self._retain) self._status = "Setting fan to {}...".format(fan_speed) self.async_schedule_update_ha_state() @@ -500,8 +509,7 @@ class MqttVacuum(MqttAvailability, VacuumDevice): if self.supported_features & SUPPORT_SEND_COMMAND == 0: return - mqtt.async_publish( - self.hass, self._send_command_topic, command, self._qos, - self._retain) + mqtt.async_publish(self.hass, self._send_command_topic, + command, self._qos, self._retain) self._status = "Sending command {}...".format(command) self.async_schedule_update_ha_state() diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index ccb54bf647f..fbe3621a4af 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -23,6 +23,7 @@ SCHEMA = 'schema' CHILD_CALLBACK = 'mysensors_child_callback_{}_{}_{}_{}' NODE_CALLBACK = 'mysensors_node_callback_{}_{}' TYPE = 'type' +UPDATE_DELAY = 0.1 # MySensors const schemas BINARY_SENSOR_SCHEMA = {PLATFORM: 'binary_sensor', TYPE: 'V_TRIPPED'} diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 07261b1c2a6..54333d8699b 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -1,5 +1,6 @@ """Handle MySensors devices.""" import logging +from functools import partial from homeassistant.const import ( ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON) @@ -7,7 +8,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import CHILD_CALLBACK, NODE_CALLBACK +from .const import CHILD_CALLBACK, NODE_CALLBACK, UPDATE_DELAY _LOGGER = logging.getLogger(__name__) @@ -39,6 +40,8 @@ class MySensorsDevice: child = gateway.sensors[node_id].children[child_id] self.child_type = child.type self._values = {} + self._update_scheduled = False + self.hass = None @property def name(self): @@ -84,6 +87,29 @@ class MySensorsDevice: else: self._values[value_type] = value + async def _async_update_callback(self): + """Update the device.""" + raise NotImplementedError + + @callback + def async_update_callback(self): + """Update the device after delay.""" + if self._update_scheduled: + return + + async def update(): + """Perform update.""" + try: + await self._async_update_callback() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error updating %s", self.name) + finally: + self._update_scheduled = False + + self._update_scheduled = True + delayed_update = partial(self.hass.async_create_task, update()) + self.hass.loop.call_later(UPDATE_DELAY, delayed_update) + class MySensorsEntity(MySensorsDevice, Entity): """Representation of a MySensors entity.""" @@ -98,10 +124,9 @@ class MySensorsEntity(MySensorsDevice, Entity): """Return true if entity is available.""" return self.value_type in self._values - @callback - def async_update_callback(self): + async def _async_update_callback(self): """Update the entity.""" - self.async_schedule_update_ha_state(True) + await self.async_update_ha_state(True) async def async_added_to_hass(self): """Register update callback.""" diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py index 39af1173706..886660baffe 100644 --- a/homeassistant/components/mysensors/handler.py +++ b/homeassistant/components/mysensors/handler.py @@ -99,7 +99,6 @@ def _handle_child_update(hass, hass_config, msg): for signal in set(signals): # Only one signal per device is needed. # A device can have multiple platforms, ie multiple schemas. - # FOR LATER: Add timer to not signal if another update comes in. async_dispatcher_send(hass, signal) diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index 5da70bfdcd2..31410d1c9b2 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -17,7 +17,7 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pybotvac==0.0.12'] +REQUIREMENTS = ['pybotvac==0.0.13'] DOMAIN = 'neato' NEATO_ROBOTS = 'neato_robots' diff --git a/homeassistant/components/nest/.translations/et.json b/homeassistant/components/nest/.translations/et.json new file mode 100644 index 00000000000..4e8c0b23bdc --- /dev/null +++ b/homeassistant/components/nest/.translations/et.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_code": "Kehtetu kood" + }, + "step": { + "link": { + "data": { + "code": "PIN-kood" + } + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/fr.json b/homeassistant/components/nest/.translations/fr.json index 822ec6ae836..3cd7b003f66 100644 --- a/homeassistant/components/nest/.translations/fr.json +++ b/homeassistant/components/nest/.translations/fr.json @@ -24,7 +24,7 @@ "data": { "code": "Code PIN" }, - "description": "Pour associer votre compte Nest, [autorisez votre compte] ( {url} ). \n\n Apr\u00e8s autorisation, copiez-collez le code PIN fourni ci-dessous.", + "description": "Pour associer votre compte Nest, [autorisez votre compte]({url}). \n\n Apr\u00e8s autorisation, copiez-collez le code PIN fourni ci-dessous.", "title": "Lier un compte Nest" } }, diff --git a/homeassistant/components/nest/.translations/ru.json b/homeassistant/components/nest/.translations/ru.json index 6a73bd47203..54ff1dff999 100644 --- a/homeassistant/components/nest/.translations/ru.json +++ b/homeassistant/components/nest/.translations/ru.json @@ -24,7 +24,7 @@ "data": { "code": "\u041f\u0438\u043d-\u043a\u043e\u0434" }, - "description": " [\u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0443\u0439\u0442\u0435\u0441\u044c]({url}), \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0441\u0432\u043e\u044e \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Nest. \n\n \u041f\u043e\u0441\u043b\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0441\u043a\u043e\u043f\u0438\u0440\u0443\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u0430\u0433\u0430\u0435\u043c\u044b\u0439 \u043f\u0438\u043d-\u043a\u043e\u0434.", + "description": "[\u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0443\u0439\u0442\u0435\u0441\u044c]({url}), \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0441\u0432\u043e\u044e \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Nest. \n\n \u041f\u043e\u0441\u043b\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0441\u043a\u043e\u043f\u0438\u0440\u0443\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u0430\u0433\u0430\u0435\u043c\u044b\u0439 \u043f\u0438\u043d-\u043a\u043e\u0434.", "title": "\u041f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Nest" } }, diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 771606b935f..6a486bb6362 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/notify.html5/ import datetime import json import logging +from functools import partial import time import uuid @@ -20,7 +21,7 @@ from homeassistant.components.frontend import add_manifest_json_key from homeassistant.components.http import HomeAssistantView from homeassistant.components.notify import ( ATTR_DATA, ATTR_TITLE, ATTR_TARGET, PLATFORM_SCHEMA, ATTR_TITLE_DEFAULT, - BaseNotificationService) + BaseNotificationService, DOMAIN) from homeassistant.const import ( URL_ROOT, HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED, HTTP_INTERNAL_SERVER_ERROR) from homeassistant.helpers import config_validation as cv @@ -34,6 +35,8 @@ _LOGGER = logging.getLogger(__name__) REGISTRATIONS_FILE = 'html5_push_registrations.conf' +SERVICE_DISMISS = 'html5_dismiss' + ATTR_GCM_SENDER_ID = 'gcm_sender_id' ATTR_GCM_API_KEY = 'gcm_api_key' @@ -44,6 +47,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ ATTR_SUBSCRIPTION = 'subscription' ATTR_BROWSER = 'browser' +ATTR_NAME = 'name' ATTR_ENDPOINT = 'endpoint' ATTR_KEYS = 'keys' @@ -56,6 +60,7 @@ ATTR_ACTION = 'action' ATTR_ACTIONS = 'actions' ATTR_TYPE = 'type' ATTR_URL = 'url' +ATTR_DISMISS = 'dismiss' ATTR_JWT = 'jwt' @@ -79,9 +84,15 @@ SUBSCRIPTION_SCHEMA = vol.All( }) ) +DISMISS_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_DATA): dict, +}) + REGISTER_SCHEMA = vol.Schema({ vol.Required(ATTR_SUBSCRIPTION): SUBSCRIPTION_SCHEMA, vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox']), + vol.Optional(ATTR_NAME): cv.string }) CALLBACK_EVENT_PAYLOAD_SCHEMA = vol.Schema({ @@ -120,7 +131,8 @@ def get_service(hass, config, discovery_info=None): add_manifest_json_key( ATTR_GCM_SENDER_ID, config.get(ATTR_GCM_SENDER_ID)) - return HTML5NotificationService(gcm_api_key, registrations, json_path) + return HTML5NotificationService( + hass, gcm_api_key, registrations, json_path) def _load_config(filename): @@ -156,7 +168,10 @@ class HTML5PushRegistrationView(HomeAssistantView): return self.json_message( humanize_error(data, ex), HTTP_BAD_REQUEST) - name = self.find_registration_name(data) + devname = data.get(ATTR_NAME) + data.pop(ATTR_NAME, None) + + name = self.find_registration_name(data, devname) previous_registration = self.registrations.get(name) self.registrations[name] = data @@ -177,14 +192,15 @@ class HTML5PushRegistrationView(HomeAssistantView): return self.json_message( 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR) - def find_registration_name(self, data): + def find_registration_name(self, data, suggested=None): """Find a registration name matching data or generate a unique one.""" endpoint = data.get(ATTR_SUBSCRIPTION).get(ATTR_ENDPOINT) for key, registration in self.registrations.items(): subscription = registration.get(ATTR_SUBSCRIPTION) if subscription.get(ATTR_ENDPOINT) == endpoint: return key - return ensure_unique_string('unnamed device', self.registrations) + return ensure_unique_string(suggested or 'unnamed device', + self.registrations) async def delete(self, request): """Delete a registration.""" @@ -320,12 +336,29 @@ class HTML5PushCallbackView(HomeAssistantView): class HTML5NotificationService(BaseNotificationService): """Implement the notification service for HTML5.""" - def __init__(self, gcm_key, registrations, json_path): + def __init__(self, hass, gcm_key, registrations, json_path): """Initialize the service.""" self._gcm_key = gcm_key self.registrations = registrations self.registrations_json_path = json_path + async def async_dismiss_message(service): + """Handle dismissing notification message service calls.""" + kwargs = {} + + if self.targets is not None: + kwargs[ATTR_TARGET] = self.targets + elif service.data.get(ATTR_TARGET) is not None: + kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET) + + kwargs[ATTR_DATA] = service.data.get(ATTR_DATA) + + await self.async_dismiss(**kwargs) + + hass.services.async_register( + DOMAIN, SERVICE_DISMISS, async_dismiss_message, + schema=DISMISS_SERVICE_SCHEMA) + @property def targets(self): """Return a dictionary of registered targets.""" @@ -334,12 +367,28 @@ class HTML5NotificationService(BaseNotificationService): targets[registration] = registration return targets + def dismiss(self, **kwargs): + """Dismisses a notification.""" + data = kwargs.get(ATTR_DATA) + tag = data.get(ATTR_TAG) if data else "" + payload = { + ATTR_TAG: tag, + ATTR_DISMISS: True, + ATTR_DATA: {} + } + + self._push_message(payload, **kwargs) + + async def async_dismiss(self, **kwargs): + """Dismisses a notification. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job( + partial(self.dismiss, **kwargs)) + def send_message(self, message="", **kwargs): """Send a message to a user.""" - import jwt - from pywebpush import WebPusher - - timestamp = int(time.time()) tag = str(uuid.uuid4()) payload = { @@ -348,7 +397,6 @@ class HTML5NotificationService(BaseNotificationService): ATTR_DATA: {}, 'icon': '/static/icons/favicon-192x192.png', ATTR_TAG: tag, - 'timestamp': (timestamp*1000), # Javascript ms since epoch ATTR_TITLE: kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) } @@ -372,6 +420,17 @@ class HTML5NotificationService(BaseNotificationService): payload.get(ATTR_ACTIONS) is None): payload[ATTR_DATA][ATTR_URL] = URL_ROOT + self._push_message(payload, **kwargs) + + def _push_message(self, payload, **kwargs): + """Send the message.""" + import jwt + from pywebpush import WebPusher + + timestamp = int(time.time()) + + payload['timestamp'] = (timestamp*1000) # Javascript ms since epoch + targets = kwargs.get(ATTR_TARGET) if not targets: diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml index 23b1c968c4a..1b7944cc7da 100644 --- a/homeassistant/components/notify/services.yaml +++ b/homeassistant/components/notify/services.yaml @@ -16,6 +16,16 @@ notify: description: Extended information for notification. Optional depending on the platform. example: platform specific +html5_dismiss: + description: Dismiss a html5 notification. + fields: + target: + description: An array of targets. Optional. + example: ['my_phone', 'my_tablet'] + data: + description: Extended information of notification. Supports tag. Optional. + example: '{ "tag": "tagname" }' + apns_register: description: Registers a device to receive push notifications. fields: diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index b626e9a93b5..853ee67db9d 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -92,6 +92,10 @@ def setup(hass, config): discovery.listen(hass, SERVICE_OCTOPRINT, device_discovered) + if DOMAIN not in config: + # Skip the setup if there is no configuration present + return True + for printer in config[DOMAIN]: name = printer[CONF_NAME] ssl = 's' if printer[CONF_SSL] else '' diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index bc29910a196..32c3da0d3e5 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -5,7 +5,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/openuv/ """ import logging -from datetime import timedelta import voluptuous as vol @@ -13,15 +12,14 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, CONF_BINARY_SENSORS, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, - CONF_SCAN_INTERVAL, CONF_SENSORS) + CONF_SENSORS) from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_time_interval from .config_flow import configured_instances -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DOMAIN REQUIREMENTS = ['pyopenuv==1.0.4'] _LOGGER = logging.getLogger(__name__) @@ -93,8 +91,6 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA, vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): - cv.time_period, }) }, extra=vol.ALLOW_EXTRA) @@ -120,7 +116,6 @@ async def async_setup(hass, config): CONF_API_KEY: conf[CONF_API_KEY], CONF_BINARY_SENSORS: conf[CONF_BINARY_SENSORS], CONF_SENSORS: conf[CONF_SENSORS], - CONF_SCAN_INTERVAL: conf[CONF_SCAN_INTERVAL], } if CONF_LATITUDE in conf: @@ -167,17 +162,13 @@ async def async_setup_entry(hass, config_entry): hass.config_entries.async_forward_entry_setup( config_entry, component)) - async def refresh(event_time): + async def update_data(service): """Refresh OpenUV data.""" _LOGGER.debug('Refreshing OpenUV data') await openuv.async_update() async_dispatcher_send(hass, TOPIC_UPDATE) - hass.data[DOMAIN][DATA_OPENUV_LISTENER][ - config_entry.entry_id] = async_track_time_interval( - hass, - refresh, - timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL])) + hass.services.async_register(DOMAIN, 'update_data', update_data) return True @@ -186,10 +177,6 @@ async def async_unload_entry(hass, config_entry): """Unload an OpenUV config entry.""" hass.data[DOMAIN][DATA_OPENUV_CLIENT].pop(config_entry.entry_id) - remove_listener = hass.data[DOMAIN][DATA_OPENUV_LISTENER].pop( - config_entry.entry_id) - remove_listener() - for component in ('binary_sensor', 'sensor'): await hass.config_entries.async_forward_entry_unload( config_entry, component) diff --git a/homeassistant/components/binary_sensor/openuv.py b/homeassistant/components/openuv/binary_sensor.py similarity index 100% rename from homeassistant/components/binary_sensor/openuv.py rename to homeassistant/components/openuv/binary_sensor.py diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index 11301baf5c5..0f566e5a9ef 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -5,11 +5,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import ( - CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, - CONF_SCAN_INTERVAL) + CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.helpers import aiohttp_client, config_validation as cv -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DOMAIN @callback @@ -54,7 +53,8 @@ class OpenUvFlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" - from pyopenuv.util import validate_api_key + from pyopenuv import Client + from pyopenuv.errors import OpenUvError if not user_input: return await self._show_form() @@ -66,14 +66,11 @@ class OpenUvFlowHandler(config_entries.ConfigFlow): return await self._show_form({CONF_LATITUDE: 'identifier_exists'}) websession = aiohttp_client.async_get_clientsession(self.hass) - api_key_validation = await validate_api_key( - user_input[CONF_API_KEY], websession) + client = Client(user_input[CONF_API_KEY], 0, 0, websession) - if not api_key_validation: + try: + await client.uv_index() + except OpenUvError: return await self._show_form({CONF_API_KEY: 'invalid_api_key'}) - scan_interval = user_input.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds - return self.async_create_entry(title=identifier, data=user_input) diff --git a/homeassistant/components/openuv/const.py b/homeassistant/components/openuv/const.py index 16623e45642..a8c7fcc0ef5 100644 --- a/homeassistant/components/openuv/const.py +++ b/homeassistant/components/openuv/const.py @@ -1,6 +1,2 @@ """Define constants for the OpenUV component.""" -from datetime import timedelta - DOMAIN = 'openuv' - -DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) diff --git a/homeassistant/components/sensor/openuv.py b/homeassistant/components/openuv/sensor.py similarity index 100% rename from homeassistant/components/sensor/openuv.py rename to homeassistant/components/openuv/sensor.py diff --git a/homeassistant/components/openuv/services.yaml b/homeassistant/components/openuv/services.yaml new file mode 100644 index 00000000000..f353c7f4774 --- /dev/null +++ b/homeassistant/components/openuv/services.yaml @@ -0,0 +1,5 @@ +# Describes the format for available OpenUV services + +--- +update_data: + description: Request new data from OpenUV. diff --git a/homeassistant/components/owntracks/.translations/de.json b/homeassistant/components/owntracks/.translations/de.json new file mode 100644 index 00000000000..fbd9cec2f5a --- /dev/null +++ b/homeassistant/components/owntracks/.translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Nur eine einzige Instanz ist notwendig." + }, + "create_entry": { + "default": "\n\n\u00d6ffnen Sie unter Android [die OwnTracks-App]({android_url}) und gehen Sie zu {android_url} - > Verbindung. \u00c4ndern Sie die folgenden Einstellungen: \n - Modus: Privates HTTP \n - Host: {webhook_url} \n - Identifizierung: \n - Benutzername: ` ` \n - Ger\u00e4te-ID: ` ` \n\n\u00d6ffnen Sie unter iOS [die OwnTracks-App]({ios_url}) und tippen Sie auf das Symbol (i) oben links - > Einstellungen. \u00c4ndern Sie die folgenden Einstellungen: \n - Modus: HTTP \n - URL: {webhook_url} \n - Aktivieren Sie die Authentifizierung \n - UserID: ` ` \n\n {secret} \n \n Weitere Informationen finden Sie in der [Dokumentation]({docs_url})." + }, + "step": { + "user": { + "description": "M\u00f6chten Sie OwnTracks wirklich einrichten?", + "title": "OwnTracks einrichten" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/nl.json b/homeassistant/components/owntracks/.translations/nl.json new file mode 100644 index 00000000000..21ee65a775a --- /dev/null +++ b/homeassistant/components/owntracks/.translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." + }, + "create_entry": { + "default": "\n\nOp Android, open [the OwnTracks app]({android_url}), ga naar 'preferences' -> 'connection'. Verander de volgende instellingen:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOp iOS, open [the OwnTracks app]({ios_url}), tik op het (i) icoon links boven -> 'settings'. Verander de volgende instellingen:\n - Mode: HTTP\n - URL: {webhook_url}\n - zet 'authentication' aan\n - UserID: ``\n\n{secret}\n\nZie [the documentation]({docs_url}) voor meer informatie." + }, + "step": { + "user": { + "description": "Weet je zeker dat je OwnTracks wilt instellen?", + "title": "Stel OwnTracks in" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/uk.json b/homeassistant/components/owntracks/.translations/uk.json new file mode 100644 index 00000000000..8f4cdebbcd4 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/uk.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "\u041f\u043e\u0442\u0440\u0456\u0431\u043d\u043e \u043b\u0438\u0448\u0435 \u043e\u0434\u0438\u043d \u0435\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440." + }, + "step": { + "user": { + "description": "\u0412\u0438 \u0432\u043f\u0435\u0432\u043d\u0435\u043d\u0456, \u0449\u043e \u0445\u043e\u0447\u0435\u0442\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u0442\u0438 OwnTracks?", + "title": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f OwnTracks" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/panel_iframe.py b/homeassistant/components/panel_iframe.py index 86594b74995..030fbbf9324 100644 --- a/homeassistant/components/panel_iframe.py +++ b/homeassistant/components/panel_iframe.py @@ -19,8 +19,8 @@ CONF_RELATIVE_URL_ERROR_MSG = "Invalid relative URL. Absolute path required." CONF_RELATIVE_URL_REGEX = r'\A/' CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: { + DOMAIN: cv.schema_with_slug_keys( + vol.Schema({ # pylint: disable=no-value-for-parameter vol.Optional(CONF_TITLE): cv.string, vol.Optional(CONF_ICON): cv.icon, @@ -29,7 +29,9 @@ CONFIG_SCHEMA = vol.Schema({ CONF_RELATIVE_URL_REGEX, msg=CONF_RELATIVE_URL_ERROR_MSG), vol.Url()), - }})}, extra=vol.ALLOW_EXTRA) + }) + ) +}, extra=vol.ALLOW_EXTRA) async def async_setup(hass, config): diff --git a/homeassistant/components/plant.py b/homeassistant/components/plant.py index cdd3c8463aa..55793558fd9 100644 --- a/homeassistant/components/plant.py +++ b/homeassistant/components/plant.py @@ -56,6 +56,13 @@ CONF_SENSOR_CONDUCTIVITY = READING_CONDUCTIVITY CONF_SENSOR_TEMPERATURE = READING_TEMPERATURE CONF_SENSOR_BRIGHTNESS = READING_BRIGHTNESS +DEFAULT_MIN_BATTERY_LEVEL = 20 +DEFAULT_MIN_MOISTURE = 20 +DEFAULT_MAX_MOISTURE = 60 +DEFAULT_MIN_CONDUCTIVITY = 500 +DEFAULT_MAX_CONDUCTIVITY = 3000 +DEFAULT_CHECK_DAYS = 3 + SCHEMA_SENSORS = vol.Schema({ vol.Optional(CONF_SENSOR_BATTERY_LEVEL): cv.entity_id, vol.Optional(CONF_SENSOR_MOISTURE): cv.entity_id, @@ -66,16 +73,22 @@ SCHEMA_SENSORS = vol.Schema({ PLANT_SCHEMA = vol.Schema({ vol.Required(CONF_SENSORS): vol.Schema(SCHEMA_SENSORS), - vol.Optional(CONF_MIN_BATTERY_LEVEL): cv.positive_int, + vol.Optional(CONF_MIN_BATTERY_LEVEL, + default=DEFAULT_MIN_BATTERY_LEVEL): cv.positive_int, vol.Optional(CONF_MIN_TEMPERATURE): vol.Coerce(float), vol.Optional(CONF_MAX_TEMPERATURE): vol.Coerce(float), - vol.Optional(CONF_MIN_MOISTURE): cv.positive_int, - vol.Optional(CONF_MAX_MOISTURE): cv.positive_int, - vol.Optional(CONF_MIN_CONDUCTIVITY): cv.positive_int, - vol.Optional(CONF_MAX_CONDUCTIVITY): cv.positive_int, + vol.Optional(CONF_MIN_MOISTURE, + default=DEFAULT_MIN_MOISTURE): cv.positive_int, + vol.Optional(CONF_MAX_MOISTURE, + default=DEFAULT_MAX_MOISTURE): cv.positive_int, + vol.Optional(CONF_MIN_CONDUCTIVITY, + default=DEFAULT_MIN_CONDUCTIVITY): cv.positive_int, + vol.Optional(CONF_MAX_CONDUCTIVITY, + default=DEFAULT_MAX_CONDUCTIVITY): cv.positive_int, vol.Optional(CONF_MIN_BRIGHTNESS): cv.positive_int, vol.Optional(CONF_MAX_BRIGHTNESS): cv.positive_int, - vol.Optional(CONF_CHECK_DAYS): cv.positive_int, + vol.Optional(CONF_CHECK_DAYS, + default=DEFAULT_CHECK_DAYS): cv.positive_int, }) DOMAIN = 'plant' diff --git a/homeassistant/components/point/.translations/de.json b/homeassistant/components/point/.translations/de.json new file mode 100644 index 00000000000..fe3b781bfac --- /dev/null +++ b/homeassistant/components/point/.translations/de.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Sie k\u00f6nnen nur ein Point-Konto konfigurieren.", + "authorize_url_fail": "Unbekannter Fehler beim Erstellen der Authorisierungs-URL.", + "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", + "external_setup": "Pointt erfolgreich von einem anderen Flow konfiguriert.", + "no_flows": "Sie m\u00fcssen Point konfigurieren, bevor Sie sich damit authentifizieren k\u00f6nnen. [Bitte lesen Sie die Anweisungen] (https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Mit Minut erfolgreich f\u00fcr Ihre Point-Ger\u00e4te authentifiziert" + }, + "error": { + "follow_link": "Bitte folgen Sie dem Link und authentifizieren Sie sich, bevor Sie auf Senden klicken", + "no_token": "Nicht mit Minut authentifiziert" + }, + "step": { + "auth": { + "description": "Folgen Sie dem Link unten und Akzeptieren Zugriff auf Ihr Minut-Konto. Kehren Sie dann zur\u00fcck und dr\u00fccken Sie unten auf Senden . \n\n [Link]({authorization_url})", + "title": "Point authentifizieren" + }, + "user": { + "data": { + "flow_impl": "Anbieter" + }, + "description": "W\u00e4hlen Sie \u00fcber welchen Authentifizierungsanbieter Sie sich mit Point authentifizieren m\u00f6chten.", + "title": "Authentifizierungsanbieter" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/nl.json b/homeassistant/components/point/.translations/nl.json index ff7f2cdcd56..50d9c7d45bb 100644 --- a/homeassistant/components/point/.translations/nl.json +++ b/homeassistant/components/point/.translations/nl.json @@ -1,12 +1,31 @@ { "config": { + "abort": { + "already_setup": "U kunt alleen een Point-account configureren.", + "authorize_url_fail": "Onbekende fout bij het genereren van een autoriseer-URL.", + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "external_setup": "Punt succesvol geconfigureerd vanuit een andere stroom.", + "no_flows": "U moet Point configureren voordat u zich ermee kunt verifi\u00ebren. [Gelieve de instructies te lezen](https://www.home-assistant.io/components/nest/)." + }, + "create_entry": { + "default": "Succesvol geverifieerd met Minut voor uw Point appara(a)t(en)" + }, + "error": { + "follow_link": "Volg de link en verifieer voordat je op Verzenden klikt", + "no_token": "Niet geverifieerd met Minut" + }, "step": { + "auth": { + "description": "Ga naar onderstaande link en Accepteer toegang tot je Minut account, kom dan hier terug en klik op Verzenden hier onder.\n\n[Link]({authorization_url})", + "title": "Verificatie Point" + }, "user": { "data": { "flow_impl": "Leverancier" }, "title": "Authenticatieleverancier" } - } + }, + "title": "Minut Point" } } \ No newline at end of file diff --git a/homeassistant/components/point/.translations/pt-BR.json b/homeassistant/components/point/.translations/pt-BR.json new file mode 100644 index 00000000000..f6f281ec9f7 --- /dev/null +++ b/homeassistant/components/point/.translations/pt-BR.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_setup": "Voc\u00ea s\u00f3 pode configurar uma conta Point.", + "authorize_url_fail": "Erro desconhecido ao gerar URL de autoriza\u00e7\u00e3o.", + "authorize_url_timeout": "Excedido tempo limite gerando a URL de autoriza\u00e7\u00e3o.", + "external_setup": "Point configurado com \u00eaxito a partir de outro fluxo.", + "no_flows": "Voc\u00ea precisa configurar o Point antes de ser capaz de autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Autenticado com sucesso com Minut para seu(s) dispositivo(s) Point" + }, + "error": { + "follow_link": "Por favor, siga o link e autentique antes de pressionar Enviar", + "no_token": "N\u00e3o autenticado com Minut" + }, + "step": { + "auth": { + "description": "Siga o link abaixo e Aceite o acesso \u00e0 sua conta Minut, depois volte e pressione Enviar. \n\n [Link]({authorization_url})" + }, + "user": { + "data": { + "flow_impl": "Provedor" + }, + "description": "Escolha atrav\u00e9s de qual provedor de autentica\u00e7\u00e3o voc\u00ea deseja autenticar com Point.", + "title": "Provedor de Autentica\u00e7\u00e3o" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/pt.json b/homeassistant/components/point/.translations/pt.json index 8831696fcff..6d24d56233c 100644 --- a/homeassistant/components/point/.translations/pt.json +++ b/homeassistant/components/point/.translations/pt.json @@ -1,12 +1,32 @@ { "config": { + "abort": { + "already_setup": "S\u00f3 pode configurar uma \u00fanica conta Point.", + "authorize_url_fail": "Erro desconhecido ao gerar um URL de autoriza\u00e7\u00e3o.", + "authorize_url_timeout": "Limite temporal ultrapassado ao gerar um URL de autoriza\u00e7\u00e3o.", + "external_setup": "Point configurado com \u00eaxito a partir de outro fluxo.", + "no_flows": "\u00c9 necess\u00e1rio configurar o Point antes de poder autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es] (https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Autenticado com sucesso com Minut para o(s) seu(s) dispositivo (s) Point" + }, "error": { - "follow_link": "Por favor, siga o link e autentique antes de pressionar Enviar" + "follow_link": "Por favor, siga o link e autentique antes de pressionar Enviar", + "no_token": "N\u00e3o autenticado com Minut" }, "step": { + "auth": { + "description": "Siga o link abaixo e Aceite o acesso \u00e0 sua conta Minut, depois volte e pressione Enviar abaixo. \n\n [Link] ( {authorization_url} )", + "title": "Autenticar Point" + }, "user": { + "data": { + "flow_impl": "Fornecedor" + }, + "description": "Escolha com qual fornecedor de autentica\u00e7\u00e3o deseja autenticar o Point.", "title": "Fornecedor de Autentica\u00e7\u00e3o" } - } + }, + "title": "" } } \ No newline at end of file diff --git a/homeassistant/components/proximity.py b/homeassistant/components/proximity.py index 38b37cad51e..e8d86d480e5 100644 --- a/homeassistant/components/proximity.py +++ b/homeassistant/components/proximity.py @@ -49,9 +49,7 @@ ZONE_SCHEMA = vol.Schema({ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: ZONE_SCHEMA, - }), + DOMAIN: cv.schema_with_slug_keys(ZONE_SCHEMA), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/rainmachine/.translations/hu.json b/homeassistant/components/rainmachine/.translations/hu.json index 2fbb55b2833..ff98eccbe5a 100644 --- a/homeassistant/components/rainmachine/.translations/hu.json +++ b/homeassistant/components/rainmachine/.translations/hu.json @@ -6,7 +6,9 @@ "step": { "user": { "data": { - "password": "Jelsz\u00f3" + "ip_address": "Kiszolg\u00e1l\u00f3 neve vagy IP c\u00edme", + "password": "Jelsz\u00f3", + "port": "Port" } } }, diff --git a/homeassistant/components/rainmachine/.translations/pt-BR.json b/homeassistant/components/rainmachine/.translations/pt-BR.json new file mode 100644 index 00000000000..8fdf05bd3c6 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/pt-BR.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Conta j\u00e1 cadastrada", + "invalid_credentials": "Credenciais inv\u00e1lidas" + }, + "step": { + "user": { + "data": { + "ip_address": "Nome do host ou endere\u00e7o IP", + "password": "Senha", + "port": "Porta" + }, + "title": "Preencha suas informa\u00e7\u00f5es" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/pt.json b/homeassistant/components/rainmachine/.translations/pt.json index 20f963d9dfb..97b9f84f26c 100644 --- a/homeassistant/components/rainmachine/.translations/pt.json +++ b/homeassistant/components/rainmachine/.translations/pt.json @@ -6,8 +6,14 @@ }, "step": { "user": { + "data": { + "ip_address": "Nome servidor ou endere\u00e7o IP", + "password": "Palavra-passe", + "port": "Porta" + }, "title": "Preencha as suas informa\u00e7\u00f5es" } - } + }, + "title": "" } } \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/rainmachine.py b/homeassistant/components/rainmachine/binary_sensor.py similarity index 100% rename from homeassistant/components/binary_sensor/rainmachine.py rename to homeassistant/components/rainmachine/binary_sensor.py diff --git a/homeassistant/components/sensor/rainmachine.py b/homeassistant/components/rainmachine/sensor.py similarity index 100% rename from homeassistant/components/sensor/rainmachine.py rename to homeassistant/components/rainmachine/sensor.py diff --git a/homeassistant/components/switch/rainmachine.py b/homeassistant/components/rainmachine/switch.py similarity index 100% rename from homeassistant/components/switch/rainmachine.py rename to homeassistant/components/rainmachine/switch.py diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index 4df471aa918..a5e4f5a8528 100644 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -22,10 +22,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import PlatformNotReady from homeassistant.util import slugify -REQUIREMENTS = ['aioharmony==0.1.2'] +REQUIREMENTS = ['aioharmony==0.1.5'] _LOGGER = logging.getLogger(__name__) +ATTR_CHANNEL = 'channel' ATTR_CURRENT_ACTIVITY = 'current_activity' DEFAULT_PORT = 8088 @@ -33,6 +34,7 @@ DEVICES = [] CONF_DEVICE_CACHE = 'harmony_device_cache' SERVICE_SYNC = 'harmony_sync' +SERVICE_CHANGE_CHANNEL = 'harmony_change_channel' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(ATTR_ACTIVITY): cv.string, @@ -47,6 +49,11 @@ HARMONY_SYNC_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) +HARMONY_CHANGE_CHANNEL_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_CHANNEL): cv.positive_int +}) + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -99,6 +106,9 @@ async def async_setup_platform(hass, config, async_add_entities, try: device = HarmonyRemote( name, address, port, activity, harmony_conf_file, delay_secs) + if not await device.connect(): + raise PlatformNotReady + DEVICES.append(device) async_add_entities([device]) register_services(hass) @@ -112,6 +122,10 @@ def register_services(hass): DOMAIN, SERVICE_SYNC, _sync_service, schema=HARMONY_SYNC_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_CHANGE_CHANNEL, _change_channel_service, + schema=HARMONY_CHANGE_CHANNEL_SCHEMA) + async def _apply_service(service, service_func, *service_func_args): """Handle services to apply.""" @@ -131,38 +145,46 @@ async def _sync_service(service): await _apply_service(service, HarmonyRemote.sync) +async def _change_channel_service(service): + channel = service.data.get(ATTR_CHANNEL) + await _apply_service(service, HarmonyRemote.change_channel, channel) + + class HarmonyRemote(remote.RemoteDevice): """Remote representation used to control a Harmony device.""" def __init__(self, name, host, port, activity, out_path, delay_secs): """Initialize HarmonyRemote class.""" - from aioharmony.harmonyapi import ( - HarmonyAPI as HarmonyClient, ClientCallbackType - ) + from aioharmony.harmonyapi import HarmonyAPI as HarmonyClient - _LOGGER.debug("%s: Device init started", name) self._name = name self.host = host self.port = port self._state = None self._current_activity = None self._default_activity = activity - self._client = HarmonyClient( - ip_address=host, - callbacks=ClientCallbackType( - new_activity=self.new_activity, - config_updated=self.new_config, - connect=self.got_connected, - disconnect=self.got_disconnected - ) - ) + self._client = HarmonyClient(ip_address=host) self._config_path = out_path self._delay_secs = delay_secs self._available = False async def async_added_to_hass(self): """Complete the initialization.""" + from aioharmony.harmonyapi import ClientCallbackType + _LOGGER.debug("%s: Harmony Hub added", self._name) + # Register the callbacks + self._client.callbacks = ClientCallbackType( + new_activity=self.new_activity, + config_updated=self.new_config, + connect=self.got_connected, + disconnect=self.got_disconnected + ) + + # Store Harmony HUB config, this will also update our current + # activity + await self.new_config() + import aioharmony.exceptions as aioexc async def shutdown(_): @@ -175,15 +197,6 @@ class HarmonyRemote(remote.RemoteDevice): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) - _LOGGER.debug("%s: Connecting", self._name) - try: - await self._client.connect() - except aioexc.TimeOut: - _LOGGER.error("%s: Connection timed-out", self._name) - else: - # Set initial state - self.new_activity(self._client.current_activity) - @property def name(self): """Return the Harmony device's name.""" @@ -209,6 +222,22 @@ class HarmonyRemote(remote.RemoteDevice): """Return True if connected to Hub, otherwise False.""" return self._available + async def connect(self): + """Connect to the Harmony HUB.""" + import aioharmony.exceptions as aioexc + + _LOGGER.debug("%s: Connecting", self._name) + try: + if not await self._client.connect(): + _LOGGER.warning("%s: Unable to connect to HUB.", self._name) + await self._client.close() + return False + except aioexc.TimeOut: + _LOGGER.warning("%s: Connection timed-out", self._name) + return False + + return True + def new_activity(self, activity_info: tuple) -> None: """Call for updating the current activity.""" activity_id, activity_name = activity_info @@ -324,7 +353,7 @@ class HarmonyRemote(remote.RemoteDevice): for _ in range(num_repeats): for single_command in command: send_command = SendCommandDevice( - device=device, + device=device_id, command=single_command, delay=0 ) @@ -344,10 +373,23 @@ class HarmonyRemote(remote.RemoteDevice): "%s: %s", result.command.command, result.command.device, - result.command.code, - result.command.msg + result.code, + result.msg ) + async def change_channel(self, channel): + """Change the channel using Harmony remote.""" + import aioharmony.exceptions as aioexc + + _LOGGER.debug("%s: Changing channel to %s", + self.name, channel) + try: + await self._client.change_channel(channel) + except aioexc.TimeOut: + _LOGGER.error("%s: Changing channel to %s timed-out", + self.name, + channel) + async def sync(self): """Sync the Harmony device with the web service.""" import aioharmony.exceptions as aioexc diff --git a/homeassistant/components/remote/roku.py b/homeassistant/components/remote/roku.py new file mode 100644 index 00000000000..86a7105dafe --- /dev/null +++ b/homeassistant/components/remote/roku.py @@ -0,0 +1,72 @@ +""" +Support for the Roku remote. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/remote.roku/ +""" +import requests.exceptions + +from homeassistant.components import remote +from homeassistant.const import (CONF_HOST) + + +DEPENDENCIES = ['roku'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Roku remote platform.""" + if not discovery_info: + return + + host = discovery_info[CONF_HOST] + async_add_entities([RokuRemote(host)], True) + + +class RokuRemote(remote.RemoteDevice): + """Device that sends commands to an Roku.""" + + def __init__(self, host): + """Initialize the Roku device.""" + from roku import Roku + + self.roku = Roku(host) + self._device_info = {} + + def update(self): + """Retrieve latest state.""" + try: + self._device_info = self.roku.device_info + except (requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout): + pass + + @property + def name(self): + """Return the name of the device.""" + if self._device_info.userdevicename: + return self._device_info.userdevicename + return "Roku {}".format(self._device_info.sernum) + + @property + def unique_id(self): + """Return a unique ID.""" + return self._device_info.sernum + + @property + def is_on(self): + """Return true if device is on.""" + return True + + @property + def should_poll(self): + """No polling needed for Roku.""" + return False + + def send_command(self, command, **kwargs): + """Send a command to one device.""" + for single_command in command: + if not hasattr(self.roku, single_command): + continue + + getattr(self.roku, single_command)() diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index 25ad626f96d..1fb4b048707 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -50,6 +50,16 @@ harmony_sync: description: Name(s) of entities to sync. example: 'remote.family_room' +harmony_change_channel: + description: Sends change channel command to the Harmony HUB + fields: + entity_id: + description: Name(s) of Harmony remote entities to send change channel command to + example: 'remote.family_room' + channel: + description: Channel number to change to + example: '200' + xiaomi_miio_learn_command: description: 'Learn an IR command, press "Call Service", point the remote at the IR device, and the learned command will be shown as a notification in Overview.' fields: diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index a247cb3e914..c8ffd043321 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -57,7 +57,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(ATTR_HIDDEN, default=True): cv.boolean, vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)), vol.Optional(CONF_COMMANDS, default={}): - vol.Schema({cv.slug: COMMAND_SCHEMA}), + cv.schema_with_slug_keys(COMMAND_SCHEMA), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/rest_command.py b/homeassistant/components/rest_command.py index 3f9b258634d..c8fb1568152 100644 --- a/homeassistant/components/rest_command.py +++ b/homeassistant/components/rest_command.py @@ -47,9 +47,7 @@ COMMAND_SCHEMA = vol.Schema({ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: COMMAND_SCHEMA, - }), + DOMAIN: cv.schema_with_slug_keys(COMMAND_SCHEMA), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/roku.py b/homeassistant/components/roku.py new file mode 100644 index 00000000000..5ceebb3dee5 --- /dev/null +++ b/homeassistant/components/roku.py @@ -0,0 +1,115 @@ +""" +Support for Roku platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/roku/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.discovery import SERVICE_ROKU +from homeassistant.const import CONF_HOST +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['python-roku==3.1.5'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'roku' + +SERVICE_SCAN = 'roku_scan' + +ATTR_ROKU = 'roku' + +DATA_ROKU = 'data_roku' + +NOTIFICATION_ID = 'roku_notification' +NOTIFICATION_TITLE = 'Roku Setup' +NOTIFICATION_SCAN_ID = 'roku_scan_notification' +NOTIFICATION_SCAN_TITLE = 'Roku Scan' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_HOST): cv.string + })]) +}, extra=vol.ALLOW_EXTRA) + +# Currently no attributes but it might change later +ROKU_SCAN_SCHEMA = vol.Schema({}) + + +def setup(hass, config): + """Set up the Roku component.""" + hass.data[DATA_ROKU] = {} + + def service_handler(service): + """Handle service calls.""" + if service.service == SERVICE_SCAN: + scan_for_rokus(hass) + + def roku_discovered(service, info): + """Set up an Roku that was auto discovered.""" + _setup_roku(hass, config, { + CONF_HOST: info['host'] + }) + + discovery.listen(hass, SERVICE_ROKU, roku_discovered) + + for conf in config.get(DOMAIN, []): + _setup_roku(hass, config, conf) + + hass.services.register( + DOMAIN, SERVICE_SCAN, service_handler, + schema=ROKU_SCAN_SCHEMA) + + return True + + +def scan_for_rokus(hass): + """Scan for devices and present a notification of the ones found.""" + from roku import Roku, RokuException + rokus = Roku.discover() + + devices = [] + for roku in rokus: + try: + r_info = roku.device_info + except RokuException: # skip non-roku device + continue + devices.append('Name: {0}
Host: {1}
'.format( + r_info.userdevicename if r_info.userdevicename + else "{} {}".format(r_info.modelname, r_info.sernum), + roku.host)) + if not devices: + devices = ['No device(s) found'] + + hass.components.persistent_notification.create( + 'The following devices were found:

' + + '

'.join(devices), + title=NOTIFICATION_SCAN_TITLE, + notification_id=NOTIFICATION_SCAN_ID) + + +def _setup_roku(hass, hass_config, roku_config): + """Set up a Roku.""" + from roku import Roku + host = roku_config[CONF_HOST] + + if host in hass.data[DATA_ROKU]: + return + + roku = Roku(host) + r_info = roku.device_info + + hass.data[DATA_ROKU][host] = { + ATTR_ROKU: r_info.sernum + } + + discovery.load_platform( + hass, 'media_player', DOMAIN, roku_config, hass_config) + + discovery.load_platform( + hass, 'remote', DOMAIN, roku_config, hass_config) diff --git a/homeassistant/components/scene/fibaro.py b/homeassistant/components/scene/fibaro.py index 7a36900f884..a0bd4e7ff40 100644 --- a/homeassistant/components/scene/fibaro.py +++ b/homeassistant/components/scene/fibaro.py @@ -9,7 +9,7 @@ import logging from homeassistant.components.scene import ( Scene) from homeassistant.components.fibaro import ( - FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + FIBARO_DEVICES, FibaroDevice) DEPENDENCIES = ['fibaro'] @@ -23,7 +23,7 @@ async def async_setup_platform(hass, config, async_add_entities, return async_add_entities( - [FibaroScene(scene, hass.data[FIBARO_CONTROLLER]) + [FibaroScene(scene) for scene in hass.data[FIBARO_DEVICES]['scene']], True) diff --git a/homeassistant/components/scene/hunterdouglas_powerview.py b/homeassistant/components/scene/hunterdouglas_powerview.py index 8c1ffa17e90..7676deb1a9c 100644 --- a/homeassistant/components/scene/hunterdouglas_powerview.py +++ b/homeassistant/components/scene/hunterdouglas_powerview.py @@ -8,14 +8,14 @@ import logging import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.scene import Scene, DOMAIN from homeassistant.const import CONF_PLATFORM from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiopvapi==1.5.4'] +REQUIREMENTS = ['aiopvapi==1.6.14'] ENTITY_ID_FORMAT = DOMAIN + '.{}' HUB_ADDRESS = 'address' @@ -25,6 +25,7 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Required(HUB_ADDRESS): cv.string, }) + SCENE_DATA = 'sceneData' ROOM_DATA = 'roomData' SCENE_NAME = 'name' @@ -39,6 +40,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up home assistant scene entries.""" # from aiopvapi.hub import Hub + from aiopvapi.helpers.aiorequest import AioRequest from aiopvapi.scenes import Scenes from aiopvapi.rooms import Rooms from aiopvapi.resources.scene import Scene as PvScene @@ -46,18 +48,17 @@ async def async_setup_platform(hass, config, async_add_entities, hub_address = config.get(HUB_ADDRESS) websession = async_get_clientsession(hass) - _scenes = await Scenes( - hub_address, hass.loop, websession).get_resources() - _rooms = await Rooms( - hub_address, hass.loop, websession).get_resources() + pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) + + _scenes = await Scenes(pv_request).get_resources() + _rooms = await Rooms(pv_request).get_resources() if not _scenes or not _rooms: _LOGGER.error( "Unable to initialize PowerView hub: %s", hub_address) return pvscenes = (PowerViewScene(hass, - PvScene(_raw_scene, hub_address, hass.loop, - websession), _rooms) + PvScene(_raw_scene, pv_request), _rooms) for _raw_scene in _scenes[SCENE_DATA]) async_add_entities(pvscenes) @@ -96,6 +97,6 @@ class PowerViewScene(Scene): """Icon to use in the frontend.""" return 'mdi:blinds' - def async_activate(self): + async def async_activate(self): """Activate scene. Try to get entities into requested state.""" - yield from self._scene.activate() + await self._scene.activate() diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index 54490af3cfa..15df6907468 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -45,7 +45,7 @@ SCRIPT_ENTRY_SCHEMA = vol.Schema({ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({cv.slug: SCRIPT_ENTRY_SCHEMA}) + DOMAIN: cv.schema_with_slug_keys(SCRIPT_ENTRY_SCHEMA) }, extra=vol.ALLOW_EXTRA) SCRIPT_SERVICE_SCHEMA = vol.Schema(dict) diff --git a/homeassistant/components/sensor/.translations/moon.de.json b/homeassistant/components/sensor/.translations/moon.de.json index aebca53ec4d..310ebf9c359 100644 --- a/homeassistant/components/sensor/.translations/moon.de.json +++ b/homeassistant/components/sensor/.translations/moon.de.json @@ -6,7 +6,7 @@ "new_moon": "Neumond", "waning_crescent": "Abnehmende Sichel", "waning_gibbous": "Drittes Viertel", - "waxing_crescent": " Zunehmende Sichel", + "waxing_crescent": "Zunehmende Sichel", "waxing_gibbous": "Zweites Viertel" } } \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.et.json b/homeassistant/components/sensor/.translations/moon.et.json new file mode 100644 index 00000000000..0d82e0d8f94 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.et.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Esimene veerand", + "full_moon": "T\u00e4iskuu", + "last_quarter": "Viimane veerand", + "new_moon": "Kuu loomine", + "waning_crescent": "Vanakuu", + "waning_gibbous": "Kahanev kuu", + "waxing_crescent": "Noorkuu", + "waxing_gibbous": "Kasvav kuu" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.sl.json b/homeassistant/components/sensor/.translations/moon.sl.json index 41e873e4def..1b69e10e6f9 100644 --- a/homeassistant/components/sensor/.translations/moon.sl.json +++ b/homeassistant/components/sensor/.translations/moon.sl.json @@ -6,7 +6,7 @@ "new_moon": "Mlaj", "waning_crescent": "Zadnji izbo\u010dec", "waning_gibbous": "Zadnji srpec", - "waxing_crescent": " Prvi izbo\u010dec", + "waxing_crescent": "Prvi izbo\u010dec", "waxing_gibbous": "Prvi srpec" } } \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.uk.json b/homeassistant/components/sensor/.translations/moon.uk.json new file mode 100644 index 00000000000..2467a705d50 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.uk.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "\u041f\u0435\u0440\u0448\u0430 \u0447\u0432\u0435\u0440\u0442\u044c", + "full_moon": "\u041f\u043e\u0432\u043d\u0438\u0439 \u043c\u0456\u0441\u044f\u0446\u044c", + "last_quarter": "\u041e\u0441\u0442\u0430\u043d\u043d\u044f \u0447\u0432\u0435\u0440\u0442\u044c", + "new_moon": "\u041d\u043e\u0432\u0438\u0439 \u043c\u0456\u0441\u044f\u0446\u044c", + "waning_crescent": "\u0417\u0440\u043e\u0441\u0442\u0430\u044e\u0447\u0438\u0439 \u043f\u0456\u0432\u043c\u0456\u0441\u044f\u0446\u044c", + "waning_gibbous": "\u041c\u043e\u043b\u043e\u0434\u0438\u0439 \u043c\u0456\u0441\u044f\u0446\u044c", + "waxing_crescent": "\u041c\u043e\u043b\u043e\u0434\u0438\u0439 \u043c\u0456\u0441\u044f\u0446\u044c", + "waxing_gibbous": "\u041c\u043e\u043b\u043e\u0434\u0438\u0439 \u043c\u0456\u0441\u044f\u0446\u044c" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.et.json b/homeassistant/components/sensor/.translations/season.et.json new file mode 100644 index 00000000000..1415a3b907b --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.et.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "S\u00fcgis", + "spring": "Kevad", + "summer": "Suvi", + "winter": "Talv" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/ambient_station.py b/homeassistant/components/sensor/ambient_station.py index 0dfbbb63244..bc44f83d764 100644 --- a/homeassistant/components/sensor/ambient_station.py +++ b/homeassistant/components/sensor/ambient_station.py @@ -32,19 +32,19 @@ UNIT_SYSTEM = {UNITS_US: 0, UNITS_SI: 1} SCAN_INTERVAL = timedelta(seconds=300) SENSOR_TYPES = { - 'winddir': ['Wind Dir', 'º'], + 'winddir': ['Wind Dir', '°'], 'windspeedmph': ['Wind Speed', 'mph'], 'windgustmph': ['Wind Gust', 'mph'], 'maxdailygust': ['Max Gust', 'mph'], - 'windgustdir': ['Gust Dir', 'º'], + 'windgustdir': ['Gust Dir', '°'], 'windspdmph_avg2m': ['Wind Avg 2m', 'mph'], 'winddir_avg2m': ['Wind Dir Avg 2m', 'mph'], 'windspdmph_avg10m': ['Wind Avg 10m', 'mph'], - 'winddir_avg10m': ['Wind Dir Avg 10m', 'º'], + 'winddir_avg10m': ['Wind Dir Avg 10m', '°'], 'humidity': ['Humidity', '%'], 'humidityin': ['Humidity In', '%'], - 'tempf': ['Temp', ['ºF', 'ºC']], - 'tempinf': ['Inside Temp', ['ºF', 'ºC']], + 'tempf': ['Temp', ['°F', '°C']], + 'tempinf': ['Inside Temp', ['°F', '°C']], 'battout': ['Battery', ''], 'hourlyrainin': ['Hourly Rain Rate', 'in/hr'], 'dailyrainin': ['Daily Rain', 'in'], @@ -60,8 +60,8 @@ SENSOR_TYPES = { 'solarradiation': ['Solar Rad', 'W/m^2'], 'co2': ['co2', 'ppm'], 'lastRain': ['Last Rain', ''], - 'dewPoint': ['Dew Point', ['ºF', 'ºC']], - 'feelsLike': ['Feels Like', ['ºF', 'ºC']], + 'dewPoint': ['Dew Point', ['°F', '°C']], + 'feelsLike': ['Feels Like', ['°F', '°C']], } _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/asuswrt.py b/homeassistant/components/sensor/asuswrt.py index 876f0dfd559..6af59ec1809 100644 --- a/homeassistant/components/sensor/asuswrt.py +++ b/homeassistant/components/sensor/asuswrt.py @@ -17,13 +17,23 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass, config, add_entities, discovery_info=None): """Set up the asuswrt sensors.""" + if discovery_info is None: + return + api = hass.data[DATA_ASUSWRT] - add_entities([ - AsuswrtRXSensor(api), - AsuswrtTXSensor(api), - AsuswrtTotalRXSensor(api), - AsuswrtTotalTXSensor(api) - ]) + + devices = [] + + if 'download' in discovery_info: + devices.append(AsuswrtTotalRXSensor(api)) + if 'upload' in discovery_info: + devices.append(AsuswrtTotalTXSensor(api)) + if 'download_speed' in discovery_info: + devices.append(AsuswrtRXSensor(api)) + if 'upload_speed' in discovery_info: + devices.append(AsuswrtTXSensor(api)) + + add_entities(devices) class AsuswrtSensor(Entity): diff --git a/homeassistant/components/sensor/blink.py b/homeassistant/components/sensor/blink.py index 6d3ca87c4ae..a1a07cb2a73 100644 --- a/homeassistant/components/sensor/blink.py +++ b/homeassistant/components/sensor/blink.py @@ -44,6 +44,9 @@ class BlinkSensor(Entity): self._unit_of_measurement = units self._icon = icon self._unique_id = "{}-{}".format(self._camera.serial, self._type) + self._sensor_key = self._type + if self._type == 'temperature': + self._sensor_key = 'temperature_calibrated' @property def name(self): @@ -74,9 +77,9 @@ class BlinkSensor(Entity): """Retrieve sensor data from the camera.""" self.data.refresh() try: - self._state = self._camera.attributes[self._type] + self._state = self._camera.attributes[self._sensor_key] except KeyError: self._state = None _LOGGER.error( "%s not a valid camera attribute. Did the API change?", - self._type) + self._sensor_key) diff --git a/homeassistant/components/sensor/darksky.py b/homeassistant/components/sensor/darksky.py index 06284028457..06232baca4e 100644 --- a/homeassistant/components/sensor/darksky.py +++ b/homeassistant/components/sensor/darksky.py @@ -74,7 +74,7 @@ SENSOR_TYPES = { ['hourly', 'daily']], 'temperature': ['Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', - ['currently', 'hourly', 'daily']], + ['currently', 'hourly']], 'apparent_temperature': ['Apparent Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['currently', 'hourly']], diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py deleted file mode 100644 index e2c9b59c59c..00000000000 --- a/homeassistant/components/sensor/deconz.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -Support for deCONZ sensor. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/sensor.deconz/ -""" -from homeassistant.components.deconz.const import ( - ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DECONZ_REACHABLE, - DOMAIN as DECONZ_DOMAIN) -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) -from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity -from homeassistant.util import slugify - -DEPENDENCIES = ['deconz'] - -ATTR_CURRENT = 'current' -ATTR_DAYLIGHT = 'daylight' -ATTR_EVENT_ID = 'event_id' - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Old way of setting up deCONZ sensors.""" - pass - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the deCONZ sensors.""" - gateway = hass.data[DECONZ_DOMAIN] - - @callback - def async_add_sensor(sensors): - """Add sensors from deCONZ.""" - from pydeconz.sensor import DECONZ_SENSOR, SWITCH as DECONZ_REMOTE - entities = [] - allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) - for sensor in sensors: - if sensor.type in DECONZ_SENSOR and \ - not (not allow_clip_sensor and sensor.type.startswith('CLIP')): - if sensor.type in DECONZ_REMOTE: - if sensor.battery: - entities.append(DeconzBattery(sensor, gateway)) - else: - entities.append(DeconzSensor(sensor, gateway)) - async_add_entities(entities, True) - - gateway.listeners.append( - async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) - - async_add_sensor(gateway.api.sensors.values()) - - -class DeconzSensor(Entity): - """Representation of a sensor.""" - - def __init__(self, sensor, gateway): - """Set up sensor and add update callback to get data from websocket.""" - self._sensor = sensor - self.gateway = gateway - self.unsub_dispatcher = None - - async def async_added_to_hass(self): - """Subscribe to sensors events.""" - self._sensor.register_async_callback(self.async_update_callback) - self.gateway.deconz_ids[self.entity_id] = self._sensor.deconz_id - self.unsub_dispatcher = async_dispatcher_connect( - self.hass, DECONZ_REACHABLE, self.async_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect sensor object when removed.""" - if self.unsub_dispatcher is not None: - self.unsub_dispatcher() - self._sensor.remove_callback(self.async_update_callback) - self._sensor = None - - @callback - def async_update_callback(self, reason): - """Update the sensor's state. - - If reason is that state is updated, - or reachable has changed or battery has changed. - """ - if reason['state'] or \ - 'reachable' in reason['attr'] or \ - 'battery' in reason['attr'] or \ - 'on' in reason['attr']: - self.async_schedule_update_ha_state() - - @property - def state(self): - """Return the state of the sensor.""" - return self._sensor.state - - @property - def name(self): - """Return the name of the sensor.""" - return self._sensor.name - - @property - def unique_id(self): - """Return a unique identifier for this sensor.""" - return self._sensor.uniqueid - - @property - def device_class(self): - """Return the class of the sensor.""" - return self._sensor.sensor_class - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return self._sensor.sensor_icon - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this sensor.""" - return self._sensor.sensor_unit - - @property - def available(self): - """Return true if sensor is available.""" - return self.gateway.available and self._sensor.reachable - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_state_attributes(self): - """Return the state attributes of the sensor.""" - from pydeconz.sensor import LIGHTLEVEL - attr = {} - if self._sensor.battery: - attr[ATTR_BATTERY_LEVEL] = self._sensor.battery - if self._sensor.on is not None: - attr[ATTR_ON] = self._sensor.on - if self._sensor.type in LIGHTLEVEL and self._sensor.dark is not None: - attr[ATTR_DARK] = self._sensor.dark - if self.unit_of_measurement == 'Watts': - attr[ATTR_CURRENT] = self._sensor.current - attr[ATTR_VOLTAGE] = self._sensor.voltage - if self._sensor.sensor_class == 'daylight': - attr[ATTR_DAYLIGHT] = self._sensor.daylight - return attr - - @property - def device_info(self): - """Return a device description for device registry.""" - if (self._sensor.uniqueid is None or - self._sensor.uniqueid.count(':') != 7): - return None - serial = self._sensor.uniqueid.split('-', 1)[0] - bridgeid = self.gateway.api.config.bridgeid - return { - 'connections': {(CONNECTION_ZIGBEE, serial)}, - 'identifiers': {(DECONZ_DOMAIN, serial)}, - 'manufacturer': self._sensor.manufacturer, - 'model': self._sensor.modelid, - 'name': self._sensor.name, - 'sw_version': self._sensor.swversion, - 'via_hub': (DECONZ_DOMAIN, bridgeid), - } - - -class DeconzBattery(Entity): - """Battery class for when a device is only represented as an event.""" - - def __init__(self, sensor, gateway): - """Register dispatcher callback for update of battery state.""" - self._sensor = sensor - self.gateway = gateway - self.unsub_dispatcher = None - - self._name = '{} {}'.format(self._sensor.name, 'Battery Level') - self._unit_of_measurement = "%" - - async def async_added_to_hass(self): - """Subscribe to sensors events.""" - self._sensor.register_async_callback(self.async_update_callback) - self.gateway.deconz_ids[self.entity_id] = self._sensor.deconz_id - self.unsub_dispatcher = async_dispatcher_connect( - self.hass, DECONZ_REACHABLE, self.async_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect sensor object when removed.""" - if self.unsub_dispatcher is not None: - self.unsub_dispatcher() - self._sensor.remove_callback(self.async_update_callback) - self._sensor = None - - @callback - def async_update_callback(self, reason): - """Update the battery's state, if needed.""" - if 'reachable' in reason['attr'] or 'battery' in reason['attr']: - self.async_schedule_update_ha_state() - - @property - def state(self): - """Return the state of the battery.""" - return self._sensor.battery - - @property - def name(self): - """Return the name of the battery.""" - return self._name - - @property - def unique_id(self): - """Return a unique identifier for the device.""" - return self._sensor.uniqueid - - @property - def device_class(self): - """Return the class of the sensor.""" - return DEVICE_CLASS_BATTERY - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement - - @property - def available(self): - """Return true if sensor is available.""" - return self.gateway.available and self._sensor.reachable - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_state_attributes(self): - """Return the state attributes of the battery.""" - attr = { - ATTR_EVENT_ID: slugify(self._sensor.name), - } - return attr - - @property - def device_info(self): - """Return a device description for device registry.""" - if (self._sensor.uniqueid is None or - self._sensor.uniqueid.count(':') != 7): - return None - serial = self._sensor.uniqueid.split('-', 1)[0] - bridgeid = self.gateway.api.config.bridgeid - return { - 'connections': {(CONNECTION_ZIGBEE, serial)}, - 'identifiers': {(DECONZ_DOMAIN, serial)}, - 'manufacturer': self._sensor.manufacturer, - 'model': self._sensor.modelid, - 'name': self._sensor.name, - 'sw_version': self._sensor.swversion, - 'via_hub': (DECONZ_DOMAIN, bridgeid), - } diff --git a/homeassistant/components/sensor/dublin_bus_transport.py b/homeassistant/components/sensor/dublin_bus_transport.py index d47c471e90d..02527f1e333 100644 --- a/homeassistant/components/sensor/dublin_bus_transport.py +++ b/homeassistant/components/sensor/dublin_bus_transport.py @@ -92,7 +92,7 @@ class DublinPublicTransportSensor(Entity): """Return the state attributes.""" if self._times is not None: next_up = "None" - if len(self._times) >= 1: + if len(self._times) > 1: next_up = self._times[1][ATTR_ROUTE] + " in " next_up += self._times[1][ATTR_DUE_IN] diff --git a/homeassistant/components/sensor/fail2ban.py b/homeassistant/components/sensor/fail2ban.py index 6080ce9fd10..78b11b1942b 100644 --- a/homeassistant/components/sensor/fail2ban.py +++ b/homeassistant/components/sensor/fail2ban.py @@ -64,7 +64,7 @@ class BanSensor(Entity): self.last_ban = None self.log_parser = log_parser self.log_parser.ip_regex[self.jail] = re.compile( - r"\[{}\].(Ban|Unban) ([\w+\.]{{3,}})".format(re.escape(self.jail)) + r"\[{}\]\s*(Ban|Unban) (.*)".format(re.escape(self.jail)) ) _LOGGER.debug("Setting up jail %s", self.jail) diff --git a/homeassistant/components/sensor/fibaro.py b/homeassistant/components/sensor/fibaro.py index e5ed5638c5b..e437ef8710d 100644 --- a/homeassistant/components/sensor/fibaro.py +++ b/homeassistant/components/sensor/fibaro.py @@ -12,7 +12,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import ENTITY_ID_FORMAT from homeassistant.components.fibaro import ( - FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + FIBARO_DEVICES, FibaroDevice) SENSOR_TYPES = { 'com.fibaro.temperatureSensor': @@ -37,18 +37,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return add_entities( - [FibaroSensor(device, hass.data[FIBARO_CONTROLLER]) + [FibaroSensor(device) for device in hass.data[FIBARO_DEVICES]['sensor']], True) class FibaroSensor(FibaroDevice, Entity): """Representation of a Fibaro Sensor.""" - def __init__(self, fibaro_device, controller): + def __init__(self, fibaro_device): """Initialize the sensor.""" self.current_value = None self.last_changed_time = None - super().__init__(fibaro_device, controller) + super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) if fibaro_device.type in SENSOR_TYPES: self._unit = SENSOR_TYPES[fibaro_device.type][1] diff --git a/homeassistant/components/sensor/flunearyou.py b/homeassistant/components/sensor/flunearyou.py index ee1c378aea3..a1a306f36e0 100644 --- a/homeassistant/components/sensor/flunearyou.py +++ b/homeassistant/components/sensor/flunearyou.py @@ -18,7 +18,7 @@ from homeassistant.helpers import aiohttp_client from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyflunearyou==1.0.0'] +REQUIREMENTS = ['pyflunearyou==1.0.1'] _LOGGER = logging.getLogger(__name__) ATTR_CITY = 'city' diff --git a/homeassistant/components/sensor/freebox.py b/homeassistant/components/sensor/freebox.py index cc737d2d398..2f8ccbc745d 100644 --- a/homeassistant/components/sensor/freebox.py +++ b/homeassistant/components/sensor/freebox.py @@ -15,13 +15,13 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( - hass, config, add_entities, discovery_info=None): + hass, config, async_add_entities, discovery_info=None): """Set up the sensors.""" fbx = hass.data[DATA_FREEBOX] - add_entities([ + async_add_entities([ FbxRXSensor(fbx), FbxTXSensor(fbx) - ]) + ], True) class FbxSensor(Entity): diff --git a/homeassistant/components/sensor/gtt.py b/homeassistant/components/sensor/gtt.py index 33175d0c071..f0e141f3549 100644 --- a/homeassistant/components/sensor/gtt.py +++ b/homeassistant/components/sensor/gtt.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import DEVICE_CLASS_TIMESTAMP from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util REQUIREMENTS = ['pygtt==1.1.2'] @@ -80,7 +79,8 @@ class GttSensor(Entity): def update(self): """Update device state.""" self.data.get_data() - next_time = dt_util.parse_time(self.data.state_bus['time'][0]['run']) + next_time = datetime.strptime( + self.data.state_bus['time'][0]['run'], "%H:%M") self._state = next_time.isoformat() diff --git a/homeassistant/components/sensor/hive.py b/homeassistant/components/sensor/hive.py index 3f900320801..e989074fb4b 100644 --- a/homeassistant/components/sensor/hive.py +++ b/homeassistant/components/sensor/hive.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.hive/ """ from homeassistant.const import TEMP_CELSIUS -from homeassistant.components.hive import DATA_HIVE +from homeassistant.components.hive import DATA_HIVE, DOMAIN from homeassistant.helpers.entity import Entity DEPENDENCIES = ['hive'] @@ -38,8 +38,24 @@ class HiveSensorEntity(Entity): self.session = hivesession self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) + self._unique_id = '{}-{}'.format(self.node_id, self.device_type) self.session.entities.append(self) + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self): + """Return device information.""" + return { + 'identifiers': { + (DOMAIN, self.unique_id) + }, + 'name': self.name + } + def handle_update(self, updatesource): """Handle the new update request.""" if '{}.{}'.format(self.device_type, self.node_id) not in updatesource: diff --git a/homeassistant/components/sensor/imap_email_content.py b/homeassistant/components/sensor/imap_email_content.py index ed535b69a1d..225ad08f7d1 100644 --- a/homeassistant/components/sensor/imap_email_content.py +++ b/homeassistant/components/sensor/imap_email_content.py @@ -2,7 +2,7 @@ Email sensor support. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.email/ +https://home-assistant.io/components/sensor.imap_email_content/ """ import logging import datetime @@ -22,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) CONF_SERVER = 'server' CONF_SENDERS = 'senders' +CONF_FOLDER = 'folder' ATTR_FROM = 'from' ATTR_BODY = 'body' @@ -36,6 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SERVER): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOLDER, default='INBOX'): cv.string, }) @@ -43,7 +45,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Email sensor platform.""" reader = EmailReader( config.get(CONF_USERNAME), config.get(CONF_PASSWORD), - config.get(CONF_SERVER), config.get(CONF_PORT)) + config.get(CONF_SERVER), config.get(CONF_PORT), + config.get(CONF_FOLDER)) value_template = config.get(CONF_VALUE_TEMPLATE) if value_template is not None: @@ -61,12 +64,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class EmailReader: """A class to read emails from an IMAP server.""" - def __init__(self, user, password, server, port): + def __init__(self, user, password, server, port, folder): """Initialize the Email Reader.""" self._user = user self._password = password self._server = server self._port = port + self._folder = folder self._last_id = None self._unread_ids = deque([]) self.connection = None @@ -97,7 +101,7 @@ class EmailReader: """Read the next email from the email server.""" import imaplib try: - self.connection.select() + self.connection.select(self._folder, readonly=True) if not self._unread_ids: search = "SINCE {0:%d-%b-%Y}".format(datetime.date.today()) diff --git a/homeassistant/components/sensor/influxdb.py b/homeassistant/components/sensor/influxdb.py index 0fc31ef273f..9f34580344c 100644 --- a/homeassistant/components/sensor/influxdb.py +++ b/homeassistant/components/sensor/influxdb.py @@ -111,7 +111,7 @@ class InfluxSensor(Entity): database=database, ssl=influx_conf['ssl'], verify_ssl=influx_conf['verify_ssl']) try: - influx.query("select * from /.*/ LIMIT 1;") + influx.query("SHOW DIAGNOSTICS;") self.connected = True self.data = InfluxSensorData( influx, query.get(CONF_GROUP_FUNCTION), query.get(CONF_FIELD), diff --git a/homeassistant/components/sensor/jewish_calendar.py b/homeassistant/components/sensor/jewish_calendar.py index 8058a411266..cc226337f02 100644 --- a/homeassistant/components/sensor/jewish_calendar.py +++ b/homeassistant/components/sensor/jewish_calendar.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.sun import get_astral_event_date import homeassistant.util.dt as dt_util -REQUIREMENTS = ['hdate==0.7.5'] +REQUIREMENTS = ['hdate==0.8.7'] _LOGGER = logging.getLogger(__name__) @@ -31,11 +31,24 @@ SENSOR_TYPES = { 'mga_end_shma': ['Latest time for Shm"a MG"A', 'mdi:calendar-clock'], 'plag_mincha': ['Plag Hamincha', 'mdi:weather-sunset-down'], 'first_stars': ['T\'set Hakochavim', 'mdi:weather-night'], + 'upcoming_shabbat_candle_lighting': ['Upcoming Shabbat Candle Lighting', + 'mdi:candle'], + 'upcoming_shabbat_havdalah': ['Upcoming Shabbat Havdalah', + 'mdi:weather-night'], + 'upcoming_candle_lighting': ['Upcoming Candle Lighting', 'mdi:candle'], + 'upcoming_havdalah': ['Upcoming Havdalah', 'mdi:weather-night'], + 'issur_melacha_in_effect': ['Issur Melacha in Effect', + 'mdi:power-plug-off'], + 'omer_count': ['Day of the Omer', 'mdi:counter'], } CONF_DIASPORA = 'diaspora' CONF_LANGUAGE = 'language' CONF_SENSORS = 'sensors' +CONF_CANDLE_LIGHT_MINUTES = 'candle_lighting_minutes_before_sunset' +CONF_HAVDALAH_OFFSET_MINUTES = 'havdalah_minutes_after_sunset' + +CANDLE_LIGHT_DEFAULT = 18 DEFAULT_NAME = 'Jewish Calendar' @@ -46,6 +59,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_LANGUAGE, default='english'): vol.In(['hebrew', 'english']), + vol.Optional(CONF_CANDLE_LIGHT_MINUTES, default=CANDLE_LIGHT_DEFAULT): int, + # Default of 0 means use 8.5 degrees / 'three_stars' time. + vol.Optional(CONF_HAVDALAH_OFFSET_MINUTES, default=0): int, vol.Optional(CONF_SENSORS, default=['date']): vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]), }) @@ -59,6 +75,8 @@ async def async_setup_platform( latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) diaspora = config.get(CONF_DIASPORA) + candle_lighting_offset = config.get(CONF_CANDLE_LIGHT_MINUTES) + havdalah_offset = config.get(CONF_HAVDALAH_OFFSET_MINUTES) if None in (latitude, longitude): _LOGGER.error("Latitude or longitude not set in Home Assistant config") @@ -68,7 +86,8 @@ async def async_setup_platform( for sensor_type in config[CONF_SENSORS]: dev.append(JewishCalSensor( name, language, sensor_type, latitude, longitude, - hass.config.time_zone, diaspora)) + hass.config.time_zone, diaspora, candle_lighting_offset, + havdalah_offset)) async_add_entities(dev, True) @@ -77,7 +96,8 @@ class JewishCalSensor(Entity): def __init__( self, name, language, sensor_type, latitude, longitude, timezone, - diaspora): + diaspora, candle_lighting_offset=CANDLE_LIGHT_DEFAULT, + havdalah_offset=0): """Initialize the Jewish calendar sensor.""" self.client_name = name self._name = SENSOR_TYPES[sensor_type][0] @@ -88,6 +108,8 @@ class JewishCalSensor(Entity): self.longitude = longitude self.timezone = timezone self.diaspora = diaspora + self.candle_lighting_offset = candle_lighting_offset + self.havdalah_offset = havdalah_offset _LOGGER.debug("Sensor %s initialized", self.type) @property @@ -124,18 +146,45 @@ class JewishCalSensor(Entity): date = hdate.HDate( today, diaspora=self.diaspora, hebrew=self._hebrew) + location = hdate.Location(latitude=self.latitude, + longitude=self.longitude, + timezone=self.timezone, + diaspora=self.diaspora) + + def make_zmanim(date): + """Create a Zmanim object.""" + return hdate.Zmanim( + date=date, location=location, + candle_lighting_offset=self.candle_lighting_offset, + havdalah_offset=self.havdalah_offset, hebrew=self._hebrew) + if self.type == 'date': self._state = date.hebrew_date elif self.type == 'weekly_portion': - self._state = date.parasha + # Compute the weekly portion based on the upcoming shabbat. + self._state = date.upcoming_shabbat.parasha elif self.type == 'holiday_name': self._state = date.holiday_description elif self.type == 'holyness': self._state = date.holiday_type + elif self.type == 'upcoming_shabbat_candle_lighting': + times = make_zmanim(date.upcoming_shabbat.previous_day.gdate) + self._state = times.candle_lighting + elif self.type == 'upcoming_candle_lighting': + times = make_zmanim(date.upcoming_shabbat_or_yom_tov.first_day + .previous_day.gdate) + self._state = times.candle_lighting + elif self.type == 'upcoming_shabbat_havdalah': + times = make_zmanim(date.upcoming_shabbat.gdate) + self._state = times.havdalah + elif self.type == 'upcoming_havdalah': + times = make_zmanim(date.upcoming_shabbat_or_yom_tov + .last_day.gdate) + self._state = times.havdalah + elif self.type == 'issur_melacha_in_effect': + self._state = make_zmanim(now).issur_melacha_in_effect else: - times = hdate.Zmanim( - date=today, latitude=self.latitude, longitude=self.longitude, - timezone=self.timezone, hebrew=self._hebrew).zmanim + times = make_zmanim(today).zmanim self._state = times[self.type].time() _LOGGER.debug("New value: %s", self._state) diff --git a/homeassistant/components/sensor/lacrosse.py b/homeassistant/components/sensor/lacrosse.py index a2dbaa8f324..32b1dac9250 100644 --- a/homeassistant/components/sensor/lacrosse.py +++ b/homeassistant/components/sensor/lacrosse.py @@ -45,7 +45,7 @@ SENSOR_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA), vol.Optional(CONF_BAUD, default=DEFAULT_BAUD): cv.string, vol.Optional(CONF_DATARATE): cv.positive_int, vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, diff --git a/homeassistant/components/sensor/lastfm.py b/homeassistant/components/sensor/lastfm.py index d92fe8b53bf..fa69a916495 100644 --- a/homeassistant/components/sensor/lastfm.py +++ b/homeassistant/components/sensor/lastfm.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylast==2.4.0'] +REQUIREMENTS = ['pylast==3.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/min_max.py b/homeassistant/components/sensor/min_max.py index 7d9e91a1bf1..f0334ef3255 100644 --- a/homeassistant/components/sensor/min_max.py +++ b/homeassistant/components/sensor/min_max.py @@ -11,7 +11,8 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, STATE_UNKNOWN, CONF_TYPE, ATTR_UNIT_OF_MEASUREMENT) + CONF_NAME, STATE_UNKNOWN, STATE_UNAVAILABLE, CONF_TYPE, + ATTR_UNIT_OF_MEASUREMENT) from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change @@ -125,7 +126,8 @@ class MinMaxSensor(Entity): @callback def async_min_max_sensor_state_listener(entity, old_state, new_state): """Handle the sensor state changes.""" - if new_state.state is None or new_state.state in STATE_UNKNOWN: + if (new_state.state is None + or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]): self.states[entity] = STATE_UNKNOWN hass.async_add_job(self.async_update_ha_state, True) return diff --git a/homeassistant/components/sensor/mychevy.py b/homeassistant/components/sensor/mychevy.py index 989126acc20..b478e2ef3ca 100644 --- a/homeassistant/components/sensor/mychevy.py +++ b/homeassistant/components/sensor/mychevy.py @@ -8,7 +8,7 @@ import logging from homeassistant.components.mychevy import ( EVSensorConfig, DOMAIN as MYCHEVY_DOMAIN, MYCHEVY_ERROR, MYCHEVY_SUCCESS, - NOTIFICATION_ID, NOTIFICATION_TITLE, UPDATE_TOPIC, ERROR_TOPIC + UPDATE_TOPIC, ERROR_TOPIC ) from homeassistant.components.sensor import ENTITY_ID_FORMAT from homeassistant.core import callback @@ -74,13 +74,10 @@ class MyChevyStatus(Entity): @callback def error(self): """Update state, trigger updates.""" - if self._state != MYCHEVY_ERROR: - self.hass.components.persistent_notification.create( - "Error:
Connection to mychevy website failed. " - "This probably means the mychevy to OnStar link is down.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - self._state = MYCHEVY_ERROR + _LOGGER.error( + "Connection to mychevy website failed. " + "This probably means the mychevy to OnStar link is down") + self._state = MYCHEVY_ERROR self.async_schedule_update_ha_state() @property @@ -144,7 +141,7 @@ class EVSensor(Entity): def icon(self): """Return the icon.""" if self._attr == BATTERY_SENSOR: - charging = self.state_attributes.get("charging", False) + charging = self._state_attributes.get("charging", False) return icon_for_battery_level(self.state, charging) return self._icon diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index 738bc53d880..5514203c6ea 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -6,11 +6,13 @@ https://home-assistant.io/components/sensor.nest/ """ import logging +from homeassistant.components.climate import ( + STATE_COOL, STATE_HEAT) from homeassistant.components.nest import ( DATA_NEST, DATA_NEST_CONFIG, CONF_SENSORS, NestSensorDevice) from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_MONITORED_CONDITIONS, - DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY) + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, STATE_OFF) DEPENDENCIES = ['nest'] @@ -39,6 +41,10 @@ SENSOR_DEVICE_CLASSES = {'humidity': DEVICE_CLASS_HUMIDITY} VARIABLE_NAME_MAPPING = {'eta': 'eta_begin', 'operation_mode': 'mode'} +VALUE_MAPPING = { + 'hvac_state': { + 'heating': STATE_HEAT, 'cooling': STATE_COOL, 'off': STATE_OFF}} + SENSOR_TYPES_DEPRECATED = ['last_ip', 'local_ip', 'last_connection', @@ -142,6 +148,9 @@ class NestBasicSensor(NestSensorDevice): if self.variable in VARIABLE_NAME_MAPPING: self._state = getattr(self.device, VARIABLE_NAME_MAPPING[self.variable]) + elif self.variable in VALUE_MAPPING: + state = getattr(self.device, self.variable) + self._state = VALUE_MAPPING[self.variable].get(state, state) elif self.variable in PROTECT_SENSOR_TYPES \ and self.variable != 'color_status': # keep backward compatibility diff --git a/homeassistant/components/sensor/postnl.py b/homeassistant/components/sensor/postnl.py index 5c70ca035cf..84cb42c0957 100644 --- a/homeassistant/components/sensor/postnl.py +++ b/homeassistant/components/sensor/postnl.py @@ -59,7 +59,9 @@ class PostNLSensor(Entity): def __init__(self, api, name): """Initialize the PostNL sensor.""" self._name = name - self._attributes = None + self._attributes = { + ATTR_ATTRIBUTION: ATTRIBUTION, + } self._state = None self._api = api @@ -92,18 +94,5 @@ class PostNLSensor(Entity): def update(self): """Update device state.""" shipments = self._api.get_relevant_shipments() - status_counts = {} - - for shipment in shipments: - status = shipment['status']['formatted']['short'] - status = self._api.parse_datetime(status, '%d-%m-%Y', '%H:%M') - - name = shipment['settings']['title'] - status_counts[name] = status - - self._attributes = { - ATTR_ATTRIBUTION: ATTRIBUTION, - **status_counts - } - - self._state = len(status_counts) + self._attributes['shipments'] = shipments + self._state = len(shipments) diff --git a/homeassistant/components/sensor/prezzibenzina.py b/homeassistant/components/sensor/prezzibenzina.py index 972a69ee0d9..171fea53314 100644 --- a/homeassistant/components/sensor/prezzibenzina.py +++ b/homeassistant/components/sensor/prezzibenzina.py @@ -67,7 +67,7 @@ async def async_setup_platform( if types is not None and info['fuel'] not in types: continue dev.append(PrezziBenzinaSensor( - index, client, station, name, info['fuel'])) + index, client, station, name, info['fuel'], info['service'])) async_add_entities(dev, True) @@ -75,13 +75,13 @@ async def async_setup_platform( class PrezziBenzinaSensor(Entity): """Implementation of a PrezziBenzina sensor.""" - def __init__(self, index, client, station, name, ft): + def __init__(self, index, client, station, name, ft, srv): """Initialize the PrezziBenzina sensor.""" self._client = client self._index = index self._data = None self._station = station - self._name = "{} {}".format(name, ft) + self._name = "{} {} {}".format(name, ft, srv) @property def name(self): diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index f2fbe6cd191..5b92753eb90 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -11,12 +11,13 @@ import voluptuous as vol import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) from homeassistant.const import ( CONF_AUTHENTICATION, CONF_FORCE_UPDATE, CONF_HEADERS, CONF_NAME, CONF_METHOD, CONF_PASSWORD, CONF_PAYLOAD, CONF_RESOURCE, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, - CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, CONF_DEVICE_CLASS, HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, STATE_UNKNOWN) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity @@ -43,6 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, @@ -61,6 +63,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): password = config.get(CONF_PASSWORD) headers = config.get(CONF_HEADERS) unit = config.get(CONF_UNIT_OF_MEASUREMENT) + device_class = config.get(CONF_DEVICE_CLASS) value_template = config.get(CONF_VALUE_TEMPLATE) json_attrs = config.get(CONF_JSON_ATTRS) force_update = config.get(CONF_FORCE_UPDATE) @@ -83,7 +86,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # Must update the sensor now (including fetching the rest resource) to # ensure it's updating its state. add_entities([RestSensor( - hass, rest, name, unit, value_template, json_attrs, force_update + hass, rest, name, unit, device_class, + value_template, json_attrs, force_update )], True) @@ -91,13 +95,14 @@ class RestSensor(Entity): """Implementation of a REST sensor.""" def __init__(self, hass, rest, name, unit_of_measurement, - value_template, json_attrs, force_update): + device_class, value_template, json_attrs, force_update): """Initialize the REST sensor.""" self._hass = hass self.rest = rest self._name = name self._state = STATE_UNKNOWN self._unit_of_measurement = unit_of_measurement + self._device_class = device_class self._value_template = value_template self._json_attrs = json_attrs self._attributes = None @@ -113,6 +118,11 @@ class RestSensor(Entity): """Return the unit the value is expressed in.""" return self._unit_of_measurement + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + @property def available(self): """Return if the sensor data are available.""" diff --git a/homeassistant/components/sensor/scrape.py b/homeassistant/components/sensor/scrape.py index 1058d0f43e7..9a67d381d42 100644 --- a/homeassistant/components/sensor/scrape.py +++ b/homeassistant/components/sensor/scrape.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['beautifulsoup4==4.6.3'] +REQUIREMENTS = ['beautifulsoup4==4.7.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/skybeacon.py b/homeassistant/components/sensor/skybeacon.py index 441053a7e7e..6960999306d 100644 --- a/homeassistant/components/sensor/skybeacon.py +++ b/homeassistant/components/sensor/skybeacon.py @@ -16,7 +16,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pygatt==3.2.0'] +REQUIREMENTS = ['pygatt[GATTTOOL]==3.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/sma.py b/homeassistant/components/sensor/sma.py index acf1ead186c..61009a472fb 100644 --- a/homeassistant/components/sensor/sma.py +++ b/homeassistant/components/sensor/sma.py @@ -19,7 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['pysma==0.2.2'] +REQUIREMENTS = ['pysma==0.3.1'] _LOGGER = logging.getLogger(__name__) @@ -37,11 +37,13 @@ def _check_sensor_schema(conf): """Check sensors and attributes are valid.""" try: import pysma - valid = [s.name for s in pysma.SENSORS] + valid = [s.name for s in pysma.Sensors()] except (ImportError, AttributeError): return conf - valid.extend(conf[CONF_CUSTOM].keys()) + for name in conf[CONF_CUSTOM]: + valid.append(name) + for sname, attrs in conf[CONF_SENSORS].items(): if sname not in valid: raise vol.Invalid("{} does not exist".format(sname)) @@ -65,9 +67,10 @@ PLATFORM_SCHEMA = vol.All(PLATFORM_SCHEMA.extend({ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), - vol.Required(CONF_SENSORS): vol.Schema({cv.slug: cv.ensure_list}), + vol.Optional(CONF_SENSORS, default={}): + cv.schema_with_slug_keys(cv.ensure_list), vol.Optional(CONF_CUSTOM, default={}): - vol.Schema({cv.slug: CUSTOM_SCHEMA}), + cv.schema_with_slug_keys(CUSTOM_SCHEMA), }, extra=vol.PREVENT_EXTRA), _check_sensor_schema) @@ -79,22 +82,29 @@ async def async_setup_platform( # Check config again during load - dependency available config = _check_sensor_schema(config) - # Sensor_defs from the custom config - for name, prop in config[CONF_CUSTOM].items(): - n_s = pysma.Sensor(name, prop['key'], prop['unit'], prop['factor']) - pysma.add_sensor(n_s) + # Init all default sensors + sensor_def = pysma.Sensors() + + # Sensor from the custom config + sensor_def.add([pysma.Sensor(o[CONF_KEY], n, o[CONF_UNIT], o[CONF_FACTOR]) + for n, o in config[CONF_CUSTOM].items()]) + + # Use all sensors by default + config_sensors = config[CONF_SENSORS] + if not config_sensors: + config_sensors = {s.name: [] for s in sensor_def} # Prepare all HASS sensor entities hass_sensors = [] used_sensors = [] - for name, attr in config[CONF_SENSORS].items(): - sub_sensors = [pysma.get_sensor(s) for s in attr] - hass_sensors.append(SMAsensor(pysma.get_sensor(name), sub_sensors)) + for name, attr in config_sensors.items(): + sub_sensors = [sensor_def[s] for s in attr] + hass_sensors.append(SMAsensor(sensor_def[name], sub_sensors)) used_sensors.append(name) used_sensors.extend(attr) async_add_entities(hass_sensors) - used_sensors = [pysma.get_sensor(s) for s in set(used_sensors)] + used_sensors = [sensor_def[s] for s in set(used_sensors)] # Init the SMA interface session = async_get_clientsession(hass, verify_ssl=config[CONF_VERIFY_SSL]) @@ -195,3 +205,8 @@ class SMAsensor(Entity): self._state = self._sensor.value return self.async_update_ha_state() if update else None + + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + return "sma-{}-{}".format(self._sensor.key, self._sensor.name) diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index b9997345c36..3964e44e376 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_USERNAME, CONF_VALUE_TEMPLATE) -REQUIREMENTS = ['pysnmp==4.4.6'] +REQUIREMENTS = ['pysnmp==4.4.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/sytadin.py b/homeassistant/components/sensor/sytadin.py index 18fa73f2341..082342a0393 100644 --- a/homeassistant/components/sensor/sytadin.py +++ b/homeassistant/components/sensor/sytadin.py @@ -18,7 +18,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['beautifulsoup4==4.6.3'] +REQUIREMENTS = ['beautifulsoup4==4.7.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py index aa651a9ab1a..5c48731f7df 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -119,7 +119,7 @@ class TautulliSensor(Entity): @property def state(self): """Return the state of the sensor.""" - return self.sessions['stream_count'] + return self.sessions.get('stream_count') @property def icon(self): diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index 3fa45935617..5f3af4a06a4 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -36,7 +36,7 @@ SENSOR_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA), }) diff --git a/homeassistant/components/sensor/vasttrafik.py b/homeassistant/components/sensor/vasttrafik.py index 7ef4170dd5a..124b0ff44ea 100644 --- a/homeassistant/components/sensor/vasttrafik.py +++ b/homeassistant/components/sensor/vasttrafik.py @@ -59,6 +59,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): planner = vasttrafik.JournyPlanner( config.get(CONF_KEY), config.get(CONF_SECRET)) sensors = [] + for departure in config.get(CONF_DEPARTURES): sensors.append( VasttrafikDepartureSensor( @@ -83,6 +84,8 @@ class VasttrafikDepartureSensor(Entity): self._lines = lines if lines else None self._delay = timedelta(minutes=delay) self._departureboard = None + self._state = None + self._attributes = None @property def name(self): @@ -97,42 +100,12 @@ class VasttrafikDepartureSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - if not self._departureboard: - return - - for departure in self._departureboard: - line = departure.get('sname') - if not self._lines or line in self._lines: - params = { - ATTR_ACCESSIBILITY: departure.get('accessibility'), - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_DIRECTION: departure.get('direction'), - ATTR_LINE: departure.get('sname'), - ATTR_TRACK: departure.get('track'), - } - return {k: v for k, v in params.items() if v} + return self._attributes @property def state(self): """Return the next departure time.""" - if not self._departureboard: - _LOGGER.warning( - "No departures from %s heading %s", - self._departure['name'], - self._heading['name'] if self._heading else 'ANY') - return - for departure in self._departureboard: - line = departure.get('sname') - if not self._lines or line in self._lines: - if 'rtTime' in self._departureboard[0]: - return self._departureboard[0]['rtTime'] - return self._departureboard[0]['time'] - # No departures of given lines found - _LOGGER.debug( - "No departures from %s heading %s on line(s) %s", - self._departure['name'], - self._heading['name'] if self._heading else 'ANY', - ', '.join((str(line) for line in self._lines))) + return self._state @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -143,5 +116,33 @@ class VasttrafikDepartureSensor(Entity): direction=self._heading['id'] if self._heading else None, date=datetime.now()+self._delay) except self._vasttrafik.Error: - _LOGGER.warning("Unable to read departure board, updating token") + _LOGGER.debug("Unable to read departure board, updating token") self._planner.update_token() + + if not self._departureboard: + _LOGGER.debug( + "No departures from %s heading %s", + self._departure['name'], + self._heading['name'] if self._heading else 'ANY') + self._state = None + self._attributes = {} + else: + for departure in self._departureboard: + line = departure.get('sname') + if not self._lines or line in self._lines: + if 'rtTime' in self._departureboard[0]: + self._state = self._departureboard[0]['rtTime'] + else: + self._state = self._departureboard[0]['time'] + + params = { + ATTR_ACCESSIBILITY: departure.get('accessibility'), + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_DIRECTION: departure.get('direction'), + ATTR_LINE: departure.get('sname'), + ATTR_TRACK: departure.get('track'), + } + + self._attributes = { + k: v for k, v in params.items() if v} + break diff --git a/homeassistant/components/sensor/zoneminder.py b/homeassistant/components/sensor/zoneminder.py index 16513dc58de..abecc99e278 100644 --- a/homeassistant/components/sensor/zoneminder.py +++ b/homeassistant/components/sensor/zoneminder.py @@ -42,19 +42,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ZoneMinder sensor platform.""" include_archived = config.get(CONF_INCLUDE_ARCHIVED) - zm_client = hass.data[ZONEMINDER_DOMAIN] - monitors = zm_client.get_monitors() - if not monitors: - _LOGGER.warning('Could not fetch any monitors from ZoneMinder') - sensors = [] - for monitor in monitors: - sensors.append(ZMSensorMonitors(monitor)) + for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): + monitors = zm_client.get_monitors() + if not monitors: + _LOGGER.warning('Could not fetch any monitors from ZoneMinder') - for sensor in config[CONF_MONITORED_CONDITIONS]: - sensors.append(ZMSensorEvents(monitor, include_archived, sensor)) + for monitor in monitors: + sensors.append(ZMSensorMonitors(monitor)) - sensors.append(ZMSensorRunState(zm_client)) + for sensor in config[CONF_MONITORED_CONDITIONS]: + sensors.append( + ZMSensorEvents(monitor, include_archived, sensor) + ) + + sensors.append(ZMSensorRunState(zm_client)) add_entities(sensors) diff --git a/homeassistant/components/shell_command.py b/homeassistant/components/shell_command.py index 2a95dd5c144..f9ec8da54e3 100644 --- a/homeassistant/components/shell_command.py +++ b/homeassistant/components/shell_command.py @@ -21,9 +21,7 @@ DOMAIN = 'shell_command' _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: cv.string, - }), + DOMAIN: cv.schema_with_slug_keys(cv.string), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/simplisafe/.translations/pt-BR.json b/homeassistant/components/simplisafe/.translations/pt-BR.json index 800ac719ca9..819cb1d95e0 100644 --- a/homeassistant/components/simplisafe/.translations/pt-BR.json +++ b/homeassistant/components/simplisafe/.translations/pt-BR.json @@ -7,11 +7,13 @@ "step": { "user": { "data": { + "code": "C\u00f3digo (para o Home Assistant)", "password": "Senha", "username": "Endere\u00e7o de e-mail" }, "title": "Preencha suas informa\u00e7\u00f5es" } - } + }, + "title": "SimpliSafe" } } \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/simplisafe/alarm_control_panel.py similarity index 96% rename from homeassistant/components/alarm_control_panel/simplisafe.py rename to homeassistant/components/simplisafe/alarm_control_panel.py index cdcdf07c982..626a819b0b9 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/alarm_control_panel.simplisafe/ import logging import re -from homeassistant.components.alarm_control_panel import AlarmControlPanel +import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.simplisafe.const import ( DATA_CLIENT, DOMAIN, TOPIC_UPDATE) from homeassistant.const import ( @@ -37,7 +37,7 @@ async def async_setup_entry(hass, entry, async_add_entities): ], True) -class SimpliSafeAlarm(AlarmControlPanel): +class SimpliSafeAlarm(alarm.AlarmControlPanel): """Representation of a SimpliSafe alarm.""" def __init__(self, system, code): @@ -64,8 +64,8 @@ class SimpliSafeAlarm(AlarmControlPanel): if not self._code: return None if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT @property def state(self): diff --git a/homeassistant/components/smhi/.translations/pt-BR.json b/homeassistant/components/smhi/.translations/pt-BR.json index 771195d8152..848f85ed133 100644 --- a/homeassistant/components/smhi/.translations/pt-BR.json +++ b/homeassistant/components/smhi/.translations/pt-BR.json @@ -1,7 +1,8 @@ { "config": { "error": { - "name_exists": "O nome j\u00e1 existe" + "name_exists": "O nome j\u00e1 existe", + "wrong_location": "Localiza\u00e7\u00e3o apenas na Su\u00e9cia" }, "step": { "user": { @@ -9,8 +10,10 @@ "latitude": "Latitude", "longitude": "Longitude", "name": "Nome" - } + }, + "title": "Localiza\u00e7\u00e3o na Su\u00e9cia" } - } + }, + "title": "Servi\u00e7o meteorol\u00f3gico sueco (SMHI)" } } \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/et.json b/homeassistant/components/sonos/.translations/et.json new file mode 100644 index 00000000000..987c54955f2 --- /dev/null +++ b/homeassistant/components/sonos/.translations/et.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "confirm": { + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 529df41de58..b4f507a60dd 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -4,7 +4,7 @@ from homeassistant.helpers import config_entry_flow DOMAIN = 'sonos' -REQUIREMENTS = ['pysonos==0.0.5'] +REQUIREMENTS = ['pysonos==0.0.6'] async def async_setup(hass, config): diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 685402611a0..9c17767f033 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -59,7 +59,7 @@ MP1_SWITCH_SLOT_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SWITCHES, default={}): - vol.Schema({cv.slug: SWITCH_SCHEMA}), + cv.schema_with_slug_keys(SWITCH_SCHEMA), vol.Optional(CONF_SLOTS, default={}): MP1_SWITCH_SLOT_SCHEMA, vol.Required(CONF_HOST): cv.string, vol.Required(CONF_MAC): cv.string, diff --git a/homeassistant/components/switch/command_line.py b/homeassistant/components/switch/command_line.py index d25c5708316..4edbd79ee0c 100644 --- a/homeassistant/components/switch/command_line.py +++ b/homeassistant/components/switch/command_line.py @@ -28,7 +28,7 @@ SWITCH_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SWITCHES): vol.Schema({cv.slug: SWITCH_SCHEMA}), + vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA), }) diff --git a/homeassistant/components/switch/deconz.py b/homeassistant/components/switch/deconz.py deleted file mode 100644 index b491bc4b567..00000000000 --- a/homeassistant/components/switch/deconz.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -Support for deCONZ switches. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/switch.deconz/ -""" -from homeassistant.components.deconz.const import ( - DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN, POWER_PLUGS, SIRENS) -from homeassistant.components.switch import SwitchDevice -from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -DEPENDENCIES = ['deconz'] - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Old way of setting up deCONZ switches.""" - pass - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up switches for deCONZ component. - - Switches are based same device class as lights in deCONZ. - """ - gateway = hass.data[DECONZ_DOMAIN] - - @callback - def async_add_switch(lights): - """Add switch from deCONZ.""" - entities = [] - for light in lights: - if light.type in POWER_PLUGS: - entities.append(DeconzPowerPlug(light, gateway)) - elif light.type in SIRENS: - entities.append(DeconzSiren(light, gateway)) - async_add_entities(entities, True) - - gateway.listeners.append( - async_dispatcher_connect(hass, 'deconz_new_light', async_add_switch)) - - async_add_switch(gateway.api.lights.values()) - - -class DeconzSwitch(SwitchDevice): - """Representation of a deCONZ switch.""" - - def __init__(self, switch, gateway): - """Set up switch and add update callback to get data from websocket.""" - self._switch = switch - self.gateway = gateway - self.unsub_dispatcher = None - - async def async_added_to_hass(self): - """Subscribe to switches events.""" - self._switch.register_async_callback(self.async_update_callback) - self.gateway.deconz_ids[self.entity_id] = self._switch.deconz_id - self.unsub_dispatcher = async_dispatcher_connect( - self.hass, DECONZ_REACHABLE, self.async_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect switch object when removed.""" - if self.unsub_dispatcher is not None: - self.unsub_dispatcher() - self._switch.remove_callback(self.async_update_callback) - self._switch = None - - @callback - def async_update_callback(self, reason): - """Update the switch's state.""" - self.async_schedule_update_ha_state() - - @property - def name(self): - """Return the name of the switch.""" - return self._switch.name - - @property - def unique_id(self): - """Return a unique identifier for this switch.""" - return self._switch.uniqueid - - @property - def available(self): - """Return True if light is available.""" - return self.gateway.available and self._switch.reachable - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_info(self): - """Return a device description for device registry.""" - if (self._switch.uniqueid is None or - self._switch.uniqueid.count(':') != 7): - return None - serial = self._switch.uniqueid.split('-', 1)[0] - bridgeid = self.gateway.api.config.bridgeid - return { - 'connections': {(CONNECTION_ZIGBEE, serial)}, - 'identifiers': {(DECONZ_DOMAIN, serial)}, - 'manufacturer': self._switch.manufacturer, - 'model': self._switch.modelid, - 'name': self._switch.name, - 'sw_version': self._switch.swversion, - 'via_hub': (DECONZ_DOMAIN, bridgeid), - } - - -class DeconzPowerPlug(DeconzSwitch): - """Representation of power plugs from deCONZ.""" - - @property - def is_on(self): - """Return true if switch is on.""" - return self._switch.state - - async def async_turn_on(self, **kwargs): - """Turn on switch.""" - data = {'on': True} - await self._switch.async_set_state(data) - - async def async_turn_off(self, **kwargs): - """Turn off switch.""" - data = {'on': False} - await self._switch.async_set_state(data) - - -class DeconzSiren(DeconzSwitch): - """Representation of sirens from deCONZ.""" - - @property - def is_on(self): - """Return true if switch is on.""" - return self._switch.alert == 'lselect' - - async def async_turn_on(self, **kwargs): - """Turn on switch.""" - data = {'alert': 'lselect'} - await self._switch.async_set_state(data) - - async def async_turn_off(self, **kwargs): - """Turn off switch.""" - data = {'alert': 'none'} - await self._switch.async_set_state(data) diff --git a/homeassistant/components/switch/fibaro.py b/homeassistant/components/switch/fibaro.py index d3e96646a45..8b59aabec72 100644 --- a/homeassistant/components/switch/fibaro.py +++ b/homeassistant/components/switch/fibaro.py @@ -9,7 +9,7 @@ import logging from homeassistant.util import convert from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice from homeassistant.components.fibaro import ( - FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + FIBARO_DEVICES, FibaroDevice) DEPENDENCIES = ['fibaro'] _LOGGER = logging.getLogger(__name__) @@ -21,17 +21,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return add_entities( - [FibaroSwitch(device, hass.data[FIBARO_CONTROLLER]) for + [FibaroSwitch(device) for device in hass.data[FIBARO_DEVICES]['switch']], True) class FibaroSwitch(FibaroDevice, SwitchDevice): """Representation of a Fibaro Switch.""" - def __init__(self, fibaro_device, controller): + def __init__(self, fibaro_device): """Initialize the Fibaro device.""" self._state = False - super().__init__(fibaro_device, controller) + super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) def turn_on(self, **kwargs): diff --git a/homeassistant/components/switch/hive.py b/homeassistant/components/switch/hive.py index 1927df28e97..a50323f0a4e 100644 --- a/homeassistant/components/switch/hive.py +++ b/homeassistant/components/switch/hive.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.hive/ """ from homeassistant.components.switch import SwitchDevice -from homeassistant.components.hive import DATA_HIVE +from homeassistant.components.hive import DATA_HIVE, DOMAIN DEPENDENCIES = ['hive'] @@ -31,8 +31,24 @@ class HiveDevicePlug(SwitchDevice): self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) + self._unique_id = '{}-{}'.format(self.node_id, self.device_type) self.session.entities.append(self) + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self): + """Return device information.""" + return { + 'identifiers': { + (DOMAIN, self.unique_id) + }, + 'name': self.name + } + def handle_update(self, updatesource): """Handle the new update request.""" if '{}.{}'.format(self.device_type, self.node_id) not in updatesource: diff --git a/homeassistant/components/switch/kankun.py b/homeassistant/components/switch/kankun.py index 59966739b91..86e7fcdab3e 100644 --- a/homeassistant/components/switch/kankun.py +++ b/homeassistant/components/switch/kankun.py @@ -30,7 +30,7 @@ SWITCH_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SWITCHES): vol.Schema({cv.slug: SWITCH_SCHEMA}), + vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA), }) diff --git a/homeassistant/components/switch/scsgate.py b/homeassistant/components/switch/scsgate.py index bb8c067ebd9..9344aeab7ed 100644 --- a/homeassistant/components/switch/scsgate.py +++ b/homeassistant/components/switch/scsgate.py @@ -24,7 +24,8 @@ CONF_SCENARIO = 'scenario' CONF_SCS_ID = 'scs_id' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): vol.Schema({cv.slug: scsgate.SCSGATE_SCHEMA}), + vol.Required(CONF_DEVICES): + cv.schema_with_slug_keys(scsgate.SCSGATE_SCHEMA), }) diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py index e1da12d317e..0baa129657d 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_USERNAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysnmp==4.4.6'] +REQUIREMENTS = ['pysnmp==4.4.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/telnet.py b/homeassistant/components/switch/telnet.py index 440279a70a8..7c3baf2981a 100644 --- a/homeassistant/components/switch/telnet.py +++ b/homeassistant/components/switch/telnet.py @@ -32,7 +32,7 @@ SWITCH_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SWITCHES): vol.Schema({cv.slug: SWITCH_SCHEMA}), + vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA), }) SCAN_INTERVAL = timedelta(seconds=10) diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index 51cea68f6b3..a2098c2f5fd 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -38,7 +38,7 @@ SWITCH_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SWITCHES): vol.Schema({cv.slug: SWITCH_SCHEMA}), + vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA), }) diff --git a/homeassistant/components/switch/tradfri.py b/homeassistant/components/switch/tradfri.py index 74997332b07..b247858b062 100644 --- a/homeassistant/components/switch/tradfri.py +++ b/homeassistant/components/switch/tradfri.py @@ -108,6 +108,8 @@ class TradfriSwitch(SwitchDevice): """Start observation of switch.""" from pytradfri.error import PytradfriError if exc: + self._available = False + self.async_schedule_update_ha_state() _LOGGER.warning("Observation failed for %s", self._name, exc_info=exc) diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 9db13446752..4ead90ca4ec 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -37,6 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'qmi.powerstrip.v1', 'zimi.powerstrip.v2', 'chuangmi.plug.m1', + 'chuangmi.plug.m3', 'chuangmi.plug.v2', 'chuangmi.plug.v3', 'chuangmi.plug.hmi205', @@ -147,8 +148,8 @@ async def async_setup_platform(hass, config, async_add_entities, device = XiaomiPowerStripSwitch(name, plug, model, unique_id) devices.append(device) hass.data[DATA_KEY][host] = device - elif model in ['chuangmi.plug.m1', 'chuangmi.plug.v2', - 'chuangmi.plug.hmi205']: + elif model in ['chuangmi.plug.m1', 'chuangmi.plug.m3', + 'chuangmi.plug.v2', 'chuangmi.plug.hmi205']: from miio import ChuangmiPlug plug = ChuangmiPlug(host, token, model=model) device = XiaomiPlugGenericSwitch(name, plug, model, unique_id) diff --git a/homeassistant/components/switch/zoneminder.py b/homeassistant/components/switch/zoneminder.py index c28fe843b90..b039d2e3ce9 100644 --- a/homeassistant/components/switch/zoneminder.py +++ b/homeassistant/components/switch/zoneminder.py @@ -29,16 +29,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): on_state = MonitorState(config.get(CONF_COMMAND_ON)) off_state = MonitorState(config.get(CONF_COMMAND_OFF)) - zm_client = hass.data[ZONEMINDER_DOMAIN] - - monitors = zm_client.get_monitors() - if not monitors: - _LOGGER.warning('Could not fetch monitors from ZoneMinder') - return - switches = [] - for monitor in monitors: - switches.append(ZMSwitchMonitors(monitor, on_state, off_state)) + for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): + monitors = zm_client.get_monitors() + if not monitors: + _LOGGER.warning('Could not fetch monitors from ZoneMinder') + return + + for monitor in monitors: + switches.append(ZMSwitchMonitors(monitor, on_state, off_state)) add_entities(switches) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index ff9e30e5c68..5e30b845863 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['tahoma-api==0.0.13'] +REQUIREMENTS = ['tahoma-api==0.0.14'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tellduslive/.translations/de.json b/homeassistant/components/tellduslive/.translations/de.json new file mode 100644 index 00000000000..4e9c32a1ee5 --- /dev/null +++ b/homeassistant/components/tellduslive/.translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "all_configured": "TelldusLive ist bereits konfiguriert", + "authorize_url_fail": "Unbekannter Fehler beim Erstellen der Authorisierungs-URL", + "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", + "unknown": "Unbekannter Fehler ist aufgetreten" + }, + "step": { + "auth": { + "description": "So verkn\u00fcpfen Sie Ihr TelldusLive-Konto: \n 1. Klicken Sie auf den Link unten \n 2. Melden Sie sich bei Telldus Live an \n 3. Autorisieren Sie ** {app_name} ** (klicken Sie auf ** Yes **). \n 4. Kommen Sie hierher zur\u00fcck und klicken Sie auf ** SUBMIT **. \n\n [Link TelldusLive-Konto]({auth_url})", + "title": "Authentifizieren Sie sich gegen TelldusLive" + }, + "user": { + "data": { + "host": "Host" + }, + "title": "Endpunkt ausw\u00e4hlen." + } + }, + "title": "Telldus Live" + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/en.json b/homeassistant/components/tellduslive/.translations/en.json index 3a72b41e1f7..4ed9ef597f4 100644 --- a/homeassistant/components/tellduslive/.translations/en.json +++ b/homeassistant/components/tellduslive/.translations/en.json @@ -1,11 +1,14 @@ { "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.", "unknown": "Unknown error occurred" }, + "error": { + "auth_error": "Authentication error, please try again" + }, "step": { "auth": { "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})", diff --git a/homeassistant/components/tellduslive/.translations/fr.json b/homeassistant/components/tellduslive/.translations/fr.json new file mode 100644 index 00000000000..9a3121c5896 --- /dev/null +++ b/homeassistant/components/tellduslive/.translations/fr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "description": "Vide" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/hu.json b/homeassistant/components/tellduslive/.translations/hu.json index 90ac7396f47..ffa983db093 100644 --- a/homeassistant/components/tellduslive/.translations/hu.json +++ b/homeassistant/components/tellduslive/.translations/hu.json @@ -2,6 +2,14 @@ "config": { "abort": { "unknown": "Ismeretlen hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "host": "Kiszolg\u00e1l\u00f3" + }, + "description": "\u00dcres" + } } } } \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/nl.json b/homeassistant/components/tellduslive/.translations/nl.json new file mode 100644 index 00000000000..609ac51c4de --- /dev/null +++ b/homeassistant/components/tellduslive/.translations/nl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "all_configured": "TelldusLive is al geconfigureerd", + "authorize_url_fail": "Onbekende fout bij het genereren van een autorisatie url.", + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "unknown": "Onbekende fout opgetreden" + }, + "step": { + "auth": { + "description": "Om uw TelldusLive-account te linken: \n 1. Klik op de onderstaande link \n 2. Log in op Telldus Live \n 3. Autoriseer ** {app_name} ** (klik op ** Ja **). \n 4. Kom hier terug en klik op ** VERSTUREN **. \n\n [Link TelldusLive account]({auth_url})", + "title": "Verifi\u00ebren tegen TelldusLive" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Leeg", + "title": "Kies eindpunt." + } + }, + "title": "Telldus Live" + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/pl.json b/homeassistant/components/tellduslive/.translations/pl.json index 63080a78632..5ee9ac221a7 100644 --- a/homeassistant/components/tellduslive/.translations/pl.json +++ b/homeassistant/components/tellduslive/.translations/pl.json @@ -15,6 +15,7 @@ "data": { "host": "Host" }, + "description": "Puste", "title": "Wybierz punkt ko\u0144cowy." } }, diff --git a/homeassistant/components/tellduslive/.translations/pt-BR.json b/homeassistant/components/tellduslive/.translations/pt-BR.json new file mode 100644 index 00000000000..c973aa223af --- /dev/null +++ b/homeassistant/components/tellduslive/.translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "unknown": "Ocorreu um erro desconhecido" + }, + "step": { + "auth": { + "description": "Para vincular sua conta do TelldusLive: \n 1. Clique no link abaixo \n 2. Fa\u00e7a o login no Telldus Live \n 3. Autorize **{app_name}** (clique em **Sim**). \n 4. Volte aqui e clique em **ENVIAR**. \n\n [Vincular conta TelldusLive]({auth_url})", + "title": "Autenticar no TelldusLive" + }, + "user": { + "title": "Escolha o ponto final." + } + }, + "title": "Telldus Live" + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/pt.json b/homeassistant/components/tellduslive/.translations/pt.json new file mode 100644 index 00000000000..d0d38a42caf --- /dev/null +++ b/homeassistant/components/tellduslive/.translations/pt.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "all_configured": "TelldusLive j\u00e1 est\u00e1 configurado", + "authorize_url_fail": "Erro desconhecido ao gerar um URL de autoriza\u00e7\u00e3o.", + "authorize_url_timeout": "Limite temporal ultrapassado ao gerar um URL de autoriza\u00e7\u00e3o.", + "unknown": "Ocorreu um erro desconhecido" + }, + "step": { + "auth": { + "description": "Para ligar \u00e0 sua conta do TelldusLive: \n 1. Clique no link abaixo \n 2. Fa\u00e7a o login no Telldus Live \n 3. Autorize **{app_name}** (clique em **Sim**). \n 4. Volte aqui e clique em **ENVIAR**. \n\n [Ligar \u00e0 TelldusLive] ( {auth_url} )", + "title": "Autenticar no TelldusLive" + }, + "user": { + "data": { + "host": "Servidor" + }, + "description": "Vazio", + "title": "Escolher endpoint." + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index 64260b6047c..3373e9cc2f7 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -35,6 +35,13 @@ class FlowHandler(config_entries.ConfigFlow): self._scan_interval = SCAN_INTERVAL def _get_auth_url(self): + from tellduslive import Session + self._session = Session( + public_key=PUBLIC_KEY, + private_key=NOT_SO_PRIVATE_KEY, + host=self._host, + application=APPLICATION_NAME, + ) return self._session.authorize_url async def async_step_user(self, user_input=None): @@ -56,38 +63,36 @@ class FlowHandler(config_entries.ConfigFlow): async def async_step_auth(self, user_input=None): """Handle the submitted configuration.""" - if not self._session: - from tellduslive import Session - self._session = Session( - public_key=PUBLIC_KEY, - private_key=NOT_SO_PRIVATE_KEY, - host=self._host, - application=APPLICATION_NAME, - ) - - if user_input is not None and self._session.authorize(): - host = self._host or CLOUD_NAME - if self._host: - session = { - KEY_HOST: host, - KEY_TOKEN: self._session.access_token - } + errors = {} + if user_input is not None: + if await self.hass.async_add_executor_job( + self._session.authorize): + host = self._host or CLOUD_NAME + if self._host: + session = { + KEY_HOST: host, + KEY_TOKEN: self._session.access_token + } + else: + session = { + KEY_TOKEN: self._session.access_token, + KEY_TOKEN_SECRET: self._session.access_token_secret + } + return self.async_create_entry( + title=host, data={ + KEY_HOST: host, + KEY_SCAN_INTERVAL: self._scan_interval.seconds, + KEY_SESSION: session, + }) else: - session = { - KEY_TOKEN: self._session.access_token, - KEY_TOKEN_SECRET: self._session.access_token_secret - } - return self.async_create_entry( - title=host, data={ - KEY_HOST: host, - KEY_SCAN_INTERVAL: self._scan_interval.seconds, - KEY_SESSION: session, - }) + errors['base'] = 'auth_error' try: with async_timeout.timeout(10): auth_url = await self.hass.async_add_executor_job( self._get_auth_url) + if not auth_url: + return self.async_abort(reason='authorize_url_fail') except asyncio.TimeoutError: return self.async_abort(reason='authorize_url_timeout') except Exception: # pylint: disable=broad-except @@ -97,6 +102,7 @@ class FlowHandler(config_entries.ConfigFlow): _LOGGER.debug('Got authorization URL %s', auth_url) return self.async_show_form( step_id='auth', + errors=errors, description_placeholders={ 'app_name': APPLICATION_NAME, 'auth_url': auth_url, @@ -107,17 +113,10 @@ class FlowHandler(config_entries.ConfigFlow): """Run when a Tellstick is discovered.""" from tellduslive import supports_local_api _LOGGER.info('Discovered tellstick device: %s', user_input) - # Ignore any known devices - for entry in self._async_current_entries(): - if entry.data[KEY_HOST] == user_input[0]: - return self.async_abort(reason='already_configured') + if supports_local_api(user_input[1]): + _LOGGER.info('%s support local API', user_input[1]) + self._hosts.append(user_input[0]) - if not supports_local_api(user_input[1]): - _LOGGER.debug('Tellstick does not support local API') - # Configure the cloud service - return await self.async_step_auth() - - self._hosts.append(user_input[0]) return await self.async_step_user() async def async_step_import(self, user_input): diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index 7be98213222..bb62889085b 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -1,24 +1,26 @@ { "config": { - "title": "Telldus Live", - "step": { - "user": { - "title": "Pick endpoint.", - "description": "", - "data": { - "host": "Host" - } + "abort": { + "already_setup": "TelldusLive is already configured", + "authorize_url_fail": "Unknown error generating an authorize url.", + "authorize_url_timeout": "Timeout generating authorize url.", + "unknown": "Unknown error occurred" }, - "auth": { - "title": "Authenticate against TelldusLive", - "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})" - } - }, - "abort": { - "authorize_url_timeout": "Timeout generating authorize url.", - "authorize_url_fail": "Unknown error generating an authorize url.", - "all_configured": "TelldusLive is already configured", - "unknown": "Unknown error occurred" - } + "error": { + "auth_error": "Authentication error, please try again" + }, + "step": { + "auth": { + "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})", + "title": "Authenticate against TelldusLive" + }, + "user": { + "data": { + "host": "Host" + }, + "title": "Pick endpoint." + } + }, + "title": "Telldus Live" } -} +} \ No newline at end of file diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 364975671c4..b898c577bb2 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -53,14 +53,14 @@ SERVICE_SCHEMA_DURATION = vol.Schema({ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: vol.Any({ + DOMAIN: cv.schema_with_slug_keys( + vol.Any({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_DURATION, timedelta(DEFAULT_DURATION)): cv.time_period, }, None) - }) + ) }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/tradfri/.translations/et.json b/homeassistant/components/tradfri/.translations/et.json new file mode 100644 index 00000000000..5d0a728407a --- /dev/null +++ b/homeassistant/components/tradfri/.translations/et.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "auth": { + "data": { + "host": "" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 86667782d09..063ba428d4a 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -29,7 +29,7 @@ from homeassistant.helpers import config_per_platform import homeassistant.helpers.config_validation as cv from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['mutagen==1.41.1'] +REQUIREMENTS = ['mutagen==1.42.0'] _LOGGER = logging.getLogger(__name__) @@ -72,7 +72,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ SCHEMA_SERVICE_SAY = vol.Schema({ vol.Required(ATTR_MESSAGE): cv.string, vol.Optional(ATTR_CACHE): cv.boolean, - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, vol.Optional(ATTR_LANGUAGE): cv.string, vol.Optional(ATTR_OPTIONS): dict, }) diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index 1dfb741bb42..0102a1fec09 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -43,7 +43,7 @@ SUPPORTED_VOICES = [ 'Joey', 'Justin', 'Matthew', 'Ivy', 'Joanna', 'Kendra', 'Kimberly', 'Salli', # English 'Geraint', # English Welsh - 'Mathieu', 'Celine', 'Léa', # French + 'Mathieu', 'Celine', 'Lea', # French 'Chantal', # French Canadian 'Hans', 'Marlene', 'Vicki', # German 'Aditi', # Hindi diff --git a/homeassistant/components/twilio/.translations/ca.json b/homeassistant/components/twilio/.translations/ca.json index 324ab0dd69a..796f71ee7e7 100644 --- a/homeassistant/components/twilio/.translations/ca.json +++ b/homeassistant/components/twilio/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar Twilio?", - "title": "Configuraci\u00f3 del Webhook de Twilio" + "title": "Configuraci\u00f3 del Webhook Twilio" } }, "title": "Twilio" diff --git a/homeassistant/components/twilio/.translations/de.json b/homeassistant/components/twilio/.translations/de.json index 86e5d9051b3..91a195780fd 100644 --- a/homeassistant/components/twilio/.translations/de.json +++ b/homeassistant/components/twilio/.translations/de.json @@ -1,5 +1,18 @@ { "config": { + "abort": { + "not_internet_accessible": "Ihre Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Twilio-Nachrichten empfangen zu k\u00f6nnen.", + "one_instance_allowed": "Es ist nur eine einzige Instanz erforderlich." + }, + "create_entry": { + "default": "Um Ereignisse an den Home Assistant zu senden, m\u00fcssen Sie [Webhooks mit Twilio]({twilio_url}) einrichten. \n\n F\u00fcllen Sie die folgenden Informationen aus: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhaltstyp: application / x-www-form-urlencoded \n\nLesen Sie in der [Dokumentation]({docs_url}) wie Sie Automationen f\u00fcr die Verarbeitung eingehender Daten konfigurieren." + }, + "step": { + "user": { + "description": "M\u00f6chten Sie Twilio wirklich einrichten?", + "title": "Twilio-Webhook einrichten" + } + }, "title": "Twilio" } } \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/es.json b/homeassistant/components/twilio/.translations/es.json index 7927ce63e7f..6dbeff23c75 100644 --- a/homeassistant/components/twilio/.translations/es.json +++ b/homeassistant/components/twilio/.translations/es.json @@ -9,7 +9,8 @@ }, "step": { "user": { - "description": "\u00bfEst\u00e1s seguro de que quieres configurar Twilio?" + "description": "\u00bfEst\u00e1s seguro de que quieres configurar Twilio?", + "title": "Configurar el Webhook de Twilio" } }, "title": "Twilio" diff --git a/homeassistant/components/unifi/.translations/de.json b/homeassistant/components/unifi/.translations/de.json index 346c1937355..2b71d01417b 100644 --- a/homeassistant/components/unifi/.translations/de.json +++ b/homeassistant/components/unifi/.translations/de.json @@ -1,16 +1,26 @@ { "config": { "abort": { + "already_configured": "Controller-Site ist bereits konfiguriert", "user_privilege": "Der Benutzer muss Administrator sein" }, + "error": { + "faulty_credentials": "Ung\u00fcltige Anmeldeinformationen", + "service_unavailable": "Kein Dienst verf\u00fcgbar" + }, "step": { "user": { "data": { + "host": "Host", "password": "Passwort", "port": "Port", - "username": "Benutzername" - } + "site": "Site-ID", + "username": "Benutzername", + "verify_ssl": "Controller mit ordnungsgem\u00e4ssem Zertifikat" + }, + "title": "UniFi-Controller einrichten" } - } + }, + "title": "UniFi-Controller" } } \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/pt-BR.json b/homeassistant/components/unifi/.translations/pt-BR.json index 8b00dada642..d40dee22f24 100644 --- a/homeassistant/components/unifi/.translations/pt-BR.json +++ b/homeassistant/components/unifi/.translations/pt-BR.json @@ -1,8 +1,13 @@ { "config": { "abort": { + "already_configured": "O site de controle j\u00e1 est\u00e1 configurado", "user_privilege": "O usu\u00e1rio precisa ser administrador" }, + "error": { + "faulty_credentials": "Credenciais do usu\u00e1rio inv\u00e1lidas", + "service_unavailable": "Servi\u00e7o indispon\u00edvel" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 1e07d8cb83d..8476aade7d7 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -22,7 +22,7 @@ DEFAULT_PORT = 8443 DEFAULT_SITE_ID = 'default' DEFAULT_VERIFY_SSL = False -REQUIREMENTS = ['aiounifi==3'] +REQUIREMENTS = ['aiounifi==4'] async def async_setup(hass, config): diff --git a/homeassistant/components/upnp/.translations/ca.json b/homeassistant/components/upnp/.translations/ca.json index 5f2606a448f..161b5d85599 100644 --- a/homeassistant/components/upnp/.translations/ca.json +++ b/homeassistant/components/upnp/.translations/ca.json @@ -4,9 +4,15 @@ "already_configured": "UPnP/IGD ja est\u00e0 configurat", "incomplete_device": "Ignorant el dispositiu incomplet UPnP", "no_devices_discovered": "No s'ha trobat cap UPnP/IGD", - "no_sensors_or_port_mapping": "Activa, com a m\u00ednim, els sensors o l'assignaci\u00f3 de ports" + "no_devices_found": "No s'han trobat dispositius UPnP/IGD a la xarxa.", + "no_sensors_or_port_mapping": "Activa, com a m\u00ednim, els sensors o l'assignaci\u00f3 de ports", + "single_instance_allowed": "Nom\u00e9s cal una sola configuraci\u00f3 de UPnP/IGD." }, "step": { + "confirm": { + "description": "Vols configurar UPnP/IGD?", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, diff --git a/homeassistant/components/upnp/.translations/de.json b/homeassistant/components/upnp/.translations/de.json index 675d1eb7d0c..51faf56367d 100644 --- a/homeassistant/components/upnp/.translations/de.json +++ b/homeassistant/components/upnp/.translations/de.json @@ -2,10 +2,17 @@ "config": { "abort": { "already_configured": "UPnP/IGD ist bereits konfiguriert", + "incomplete_device": "Unvollst\u00e4ndiges UPnP-Ger\u00e4t wird ignoriert", "no_devices_discovered": "Keine UPnP/IGDs entdeckt", - "no_sensors_or_port_mapping": "Aktiviere mindestens Sensoren oder Port-Mapping" + "no_devices_found": "Keine UPnP/IGD-Ger\u00e4te im Netzwerk gefunden.", + "no_sensors_or_port_mapping": "Aktiviere mindestens Sensoren oder Port-Mapping", + "single_instance_allowed": "Es ist nur eine einzige Konfiguration von UPnP/IGD erforderlich." }, "step": { + "confirm": { + "description": "M\u00f6chten Sie UPnP/IGD einrichten?", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, diff --git a/homeassistant/components/upnp/.translations/en.json b/homeassistant/components/upnp/.translations/en.json index 76384beac71..632d5112f1a 100644 --- a/homeassistant/components/upnp/.translations/en.json +++ b/homeassistant/components/upnp/.translations/en.json @@ -4,9 +4,15 @@ "already_configured": "UPnP/IGD is already configured", "incomplete_device": "Ignoring incomplete UPnP device", "no_devices_discovered": "No UPnP/IGDs discovered", - "no_sensors_or_port_mapping": "Enable at least sensors or port mapping" + "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" }, diff --git a/homeassistant/components/upnp/.translations/es.json b/homeassistant/components/upnp/.translations/es.json index 652ff87d9d4..6afdeca8047 100644 --- a/homeassistant/components/upnp/.translations/es.json +++ b/homeassistant/components/upnp/.translations/es.json @@ -6,6 +6,10 @@ "no_devices_discovered": "No se descubrieron UPnP / IGDs", "no_sensors_or_port_mapping": "Habilitar al menos sensores o mapeo de puertos" }, + "error": { + "one": "UNO", + "other": "OTRO" + }, "step": { "init": { "title": "UPnP / IGD" diff --git a/homeassistant/components/upnp/.translations/et.json b/homeassistant/components/upnp/.translations/et.json new file mode 100644 index 00000000000..0c49a92bc0a --- /dev/null +++ b/homeassistant/components/upnp/.translations/et.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "init": { + "title": "" + }, + "user": { + "data": { + "igd": "" + } + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/hu.json b/homeassistant/components/upnp/.translations/hu.json index 466c80f9e56..f2fd380b1e3 100644 --- a/homeassistant/components/upnp/.translations/hu.json +++ b/homeassistant/components/upnp/.translations/hu.json @@ -1,10 +1,17 @@ { "config": { + "abort": { + "no_devices_found": "Nincsenek UPnPIGD eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton." + }, "error": { "one": "hiba", "other": "" }, "step": { + "confirm": { + "description": "Be akarja \u00e1ll\u00edtani a UPnP/IGD-t?", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, diff --git a/homeassistant/components/upnp/.translations/ko.json b/homeassistant/components/upnp/.translations/ko.json index 9e10ae1d67c..d38d5be58ba 100644 --- a/homeassistant/components/upnp/.translations/ko.json +++ b/homeassistant/components/upnp/.translations/ko.json @@ -4,12 +4,18 @@ "already_configured": "UPnP/IGD \uac00 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4", "incomplete_device": "\ubd88\uc644\uc804\ud55c UPnP \uc7a5\uce58 \ubb34\uc2dc\ud558\uae30", "no_devices_discovered": "\ubc1c\uacac\ub41c UPnP/IGD \uac00 \uc5c6\uc2b5\ub2c8\ub2e4", - "no_sensors_or_port_mapping": "\ucd5c\uc18c\ud55c \uc13c\uc11c \ud639\uc740 \ud3ec\ud2b8 \ub9e4\ud551\uc744 \ud65c\uc131\ud654 \ud574\uc57c \ud569\ub2c8\ub2e4" + "no_devices_found": "UPnP/IGD \uc7a5\uce58\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "no_sensors_or_port_mapping": "\ucd5c\uc18c\ud55c \uc13c\uc11c \ud639\uc740 \ud3ec\ud2b8 \ub9e4\ud551\uc744 \ud65c\uc131\ud654 \ud574\uc57c \ud569\ub2c8\ub2e4", + "single_instance_allowed": "\ud558\ub098\uc758 UPnP/IGD \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." }, "error": { "other": "\ub2e4\ub978" }, "step": { + "confirm": { + "description": "UPnP/IGD \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, diff --git a/homeassistant/components/upnp/.translations/lb.json b/homeassistant/components/upnp/.translations/lb.json index 183144afb53..029e1e87cf1 100644 --- a/homeassistant/components/upnp/.translations/lb.json +++ b/homeassistant/components/upnp/.translations/lb.json @@ -4,13 +4,19 @@ "already_configured": "UPnP/IGD ass scho konfigur\u00e9iert", "incomplete_device": "Ignor\u00e9iert onvollst\u00e4nnegen UPnP-Apparat", "no_devices_discovered": "Keng UPnP/IGDs entdeckt", - "no_sensors_or_port_mapping": "Aktiv\u00e9ier op mannst Sensoren oder Port Mapping" + "no_devices_found": "Keng UPnP/IGD Apparater am Netzwierk fonnt.", + "no_sensors_or_port_mapping": "Aktiv\u00e9ier op mannst Sensoren oder Port Mapping", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun UPnP/IGD ass n\u00e9ideg." }, "error": { "one": "Een", "other": "Aaner" }, "step": { + "confirm": { + "description": "Soll UPnP/IGD konfigur\u00e9iert ginn?", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, diff --git a/homeassistant/components/upnp/.translations/nl.json b/homeassistant/components/upnp/.translations/nl.json index c6939f9a0a7..5d426f2edaf 100644 --- a/homeassistant/components/upnp/.translations/nl.json +++ b/homeassistant/components/upnp/.translations/nl.json @@ -1,12 +1,18 @@ { "config": { "abort": { - "already_configured": "UPnP / IGD is al geconfigureerd", + "already_configured": "UPnP/IGD is al geconfigureerd", "incomplete_device": "Onvolledig UPnP-apparaat negeren", - "no_devices_discovered": "Geen UPnP / IGD's ontdekt", - "no_sensors_or_port_mapping": "Schakel ten minste sensoren of poorttoewijzing in" + "no_devices_discovered": "Geen UPnP'/IGD's ontdekt", + "no_devices_found": "Geen UPnP/IGD apparaten gevonden op het netwerk.", + "no_sensors_or_port_mapping": "Schakel ten minste sensoren of poorttoewijzing in", + "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van UPnP/IGD nodig." }, "step": { + "confirm": { + "description": "Wilt u UPnP/IGD instellen?", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, @@ -19,6 +25,6 @@ "title": "Configuratiemogelijkheden voor de UPnP/IGD" } }, - "title": "UPnP / IGD" + "title": "UPnP/IGD" } } \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/no.json b/homeassistant/components/upnp/.translations/no.json index 50b661627e3..813509121e3 100644 --- a/homeassistant/components/upnp/.translations/no.json +++ b/homeassistant/components/upnp/.translations/no.json @@ -4,7 +4,9 @@ "already_configured": "UPnP / IGD er allerede konfigurert", "incomplete_device": "Ignorerer ufullstendig UPnP-enhet", "no_devices_discovered": "Ingen UPnP / IGDs oppdaget", - "no_sensors_or_port_mapping": "Aktiver minst sensorer eller port mapping" + "no_devices_found": "Ingen UPnP / IGD-enheter funnet p\u00e5 nettverket.", + "no_sensors_or_port_mapping": "Aktiver minst sensorer eller port mapping", + "single_instance_allowed": "Bare en enkelt konfigurasjon av UPnP / IGD er n\u00f8dvendig." }, "error": { "few": "f\u00e5", @@ -15,6 +17,10 @@ "zero": "ingen" }, "step": { + "confirm": { + "description": "\u00d8nsker du \u00e5 konfigurere UPnP / IGD?", + "title": "UPnP / IGD" + }, "init": { "title": "UPnP / IGD" }, diff --git a/homeassistant/components/upnp/.translations/pl.json b/homeassistant/components/upnp/.translations/pl.json index d01946cb6e2..d7ede44d22d 100644 --- a/homeassistant/components/upnp/.translations/pl.json +++ b/homeassistant/components/upnp/.translations/pl.json @@ -4,7 +4,9 @@ "already_configured": "UPnP/IGD jest ju\u017c skonfigurowane", "incomplete_device": "Ignorowanie niekompletnego urz\u0105dzenia UPnP", "no_devices_discovered": "Nie wykryto urz\u0105dze\u0144 UPnP/IGD", - "no_sensors_or_port_mapping": "W\u0142\u0105cz przynajmniej sensory lub mapowanie port\u00f3w" + "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 UPnP/IGD.", + "no_sensors_or_port_mapping": "W\u0142\u0105cz przynajmniej sensory lub mapowanie port\u00f3w", + "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja UPnP/IGD." }, "error": { "few": "kilka", @@ -13,6 +15,10 @@ "other": "inne" }, "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 UPnP/IGD?", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, diff --git a/homeassistant/components/upnp/.translations/pt-BR.json b/homeassistant/components/upnp/.translations/pt-BR.json new file mode 100644 index 00000000000..4dd71176cf4 --- /dev/null +++ b/homeassistant/components/upnp/.translations/pt-BR.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_devices_discovered": "Nenhum UPnP/IGD descoberto", + "no_devices_found": "Nenhum dispositivo UPnP/IGD encontrado na rede.", + "no_sensors_or_port_mapping": "Ative pelo menos sensores ou mapeamento de porta", + "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do UPnP/IGD \u00e9 necess\u00e1ria." + }, + "step": { + "confirm": { + "description": "Deseja configurar o UPnP/IGD?", + "title": "UPnP/IGD" + }, + "init": { + "title": "UPnP/IGD" + }, + "user": { + "data": { + "enable_port_mapping": "Ativar o mapeamento de porta para o Home Assistant", + "enable_sensors": "Adicionar sensores de tr\u00e1fego", + "igd": "UPnP/IGD" + }, + "title": "Op\u00e7\u00f5es de configura\u00e7\u00e3o para o UPnP/IGD" + } + }, + "title": "UPnP/IGD" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/pt.json b/homeassistant/components/upnp/.translations/pt.json index 99d056a7d78..5c5693e6a0c 100644 --- a/homeassistant/components/upnp/.translations/pt.json +++ b/homeassistant/components/upnp/.translations/pt.json @@ -4,13 +4,19 @@ "already_configured": "UPnP/IGD j\u00e1 est\u00e1 configurado", "incomplete_device": "Dispositivos UPnP incompletos ignorados", "no_devices_discovered": "Nenhum UPnP/IGDs descoberto", - "no_sensors_or_port_mapping": "Ative pelo menos os sensores ou o mapeamento de porta" + "no_devices_found": "Nenhum dispositivo UPnP / IGD encontrado na rede.", + "no_sensors_or_port_mapping": "Ative pelo menos os sensores ou o mapeamento de porta", + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do UPnP/IGD \u00e9 necess\u00e1ria." }, "error": { "one": "um", "other": "v\u00e1rios" }, "step": { + "confirm": { + "description": "Deseja configurar o UPnP / IGD?", + "title": "" + }, "init": { "title": "UPnP/IGD" }, diff --git a/homeassistant/components/upnp/.translations/ru.json b/homeassistant/components/upnp/.translations/ru.json index 8e86c41366b..6a7c43f9e46 100644 --- a/homeassistant/components/upnp/.translations/ru.json +++ b/homeassistant/components/upnp/.translations/ru.json @@ -4,9 +4,15 @@ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", "incomplete_device": "\u0418\u0433\u043d\u043e\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u043d\u0435\u043f\u043e\u043b\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 UPnP", "no_devices_discovered": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e UPnP / IGD", - "no_sensors_or_port_mapping": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0438\u043b\u0438 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u043e\u0440\u0442\u043e\u0432 " + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 UPnP / IGD \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "no_sensors_or_port_mapping": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0438\u043b\u0438 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u043e\u0440\u0442\u043e\u0432 ", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." }, "step": { + "confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c UPnP / IGD?", + "title": "UPnP / IGD" + }, "init": { "title": "UPnP / IGD" }, diff --git a/homeassistant/components/upnp/.translations/sl.json b/homeassistant/components/upnp/.translations/sl.json index f7052051192..4bf6501bd2a 100644 --- a/homeassistant/components/upnp/.translations/sl.json +++ b/homeassistant/components/upnp/.translations/sl.json @@ -4,7 +4,9 @@ "already_configured": "UPnP/IGD je \u017ee konfiguriran", "incomplete_device": "Ignoriranje nepopolnih UPnP naprav", "no_devices_discovered": "Ni odkritih UPnP/IGD naprav", - "no_sensors_or_port_mapping": "Omogo\u010dite vsaj senzorje ali preslikavo vrat (port mapping)" + "no_devices_found": "Naprav UPnP/IGD ni mogo\u010de najti v omre\u017eju.", + "no_sensors_or_port_mapping": "Omogo\u010dite vsaj senzorje ali preslikavo vrat (port mapping)", + "single_instance_allowed": "Potrebna je samo ena konfiguracija UPnp/IGD." }, "error": { "few": "nekaj", @@ -13,6 +15,10 @@ "two": "dve" }, "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti UPnp/IGD?", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, diff --git a/homeassistant/components/upnp/.translations/uk.json b/homeassistant/components/upnp/.translations/uk.json new file mode 100644 index 00000000000..44268a5b5b5 --- /dev/null +++ b/homeassistant/components/upnp/.translations/uk.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "UPnP/IGD \u0432\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0454\u043d\u043e", + "no_devices_discovered": "\u041d\u0435 \u0432\u0438\u044f\u0432\u043b\u0435\u043d\u043e UPnP/IGD", + "no_sensors_or_port_mapping": "\u0423\u0432\u0456\u043c\u043a\u043d\u0456\u0442\u044c \u043f\u0440\u0438\u043d\u0430\u0439\u043c\u043d\u0456 \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0430\u0431\u043e \u0432\u0456\u0434\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u043d\u044f \u043f\u043e\u0440\u0442\u0456\u0432" + }, + "step": { + "user": { + "data": { + "enable_port_mapping": "\u0423\u0432\u0456\u043c\u043a\u043d\u0443\u0442\u0438 \u0432\u0456\u0434\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u043d\u044f \u043f\u043e\u0440\u0442\u0456\u0432 \u0434\u043b\u044f Home Assistant", + "enable_sensors": "\u0414\u043e\u0434\u0430\u0442\u0438 \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0442\u0440\u0430\u0444\u0456\u043a\u0443" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0457\u0442\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438 UPnP/IGD" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/zh-Hant.json b/homeassistant/components/upnp/.translations/zh-Hant.json index 2c1fe82c523..2a036a1d2f3 100644 --- a/homeassistant/components/upnp/.translations/zh-Hant.json +++ b/homeassistant/components/upnp/.translations/zh-Hant.json @@ -4,9 +4,15 @@ "already_configured": "UPnP/IGD \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "incomplete_device": "\u5ffd\u7565\u4e0d\u76f8\u5bb9 UPnP \u88dd\u7f6e", "no_devices_discovered": "\u672a\u641c\u5c0b\u5230 UPnP/IGD", - "no_sensors_or_port_mapping": "\u81f3\u5c11\u958b\u555f\u611f\u61c9\u5668\u6216\u901a\u8a0a\u57e0\u8f49\u767c" + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 UPnP/IGD \u88dd\u7f6e\u3002", + "no_sensors_or_port_mapping": "\u81f3\u5c11\u958b\u555f\u611f\u61c9\u5668\u6216\u901a\u8a0a\u57e0\u8f49\u767c", + "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 UPnP/IGD \u5373\u53ef\u3002" }, "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a UPnP/IGD\uff1f", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 4799e945be0..6341c9661ed 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -95,7 +95,7 @@ def is_on(hass, entity_id=None): async def async_setup(hass, config): """Set up the vacuum component.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_VACUUMS) await component.async_setup(config) @@ -152,6 +152,16 @@ async def async_setup(hass, config): return True +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class _BaseVacuum(Entity): """Representation of a base vacuum. diff --git a/homeassistant/components/vacuum/neato.py b/homeassistant/components/vacuum/neato.py index 37d4783cdbd..9ec9fe688b7 100644 --- a/homeassistant/components/vacuum/neato.py +++ b/homeassistant/components/vacuum/neato.py @@ -13,7 +13,7 @@ from homeassistant.components.vacuum import ( SUPPORT_STATE, SUPPORT_STOP, SUPPORT_START, STATE_IDLE, STATE_PAUSED, STATE_CLEANING, STATE_DOCKED, STATE_RETURNING, STATE_ERROR, SUPPORT_MAP, ATTR_STATUS, ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON, - SUPPORT_LOCATE) + SUPPORT_LOCATE, SUPPORT_CLEAN_SPOT) from homeassistant.components.neato import ( NEATO_ROBOTS, NEATO_LOGIN, NEATO_MAP_DATA, ACTION, ERRORS, MODE, ALERTS) @@ -24,7 +24,7 @@ DEPENDENCIES = ['neato'] SCAN_INTERVAL = timedelta(minutes=5) SUPPORT_NEATO = SUPPORT_BATTERY | SUPPORT_PAUSE | SUPPORT_RETURN_HOME | \ - SUPPORT_STOP | SUPPORT_START | \ + SUPPORT_STOP | SUPPORT_START | SUPPORT_CLEAN_SPOT | \ SUPPORT_STATE | SUPPORT_MAP | SUPPORT_LOCATE ATTR_CLEAN_START = 'clean_start' @@ -225,3 +225,7 @@ class NeatoConnectedVacuum(StateVacuumDevice): def locate(self, **kwargs): """Locate the robot by making it emit a sound.""" self.robot.locate() + + def clean_spot(self, **kwargs): + """Run a spot cleaning starting from the base.""" + self.robot.start_spot_cleaning() diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 9f9b58ec8b6..ce4dccbaf75 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -93,8 +93,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_UPDATE_INTERVAL): ( vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL))), - vol.Optional(CONF_NAME, default={}): vol.Schema( - {cv.slug: cv.string}), + vol.Optional(CONF_NAME, default={}): + cv.schema_with_slug_keys(cv.string), vol.Optional(CONF_RESOURCES): vol.All( cv.ensure_list, [vol.In(RESOURCES)]), vol.Optional(CONF_REGION): cv.string, diff --git a/homeassistant/components/waterfurnace.py b/homeassistant/components/waterfurnace.py index 0947afea141..bbae6170048 100644 --- a/homeassistant/components/waterfurnace.py +++ b/homeassistant/components/waterfurnace.py @@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery -REQUIREMENTS = ["waterfurnace==1.0.0"] +REQUIREMENTS = ["waterfurnace==1.1.0"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index c4cefa2c2d1..90e717efd9c 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -25,7 +25,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import track_time_interval from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['python-wink==1.10.1', 'pubnubsub-handler==1.0.2'] +REQUIREMENTS = ['python-wink==1.10.1', 'pubnubsub-handler==1.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/.translations/de.json b/homeassistant/components/zha/.translations/de.json new file mode 100644 index 00000000000..280c941b427 --- /dev/null +++ b/homeassistant/components/zha/.translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Es ist nur eine einzige Konfiguration von ZHA zul\u00e4ssig." + }, + "error": { + "cannot_connect": "Kein Verbindung zu ZHA-Ger\u00e4t m\u00f6glich" + }, + "step": { + "user": { + "data": { + "radio_type": "Radio-Type", + "usb_path": "USB-Ger\u00e4te-Pfad" + }, + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/nl.json b/homeassistant/components/zha/.translations/nl.json index e7a3c901c21..b2af24aceac 100644 --- a/homeassistant/components/zha/.translations/nl.json +++ b/homeassistant/components/zha/.translations/nl.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "single_instance_allowed": "Slechts \u00e9\u00e9n configuratie van ZHA is toegestaan." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken met ZHA apparaat." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/zha/.translations/pt-BR.json b/homeassistant/components/zha/.translations/pt-BR.json new file mode 100644 index 00000000000..c8eb87a5181 --- /dev/null +++ b/homeassistant/components/zha/.translations/pt-BR.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do ZHA \u00e9 permitida." + }, + "error": { + "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao dispositivo ZHA." + }, + "step": { + "user": { + "data": { + "radio_type": "Tipo de r\u00e1dio", + "usb_path": "Caminho do Dispositivo USB" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/pt.json b/homeassistant/components/zha/.translations/pt.json index 8db9f20dc7b..0259403fabc 100644 --- a/homeassistant/components/zha/.translations/pt.json +++ b/homeassistant/components/zha/.translations/pt.json @@ -12,8 +12,10 @@ "radio_type": "Tipo de r\u00e1dio", "usb_path": "Caminho do Dispositivo USB" }, - "description": "Vazio" + "description": "Vazio", + "title": "" } - } + }, + "title": "" } } \ No newline at end of file diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 3fe8980c451..335295b2c2c 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -12,7 +12,6 @@ import types import voluptuous as vol from homeassistant import config_entries, const as ha_const -from homeassistant.components.zha.entities import ZhaDeviceEntity import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -21,20 +20,27 @@ from homeassistant.helpers.entity_component import EntityComponent # Loading the config flow file will register the flow from . import config_flow # noqa # pylint: disable=unused-import from . import const as zha_const -from .event import ZhaEvent +from .event import ZhaEvent, ZhaRelayEvent +from . import api +from .helpers import convert_ieee +from .entities import ZhaDeviceEntity from .const import ( COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_DEVICE_CONFIG, CONF_RADIO_TYPE, CONF_USB_PATH, DATA_ZHA, DATA_ZHA_BRIDGE_ID, DATA_ZHA_CONFIG, DATA_ZHA_CORE_COMPONENT, DATA_ZHA_DISPATCHERS, DATA_ZHA_RADIO, DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, DEFAULT_RADIO_TYPE, DOMAIN, ZHA_DISCOVERY_NEW, RadioType, - EVENTABLE_CLUSTERS, DATA_ZHA_CORE_EVENTS, ENABLE_QUIRKS) + EVENTABLE_CLUSTERS, DATA_ZHA_CORE_EVENTS, ENABLE_QUIRKS, + DEVICE_CLASS, SINGLE_INPUT_CLUSTER_DEVICE_CLASS, + SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, CUSTOM_CLUSTER_MAPPINGS, + COMPONENT_CLUSTERS) REQUIREMENTS = [ 'bellows==0.7.0', 'zigpy==0.2.0', 'zigpy-xbee==0.1.1', - 'zha-quirks==0.0.5' + 'zha-quirks==0.0.6', + 'zigpy-deconz==0.0.1' ] DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ @@ -56,22 +62,6 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) -ATTR_DURATION = 'duration' -ATTR_IEEE = 'ieee_address' - -SERVICE_PERMIT = 'permit' -SERVICE_REMOVE = 'remove' -SERVICE_SCHEMAS = { - SERVICE_PERMIT: vol.Schema({ - vol.Optional(ATTR_DURATION, default=60): - vol.All(vol.Coerce(int), vol.Range(1, 254)), - }), - SERVICE_REMOVE: vol.Schema({ - vol.Required(ATTR_IEEE): cv.string, - }), -} - - # Zigbee definitions CENTICELSIUS = 'C-100' @@ -106,6 +96,7 @@ async def async_setup_entry(hass, config_entry): Will automatically load components to support devices found on the network. """ + establish_device_mappings() hass.data[DATA_ZHA] = hass.data.get(DATA_ZHA, {}) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = [] @@ -130,6 +121,11 @@ async def async_setup_entry(hass, config_entry): from zigpy_xbee.zigbee.application import ControllerApplication radio = zigpy_xbee.api.XBee() radio_description = "XBee" + elif radio_type == RadioType.deconz.name: + import zigpy_deconz.api + from zigpy_deconz.zigbee.application import ControllerApplication + radio = zigpy_deconz.api.Deconz() + radio_description = "Deconz" await radio.connect(usb_path, baudrate) hass.data[DATA_ZHA][DATA_ZHA_RADIO] = radio @@ -179,25 +175,7 @@ async def async_setup_entry(hass, config_entry): config_entry, component) ) - async def permit(service): - """Allow devices to join this network.""" - duration = service.data.get(ATTR_DURATION) - _LOGGER.info("Permitting joins for %ss", duration) - await application_controller.permit(duration) - - hass.services.async_register(DOMAIN, SERVICE_PERMIT, permit, - schema=SERVICE_SCHEMAS[SERVICE_PERMIT]) - - async def remove(service): - """Remove a node from the network.""" - from bellows.types import EmberEUI64, uint8_t - ieee = service.data.get(ATTR_IEEE) - ieee = EmberEUI64([uint8_t(p, base=16) for p in ieee.split(':')]) - _LOGGER.info("Removing node %s", ieee) - await application_controller.remove(ieee) - - hass.services.async_register(DOMAIN, SERVICE_REMOVE, remove, - schema=SERVICE_SCHEMAS[SERVICE_REMOVE]) + api.async_load_api(hass, application_controller, listener) def zha_shutdown(event): """Close radio.""" @@ -209,8 +187,7 @@ async def async_setup_entry(hass, config_entry): async def async_unload_entry(hass, config_entry): """Unload ZHA config entry.""" - hass.services.async_remove(DOMAIN, SERVICE_PERMIT) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + api.async_unload_api(hass) dispatchers = hass.data[DATA_ZHA].get(DATA_ZHA_DISPATCHERS, []) for unsub_dispatcher in dispatchers: @@ -236,6 +213,88 @@ async def async_unload_entry(hass, config_entry): return True +def establish_device_mappings(): + """Establish mappings between ZCL objects and HA ZHA objects. + + These cannot be module level, as importing bellows must be done in a + in a function. + """ + from zigpy import zcl, quirks + from zigpy.profiles import PROFILES, zha, zll + from .sensor import RelativeHumiditySensor + + if zha.PROFILE_ID not in DEVICE_CLASS: + DEVICE_CLASS[zha.PROFILE_ID] = {} + if zll.PROFILE_ID not in DEVICE_CLASS: + DEVICE_CLASS[zll.PROFILE_ID] = {} + + EVENTABLE_CLUSTERS.append(zcl.clusters.general.AnalogInput.cluster_id) + EVENTABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) + EVENTABLE_CLUSTERS.append(zcl.clusters.general.MultistateInput.cluster_id) + EVENTABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) + + DEVICE_CLASS[zha.PROFILE_ID].update({ + zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', + zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', + zha.DeviceType.REMOTE_CONTROL: 'binary_sensor', + zha.DeviceType.SMART_PLUG: 'switch', + zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: 'light', + zha.DeviceType.ON_OFF_LIGHT: 'light', + zha.DeviceType.DIMMABLE_LIGHT: 'light', + zha.DeviceType.COLOR_DIMMABLE_LIGHT: 'light', + zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'binary_sensor', + zha.DeviceType.DIMMER_SWITCH: 'binary_sensor', + zha.DeviceType.COLOR_DIMMER_SWITCH: 'binary_sensor', + }) + DEVICE_CLASS[zll.PROFILE_ID].update({ + zll.DeviceType.ON_OFF_LIGHT: 'light', + zll.DeviceType.ON_OFF_PLUGIN_UNIT: 'switch', + zll.DeviceType.DIMMABLE_LIGHT: 'light', + zll.DeviceType.DIMMABLE_PLUGIN_UNIT: 'light', + zll.DeviceType.COLOR_LIGHT: 'light', + zll.DeviceType.EXTENDED_COLOR_LIGHT: 'light', + zll.DeviceType.COLOR_TEMPERATURE_LIGHT: 'light', + zll.DeviceType.COLOR_CONTROLLER: 'binary_sensor', + zll.DeviceType.COLOR_SCENE_CONTROLLER: 'binary_sensor', + zll.DeviceType.CONTROLLER: 'binary_sensor', + zll.DeviceType.SCENE_CONTROLLER: 'binary_sensor', + zll.DeviceType.ON_OFF_SENSOR: 'binary_sensor', + }) + + SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ + zcl.clusters.general.OnOff: 'switch', + zcl.clusters.measurement.RelativeHumidity: 'sensor', + zcl.clusters.measurement.TemperatureMeasurement: 'sensor', + zcl.clusters.measurement.PressureMeasurement: 'sensor', + zcl.clusters.measurement.IlluminanceMeasurement: 'sensor', + zcl.clusters.smartenergy.Metering: 'sensor', + zcl.clusters.homeautomation.ElectricalMeasurement: 'sensor', + zcl.clusters.general.PowerConfiguration: 'sensor', + zcl.clusters.security.IasZone: 'binary_sensor', + zcl.clusters.measurement.OccupancySensing: 'binary_sensor', + zcl.clusters.hvac.Fan: 'fan', + }) + SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({ + zcl.clusters.general.OnOff: 'binary_sensor', + }) + + # A map of device/cluster to component/sub-component + CUSTOM_CLUSTER_MAPPINGS.update({ + (quirks.smartthings.SmartthingsTemperatureHumiditySensor, 64581): + ('sensor', RelativeHumiditySensor) + }) + + # A map of hass components to all Zigbee clusters it could use + for profile_id, classes in DEVICE_CLASS.items(): + profile = PROFILES[profile_id] + for device_type, component in classes.items(): + if component not in COMPONENT_CLUSTERS: + COMPONENT_CLUSTERS[component] = (set(), set()) + clusters = profile.CLUSTERS[device_type] + COMPONENT_CLUSTERS[component][0].update(clusters[0]) + COMPONENT_CLUSTERS[component][1].update(clusters[1]) + + class ApplicationListener: """All handlers for events that happen on the ZigBee application.""" @@ -246,7 +305,6 @@ class ApplicationListener: self._component = EntityComponent(_LOGGER, DOMAIN, hass) self._device_registry = collections.defaultdict(list) self._events = {} - zha_const.populate_data() for component in COMPONENTS: hass.data[DATA_ZHA][component] = ( @@ -285,6 +343,28 @@ class ApplicationListener: if device.ieee in self._events: self._events.pop(device.ieee) + def get_device_entity(self, ieee_str): + """Return ZHADeviceEntity for given ieee.""" + ieee = convert_ieee(ieee_str) + if ieee in self._device_registry: + entities = self._device_registry[ieee] + entity = next( + ent for ent in entities if isinstance(ent, ZhaDeviceEntity)) + return entity + return None + + def get_entities_for_ieee(self, ieee_str): + """Return list of entities for given ieee.""" + ieee = convert_ieee(ieee_str) + if ieee in self._device_registry: + return self._device_registry[ieee] + return [] + + @property + def device_registry(self) -> str: + """Return devices.""" + return self._device_registry + async def async_device_initialized(self, device, join): """Handle device joined and basic information discovered (async).""" import zigpy.profiles @@ -393,10 +473,18 @@ class ApplicationListener: if cluster.cluster_id in EVENTABLE_CLUSTERS: if cluster.endpoint.device.ieee not in self._events: self._events.update({cluster.endpoint.device.ieee: []}) - self._events[cluster.endpoint.device.ieee].append(ZhaEvent( - self._hass, - cluster - )) + from zigpy.zcl.clusters.general import OnOff, LevelControl + if discovery_attr == 'out_clusters' and \ + (cluster.cluster_id == OnOff.cluster_id or + cluster.cluster_id == LevelControl.cluster_id): + self._events[cluster.endpoint.device.ieee].append( + ZhaRelayEvent(self._hass, cluster) + ) + else: + self._events[cluster.endpoint.device.ieee].append(ZhaEvent( + self._hass, + cluster + )) if cluster.cluster_id in profile_clusters: return diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py new file mode 100644 index 00000000000..308c221bf2f --- /dev/null +++ b/homeassistant/components/zha/api.py @@ -0,0 +1,416 @@ +""" +Web socket API for Zigbee Home Automation devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" + +import logging +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.helpers.config_validation as cv +from .entities import ZhaDeviceEntity +from .const import ( + DOMAIN, ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, ATTR_ATTRIBUTE, ATTR_VALUE, + ATTR_MANUFACTURER, ATTR_COMMAND, ATTR_COMMAND_TYPE, ATTR_ARGS, IN, OUT, + CLIENT_COMMANDS, SERVER_COMMANDS, SERVER) + +_LOGGER = logging.getLogger(__name__) + +TYPE = 'type' +CLIENT = 'client' +ID = 'id' +NAME = 'name' +RESPONSE = 'response' +DEVICE_INFO = 'device_info' + +ATTR_DURATION = 'duration' +ATTR_IEEE_ADDRESS = 'ieee_address' +ATTR_IEEE = 'ieee' + +SERVICE_PERMIT = 'permit' +SERVICE_REMOVE = 'remove' +SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = 'set_zigbee_cluster_attribute' +SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = 'issue_zigbee_cluster_command' +ZIGBEE_CLUSTER_SERVICE = 'zigbee_cluster_service' +IEEE_SERVICE = 'ieee_based_service' + +SERVICE_SCHEMAS = { + SERVICE_PERMIT: vol.Schema({ + vol.Optional(ATTR_DURATION, default=60): + vol.All(vol.Coerce(int), vol.Range(1, 254)), + }), + IEEE_SERVICE: vol.Schema({ + vol.Required(ATTR_IEEE_ADDRESS): cv.string, + }), + ZIGBEE_CLUSTER_SERVICE: vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=IN): cv.string + }), + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=IN): cv.string, + vol.Required(ATTR_ATTRIBUTE): cv.positive_int, + vol.Required(ATTR_VALUE): cv.string, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + }), + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Required(ATTR_COMMAND_TYPE): cv.string, + vol.Optional(ATTR_ARGS, default=''): cv.string, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + }), +} + +WS_RECONFIGURE_NODE = 'zha/nodes/reconfigure' +SCHEMA_WS_RECONFIGURE_NODE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_RECONFIGURE_NODE, + vol.Required(ATTR_IEEE): str +}) + +WS_ENTITIES_BY_IEEE = 'zha/entities' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_ENTITIES_BY_IEEE, +}) + +WS_ENTITY_CLUSTERS = 'zha/entities/clusters' +SCHEMA_WS_CLUSTERS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_ENTITY_CLUSTERS, + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_IEEE): str +}) + +WS_ENTITY_CLUSTER_ATTRIBUTES = 'zha/entities/clusters/attributes' +SCHEMA_WS_CLUSTER_ATTRIBUTES = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_ENTITY_CLUSTER_ATTRIBUTES, + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_IEEE): str, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str + }) + +WS_READ_CLUSTER_ATTRIBUTE = 'zha/entities/clusters/attributes/value' +SCHEMA_WS_READ_CLUSTER_ATTRIBUTE = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_READ_CLUSTER_ATTRIBUTE, + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + vol.Required(ATTR_ATTRIBUTE): int, + vol.Optional(ATTR_MANUFACTURER): object, + }) + +WS_ENTITY_CLUSTER_COMMANDS = 'zha/entities/clusters/commands' +SCHEMA_WS_CLUSTER_COMMANDS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_ENTITY_CLUSTER_COMMANDS, + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_IEEE): str, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str +}) + + +@websocket_api.async_response +async def websocket_entity_cluster_attributes(hass, connection, msg): + """Return a list of cluster attributes.""" + entity_id = msg[ATTR_ENTITY_ID] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + component = hass.data.get(entity_id.split('.')[0]) + entity = component.get_entity(entity_id) + cluster_attributes = [] + if entity is not None: + res = await entity.get_cluster_attributes(cluster_id, cluster_type) + if res is not None: + for attr_id in res: + cluster_attributes.append( + { + ID: attr_id, + NAME: res[attr_id][0] + } + ) + _LOGGER.debug("Requested attributes for: %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENTITY_ID, entity_id), + "{}: [{}]".format(RESPONSE, cluster_attributes) + ) + + connection.send_message(websocket_api.result_message( + msg[ID], + cluster_attributes + )) + + +@websocket_api.async_response +async def websocket_entity_cluster_commands(hass, connection, msg): + """Return a list of cluster commands.""" + entity_id = msg[ATTR_ENTITY_ID] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + component = hass.data.get(entity_id.split('.')[0]) + entity = component.get_entity(entity_id) + cluster_commands = [] + if entity is not None: + res = await entity.get_cluster_commands(cluster_id, cluster_type) + if res is not None: + for cmd_id in res[CLIENT_COMMANDS]: + cluster_commands.append( + { + TYPE: CLIENT, + ID: cmd_id, + NAME: res[CLIENT_COMMANDS][cmd_id][0] + } + ) + for cmd_id in res[SERVER_COMMANDS]: + cluster_commands.append( + { + TYPE: SERVER, + ID: cmd_id, + NAME: res[SERVER_COMMANDS][cmd_id][0] + } + ) + _LOGGER.debug("Requested commands for: %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENTITY_ID, entity_id), + "{}: [{}]".format(RESPONSE, cluster_commands) + ) + + connection.send_message(websocket_api.result_message( + msg[ID], + cluster_commands + )) + + +@websocket_api.async_response +async def websocket_read_zigbee_cluster_attributes(hass, connection, msg): + """Read zigbee attribute for cluster on zha entity.""" + entity_id = msg[ATTR_ENTITY_ID] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + attribute = msg[ATTR_ATTRIBUTE] + component = hass.data.get(entity_id.split('.')[0]) + entity = component.get_entity(entity_id) + clusters = await entity.get_clusters() + cluster = clusters[cluster_type][cluster_id] + manufacturer = msg.get(ATTR_MANUFACTURER) or None + success = failure = None + if entity is not None: + success, failure = await cluster.read_attributes( + [attribute], + allow_cache=False, + only_cache=False, + manufacturer=manufacturer + ) + _LOGGER.debug("Read attribute for: %s %s %s %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENTITY_ID, entity_id), + "{}: [{}]".format(ATTR_ATTRIBUTE, attribute), + "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer), + "{}: [{}]".format(RESPONSE, str(success.get(attribute))), + "{}: [{}]".format('failure', failure) + ) + connection.send_message(websocket_api.result_message( + msg[ID], + str(success.get(attribute)) + )) + + +def async_load_api(hass, application_controller, listener): + """Set up the web socket API.""" + async def permit(service): + """Allow devices to join this network.""" + duration = service.data.get(ATTR_DURATION) + _LOGGER.info("Permitting joins for %ss", duration) + await application_controller.permit(duration) + + hass.services.async_register(DOMAIN, SERVICE_PERMIT, permit, + schema=SERVICE_SCHEMAS[SERVICE_PERMIT]) + + async def remove(service): + """Remove a node from the network.""" + from bellows.types import EmberEUI64, uint8_t + ieee = service.data.get(ATTR_IEEE_ADDRESS) + ieee = EmberEUI64([uint8_t(p, base=16) for p in ieee.split(':')]) + _LOGGER.info("Removing node %s", ieee) + await application_controller.remove(ieee) + + hass.services.async_register(DOMAIN, SERVICE_REMOVE, remove, + schema=SERVICE_SCHEMAS[IEEE_SERVICE]) + + async def set_zigbee_cluster_attributes(service): + """Set zigbee attribute for cluster on zha entity.""" + entity_id = service.data.get(ATTR_ENTITY_ID) + cluster_id = service.data.get(ATTR_CLUSTER_ID) + cluster_type = service.data.get(ATTR_CLUSTER_TYPE) + attribute = service.data.get(ATTR_ATTRIBUTE) + value = service.data.get(ATTR_VALUE) + manufacturer = service.data.get(ATTR_MANUFACTURER) or None + component = hass.data.get(entity_id.split('.')[0]) + entity = component.get_entity(entity_id) + response = None + if entity is not None: + response = await entity.write_zigbe_attribute( + cluster_id, + attribute, + value, + cluster_type=cluster_type, + manufacturer=manufacturer + ) + _LOGGER.debug("Set attribute for: %s %s %s %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENTITY_ID, entity_id), + "{}: [{}]".format(ATTR_ATTRIBUTE, attribute), + "{}: [{}]".format(ATTR_VALUE, value), + "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer), + "{}: [{}]".format(RESPONSE, response) + ) + + hass.services.async_register(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + set_zigbee_cluster_attributes, + schema=SERVICE_SCHEMAS[ + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE + ]) + + async def issue_zigbee_cluster_command(service): + """Issue command on zigbee cluster on zha entity.""" + entity_id = service.data.get(ATTR_ENTITY_ID) + cluster_id = service.data.get(ATTR_CLUSTER_ID) + cluster_type = service.data.get(ATTR_CLUSTER_TYPE) + command = service.data.get(ATTR_COMMAND) + command_type = service.data.get(ATTR_COMMAND_TYPE) + args = service.data.get(ATTR_ARGS) + manufacturer = service.data.get(ATTR_MANUFACTURER) or None + component = hass.data.get(entity_id.split('.')[0]) + entity = component.get_entity(entity_id) + response = None + if entity is not None: + response = await entity.issue_cluster_command( + cluster_id, + command, + command_type, + args, + cluster_type=cluster_type, + manufacturer=manufacturer + ) + _LOGGER.debug("Issue command for: %s %s %s %s %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENTITY_ID, entity_id), + "{}: [{}]".format(ATTR_COMMAND, command), + "{}: [{}]".format(ATTR_COMMAND_TYPE, command_type), + "{}: [{}]".format(ATTR_ARGS, args), + "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer), + "{}: [{}]".format(RESPONSE, response) + ) + + hass.services.async_register(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + issue_zigbee_cluster_command, + schema=SERVICE_SCHEMAS[ + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND + ]) + + @websocket_api.async_response + async def websocket_reconfigure_node(hass, connection, msg): + """Reconfigure a ZHA nodes entities by its ieee address.""" + ieee = msg[ATTR_IEEE] + entities = listener.get_entities_for_ieee(ieee) + _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) + for entity in entities: + if hasattr(entity, 'async_configure'): + hass.async_create_task(entity.async_configure()) + + hass.components.websocket_api.async_register_command( + WS_RECONFIGURE_NODE, websocket_reconfigure_node, + SCHEMA_WS_RECONFIGURE_NODE + ) + + @websocket_api.async_response + async def websocket_entities_by_ieee(hass, connection, msg): + """Return a dict of all zha entities grouped by ieee.""" + entities_by_ieee = {} + for ieee, entities in listener.device_registry.items(): + ieee_string = str(ieee) + entities_by_ieee[ieee_string] = [] + for entity in entities: + if not isinstance(entity, ZhaDeviceEntity): + entities_by_ieee[ieee_string].append({ + ATTR_ENTITY_ID: entity.entity_id, + DEVICE_INFO: entity.device_info + }) + connection.send_message(websocket_api.result_message( + msg[ID], + entities_by_ieee + )) + + hass.components.websocket_api.async_register_command( + WS_ENTITIES_BY_IEEE, websocket_entities_by_ieee, + SCHEMA_WS_LIST + ) + + @websocket_api.async_response + async def websocket_entity_clusters(hass, connection, msg): + """Return a list of entity clusters.""" + entity_id = msg[ATTR_ENTITY_ID] + entities = listener.get_entities_for_ieee(msg[ATTR_IEEE]) + entity = next( + ent for ent in entities if ent.entity_id == entity_id) + entity_clusters = await entity.get_clusters() + clusters = [] + + for cluster_id, cluster in entity_clusters[IN].items(): + clusters.append({ + TYPE: IN, + ID: cluster_id, + NAME: cluster.__class__.__name__ + }) + for cluster_id, cluster in entity_clusters[OUT].items(): + clusters.append({ + TYPE: OUT, + ID: cluster_id, + NAME: cluster.__class__.__name__ + }) + + connection.send_message(websocket_api.result_message( + msg[ID], + clusters + )) + + hass.components.websocket_api.async_register_command( + WS_ENTITY_CLUSTERS, websocket_entity_clusters, + SCHEMA_WS_CLUSTERS + ) + + hass.components.websocket_api.async_register_command( + WS_ENTITY_CLUSTER_ATTRIBUTES, websocket_entity_cluster_attributes, + SCHEMA_WS_CLUSTER_ATTRIBUTES + ) + + hass.components.websocket_api.async_register_command( + WS_ENTITY_CLUSTER_COMMANDS, websocket_entity_cluster_commands, + SCHEMA_WS_CLUSTER_COMMANDS + ) + + hass.components.websocket_api.async_register_command( + WS_READ_CLUSTER_ATTRIBUTE, websocket_read_zigbee_cluster_attributes, + SCHEMA_WS_READ_CLUSTER_ATTRIBUTE + ) + + +def async_unload_api(hass): + """Unload the ZHA API.""" + hass.services.async_remove(DOMAIN, SERVICE_PERMIT) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/zha/binary_sensor.py similarity index 98% rename from homeassistant/components/binary_sensor/zha.py rename to homeassistant/components/zha/binary_sensor.py index 0d426f0aa14..fce9376700e 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -7,16 +7,16 @@ at https://home-assistant.io/components/binary_sensor.zha/ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice -from homeassistant.components.zha import helpers -from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) -from homeassistant.components.zha.entities import ZhaEntity from homeassistant.const import STATE_ON -from homeassistant.components.zha.entities.listeners import ( - OnOffListener, LevelListener -) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity +from . import helpers +from .const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) +from .entities import ZhaEntity +from .entities.listeners import ( + OnOffListener, LevelListener +) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 5b650c95cc4..47c3982c5d6 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -36,12 +36,28 @@ DEFAULT_RADIO_TYPE = 'ezsp' DEFAULT_BAUDRATE = 57600 DEFAULT_DATABASE_NAME = 'zigbee.db' +ATTR_CLUSTER_ID = 'cluster_id' +ATTR_CLUSTER_TYPE = 'cluster_type' +ATTR_ATTRIBUTE = 'attribute' +ATTR_VALUE = 'value' +ATTR_MANUFACTURER = 'manufacturer' +ATTR_COMMAND = 'command' +ATTR_COMMAND_TYPE = 'command_type' +ATTR_ARGS = 'args' + +IN = 'in' +OUT = 'out' +CLIENT_COMMANDS = 'client_commands' +SERVER_COMMANDS = 'server_commands' +SERVER = 'server' + class RadioType(enum.Enum): """Possible options for radio type.""" ezsp = 'ezsp' xbee = 'xbee' + deconz = 'deconz' @classmethod def list(cls): @@ -77,85 +93,3 @@ REPORT_CONFIG_IMMEDIATE = (REPORT_CONFIG_MIN_INT_IMMEDIATE, REPORT_CONFIG_RPT_CHANGE) REPORT_CONFIG_OP = (REPORT_CONFIG_MIN_INT_OP, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_RPT_CHANGE) - - -def populate_data(): - """Populate data using constants from bellows. - - These cannot be module level, as importing bellows must be done in a - in a function. - """ - from zigpy import zcl, quirks - from zigpy.profiles import PROFILES, zha, zll - from homeassistant.components.sensor import zha as sensor_zha - - if zha.PROFILE_ID not in DEVICE_CLASS: - DEVICE_CLASS[zha.PROFILE_ID] = {} - if zll.PROFILE_ID not in DEVICE_CLASS: - DEVICE_CLASS[zll.PROFILE_ID] = {} - - EVENTABLE_CLUSTERS.append(zcl.clusters.general.AnalogInput.cluster_id) - EVENTABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) - EVENTABLE_CLUSTERS.append(zcl.clusters.general.MultistateInput.cluster_id) - EVENTABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) - - DEVICE_CLASS[zha.PROFILE_ID].update({ - zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', - zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', - zha.DeviceType.REMOTE_CONTROL: 'binary_sensor', - zha.DeviceType.SMART_PLUG: 'switch', - zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: 'light', - zha.DeviceType.ON_OFF_LIGHT: 'light', - zha.DeviceType.DIMMABLE_LIGHT: 'light', - zha.DeviceType.COLOR_DIMMABLE_LIGHT: 'light', - zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'binary_sensor', - zha.DeviceType.DIMMER_SWITCH: 'binary_sensor', - zha.DeviceType.COLOR_DIMMER_SWITCH: 'binary_sensor', - }) - DEVICE_CLASS[zll.PROFILE_ID].update({ - zll.DeviceType.ON_OFF_LIGHT: 'light', - zll.DeviceType.ON_OFF_PLUGIN_UNIT: 'switch', - zll.DeviceType.DIMMABLE_LIGHT: 'light', - zll.DeviceType.DIMMABLE_PLUGIN_UNIT: 'light', - zll.DeviceType.COLOR_LIGHT: 'light', - zll.DeviceType.EXTENDED_COLOR_LIGHT: 'light', - zll.DeviceType.COLOR_TEMPERATURE_LIGHT: 'light', - zll.DeviceType.COLOR_CONTROLLER: 'binary_sensor', - zll.DeviceType.COLOR_SCENE_CONTROLLER: 'binary_sensor', - zll.DeviceType.CONTROLLER: 'binary_sensor', - zll.DeviceType.SCENE_CONTROLLER: 'binary_sensor', - zll.DeviceType.ON_OFF_SENSOR: 'binary_sensor', - }) - - SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ - zcl.clusters.general.OnOff: 'switch', - zcl.clusters.measurement.RelativeHumidity: 'sensor', - zcl.clusters.measurement.TemperatureMeasurement: 'sensor', - zcl.clusters.measurement.PressureMeasurement: 'sensor', - zcl.clusters.measurement.IlluminanceMeasurement: 'sensor', - zcl.clusters.smartenergy.Metering: 'sensor', - zcl.clusters.homeautomation.ElectricalMeasurement: 'sensor', - zcl.clusters.general.PowerConfiguration: 'sensor', - zcl.clusters.security.IasZone: 'binary_sensor', - zcl.clusters.measurement.OccupancySensing: 'binary_sensor', - zcl.clusters.hvac.Fan: 'fan', - }) - SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({ - zcl.clusters.general.OnOff: 'binary_sensor', - }) - - # A map of device/cluster to component/sub-component - CUSTOM_CLUSTER_MAPPINGS.update({ - (quirks.smartthings.SmartthingsTemperatureHumiditySensor, 64581): - ('sensor', sensor_zha.RelativeHumiditySensor) - }) - - # A map of hass components to all Zigbee clusters it could use - for profile_id, classes in DEVICE_CLASS.items(): - profile = PROFILES[profile_id] - for device_type, component in classes.items(): - if component not in COMPONENT_CLUSTERS: - COMPONENT_CLUSTERS[component] = (set(), set()) - clusters = profile.CLUSTERS[device_type] - COMPONENT_CLUSTERS[component][0].update(clusters[0]) - COMPONENT_CLUSTERS[component][1].update(clusters[1]) diff --git a/homeassistant/components/zha/entities/entity.py b/homeassistant/components/zha/entities/entity.py index dadd79e82a5..8f8c8e58e05 100644 --- a/homeassistant/components/zha/entities/entity.py +++ b/homeassistant/components/zha/entities/entity.py @@ -8,16 +8,21 @@ import asyncio import logging from random import uniform -from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_BRIDGE_ID, DOMAIN) -from homeassistant.components.zha.helpers import bind_configure_reporting +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import callback from homeassistant.helpers import entity from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.util import slugify +from ..const import ( + DATA_ZHA, DATA_ZHA_BRIDGE_ID, DOMAIN, ATTR_CLUSTER_ID, ATTR_ATTRIBUTE, + ATTR_VALUE, ATTR_MANUFACTURER, ATTR_COMMAND, SERVER, ATTR_COMMAND_TYPE, + ATTR_ARGS, IN, OUT, CLIENT_COMMANDS, SERVER_COMMANDS) +from ..helpers import bind_configure_reporting _LOGGER = logging.getLogger(__name__) +ENTITY_SUFFIX = 'entity_suffix' + class ZhaEntity(entity.Entity): """A base class for ZHA entities.""" @@ -29,6 +34,7 @@ class ZhaEntity(entity.Entity): **kwargs): """Init ZHA entity.""" self._device_state_attributes = {} + self._name = None ieee = endpoint.device.ieee ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) if manufacturer and model is not None: @@ -38,18 +44,15 @@ class ZhaEntity(entity.Entity): slugify(model), ieeetail, endpoint.endpoint_id, - kwargs.get('entity_suffix', ''), - ) - self._device_state_attributes['friendly_name'] = "{} {}".format( - manufacturer, - model, + kwargs.get(ENTITY_SUFFIX, ''), ) + self._name = "{} {}".format(manufacturer, model) else: self.entity_id = "{}.zha_{}_{}{}".format( self._domain, ieeetail, endpoint.endpoint_id, - kwargs.get('entity_suffix', ''), + kwargs.get(ENTITY_SUFFIX, ''), ) self._endpoint = endpoint @@ -69,6 +72,100 @@ class ZhaEntity(entity.Entity): self.manufacturer_code = None application_listener.register_entity(ieee, self) + async def get_clusters(self): + """Get zigbee clusters from this entity.""" + return { + IN: self._in_clusters, + OUT: self._out_clusters + } + + async def _get_cluster(self, cluster_id, cluster_type=IN): + """Get zigbee cluster from this entity.""" + if cluster_type == IN: + cluster = self._in_clusters[cluster_id] + else: + cluster = self._out_clusters[cluster_id] + if cluster is None: + _LOGGER.warning('in_cluster with id: %s not found on entity: %s', + cluster_id, self.entity_id) + return cluster + + async def get_cluster_attributes(self, cluster_id, cluster_type=IN): + """Get zigbee attributes for specified cluster.""" + cluster = await self._get_cluster(cluster_id, cluster_type) + if cluster is None: + return + return cluster.attributes + + async def write_zigbe_attribute(self, cluster_id, attribute, value, + cluster_type=IN, manufacturer=None): + """Write a value to a zigbee attribute for a cluster in this entity.""" + cluster = await self._get_cluster(cluster_id, cluster_type) + if cluster is None: + return + + from zigpy.exceptions import DeliveryError + try: + response = await cluster.write_attributes( + {attribute: value}, + manufacturer=manufacturer + ) + _LOGGER.debug( + 'set: %s for attr: %s to cluster: %s for entity: %s - res: %s', + value, + attribute, + cluster_id, + self.entity_id, + response + ) + return response + except DeliveryError as exc: + _LOGGER.debug( + 'failed to set attribute: %s %s %s %s %s', + '{}: {}'.format(ATTR_VALUE, value), + '{}: {}'.format(ATTR_ATTRIBUTE, attribute), + '{}: {}'.format(ATTR_CLUSTER_ID, cluster_id), + '{}: {}'.format(ATTR_ENTITY_ID, self.entity_id), + exc + ) + + async def get_cluster_commands(self, cluster_id, cluster_type=IN): + """Get zigbee commands for specified cluster.""" + cluster = await self._get_cluster(cluster_id, cluster_type) + if cluster is None: + return + return { + CLIENT_COMMANDS: cluster.client_commands, + SERVER_COMMANDS: cluster.server_commands, + } + + async def issue_cluster_command(self, cluster_id, command, command_type, + args, cluster_type=IN, + manufacturer=None): + """Issue a command against specified zigbee cluster on this entity.""" + cluster = await self._get_cluster(cluster_id, cluster_type) + if cluster is None: + return + response = None + if command_type == SERVER: + response = await cluster.command(command, *args, + manufacturer=manufacturer, + expect_reply=True) + else: + response = await cluster.client_command(command, *args) + + _LOGGER.debug( + 'Issued cluster command: %s %s %s %s %s %s %s', + '{}: {}'.format(ATTR_CLUSTER_ID, cluster_id), + '{}: {}'.format(ATTR_COMMAND, command), + '{}: {}'.format(ATTR_COMMAND_TYPE, command_type), + '{}: {}'.format(ATTR_ARGS, args), + '{}: {}'.format(ATTR_CLUSTER_ID, cluster_type), + '{}: {}'.format(ATTR_MANUFACTURER, manufacturer), + '{}: {}'.format(ATTR_ENTITY_ID, self.entity_id) + ) + return response + async def async_added_to_hass(self): """Handle entity addition to hass. @@ -134,6 +231,11 @@ class ZhaEntity(entity.Entity): cluster = self._out_clusters[cluster_id] return cluster + @property + def name(self): + """Return Entity's default name.""" + return self._name + @property def zcl_reporting_config(self): """Return a dict of ZCL attribute reporting configuration. @@ -201,9 +303,9 @@ class ZhaEntity(entity.Entity): return { 'connections': {(CONNECTION_ZIGBEE, ieee)}, 'identifiers': {(DOMAIN, ieee)}, - 'manufacturer': self._endpoint.manufacturer, + ATTR_MANUFACTURER: self._endpoint.manufacturer, 'model': self._endpoint.model, - 'name': self._device_state_attributes.get('friendly_name', ieee), + 'name': self.name or ieee, 'via_hub': (DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), } diff --git a/homeassistant/components/zha/event.py b/homeassistant/components/zha/event.py index 20175dd097f..7828a695a7b 100644 --- a/homeassistant/components/zha/event.py +++ b/homeassistant/components/zha/event.py @@ -67,3 +67,33 @@ class ZhaEvent(): }, EventOrigin.remote ) + + +class ZhaRelayEvent(ZhaEvent): + """Event relay that can be attached to zigbee clusters.""" + + @callback + def attribute_updated(self, attribute, value): + """Handle an attribute updated on this cluster.""" + self.zha_send_event( + self._cluster, + 'attribute_updated', + { + 'attribute_id': attribute, + 'attribute_name': self._cluster.attributes.get( + attribute, + ['Unknown'])[0], + 'value': value + } + ) + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle a cluster command received on this cluster.""" + if self._cluster.server_commands is not None and\ + self._cluster.server_commands.get(command_id) is not None: + self.zha_send_event( + self._cluster, + self._cluster.server_commands.get(command_id)[0], + args + ) diff --git a/homeassistant/components/fan/zha.py b/homeassistant/components/zha/fan.py similarity index 96% rename from homeassistant/components/fan/zha.py rename to homeassistant/components/zha/fan.py index 1649f2e57ca..630ab3f7bb9 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/zha/fan.py @@ -9,11 +9,11 @@ import logging from homeassistant.components.fan import ( DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, FanEntity) -from homeassistant.components.zha import helpers -from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_OP, ZHA_DISCOVERY_NEW) -from homeassistant.components.zha.entities import ZhaEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import helpers +from .const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_OP, ZHA_DISCOVERY_NEW) +from .entities import ZhaEntity DEPENDENCIES = ['zha'] diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 3212849f721..a182479d221 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -14,7 +14,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): +async def safe_read(cluster, attributes, allow_cache=True, only_cache=False, + manufacturer=None): """Swallow all exceptions from network read. If we throw during initialization, setup fails. Rather have an entity that @@ -25,7 +26,8 @@ async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): result, _ = await cluster.read_attributes( attributes, allow_cache=allow_cache, - only_cache=only_cache + only_cache=only_cache, + manufacturer=manufacturer ) return result except Exception: # pylint: disable=broad-except @@ -116,6 +118,10 @@ async def check_zigpy_connection(usb_path, radio_type, database_path): import zigpy_xbee.api from zigpy_xbee.zigbee.application import ControllerApplication radio = zigpy_xbee.api.XBee() + elif radio_type == RadioType.deconz.name: + import zigpy_deconz.api + from zigpy_deconz.zigbee.application import ControllerApplication + radio = zigpy_deconz.api.Deconz() try: await radio.connect(usb_path, DEFAULT_BAUDRATE) controller = ControllerApplication(radio, database_path) @@ -124,3 +130,9 @@ async def check_zigpy_connection(usb_path, radio_type, database_path): except Exception: # pylint: disable=broad-except return False return True + + +def convert_ieee(ieee_str): + """Convert given ieee string to EUI64.""" + from zigpy.types import EUI64, uint8_t + return EUI64([uint8_t(p, base=16) for p in ieee_str.split(':')]) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/zha/light.py similarity index 97% rename from homeassistant/components/light/zha.py rename to homeassistant/components/zha/light.py index 5b06e8fa321..766608b35b1 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/zha/light.py @@ -7,16 +7,16 @@ at https://home-assistant.io/components/light.zha/ import logging from homeassistant.components import light -from homeassistant.components.zha import helpers -from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, - REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) -from homeassistant.components.zha.entities import ZhaEntity -from homeassistant.components.zha.entities.listeners import ( - OnOffListener, LevelListener -) from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util +from . import helpers +from .const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) +from .entities import ZhaEntity +from .entities.listeners import ( + OnOffListener, LevelListener +) _LOGGER = logging.getLogger(__name__) @@ -155,7 +155,8 @@ class Light(ZhaEntity, light.Light): duration = kwargs.get(light.ATTR_TRANSITION, DEFAULT_DURATION) duration = duration * 10 # tenths of s - if light.ATTR_COLOR_TEMP in kwargs: + if light.ATTR_COLOR_TEMP in kwargs and \ + self.supported_features & light.SUPPORT_COLOR_TEMP: temperature = kwargs[light.ATTR_COLOR_TEMP] try: res = await self._endpoint.light_color.move_to_color_temp( @@ -168,7 +169,8 @@ class Light(ZhaEntity, light.Light): return self._color_temp = temperature - if light.ATTR_HS_COLOR in kwargs: + if light.ATTR_HS_COLOR in kwargs and \ + self.supported_features & light.SUPPORT_COLOR: self._hs_color = kwargs[light.ATTR_HS_COLOR] xy_color = color_util.color_hs_to_xy(*self._hs_color) try: @@ -187,7 +189,6 @@ class Light(ZhaEntity, light.Light): if self._brightness is not None: brightness = kwargs.get( light.ATTR_BRIGHTNESS, self._brightness or 255) - self._brightness = brightness # Move to level with on/off: try: res = await self._endpoint.level.move_to_level_with_on_off( @@ -201,6 +202,7 @@ class Light(ZhaEntity, light.Light): self.entity_id, ex) return self._state = 1 + self._brightness = brightness self.async_schedule_update_ha_state() return diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/zha/sensor.py similarity index 98% rename from homeassistant/components/sensor/zha.py rename to homeassistant/components/zha/sensor.py index 41499cddcb2..dabbcb79815 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/zha/sensor.py @@ -7,14 +7,14 @@ at https://home-assistant.io/components/sensor.zha/ import logging from homeassistant.components.sensor import DOMAIN -from homeassistant.components.zha import helpers -from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_MIN_INT, REPORT_CONFIG_RPT_CHANGE, ZHA_DISCOVERY_NEW) -from homeassistant.components.zha.entities import ZhaEntity from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.temperature import convert as convert_temperature +from . import helpers +from .const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_MIN_INT, REPORT_CONFIG_RPT_CHANGE, ZHA_DISCOVERY_NEW) +from .entities import ZhaEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/services.yaml b/homeassistant/components/zha/services.yaml index 4b1122b8167..c328d69a6c3 100644 --- a/homeassistant/components/zha/services.yaml +++ b/homeassistant/components/zha/services.yaml @@ -13,3 +13,63 @@ remove: ieee_address: description: IEEE address of the node to remove example: "00:0d:6f:00:05:7d:2d:34" + +reconfigure_device: + description: >- + Reconfigure ZHA device (heal device). Use this if you are having issues + with the device. If the device in question is a battery powered device + please ensure it is awake and accepting commands when you use this + service. + fields: + ieee_address: + description: IEEE address of the device to reconfigure + example: "00:0d:6f:00:05:7d:2d:34" + +set_zigbee_cluster_attribute: + description: >- + Set attribute value for the specified cluster on the specified entity. + fields: + entity_id: + description: Entity id + example: "binary_sensor.centralite_3130_00e8fb4e_1" + cluster_id: + description: ZCL cluster to retrieve attributes for + example: 6 + cluster_type: + description: type of the cluster (in or out) + example: "out" + attribute: + description: id of the attribute to set + example: 0 + value: + description: value to write to the attribute + example: 0x0001 + manufacturer: + description: manufacturer code + example: 0x00FC + +issue_zigbee_cluster_command: + description: >- + Issue command on the specified cluster on the specified entity. + fields: + entity_id: + description: Entity id + example: "binary_sensor.centralite_3130_00e8fb4e_1" + cluster_id: + description: ZCL cluster to retrieve attributes for + example: 6 + cluster_type: + description: type of the cluster (in or out) + example: "out" + command: + description: id of the command to execute + example: 0 + command_type: + description: type of the command to execute (client or server) + example: "server" + args: + description: args to pass to the command + example: {} + manufacturer: + description: manufacturer code + example: 0x00FC diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/zha/switch.py similarity index 96% rename from homeassistant/components/switch/zha.py rename to homeassistant/components/zha/switch.py index 0e1080664be..793da4e1e3a 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/zha/switch.py @@ -7,11 +7,11 @@ at https://home-assistant.io/components/switch.zha/ import logging from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.components.zha import helpers -from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) -from homeassistant.components.zha.entities import ZhaEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import helpers +from .const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) +from .entities import ZhaEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zone/.translations/et.json b/homeassistant/components/zone/.translations/et.json new file mode 100644 index 00000000000..aa921f376e7 --- /dev/null +++ b/homeassistant/components/zone/.translations/et.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "init": { + "data": { + "icon": "Ikoon", + "latitude": "Laius", + "longitude": "Pikkus", + "name": "Nimi", + "radius": "Raadius" + }, + "title": "M\u00e4\u00e4ra tsooni parameetrid" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index 8d603d04f59..258841e20d0 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -8,10 +8,10 @@ import logging import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PATH, CONF_SSL, CONF_USERNAME, - CONF_VERIFY_SSL, ATTR_NAME) -import homeassistant.helpers.config_validation as cv + CONF_VERIFY_SSL, ATTR_NAME, ATTR_ID) _LOGGER = logging.getLogger(__name__) @@ -26,20 +26,23 @@ DEFAULT_TIMEOUT = 10 DEFAULT_VERIFY_SSL = True DOMAIN = 'zoneminder' +HOST_CONFIG_SCHEMA = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, + vol.Optional(CONF_PATH_ZMS, default=DEFAULT_PATH_ZMS): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, - vol.Optional(CONF_PATH_ZMS, default=DEFAULT_PATH_ZMS): cv.string, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - }) + DOMAIN: vol.All(cv.ensure_list, [HOST_CONFIG_SCHEMA]) }, extra=vol.ALLOW_EXTRA) SERVICE_SET_RUN_STATE = 'set_run_state' SET_RUN_STATE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ID): cv.string, vol.Required(ATTR_NAME): cv.string }) @@ -48,27 +51,46 @@ def setup(hass, config): """Set up the ZoneMinder component.""" from zoneminder.zm import ZoneMinder - conf = config[DOMAIN] - if conf[CONF_SSL]: - schema = 'https' - else: - schema = 'http' + hass.data[DOMAIN] = {} - server_origin = '{}://{}'.format(schema, conf[CONF_HOST]) - hass.data[DOMAIN] = ZoneMinder(server_origin, - conf.get(CONF_USERNAME), - conf.get(CONF_PASSWORD), - conf.get(CONF_PATH), - conf.get(CONF_PATH_ZMS), - conf.get(CONF_VERIFY_SSL)) + success = True + + for conf in config[DOMAIN]: + if conf[CONF_SSL]: + schema = 'https' + else: + schema = 'http' + + host_name = conf[CONF_HOST] + server_origin = '{}://{}'.format(schema, host_name) + zm_client = ZoneMinder( + server_origin, + conf.get(CONF_USERNAME), + conf.get(CONF_PASSWORD), + conf.get(CONF_PATH), + conf.get(CONF_PATH_ZMS), + conf.get(CONF_VERIFY_SSL) + ) + hass.data[DOMAIN][host_name] = zm_client + + success = zm_client.login() and success def set_active_state(call): """Set the ZoneMinder run state to the given state name.""" - return hass.data[DOMAIN].set_active_state(call.data[ATTR_NAME]) + zm_id = call.data[ATTR_ID] + state_name = call.data[ATTR_NAME] + if zm_id not in hass.data[DOMAIN]: + _LOGGER.error('Invalid ZoneMinder host provided: %s', zm_id) + if not hass.data[DOMAIN][zm_id].set_active_state(state_name): + _LOGGER.error( + 'Unable to change ZoneMinder state. Host: %s, state: %s', + zm_id, + state_name + ) hass.services.register( DOMAIN, SERVICE_SET_RUN_STATE, set_active_state, schema=SET_RUN_STATE_SCHEMA ) - return hass.data[DOMAIN].login() + return success diff --git a/homeassistant/components/zwave/.translations/et.json b/homeassistant/components/zwave/.translations/et.json new file mode 100644 index 00000000000..8c4c45f9c89 --- /dev/null +++ b/homeassistant/components/zwave/.translations/et.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/pt-BR.json b/homeassistant/components/zwave/.translations/pt-BR.json index 16c25cb7cab..2b4b19cde5a 100644 --- a/homeassistant/components/zwave/.translations/pt-BR.json +++ b/homeassistant/components/zwave/.translations/pt-BR.json @@ -1,5 +1,22 @@ { "config": { + "abort": { + "already_configured": "Z-Wave j\u00e1 est\u00e1 configurado.", + "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia do Z-Wave" + }, + "error": { + "option_error": "A valida\u00e7\u00e3o Z-Wave falhou. O caminho para o USB est\u00e1 correto?" + }, + "step": { + "user": { + "data": { + "network_key": "Chave de rede (deixe em branco para gerar automaticamente)", + "usb_path": "Caminho do USB" + }, + "description": "Consulte https://www.home-assistant.io/docs/z-wave/installation/ para obter informa\u00e7\u00f5es sobre as vari\u00e1veis de configura\u00e7\u00e3o", + "title": "Configurar o Z-Wave" + } + }, "title": "Z-Wave" } } \ No newline at end of file diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 7860b545be2..093e6071bb4 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -284,7 +284,7 @@ async def async_setup_entry(hass, config_entry): options.set_console_output(use_debug) - if CONF_NETWORK_KEY in config_entry.data: + if config_entry.data.get(CONF_NETWORK_KEY): options.addOption("NetworkKey", config_entry.data[CONF_NETWORK_KEY]) await hass.async_add_executor_job(options.lock) diff --git a/homeassistant/config.py b/homeassistant/config.py index 10d3ce21a00..0edadf6a78d 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -170,10 +170,9 @@ def _no_duplicate_auth_mfa_module(configs: Sequence[Dict[str, Any]]) \ return configs -PACKAGES_CONFIG_SCHEMA = vol.Schema({ - cv.slug: vol.Schema( # Package names are slugs - {cv.string: vol.Any(dict, list, None)}) # Component configuration -}) +PACKAGES_CONFIG_SCHEMA = cv.schema_with_slug_keys( # Package names are slugs + vol.Schema({cv.string: vol.Any(dict, list, None)}) # Component config +) CUSTOMIZE_DICT_SCHEMA = vol.Schema({ vol.Optional(ATTR_FRIENDLY_NAME): cv.string, @@ -627,7 +626,7 @@ def _identify_config_schema(module: ModuleType) -> \ except (AttributeError, KeyError): return None, None t_schema = str(schema) - if t_schema.startswith('{'): + if t_schema.startswith('{') or 'schema_with_slug_keys' in t_schema: return ('dict', schema) if t_schema.startswith(('[', 'All(.) -ENTITY_ID_PATTERN = re.compile(r"^(\w+)\.(\w+)$") - # How long to wait till things that run on startup have to finish. TIMEOUT_EVENT_START = 15 @@ -76,8 +73,12 @@ def split_entity_id(entity_id: str) -> List[str]: def valid_entity_id(entity_id: str) -> bool: - """Test if an entity ID is a valid format.""" - return ENTITY_ID_PATTERN.match(entity_id) is not None + """Test if an entity ID is a valid format. + + Format: . where both are slugs. + """ + return ('.' in entity_id and + slugify(entity_id) == entity_id.replace('.', '_', 1)) def valid_state(state: str) -> bool: @@ -258,11 +259,15 @@ class HomeAssistant: """ task = None - if asyncio.iscoroutine(target): + check_target = target + if isinstance(target, functools.partial): + check_target = target.func + + if asyncio.iscoroutine(check_target): task = self.loop.create_task(target) # type: ignore - elif is_callback(target): + elif is_callback(check_target): self.loop.call_soon(target, *args) - elif asyncio.iscoroutinefunction(target): + elif asyncio.iscoroutinefunction(check_target): task = self.loop.create_task(target(*args)) else: task = self.loop.run_in_executor( # type: ignore diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 53b246c700d..b2669312e38 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -155,10 +155,9 @@ def _async_get_connector(hass, verify_ssl=True): connector = aiohttp.TCPConnector(loop=hass.loop, ssl=ssl_context) hass.data[key] = connector - @callback - def _async_close_connector(event): + async def _async_close_connector(event): """Close connector pool.""" - connector.close() + await connector.close() hass.bus.async_listen_once( EVENT_HOMEASSISTANT_CLOSE, _async_close_connector) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index c14f4e4fadb..475135b4cce 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -200,10 +200,10 @@ def icon(value): """Validate icon.""" value = str(value) - if value.startswith('mdi:'): + if ':' in value: return value - raise vol.Invalid('Icons should start with prefix "mdi:"') + raise vol.Invalid('Icons should be specifed on the form "prefix:name"') time_period_dict = vol.All( @@ -319,7 +319,26 @@ def service(value): .format(value)) -def slug(value): +def schema_with_slug_keys(value_schema: Union[T, Callable]) -> Callable: + """Ensure dicts have slugs as keys. + + Replacement of vol.Schema({cv.slug: value_schema}) to prevent misleading + "Extra keys" errors from voluptuous. + """ + schema = vol.Schema({str: value_schema}) + + def verify(value: Dict) -> Dict: + """Validate all keys are slugs and then the value_schema.""" + if not isinstance(value, dict): + raise vol.Invalid('expected dictionary') + + for key in value.keys(): + slug(key) + return schema(value) + return verify + + +def slug(value: Any) -> str: """Validate value is a valid slug.""" if value is None: raise vol.Invalid('Slug should not be None') @@ -330,7 +349,7 @@ def slug(value): raise vol.Invalid('invalid slug {} (try {})'.format(value, slg)) -def slugify(value): +def slugify(value: Any) -> str: """Coerce a value to a slug.""" if value is None: raise vol.Invalid('Slug should not be None') @@ -517,7 +536,7 @@ SERVICE_SCHEMA = vol.All(vol.Schema({ vol.Exclusive('service_template', 'service name'): template, vol.Optional('data'): dict, vol.Optional('data_template'): {match_all: template_complex}, - vol.Optional(CONF_ENTITY_ID): entity_ids, + vol.Optional(CONF_ENTITY_ID): comp_entity_ids, }), has_at_least_one_key('service', 'service_template')) NUMERIC_STATE_CONDITION_SCHEMA = vol.All(vol.Schema({ diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index ce876991097..21634121cd2 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -7,7 +7,8 @@ import logging from homeassistant import config as conf_util from homeassistant.setup import async_prepare_setup_platform from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE, MATCH_ALL) + ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE, + ENTITY_MATCH_ALL) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, discovery @@ -163,7 +164,7 @@ class EntityComponent: """ data_ent_id = service.data.get(ATTR_ENTITY_ID) - if data_ent_id in (None, MATCH_ALL): + if data_ent_id in (None, ENTITY_MATCH_ALL): if data_ent_id is None: self.logger.warning( 'Not passing an entity ID to a service to target all ' diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 8216681496b..82530708838 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -122,8 +122,15 @@ class EntityRegistry: entity_id = self.async_get_entity_id(domain, platform, unique_id) if entity_id: return self._async_update_entity( - entity_id, config_entry_id=config_entry_id, - device_id=device_id) + entity_id, + config_entry_id=config_entry_id, + device_id=device_id, + # When we changed our slugify algorithm, we invalidated some + # stored entity IDs with either a __ or ending in _. + # Fix introduced in 0.86 (Jan 23, 2018). Next line can be + # removed when we release 1.0 or in 2019. + new_entity_id='.'.join(slugify(part) for part + in entity_id.split('.', 1))) entity_id = self.async_generate_entity_id( domain, suggested_object_id or '{}_{}'.format(platform, unique_id), diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index e82302dfd3b..d8bbcbc6e12 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -165,7 +165,7 @@ class Template: try: variables['value_json'] = json.loads(value) - except ValueError: + except (ValueError, TypeError): pass try: diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 2ca621154a1..e1c5895b89a 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -1,10 +1,10 @@ """Translation string lookup helpers.""" import logging -from os import path +import pathlib from typing import Any, Dict, Iterable from homeassistant import config_entries -from homeassistant.loader import get_component, bind_hass +from homeassistant.loader import get_component, get_platform, bind_hass from homeassistant.util.json import load_json from .typing import HomeAssistantType @@ -32,24 +32,51 @@ def flatten(data: Dict) -> Dict[str, Any]: def component_translation_file(hass: HomeAssistantType, component: str, language: str) -> str: - """Return the translation json file location for a component.""" - if '.' in component: - name = component.split('.', 1)[1] - else: - name = component + """Return the translation json file location for a component. - module = get_component(hass, component) + For component one of: + - components/light/.translations/nl.json + - components/.translations/group.nl.json + + For platform one of: + - components/light/.translations/hue.nl.json + - components/hue/.translations/light.nl.json + """ + is_platform = '.' in component + + if not is_platform: + module = get_component(hass, component) + assert module is not None + + module_path = pathlib.Path(module.__file__) + + if module.__name__ == module.__package__: + # light/__init__.py + filename = '{}.json'.format(language) + else: + # group.py + filename = '{}.{}.json'.format(component, language) + + return str(module_path.parent / '.translations' / filename) + + # It's a platform + parts = component.split('.', 1) + module = get_platform(hass, *parts) assert module is not None - component_path = path.dirname(module.__file__) - # If loading translations for the package root, (__init__.py), the - # prefix should be skipped. - if module.__name__ == module.__package__: - filename = '{}.json'.format(language) + # Either within HA or custom_components + # Either light/hue.py or hue/light.py + module_path = pathlib.Path(module.__file__) + + # Compare to parent so we don't have to strip off `.py` + if module_path.parent.name == parts[0]: + # this is light/hue.py + filename = "{}.{}.json".format(parts[1], language) else: - filename = '{}.{}.json'.format(name, language) + # this is hue/light.py + filename = "{}.{}.json".format(parts[0], language) - return path.join(component_path, '.translations', filename) + return str(module_path.parent / '.translations' / filename) def load_translations_files(translation_files: Dict[str, str]) \ diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 61aacd3b233..cd22a69dab1 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -52,18 +52,43 @@ def set_component(hass, # type: HomeAssistant def get_platform(hass, # type: HomeAssistant - domain: str, platform: str) -> Optional[ModuleType]: + domain: str, platform_name: str) -> Optional[ModuleType]: """Try to load specified platform. Async friendly. """ - return get_component(hass, PLATFORM_FORMAT.format(domain, platform)) + platform = _load_file(hass, PLATFORM_FORMAT.format( + domain=domain, platform=platform_name)) + + if platform is None: + # Turn it around for legacy purposes + platform = _load_file(hass, PLATFORM_FORMAT.format( + domain=platform_name, platform=domain)) + + if platform is None: + _LOGGER.error("Unable to find platform %s", platform_name) + + return platform def get_component(hass, # type: HomeAssistant comp_or_platform: str) -> Optional[ModuleType]: """Try to load specified component. + Async friendly. + """ + comp = _load_file(hass, comp_or_platform) + + if comp is None: + _LOGGER.error("Unable to find component %s", comp_or_platform) + + return comp + + +def _load_file(hass, # type: HomeAssistant + comp_or_platform: str) -> Optional[ModuleType]: + """Try to load specified file. + Looks in config dir first, then built-in components. Only returns it if also found to be valid. Async friendly. @@ -134,8 +159,6 @@ def get_component(hass, # type: HomeAssistant ("Error loading %s. Make sure all " "dependencies are installed"), path) - _LOGGER.error("Unable to find component %s", comp_or_platform) - return None diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b06287bcf17..06577be4763 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,8 +1,8 @@ -aiohttp==3.5.1 +aiohttp==3.5.4 astral==1.7.1 async_timeout==3.0.1 attrs==18.2.0 -bcrypt==3.1.4 +bcrypt==3.1.5 certifi>=2018.04.16 jinja2>=2.10 PyJWT==1.6.4 @@ -12,7 +12,7 @@ python-slugify==1.2.6 pytz>=2018.07 pyyaml>=3.13,<4 requests==2.21.0 -ruamel.yaml==0.15.81 +ruamel.yaml==0.15.85 voluptuous==0.11.5 voluptuous-serialize==2.0.0 diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index 9837ff64cd5..f7fa33aca37 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ import os from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==17.1.0', 'keyrings.alt==3.1.1'] +REQUIREMENTS = ['keyring==17.1.1', 'keyrings.alt==3.1.1'] def run(args): diff --git a/homeassistant/setup.py b/homeassistant/setup.py index cc7c4284f9c..49aae2178fc 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -190,7 +190,8 @@ async def async_prepare_setup_platform(hass: core.HomeAssistant, config: Dict, This method is a coroutine. """ - platform_path = PLATFORM_FORMAT.format(domain, platform_name) + platform_path = PLATFORM_FORMAT.format(domain=domain, + platform=platform_name) def log_error(msg: str) -> None: """Log helper.""" diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 0538bfbf369..5a32d89a793 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -2,7 +2,8 @@ import math import colorsys -from typing import Tuple, List +from typing import Tuple, List, Optional +import attr # Official CSS3 colors from w3.org: # https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 @@ -162,6 +163,24 @@ COLORS = { } +@attr.s() +class XYPoint: + """Represents a CIE 1931 XY coordinate pair.""" + + x = attr.ib(type=float) + y = attr.ib(type=float) + + +@attr.s() +class GamutType: + """Represents the Gamut of a light.""" + + # ColorGamut = gamut(xypoint(xR,yR),xypoint(xG,yG),xypoint(xB,yB)) + red = attr.ib(type=XYPoint) + green = attr.ib(type=XYPoint) + blue = attr.ib(type=XYPoint) + + def color_name_to_rgb(color_name: str) -> Tuple[int, int, int]: """Convert color name to RGB hex value.""" # COLORS map has no spaces in it, so make the color_name have no @@ -174,9 +193,10 @@ def color_name_to_rgb(color_name: str) -> Tuple[int, int, int]: # pylint: disable=invalid-name -def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float]: +def color_RGB_to_xy(iR: int, iG: int, iB: int, + Gamut: Optional[GamutType] = None) -> Tuple[float, float]: """Convert from RGB color to XY color.""" - return color_RGB_to_xy_brightness(iR, iG, iB)[:2] + return color_RGB_to_xy_brightness(iR, iG, iB, Gamut)[:2] # Taken from: @@ -184,7 +204,8 @@ def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float]: # License: Code is given as is. Use at your own risk and discretion. # pylint: disable=invalid-name def color_RGB_to_xy_brightness( - iR: int, iG: int, iB: int) -> Tuple[float, float, int]: + iR: int, iG: int, iB: int, + Gamut: Optional[GamutType] = None) -> Tuple[float, float, int]: """Convert from RGB color to XY color.""" if iR + iG + iB == 0: return 0.0, 0.0, 0 @@ -214,19 +235,36 @@ def color_RGB_to_xy_brightness( Y = 1 if Y > 1 else Y brightness = round(Y * 255) + # Check if the given xy value is within the color-reach of the lamp. + if Gamut: + in_reach = check_point_in_lamps_reach((x, y), Gamut) + if not in_reach: + xy_closest = get_closest_point_to_point((x, y), Gamut) + x = xy_closest[0] + y = xy_closest[1] + return round(x, 3), round(y, 3), brightness -def color_xy_to_RGB(vX: float, vY: float) -> Tuple[int, int, int]: +def color_xy_to_RGB( + vX: float, vY: float, + Gamut: Optional[GamutType] = None) -> Tuple[int, int, int]: """Convert from XY to a normalized RGB.""" - return color_xy_brightness_to_RGB(vX, vY, 255) + return color_xy_brightness_to_RGB(vX, vY, 255, Gamut) # Converted to Python from Obj-C, original source from: # http://www.developers.meethue.com/documentation/color-conversions-rgb-xy -def color_xy_brightness_to_RGB(vX: float, vY: float, - ibrightness: int) -> Tuple[int, int, int]: +def color_xy_brightness_to_RGB( + vX: float, vY: float, ibrightness: int, + Gamut: Optional[GamutType] = None) -> Tuple[int, int, int]: """Convert from XYZ to RGB.""" + if Gamut: + if not check_point_in_lamps_reach((vX, vY), Gamut): + xy_closest = get_closest_point_to_point((vX, vY), Gamut) + vX = xy_closest[0] + vY = xy_closest[1] + brightness = ibrightness / 255. if brightness == 0: return (0, 0, 0) @@ -338,15 +376,17 @@ def color_hs_to_RGB(iH: float, iS: float) -> Tuple[int, int, int]: return color_hsv_to_RGB(iH, iS, 100) -def color_xy_to_hs(vX: float, vY: float) -> Tuple[float, float]: +def color_xy_to_hs(vX: float, vY: float, + Gamut: Optional[GamutType] = None) -> Tuple[float, float]: """Convert an xy color to its hs representation.""" - h, s, _ = color_RGB_to_hsv(*color_xy_to_RGB(vX, vY)) + h, s, _ = color_RGB_to_hsv(*color_xy_to_RGB(vX, vY, Gamut)) return h, s -def color_hs_to_xy(iH: float, iS: float) -> Tuple[float, float]: +def color_hs_to_xy(iH: float, iS: float, + Gamut: Optional[GamutType] = None) -> Tuple[float, float]: """Convert an hs color to its xy representation.""" - return color_RGB_to_xy(*color_hs_to_RGB(iH, iS)) + return color_RGB_to_xy(*color_hs_to_RGB(iH, iS), Gamut) def _match_max_scale(input_colors: Tuple, output_colors: Tuple) -> Tuple: @@ -474,3 +514,89 @@ def color_temperature_mired_to_kelvin(mired_temperature: float) -> float: def color_temperature_kelvin_to_mired(kelvin_temperature: float) -> float: """Convert degrees kelvin to mired shift.""" return math.floor(1000000 / kelvin_temperature) + + +# The following 5 functions are adapted from rgbxy provided by Benjamin Knight +# License: The MIT License (MIT), 2014. +# https://github.com/benknight/hue-python-rgb-converter +def cross_product(p1: XYPoint, p2: XYPoint) -> float: + """Calculate the cross product of two XYPoints.""" + return float(p1.x * p2.y - p1.y * p2.x) + + +def get_distance_between_two_points(one: XYPoint, two: XYPoint) -> float: + """Calculate the distance between two XYPoints.""" + dx = one.x - two.x + dy = one.y - two.y + return math.sqrt(dx * dx + dy * dy) + + +def get_closest_point_to_line(A: XYPoint, B: XYPoint, P: XYPoint) -> XYPoint: + """ + Find the closest point from P to a line defined by A and B. + + This point will be reproducible by the lamp + as it is on the edge of the gamut. + """ + AP = XYPoint(P.x - A.x, P.y - A.y) + AB = XYPoint(B.x - A.x, B.y - A.y) + ab2 = AB.x * AB.x + AB.y * AB.y + ap_ab = AP.x * AB.x + AP.y * AB.y + t = ap_ab / ab2 + + if t < 0.0: + t = 0.0 + elif t > 1.0: + t = 1.0 + + return XYPoint(A.x + AB.x * t, A.y + AB.y * t) + + +def get_closest_point_to_point(xy_tuple: Tuple[float, float], + Gamut: GamutType) -> Tuple[float, float]: + """ + Get the closest matching color within the gamut of the light. + + Should only be used if the supplied color is outside of the color gamut. + """ + xy_point = XYPoint(xy_tuple[0], xy_tuple[1]) + + # find the closest point on each line in the CIE 1931 'triangle'. + pAB = get_closest_point_to_line(Gamut.red, Gamut.green, xy_point) + pAC = get_closest_point_to_line(Gamut.blue, Gamut.red, xy_point) + pBC = get_closest_point_to_line(Gamut.green, Gamut.blue, xy_point) + + # Get the distances per point and see which point is closer to our Point. + dAB = get_distance_between_two_points(xy_point, pAB) + dAC = get_distance_between_two_points(xy_point, pAC) + dBC = get_distance_between_two_points(xy_point, pBC) + + lowest = dAB + closest_point = pAB + + if dAC < lowest: + lowest = dAC + closest_point = pAC + + if dBC < lowest: + lowest = dBC + closest_point = pBC + + # Change the xy value to a value which is within the reach of the lamp. + cx = closest_point.x + cy = closest_point.y + + return (cx, cy) + + +def check_point_in_lamps_reach(p: Tuple[float, float], + Gamut: GamutType) -> bool: + """Check if the provided XYPoint can be recreated by a Hue lamp.""" + v1 = XYPoint(Gamut.green.x - Gamut.red.x, Gamut.green.y - Gamut.red.y) + v2 = XYPoint(Gamut.blue.x - Gamut.red.x, Gamut.blue.y - Gamut.red.y) + + q = XYPoint(p[0] - Gamut.red.x, p[1] - Gamut.red.y) + s = cross_product(q, v2) / cross_product(v1, v2) + t = cross_product(v1, q) / cross_product(v1, v2) + + return (s >= 0.0) and (t >= 0.0) and (s + t <= 1.0) diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 16aec2ec617..f77d1752d6a 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -10,8 +10,8 @@ from typing import Any, Optional, Tuple, Dict import requests ELEVATION_URL = 'http://maps.googleapis.com/maps/api/elevation/json' -FREEGEO_API = 'https://freegeoip.net/json/' IP_API = 'http://ip-api.com/json' +IPAPI = 'https://ipapi.co/json/' # Constants from https://github.com/maurycyp/vincenty # Earth ellipsoid according to WGS 84 @@ -35,7 +35,7 @@ LocationInfo = collections.namedtuple( def detect_location_info() -> Optional[LocationInfo]: """Detect location information.""" - data = _get_freegeoip() + data = _get_ipapi() if data is None: data = _get_ip_api() @@ -159,22 +159,22 @@ def vincenty(point1: Tuple[float, float], point2: Tuple[float, float], return round(s, 6) -def _get_freegeoip() -> Optional[Dict[str, Any]]: - """Query freegeoip.io for location data.""" +def _get_ipapi() -> Optional[Dict[str, Any]]: + """Query ipapi.co for location data.""" try: - raw_info = requests.get(FREEGEO_API, timeout=5).json() + raw_info = requests.get(IPAPI, timeout=5).json() except (requests.RequestException, ValueError): return None return { 'ip': raw_info.get('ip'), - 'country_code': raw_info.get('country_code'), + 'country_code': raw_info.get('country'), 'country_name': raw_info.get('country_name'), 'region_code': raw_info.get('region_code'), - 'region_name': raw_info.get('region_name'), + 'region_name': raw_info.get('region'), 'city': raw_info.get('city'), - 'zip_code': raw_info.get('zip_code'), - 'time_zone': raw_info.get('time_zone'), + 'zip_code': raw_info.get('postal'), + 'time_zone': raw_info.get('timezone'), 'latitude': raw_info.get('latitude'), 'longitude': raw_info.get('longitude'), } diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index f2bf15d8a03..ae32566c73c 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -1,9 +1,12 @@ """Logging utilities.""" import asyncio from asyncio.events import AbstractEventLoop +from functools import wraps +import inspect import logging import threading -from typing import Optional +import traceback +from typing import Any, Callable, Optional from .async_ import run_coroutine_threadsafe @@ -121,3 +124,38 @@ class AsyncHandler: def name(self, name: str) -> None: """Wrap property get_name to handler.""" self.handler.set_name(name) # type: ignore + + +def catch_log_exception( + func: Callable[..., Any], + format_err: Callable[..., Any], + *args: Any) -> Callable[[], None]: + """Decorate an callback to catch and log exceptions.""" + def log_exception(*args: Any) -> None: + module_name = inspect.getmodule(inspect.trace()[1][0]).__name__ + # Do not print the wrapper in the traceback + frames = len(inspect.trace()) - 1 + exc_msg = traceback.format_exc(-frames) + friendly_msg = format_err(*args) + logging.getLogger(module_name).error('%s\n%s', friendly_msg, exc_msg) + + wrapper_func = None + if asyncio.iscoroutinefunction(func): + @wraps(func) + async def async_wrapper(*args: Any) -> None: + """Catch and log exception.""" + try: + await func(*args) + except Exception: # pylint: disable=broad-except + log_exception(*args) + wrapper_func = async_wrapper + else: + @wraps(func) + def wrapper(*args: Any) -> None: + """Catch and log exception.""" + try: + func(*args) + except Exception: # pylint: disable=broad-except + log_exception(*args) + wrapper_func = wrapper + return wrapper_func diff --git a/requirements_all.txt b/requirements_all.txt index 58fbb1bd5be..4f96d15c61f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,9 +1,9 @@ # Home Assistant core -aiohttp==3.5.1 +aiohttp==3.5.4 astral==1.7.1 async_timeout==3.0.1 attrs==18.2.0 -bcrypt==3.1.4 +bcrypt==3.1.5 certifi>=2018.04.16 jinja2>=2.10 PyJWT==1.6.4 @@ -13,7 +13,7 @@ python-slugify==1.2.6 pytz>=2018.07 pyyaml>=3.13,<4 requests==2.21.0 -ruamel.yaml==0.15.81 +ruamel.yaml==0.15.85 voluptuous==0.11.5 voluptuous-serialize==2.0.0 @@ -81,7 +81,7 @@ WazeRouteCalculator==0.6 YesssSMS==0.2.3 # homeassistant.components.abode -abodepy==0.14.0 +abodepy==0.15.0 # homeassistant.components.media_player.frontier_silicon afsapi==0.0.4 @@ -96,7 +96,7 @@ aioautomatic==0.6.5 aiodns==1.1.1 # homeassistant.components.esphome -aioesphomeapi==1.4.1 +aioesphomeapi==1.4.2 # homeassistant.components.freebox aiofreepybox==0.0.6 @@ -105,14 +105,14 @@ aiofreepybox==0.0.6 aioftp==0.12.0 # homeassistant.components.remote.harmony -aioharmony==0.1.2 +aioharmony==0.1.5 # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==1.5.0 +aiohue==1.8.0 # homeassistant.components.sensor.imap aioimaplib==0.7.13 @@ -124,10 +124,10 @@ aiolifx==0.6.7 aiolifx_effects==0.2.1 # homeassistant.components.scene.hunterdouglas_powerview -aiopvapi==1.5.4 +aiopvapi==1.6.14 # homeassistant.components.unifi -aiounifi==3 +aiounifi==4 # homeassistant.components.cover.aladdin_connect aladdin_connect==0.3 @@ -187,7 +187,7 @@ batinfo==0.4.2 # homeassistant.components.device_tracker.linksys_ap # homeassistant.components.sensor.scrape # homeassistant.components.sensor.sytadin -beautifulsoup4==4.6.3 +beautifulsoup4==4.7.1 # homeassistant.components.zha bellows==0.7.0 @@ -196,7 +196,7 @@ bellows==0.7.0 bimmer_connected==0.5.3 # homeassistant.components.blink -blinkpy==0.11.0 +blinkpy==0.11.1 # homeassistant.components.light.blinksticklight blinkstick==1.1.8 @@ -327,7 +327,7 @@ distro==1.3.0 dlipower==0.7.165 # homeassistant.components.doorbird -doorbirdpy==2.0.4 +doorbirdpy==2.0.6 # homeassistant.components.sensor.dovado dovado==0.4.1 @@ -351,6 +351,9 @@ eliqonline==1.2.2 # homeassistant.components.elkm1 elkm1-lib==0.7.13 +# homeassistant.components.emulated_roku +emulated_roku==0.1.7 + # homeassistant.components.enocean enocean==0.40 @@ -463,7 +466,7 @@ googlemaps==2.5.1 gps3==0.33.3 # homeassistant.components.greeneye_monitor -greeneye_monitor==0.1 +greeneye_monitor==1.0 # homeassistant.components.light.greenwave greenwavereality==0.5.1 @@ -487,7 +490,7 @@ hangups==0.4.6 hbmqtt==0.9.4 # homeassistant.components.sensor.jewish_calendar -hdate==0.7.5 +hdate==0.8.7 # homeassistant.components.climate.heatmiser heatmiserV3==0.9.1 @@ -508,10 +511,10 @@ hlk-sw16==0.0.6 hole==0.3.0 # homeassistant.components.binary_sensor.workday -holidays==0.9.8 +holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190109.1 +home-assistant-frontend==20190121.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 @@ -520,14 +523,14 @@ homeassistant-pyozw==0.1.2 # homekit==0.12.0 # homeassistant.components.homematicip_cloud -homematicip==0.9.8 +homematicip==0.10.3 # homeassistant.components.google # homeassistant.components.remember_the_milk httplib2==0.10.3 # homeassistant.components.huawei_lte -huawei-lte-api==1.1.1 +huawei-lte-api==1.1.3 # homeassistant.components.hydrawise hydrawiser==0.1.1 @@ -570,7 +573,7 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==17.1.0 +keyring==17.1.1 # homeassistant.scripts.keyring keyrings.alt==3.1.1 @@ -622,7 +625,7 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps -locationsharinglib==3.0.9 +locationsharinglib==3.0.11 # homeassistant.components.logi_circle logi_circle==0.1.7 @@ -677,7 +680,7 @@ mitemp_bt==0.0.1 motorparts==1.0.2 # homeassistant.components.tts -mutagen==1.41.1 +mutagen==1.42.0 # homeassistant.components.mychevy mychevy==1.2.0 @@ -689,8 +692,7 @@ mycroftapi==2.0 myusps==1.3.2 # homeassistant.components.media_player.nad -# homeassistant.components.media_player.nadtcp -nad_receiver==0.0.9 +nad_receiver==0.0.11 # homeassistant.components.light.nanoleaf_aurora nanoleaf==0.4.1 @@ -788,7 +790,7 @@ pilight==0.1.1 # homeassistant.components.camera.proxy # homeassistant.components.image_processing.tensorflow -pillow==5.3.0 +pillow==5.4.1 # homeassistant.components.dominos pizzapi==0.0.3 @@ -829,7 +831,7 @@ protobuf==3.6.1 psutil==5.4.8 # homeassistant.components.wink -pubnubsub-handler==1.0.2 +pubnubsub-handler==1.0.3 # homeassistant.components.notify.pushbullet # homeassistant.components.sensor.pushbullet @@ -839,7 +841,7 @@ pushbullet.py==0.11.0 pushetta==1.0.15 # homeassistant.components.light.rpi_gpio_pwm -pwmled==1.3.0 +pwmled==1.4.1 # homeassistant.components.august py-august==0.7.0 @@ -919,7 +921,7 @@ pyblackbird==0.5 # pybluez==0.22 # homeassistant.components.neato -pybotvac==0.0.12 +pybotvac==0.0.13 # homeassistant.components.cloudflare pycfdns==0.0.1 @@ -988,7 +990,7 @@ pyflexit==0.3 pyflic-homeassistant==0.4.dev0 # homeassistant.components.sensor.flunearyou -pyflunearyou==1.0.0 +pyflunearyou==1.0.1 # homeassistant.components.light.futurenow pyfnip==0.2 @@ -1001,7 +1003,7 @@ pyfttt==0.3 # homeassistant.components.device_tracker.bluetooth_le_tracker # homeassistant.components.sensor.skybeacon -pygatt==3.2.0 +pygatt[GATTTOOL]==3.2.0 # homeassistant.components.cover.gogogate2 pygogogate2==0.1.1 @@ -1019,7 +1021,7 @@ pyhaversion==2.0.3 pyhik==0.1.9 # homeassistant.components.hive -pyhiveapi==0.2.14 +pyhiveapi==0.2.17 # homeassistant.components.homematic pyhomematic==0.1.54 @@ -1058,7 +1060,7 @@ pykwb==0.0.8 pylacrosse==0.3.1 # homeassistant.components.sensor.lastfm -pylast==2.4.0 +pylast==3.0.0 # homeassistant.components.sensor.launch_library pylaunches==0.2.0 @@ -1085,7 +1087,7 @@ pylutron-caseta==0.5.0 # homeassistant.components.lutron pylutron==0.2.0 -# homeassistant.components.notify.mailgun +# homeassistant.components.mailgun.notify pymailgunner==1.4 # homeassistant.components.media_player.mediaroom @@ -1122,7 +1124,7 @@ pynetgear==0.5.2 pynetio==0.1.9.1 # homeassistant.components.lock.nuki -pynuki==1.3.1 +pynuki==1.3.2 # homeassistant.components.sensor.nut pynut2==2.1.2 @@ -1198,15 +1200,15 @@ pysesame==0.1.0 pysher==1.0.1 # homeassistant.components.sensor.sma -pysma==0.2.2 +pysma==0.3.1 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp # homeassistant.components.switch.snmp -pysnmp==4.4.6 +pysnmp==4.4.8 # homeassistant.components.sonos -pysonos==0.0.5 +pysonos==0.0.6 # homeassistant.components.spc pyspcwebgw==0.4.0 @@ -1302,7 +1304,7 @@ python-qbittorrent==0.3.1 # homeassistant.components.sensor.ripple python-ripple-api==0.0.3 -# homeassistant.components.media_player.roku +# homeassistant.components.roku python-roku==3.1.5 # homeassistant.components.sensor.sochain @@ -1363,7 +1365,7 @@ pytradfri[async]==6.0.1 pytrafikverket==0.1.5.8 # homeassistant.components.device_tracker.unifi -pyunifi==2.13 +pyunifi==2.16 # homeassistant.components.binary_sensor.uptimerobot pyuptimerobot==0.0.5 @@ -1571,7 +1573,7 @@ suds-py3==1.3.3.0 swisshydrodata==0.0.3 # homeassistant.components.tahoma -tahoma-api==0.0.13 +tahoma-api==0.0.14 # homeassistant.components.sensor.tank_utility tank_utility==1.4.0 @@ -1680,7 +1682,7 @@ warrant==0.6.1 watchdog==0.8.3 # homeassistant.components.waterfurnace -waterfurnace==1.0.0 +waterfurnace==1.1.0 # homeassistant.components.media_player.gpmdp websocket-client==0.54.0 @@ -1724,7 +1726,7 @@ yeelight==0.4.3 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.12.17 +youtube_dl==2019.01.10 # homeassistant.components.light.zengge zengge==0.2 @@ -1733,7 +1735,7 @@ zengge==0.2 zeroconf==0.21.3 # homeassistant.components.zha -zha-quirks==0.0.5 +zha-quirks==0.0.6 # homeassistant.components.climate.zhong_hong zhong_hong_hvac==1.0.9 @@ -1741,6 +1743,9 @@ zhong_hong_hvac==1.0.9 # homeassistant.components.media_player.ziggo_mediabox_xl ziggo-mediabox-xl==1.1.0 +# homeassistant.components.zha +zigpy-deconz==0.0.1 + # homeassistant.components.zha zigpy-xbee==0.1.1 diff --git a/requirements_test.txt b/requirements_test.txt index ffc56cdb582..af256efc709 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,8 +10,8 @@ mypy==0.650 pydocstyle==3.0.0 pylint==2.2.2 pytest-aiohttp==0.3.0 -pytest-cov==2.6.0 +pytest-cov==2.6.1 pytest-sugar==0.9.2 pytest-timeout==1.3.3 -pytest==4.0.2 +pytest==4.1.1 requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7595cf2f8c7..edcd1d101aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -11,10 +11,10 @@ mypy==0.650 pydocstyle==3.0.0 pylint==2.2.2 pytest-aiohttp==0.3.0 -pytest-cov==2.6.0 +pytest-cov==2.6.1 pytest-sugar==0.9.2 pytest-timeout==1.3.3 -pytest==4.0.2 +pytest==4.1.1 requests_mock==1.5.2 @@ -38,10 +38,10 @@ aioautomatic==0.6.5 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==1.5.0 +aiohue==1.8.0 # homeassistant.components.unifi -aiounifi==3 +aiounifi==4 # homeassistant.components.notify.apns apns2==0.3.0 @@ -61,6 +61,9 @@ defusedxml==0.5.0 # homeassistant.components.sensor.dsmr dsmr_parser==0.12 +# homeassistant.components.emulated_roku +emulated_roku==0.1.7 + # homeassistant.components.sensor.entur_public_transport enturclient==0.1.3 @@ -98,16 +101,16 @@ hangups==0.4.6 hbmqtt==0.9.4 # homeassistant.components.sensor.jewish_calendar -hdate==0.7.5 +hdate==0.8.7 # homeassistant.components.binary_sensor.workday -holidays==0.9.8 +holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190109.1 +home-assistant-frontend==20190121.1 # homeassistant.components.homematicip_cloud -homematicip==0.9.8 +homematicip==0.10.3 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -199,7 +202,7 @@ pyotp==2.2.6 pyqwikswitch==0.8 # homeassistant.components.sonos -pysonos==0.0.5 +pysonos==0.0.6 # homeassistant.components.spc pyspcwebgw==0.4.0 @@ -221,7 +224,7 @@ pythonwhois==2.4.3 pytradfri[async]==6.0.1 # homeassistant.components.device_tracker.unifi -pyunifi==2.13 +pyunifi==2.16 # homeassistant.components.notify.html5 pywebpush==1.6.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 5cd1d4b22d1..e351c7b022b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -46,6 +46,7 @@ TEST_REQUIREMENTS = ( 'coinmarketcap', 'defusedxml', 'dsmr_parser', + 'emulated_roku', 'enturclient', 'ephem', 'evohomeclient', diff --git a/script/release b/script/release index cf4f808377e..4dc94eb7f15 100755 --- a/script/release +++ b/script/release @@ -27,6 +27,6 @@ then exit 1 fi -rm -rf dist +rm -rf dist build python3 setup.py sdist bdist_wheel python3 -m twine upload dist/* --skip-existing diff --git a/setup.py b/setup.py index 0581d7bfcfa..d8c2c57b3d3 100755 --- a/setup.py +++ b/setup.py @@ -32,11 +32,11 @@ PROJECT_URLS = { PACKAGES = find_packages(exclude=['tests', 'tests.*']) REQUIRES = [ - 'aiohttp==3.5.1', + 'aiohttp==3.5.4', 'astral==1.7.1', 'async_timeout==3.0.1', 'attrs==18.2.0', - 'bcrypt==3.1.4', + 'bcrypt==3.1.5', 'certifi>=2018.04.16', 'jinja2>=2.10', 'PyJWT==1.6.4', @@ -47,7 +47,7 @@ REQUIRES = [ 'pytz>=2018.07', 'pyyaml>=3.13,<4', 'requests==2.21.0', - 'ruamel.yaml==0.15.81', + 'ruamel.yaml==0.15.85', 'voluptuous==0.11.5', 'voluptuous-serialize==2.0.0', ] diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index d3fa27b9f5b..b654b42fb35 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -1,5 +1,5 @@ """Test the Home Assistant local auth provider.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest import voluptuous as vol @@ -9,6 +9,8 @@ from homeassistant.auth import auth_manager_from_config, auth_store from homeassistant.auth.providers import ( auth_provider_from_config, homeassistant as hass_auth) +from tests.common import mock_coro + @pytest.fixture def data(hass): @@ -18,17 +20,13 @@ def data(hass): return data -async def test_adding_user(data, hass): - """Test adding a user.""" - data.add_auth('test-user', 'test-pass') - data.validate_login('test-user', 'test-pass') - - -async def test_adding_user_duplicate_username(data, hass): - """Test adding a user with duplicate username.""" - data.add_auth('test-user', 'test-pass') - with pytest.raises(hass_auth.InvalidUser): - data.add_auth('test-user', 'other-pass') +@pytest.fixture +def legacy_data(hass): + """Create a loaded legacy data class.""" + data = hass_auth.Data(hass) + hass.loop.run_until_complete(data.async_load()) + data.is_legacy = True + return data async def test_validating_password_invalid_user(data, hass): @@ -37,30 +35,72 @@ async def test_validating_password_invalid_user(data, hass): data.validate_login('non-existing', 'pw') +async def test_not_allow_set_id(): + """Test we are not allowed to set an ID in config.""" + hass = Mock() + with pytest.raises(vol.Invalid): + await auth_provider_from_config(hass, None, { + 'type': 'homeassistant', + 'id': 'invalid', + }) + + +async def test_new_users_populate_values(hass, data): + """Test that we populate data for new users.""" + data.add_auth('hello', 'test-pass') + await data.async_save() + + manager = await auth_manager_from_config(hass, [{ + 'type': 'homeassistant' + }], []) + provider = manager.auth_providers[0] + credentials = await provider.async_get_or_create_credentials({ + 'username': 'hello' + }) + user = await manager.async_get_or_create_user(credentials) + assert user.name == 'hello' + assert user.is_active + + +async def test_changing_password_raises_invalid_user(data, hass): + """Test that changing password raises invalid user.""" + with pytest.raises(hass_auth.InvalidUser): + data.change_password('non-existing', 'pw') + + +# Modern mode + +async def test_adding_user(data, hass): + """Test adding a user.""" + data.add_auth('test-user', 'test-pass') + data.validate_login('test-user', 'test-pass') + data.validate_login(' test-user ', 'test-pass') + + +async def test_adding_user_duplicate_username(data, hass): + """Test adding a user with duplicate username.""" + data.add_auth('test-user', 'test-pass') + with pytest.raises(hass_auth.InvalidUser): + data.add_auth('test-user ', 'other-pass') + + async def test_validating_password_invalid_password(data, hass): """Test validating an invalid password.""" data.add_auth('test-user', 'test-pass') with pytest.raises(hass_auth.InvalidAuth): - data.validate_login('test-user', 'invalid-pass') + data.validate_login(' test-user ', 'invalid-pass') async def test_changing_password(data, hass): """Test adding a user.""" - user = 'test-user' - data.add_auth(user, 'test-pass') - data.change_password(user, 'new-pass') + data.add_auth('test-user', 'test-pass') + data.change_password('test-user ', 'new-pass') with pytest.raises(hass_auth.InvalidAuth): - data.validate_login(user, 'test-pass') + data.validate_login('test-user', 'test-pass') - data.validate_login(user, 'new-pass') - - -async def test_changing_password_raises_invalid_user(data, hass): - """Test that we initialize an empty config.""" - with pytest.raises(hass_auth.InvalidUser): - data.change_password('non-existing', 'pw') + data.validate_login('test-user', 'new-pass') async def test_login_flow_validates(data, hass): @@ -81,6 +121,110 @@ async def test_login_flow_validates(data, hass): assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['errors']['base'] == 'invalid_auth' + result = await flow.async_step_init({ + 'username': 'test-user ', + 'password': 'incorrect-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + + result = await flow.async_step_init({ + 'username': 'test-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['username'] == 'test-user' + + +async def test_saving_loading(data, hass): + """Test saving and loading JSON.""" + data.add_auth('test-user', 'test-pass') + data.add_auth('second-user', 'second-pass') + await data.async_save() + + data = hass_auth.Data(hass) + await data.async_load() + data.validate_login('test-user ', 'test-pass') + data.validate_login('second-user ', 'second-pass') + + +async def test_get_or_create_credentials(hass, data): + """Test that we can get or create credentials.""" + manager = await auth_manager_from_config(hass, [{ + 'type': 'homeassistant' + }], []) + provider = manager.auth_providers[0] + provider.data = data + credentials1 = await provider.async_get_or_create_credentials({ + 'username': 'hello' + }) + with patch.object(provider, 'async_credentials', + return_value=mock_coro([credentials1])): + credentials2 = await provider.async_get_or_create_credentials({ + 'username': 'hello ' + }) + assert credentials1 is credentials2 + + +# Legacy mode + +async def test_legacy_adding_user(legacy_data, hass): + """Test in legacy mode adding a user.""" + legacy_data.add_auth('test-user', 'test-pass') + legacy_data.validate_login('test-user', 'test-pass') + + +async def test_legacy_adding_user_duplicate_username(legacy_data, hass): + """Test in legacy mode adding a user with duplicate username.""" + legacy_data.add_auth('test-user', 'test-pass') + with pytest.raises(hass_auth.InvalidUser): + legacy_data.add_auth('test-user', 'other-pass') + + +async def test_legacy_validating_password_invalid_password(legacy_data, hass): + """Test in legacy mode validating an invalid password.""" + legacy_data.add_auth('test-user', 'test-pass') + + with pytest.raises(hass_auth.InvalidAuth): + legacy_data.validate_login('test-user', 'invalid-pass') + + +async def test_legacy_changing_password(legacy_data, hass): + """Test in legacy mode adding a user.""" + user = 'test-user' + legacy_data.add_auth(user, 'test-pass') + legacy_data.change_password(user, 'new-pass') + + with pytest.raises(hass_auth.InvalidAuth): + legacy_data.validate_login(user, 'test-pass') + + legacy_data.validate_login(user, 'new-pass') + + +async def test_legacy_changing_password_raises_invalid_user(legacy_data, hass): + """Test in legacy mode that we initialize an empty config.""" + with pytest.raises(hass_auth.InvalidUser): + legacy_data.change_password('non-existing', 'pw') + + +async def test_legacy_login_flow_validates(legacy_data, hass): + """Test in legacy mode login flow.""" + legacy_data.add_auth('test-user', 'test-pass') + await legacy_data.async_save() + + provider = hass_auth.HassAuthProvider(hass, auth_store.AuthStore(hass), + {'type': 'homeassistant'}) + flow = await provider.async_login_flow({}) + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + result = await flow.async_step_init({ + 'username': 'incorrect-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + result = await flow.async_step_init({ 'username': 'test-user', 'password': 'incorrect-pass', @@ -96,40 +240,43 @@ async def test_login_flow_validates(data, hass): assert result['data']['username'] == 'test-user' -async def test_saving_loading(data, hass): - """Test saving and loading JSON.""" - data.add_auth('test-user', 'test-pass') - data.add_auth('second-user', 'second-pass') - await data.async_save() +async def test_legacy_saving_loading(legacy_data, hass): + """Test in legacy mode saving and loading JSON.""" + legacy_data.add_auth('test-user', 'test-pass') + legacy_data.add_auth('second-user', 'second-pass') + await legacy_data.async_save() - data = hass_auth.Data(hass) - await data.async_load() - data.validate_login('test-user', 'test-pass') - data.validate_login('second-user', 'second-pass') + legacy_data = hass_auth.Data(hass) + await legacy_data.async_load() + legacy_data.is_legacy = True + legacy_data.validate_login('test-user', 'test-pass') + legacy_data.validate_login('second-user', 'second-pass') + + with pytest.raises(hass_auth.InvalidAuth): + legacy_data.validate_login('test-user ', 'test-pass') -async def test_not_allow_set_id(): - """Test we are not allowed to set an ID in config.""" - hass = Mock() - with pytest.raises(vol.Invalid): - await auth_provider_from_config(hass, None, { - 'type': 'homeassistant', - 'id': 'invalid', - }) - - -async def test_new_users_populate_values(hass, data): - """Test that we populate data for new users.""" - data.add_auth('hello', 'test-pass') - await data.async_save() - +async def test_legacy_get_or_create_credentials(hass, legacy_data): + """Test in legacy mode that we can get or create credentials.""" manager = await auth_manager_from_config(hass, [{ 'type': 'homeassistant' }], []) provider = manager.auth_providers[0] - credentials = await provider.async_get_or_create_credentials({ + provider.data = legacy_data + credentials1 = await provider.async_get_or_create_credentials({ 'username': 'hello' }) - user = await manager.async_get_or_create_user(credentials) - assert user.name == 'hello' - assert user.is_active + + with patch.object(provider, 'async_credentials', + return_value=mock_coro([credentials1])): + credentials2 = await provider.async_get_or_create_credentials({ + 'username': 'hello' + }) + assert credentials1 is credentials2 + + with patch.object(provider, 'async_credentials', + return_value=mock_coro([credentials1])): + credentials3 = await provider.async_get_or_create_credentials({ + 'username': 'hello ' + }) + assert credentials1 is not credentials3 diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 93551076461..845e59295ac 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -153,6 +153,15 @@ async def discovery_test(device, hass, expected_endpoints=1): return None +def get_capability(capabilities, capability_name): + """Search a set of capabilities for a specific one.""" + for capability in capabilities: + if capability['interface'] == capability_name: + return capability + + return None + + def assert_endpoint_capabilities(endpoint, *interfaces): """Assert the endpoint supports the given interfaces. @@ -176,7 +185,11 @@ async def test_switch(hass, events): assert appliance['endpointId'] == 'switch#test' assert appliance['displayCategories'][0] == "SWITCH" assert appliance['friendlyName'] == "Test switch" - assert_endpoint_capabilities(appliance, 'Alexa.PowerController') + assert_endpoint_capabilities( + appliance, + 'Alexa.PowerController', + 'Alexa.EndpointHealth', + ) await assert_power_controller_works( 'switch#test', @@ -196,7 +209,11 @@ async def test_light(hass): assert appliance['endpointId'] == 'light#test_1' assert appliance['displayCategories'][0] == "LIGHT" assert appliance['friendlyName'] == "Test light 1" - assert_endpoint_capabilities(appliance, 'Alexa.PowerController') + assert_endpoint_capabilities( + appliance, + 'Alexa.PowerController', + 'Alexa.EndpointHealth', + ) await assert_power_controller_works( 'light#test_1', @@ -222,6 +239,7 @@ async def test_dimmable_light(hass): appliance, 'Alexa.BrightnessController', 'Alexa.PowerController', + 'Alexa.EndpointHealth', ) properties = await reported_properties(hass, 'light#test_2') @@ -260,6 +278,7 @@ async def test_color_light(hass): 'Alexa.PowerController', 'Alexa.ColorController', 'Alexa.ColorTemperatureController', + 'Alexa.EndpointHealth', ) # IncreaseColorTemperature and DecreaseColorTemperature have their own @@ -277,7 +296,8 @@ async def test_script(hass): (capability,) = assert_endpoint_capabilities( appliance, - 'Alexa.SceneController') + 'Alexa.SceneController', + ) assert not capability['supportsDeactivation'] await assert_scene_controller_works( @@ -299,7 +319,8 @@ async def test_cancelable_script(hass): assert appliance['endpointId'] == 'script#test_2' (capability,) = assert_endpoint_capabilities( appliance, - 'Alexa.SceneController') + 'Alexa.SceneController', + ) assert capability['supportsDeactivation'] await assert_scene_controller_works( @@ -321,7 +342,11 @@ async def test_input_boolean(hass): assert appliance['endpointId'] == 'input_boolean#test' assert appliance['displayCategories'][0] == "OTHER" assert appliance['friendlyName'] == "Test input boolean" - assert_endpoint_capabilities(appliance, 'Alexa.PowerController') + assert_endpoint_capabilities( + appliance, + 'Alexa.PowerController', + 'Alexa.EndpointHealth', + ) await assert_power_controller_works( 'input_boolean#test', @@ -341,7 +366,8 @@ async def test_scene(hass): (capability,) = assert_endpoint_capabilities( appliance, - 'Alexa.SceneController') + 'Alexa.SceneController' + ) assert not capability['supportsDeactivation'] await assert_scene_controller_works( @@ -359,7 +385,11 @@ async def test_fan(hass): assert appliance['endpointId'] == 'fan#test_1' assert appliance['displayCategories'][0] == "OTHER" assert appliance['friendlyName'] == "Test fan 1" - assert_endpoint_capabilities(appliance, 'Alexa.PowerController') + assert_endpoint_capabilities( + appliance, + 'Alexa.PowerController', + 'Alexa.EndpointHealth', + ) async def test_variable_fan(hass): @@ -386,6 +416,7 @@ async def test_variable_fan(hass): appliance, 'Alexa.PercentageController', 'Alexa.PowerController', + 'Alexa.EndpointHealth', ) call, _ = await assert_request_calls_service( @@ -412,7 +443,11 @@ async def test_lock(hass): assert appliance['endpointId'] == 'lock#test' assert appliance['displayCategories'][0] == "SMARTLOCK" assert appliance['friendlyName'] == "Test lock" - assert_endpoint_capabilities(appliance, 'Alexa.LockController') + assert_endpoint_capabilities( + appliance, + 'Alexa.LockController', + 'Alexa.EndpointHealth', + ) _, msg = await assert_request_calls_service( 'Alexa.LockController', 'Lock', 'lock#test', @@ -449,6 +484,7 @@ async def test_media_player(hass): 'Alexa.Speaker', 'Alexa.StepSpeaker', 'Alexa.PlaybackController', + 'Alexa.EndpointHealth', ) await assert_power_controller_works( @@ -546,7 +582,11 @@ async def test_alert(hass): assert appliance['endpointId'] == 'alert#test' assert appliance['displayCategories'][0] == "OTHER" assert appliance['friendlyName'] == "Test alert" - assert_endpoint_capabilities(appliance, 'Alexa.PowerController') + assert_endpoint_capabilities( + appliance, + 'Alexa.PowerController', + 'Alexa.EndpointHealth', + ) await assert_power_controller_works( 'alert#test', @@ -563,7 +603,11 @@ async def test_automation(hass): assert appliance['endpointId'] == 'automation#test' assert appliance['displayCategories'][0] == "OTHER" assert appliance['friendlyName'] == "Test automation" - assert_endpoint_capabilities(appliance, 'Alexa.PowerController') + assert_endpoint_capabilities( + appliance, + 'Alexa.PowerController', + 'Alexa.EndpointHealth', + ) await assert_power_controller_works( 'automation#test', @@ -580,7 +624,11 @@ async def test_group(hass): assert appliance['endpointId'] == 'group#test' assert appliance['displayCategories'][0] == "OTHER" assert appliance['friendlyName'] == "Test group" - assert_endpoint_capabilities(appliance, 'Alexa.PowerController') + assert_endpoint_capabilities( + appliance, + 'Alexa.PowerController', + 'Alexa.EndpointHealth', + ) await assert_power_controller_works( 'group#test', @@ -609,6 +657,7 @@ async def test_cover(hass): appliance, 'Alexa.PercentageController', 'Alexa.PowerController', + 'Alexa.EndpointHealth', ) await assert_power_controller_works( @@ -675,11 +724,16 @@ async def test_temp_sensor(hass): assert appliance['displayCategories'][0] == 'TEMPERATURE_SENSOR' assert appliance['friendlyName'] == 'Test Temp Sensor' - (capability,) = assert_endpoint_capabilities( + capabilities = assert_endpoint_capabilities( appliance, - 'Alexa.TemperatureSensor') - assert capability['interface'] == 'Alexa.TemperatureSensor' - properties = capability['properties'] + 'Alexa.TemperatureSensor', + 'Alexa.EndpointHealth', + ) + + temp_sensor_capability = get_capability(capabilities, + 'Alexa.TemperatureSensor') + assert temp_sensor_capability is not None + properties = temp_sensor_capability['properties'] assert properties['retrievable'] is True assert {'name': 'temperature'} in properties['supported'] @@ -704,11 +758,16 @@ async def test_contact_sensor(hass): assert appliance['displayCategories'][0] == 'CONTACT_SENSOR' assert appliance['friendlyName'] == 'Test Contact Sensor' - (capability,) = assert_endpoint_capabilities( + capabilities = assert_endpoint_capabilities( appliance, - 'Alexa.ContactSensor') - assert capability['interface'] == 'Alexa.ContactSensor' - properties = capability['properties'] + 'Alexa.ContactSensor', + 'Alexa.EndpointHealth', + ) + + contact_sensor_capability = get_capability(capabilities, + 'Alexa.ContactSensor') + assert contact_sensor_capability is not None + properties = contact_sensor_capability['properties'] assert properties['retrievable'] is True assert {'name': 'detectionState'} in properties['supported'] @@ -717,6 +776,9 @@ async def test_contact_sensor(hass): properties.assert_equal('Alexa.ContactSensor', 'detectionState', 'DETECTED') + properties.assert_equal('Alexa.EndpointHealth', 'connectivity', + {'value': 'OK'}) + async def test_motion_sensor(hass): """Test motion sensor discovery.""" @@ -734,11 +796,16 @@ async def test_motion_sensor(hass): assert appliance['displayCategories'][0] == 'MOTION_SENSOR' assert appliance['friendlyName'] == 'Test Motion Sensor' - (capability,) = assert_endpoint_capabilities( + capabilities = assert_endpoint_capabilities( appliance, - 'Alexa.MotionSensor') - assert capability['interface'] == 'Alexa.MotionSensor' - properties = capability['properties'] + 'Alexa.MotionSensor', + 'Alexa.EndpointHealth', + ) + + motion_sensor_capability = get_capability(capabilities, + 'Alexa.MotionSensor') + assert motion_sensor_capability is not None + properties = motion_sensor_capability['properties'] assert properties['retrievable'] is True assert {'name': 'detectionState'} in properties['supported'] @@ -787,6 +854,7 @@ async def test_thermostat(hass): appliance, 'Alexa.ThermostatController', 'Alexa.TemperatureSensor', + 'Alexa.EndpointHealth', ) properties = await reported_properties( @@ -1486,9 +1554,11 @@ async def test_entity_config(hass): assert appliance['displayCategories'][0] == "SWITCH" assert appliance['friendlyName'] == "Config name" assert appliance['description'] == "Config description" - assert len(appliance['capabilities']) == 1 - assert appliance['capabilities'][-1]['interface'] == \ - 'Alexa.PowerController' + assert_endpoint_capabilities( + appliance, + 'Alexa.PowerController', + 'Alexa.EndpointHealth', + ) async def test_unsupported_domain(hass): @@ -1651,6 +1721,38 @@ async def test_disabled(hass): assert msg['payload']['type'] == 'BRIDGE_UNREACHABLE' +async def test_endpoint_good_health(hass): + """Test endpoint health reporting.""" + device = ( + 'binary_sensor.test_contact', + 'on', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + await discovery_test(device, hass) + properties = await reported_properties(hass, 'binary_sensor#test_contact') + properties.assert_equal('Alexa.EndpointHealth', 'connectivity', + {'value': 'OK'}) + + +async def test_endpoint_bad_health(hass): + """Test endpoint health reporting.""" + device = ( + 'binary_sensor.test_contact', + 'unavailable', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + await discovery_test(device, hass) + properties = await reported_properties(hass, 'binary_sensor#test_contact') + properties.assert_equal('Alexa.EndpointHealth', 'connectivity', + {'value': 'UNREACHABLE'}) + + async def test_report_state(hass, aioclient_mock): """Test proactive state reports.""" aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) @@ -1681,7 +1783,7 @@ async def test_report_state(hass, aioclient_mock): assert len(aioclient_mock.mock_calls) == 1 call = aioclient_mock.mock_calls - call_json = json.loads(call[0][2]) + call_json = call[0][2] assert call_json["event"]["payload"]["change"]["properties"][0][ "value"] == "NOT_DETECTED" assert call_json["event"]["endpoint"][ diff --git a/tests/components/automation/test_geo_location.py b/tests/components/automation/test_geo_location.py index 946c9a8abc6..928296c8d27 100644 --- a/tests/components/automation/test_geo_location.py +++ b/tests/components/automation/test_geo_location.py @@ -1,4 +1,4 @@ -"""The tests for the geo location trigger.""" +"""The tests for the geolocation trigger.""" import pytest from homeassistant.components import automation, zone diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index 11387f25889..0e570800342 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -10,7 +10,6 @@ import homeassistant.components.automation as automation from tests.common import ( async_fire_time_changed, assert_setup_component, mock_component) -from tests.components.automation import common from tests.common import async_mock_service @@ -26,158 +25,6 @@ def setup_comp(hass): mock_component(hass, 'group') -async def test_if_fires_when_hour_matches(hass, calls): - """Test for firing if hour is matching.""" - assert await async_setup_component(hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': { - 'platform': 'time', - 'hours': 0, - }, - 'action': { - 'service': 'test.automation' - } - } - }) - - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0)) - await hass.async_block_till_done() - assert 1 == len(calls) - - await common.async_turn_off(hass) - await hass.async_block_till_done() - - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0)) - await hass.async_block_till_done() - assert 1 == len(calls) - - -async def test_if_fires_when_minute_matches(hass, calls): - """Test for firing if minutes are matching.""" - assert await async_setup_component(hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': { - 'platform': 'time', - 'minutes': 0, - }, - 'action': { - 'service': 'test.automation' - } - } - }) - - async_fire_time_changed(hass, dt_util.utcnow().replace(minute=0)) - - await hass.async_block_till_done() - assert 1 == len(calls) - - -async def test_if_fires_when_second_matches(hass, calls): - """Test for firing if seconds are matching.""" - assert await async_setup_component(hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': { - 'platform': 'time', - 'seconds': 0, - }, - 'action': { - 'service': 'test.automation' - } - } - }) - - async_fire_time_changed(hass, dt_util.utcnow().replace(second=0)) - - await hass.async_block_till_done() - assert 1 == len(calls) - - -async def test_if_fires_when_all_matches(hass, calls): - """Test for firing if everything matches.""" - assert await async_setup_component(hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': { - 'platform': 'time', - 'hours': 1, - 'minutes': 2, - 'seconds': 3, - }, - 'action': { - 'service': 'test.automation' - } - } - }) - - async_fire_time_changed(hass, dt_util.utcnow().replace( - hour=1, minute=2, second=3)) - - await hass.async_block_till_done() - assert 1 == len(calls) - - -async def test_if_fires_periodic_seconds(hass, calls): - """Test for firing periodically every second.""" - assert await async_setup_component(hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': { - 'platform': 'time', - 'seconds': "/2", - }, - 'action': { - 'service': 'test.automation' - } - } - }) - - async_fire_time_changed(hass, dt_util.utcnow().replace( - hour=0, minute=0, second=2)) - - await hass.async_block_till_done() - assert 1 == len(calls) - - -async def test_if_fires_periodic_minutes(hass, calls): - """Test for firing periodically every minute.""" - assert await async_setup_component(hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': { - 'platform': 'time', - 'minutes': "/2", - }, - 'action': { - 'service': 'test.automation' - } - } - }) - - async_fire_time_changed(hass, dt_util.utcnow().replace( - hour=0, minute=2, second=0)) - - await hass.async_block_till_done() - assert 1 == len(calls) - - -async def test_if_fires_periodic_hours(hass, calls): - """Test for firing periodically every hour.""" - assert await async_setup_component(hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': { - 'platform': 'time', - 'hours': "/2", - }, - 'action': { - 'service': 'test.automation' - } - } - }) - - async_fire_time_changed(hass, dt_util.utcnow().replace( - hour=2, minute=0, second=0)) - - await hass.async_block_till_done() - assert 1 == len(calls) - - async def test_if_fires_using_at(hass, calls): """Test for firing at.""" assert await async_setup_component(hass, automation.DOMAIN, { @@ -204,27 +51,6 @@ async def test_if_fires_using_at(hass, calls): assert 'time - 5' == calls[0].data['some'] -async def test_if_not_working_if_no_values_in_conf_provided(hass, calls): - """Test for failure if no configuration.""" - with assert_setup_component(0): - assert await async_setup_component(hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': { - 'platform': 'time', - }, - 'action': { - 'service': 'test.automation' - } - } - }) - - async_fire_time_changed(hass, dt_util.utcnow().replace( - hour=5, minute=0, second=0)) - - await hass.async_block_till_done() - assert 0 == len(calls) - - async def test_if_not_fires_using_wrong_at(hass, calls): """YAML translates time values to total seconds. diff --git a/tests/components/automation/test_time_pattern.py b/tests/components/automation/test_time_pattern.py new file mode 100644 index 00000000000..70a3fe308d5 --- /dev/null +++ b/tests/components/automation/test_time_pattern.py @@ -0,0 +1,219 @@ +"""The tests for the time_pattern automation.""" +import pytest + +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util +import homeassistant.components.automation as automation + +from tests.common import async_fire_time_changed, mock_component +from tests.components.automation import common +from tests.common import async_mock_service + + +@pytest.fixture +def calls(hass): + """Track calls to a mock serivce.""" + return async_mock_service(hass, 'test', 'automation') + + +@pytest.fixture(autouse=True) +def setup_comp(hass): + """Initialize components.""" + mock_component(hass, 'group') + + +async def test_if_fires_when_hour_matches(hass, calls): + """Test for firing if hour is matching.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time_pattern', + 'hours': 0, + 'minutes': '*', + 'seconds': '*', + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0)) + await hass.async_block_till_done() + assert 1 == len(calls) + + await common.async_turn_off(hass) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0)) + await hass.async_block_till_done() + assert 1 == len(calls) + + +async def test_if_fires_when_minute_matches(hass, calls): + """Test for firing if minutes are matching.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time_pattern', + 'hours': '*', + 'minutes': 0, + 'seconds': '*', + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + async_fire_time_changed(hass, dt_util.utcnow().replace(minute=0)) + + await hass.async_block_till_done() + assert 1 == len(calls) + + +async def test_if_fires_when_second_matches(hass, calls): + """Test for firing if seconds are matching.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time_pattern', + 'hours': '*', + 'minutes': '*', + 'seconds': 0, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + async_fire_time_changed(hass, dt_util.utcnow().replace(second=0)) + + await hass.async_block_till_done() + assert 1 == len(calls) + + +async def test_if_fires_when_all_matches(hass, calls): + """Test for firing if everything matches.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time_pattern', + 'hours': 1, + 'minutes': 2, + 'seconds': 3, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + async_fire_time_changed(hass, dt_util.utcnow().replace( + hour=1, minute=2, second=3)) + + await hass.async_block_till_done() + assert 1 == len(calls) + + +async def test_if_fires_periodic_seconds(hass, calls): + """Test for firing periodically every second.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time_pattern', + 'hours': '*', + 'minutes': '*', + 'seconds': "/2", + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + async_fire_time_changed(hass, dt_util.utcnow().replace( + hour=0, minute=0, second=2)) + + await hass.async_block_till_done() + assert 1 == len(calls) + + +async def test_if_fires_periodic_minutes(hass, calls): + """Test for firing periodically every minute.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time_pattern', + 'hours': '*', + 'minutes': "/2", + 'seconds': '*', + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + async_fire_time_changed(hass, dt_util.utcnow().replace( + hour=0, minute=2, second=0)) + + await hass.async_block_till_done() + assert 1 == len(calls) + + +async def test_if_fires_periodic_hours(hass, calls): + """Test for firing periodically every hour.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time_pattern', + 'hours': "/2", + 'minutes': '*', + 'seconds': '*', + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + async_fire_time_changed(hass, dt_util.utcnow().replace( + hour=2, minute=0, second=0)) + + await hass.async_block_till_done() + assert 1 == len(calls) + + +async def test_default_values(hass, calls): + """Test for firing at 2 minutes every hour.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time_pattern', + 'minutes': "2", + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + async_fire_time_changed(hass, dt_util.utcnow().replace( + hour=1, minute=2, second=0)) + + await hass.async_block_till_done() + assert 1 == len(calls) + + async_fire_time_changed(hass, dt_util.utcnow().replace( + hour=1, minute=2, second=1)) + + await hass.async_block_till_done() + assert 1 == len(calls) + + async_fire_time_changed(hass, dt_util.utcnow().replace( + hour=2, minute=2, second=0)) + + await hass.async_block_till_done() + assert 2 == len(calls) diff --git a/tests/components/camera/test_push.py b/tests/components/camera/test_push.py index be1d24ce34f..dc52965cbe0 100644 --- a/tests/components/camera/test_push.py +++ b/tests/components/camera/test_push.py @@ -4,14 +4,12 @@ import io from datetime import timedelta from homeassistant import core as ha -from homeassistant.components import webhook from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -async def test_bad_posting(aioclient_mock, hass, aiohttp_client): +async def test_bad_posting(hass, aiohttp_client): """Test that posting to wrong api endpoint fails.""" - await async_setup_component(hass, webhook.DOMAIN, {}) await async_setup_component(hass, 'camera', { 'camera': { 'platform': 'push', @@ -23,17 +21,9 @@ async def test_bad_posting(aioclient_mock, hass, aiohttp_client): client = await aiohttp_client(hass.http.app) - # wrong webhook - files = {'image': io.BytesIO(b'fake')} - resp = await client.post('/api/webhood/camera.wrong', data=files) - assert resp.status == 404 - # missing file - camera_state = hass.states.get('camera.config_test') - assert camera_state.state == 'idle' - - resp = await client.post('/api/webhook/camera.config_test') - assert resp.status == 200 # webhooks always return 200 + async with client.post('/api/webhook/camera.config_test') as resp: + assert resp.status == 200 # webhooks always return 200 camera_state = hass.states.get('camera.config_test') assert camera_state.state == 'idle' # no file supplied we are still idle @@ -41,7 +31,6 @@ async def test_bad_posting(aioclient_mock, hass, aiohttp_client): async def test_posting_url(hass, aiohttp_client): """Test that posting to api endpoint works.""" - await async_setup_component(hass, webhook.DOMAIN, {}) await async_setup_component(hass, 'camera', { 'camera': { 'platform': 'push', diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 2133a803aef..1a528f8cedf 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -10,8 +10,9 @@ from homeassistant.components.cloud import ( Cloud, iot, auth_api, MODE_DEV) from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) +from homeassistant.util import dt as dt_util from tests.components.alexa import test_smart_home as test_alexa -from tests.common import mock_coro +from tests.common import mock_coro, async_fire_time_changed from . import mock_cloud_prefs @@ -147,17 +148,36 @@ def test_handler_forwarding(): assert payload == 'payload' -@asyncio.coroutine -def test_handling_core_messages(hass, mock_cloud): +async def test_handling_core_messages_logout(hass, mock_cloud): """Test handling core messages.""" mock_cloud.logout.return_value = mock_coro() - yield from iot.async_handle_cloud(hass, mock_cloud, { + await iot.async_handle_cloud(hass, mock_cloud, { 'action': 'logout', 'reason': 'Logged in at two places.' }) assert len(mock_cloud.logout.mock_calls) == 1 +async def test_handling_core_messages_refresh_auth(hass, mock_cloud): + """Test handling core messages.""" + mock_cloud.hass = hass + with patch('random.randint', return_value=0) as mock_rand, patch( + 'homeassistant.components.cloud.auth_api.check_token' + ) as mock_check: + await iot.async_handle_cloud(hass, mock_cloud, { + 'action': 'refresh_auth', + 'seconds': 230, + }) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + assert len(mock_rand.mock_calls) == 1 + assert mock_rand.mock_calls[0][1] == (0, 230) + + assert len(mock_check.mock_calls) == 1 + assert mock_check.mock_calls[0][1][0] is mock_cloud + + @asyncio.coroutine def test_cloud_getting_disconnected_by_server(mock_client, caplog, mock_cloud): """Test server disconnecting instance.""" diff --git a/tests/components/config/test_auth_provider_homeassistant.py b/tests/components/config/test_auth_provider_homeassistant.py index a4c4c5a3c5a..4cbf3493a93 100644 --- a/tests/components/config/test_auth_provider_homeassistant.py +++ b/tests/components/config/test_auth_provider_homeassistant.py @@ -168,10 +168,6 @@ async def test_delete_removes_credential(hass, hass_ws_client, client = await hass_ws_client(hass, hass_access_token) user = MockUser().add_to_hass(hass) - user.credentials.append( - await hass.auth.auth_providers[0].async_get_or_create_credentials({ - 'username': 'test-user'})) - hass_storage[prov_ha.STORAGE_KEY] = { 'version': 1, 'data': { @@ -181,6 +177,10 @@ async def test_delete_removes_credential(hass, hass_ws_client, } } + user.credentials.append( + await hass.auth.auth_providers[0].async_get_or_create_credentials({ + 'username': 'test-user'})) + await client.send_json({ 'id': 5, 'type': auth_ha.WS_TYPE_DELETE, diff --git a/tests/components/binary_sensor/test_deconz.py b/tests/components/deconz/test_binary_sensor.py similarity index 100% rename from tests/components/binary_sensor/test_deconz.py rename to tests/components/deconz/test_binary_sensor.py diff --git a/tests/components/cover/test_deconz.py b/tests/components/deconz/test_cover.py similarity index 100% rename from tests/components/cover/test_deconz.py rename to tests/components/deconz/test_cover.py diff --git a/tests/components/light/test_deconz.py b/tests/components/deconz/test_light.py similarity index 100% rename from tests/components/light/test_deconz.py rename to tests/components/deconz/test_light.py diff --git a/tests/components/scene/test_deconz.py b/tests/components/deconz/test_scene.py similarity index 100% rename from tests/components/scene/test_deconz.py rename to tests/components/deconz/test_scene.py diff --git a/tests/components/sensor/test_deconz.py b/tests/components/deconz/test_sensor.py similarity index 100% rename from tests/components/sensor/test_deconz.py rename to tests/components/deconz/test_sensor.py diff --git a/tests/components/switch/test_deconz.py b/tests/components/deconz/test_switch.py similarity index 100% rename from tests/components/switch/test_deconz.py rename to tests/components/deconz/test_switch.py diff --git a/tests/components/device_tracker/test_locative.py b/tests/components/device_tracker/test_locative.py deleted file mode 100644 index a167a1e9fd4..00000000000 --- a/tests/components/device_tracker/test_locative.py +++ /dev/null @@ -1,210 +0,0 @@ -"""The tests the for Locative device tracker platform.""" -import asyncio -from unittest.mock import patch - -import pytest - -from homeassistant.setup import async_setup_component -import homeassistant.components.device_tracker as device_tracker -from homeassistant.const import CONF_PLATFORM -from homeassistant.components.device_tracker.locative import URL - - -def _url(data=None): - """Generate URL.""" - data = data or {} - data = "&".join(["{}={}".format(name, value) for - name, value in data.items()]) - return "{}?{}".format(URL, data) - - -@pytest.fixture -def locative_client(loop, hass, hass_client): - """Locative mock client.""" - assert loop.run_until_complete(async_setup_component( - hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'locative' - } - })) - - with patch('homeassistant.components.device_tracker.update_config'): - yield loop.run_until_complete(hass_client()) - - -@asyncio.coroutine -def test_missing_data(locative_client): - """Test missing data.""" - data = { - 'latitude': 1.0, - 'longitude': 1.1, - 'device': '123', - 'id': 'Home', - 'trigger': 'enter' - } - - # No data - req = yield from locative_client.get(_url({})) - assert req.status == 422 - - # No latitude - copy = data.copy() - del copy['latitude'] - req = yield from locative_client.get(_url(copy)) - assert req.status == 422 - - # No device - copy = data.copy() - del copy['device'] - req = yield from locative_client.get(_url(copy)) - assert req.status == 422 - - # No location - copy = data.copy() - del copy['id'] - req = yield from locative_client.get(_url(copy)) - assert req.status == 422 - - # No trigger - copy = data.copy() - del copy['trigger'] - req = yield from locative_client.get(_url(copy)) - assert req.status == 422 - - # Test message - copy = data.copy() - copy['trigger'] = 'test' - req = yield from locative_client.get(_url(copy)) - assert req.status == 200 - - # Test message, no location - copy = data.copy() - copy['trigger'] = 'test' - del copy['id'] - req = yield from locative_client.get(_url(copy)) - assert req.status == 200 - - # Unknown trigger - copy = data.copy() - copy['trigger'] = 'foobar' - req = yield from locative_client.get(_url(copy)) - assert req.status == 422 - - -@asyncio.coroutine -def test_enter_and_exit(hass, locative_client): - """Test when there is a known zone.""" - data = { - 'latitude': 40.7855, - 'longitude': -111.7367, - 'device': '123', - 'id': 'Home', - 'trigger': 'enter' - } - - # Enter the Home - req = yield from locative_client.get(_url(data)) - assert req.status == 200 - state_name = hass.states.get('{}.{}'.format('device_tracker', - data['device'])).state - assert 'home' == state_name - - data['id'] = 'HOME' - data['trigger'] = 'exit' - - # Exit Home - req = yield from locative_client.get(_url(data)) - assert req.status == 200 - state_name = hass.states.get('{}.{}'.format('device_tracker', - data['device'])).state - assert 'not_home' == state_name - - data['id'] = 'hOmE' - data['trigger'] = 'enter' - - # Enter Home again - req = yield from locative_client.get(_url(data)) - assert req.status == 200 - state_name = hass.states.get('{}.{}'.format('device_tracker', - data['device'])).state - assert 'home' == state_name - - data['trigger'] = 'exit' - - # Exit Home - req = yield from locative_client.get(_url(data)) - assert req.status == 200 - state_name = hass.states.get('{}.{}'.format('device_tracker', - data['device'])).state - assert 'not_home' == state_name - - data['id'] = 'work' - data['trigger'] = 'enter' - - # Enter Work - req = yield from locative_client.get(_url(data)) - assert req.status == 200 - state_name = hass.states.get('{}.{}'.format('device_tracker', - data['device'])).state - assert 'work' == state_name - - -@asyncio.coroutine -def test_exit_after_enter(hass, locative_client): - """Test when an exit message comes after an enter message.""" - data = { - 'latitude': 40.7855, - 'longitude': -111.7367, - 'device': '123', - 'id': 'Home', - 'trigger': 'enter' - } - - # Enter Home - req = yield from locative_client.get(_url(data)) - assert req.status == 200 - - state = hass.states.get('{}.{}'.format('device_tracker', - data['device'])) - assert state.state == 'home' - - data['id'] = 'Work' - - # Enter Work - req = yield from locative_client.get(_url(data)) - assert req.status == 200 - - state = hass.states.get('{}.{}'.format('device_tracker', - data['device'])) - assert state.state == 'work' - - data['id'] = 'Home' - data['trigger'] = 'exit' - - # Exit Home - req = yield from locative_client.get(_url(data)) - assert req.status == 200 - - state = hass.states.get('{}.{}'.format('device_tracker', - data['device'])) - assert state.state == 'work' - - -@asyncio.coroutine -def test_exit_first(hass, locative_client): - """Test when an exit message is sent first on a new device.""" - data = { - 'latitude': 40.7855, - 'longitude': -111.7367, - 'device': 'new_device', - 'id': 'Home', - 'trigger': 'exit' - } - - # Exit Home - req = yield from locative_client.get(_url(data)) - assert req.status == 200 - - state = hass.states.get('{}.{}'.format('device_tracker', - data['device'])) - assert state.state == 'not_home' diff --git a/tests/components/emulated_roku/__init__.py b/tests/components/emulated_roku/__init__.py new file mode 100644 index 00000000000..8f50effe0c3 --- /dev/null +++ b/tests/components/emulated_roku/__init__.py @@ -0,0 +1 @@ +"""Tests for emulated_roku.""" diff --git a/tests/components/emulated_roku/test_binding.py b/tests/components/emulated_roku/test_binding.py new file mode 100644 index 00000000000..e0fe2e450a2 --- /dev/null +++ b/tests/components/emulated_roku/test_binding.py @@ -0,0 +1,68 @@ +"""Tests for emulated_roku library bindings.""" +from unittest.mock import Mock, patch + +from homeassistant.components.emulated_roku.binding import EmulatedRoku, \ + EVENT_ROKU_COMMAND, \ + ATTR_SOURCE_NAME, ATTR_COMMAND_TYPE, ATTR_KEY, ATTR_APP_ID, \ + ROKU_COMMAND_KEYPRESS, ROKU_COMMAND_KEYDOWN, \ + ROKU_COMMAND_KEYUP, ROKU_COMMAND_LAUNCH + +from tests.common import mock_coro_func + + +async def test_events_fired_properly(hass): + """Test that events are fired correctly.""" + binding = EmulatedRoku(hass, 'Test Emulated Roku', + '1.2.3.4', 8060, + None, None, None) + + events = [] + roku_event_handler = None + + def instantiate(loop, handler, + roku_usn, host_ip, listen_port, + advertise_ip=None, advertise_port=None, + bind_multicast=None): + nonlocal roku_event_handler + roku_event_handler = handler + + return Mock(start=mock_coro_func(), close=mock_coro_func()) + + def listener(event): + events.append(event) + + with patch('emulated_roku.EmulatedRokuServer', instantiate): + hass.bus.async_listen(EVENT_ROKU_COMMAND, listener) + + assert await binding.setup() is True + + assert roku_event_handler is not None + + roku_event_handler.on_keydown('Test Emulated Roku', 'A') + roku_event_handler.on_keyup('Test Emulated Roku', 'A') + roku_event_handler.on_keypress('Test Emulated Roku', 'C') + roku_event_handler.launch('Test Emulated Roku', '1') + + await hass.async_block_till_done() + + assert len(events) == 4 + + assert events[0].event_type == EVENT_ROKU_COMMAND + assert events[0].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYDOWN + assert events[0].data[ATTR_SOURCE_NAME] == 'Test Emulated Roku' + assert events[0].data[ATTR_KEY] == 'A' + + assert events[1].event_type == EVENT_ROKU_COMMAND + assert events[1].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYUP + assert events[1].data[ATTR_SOURCE_NAME] == 'Test Emulated Roku' + assert events[1].data[ATTR_KEY] == 'A' + + assert events[2].event_type == EVENT_ROKU_COMMAND + assert events[2].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYPRESS + assert events[2].data[ATTR_SOURCE_NAME] == 'Test Emulated Roku' + assert events[2].data[ATTR_KEY] == 'C' + + assert events[3].event_type == EVENT_ROKU_COMMAND + assert events[3].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_LAUNCH + assert events[3].data[ATTR_SOURCE_NAME] == 'Test Emulated Roku' + assert events[3].data[ATTR_APP_ID] == '1' diff --git a/tests/components/emulated_roku/test_config_flow.py b/tests/components/emulated_roku/test_config_flow.py new file mode 100644 index 00000000000..4d6156bcb92 --- /dev/null +++ b/tests/components/emulated_roku/test_config_flow.py @@ -0,0 +1,36 @@ +"""Tests for emulated_roku config flow.""" +from homeassistant.components.emulated_roku import config_flow +from tests.common import MockConfigEntry + + +async def test_flow_works(hass): + """Test that config flow works.""" + flow = config_flow.EmulatedRokuFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input={ + 'name': 'Emulated Roku Test', + 'listen_port': 8060 + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Emulated Roku Test' + assert result['data'] == { + 'name': 'Emulated Roku Test', + 'listen_port': 8060 + } + + +async def test_flow_already_registered_entry(hass): + """Test that config flow doesn't allow existing names.""" + MockConfigEntry(domain='emulated_roku', data={ + 'name': 'Emulated Roku Test', + 'listen_port': 8062 + }).add_to_hass(hass) + flow = config_flow.EmulatedRokuFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input={ + 'name': 'Emulated Roku Test', + 'listen_port': 8062 + }) + assert result['type'] == 'abort' diff --git a/tests/components/emulated_roku/test_init.py b/tests/components/emulated_roku/test_init.py new file mode 100644 index 00000000000..5134d987d75 --- /dev/null +++ b/tests/components/emulated_roku/test_init.py @@ -0,0 +1,91 @@ +"""Test emulated_roku component setup process.""" +from unittest.mock import Mock, patch + +from homeassistant.setup import async_setup_component +from homeassistant.components import emulated_roku + +from tests.common import mock_coro_func + + +async def test_config_required_fields(hass): + """Test that configuration is successful with required fields.""" + with patch.object(emulated_roku, 'configured_servers', return_value=[]), \ + patch('emulated_roku.EmulatedRokuServer', + return_value=Mock(start=mock_coro_func(), + close=mock_coro_func())): + assert await async_setup_component(hass, emulated_roku.DOMAIN, { + emulated_roku.DOMAIN: { + emulated_roku.CONF_SERVERS: [{ + emulated_roku.CONF_NAME: 'Emulated Roku Test', + emulated_roku.CONF_LISTEN_PORT: 8060 + }] + } + }) is True + + +async def test_config_already_registered_not_configured(hass): + """Test that an already registered name causes the entry to be ignored.""" + with patch('emulated_roku.EmulatedRokuServer', + return_value=Mock(start=mock_coro_func(), + close=mock_coro_func())) as instantiate, \ + patch.object(emulated_roku, 'configured_servers', + return_value=['Emulated Roku Test']): + assert await async_setup_component(hass, emulated_roku.DOMAIN, { + emulated_roku.DOMAIN: { + emulated_roku.CONF_SERVERS: [{ + emulated_roku.CONF_NAME: 'Emulated Roku Test', + emulated_roku.CONF_LISTEN_PORT: 8060 + }] + } + }) is True + + assert len(instantiate.mock_calls) == 0 + + +async def test_setup_entry_successful(hass): + """Test setup entry is successful.""" + entry = Mock() + entry.data = { + emulated_roku.CONF_NAME: 'Emulated Roku Test', + emulated_roku.CONF_LISTEN_PORT: 8060, + emulated_roku.CONF_HOST_IP: '1.2.3.5', + emulated_roku.CONF_ADVERTISE_IP: '1.2.3.4', + emulated_roku.CONF_ADVERTISE_PORT: 8071, + emulated_roku.CONF_UPNP_BIND_MULTICAST: False + } + + with patch('emulated_roku.EmulatedRokuServer', + return_value=Mock(start=mock_coro_func(), + close=mock_coro_func())) as instantiate: + assert await emulated_roku.async_setup_entry(hass, entry) is True + + assert len(instantiate.mock_calls) == 1 + assert hass.data[emulated_roku.DOMAIN] + + roku_instance = hass.data[emulated_roku.DOMAIN]['Emulated Roku Test'] + + assert roku_instance.roku_usn == 'Emulated Roku Test' + assert roku_instance.host_ip == '1.2.3.5' + assert roku_instance.listen_port == 8060 + assert roku_instance.advertise_ip == '1.2.3.4' + assert roku_instance.advertise_port == 8071 + assert roku_instance.bind_multicast is False + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = Mock() + entry.data = {'name': 'Emulated Roku Test', 'listen_port': 8060} + + with patch('emulated_roku.EmulatedRokuServer', + return_value=Mock(start=mock_coro_func(), + close=mock_coro_func())): + assert await emulated_roku.async_setup_entry(hass, entry) is True + + assert emulated_roku.DOMAIN in hass.data + + await hass.async_block_till_done() + + assert await emulated_roku.async_unload_entry(hass, entry) + + assert len(hass.data[emulated_roku.DOMAIN]) == 0 diff --git a/tests/components/geo_location/test_demo.py b/tests/components/geo_location/test_demo.py index a35a8e11af6..d1c11fe55bf 100644 --- a/tests/components/geo_location/test_demo.py +++ b/tests/components/geo_location/test_demo.py @@ -39,7 +39,7 @@ class TestDemoPlatform(unittest.TestCase): with assert_setup_component(1, geo_location.DOMAIN): assert setup_component(self.hass, geo_location.DOMAIN, CONFIG) - # In this test, only entities of the geo location domain have been + # In this test, only entities of the geolocation domain have been # generated. all_states = self.hass.states.all() assert len(all_states) == NUMBER_OF_DEMO_DEVICES diff --git a/tests/components/geo_location/test_init.py b/tests/components/geo_location/test_init.py index 09030354901..a3b04848b6d 100644 --- a/tests/components/geo_location/test_init.py +++ b/tests/components/geo_location/test_init.py @@ -1,8 +1,8 @@ -"""The tests for the geo location component.""" +"""The tests for the geolocation component.""" import pytest from homeassistant.components import geo_location -from homeassistant.components.geo_location import GeoLocationEvent +from homeassistant.components.geo_location import GeolocationEvent from homeassistant.setup import async_setup_component @@ -13,8 +13,8 @@ async def test_setup_component(hass): async def test_event(hass): - """Simple test of the geo location event class.""" - entity = GeoLocationEvent() + """Simple test of the geolocation event class.""" + entity = GeolocationEvent() assert entity.state is None assert entity.distance is None diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index ae90af61ced..dbad7ba668b 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -1,12 +1,13 @@ """The tests for the Geofency device tracker platform.""" # pylint: disable=redefined-outer-name -from unittest.mock import patch +from unittest.mock import patch, Mock import pytest +from homeassistant import data_entry_flow from homeassistant.components import zone from homeassistant.components.geofency import ( - CONF_MOBILE_BEACONS, URL, DOMAIN) + CONF_MOBILE_BEACONS, DOMAIN) from homeassistant.const import ( HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, STATE_NOT_HOME) @@ -111,8 +112,11 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -def geofency_client(loop, hass, hass_client): - """Geofency mock client.""" +def geofency_client(loop, hass, aiohttp_client): + """Geofency mock client (unauthenticated).""" + assert loop.run_until_complete(async_setup_component( + hass, 'persistent_notification', {})) + assert loop.run_until_complete(async_setup_component( hass, DOMAIN, { DOMAIN: { @@ -122,7 +126,7 @@ def geofency_client(loop, hass, hass_client): loop.run_until_complete(hass.async_block_till_done()) with patch('homeassistant.components.device_tracker.update_config'): - yield loop.run_until_complete(hass_client()) + yield loop.run_until_complete(aiohttp_client(hass.http.app)) @pytest.fixture(autouse=True) @@ -138,10 +142,28 @@ def setup_zones(loop, hass): }})) -async def test_data_validation(geofency_client): +@pytest.fixture +async def webhook_id(hass, geofency_client): + """Initialize the Geofency component and get the webhook_id.""" + hass.config.api = Mock(base_url='http://example.com') + result = await hass.config_entries.flow.async_init(DOMAIN, context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + return result['result'].data['webhook_id'] + + +async def test_data_validation(geofency_client, webhook_id): """Test data validation.""" + url = '/api/webhook/{}'.format(webhook_id) + # No data - req = await geofency_client.post(URL) + req = await geofency_client.post(url) assert req.status == HTTP_UNPROCESSABLE_ENTITY missing_attributes = ['address', 'device', @@ -151,14 +173,16 @@ async def test_data_validation(geofency_client): for attribute in missing_attributes: copy = GPS_ENTER_HOME.copy() del copy[attribute] - req = await geofency_client.post(URL, data=copy) + req = await geofency_client.post(url, data=copy) assert req.status == HTTP_UNPROCESSABLE_ENTITY -async def test_gps_enter_and_exit_home(hass, geofency_client): +async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id): """Test GPS based zone enter and exit.""" + url = '/api/webhook/{}'.format(webhook_id) + # Enter the Home zone - req = await geofency_client.post(URL, data=GPS_ENTER_HOME) + req = await geofency_client.post(url, data=GPS_ENTER_HOME) await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_ENTER_HOME['device']) @@ -167,7 +191,7 @@ async def test_gps_enter_and_exit_home(hass, geofency_client): assert STATE_HOME == state_name # Exit the Home zone - req = await geofency_client.post(URL, data=GPS_EXIT_HOME) + req = await geofency_client.post(url, data=GPS_EXIT_HOME) await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_EXIT_HOME['device']) @@ -180,7 +204,7 @@ async def test_gps_enter_and_exit_home(hass, geofency_client): data['currentLatitude'] = NOT_HOME_LATITUDE data['currentLongitude'] = NOT_HOME_LONGITUDE - req = await geofency_client.post(URL, data=data) + req = await geofency_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_EXIT_HOME['device']) @@ -192,10 +216,12 @@ async def test_gps_enter_and_exit_home(hass, geofency_client): assert NOT_HOME_LONGITUDE == current_longitude -async def test_beacon_enter_and_exit_home(hass, geofency_client): +async def test_beacon_enter_and_exit_home(hass, geofency_client, webhook_id): """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" + url = '/api/webhook/{}'.format(webhook_id) + # Enter the Home zone - req = await geofency_client.post(URL, data=BEACON_ENTER_HOME) + req = await geofency_client.post(url, data=BEACON_ENTER_HOME) await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) @@ -204,7 +230,7 @@ async def test_beacon_enter_and_exit_home(hass, geofency_client): assert STATE_HOME == state_name # Exit the Home zone - req = await geofency_client.post(URL, data=BEACON_EXIT_HOME) + req = await geofency_client.post(url, data=BEACON_EXIT_HOME) await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) @@ -213,10 +239,12 @@ async def test_beacon_enter_and_exit_home(hass, geofency_client): assert STATE_NOT_HOME == state_name -async def test_beacon_enter_and_exit_car(hass, geofency_client): +async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id): """Test use of mobile iBeacon.""" + url = '/api/webhook/{}'.format(webhook_id) + # Enter the Car away from Home zone - req = await geofency_client.post(URL, data=BEACON_ENTER_CAR) + req = await geofency_client.post(url, data=BEACON_ENTER_CAR) await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) @@ -225,7 +253,7 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client): assert STATE_NOT_HOME == state_name # Exit the Car away from Home zone - req = await geofency_client.post(URL, data=BEACON_EXIT_CAR) + req = await geofency_client.post(url, data=BEACON_EXIT_CAR) await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) @@ -237,7 +265,7 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client): data = BEACON_ENTER_CAR.copy() data['latitude'] = HOME_LATITUDE data['longitude'] = HOME_LONGITUDE - req = await geofency_client.post(URL, data=data) + req = await geofency_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(data['name'])) @@ -246,7 +274,7 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client): assert STATE_HOME == state_name # Exit the Car in the Home zone - req = await geofency_client.post(URL, data=data) + req = await geofency_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(data['name'])) diff --git a/tests/components/gpslogger/__init__.py b/tests/components/gpslogger/__init__.py new file mode 100644 index 00000000000..636a9a767f9 --- /dev/null +++ b/tests/components/gpslogger/__init__.py @@ -0,0 +1 @@ +"""Tests for the GPSLogger component.""" diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py new file mode 100644 index 00000000000..cf818e54911 --- /dev/null +++ b/tests/components/gpslogger/test_init.py @@ -0,0 +1,166 @@ +"""The tests the for GPSLogger device tracker platform.""" +from unittest.mock import patch, Mock + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components import zone +from homeassistant.components.device_tracker import \ + DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.gpslogger import DOMAIN +from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, \ + STATE_HOME, STATE_NOT_HOME +from homeassistant.setup import async_setup_component + +HOME_LATITUDE = 37.239622 +HOME_LONGITUDE = -115.815811 + + +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf): + """Mock device tracker config loading.""" + pass + + +@pytest.fixture +def gpslogger_client(loop, hass, aiohttp_client): + """Mock client for GPSLogger (unauthenticated).""" + assert loop.run_until_complete(async_setup_component( + hass, 'persistent_notification', {})) + + assert loop.run_until_complete(async_setup_component( + hass, DOMAIN, { + DOMAIN: {} + })) + + with patch('homeassistant.components.device_tracker.update_config'): + yield loop.run_until_complete(aiohttp_client(hass.http.app)) + + +@pytest.fixture(autouse=True) +def setup_zones(loop, hass): + """Set up Zone config in HA.""" + assert loop.run_until_complete(async_setup_component( + hass, zone.DOMAIN, { + 'zone': { + 'name': 'Home', + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'radius': 100, + }})) + + +@pytest.fixture +async def webhook_id(hass, gpslogger_client): + """Initialize the GPSLogger component and get the webhook_id.""" + hass.config.api = Mock(base_url='http://example.com') + result = await hass.config_entries.flow.async_init(DOMAIN, context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + return result['result'].data['webhook_id'] + + +async def test_missing_data(hass, gpslogger_client, webhook_id): + """Test missing data.""" + url = '/api/webhook/{}'.format(webhook_id) + + data = { + 'latitude': 1.0, + 'longitude': 1.1, + 'device': '123', + } + + # No data + req = await gpslogger_client.post(url) + await hass.async_block_till_done() + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + # No latitude + copy = data.copy() + del copy['latitude'] + req = await gpslogger_client.post(url, data=copy) + await hass.async_block_till_done() + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + # No device + copy = data.copy() + del copy['device'] + req = await gpslogger_client.post(url, data=copy) + await hass.async_block_till_done() + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + +async def test_enter_and_exit(hass, gpslogger_client, webhook_id): + """Test when there is a known zone.""" + url = '/api/webhook/{}'.format(webhook_id) + + data = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '123', + } + + # Enter the Home + req = await gpslogger_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert STATE_HOME == state_name + + # Enter Home again + req = await gpslogger_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert STATE_HOME == state_name + + data['longitude'] = 0 + data['latitude'] = 0 + + # Enter Somewhere else + req = await gpslogger_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert STATE_NOT_HOME == state_name + + +async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): + """Test when additional attributes are present.""" + url = '/api/webhook/{}'.format(webhook_id) + + data = { + 'latitude': 1.0, + 'longitude': 1.1, + 'device': '123', + 'accuracy': 10.5, + 'battery': 10, + 'speed': 100, + 'direction': 105.32, + 'altitude': 102, + 'provider': 'gps', + 'activity': 'running' + } + + req = await gpslogger_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])) + assert STATE_NOT_HOME == state.state + assert 10.5 == state.attributes['gps_accuracy'] + assert 10.0 == state.attributes['battery'] + assert 100.0 == state.attributes['speed'] + assert 105.32 == state.attributes['direction'] + assert 102.0 == state.attributes['altitude'] + assert 'gps' == state.attributes['provider'] + assert 'running' == state.attributes['activity'] diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 435de6d1edf..f69be17a9e7 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -27,7 +27,7 @@ def hassio_env(): @pytest.fixture -def hassio_client(hassio_env, hass, aiohttp_client, legacy_auth): +def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): """Create mock hassio http client.""" with patch('homeassistant.components.hassio.HassIO.update_hass_api', Mock(return_value=mock_coro({"result": "ok"}))), \ @@ -40,13 +40,27 @@ def hassio_client(hassio_env, hass, aiohttp_client, legacy_auth): 'api_password': API_PASSWORD } })) + + +@pytest.fixture +def hassio_client(hassio_stubs, hass, hass_client): + """Return a Hass.io HTTP client.""" + yield hass.loop.run_until_complete(hass_client()) + + +@pytest.fixture +def hassio_noauth_client(hassio_stubs, hass, aiohttp_client): + """Return a Hass.io HTTP client without auth.""" yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @pytest.fixture def hassio_handler(hass, aioclient_mock): """Create mock hassio handler.""" - websession = hass.helpers.aiohttp_client.async_get_clientsession() + async def get_client_session(): + return hass.helpers.aiohttp_client.async_get_clientsession() + + websession = hass.loop.run_until_complete(get_client_session()) with patch.dict(os.environ, {'HASSIO_TOKEN': HASSIO_TOKEN}): yield HassIO(hass.loop, websession, "127.0.0.1") diff --git a/tests/components/hassio/test_auth.py b/tests/components/hassio/test_auth.py index fdf3230dedc..ed34ea96b49 100644 --- a/tests/components/hassio/test_auth.py +++ b/tests/components/hassio/test_auth.py @@ -4,14 +4,12 @@ from unittest.mock import patch, Mock from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.exceptions import HomeAssistantError -from tests.common import mock_coro, register_auth_provider +from tests.common import mock_coro from . import API_PASSWORD async def test_login_success(hass, hassio_client): """Test no auth needed for .""" - await register_auth_provider(hass, {'type': 'homeassistant'}) - with patch('homeassistant.auth.providers.homeassistant.' 'HassAuthProvider.async_validate_login', Mock(return_value=mock_coro())) as mock_login: @@ -34,8 +32,6 @@ async def test_login_success(hass, hassio_client): async def test_login_error(hass, hassio_client): """Test no auth needed for error.""" - await register_auth_provider(hass, {'type': 'homeassistant'}) - with patch('homeassistant.auth.providers.homeassistant.' 'HassAuthProvider.async_validate_login', Mock(side_effect=HomeAssistantError())) as mock_login: @@ -58,8 +54,6 @@ async def test_login_error(hass, hassio_client): async def test_login_no_data(hass, hassio_client): """Test auth with no data -> error.""" - await register_auth_provider(hass, {'type': 'homeassistant'}) - with patch('homeassistant.auth.providers.homeassistant.' 'HassAuthProvider.async_validate_login', Mock(side_effect=HomeAssistantError())) as mock_login: @@ -77,8 +71,6 @@ async def test_login_no_data(hass, hassio_client): async def test_login_no_username(hass, hassio_client): """Test auth with no username in data -> error.""" - await register_auth_provider(hass, {'type': 'homeassistant'}) - with patch('homeassistant.auth.providers.homeassistant.' 'HassAuthProvider.async_validate_login', Mock(side_effect=HomeAssistantError())) as mock_login: @@ -100,8 +92,6 @@ async def test_login_no_username(hass, hassio_client): async def test_login_success_extra(hass, hassio_client): """Test auth with extra data.""" - await register_auth_provider(hass, {'type': 'homeassistant'}) - with patch('homeassistant.auth.providers.homeassistant.' 'HassAuthProvider.async_validate_login', Mock(return_value=mock_coro())) as mock_login: diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index 07db126312b..3f58c6e697e 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -40,9 +40,10 @@ def test_forward_request(hassio_client): 'build_type', [ 'supervisor/info', 'homeassistant/update', 'host/info' ]) -def test_auth_required_forward_request(hassio_client, build_type): +def test_auth_required_forward_request(hassio_noauth_client, build_type): """Test auth required for normal request.""" - resp = yield from hassio_client.post("/api/hassio/{}".format(build_type)) + resp = yield from hassio_noauth_client.post( + "/api/hassio/{}".format(build_type)) # Check we got right response assert resp.status == 401 @@ -135,3 +136,20 @@ def test_bad_gateway_when_cannot_find_supervisor(hassio_client): HTTP_HEADER_HA_AUTH: API_PASSWORD }) assert resp.status == 502 + + +async def test_forwarding_user_info(hassio_client, hass_admin_user, + aioclient_mock): + """Test that we forward user info correctly.""" + aioclient_mock.get('http://127.0.0.1/hello') + + resp = await hassio_client.get('/api/hassio/hello') + + # Check we got right response + assert resp.status == 200 + + assert len(aioclient_mock.mock_calls) == 1 + + req_headers = aioclient_mock.mock_calls[0][-1] + req_headers['X-HASS-USER-ID'] == hass_admin_user.id + req_headers['X-HASS-IS-ADMIN'] == '1' diff --git a/tests/components/light/test_hue.py b/tests/components/hue/test_light.py similarity index 84% rename from tests/components/light/test_hue.py rename to tests/components/hue/test_light.py index ad4026e7f31..f7865fcf4f8 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/hue/test_light.py @@ -11,7 +11,7 @@ import pytest from homeassistant import config_entries from homeassistant.components import hue -import homeassistant.components.light.hue as hue_light +from homeassistant.components.hue import light as hue_light from homeassistant.util import color _LOGGER = logging.getLogger(__name__) @@ -85,6 +85,16 @@ LIGHT_1_ON = { "colormode": "xy", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 1", "modelid": "LCT001", @@ -105,6 +115,16 @@ LIGHT_1_OFF = { "colormode": "xy", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 1", "modelid": "LCT001", @@ -125,6 +145,16 @@ LIGHT_2_OFF = { "colormode": "hs", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 2", "modelid": "LCT001", @@ -145,6 +175,16 @@ LIGHT_2_ON = { "colormode": "hs", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 2 new", "modelid": "LCT001", @@ -156,6 +196,23 @@ LIGHT_RESPONSE = { "1": LIGHT_1_ON, "2": LIGHT_2_OFF, } +LIGHT_RAW = { + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, + "swversion": "66009461", +} +LIGHT_GAMUT = color.GamutType(color.XYPoint(0.704, 0.296), + color.XYPoint(0.2151, 0.7106), + color.XYPoint(0.138, 0.08)) +LIGHT_GAMUT_TYPE = 'A' @pytest.fixture @@ -380,6 +437,16 @@ async def test_new_light_discovered(hass, mock_bridge): "colormode": "hs", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 3", "modelid": "LCT001", @@ -493,6 +560,16 @@ async def test_other_light_update(hass, mock_bridge): "colormode": "hs", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 2 new", "modelid": "LCT001", @@ -573,6 +650,21 @@ async def test_light_turn_on_service(hass, mock_bridge): assert light is not None assert light.state == 'on' + # test hue gamut in turn_on service + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.hue_lamp_2', + 'rgb_color': [0, 0, 255], + }, blocking=True) + + assert len(mock_bridge.mock_requests) == 5 + + assert mock_bridge.mock_requests[3]['json'] == { + 'on': True, + 'xy': (0.138, 0.08), + 'effect': 'none', + 'alert': 'none', + } + async def test_light_turn_off_service(hass, mock_bridge): """Test calling the turn on service on a light.""" @@ -608,7 +700,8 @@ async def test_light_turn_off_service(hass, mock_bridge): def test_available(): """Test available property.""" light = hue_light.HueLight( - light=Mock(state={'reachable': False}), + light=Mock(state={'reachable': False}, + raw=LIGHT_RAW), request_bridge_update=None, bridge=Mock(allow_unreachable=False), is_group=False, @@ -617,7 +710,8 @@ def test_available(): assert light.available is False light = hue_light.HueLight( - light=Mock(state={'reachable': False}), + light=Mock(state={'reachable': False}, + raw=LIGHT_RAW), request_bridge_update=None, bridge=Mock(allow_unreachable=True), is_group=False, @@ -626,7 +720,8 @@ def test_available(): assert light.available is True light = hue_light.HueLight( - light=Mock(state={'reachable': False}), + light=Mock(state={'reachable': False}, + raw=LIGHT_RAW), request_bridge_update=None, bridge=Mock(allow_unreachable=False), is_group=True, @@ -639,10 +734,13 @@ def test_hs_color(): """Test hs_color property.""" light = hue_light.HueLight( light=Mock(state={ - 'colormode': 'ct', - 'hue': 1234, - 'sat': 123, - }), + 'colormode': 'ct', + 'hue': 1234, + 'sat': 123, + }, + raw=LIGHT_RAW, + colorgamuttype=LIGHT_GAMUT_TYPE, + colorgamut=LIGHT_GAMUT), request_bridge_update=None, bridge=Mock(), is_group=False, @@ -652,10 +750,13 @@ def test_hs_color(): light = hue_light.HueLight( light=Mock(state={ - 'colormode': 'hs', - 'hue': 1234, - 'sat': 123, - }), + 'colormode': 'hs', + 'hue': 1234, + 'sat': 123, + }, + raw=LIGHT_RAW, + colorgamuttype=LIGHT_GAMUT_TYPE, + colorgamut=LIGHT_GAMUT), request_bridge_update=None, bridge=Mock(), is_group=False, @@ -665,14 +766,17 @@ def test_hs_color(): light = hue_light.HueLight( light=Mock(state={ - 'colormode': 'xy', - 'hue': 1234, - 'sat': 123, - 'xy': [0.4, 0.5] - }), + 'colormode': 'xy', + 'hue': 1234, + 'sat': 123, + 'xy': [0.4, 0.5] + }, + raw=LIGHT_RAW, + colorgamuttype=LIGHT_GAMUT_TYPE, + colorgamut=LIGHT_GAMUT), request_bridge_update=None, bridge=Mock(), is_group=False, ) - assert light.hs_color == color.color_xy_to_hs(0.4, 0.5) + assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) diff --git a/tests/components/locative/__init__.py b/tests/components/locative/__init__.py new file mode 100644 index 00000000000..8be6da6628d --- /dev/null +++ b/tests/components/locative/__init__.py @@ -0,0 +1 @@ +"""Tests for the Locative component.""" diff --git a/tests/components/locative/test_init.py b/tests/components/locative/test_init.py new file mode 100644 index 00000000000..5f18d47eb22 --- /dev/null +++ b/tests/components/locative/test_init.py @@ -0,0 +1,236 @@ +"""The tests the for Locative device tracker platform.""" +from unittest.mock import patch, Mock + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.device_tracker import \ + DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.locative import DOMAIN +from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf): + """Mock device tracker config loading.""" + pass + + +@pytest.fixture +def locative_client(loop, hass, hass_client): + """Locative mock client.""" + assert loop.run_until_complete(async_setup_component( + hass, DOMAIN, { + DOMAIN: {} + })) + + with patch('homeassistant.components.device_tracker.update_config'): + yield loop.run_until_complete(hass_client()) + + +@pytest.fixture +async def webhook_id(hass, locative_client): + """Initialize the Geofency component and get the webhook_id.""" + hass.config.api = Mock(base_url='http://example.com') + result = await hass.config_entries.flow.async_init('locative', context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + return result['result'].data['webhook_id'] + + +async def test_missing_data(locative_client, webhook_id): + """Test missing data.""" + url = '/api/webhook/{}'.format(webhook_id) + + data = { + 'latitude': 1.0, + 'longitude': 1.1, + 'device': '123', + 'id': 'Home', + 'trigger': 'enter' + } + + # No data + req = await locative_client.post(url) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + # No latitude + copy = data.copy() + del copy['latitude'] + req = await locative_client.post(url, data=copy) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + # No device + copy = data.copy() + del copy['device'] + req = await locative_client.post(url, data=copy) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + # No location + copy = data.copy() + del copy['id'] + req = await locative_client.post(url, data=copy) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + # No trigger + copy = data.copy() + del copy['trigger'] + req = await locative_client.post(url, data=copy) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + # Test message + copy = data.copy() + copy['trigger'] = 'test' + req = await locative_client.post(url, data=copy) + assert req.status == HTTP_OK + + # Test message, no location + copy = data.copy() + copy['trigger'] = 'test' + del copy['id'] + req = await locative_client.post(url, data=copy) + assert req.status == HTTP_OK + + # Unknown trigger + copy = data.copy() + copy['trigger'] = 'foobar' + req = await locative_client.post(url, data=copy) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + +async def test_enter_and_exit(hass, locative_client, webhook_id): + """Test when there is a known zone.""" + url = '/api/webhook/{}'.format(webhook_id) + + data = { + 'latitude': 40.7855, + 'longitude': -111.7367, + 'device': '123', + 'id': 'Home', + 'trigger': 'enter' + } + + # Enter the Home + req = await locative_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert 'home' == state_name + + data['id'] = 'HOME' + data['trigger'] = 'exit' + + # Exit Home + req = await locative_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert 'not_home' == state_name + + data['id'] = 'hOmE' + data['trigger'] = 'enter' + + # Enter Home again + req = await locative_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert 'home' == state_name + + data['trigger'] = 'exit' + + # Exit Home + req = await locative_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert 'not_home' == state_name + + data['id'] = 'work' + data['trigger'] = 'enter' + + # Enter Work + req = await locative_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert 'work' == state_name + + +async def test_exit_after_enter(hass, locative_client, webhook_id): + """Test when an exit message comes after an enter message.""" + url = '/api/webhook/{}'.format(webhook_id) + + data = { + 'latitude': 40.7855, + 'longitude': -111.7367, + 'device': '123', + 'id': 'Home', + 'trigger': 'enter' + } + + # Enter Home + req = await locative_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])) + assert state.state == 'home' + + data['id'] = 'Work' + + # Enter Work + req = await locative_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])) + assert state.state == 'work' + + data['id'] = 'Home' + data['trigger'] = 'exit' + + # Exit Home + req = await locative_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])) + assert state.state == 'work' + + +async def test_exit_first(hass, locative_client, webhook_id): + """Test when an exit message is sent first on a new device.""" + url = '/api/webhook/{}'.format(webhook_id) + + data = { + 'latitude': 40.7855, + 'longitude': -111.7367, + 'device': 'new_device', + 'id': 'Home', + 'trigger': 'exit' + } + + # Exit Home + req = await locative_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])) + assert state.state == 'not_home' diff --git a/tests/components/lock/test_zwave.py b/tests/components/lock/test_zwave.py index 484e4796759..07095e4fe3e 100644 --- a/tests/components/lock/test_zwave.py +++ b/tests/components/lock/test_zwave.py @@ -143,6 +143,47 @@ def test_v2btze_value_changed(mock_openzwave): assert device.is_locked +def test_alarm_type_workaround(mock_openzwave): + """Test value changed for Z-Wave lock using alarm type.""" + node = MockNode(manufacturer_id='0109', product_id='0000') + values = MockEntityValues( + primary=MockValue(data=True, node=node), + access_control=None, + alarm_type=MockValue(data=16, node=node), + alarm_level=None, + ) + device = zwave.get_device(node=node, values=values) + assert not device.is_locked + + values.alarm_type.data = 18 + value_changed(values.alarm_type) + assert device.is_locked + + values.alarm_type.data = 19 + value_changed(values.alarm_type) + assert not device.is_locked + + values.alarm_type.data = 21 + value_changed(values.alarm_type) + assert device.is_locked + + values.alarm_type.data = 22 + value_changed(values.alarm_type) + assert not device.is_locked + + values.alarm_type.data = 24 + value_changed(values.alarm_type) + assert device.is_locked + + values.alarm_type.data = 25 + value_changed(values.alarm_type) + assert not device.is_locked + + values.alarm_type.data = 27 + value_changed(values.alarm_type) + assert device.is_locked + + def test_lock_access_control(mock_openzwave): """Test access control for Z-Wave lock.""" node = MockNode() diff --git a/tests/components/alarm_control_panel/test_mqtt.py b/tests/components/mqtt/test_alarm_control_panel.py similarity index 93% rename from tests/components/alarm_control_panel/test_mqtt.py rename to tests/components/mqtt/test_alarm_control_panel.py index 9f161aaf083..1a89e2382e3 100644 --- a/tests/components/alarm_control_panel/test_mqtt.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -300,7 +300,7 @@ async def test_discovery_removal_alarm(hass, mqtt_mock, caplog): async def test_discovery_update_alarm(hass, mqtt_mock, caplog): - """Test removal of discovered alarm_control_panel.""" + """Test update of discovered alarm_control_panel.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) @@ -338,6 +338,41 @@ async def test_discovery_update_alarm(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, + 'homeassistant/alarm_control_panel/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('alarm_control_panel.beer') + assert state is None + + async_fire_mqtt_message(hass, + 'homeassistant/alarm_control_panel/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('alarm_control_panel.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('alarm_control_panel.beer') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT alarm control panel device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/mqtt/test_binary_sensor.py similarity index 94% rename from tests/components/binary_sensor/test_mqtt.py rename to tests/components/mqtt/test_binary_sensor.py index 71a30e6ee18..5a1c80beae2 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -390,7 +390,7 @@ async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog): async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): - """Test removal of discovered binary_sensor.""" + """Test update of discovered binary_sensor.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) data1 = ( @@ -421,6 +421,39 @@ async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "off_delay": -1 }' + ) + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('binary_sensor.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('binary_sensor.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('binary_sensor.beer') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT binary sensor device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) diff --git a/tests/components/camera/test_mqtt.py b/tests/components/mqtt/test_camera.py similarity index 100% rename from tests/components/camera/test_mqtt.py rename to tests/components/mqtt/test_camera.py diff --git a/tests/components/climate/test_mqtt.py b/tests/components/mqtt/test_climate.py similarity index 97% rename from tests/components/climate/test_mqtt.py rename to tests/components/mqtt/test_climate.py index 1fd45701a95..a2aa424eeee 100644 --- a/tests/components/climate/test_mqtt.py +++ b/tests/components/mqtt/test_climate.py @@ -719,7 +719,7 @@ async def test_discovery_removal_climate(hass, mqtt_mock, caplog): async def test_discovery_update_climate(hass, mqtt_mock, caplog): - """Test removal of discovered climate.""" + """Test update of discovered climate.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) data1 = ( @@ -749,6 +749,39 @@ async def test_discovery_update_climate(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "power_command_topic": "test_topic#" }' + ) + data2 = ( + '{ "name": "Milk", ' + ' "power_command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('climate.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('climate.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('climate.beer') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT climate device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) diff --git a/tests/components/cover/test_mqtt.py b/tests/components/mqtt/test_cover.py similarity index 97% rename from tests/components/cover/test_mqtt.py rename to tests/components/mqtt/test_cover.py index a31f393c507..36f566d0c19 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/mqtt/test_cover.py @@ -5,7 +5,7 @@ from unittest.mock import ANY from homeassistant.components import cover, mqtt from homeassistant.components.cover import (ATTR_POSITION, ATTR_TILT_POSITION) -from homeassistant.components.cover.mqtt import MqttCover +from homeassistant.components.mqtt.cover import MqttCover from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, @@ -1067,7 +1067,7 @@ async def test_discovery_removal_cover(hass, mqtt_mock, caplog): async def test_discovery_update_cover(hass, mqtt_mock, caplog): - """Test removal of discovered cover.""" + """Test update of discovered cover.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) data1 = ( @@ -1098,6 +1098,39 @@ async def test_discovery_update_cover(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic#" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('cover.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('cover.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('cover.beer') + assert state is None + + async def test_unique_id(hass): """Test unique_id option only creates one cover per id.""" await async_mock_mqtt_component(hass) diff --git a/tests/components/fan/test_mqtt.py b/tests/components/mqtt/test_fan.py similarity index 88% rename from tests/components/fan/test_mqtt.py rename to tests/components/mqtt/test_fan.py index 405c20196a0..fea6f6dda74 100644 --- a/tests/components/fan/test_mqtt.py +++ b/tests/components/mqtt/test_fan.py @@ -3,7 +3,7 @@ import json from unittest.mock import ANY from homeassistant.setup import async_setup_component -from homeassistant.components import fan +from homeassistant.components import fan, mqtt from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE @@ -111,7 +111,7 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_discovery_removal_fan(hass, mqtt_mock, caplog): """Test removal of discovered fan.""" - entry = MockConfigEntry(domain='mqtt') + entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) data = ( '{ "name": "Beer",' @@ -132,8 +132,8 @@ async def test_discovery_removal_fan(hass, mqtt_mock, caplog): async def test_discovery_update_fan(hass, mqtt_mock, caplog): - """Test removal of discovered fan.""" - entry = MockConfigEntry(domain='mqtt') + """Test update of discovered fan.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) data1 = ( '{ "name": "Beer",' @@ -163,6 +163,38 @@ async def test_discovery_update_fan(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('fan.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('fan.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('fan.beer') + assert state is None + + async def test_unique_id(hass): """Test unique_id option only creates one fan per id.""" await async_mock_mqtt_component(hass) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 81e6a7b298d..540cfe0369d 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -231,6 +231,19 @@ class TestMQTTComponent(unittest.TestCase): 'model': 'Glass', 'sw_version': '0.1-beta', }) + # full device info with via_hub + mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({ + 'identifiers': ['helloworld', 'hello'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ["zigbee", "zigbee_id"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + 'via_hub': 'test-hub', + }) # no identifiers with pytest.raises(vol.Invalid): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({ @@ -284,6 +297,23 @@ class TestMQTTCallbacks(unittest.TestCase): "b'\\x9a' on test-topic with encoding utf-8" in \ test_handle.output[0] + def test_message_callback_exception_gets_logged(self): + """Test exception raised by message handler.""" + @callback + def bad_handler(*args): + """Record calls.""" + raise Exception('This is a bad message callback') + mqtt.subscribe(self.hass, 'test-topic', bad_handler) + + with self.assertLogs(level='WARNING') as test_handle: + fire_mqtt_message(self.hass, 'test-topic', 'test') + + self.hass.block_till_done() + assert \ + "Exception in bad_handler when handling msg on 'test-topic':" \ + " 'test'" in \ + test_handle.output[0] + def test_all_subscriptions_run_when_decode_fails(self): """Test all other subscriptions still run when decode fails for one.""" mqtt.subscribe(self.hass, 'test-topic', self.record_calls, diff --git a/tests/components/light/test_mqtt.py b/tests/components/mqtt/test_light.py similarity index 100% rename from tests/components/light/test_mqtt.py rename to tests/components/mqtt/test_light.py diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/mqtt/test_light_json.py similarity index 100% rename from tests/components/light/test_mqtt_json.py rename to tests/components/mqtt/test_light_json.py diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/mqtt/test_light_template.py similarity index 100% rename from tests/components/light/test_mqtt_template.py rename to tests/components/mqtt/test_light_template.py diff --git a/tests/components/lock/test_mqtt.py b/tests/components/mqtt/test_lock.py similarity index 68% rename from tests/components/lock/test_mqtt.py rename to tests/components/mqtt/test_lock.py index 0fcb97f1b68..83ae806d295 100644 --- a/tests/components/lock/test_mqtt.py +++ b/tests/components/mqtt/test_lock.py @@ -1,5 +1,6 @@ """The tests for the MQTT lock platform.""" import json +from unittest.mock import ANY from homeassistant.setup import async_setup_component from homeassistant.const import ( @@ -8,7 +9,8 @@ from homeassistant.components import lock, mqtt from homeassistant.components.mqtt.discovery import async_start from tests.common import ( - async_fire_mqtt_message, async_mock_mqtt_component, MockConfigEntry) + async_fire_mqtt_message, async_mock_mqtt_component, MockConfigEntry, + mock_registry) async def test_controlling_state_via_topic(hass, mqtt_mock): @@ -182,6 +184,72 @@ async def test_discovery_removal_lock(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('lock.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('lock.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('lock.beer') + assert state is None + + +async def test_discovery_update_lock(hass, mqtt_mock, caplog): + """Test update of discovered lock.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "command_topic",' + ' "availability_topic": "availability_topic1" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic2",' + ' "command_topic": "command_topic",' + ' "availability_topic": "availability_topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config', + data1) + await hass.async_block_till_done() + state = hass.states.get('lock.beer') + assert state is not None + assert state.name == 'Beer' + async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('lock.beer') + assert state is not None + assert state.name == 'Milk' + + state = hass.states.get('lock.milk') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT lock device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) @@ -219,3 +287,39 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.name == 'Beer' assert device.model == 'Glass' assert device.sw_version == '0.1-beta' + + +async def test_entity_id_update(hass, mqtt_mock): + """Test MQTT subscriptions are managed when entity_id is updated.""" + registry = mock_registry(hass, {}) + mock_mqtt = await async_mock_mqtt_component(hass) + assert await async_setup_component(hass, lock.DOMAIN, { + lock.DOMAIN: [{ + 'platform': 'mqtt', + 'name': 'beer', + 'state_topic': 'test-topic', + 'command_topic': 'test-topic', + 'availability_topic': 'avty-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }] + }) + + state = hass.states.get('lock.beer') + assert state is not None + assert mock_mqtt.async_subscribe.call_count == 2 + mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8') + mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8') + mock_mqtt.async_subscribe.reset_mock() + + registry.async_update_entity('lock.beer', new_entity_id='lock.milk') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('lock.beer') + assert state is None + + state = hass.states.get('lock.milk') + assert state is not None + assert mock_mqtt.async_subscribe.call_count == 2 + mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8') + mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8') diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/mqtt/test_sensor.py similarity index 90% rename from tests/components/sensor/test_mqtt.py rename to tests/components/mqtt/test_sensor.py index 79ba2c7a512..9de76ff64f4 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/mqtt/test_sensor.py @@ -268,7 +268,7 @@ class TestSensorMQTT(unittest.TestCase): assert '100' == \ state.attributes.get('val') - @patch('homeassistant.components.sensor.mqtt._LOGGER') + @patch('homeassistant.components.mqtt.sensor._LOGGER') def test_update_with_json_attrs_not_dict(self, mock_logger): """Test attributes get extracted from a JSON result.""" mock_component(self.hass, 'mqtt') @@ -289,7 +289,7 @@ class TestSensorMQTT(unittest.TestCase): assert state.attributes.get('val') is None assert mock_logger.warning.called - @patch('homeassistant.components.sensor.mqtt._LOGGER') + @patch('homeassistant.components.mqtt.sensor._LOGGER') def test_update_with_json_attrs_bad_JSON(self, mock_logger): """Test attributes get extracted from a JSON result.""" mock_component(self.hass, 'mqtt') @@ -474,7 +474,7 @@ async def test_discovery_removal_sensor(hass, mqtt_mock, caplog): async def test_discovery_update_sensor(hass, mqtt_mock, caplog): - """Test removal of discovered sensor.""" + """Test update of discovered sensor.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) data1 = ( @@ -506,6 +506,39 @@ async def test_discovery_update_sensor(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic#" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('sensor.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('sensor.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('sensor.beer') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT sensor device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) @@ -577,3 +610,36 @@ async def test_entity_id_update(hass, mqtt_mock): assert mock_mqtt.async_subscribe.call_count == 2 mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8') mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8') + + +async def test_entity_device_info_with_hub(hass, mqtt_mock): + """Test MQTT sensor device registry integration.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + + registry = await hass.helpers.device_registry.async_get_registry() + hub = registry.async_get_or_create( + config_entry_id='123', + connections=set(), + identifiers={('mqtt', 'hub-id')}, + manufacturer='manufacturer', model='hub' + ) + + data = json.dumps({ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'device': { + 'identifiers': ['helloworld'], + 'via_hub': 'hub-id', + }, + 'unique_id': 'veryunique' + }) + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.hub_device_id == hub.id diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py index 102b71d7b53..69386e2bad4 100644 --- a/tests/components/mqtt/test_subscription.py +++ b/tests/components/mqtt/test_subscription.py @@ -1,4 +1,6 @@ """The tests for the MQTT subscription component.""" +from unittest import mock + from homeassistant.core import callback from homeassistant.components.mqtt.subscription import ( async_subscribe_topics, async_unsubscribe_topics) @@ -135,7 +137,7 @@ async def test_qos_encoding_default(hass, mqtt_mock, caplog): {'test_topic1': {'topic': 'test-topic1', 'msg_callback': msg_callback}}) mock_mqtt.async_subscribe.assert_called_once_with( - 'test-topic1', msg_callback, 0, 'utf-8') + 'test-topic1', mock.ANY, 0, 'utf-8') async def test_qos_encoding_custom(hass, mqtt_mock, caplog): @@ -155,7 +157,7 @@ async def test_qos_encoding_custom(hass, mqtt_mock, caplog): 'qos': 1, 'encoding': 'utf-16'}}) mock_mqtt.async_subscribe.assert_called_once_with( - 'test-topic1', msg_callback, 1, 'utf-16') + 'test-topic1', mock.ANY, 1, 'utf-16') async def test_no_change(hass, mqtt_mock, caplog): diff --git a/tests/components/switch/test_mqtt.py b/tests/components/mqtt/test_switch.py similarity index 90% rename from tests/components/switch/test_mqtt.py rename to tests/components/mqtt/test_switch.py index cbacc9d5335..f5adb4062c6 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/mqtt/test_switch.py @@ -128,55 +128,6 @@ async def test_controlling_state_via_topic_and_json_message( assert STATE_OFF == state.state -async def test_controlling_availability(hass, mock_publish): - """Test the controlling state via topic.""" - assert await async_setup_component(hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'state-topic', - 'command_topic': 'command-topic', - 'availability_topic': 'availability_topic', - 'payload_on': 1, - 'payload_off': 0, - 'payload_available': 1, - 'payload_not_available': 0 - } - }) - - state = hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state - - async_fire_mqtt_message(hass, 'availability_topic', '1') - await hass.async_block_till_done() - await hass.async_block_till_done() - - state = hass.states.get('switch.test') - assert STATE_OFF == state.state - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - async_fire_mqtt_message(hass, 'availability_topic', '0') - await hass.async_block_till_done() - await hass.async_block_till_done() - - state = hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state - - async_fire_mqtt_message(hass, 'state-topic', '1') - await hass.async_block_till_done() - await hass.async_block_till_done() - - state = hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state - - async_fire_mqtt_message(hass, 'availability_topic', '1') - await hass.async_block_till_done() - await hass.async_block_till_done() - - state = hass.states.get('switch.test') - assert STATE_ON == state.state - - async def test_default_availability_payload(hass, mock_publish): """Test the availability payload.""" assert await async_setup_component(hass, switch.DOMAIN, { @@ -334,7 +285,7 @@ async def test_unique_id(hass): async def test_discovery_removal_switch(hass, mqtt_mock, caplog): - """Test expansion of discovered switch.""" + """Test removal of discovered switch.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) @@ -363,7 +314,7 @@ async def test_discovery_removal_switch(hass, mqtt_mock, caplog): async def test_discovery_update_switch(hass, mqtt_mock, caplog): - """Test expansion of discovered switch.""" + """Test update of discovered switch.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) @@ -398,6 +349,39 @@ async def test_discovery_update_switch(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('switch.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('switch.beer') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT switch device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py new file mode 100644 index 00000000000..356ce44c6cb --- /dev/null +++ b/tests/components/mqtt/test_vacuum.py @@ -0,0 +1,396 @@ +"""The tests for the Mqtt vacuum platform.""" +import json +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.const import ( + CONF_PLATFORM, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, CONF_NAME) +from homeassistant.components import vacuum, mqtt +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON, ATTR_STATUS, + ATTR_FAN_SPEED) +from homeassistant.components.mqtt import ( + CONF_COMMAND_TOPIC, vacuum as mqttvacuum) +from homeassistant.components.mqtt.discovery import async_start +from tests.common import ( + async_mock_mqtt_component, + async_fire_mqtt_message, MockConfigEntry) +from tests.components.vacuum import common + +default_config = { + CONF_PLATFORM: 'mqtt', + CONF_NAME: 'mqtttest', + CONF_COMMAND_TOPIC: 'vacuum/command', + mqttvacuum.CONF_SEND_COMMAND_TOPIC: 'vacuum/send_command', + mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: 'vacuum/state', + mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: + '{{ value_json.battery_level }}', + mqttvacuum.CONF_CHARGING_TOPIC: 'vacuum/state', + mqttvacuum.CONF_CHARGING_TEMPLATE: '{{ value_json.charging }}', + mqttvacuum.CONF_CLEANING_TOPIC: 'vacuum/state', + mqttvacuum.CONF_CLEANING_TEMPLATE: '{{ value_json.cleaning }}', + mqttvacuum.CONF_DOCKED_TOPIC: 'vacuum/state', + mqttvacuum.CONF_DOCKED_TEMPLATE: '{{ value_json.docked }}', + mqttvacuum.CONF_STATE_TOPIC: 'vacuum/state', + mqttvacuum.CONF_STATE_TEMPLATE: '{{ value_json.state }}', + mqttvacuum.CONF_FAN_SPEED_TOPIC: 'vacuum/state', + mqttvacuum.CONF_FAN_SPEED_TEMPLATE: '{{ value_json.fan_speed }}', + mqttvacuum.CONF_SET_FAN_SPEED_TOPIC: 'vacuum/set_fan_speed', + mqttvacuum.CONF_FAN_SPEED_LIST: ['min', 'medium', 'high', 'max'], +} + + +@pytest.fixture +def mock_publish(hass): + """Initialize components.""" + yield hass.loop.run_until_complete(async_mock_mqtt_component(hass)) + + +async def test_default_supported_features(hass, mock_publish): + """Test that the correct supported features.""" + assert await async_setup_component(hass, vacuum.DOMAIN, { + vacuum.DOMAIN: default_config, + }) + entity = hass.states.get('vacuum.mqtttest') + entity_features = \ + entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) + assert sorted(mqttvacuum.services_to_strings(entity_features)) == \ + sorted(['turn_on', 'turn_off', 'stop', + 'return_home', 'battery', 'status', + 'clean_spot']) + + +async def test_all_commands(hass, mock_publish): + """Test simple commands to the vacuum.""" + default_config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \ + mqttvacuum.services_to_strings(mqttvacuum.ALL_SERVICES) + + assert await async_setup_component(hass, vacuum.DOMAIN, { + vacuum.DOMAIN: default_config, + }) + + common.turn_on(hass, 'vacuum.mqtttest') + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'turn_on', 0, False) + mock_publish.async_publish.reset_mock() + + common.turn_off(hass, 'vacuum.mqtttest') + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'turn_off', 0, False) + mock_publish.async_publish.reset_mock() + + common.stop(hass, 'vacuum.mqtttest') + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'stop', 0, False) + mock_publish.async_publish.reset_mock() + + common.clean_spot(hass, 'vacuum.mqtttest') + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'clean_spot', 0, False) + mock_publish.async_publish.reset_mock() + + common.locate(hass, 'vacuum.mqtttest') + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'locate', 0, False) + mock_publish.async_publish.reset_mock() + + common.start_pause(hass, 'vacuum.mqtttest') + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'start_pause', 0, False) + mock_publish.async_publish.reset_mock() + + common.return_to_base(hass, 'vacuum.mqtttest') + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'return_to_base', 0, False) + mock_publish.async_publish.reset_mock() + + common.set_fan_speed(hass, 'high', 'vacuum.mqtttest') + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_publish.async_publish.assert_called_once_with( + 'vacuum/set_fan_speed', 'high', 0, False) + mock_publish.async_publish.reset_mock() + + common.send_command(hass, '44 FE 93', entity_id='vacuum.mqtttest') + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_publish.async_publish.assert_called_once_with( + 'vacuum/send_command', '44 FE 93', 0, False) + + +async def test_status(hass, mock_publish): + """Test status updates from the vacuum.""" + default_config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \ + mqttvacuum.services_to_strings(mqttvacuum.ALL_SERVICES) + + assert await async_setup_component(hass, vacuum.DOMAIN, { + vacuum.DOMAIN: default_config, + }) + + message = """{ + "battery_level": 54, + "cleaning": true, + "docked": false, + "charging": false, + "fan_speed": "max" + }""" + async_fire_mqtt_message(hass, 'vacuum/state', message) + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('vacuum.mqtttest') + assert STATE_ON == state.state + assert 'mdi:battery-50' == \ + state.attributes.get(ATTR_BATTERY_ICON) + assert 54 == state.attributes.get(ATTR_BATTERY_LEVEL) + assert 'max' == state.attributes.get(ATTR_FAN_SPEED) + + message = """{ + "battery_level": 61, + "docked": true, + "cleaning": false, + "charging": true, + "fan_speed": "min" + }""" + + async_fire_mqtt_message(hass, 'vacuum/state', message) + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('vacuum.mqtttest') + assert STATE_OFF == state.state + assert 'mdi:battery-charging-60' == \ + state.attributes.get(ATTR_BATTERY_ICON) + assert 61 == state.attributes.get(ATTR_BATTERY_LEVEL) + assert 'min' == state.attributes.get(ATTR_FAN_SPEED) + + +async def test_battery_template(hass, mock_publish): + """Test that you can use non-default templates for battery_level.""" + default_config.update({ + mqttvacuum.CONF_SUPPORTED_FEATURES: + mqttvacuum.services_to_strings(mqttvacuum.ALL_SERVICES), + mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level", + mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}" + }) + + assert await async_setup_component(hass, vacuum.DOMAIN, { + vacuum.DOMAIN: default_config, + }) + + async_fire_mqtt_message(hass, 'retroroomba/battery_level', '54') + await hass.async_block_till_done() + state = hass.states.get('vacuum.mqtttest') + assert 54 == state.attributes.get(ATTR_BATTERY_LEVEL) + assert state.attributes.get(ATTR_BATTERY_ICON) == \ + 'mdi:battery-50' + + +async def test_status_invalid_json(hass, mock_publish): + """Test to make sure nothing breaks if the vacuum sends bad JSON.""" + default_config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \ + mqttvacuum.services_to_strings(mqttvacuum.ALL_SERVICES) + + assert await async_setup_component(hass, vacuum.DOMAIN, { + vacuum.DOMAIN: default_config, + }) + + async_fire_mqtt_message(hass, 'vacuum/state', '{"asdfasas false}') + await hass.async_block_till_done() + state = hass.states.get('vacuum.mqtttest') + assert STATE_OFF == state.state + assert "Stopped" == state.attributes.get(ATTR_STATUS) + + +async def test_default_availability_payload(hass, mock_publish): + """Test availability by default payload with defined topic.""" + default_config.update({ + 'availability_topic': 'availability-topic' + }) + + assert await async_setup_component(hass, vacuum.DOMAIN, { + vacuum.DOMAIN: default_config, + }) + + state = hass.states.get('vacuum.mqtttest') + assert STATE_UNAVAILABLE == state.state + + async_fire_mqtt_message(hass, 'availability-topic', 'online') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('vacuum.mqtttest') + assert STATE_UNAVAILABLE != state.state + + async_fire_mqtt_message(hass, 'availability-topic', 'offline') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('vacuum.mqtttest') + assert STATE_UNAVAILABLE == state.state + + +async def test_custom_availability_payload(hass, mock_publish): + """Test availability by custom payload with defined topic.""" + default_config.update({ + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + }) + + assert await async_setup_component(hass, vacuum.DOMAIN, { + vacuum.DOMAIN: default_config, + }) + + state = hass.states.get('vacuum.mqtttest') + assert STATE_UNAVAILABLE == state.state + + async_fire_mqtt_message(hass, 'availability-topic', 'good') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('vacuum.mqtttest') + assert STATE_UNAVAILABLE != state.state + + async_fire_mqtt_message(hass, 'availability-topic', 'nogood') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('vacuum.mqtttest') + assert STATE_UNAVAILABLE == state.state + + +async def test_discovery_removal_vacuum(hass, mock_publish): + """Test removal of discovered vacuum.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('vacuum.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', '') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('vacuum.beer') + assert state is None + + +async def test_discovery_update_vacuum(hass, mock_publish): + """Test update of discovered vacuum.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('vacuum.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('vacuum.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('vacuum.milk') + assert state is None + + +async def test_unique_id(hass, mock_publish): + """Test unique id option only creates one vacuum per unique_id.""" + await async_mock_mqtt_component(hass) + assert await async_setup_component(hass, vacuum.DOMAIN, { + vacuum.DOMAIN: [{ + 'platform': 'mqtt', + 'name': 'Test 1', + 'command_topic': 'command-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }, { + 'platform': 'mqtt', + 'name': 'Test 2', + 'command_topic': 'command-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }] + }) + + async_fire_mqtt_message(hass, 'test-topic', 'payload') + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 2 + # all vacuums group is 1, unique id created is 1 + + +async def test_entity_device_info_with_identifier(hass, mock_publish): + """Test MQTT vacuum device registry integration.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps({ + 'platform': 'mqtt', + 'name': 'Test 1', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + }) + async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.identifiers == {('mqtt', 'helloworld')} + assert device.connections == {('mac', "02:5b:26:a8:dc:12")} + assert device.manufacturer == 'Whatever' + assert device.name == 'Beer' + assert device.model == 'Glass' + assert device.sw_version == '0.1-beta' diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index 08210ecd9a2..e33c297b166 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -81,6 +81,40 @@ class TestHtml5Notify: assert service is not None + @patch('pywebpush.WebPusher') + def test_dismissing_message(self, mock_wp): + """Test dismissing message.""" + hass = MagicMock() + + data = { + 'device': SUBSCRIPTION_1 + } + + m = mock_open(read_data=json.dumps(data)) + with patch( + 'homeassistant.util.json.open', + m, create=True + ): + service = html5.get_service(hass, {'gcm_sender_id': '100'}) + + assert service is not None + + service.dismiss(target=['device', 'non_existing'], + data={'tag': 'test'}) + + assert len(mock_wp.mock_calls) == 3 + + # WebPusher constructor + assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_1['subscription'] + # Third mock_call checks the status_code of the response. + assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__' + + # Call to send + payload = json.loads(mock_wp.mock_calls[1][1][0]) + + assert payload['dismiss'] is True + assert payload['tag'] == 'test' + @patch('pywebpush.WebPusher') def test_sending_message(self, mock_wp): """Test sending message.""" @@ -165,6 +199,23 @@ async def test_registering_new_device_view(hass, hass_client): } +async def test_registering_new_device_view_with_name(hass, hass_client): + """Test that the HTML view works with name attribute.""" + client = await mock_client(hass, hass_client) + + SUB_WITH_NAME = SUBSCRIPTION_1.copy() + SUB_WITH_NAME['name'] = 'test device' + + with patch('homeassistant.components.notify.html5.save_json') as mock_save: + resp = await client.post(REGISTER_URL, data=json.dumps(SUB_WITH_NAME)) + + assert resp.status == 200 + assert len(mock_save.mock_calls) == 1 + assert mock_save.mock_calls[0][1][1] == { + 'test device': SUBSCRIPTION_1, + } + + async def test_registering_new_device_expiration_view(hass, hass_client): """Test that the HTML view works.""" client = await mock_client(hass, hass_client) @@ -209,6 +260,27 @@ async def test_registering_existing_device_view(hass, hass_client): } +async def test_registering_existing_device_view_with_name(hass, hass_client): + """Test subscription is updated when reg'ing existing device with name.""" + registrations = {} + client = await mock_client(hass, hass_client, registrations) + + SUB_WITH_NAME = SUBSCRIPTION_1.copy() + SUB_WITH_NAME['name'] = 'test device' + + with patch('homeassistant.components.notify.html5.save_json') as mock_save: + await client.post(REGISTER_URL, data=json.dumps(SUB_WITH_NAME)) + resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) + + assert resp.status == 200 + assert mock_save.mock_calls[0][1][1] == { + 'test device': SUBSCRIPTION_4, + } + assert registrations == { + 'test device': SUBSCRIPTION_4, + } + + async def test_registering_existing_device_fails_view(hass, hass_client): """Test sub. is not updated when registering existing device fails.""" registrations = {} diff --git a/tests/components/openuv/test_config_flow.py b/tests/components/openuv/test_config_flow.py index 60aa990333f..21e3db7df4f 100644 --- a/tests/components/openuv/test_config_flow.py +++ b/tests/components/openuv/test_config_flow.py @@ -1,14 +1,27 @@ """Define tests for the OpenUV config flow.""" -from datetime import timedelta -from unittest.mock import patch +import pytest +from pyopenuv.errors import OpenUvError from homeassistant import data_entry_flow from homeassistant.components.openuv import DOMAIN, config_flow from homeassistant.const import ( - CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, - CONF_SCAN_INTERVAL) + CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE) -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry, MockDependency, mock_coro + + +@pytest.fixture +def uv_index_response(): + """Define a fixture for a successful /uv response.""" + return mock_coro() + + +@pytest.fixture +def mock_pyopenuv(uv_index_response): + """Mock the pyopenuv library.""" + with MockDependency('pyopenuv') as mock_pyopenuv_: + mock_pyopenuv_.Client().uv_index.return_value = uv_index_response + yield mock_pyopenuv_ async def test_duplicate_error(hass): @@ -28,7 +41,9 @@ async def test_duplicate_error(hass): assert result['errors'] == {CONF_LATITUDE: 'identifier_exists'} -async def test_invalid_api_key(hass): +@pytest.mark.parametrize( + 'uv_index_response', [mock_coro(exception=OpenUvError)]) +async def test_invalid_api_key(hass, mock_pyopenuv): """Test that an invalid API key throws an error.""" conf = { CONF_API_KEY: '12345abcde', @@ -40,10 +55,8 @@ async def test_invalid_api_key(hass): flow = config_flow.OpenUvFlowHandler() flow.hass = hass - with patch('pyopenuv.util.validate_api_key', - return_value=mock_coro(False)): - result = await flow.async_step_user(user_input=conf) - assert result['errors'] == {CONF_API_KEY: 'invalid_api_key'} + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_API_KEY: 'invalid_api_key'} async def test_show_form(hass): @@ -57,7 +70,7 @@ async def test_show_form(hass): assert result['step_id'] == 'user' -async def test_step_import(hass): +async def test_step_import(hass, mock_pyopenuv): """Test that the import step works.""" conf = { CONF_API_KEY: '12345abcde', @@ -69,44 +82,35 @@ async def test_step_import(hass): flow = config_flow.OpenUvFlowHandler() flow.hass = hass - with patch('pyopenuv.util.validate_api_key', - return_value=mock_coro(True)): - result = await flow.async_step_import(import_config=conf) - - assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result['title'] == '39.128712, -104.9812612' - assert result['data'] == { - CONF_API_KEY: '12345abcde', - CONF_ELEVATION: 59.1234, - CONF_LATITUDE: 39.128712, - CONF_LONGITUDE: -104.9812612, - CONF_SCAN_INTERVAL: 1800, - } + result = await flow.async_step_import(import_config=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '39.128712, -104.9812612' + assert result['data'] == { + CONF_API_KEY: '12345abcde', + CONF_ELEVATION: 59.1234, + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + } -async def test_step_user(hass): +async def test_step_user(hass, mock_pyopenuv): """Test that the user step works.""" conf = { CONF_API_KEY: '12345abcde', CONF_ELEVATION: 59.1234, CONF_LATITUDE: 39.128712, CONF_LONGITUDE: -104.9812612, - CONF_SCAN_INTERVAL: timedelta(minutes=5) } flow = config_flow.OpenUvFlowHandler() flow.hass = hass - with patch('pyopenuv.util.validate_api_key', - return_value=mock_coro(True)): - result = await flow.async_step_user(user_input=conf) - - assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result['title'] == '39.128712, -104.9812612' - assert result['data'] == { - CONF_API_KEY: '12345abcde', - CONF_ELEVATION: 59.1234, - CONF_LATITUDE: 39.128712, - CONF_LONGITUDE: -104.9812612, - CONF_SCAN_INTERVAL: 300, - } + result = await flow.async_step_user(user_input=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '39.128712, -104.9812612' + assert result['data'] == { + CONF_API_KEY: '12345abcde', + CONF_ELEVATION: 59.1234, + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + } diff --git a/tests/components/sensor/test_fail2ban.py b/tests/components/sensor/test_fail2ban.py index a48b7027725..f9be2cbf985 100644 --- a/tests/components/sensor/test_fail2ban.py +++ b/tests/components/sensor/test_fail2ban.py @@ -19,6 +19,10 @@ def fake_log(log_key): '2017-01-01 12:23:35 fail2ban.actions [111]: ' 'NOTICE [jail_one] Ban 111.111.111.111' ), + 'ipv6_ban': ( + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Ban 2607:f0d0:1002:51::4' + ), 'multi_ban': ( '2017-01-01 12:23:35 fail2ban.actions [111]: ' 'NOTICE [jail_one] Ban 111.111.111.111\n' @@ -112,6 +116,23 @@ class TestBanSensor(unittest.TestCase): assert \ sensor.state_attributes[STATE_ALL_BANS] == ['111.111.111.111'] + def test_ipv6_ban(self): + """Test that log is parsed correctly for IPV6 bans.""" + log_parser = BanLogParser('/tmp') + sensor = BanSensor('fail2ban', 'jail_one', log_parser) + assert sensor.name == 'fail2ban jail_one' + mock_fh = MockOpen(read_data=fake_log('ipv6_ban')) + with patch('homeassistant.components.sensor.fail2ban.open', mock_fh, + create=True): + sensor.update() + + assert sensor.state == '2607:f0d0:1002:51::4' + assert \ + sensor.state_attributes[STATE_CURRENT_BANS] == \ + ['2607:f0d0:1002:51::4'] + assert \ + sensor.state_attributes[STATE_ALL_BANS] == ['2607:f0d0:1002:51::4'] + def test_multiple_ban(self): """Test that log is parsed correctly for multiple ban.""" log_parser = BanLogParser('/tmp') diff --git a/tests/components/sensor/test_jewish_calendar.py b/tests/components/sensor/test_jewish_calendar.py index 320bc903661..639364164e0 100644 --- a/tests/components/sensor/test_jewish_calendar.py +++ b/tests/components/sensor/test_jewish_calendar.py @@ -1,4 +1,5 @@ """The tests for the Jewish calendar sensor platform.""" +from collections import namedtuple from datetime import time from datetime import datetime as dt from unittest.mock import patch @@ -8,13 +9,34 @@ import pytest from homeassistant.util.async_ import run_coroutine_threadsafe from homeassistant.util.dt import get_time_zone, set_default_time_zone from homeassistant.setup import setup_component -from homeassistant.components.sensor.jewish_calendar import JewishCalSensor +from homeassistant.components.sensor.jewish_calendar import ( + JewishCalSensor, CANDLE_LIGHT_DEFAULT) from tests.common import get_test_home_assistant +_LatLng = namedtuple('_LatLng', ['lat', 'lng']) + +NYC_LATLNG = _LatLng(40.7128, -74.0060) +JERUSALEM_LATLNG = _LatLng(31.778, 35.235) + + +def make_nyc_test_params(dtime, results, havdalah_offset=0): + """Make test params for NYC.""" + return (dtime, CANDLE_LIGHT_DEFAULT, havdalah_offset, True, + 'America/New_York', NYC_LATLNG.lat, NYC_LATLNG.lng, results) + + +def make_jerusalem_test_params(dtime, results, havdalah_offset=0): + """Make test params for Jerusalem.""" + return (dtime, CANDLE_LIGHT_DEFAULT, havdalah_offset, False, + 'Asia/Jerusalem', JERUSALEM_LATLNG.lat, JERUSALEM_LATLNG.lng, + results) + + class TestJewishCalenderSensor(): """Test the Jewish Calendar sensor.""" + # pylint: disable=attribute-defined-outside-init def setup_method(self, method): """Set up things to run when tests begin.""" self.hass = get_test_home_assistant() @@ -99,21 +121,315 @@ class TestJewishCalenderSensor(): "date_after_sunset" ] - @pytest.mark.parametrize(["time", "tzname", "latitude", "longitude", + @pytest.mark.parametrize(["cur_time", "tzname", "latitude", "longitude", "language", "sensor", "diaspora", "result"], test_params, ids=test_ids) - def test_jewish_calendar_sensor(self, time, tzname, latitude, longitude, - language, sensor, diaspora, result): + def test_jewish_calendar_sensor(self, cur_time, tzname, latitude, + longitude, language, sensor, diaspora, + result): """Test Jewish calendar sensor output.""" - tz = get_time_zone(tzname) - set_default_time_zone(tz) - test_time = tz.localize(time) + time_zone = get_time_zone(tzname) + set_default_time_zone(time_zone) + test_time = time_zone.localize(cur_time) self.hass.config.latitude = latitude self.hass.config.longitude = longitude sensor = JewishCalSensor( name='test', language=language, sensor_type=sensor, latitude=latitude, longitude=longitude, - timezone=tz, diaspora=diaspora) + timezone=time_zone, diaspora=diaspora) + sensor.hass = self.hass + with patch('homeassistant.util.dt.now', return_value=test_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop).result() + assert sensor.state == result + + shabbat_params = [ + make_nyc_test_params( + dt(2018, 9, 1, 16, 0), + {'upcoming_shabbat_candle_lighting': dt(2018, 8, 31, 19, 15), + 'upcoming_shabbat_havdalah': dt(2018, 9, 1, 20, 14), + 'weekly_portion': 'Ki Tavo', + 'hebrew_weekly_portion': 'כי תבוא'}), + make_nyc_test_params( + dt(2018, 9, 1, 16, 0), + {'upcoming_shabbat_candle_lighting': dt(2018, 8, 31, 19, 15), + 'upcoming_shabbat_havdalah': dt(2018, 9, 1, 20, 22), + 'weekly_portion': 'Ki Tavo', + 'hebrew_weekly_portion': 'כי תבוא'}, + havdalah_offset=50), + make_nyc_test_params( + dt(2018, 9, 1, 20, 21), + {'upcoming_shabbat_candle_lighting': dt(2018, 9, 7, 19, 4), + 'upcoming_shabbat_havdalah': dt(2018, 9, 8, 20, 2), + 'weekly_portion': 'Nitzavim', + 'hebrew_weekly_portion': 'נצבים'}), + make_nyc_test_params( + dt(2018, 9, 7, 13, 1), + {'upcoming_shabbat_candle_lighting': dt(2018, 9, 7, 19, 4), + 'upcoming_shabbat_havdalah': dt(2018, 9, 8, 20, 2), + 'weekly_portion': 'Nitzavim', + 'hebrew_weekly_portion': 'נצבים'}), + make_nyc_test_params( + dt(2018, 9, 8, 21, 25), + {'upcoming_candle_lighting': dt(2018, 9, 9, 19, 1), + 'upcoming_havdalah': dt(2018, 9, 11, 19, 57), + 'upcoming_shabbat_candle_lighting': dt(2018, 9, 14, 18, 52), + 'upcoming_shabbat_havdalah': dt(2018, 9, 15, 19, 50), + 'weekly_portion': 'Vayeilech', + 'hebrew_weekly_portion': 'וילך', + 'holiday_name': 'Erev Rosh Hashana', + 'hebrew_holiday_name': 'ערב ראש השנה'}), + make_nyc_test_params( + dt(2018, 9, 9, 21, 25), + {'upcoming_candle_lighting': dt(2018, 9, 9, 19, 1), + 'upcoming_havdalah': dt(2018, 9, 11, 19, 57), + 'upcoming_shabbat_candle_lighting': dt(2018, 9, 14, 18, 52), + 'upcoming_shabbat_havdalah': dt(2018, 9, 15, 19, 50), + 'weekly_portion': 'Vayeilech', + 'hebrew_weekly_portion': 'וילך', + 'holiday_name': 'Rosh Hashana I', + 'hebrew_holiday_name': "א' ראש השנה"}), + make_nyc_test_params( + dt(2018, 9, 10, 21, 25), + {'upcoming_candle_lighting': dt(2018, 9, 9, 19, 1), + 'upcoming_havdalah': dt(2018, 9, 11, 19, 57), + 'upcoming_shabbat_candle_lighting': dt(2018, 9, 14, 18, 52), + 'upcoming_shabbat_havdalah': dt(2018, 9, 15, 19, 50), + 'weekly_portion': 'Vayeilech', + 'hebrew_weekly_portion': 'וילך', + 'holiday_name': 'Rosh Hashana II', + 'hebrew_holiday_name': "ב' ראש השנה"}), + make_nyc_test_params( + dt(2018, 9, 28, 21, 25), + {'upcoming_shabbat_candle_lighting': dt(2018, 9, 28, 18, 28), + 'upcoming_shabbat_havdalah': dt(2018, 9, 29, 19, 25), + 'weekly_portion': 'none', + 'hebrew_weekly_portion': 'none'}), + make_nyc_test_params( + dt(2018, 9, 29, 21, 25), + {'upcoming_candle_lighting': dt(2018, 9, 30, 18, 25), + 'upcoming_havdalah': dt(2018, 10, 2, 19, 20), + 'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 17), + 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 19, 13), + 'weekly_portion': 'Bereshit', + 'hebrew_weekly_portion': 'בראשית', + 'holiday_name': 'Hoshana Raba', + 'hebrew_holiday_name': 'הושענא רבה'}), + make_nyc_test_params( + dt(2018, 9, 30, 21, 25), + {'upcoming_candle_lighting': dt(2018, 9, 30, 18, 25), + 'upcoming_havdalah': dt(2018, 10, 2, 19, 20), + 'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 17), + 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 19, 13), + 'weekly_portion': 'Bereshit', + 'hebrew_weekly_portion': 'בראשית', + 'holiday_name': 'Shmini Atzeret', + 'hebrew_holiday_name': 'שמיני עצרת'}), + make_nyc_test_params( + dt(2018, 10, 1, 21, 25), + {'upcoming_candle_lighting': dt(2018, 9, 30, 18, 25), + 'upcoming_havdalah': dt(2018, 10, 2, 19, 20), + 'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 17), + 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 19, 13), + 'weekly_portion': 'Bereshit', + 'hebrew_weekly_portion': 'בראשית', + 'holiday_name': 'Simchat Torah', + 'hebrew_holiday_name': 'שמחת תורה'}), + make_jerusalem_test_params( + dt(2018, 9, 29, 21, 25), + {'upcoming_candle_lighting': dt(2018, 9, 30, 18, 10), + 'upcoming_havdalah': dt(2018, 10, 1, 19, 2), + 'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 3), + 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 18, 56), + 'weekly_portion': 'Bereshit', + 'hebrew_weekly_portion': 'בראשית', + 'holiday_name': 'Hoshana Raba', + 'hebrew_holiday_name': 'הושענא רבה'}), + make_jerusalem_test_params( + dt(2018, 9, 30, 21, 25), + {'upcoming_candle_lighting': dt(2018, 9, 30, 18, 10), + 'upcoming_havdalah': dt(2018, 10, 1, 19, 2), + 'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 3), + 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 18, 56), + 'weekly_portion': 'Bereshit', + 'hebrew_weekly_portion': 'בראשית', + 'holiday_name': 'Shmini Atzeret', + 'hebrew_holiday_name': 'שמיני עצרת'}), + make_jerusalem_test_params( + dt(2018, 10, 1, 21, 25), + {'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 3), + 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 18, 56), + 'weekly_portion': 'Bereshit', + 'hebrew_weekly_portion': 'בראשית'}), + make_nyc_test_params( + dt(2016, 6, 11, 8, 25), + {'upcoming_candle_lighting': dt(2016, 6, 10, 20, 7), + 'upcoming_havdalah': dt(2016, 6, 13, 21, 17), + 'upcoming_shabbat_candle_lighting': dt(2016, 6, 10, 20, 7), + 'upcoming_shabbat_havdalah': None, + 'weekly_portion': 'Bamidbar', + 'hebrew_weekly_portion': 'במדבר', + 'holiday_name': 'Erev Shavuot', + 'hebrew_holiday_name': 'ערב שבועות'}), + make_nyc_test_params( + dt(2016, 6, 12, 8, 25), + {'upcoming_candle_lighting': dt(2016, 6, 10, 20, 7), + 'upcoming_havdalah': dt(2016, 6, 13, 21, 17), + 'upcoming_shabbat_candle_lighting': dt(2016, 6, 17, 20, 10), + 'upcoming_shabbat_havdalah': dt(2016, 6, 18, 21, 19), + 'weekly_portion': 'Nasso', + 'hebrew_weekly_portion': 'נשא', + 'holiday_name': 'Shavuot', + 'hebrew_holiday_name': 'שבועות'}), + make_jerusalem_test_params( + dt(2017, 9, 21, 8, 25), + {'upcoming_candle_lighting': dt(2017, 9, 20, 18, 23), + 'upcoming_havdalah': dt(2017, 9, 23, 19, 13), + 'upcoming_shabbat_candle_lighting': dt(2017, 9, 22, 19, 14), + 'upcoming_shabbat_havdalah': dt(2017, 9, 23, 19, 13), + 'weekly_portion': "Ha'Azinu", + 'hebrew_weekly_portion': 'האזינו', + 'holiday_name': 'Rosh Hashana I', + 'hebrew_holiday_name': "א' ראש השנה"}), + make_jerusalem_test_params( + dt(2017, 9, 22, 8, 25), + {'upcoming_candle_lighting': dt(2017, 9, 20, 18, 23), + 'upcoming_havdalah': dt(2017, 9, 23, 19, 13), + 'upcoming_shabbat_candle_lighting': dt(2017, 9, 22, 19, 14), + 'upcoming_shabbat_havdalah': dt(2017, 9, 23, 19, 13), + 'weekly_portion': "Ha'Azinu", + 'hebrew_weekly_portion': 'האזינו', + 'holiday_name': 'Rosh Hashana II', + 'hebrew_holiday_name': "ב' ראש השנה"}), + make_jerusalem_test_params( + dt(2017, 9, 23, 8, 25), + {'upcoming_candle_lighting': dt(2017, 9, 20, 18, 23), + 'upcoming_havdalah': dt(2017, 9, 23, 19, 13), + 'upcoming_shabbat_candle_lighting': dt(2017, 9, 22, 19, 14), + 'upcoming_shabbat_havdalah': dt(2017, 9, 23, 19, 13), + 'weekly_portion': "Ha'Azinu", + 'hebrew_weekly_portion': 'האזינו', + 'holiday_name': '', + 'hebrew_holiday_name': ''}), + ] + + shabbat_test_ids = [ + "currently_first_shabbat", + "currently_first_shabbat_with_havdalah_offset", + "after_first_shabbat", + "friday_upcoming_shabbat", + "upcoming_rosh_hashana", + "currently_rosh_hashana", + "second_day_rosh_hashana", + "currently_shabbat_chol_hamoed", + "upcoming_two_day_yomtov_in_diaspora", + "currently_first_day_of_two_day_yomtov_in_diaspora", + "currently_second_day_of_two_day_yomtov_in_diaspora", + "upcoming_one_day_yom_tov_in_israel", + "currently_one_day_yom_tov_in_israel", + "after_one_day_yom_tov_in_israel", + # Type 1 = Sat/Sun/Mon + "currently_first_day_of_three_day_type1_yomtov_in_diaspora", + "currently_second_day_of_three_day_type1_yomtov_in_diaspora", + # Type 2 = Thurs/Fri/Sat + "currently_first_day_of_three_day_type2_yomtov_in_israel", + "currently_second_day_of_three_day_type2_yomtov_in_israel", + "currently_third_day_of_three_day_type2_yomtov_in_israel", + ] + + @pytest.mark.parametrize(["now", "candle_lighting", "havdalah", "diaspora", + "tzname", "latitude", "longitude", "result"], + shabbat_params, ids=shabbat_test_ids) + def test_shabbat_times_sensor(self, now, candle_lighting, havdalah, + diaspora, tzname, latitude, longitude, + result): + """Test sensor output for upcoming shabbat/yomtov times.""" + time_zone = get_time_zone(tzname) + set_default_time_zone(time_zone) + test_time = time_zone.localize(now) + for sensor_type, value in result.items(): + if isinstance(value, dt): + result[sensor_type] = time_zone.localize(value) + self.hass.config.latitude = latitude + self.hass.config.longitude = longitude + + if ('upcoming_shabbat_candle_lighting' in result + and 'upcoming_candle_lighting' not in result): + result['upcoming_candle_lighting'] = \ + result['upcoming_shabbat_candle_lighting'] + if ('upcoming_shabbat_havdalah' in result + and 'upcoming_havdalah' not in result): + result['upcoming_havdalah'] = result['upcoming_shabbat_havdalah'] + + for sensor_type, result_value in result.items(): + language = 'english' + if sensor_type.startswith('hebrew_'): + language = 'hebrew' + sensor_type = sensor_type.replace('hebrew_', '') + sensor = JewishCalSensor( + name='test', language=language, sensor_type=sensor_type, + latitude=latitude, longitude=longitude, + timezone=time_zone, diaspora=diaspora, + havdalah_offset=havdalah, + candle_lighting_offset=candle_lighting) + sensor.hass = self.hass + with patch('homeassistant.util.dt.now', return_value=test_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop).result() + assert sensor.state == result_value, "Value for {}".format( + sensor_type) + + melacha_params = [ + make_nyc_test_params(dt(2018, 9, 1, 16, 0), True), + make_nyc_test_params(dt(2018, 9, 1, 20, 21), False), + make_nyc_test_params(dt(2018, 9, 7, 13, 1), False), + make_nyc_test_params(dt(2018, 9, 8, 21, 25), False), + make_nyc_test_params(dt(2018, 9, 9, 21, 25), True), + make_nyc_test_params(dt(2018, 9, 10, 21, 25), True), + make_nyc_test_params(dt(2018, 9, 28, 21, 25), True), + make_nyc_test_params(dt(2018, 9, 29, 21, 25), False), + make_nyc_test_params(dt(2018, 9, 30, 21, 25), True), + make_nyc_test_params(dt(2018, 10, 1, 21, 25), True), + make_jerusalem_test_params(dt(2018, 9, 29, 21, 25), False), + make_jerusalem_test_params(dt(2018, 9, 30, 21, 25), True), + make_jerusalem_test_params(dt(2018, 10, 1, 21, 25), False), + ] + melacha_test_ids = [ + "currently_first_shabbat", + "after_first_shabbat", + "friday_upcoming_shabbat", + "upcoming_rosh_hashana", + "currently_rosh_hashana", + "second_day_rosh_hashana", + "currently_shabbat_chol_hamoed", + "upcoming_two_day_yomtov_in_diaspora", + "currently_first_day_of_two_day_yomtov_in_diaspora", + "currently_second_day_of_two_day_yomtov_in_diaspora", + "upcoming_one_day_yom_tov_in_israel", + "currently_one_day_yom_tov_in_israel", + "after_one_day_yom_tov_in_israel", + ] + + @pytest.mark.parametrize(["now", "candle_lighting", "havdalah", "diaspora", + "tzname", "latitude", "longitude", "result"], + melacha_params, ids=melacha_test_ids) + def test_issur_melacha_sensor(self, now, candle_lighting, havdalah, + diaspora, tzname, latitude, longitude, + result): + """Test Issur Melacha sensor output.""" + time_zone = get_time_zone(tzname) + set_default_time_zone(time_zone) + test_time = time_zone.localize(now) + self.hass.config.latitude = latitude + self.hass.config.longitude = longitude + sensor = JewishCalSensor( + name='test', language='english', + sensor_type='issur_melacha_in_effect', + latitude=latitude, longitude=longitude, + timezone=time_zone, diaspora=diaspora, havdalah_offset=havdalah, + candle_lighting_offset=candle_lighting) sensor.hass = self.hass with patch('homeassistant.util.dt.now', return_value=test_time): run_coroutine_threadsafe( diff --git a/tests/components/sensor/test_min_max.py b/tests/components/sensor/test_min_max.py index ae2f40e5802..f76a05c2ce0 100644 --- a/tests/components/sensor/test_min_max.py +++ b/tests/components/sensor/test_min_max.py @@ -3,7 +3,8 @@ import unittest from homeassistant.setup import setup_component from homeassistant.const import ( - STATE_UNKNOWN, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT) + STATE_UNKNOWN, STATE_UNAVAILABLE, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, + TEMP_FAHRENHEIT) from tests.common import get_test_home_assistant @@ -209,7 +210,7 @@ class TestMinMaxSensor(unittest.TestCase): state = self.hass.states.get('sensor.test_max') assert STATE_UNKNOWN != state.state - self.hass.states.set(entity_ids[1], STATE_UNKNOWN) + self.hass.states.set(entity_ids[1], STATE_UNAVAILABLE) self.hass.block_till_done() state = self.hass.states.get('sensor.test_max') diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index 2ce72fc4fc4..05ab628b1a8 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -135,13 +135,14 @@ class TestRestSensor(unittest.TestCase): '{ "key": "' + self.initial_state + '" }')) self.name = 'foo' self.unit_of_measurement = 'MB' + self.device_class = None self.value_template = template('{{ value_json.key }}') self.value_template.hass = self.hass self.force_update = False self.sensor = rest.RestSensor( self.hass, self.rest, self.name, self.unit_of_measurement, - self.value_template, [], self.force_update + self.device_class, self.value_template, [], self.force_update ) def tearDown(self): @@ -192,7 +193,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( 'plain_state')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, [], + self.unit_of_measurement, + self.device_class, None, [], self.force_update) self.sensor.update() assert 'plain_state' == self.sensor.state @@ -204,7 +206,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( '{ "key": "some_json_value" }')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key'], + self.unit_of_measurement, + self.device_class, None, ['key'], self.force_update) self.sensor.update() assert 'some_json_value' == \ @@ -216,7 +219,8 @@ class TestRestSensor(unittest.TestCase): self.rest.update = Mock('rest.RestData.update', side_effect=self.update_side_effect(None)) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key'], + self.unit_of_measurement, + self.device_class, None, ['key'], self.force_update) self.sensor.update() assert {} == self.sensor.device_state_attributes @@ -229,7 +233,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( '["list", "of", "things"]')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key'], + self.unit_of_measurement, + self.device_class, None, ['key'], self.force_update) self.sensor.update() assert {} == self.sensor.device_state_attributes @@ -242,7 +247,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( 'This is text rather than JSON data.')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key'], + self.unit_of_measurement, + self.device_class, None, ['key'], self.force_update) self.sensor.update() assert {} == self.sensor.device_state_attributes @@ -256,6 +262,7 @@ class TestRestSensor(unittest.TestCase): '{ "key": "json_state_updated_value" }')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, self.unit_of_measurement, + self.device_class, self.value_template, ['key'], self.force_update) self.sensor.update() diff --git a/tests/components/switch/test_unifi.py b/tests/components/switch/test_unifi.py index 0513b5724db..67f1e416cf1 100644 --- a/tests/components/switch/test_unifi.py +++ b/tests/components/switch/test_unifi.py @@ -109,7 +109,7 @@ DEVICE_1 = { 'mac': '00:00:00:00:01:01', 'type': 'usw', 'name': 'mock-name', - 'portconf_id': '', + 'port_overrides': [], 'port_table': [ { 'media': 'GE', diff --git a/tests/components/switch/test_wake_on_lan.py b/tests/components/switch/test_wake_on_lan.py index c3f4e04057d..312a49b5183 100644 --- a/tests/components/switch/test_wake_on_lan.py +++ b/tests/components/switch/test_wake_on_lan.py @@ -139,11 +139,11 @@ class TestWOLSwitch(unittest.TestCase): 'mac_address': '00-01-02-03-04-05', 'host': 'validhostname', 'turn_off': { - 'service': 'shell_command.turn_off_TARGET', + 'service': 'shell_command.turn_off_target', }, } }) - calls = mock_service(self.hass, 'shell_command', 'turn_off_TARGET') + calls = mock_service(self.hass, 'shell_command', 'turn_off_target') state = self.hass.states.get('switch.wake_on_lan') assert STATE_OFF == state.state diff --git a/tests/components/tellduslive/test_config_flow.py b/tests/components/tellduslive/test_config_flow.py index ba569a9f149..5be9a03742e 100644 --- a/tests/components/tellduslive/test_config_flow.py +++ b/tests/components/tellduslive/test_config_flow.py @@ -67,6 +67,7 @@ async def test_full_flow_implementation(hass, mock_tellduslive): result = await flow.async_step_discovery(['localhost', 'tellstick']) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'user' + assert len(flow._hosts) == 2 result = await flow.async_step_user() assert result['type'] == data_entry_flow.RESULT_TYPE_FORM @@ -156,12 +157,14 @@ async def test_step_disco_no_local_api(hass, mock_tellduslive): result = await flow.async_step_discovery(['localhost', 'tellstick']) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'auth' + assert len(flow._hosts) == 1 async def test_step_auth(hass, mock_tellduslive): """Test that create cloud entity from auth.""" flow = init_config_flow(hass) + await flow.async_step_auth() result = await flow.async_step_auth(['localhost', 'tellstick']) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result['title'] == 'Cloud API' @@ -178,10 +181,11 @@ async def test_wrong_auth_flow_implementation(hass, mock_tellduslive): """Test wrong auth.""" flow = init_config_flow(hass) - await flow.async_step_user() + await flow.async_step_auth() result = await flow.async_step_auth('') assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'auth' + assert result['errors']['base'] == 'auth_error' async def test_not_pick_host_if_only_one(hass, mock_tellduslive): @@ -201,6 +205,14 @@ async def test_abort_if_timeout_generating_auth_url(hass, mock_tellduslive): assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'authorize_url_timeout' +async def test_abort_no_auth_url(hass, mock_tellduslive): + """Test abort if generating authorize url returns none.""" + flow = init_config_flow(hass) + flow._get_auth_url = Mock(return_value=False) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_fail' async def test_abort_if_exception_generating_auth_url(hass, mock_tellduslive): """Test we abort if generating authorize url blows up.""" @@ -220,4 +232,4 @@ async def test_discovery_already_configured(hass, mock_tellduslive): result = await flow.async_step_discovery(['some-host', '']) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == 'already_configured' + assert result['reason'] == 'already_setup' diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py index d74ec41b749..b69bc03760b 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -394,6 +394,77 @@ class TestInfluxDB(unittest.TestCase): assert not mock_client.return_value.write_points.called mock_client.return_value.write_points.reset_mock() + def test_event_listener_whitelist_domain_and_entities(self, mock_client): + """Test the event listener against a whitelist.""" + config = { + 'influxdb': { + 'host': 'host', + 'username': 'user', + 'password': 'pass', + 'include': { + 'domains': ['fake'], + 'entities': ['other.one'], + } + } + } + assert setup_component(self.hass, influxdb.DOMAIN, config) + self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() + + for domain in ('fake', 'another_fake'): + state = mock.MagicMock( + state=1, domain=domain, + entity_id='{}.something'.format(domain), + object_id='something', attributes={}) + event = mock.MagicMock(data={'new_state': state}, time_fired=12345) + body = [{ + 'measurement': '{}.something'.format(domain), + 'tags': { + 'domain': domain, + 'entity_id': 'something', + }, + 'time': 12345, + 'fields': { + 'value': 1, + }, + }] + self.handler_method(event) + self.hass.data[influxdb.DOMAIN].block_till_done() + if domain == 'fake': + assert mock_client.return_value.write_points.call_count == 1 + assert mock_client.return_value.write_points.call_args == \ + mock.call(body) + else: + assert not mock_client.return_value.write_points.called + mock_client.return_value.write_points.reset_mock() + + for entity_id in ('one', 'two'): + state = mock.MagicMock( + state=1, domain='other', + entity_id='other.{}'.format(entity_id), + object_id=entity_id, attributes={}) + event = mock.MagicMock(data={'new_state': state}, time_fired=12345) + body = [{ + 'measurement': 'other.{}'.format(entity_id), + 'tags': { + 'domain': 'other', + 'entity_id': entity_id, + }, + 'time': 12345, + 'fields': { + 'value': 1, + }, + }] + self.handler_method(event) + self.hass.data[influxdb.DOMAIN].block_till_done() + if entity_id == 'one': + assert mock_client.return_value.write_points.call_count == 1 + assert mock_client.return_value.write_points.call_args == \ + mock.call(body) + else: + assert not mock_client.return_value.write_points.called + mock_client.return_value.write_points.reset_mock() + def test_event_listener_invalid_type(self, mock_client): """Test the event listener when an attribute has an invalid type.""" self._setup(mock_client) diff --git a/tests/components/vacuum/common.py b/tests/components/vacuum/common.py index 436f23f5546..62a0e429c0a 100644 --- a/tests/components/vacuum/common.py +++ b/tests/components/vacuum/common.py @@ -10,92 +10,189 @@ from homeassistant.components.vacuum import ( from homeassistant.const import ( ATTR_COMMAND, ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON) +from homeassistant.core import callback from homeassistant.loader import bind_hass @bind_hass def turn_on(hass, entity_id=None): + """Turn all or specified vacuum on.""" + hass.add_job(async_turn_on, hass, entity_id) + + +@callback +@bind_hass +def async_turn_on(hass, entity_id=None): """Turn all or specified vacuum on.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_TURN_ON, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, data)) @bind_hass def turn_off(hass, entity_id=None): + """Turn all or specified vacuum off.""" + hass.add_job(async_turn_off, hass, entity_id) + + +@callback +@bind_hass +def async_turn_off(hass, entity_id=None): """Turn all or specified vacuum off.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, data)) @bind_hass def toggle(hass, entity_id=None): + """Toggle all or specified vacuum.""" + hass.add_job(async_toggle, hass, entity_id) + + +@callback +@bind_hass +def async_toggle(hass, entity_id=None): """Toggle all or specified vacuum.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_TOGGLE, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, data)) @bind_hass def locate(hass, entity_id=None): + """Locate all or specified vacuum.""" + hass.add_job(async_locate, hass, entity_id) + + +@callback +@bind_hass +def async_locate(hass, entity_id=None): """Locate all or specified vacuum.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_LOCATE, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_LOCATE, data)) @bind_hass def clean_spot(hass, entity_id=None): + """Tell all or specified vacuum to perform a spot clean-up.""" + hass.add_job(async_clean_spot, hass, entity_id) + + +@callback +@bind_hass +def async_clean_spot(hass, entity_id=None): """Tell all or specified vacuum to perform a spot clean-up.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_CLEAN_SPOT, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_CLEAN_SPOT, data)) @bind_hass def return_to_base(hass, entity_id=None): + """Tell all or specified vacuum to return to base.""" + hass.add_job(async_return_to_base, hass, entity_id) + + +@callback +@bind_hass +def async_return_to_base(hass, entity_id=None): """Tell all or specified vacuum to return to base.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_RETURN_TO_BASE, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_RETURN_TO_BASE, data)) @bind_hass def start_pause(hass, entity_id=None): + """Tell all or specified vacuum to start or pause the current task.""" + hass.add_job(async_start_pause, hass, entity_id) + + +@callback +@bind_hass +def async_start_pause(hass, entity_id=None): """Tell all or specified vacuum to start or pause the current task.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_START_PAUSE, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_START_PAUSE, data)) @bind_hass def start(hass, entity_id=None): + """Tell all or specified vacuum to start or resume the current task.""" + hass.add_job(async_start, hass, entity_id) + + +@callback +@bind_hass +def async_start(hass, entity_id=None): """Tell all or specified vacuum to start or resume the current task.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_START, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_START, data)) @bind_hass def pause(hass, entity_id=None): + """Tell all or the specified vacuum to pause the current task.""" + hass.add_job(async_pause, hass, entity_id) + + +@callback +@bind_hass +def async_pause(hass, entity_id=None): """Tell all or the specified vacuum to pause the current task.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_PAUSE, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_PAUSE, data)) @bind_hass def stop(hass, entity_id=None): + """Stop all or specified vacuum.""" + hass.add_job(async_stop, hass, entity_id) + + +@callback +@bind_hass +def async_stop(hass, entity_id=None): """Stop all or specified vacuum.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_STOP, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_STOP, data)) @bind_hass def set_fan_speed(hass, fan_speed, entity_id=None): + """Set fan speed for all or specified vacuum.""" + hass.add_job(async_set_fan_speed, hass, fan_speed, entity_id) + + +@callback +@bind_hass +def async_set_fan_speed(hass, fan_speed, entity_id=None): """Set fan speed for all or specified vacuum.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} data[ATTR_FAN_SPEED] = fan_speed - hass.services.call(DOMAIN, SERVICE_SET_FAN_SPEED, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_SET_FAN_SPEED, data)) @bind_hass def send_command(hass, command, params=None, entity_id=None): + """Send command to all or specified vacuum.""" + hass.add_job(async_send_command, hass, command, params, entity_id) + + +@callback +@bind_hass +def async_send_command(hass, command, params=None, entity_id=None): """Send command to all or specified vacuum.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} data[ATTR_COMMAND] = command if params is not None: data[ATTR_PARAMS] = params - hass.services.call(DOMAIN, SERVICE_SEND_COMMAND, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_SEND_COMMAND, data)) diff --git a/tests/components/vacuum/test_mqtt.py b/tests/components/vacuum/test_mqtt.py deleted file mode 100644 index ba2c1866807..00000000000 --- a/tests/components/vacuum/test_mqtt.py +++ /dev/null @@ -1,254 +0,0 @@ -"""The tests for the Demo vacuum platform.""" -import unittest - -from homeassistant.components import vacuum -from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON, ATTR_STATUS, - ATTR_FAN_SPEED, mqtt) -from homeassistant.components.mqtt import CONF_COMMAND_TOPIC -from homeassistant.const import ( - CONF_PLATFORM, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, CONF_NAME) -from homeassistant.setup import setup_component - -from tests.common import ( - fire_mqtt_message, get_test_home_assistant, mock_mqtt_component) -from tests.components.vacuum import common - - -class TestVacuumMQTT(unittest.TestCase): - """MQTT vacuum component test class.""" - - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.mock_publish = mock_mqtt_component(self.hass) - - self.default_config = { - CONF_PLATFORM: 'mqtt', - CONF_NAME: 'mqtttest', - CONF_COMMAND_TOPIC: 'vacuum/command', - mqtt.CONF_SEND_COMMAND_TOPIC: 'vacuum/send_command', - mqtt.CONF_BATTERY_LEVEL_TOPIC: 'vacuum/state', - mqtt.CONF_BATTERY_LEVEL_TEMPLATE: - '{{ value_json.battery_level }}', - mqtt.CONF_CHARGING_TOPIC: 'vacuum/state', - mqtt.CONF_CHARGING_TEMPLATE: '{{ value_json.charging }}', - mqtt.CONF_CLEANING_TOPIC: 'vacuum/state', - mqtt.CONF_CLEANING_TEMPLATE: '{{ value_json.cleaning }}', - mqtt.CONF_DOCKED_TOPIC: 'vacuum/state', - mqtt.CONF_DOCKED_TEMPLATE: '{{ value_json.docked }}', - mqtt.CONF_STATE_TOPIC: 'vacuum/state', - mqtt.CONF_STATE_TEMPLATE: '{{ value_json.state }}', - mqtt.CONF_FAN_SPEED_TOPIC: 'vacuum/state', - mqtt.CONF_FAN_SPEED_TEMPLATE: '{{ value_json.fan_speed }}', - mqtt.CONF_SET_FAN_SPEED_TOPIC: 'vacuum/set_fan_speed', - mqtt.CONF_FAN_SPEED_LIST: ['min', 'medium', 'high', 'max'], - } - - def tearDown(self): # pylint: disable=invalid-name - """Stop down everything that was started.""" - self.hass.stop() - - def test_default_supported_features(self): - """Test that the correct supported features.""" - assert setup_component(self.hass, vacuum.DOMAIN, { - vacuum.DOMAIN: self.default_config, - }) - entity = self.hass.states.get('vacuum.mqtttest') - entity_features = \ - entity.attributes.get(mqtt.CONF_SUPPORTED_FEATURES, 0) - assert sorted(mqtt.services_to_strings(entity_features)) == \ - sorted(['turn_on', 'turn_off', 'stop', - 'return_home', 'battery', 'status', - 'clean_spot']) - - def test_all_commands(self): - """Test simple commands to the vacuum.""" - self.default_config[mqtt.CONF_SUPPORTED_FEATURES] = \ - mqtt.services_to_strings(mqtt.ALL_SERVICES) - - assert setup_component(self.hass, vacuum.DOMAIN, { - vacuum.DOMAIN: self.default_config, - }) - - common.turn_on(self.hass, 'vacuum.mqtttest') - self.hass.block_till_done() - self.mock_publish.async_publish.assert_called_once_with( - 'vacuum/command', 'turn_on', 0, False) - self.mock_publish.async_publish.reset_mock() - - common.turn_off(self.hass, 'vacuum.mqtttest') - self.hass.block_till_done() - self.mock_publish.async_publish.assert_called_once_with( - 'vacuum/command', 'turn_off', 0, False) - self.mock_publish.async_publish.reset_mock() - - common.stop(self.hass, 'vacuum.mqtttest') - self.hass.block_till_done() - self.mock_publish.async_publish.assert_called_once_with( - 'vacuum/command', 'stop', 0, False) - self.mock_publish.async_publish.reset_mock() - - common.clean_spot(self.hass, 'vacuum.mqtttest') - self.hass.block_till_done() - self.mock_publish.async_publish.assert_called_once_with( - 'vacuum/command', 'clean_spot', 0, False) - self.mock_publish.async_publish.reset_mock() - - common.locate(self.hass, 'vacuum.mqtttest') - self.hass.block_till_done() - self.mock_publish.async_publish.assert_called_once_with( - 'vacuum/command', 'locate', 0, False) - self.mock_publish.async_publish.reset_mock() - - common.start_pause(self.hass, 'vacuum.mqtttest') - self.hass.block_till_done() - self.mock_publish.async_publish.assert_called_once_with( - 'vacuum/command', 'start_pause', 0, False) - self.mock_publish.async_publish.reset_mock() - - common.return_to_base(self.hass, 'vacuum.mqtttest') - self.hass.block_till_done() - self.mock_publish.async_publish.assert_called_once_with( - 'vacuum/command', 'return_to_base', 0, False) - self.mock_publish.async_publish.reset_mock() - - common.set_fan_speed(self.hass, 'high', 'vacuum.mqtttest') - self.hass.block_till_done() - self.mock_publish.async_publish.assert_called_once_with( - 'vacuum/set_fan_speed', 'high', 0, False) - self.mock_publish.async_publish.reset_mock() - - common.send_command(self.hass, '44 FE 93', entity_id='vacuum.mqtttest') - self.hass.block_till_done() - self.mock_publish.async_publish.assert_called_once_with( - 'vacuum/send_command', '44 FE 93', 0, False) - - def test_status(self): - """Test status updates from the vacuum.""" - self.default_config[mqtt.CONF_SUPPORTED_FEATURES] = \ - mqtt.services_to_strings(mqtt.ALL_SERVICES) - - assert setup_component(self.hass, vacuum.DOMAIN, { - vacuum.DOMAIN: self.default_config, - }) - - message = """{ - "battery_level": 54, - "cleaning": true, - "docked": false, - "charging": false, - "fan_speed": "max" - }""" - fire_mqtt_message(self.hass, 'vacuum/state', message) - self.hass.block_till_done() - state = self.hass.states.get('vacuum.mqtttest') - assert STATE_ON == state.state - assert 'mdi:battery-50' == \ - state.attributes.get(ATTR_BATTERY_ICON) - assert 54 == state.attributes.get(ATTR_BATTERY_LEVEL) - assert 'max' == state.attributes.get(ATTR_FAN_SPEED) - - message = """{ - "battery_level": 61, - "docked": true, - "cleaning": false, - "charging": true, - "fan_speed": "min" - }""" - - fire_mqtt_message(self.hass, 'vacuum/state', message) - self.hass.block_till_done() - state = self.hass.states.get('vacuum.mqtttest') - assert STATE_OFF == state.state - assert 'mdi:battery-charging-60' == \ - state.attributes.get(ATTR_BATTERY_ICON) - assert 61 == state.attributes.get(ATTR_BATTERY_LEVEL) - assert 'min' == state.attributes.get(ATTR_FAN_SPEED) - - def test_battery_template(self): - """Test that you can use non-default templates for battery_level.""" - self.default_config.update({ - mqtt.CONF_SUPPORTED_FEATURES: - mqtt.services_to_strings(mqtt.ALL_SERVICES), - mqtt.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level", - mqtt.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}" - }) - - assert setup_component(self.hass, vacuum.DOMAIN, { - vacuum.DOMAIN: self.default_config, - }) - - fire_mqtt_message(self.hass, 'retroroomba/battery_level', '54') - self.hass.block_till_done() - state = self.hass.states.get('vacuum.mqtttest') - assert 54 == state.attributes.get(ATTR_BATTERY_LEVEL) - assert state.attributes.get(ATTR_BATTERY_ICON) == \ - 'mdi:battery-50' - - def test_status_invalid_json(self): - """Test to make sure nothing breaks if the vacuum sends bad JSON.""" - self.default_config[mqtt.CONF_SUPPORTED_FEATURES] = \ - mqtt.services_to_strings(mqtt.ALL_SERVICES) - - assert setup_component(self.hass, vacuum.DOMAIN, { - vacuum.DOMAIN: self.default_config, - }) - - fire_mqtt_message(self.hass, 'vacuum/state', '{"asdfasas false}') - self.hass.block_till_done() - state = self.hass.states.get('vacuum.mqtttest') - assert STATE_OFF == state.state - assert "Stopped" == state.attributes.get(ATTR_STATUS) - - def test_default_availability_payload(self): - """Test availability by default payload with defined topic.""" - self.default_config.update({ - 'availability_topic': 'availability-topic' - }) - - assert setup_component(self.hass, vacuum.DOMAIN, { - vacuum.DOMAIN: self.default_config, - }) - - state = self.hass.states.get('vacuum.mqtttest') - assert STATE_UNAVAILABLE == state.state - - fire_mqtt_message(self.hass, 'availability-topic', 'online') - self.hass.block_till_done() - - state = self.hass.states.get('vacuum.mqtttest') - assert STATE_UNAVAILABLE != state.state - - fire_mqtt_message(self.hass, 'availability-topic', 'offline') - self.hass.block_till_done() - - state = self.hass.states.get('vacuum.mqtttest') - assert STATE_UNAVAILABLE == state.state - - def test_custom_availability_payload(self): - """Test availability by custom payload with defined topic.""" - self.default_config.update({ - 'availability_topic': 'availability-topic', - 'payload_available': 'good', - 'payload_not_available': 'nogood' - }) - - assert setup_component(self.hass, vacuum.DOMAIN, { - vacuum.DOMAIN: self.default_config, - }) - - state = self.hass.states.get('vacuum.mqtttest') - assert STATE_UNAVAILABLE == state.state - - fire_mqtt_message(self.hass, 'availability-topic', 'good') - self.hass.block_till_done() - - state = self.hass.states.get('vacuum.mqtttest') - assert STATE_UNAVAILABLE != state.state - - fire_mqtt_message(self.hass, 'availability-topic', 'nogood') - self.hass.block_till_done() - - state = self.hass.states.get('vacuum.mqtttest') - assert STATE_UNAVAILABLE == state.state diff --git a/tests/fixtures/freegeoip.io.json b/tests/fixtures/freegeoip.io.json deleted file mode 100644 index 8afdaba070e..00000000000 --- a/tests/fixtures/freegeoip.io.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "ip": "1.2.3.4", - "country_code": "US", - "country_name": "United States", - "region_code": "CA", - "region_name": "California", - "city": "San Diego", - "zip_code": "92122", - "time_zone": "America\/Los_Angeles", - "latitude": 32.8594, - "longitude": -117.2073, - "metro_code": 825 -} diff --git a/tests/fixtures/ipapi.co.json b/tests/fixtures/ipapi.co.json new file mode 100644 index 00000000000..f1dc58a756b --- /dev/null +++ b/tests/fixtures/ipapi.co.json @@ -0,0 +1,20 @@ +{ + "ip": "1.2.3.4", + "city": "Bern", + "region": "Bern", + "region_code": "BE", + "country": "CH", + "country_name": "Switzerland", + "continent_code": "EU", + "in_eu": false, + "postal": "3000", + "latitude": 46.9480278, + "longitude": 7.4490812, + "timezone": "Europe/Zurich", + "utc_offset": "+0100", + "country_calling_code": "+41", + "currency": "CHF", + "languages": "de-CH,fr-CH,it-CH,rm", + "asn": "AS6830", + "org": "Liberty Global B.V." +} \ No newline at end of file diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 412882f0a01..03dd3cfe55a 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -258,11 +258,12 @@ def test_icon(): """Test icon validation.""" schema = vol.Schema(cv.icon) - for value in (False, 'work', 'icon:work'): + for value in (False, 'work'): with pytest.raises(vol.MultipleInvalid): schema(value) schema('mdi:work') + schema('custom:prefix') def test_time_period(): @@ -331,6 +332,10 @@ def test_service_schema(): 'service': 'homeassistant.turn_on', 'entity_id': 'light.kitchen', }, + { + 'service': 'light.turn_on', + 'entity_id': 'all', + }, { 'service': 'homeassistant.turn_on', 'entity_id': ['light.kitchen', 'light.ceiling'], diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 8f54f0ee5bc..27e33a4fe7d 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -479,7 +479,7 @@ async def test_extract_all_use_match_all(hass, caplog): MockEntity(name='test_2'), ]) - call = ha.ServiceCall('test', 'service', {'entity_id': '*'}) + call = ha.ServiceCall('test', 'service', {'entity_id': 'all'}) assert ['test_domain.test_1', 'test_domain.test_2'] == \ sorted(ent.entity_id for ent in diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index a8c9086b2d2..ef7b4a60ee2 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest +from homeassistant.core import valid_entity_id from homeassistant.helpers import entity_registry from tests.common import mock_registry, flush_store @@ -222,3 +223,36 @@ async def test_migration(hass): assert entry.name == 'Test Name' assert entry.disabled_by == 'hass' assert entry.config_entry_id == 'test-config-id' + + +async def test_loading_invalid_entity_id(hass, hass_storage): + """Test we autofix invalid entity IDs.""" + hass_storage[entity_registry.STORAGE_KEY] = { + 'version': entity_registry.STORAGE_VERSION, + 'data': { + 'entities': [ + { + 'entity_id': 'test.invalid__middle', + 'platform': 'super_platform', + 'unique_id': 'id-invalid-middle', + 'name': 'registry override', + }, { + 'entity_id': 'test.invalid_end_', + 'platform': 'super_platform', + 'unique_id': 'id-invalid-end', + } + ] + } + } + + registry = await entity_registry.async_get_registry(hass) + + entity_invalid_middle = registry.async_get_or_create( + 'test', 'super_platform', 'id-invalid-middle') + + assert valid_entity_id(entity_invalid_middle.entity_id) + + entity_invalid_end = registry.async_get_or_create( + 'test', 'super_platform', 'id-invalid-end') + + assert valid_entity_id(entity_invalid_end.entity_id) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 02331c400d3..3febd4037ad 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -4,6 +4,7 @@ from datetime import datetime import unittest import random import math +import pytz from unittest.mock import patch from homeassistant.components import group @@ -422,6 +423,16 @@ class TestHelpersTemplate(unittest.TestCase): assert '' == \ tpl.render_with_possible_json_value('{"hello": "world"}', '') + def test_render_with_possible_json_value_non_string_value(self): + """Render with possible JSON value with non-string value.""" + tpl = template.Template(""" +{{ strptime(value~'+0000', '%Y-%m-%d %H:%M:%S%z') }} + """, self.hass) + value = datetime(2019, 1, 18, 12, 13, 14) + expected = str(pytz.utc.localize(value)) + assert expected == \ + tpl.render_with_possible_json_value(value) + def test_raise_exception_on_error(self): """Test raising an exception on error.""" with pytest.raises(TemplateError): diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 99c6f7dddf1..9d62f204dcd 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -40,7 +40,10 @@ def test_flatten(): async def test_component_translation_file(hass): """Test the component translation file function.""" assert await async_setup_component(hass, 'switch', { - 'switch': {'platform': 'test'} + 'switch': [ + {'platform': 'test'}, + {'platform': 'test_embedded'} + ] }) assert await async_setup_component(hass, 'test_standalone', { 'test_standalone' @@ -53,6 +56,11 @@ async def test_component_translation_file(hass): hass, 'switch.test', 'en')) == path.normpath(hass.config.path( 'custom_components', 'switch', '.translations', 'test.en.json')) + assert path.normpath(translation.component_translation_file( + hass, 'switch.test_embedded', 'en')) == path.normpath(hass.config.path( + 'custom_components', 'test_embedded', '.translations', + 'switch.en.json')) + assert path.normpath(translation.component_translation_file( hass, 'test_standalone', 'en')) == path.normpath(hass.config.path( 'custom_components', '.translations', 'test_standalone.en.json')) diff --git a/tests/test_core.py b/tests/test_core.py index 5ee9f5cdb05..f1900979bec 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,6 +1,7 @@ """Test to verify that Home Assistant core works.""" # pylint: disable=protected-access import asyncio +import functools import logging import os import unittest @@ -45,11 +46,24 @@ def test_async_add_job_schedule_callback(): assert len(hass.add_job.mock_calls) == 0 -@patch('asyncio.iscoroutinefunction', return_value=True) -def test_async_add_job_schedule_coroutinefunction(mock_iscoro): - """Test that we schedule coroutines and add jobs to the job pool.""" +def test_async_add_job_schedule_partial_callback(): + """Test that we schedule partial coros and add jobs to the job pool.""" hass = MagicMock() job = MagicMock() + partial = functools.partial(ha.callback(job)) + + ha.HomeAssistant.async_add_job(hass, partial) + assert len(hass.loop.call_soon.mock_calls) == 1 + assert len(hass.loop.create_task.mock_calls) == 0 + assert len(hass.add_job.mock_calls) == 0 + + +def test_async_add_job_schedule_coroutinefunction(): + """Test that we schedule coroutines and add jobs to the job pool.""" + hass = MagicMock() + + async def job(): + pass ha.HomeAssistant.async_add_job(hass, job) assert len(hass.loop.call_soon.mock_calls) == 0 @@ -57,11 +71,26 @@ def test_async_add_job_schedule_coroutinefunction(mock_iscoro): assert len(hass.add_job.mock_calls) == 0 -@patch('asyncio.iscoroutinefunction', return_value=False) -def test_async_add_job_add_threaded_job_to_pool(mock_iscoro): +def test_async_add_job_schedule_partial_coroutinefunction(): + """Test that we schedule partial coros and add jobs to the job pool.""" + hass = MagicMock() + + async def job(): + pass + partial = functools.partial(job) + + ha.HomeAssistant.async_add_job(hass, partial) + assert len(hass.loop.call_soon.mock_calls) == 0 + assert len(hass.loop.create_task.mock_calls) == 1 + assert len(hass.add_job.mock_calls) == 0 + + +def test_async_add_job_add_threaded_job_to_pool(): """Test that we schedule coroutines and add jobs to the job pool.""" hass = MagicMock() - job = MagicMock() + + def job(): + pass ha.HomeAssistant.async_add_job(hass, job) assert len(hass.loop.call_soon.mock_calls) == 0 @@ -69,13 +98,14 @@ def test_async_add_job_add_threaded_job_to_pool(mock_iscoro): assert len(hass.loop.run_in_executor.mock_calls) == 1 -@patch('asyncio.iscoroutine', return_value=True) -def test_async_create_task_schedule_coroutine(mock_iscoro): +def test_async_create_task_schedule_coroutine(): """Test that we schedule coroutines and add jobs to the job pool.""" hass = MagicMock() - job = MagicMock() - ha.HomeAssistant.async_create_task(hass, job) + async def job(): + pass + + ha.HomeAssistant.async_create_task(hass, job()) assert len(hass.loop.call_soon.mock_calls) == 0 assert len(hass.loop.create_task.mock_calls) == 1 assert len(hass.add_job.mock_calls) == 0 diff --git a/tests/test_loader.py b/tests/test_loader.py index 90d259c860e..5bd273ea16a 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -35,7 +35,6 @@ class TestLoader(unittest.TestCase): def test_get_component(self): """Test if get_component works.""" assert http == loader.get_component(self.hass, 'http') - assert loader.get_component(self.hass, 'light.hue') is not None def test_load_order_component(self): """Test if we can get the proper load order of components.""" diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index f5bf0b8a4f8..8b3b057bfc0 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -182,6 +182,11 @@ class AiohttpClientMockResponse: """Return yarl of URL.""" return self._url + @property + def content_type(self): + """Return yarl of URL.""" + return self._headers.get('content-type') + @property def content(self): """Return content.""" diff --git a/tests/testing_config/custom_components/test_embedded/__init__.py b/tests/testing_config/custom_components/test_embedded/__init__.py new file mode 100644 index 00000000000..2206054356e --- /dev/null +++ b/tests/testing_config/custom_components/test_embedded/__init__.py @@ -0,0 +1,6 @@ +"""Component with embedded platforms.""" + + +async def async_setup(hass, config): + """Mock config.""" + return True diff --git a/tests/testing_config/custom_components/test_embedded/switch.py b/tests/testing_config/custom_components/test_embedded/switch.py new file mode 100644 index 00000000000..e4e0f5fcd39 --- /dev/null +++ b/tests/testing_config/custom_components/test_embedded/switch.py @@ -0,0 +1,7 @@ +"""Switch platform for the embedded component.""" + + +async def async_setup_platform(hass, config, async_add_entities_callback, + discovery_info=None): + """Find and return test switches.""" + pass diff --git a/tests/util/test_color.py b/tests/util/test_color.py index b7802d3dc09..b54b2bc5776 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -5,6 +5,10 @@ import homeassistant.util.color as color_util import pytest import voluptuous as vol +GAMUT = color_util.GamutType(color_util.XYPoint(0.704, 0.296), + color_util.XYPoint(0.2151, 0.7106), + color_util.XYPoint(0.138, 0.08)) + class TestColorUtil(unittest.TestCase): """Test color util methods.""" @@ -29,6 +33,15 @@ class TestColorUtil(unittest.TestCase): assert (0.701, 0.299, 16) == \ color_util.color_RGB_to_xy_brightness(128, 0, 0) + assert (0.7, 0.299, 72) == \ + color_util.color_RGB_to_xy_brightness(255, 0, 0, GAMUT) + + assert (0.215, 0.711, 170) == \ + color_util.color_RGB_to_xy_brightness(0, 255, 0, GAMUT) + + assert (0.138, 0.08, 12) == \ + color_util.color_RGB_to_xy_brightness(0, 0, 255, GAMUT) + def test_color_RGB_to_xy(self): """Test color_RGB_to_xy.""" assert (0, 0) == \ @@ -48,6 +61,15 @@ class TestColorUtil(unittest.TestCase): assert (0.701, 0.299) == \ color_util.color_RGB_to_xy(128, 0, 0) + assert (0.138, 0.08) == \ + color_util.color_RGB_to_xy(0, 0, 255, GAMUT) + + assert (0.215, 0.711) == \ + color_util.color_RGB_to_xy(0, 255, 0, GAMUT) + + assert (0.7, 0.299) == \ + color_util.color_RGB_to_xy(255, 0, 0, GAMUT) + def test_color_xy_brightness_to_RGB(self): """Test color_xy_brightness_to_RGB.""" assert (0, 0, 0) == \ @@ -68,6 +90,15 @@ class TestColorUtil(unittest.TestCase): assert (0, 63, 255) == \ color_util.color_xy_brightness_to_RGB(0, 0, 255) + assert (255, 0, 3) == \ + color_util.color_xy_brightness_to_RGB(1, 0, 255, GAMUT) + + assert (82, 255, 0) == \ + color_util.color_xy_brightness_to_RGB(0, 1, 255, GAMUT) + + assert (9, 85, 255) == \ + color_util.color_xy_brightness_to_RGB(0, 0, 255, GAMUT) + def test_color_xy_to_RGB(self): """Test color_xy_to_RGB.""" assert (255, 243, 222) == \ @@ -82,6 +113,15 @@ class TestColorUtil(unittest.TestCase): assert (0, 63, 255) == \ color_util.color_xy_to_RGB(0, 0) + assert (255, 0, 3) == \ + color_util.color_xy_to_RGB(1, 0, GAMUT) + + assert (82, 255, 0) == \ + color_util.color_xy_to_RGB(0, 1, GAMUT) + + assert (9, 85, 255) == \ + color_util.color_xy_to_RGB(0, 0, GAMUT) + def test_color_RGB_to_hsv(self): """Test color_RGB_to_hsv.""" assert (0, 0, 0) == \ @@ -150,6 +190,15 @@ class TestColorUtil(unittest.TestCase): assert (225.176, 100) == \ color_util.color_xy_to_hs(0, 0) + assert (359.294, 100) == \ + color_util.color_xy_to_hs(1, 0, GAMUT) + + assert (100.706, 100) == \ + color_util.color_xy_to_hs(0, 1, GAMUT) + + assert (221.463, 96.471) == \ + color_util.color_xy_to_hs(0, 0, GAMUT) + def test_color_hs_to_xy(self): """Test color_hs_to_xy.""" assert (0.151, 0.343) == \ @@ -167,6 +216,21 @@ class TestColorUtil(unittest.TestCase): assert (0.323, 0.329) == \ color_util.color_hs_to_xy(360, 0) + assert (0.7, 0.299) == \ + color_util.color_hs_to_xy(0, 100, GAMUT) + + assert (0.215, 0.711) == \ + color_util.color_hs_to_xy(120, 100, GAMUT) + + assert (0.17, 0.34) == \ + color_util.color_hs_to_xy(180, 100, GAMUT) + + assert (0.138, 0.08) == \ + color_util.color_hs_to_xy(240, 100, GAMUT) + + assert (0.7, 0.299) == \ + color_util.color_hs_to_xy(360, 100, GAMUT) + def test_rgb_hex_to_rgb_list(self): """Test rgb_hex_to_rgb_list.""" assert [255, 255, 255] == \ diff --git a/tests/util/test_location.py b/tests/util/test_location.py index d83979affd0..7d0df7edb18 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -30,62 +30,59 @@ class TestLocationUtil(TestCase): def test_get_distance_to_same_place(self): """Test getting the distance.""" - meters = location_util.distance(COORDINATES_PARIS[0], - COORDINATES_PARIS[1], - COORDINATES_PARIS[0], - COORDINATES_PARIS[1]) + meters = location_util.distance( + COORDINATES_PARIS[0], COORDINATES_PARIS[1], + COORDINATES_PARIS[0], COORDINATES_PARIS[1]) assert meters == 0 def test_get_distance(self): """Test getting the distance.""" - meters = location_util.distance(COORDINATES_PARIS[0], - COORDINATES_PARIS[1], - COORDINATES_NEW_YORK[0], - COORDINATES_NEW_YORK[1]) + meters = location_util.distance( + COORDINATES_PARIS[0], COORDINATES_PARIS[1], + COORDINATES_NEW_YORK[0], COORDINATES_NEW_YORK[1]) assert meters/1000 - DISTANCE_KM < 0.01 def test_get_kilometers(self): """Test getting the distance between given coordinates in km.""" - kilometers = location_util.vincenty(COORDINATES_PARIS, - COORDINATES_NEW_YORK) + kilometers = location_util.vincenty( + COORDINATES_PARIS, COORDINATES_NEW_YORK) assert round(kilometers, 2) == DISTANCE_KM def test_get_miles(self): """Test getting the distance between given coordinates in miles.""" - miles = location_util.vincenty(COORDINATES_PARIS, - COORDINATES_NEW_YORK, - miles=True) + miles = location_util.vincenty( + COORDINATES_PARIS, COORDINATES_NEW_YORK, miles=True) assert round(miles, 2) == DISTANCE_MILES @requests_mock.Mocker() - def test_detect_location_info_freegeoip(self, m): - """Test detect location info using freegeoip.""" - m.get(location_util.FREEGEO_API, - text=load_fixture('freegeoip.io.json')) + def test_detect_location_info_ipapi(self, m): + """Test detect location info using ipapi.co.""" + m.get( + location_util.IPAPI, text=load_fixture('ipapi.co.json')) info = location_util.detect_location_info(_test_real=True) assert info is not None assert info.ip == '1.2.3.4' - assert info.country_code == 'US' - assert info.country_name == 'United States' - assert info.region_code == 'CA' - assert info.region_name == 'California' - assert info.city == 'San Diego' - assert info.zip_code == '92122' - assert info.time_zone == 'America/Los_Angeles' - assert info.latitude == 32.8594 - assert info.longitude == -117.2073 - assert not info.use_metric + assert info.country_code == 'CH' + assert info.country_name == 'Switzerland' + assert info.region_code == 'BE' + assert info.region_name == 'Bern' + assert info.city == 'Bern' + assert info.zip_code == '3000' + assert info.time_zone == 'Europe/Zurich' + assert info.latitude == 46.9480278 + assert info.longitude == 7.4490812 + assert info.use_metric @requests_mock.Mocker() - @patch('homeassistant.util.location._get_freegeoip', return_value=None) - def test_detect_location_info_ipapi(self, mock_req, mock_freegeoip): - """Test detect location info using freegeoip.""" - mock_req.get(location_util.IP_API, - text=load_fixture('ip-api.com.json')) + @patch('homeassistant.util.location._get_ipapi', return_value=None) + def test_detect_location_info_ip_api(self, mock_req, mock_ipapi): + """Test detect location info using ip-api.com.""" + mock_req.get( + location_util.IP_API, text=load_fixture('ip-api.com.json')) info = location_util.detect_location_info(_test_real=True) @@ -103,11 +100,10 @@ class TestLocationUtil(TestCase): assert not info.use_metric @patch('homeassistant.util.location.elevation', return_value=0) - @patch('homeassistant.util.location._get_freegeoip', return_value=None) + @patch('homeassistant.util.location._get_ipapi', return_value=None) @patch('homeassistant.util.location._get_ip_api', return_value=None) - def test_detect_location_info_both_queries_fail(self, mock_ipapi, - mock_freegeoip, - mock_elevation): + def test_detect_location_info_both_queries_fail( + self, mock_ipapi, mock_ip_api, mock_elevation): """Ensure we return None if both queries fail.""" info = location_util.detect_location_info(_test_real=True) assert info is None @@ -115,8 +111,8 @@ class TestLocationUtil(TestCase): @patch('homeassistant.util.location.requests.get', side_effect=requests.RequestException) def test_freegeoip_query_raises(self, mock_get): - """Test freegeoip query when the request to API fails.""" - info = location_util._get_freegeoip() + """Test ipapi.co query when the request to API fails.""" + info = location_util._get_ipapi() assert info is None @patch('homeassistant.util.location.requests.get', diff --git a/tests/util/test_ruamel_yaml.py b/tests/util/test_ruamel_yaml.py index 61006c98642..4ac8b85ae98 100644 --- a/tests/util/test_ruamel_yaml.py +++ b/tests/util/test_ruamel_yaml.py @@ -135,7 +135,7 @@ class TestYAML(unittest.TestCase): def test_save_and_load(self): """Test saving and loading back.""" fname = self._path_for("test1") - open(fname, "w+") + open(fname, "w+").close() util_yaml.save_yaml(fname, self.yaml.load(TEST_YAML_A)) data = util_yaml.load_yaml(fname, True) assert data == self.yaml.load(TEST_YAML_A) @@ -143,7 +143,7 @@ class TestYAML(unittest.TestCase): def test_overwrite_and_reload(self): """Test that we can overwrite an existing file and read back.""" fname = self._path_for("test2") - open(fname, "w+") + open(fname, "w+").close() util_yaml.save_yaml(fname, self.yaml.load(TEST_YAML_A)) util_yaml.save_yaml(fname, self.yaml.load(TEST_YAML_B)) data = util_yaml.load_yaml(fname, True) diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 3046fe51ba3..41f447dff12 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -17,7 +17,6 @@ LABEL maintainer="Paulus Schoutsen " VOLUME /config -RUN mkdir -p /usr/src/app WORKDIR /usr/src/app # Copy build scripts