diff --git a/.coveragerc b/.coveragerc index 04299609bbb..25aa405035b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -209,7 +209,6 @@ omit = homeassistant/components/lutron_caseta.py homeassistant/components/*/lutron_caseta.py - homeassistant/components/mailgun.py homeassistant/components/*/mailgun.py homeassistant/components/matrix.py @@ -458,7 +457,6 @@ omit = homeassistant/components/cover/myq.py homeassistant/components/cover/opengarage.py homeassistant/components/cover/rpi_gpio.py - homeassistant/components/cover/ryobi_gdo.py homeassistant/components/cover/scsgate.py homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py @@ -758,6 +756,7 @@ omit = homeassistant/components/sensor/radarr.py homeassistant/components/sensor/rainbird.py homeassistant/components/sensor/ripple.py + homeassistant/components/sensor/rtorrent.py homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sense.py homeassistant/components/sensor/sensehat.py diff --git a/CODEOWNERS b/CODEOWNERS index c49af4864a9..2bf31378ac3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -58,6 +58,7 @@ homeassistant/components/climate/mill.py @danielhiversen homeassistant/components/climate/sensibo.py @andrey-git homeassistant/components/cover/group.py @cdce8p homeassistant/components/cover/template.py @PhracturedBlue +homeassistant/components/device_tracker/asuswrt.py @kennedyshead homeassistant/components/device_tracker/automatic.py @armills homeassistant/components/device_tracker/huawei_router.py @abmantis homeassistant/components/device_tracker/quantum_gateway.py @cisasteelersfan diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index 015e1e0d1fc..f7d9f012f65 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -16,7 +16,7 @@ from homeassistant.const import ( from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.dispatcher import dispatcher_send -REQUIREMENTS = ['pyarlo==0.2.0'] +REQUIREMENTS = ['pyarlo==0.2.2'] _LOGGER = logging.getLogger(__name__) @@ -81,7 +81,7 @@ def setup(hass, config): def hub_refresh(event_time): """Call ArloHub to refresh information.""" - _LOGGER.info("Updating Arlo Hub component") + _LOGGER.debug("Updating Arlo Hub component") hass.data[DATA_ARLO].update(update_cameras=True, update_base_station=True) dispatcher_send(hass, SIGNAL_UPDATE_ARLO) diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py index 850d972c373..ce8e3d8de11 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -225,8 +225,17 @@ class AugustData: for doorbell in self._doorbells: _LOGGER.debug("Updating status for %s", doorbell.device_name) - detail_by_id[doorbell.device_id] = self._api.get_doorbell_detail( - self._access_token, doorbell.device_id) + try: + detail_by_id[doorbell.device_id] =\ + self._api.get_doorbell_detail( + self._access_token, doorbell.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve doorbell" + " status for %s. %s", doorbell.device_name, ex) + detail_by_id[doorbell.device_id] = None + except Exception: + detail_by_id[doorbell.device_id] = None + raise _LOGGER.debug("Completed retrieving doorbell details") self._doorbell_detail_by_id = detail_by_id @@ -260,8 +269,17 @@ class AugustData: for lock in self._locks: _LOGGER.debug("Updating status for %s", lock.device_name) - state_by_id[lock.device_id] = self._api.get_lock_door_status( - self._access_token, lock.device_id) + + try: + state_by_id[lock.device_id] = self._api.get_lock_door_status( + self._access_token, lock.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve door" + " status for %s. %s", lock.device_name, ex) + state_by_id[lock.device_id] = None + except Exception: + state_by_id[lock.device_id] = None + raise _LOGGER.debug("Completed retrieving door status") self._door_state_by_id = state_by_id @@ -275,10 +293,27 @@ class AugustData: for lock in self._locks: _LOGGER.debug("Updating status for %s", lock.device_name) - status_by_id[lock.device_id] = self._api.get_lock_status( - self._access_token, lock.device_id) - detail_by_id[lock.device_id] = self._api.get_lock_detail( - self._access_token, lock.device_id) + try: + status_by_id[lock.device_id] = self._api.get_lock_status( + self._access_token, lock.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve door" + " status for %s. %s", lock.device_name, ex) + status_by_id[lock.device_id] = None + except Exception: + status_by_id[lock.device_id] = None + raise + + try: + detail_by_id[lock.device_id] = self._api.get_lock_detail( + self._access_token, lock.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve door" + " details for %s. %s", lock.device_name, ex) + detail_by_id[lock.device_id] = None + except Exception: + detail_by_id[lock.device_id] = None + raise _LOGGER.debug("Completed retrieving locks status") self._lock_status_by_id = status_by_id diff --git a/homeassistant/components/auth/.translations/ro.json b/homeassistant/components/auth/.translations/ro.json new file mode 100644 index 00000000000..19f9ec10c73 --- /dev/null +++ b/homeassistant/components/auth/.translations/ro.json @@ -0,0 +1,34 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nu sunt disponibile servicii de notificare." + }, + "error": { + "invalid_code": "Cod invalid, va rugam incercati din nou." + }, + "step": { + "init": { + "description": "Selecta\u021bi unul dintre serviciile de notificare:", + "title": "Configura\u021bi o parol\u0103 unic\u0103 livrat\u0103 de o component\u0103 de notificare" + }, + "setup": { + "description": "O parol\u0103 unic\u0103 a fost trimis\u0103 prin **notify.{notify_service}**. Introduce\u021bi parola mai jos:", + "title": "Verifica\u021bi configurarea" + } + }, + "title": "Notifica\u021bi o parol\u0103 unic\u0103" + }, + "totp": { + "error": { + "invalid_code": "Cod invalid, va rugam incercati din nou. Dac\u0103 primi\u021bi aceast\u0103 eroare \u00een mod consecvent, asigura\u021bi-v\u0103 c\u0103 ceasul sistemului dvs. Home Assistant este corect." + }, + "step": { + "init": { + "title": "Configura\u021bi autentificarea cu doi factori utiliz\u00e2nd TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py new file mode 100644 index 00000000000..b2c9a9c093a --- /dev/null +++ b/homeassistant/components/automation/geo_location.py @@ -0,0 +1,74 @@ +""" +Offer geo location 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 +""" +import voluptuous as vol + +from homeassistant.components.geo_location import DOMAIN +from homeassistant.core import callback +from homeassistant.const import ( + CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE, EVENT_STATE_CHANGED) +from homeassistant.helpers import ( + condition, config_validation as cv) +from homeassistant.helpers.config_validation import entity_domain + +EVENT_ENTER = 'enter' +EVENT_LEAVE = 'leave' +DEFAULT_EVENT = EVENT_ENTER + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'geo_location', + vol.Required(CONF_SOURCE): cv.string, + vol.Required(CONF_ZONE): entity_domain('zone'), + vol.Required(CONF_EVENT, default=DEFAULT_EVENT): + vol.Any(EVENT_ENTER, EVENT_LEAVE), +}) + + +def source_match(state, source): + """Check if the state matches the provided source.""" + return state and state.attributes.get('source') == source + + +async def async_trigger(hass, config, action): + """Listen for state changes based on configuration.""" + source = config.get(CONF_SOURCE).lower() + zone_entity_id = config.get(CONF_ZONE) + trigger_event = config.get(CONF_EVENT) + + @callback + def state_change_listener(event): + """Handle specific state changes.""" + # Skip if the event is not a geo_location entity. + if not event.data.get('entity_id').startswith(DOMAIN): + return + # Skip if the event's source does not match the trigger's source. + from_state = event.data.get('old_state') + to_state = event.data.get('new_state') + if not source_match(from_state, source) \ + and not source_match(to_state, source): + return + + zone_state = hass.states.get(zone_entity_id) + from_match = condition.zone(hass, zone_state, from_state) + to_match = condition.zone(hass, zone_state, to_state) + + # pylint: disable=too-many-boolean-expressions + if trigger_event == EVENT_ENTER and not from_match and to_match or \ + trigger_event == EVENT_LEAVE and from_match and not to_match: + hass.async_run_job(action({ + 'trigger': { + 'platform': 'geo_location', + 'source': source, + 'entity_id': event.data.get('entity_id'), + 'from_state': from_state, + 'to_state': to_state, + 'zone': zone_state, + 'event': trigger_event, + }, + }, context=event.context)) + + return hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener) diff --git a/homeassistant/components/binary_sensor/august.py b/homeassistant/components/binary_sensor/august.py index 55b31a6da5f..4116a791b01 100644 --- a/homeassistant/components/binary_sensor/august.py +++ b/homeassistant/components/binary_sensor/august.py @@ -19,14 +19,15 @@ SCAN_INTERVAL = timedelta(seconds=5) def _retrieve_door_state(data, lock): """Get the latest state of the DoorSense sensor.""" - from august.lock import LockDoorStatus - doorstate = data.get_door_state(lock.device_id) - return doorstate == LockDoorStatus.OPEN + return data.get_door_state(lock.device_id) def _retrieve_online_state(data, doorbell): """Get the latest state of the sensor.""" detail = data.get_doorbell_detail(doorbell.device_id) + if detail is None: + return None + return detail.is_online @@ -138,9 +139,10 @@ class AugustDoorBinarySensor(BinarySensorDevice): """Get the latest state of the sensor.""" state_provider = SENSOR_TYPES_DOOR[self._sensor_type][2] self._state = state_provider(self._data, self._door) + self._available = self._state is not None from august.lock import LockDoorStatus - self._available = self._state != LockDoorStatus.UNKNOWN + self._state = self._state == LockDoorStatus.OPEN class AugustDoorbellBinarySensor(BinarySensorDevice): @@ -152,6 +154,12 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): self._sensor_type = sensor_type self._doorbell = doorbell self._state = None + self._available = False + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available @property def is_on(self): @@ -173,3 +181,4 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): """Get the latest state of the sensor.""" state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][2] self._state = state_provider(self._data, self._doorbell) + self._available = self._state is not None diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index beaeb9ce21b..db9ad585999 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -131,7 +131,14 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, await MqttDiscoveryUpdate.async_added_to_hass(self) @callback - def state_message_received(topic, payload, qos): + def off_delay_listener(now): + """Switch device off after a delay.""" + self._delay_listener = None + self._state = False + self.async_schedule_update_ha_state() + + @callback + def state_message_received(_topic, payload, _qos): """Handle a new received MQTT state message.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( @@ -146,17 +153,10 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, self._name, self._state_topic) return + if self._delay_listener is not None: + self._delay_listener() + if (self._state and self._off_delay is not None): - @callback - def off_delay_listener(now): - """Switch device off after a delay.""" - self._delay_listener = None - self._state = False - self.async_schedule_update_ha_state() - - if self._delay_listener is not None: - self._delay_listener() - self._delay_listener = evt.async_call_later( self.hass, self._off_delay, off_delay_listener) diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index 82b5e66629a..fc8207f83b7 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -14,15 +14,16 @@ 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.7'] +REQUIREMENTS = ['holidays==0.9.8'] _LOGGER = logging.getLogger(__name__) # List of all countries currently supported by holidays # There seems to be no way to get the list out at runtime ALL_COUNTRIES = [ - 'Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT', 'Belarus', 'BY' - 'Belgium', 'BE', 'Canada', 'CA', 'Colombia', 'CO', 'Czech', 'CZ', + 'Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT', + 'Brazil', 'BR', 'Belarus', 'BY', 'Belgium', 'BE', + 'Canada', 'CA', 'Colombia', 'CO', 'Croatia', 'HR', 'Czech', 'CZ', 'Denmark', 'DK', 'England', 'EuropeanCentralBank', 'ECB', 'TAR', 'Finland', 'FI', 'France', 'FRA', 'Germany', 'DE', 'Hungary', 'HU', 'India', 'IND', 'Ireland', 'Isle of Man', 'Italy', 'IT', 'Japan', 'JP', @@ -30,7 +31,7 @@ ALL_COUNTRIES = [ 'Northern Ireland', 'Norway', 'NO', 'Polish', 'PL', 'Portugal', 'PT', 'PortugalExt', 'PTE', 'Scotland', 'Slovenia', 'SI', 'Slovakia', 'SK', 'South Africa', 'ZA', 'Spain', 'ES', 'Sweden', 'SE', 'Switzerland', 'CH', - 'UnitedKingdom', 'UK', 'UnitedStates', 'US', 'Wales', + 'Ukraine', 'UA', 'UnitedKingdom', 'UK', 'UnitedStates', 'US', 'Wales', ] ALLOWED_DAYS = WEEKDAYS + ['holiday'] diff --git a/homeassistant/components/cast/.translations/ro.json b/homeassistant/components/cast/.translations/ro.json new file mode 100644 index 00000000000..8a1d19c0ecf --- /dev/null +++ b/homeassistant/components/cast/.translations/ro.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nu s-au g\u0103sit dispozitive Google Cast \u00een re\u021bea.", + "single_instance_allowed": "Este necesar\u0103 o singur\u0103 configura\u021bie a serviciului Google Cast." + }, + "step": { + "confirm": { + "description": "Dori\u021bi s\u0103 configura\u021bi Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/dyson.py b/homeassistant/components/climate/dyson.py new file mode 100644 index 00000000000..0b09ec7f0b4 --- /dev/null +++ b/homeassistant/components/climate/dyson.py @@ -0,0 +1,176 @@ +""" +Support for Dyson Pure Hot+Cool link fan. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.dyson/ +""" +import logging + +from homeassistant.components.dyson import DYSON_DEVICES +from homeassistant.components.climate import ( + ClimateDevice, STATE_HEAT, STATE_COOL, STATE_IDLE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE) +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE + +_LOGGER = logging.getLogger(__name__) + +STATE_DIFFUSE = "Diffuse Mode" +STATE_FOCUS = "Focus Mode" +FAN_LIST = [STATE_FOCUS, STATE_DIFFUSE] +OPERATION_LIST = [STATE_HEAT, STATE_COOL] + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + | SUPPORT_OPERATION_MODE) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Dyson fan components.""" + if discovery_info is None: + return + + from libpurecoollink.dyson_pure_hotcool_link import DysonPureHotCoolLink + # Get Dyson Devices from parent component. + add_devices( + [DysonPureHotCoolLinkDevice(device) + for device in hass.data[DYSON_DEVICES] + if isinstance(device, DysonPureHotCoolLink)] + ) + + +class DysonPureHotCoolLinkDevice(ClimateDevice): + """Representation of a Dyson climate fan.""" + + def __init__(self, device): + """Initialize the fan.""" + self._device = device + self._current_temp = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_job(self._device.add_message_listener, + self.on_message) + + def on_message(self, message): + """Call when new messages received from the climate.""" + from libpurecoollink.dyson_pure_state import DysonPureHotCoolState + + if isinstance(message, DysonPureHotCoolState): + _LOGGER.debug("Message received for climate device %s : %s", + self.name, message) + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the display name of this climate.""" + return self._device.name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._device.environmental_state: + temperature_kelvin = self._device.environmental_state.temperature + if temperature_kelvin != 0: + self._current_temp = float("{0:.1f}".format( + temperature_kelvin - 273)) + return self._current_temp + + @property + def target_temperature(self): + """Return the target temperature.""" + heat_target = int(self._device.state.heat_target) / 10 + return int(heat_target - 273) + + @property + def current_humidity(self): + """Return the current humidity.""" + if self._device.environmental_state: + if self._device.environmental_state.humidity == 0: + return None + return self._device.environmental_state.humidity + return None + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + from libpurecoollink.const import HeatMode, HeatState + if self._device.state.heat_mode == HeatMode.HEAT_ON.value: + if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value: + return STATE_HEAT + return STATE_IDLE + return STATE_COOL + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return OPERATION_LIST + + @property + def current_fan_mode(self): + """Return the fan setting.""" + from libpurecoollink.const import FocusMode + if self._device.state.focus_mode == FocusMode.FOCUS_ON.value: + return STATE_FOCUS + return STATE_DIFFUSE + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return FAN_LIST + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is None: + return + target_temp = int(target_temp) + _LOGGER.debug("Set %s temperature %s", self.name, target_temp) + # Limit the target temperature into acceptable range. + target_temp = min(self.max_temp, target_temp) + target_temp = max(self.min_temp, target_temp) + from libpurecoollink.const import HeatTarget, HeatMode + self._device.set_configuration( + heat_target=HeatTarget.celsius(target_temp), + heat_mode=HeatMode.HEAT_ON) + + def set_fan_mode(self, fan_mode): + """Set new fan mode.""" + _LOGGER.debug("Set %s focus mode %s", self.name, fan_mode) + from libpurecoollink.const import FocusMode + if fan_mode == STATE_FOCUS: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_ON) + elif fan_mode == STATE_DIFFUSE: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_OFF) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + _LOGGER.debug("Set %s heat mode %s", self.name, operation_mode) + from libpurecoollink.const import HeatMode + if operation_mode == STATE_HEAT: + self._device.set_configuration(heat_mode=HeatMode.HEAT_ON) + elif operation_mode == STATE_COOL: + self._device.set_configuration(heat_mode=HeatMode.HEAT_OFF) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return 1 + + @property + def max_temp(self): + """Return the maximum temperature.""" + return 37 diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 258699ff90a..ad8875462fd 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -380,6 +380,8 @@ class GenericThermostat(ClimateDevice): async def async_turn_away_mode_on(self): """Turn away mode on by setting it on away hold indefinitely.""" + if self._is_away: + return self._is_away = True self._saved_target_temp = self._target_temp self._target_temp = self._away_temp @@ -388,6 +390,8 @@ class GenericThermostat(ClimateDevice): async def async_turn_away_mode_off(self): """Turn away off.""" + if not self._is_away: + return self._is_away = False self._target_temp = self._saved_target_temp await self._async_control_heating() diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index 763e239689b..11ad83bdbcc 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.1.2'] +REQUIREMENTS = ['millheater==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -43,6 +43,7 @@ async def async_setup_platform(hass, config, async_add_entities, _LOGGER.error("Failed to connect to Mill") return + await mill_data_connection.update_rooms() await mill_data_connection.update_heaters() dev = [] diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 54a221565b4..3bfc5909b0b 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -162,7 +162,7 @@ class Cloud: @property def subscription_expired(self): """Return a boolean if the subscription has expired.""" - return dt_util.utcnow() > self.expiration_date + timedelta(days=3) + return dt_util.utcnow() > self.expiration_date + timedelta(days=7) @property def expiration_date(self): diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index dcf7567482a..042b90bf9cb 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -113,6 +113,24 @@ def check_token(cloud): raise _map_aws_exception(err) +def renew_access_token(cloud): + """Renew access token.""" + from botocore.exceptions import ClientError + + cognito = _cognito( + cloud, + access_token=cloud.access_token, + refresh_token=cloud.refresh_token) + + try: + cognito.renew_access_token() + cloud.id_token = cognito.id_token + cloud.access_token = cognito.access_token + cloud.write_user_info() + except ClientError as err: + raise _map_aws_exception(err) + + def _authenticate(cloud, email, password): """Log in and return an authenticated Cognito instance.""" from botocore.exceptions import ClientError diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 720ca00cf52..0df4a39406e 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -14,7 +14,7 @@ from homeassistant.components import websocket_api from . import auth_api from .const import DOMAIN, REQUEST_TIMEOUT -from .iot import STATE_DISCONNECTED +from .iot import STATE_DISCONNECTED, STATE_CONNECTED _LOGGER = logging.getLogger(__name__) @@ -249,13 +249,28 @@ async def websocket_subscription(hass, connection, msg): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): response = await cloud.fetch_subscription_info() - if response.status == 200: - connection.send_message(websocket_api.result_message( - msg['id'], await response.json())) - else: + if response.status != 200: connection.send_message(websocket_api.error_message( msg['id'], 'request_failed', 'Failed to request subscription')) + data = await response.json() + + # Check if a user is subscribed but local info is outdated + # In that case, let's refresh and reconnect + if data.get('provider') and cloud.iot.state != STATE_CONNECTED: + _LOGGER.debug( + "Found disconnected account with valid subscriotion, connecting") + await hass.async_add_executor_job( + auth_api.renew_access_token, cloud) + + # Cancel reconnect in progress + if cloud.iot.state != STATE_DISCONNECTED: + await cloud.iot.disconnect() + + hass.async_create_task(cloud.iot.connect()) + + connection.send_message(websocket_api.result_message(msg['id'], data)) + @websocket_api.async_response async def websocket_update_prefs(hass, connection, msg): diff --git a/homeassistant/components/cover/ryobi_gdo.py b/homeassistant/components/cover/ryobi_gdo.py deleted file mode 100644 index fec91f843fd..00000000000 --- a/homeassistant/components/cover/ryobi_gdo.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Ryobi platform for the cover component. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/cover.ryobi_gdo/ -""" -import logging -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv - -from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE) -from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, STATE_UNKNOWN, STATE_CLOSED) - -REQUIREMENTS = ['py_ryobi_gdo==0.0.10'] - -_LOGGER = logging.getLogger(__name__) - -CONF_DEVICE_ID = 'device_id' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, -}) - -SUPPORTED_FEATURES = (SUPPORT_OPEN | SUPPORT_CLOSE) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Ryobi covers.""" - from py_ryobi_gdo import RyobiGDO as ryobi_door - covers = [] - - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - devices = config.get(CONF_DEVICE_ID) - - for device_id in devices: - my_door = ryobi_door(username, password, device_id) - _LOGGER.debug("Getting the API key") - if my_door.get_api_key() is False: - _LOGGER.error("Wrong credentials, no API key retrieved") - return - _LOGGER.debug("Checking if the device ID is present") - if my_door.check_device_id() is False: - _LOGGER.error("%s not in your device list", device_id) - return - _LOGGER.debug("Adding device %s to covers", device_id) - covers.append(RyobiCover(hass, my_door)) - if covers: - _LOGGER.debug("Adding covers") - add_entities(covers, True) - - -class RyobiCover(CoverDevice): - """Representation of a ryobi cover.""" - - def __init__(self, hass, ryobi_door): - """Initialize the cover.""" - self.ryobi_door = ryobi_door - self._name = 'ryobi_gdo_{}'.format(ryobi_door.get_device_id()) - self._door_state = None - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def is_closed(self): - """Return if the cover is closed.""" - if self._door_state == STATE_UNKNOWN: - return False - return self._door_state == STATE_CLOSED - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return 'garage' - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORTED_FEATURES - - def close_cover(self, **kwargs): - """Close the cover.""" - _LOGGER.debug("Closing garage door") - self.ryobi_door.close_device() - - def open_cover(self, **kwargs): - """Open the cover.""" - _LOGGER.debug("Opening garage door") - self.ryobi_door.open_device() - - def update(self): - """Update status from the door.""" - _LOGGER.debug("Updating RyobiGDO status") - self.ryobi_door.update() - self._door_state = self.ryobi_door.get_door_status() diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 710a07f77d3..2ac3aaee933 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -5,10 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.asuswrt/ """ import logging -import re -import socket -import telnetlib -from collections import namedtuple import voluptuous as vol @@ -19,7 +15,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, CONF_PROTOCOL) -REQUIREMENTS = ['pexpect==4.6.0'] +REQUIREMENTS = ['aioasuswrt==1.0.0'] _LOGGER = logging.getLogger(__name__) @@ -44,345 +40,53 @@ PLATFORM_SCHEMA = vol.All( })) -_LEASES_CMD = 'cat /var/lib/misc/dnsmasq.leases' -_LEASES_REGEX = re.compile( - r'\w+\s' + - r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' + - r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + - r'(?P([^\s]+))') - -# Command to get both 5GHz and 2.4GHz clients -_WL_CMD = 'for dev in `nvram get wl_ifnames`; do wl -i $dev assoclist; done' -_WL_REGEX = re.compile( - r'\w+\s' + - r'(?P(([0-9A-F]{2}[:-]){5}([0-9A-F]{2})))') - -_IP_NEIGH_CMD = 'ip neigh' -_IP_NEIGH_REGEX = re.compile( - r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3}|' - r'([0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){1,7})\s' - r'\w+\s' - r'\w+\s' - r'(\w+\s(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' - r'\s?(router)?' - r'\s?(nud)?' - r'(?P(\w+))') - -_ARP_CMD = 'arp -n' -_ARP_REGEX = re.compile( - r'.+\s' + - r'\((?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\)\s' + - r'.+\s' + - r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))' + - r'\s' + - r'.*') - - -def get_scanner(hass, config): +async def async_get_scanner(hass, config): """Validate the configuration and return an ASUS-WRT scanner.""" scanner = AsusWrtDeviceScanner(config[DOMAIN]) - + await scanner.async_connect() return scanner if scanner.success_init else None -def _parse_lines(lines, regex): - """Parse the lines using the given regular expression. - - If a line can't be parsed it is logged and skipped in the output. - """ - results = [] - for line in lines: - match = regex.search(line) - if not match: - _LOGGER.debug("Could not parse row: %s", line) - continue - results.append(match.groupdict()) - return results - - -Device = namedtuple('Device', ['mac', 'ip', 'name']) - - class AsusWrtDeviceScanner(DeviceScanner): """This class queries a router running ASUSWRT firmware.""" # Eighth attribute needed for mode (AP mode vs router mode) def __init__(self, config): """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config.get(CONF_PASSWORD, '') - self.ssh_key = config.get('ssh_key', config.get('pub_key', '')) - self.protocol = config[CONF_PROTOCOL] - self.mode = config[CONF_MODE] - self.port = config[CONF_PORT] - self.require_ip = config[CONF_REQUIRE_IP] - - if self.protocol == 'ssh': - self.connection = SshConnection( - self.host, self.port, self.username, self.password, - self.ssh_key) - else: - self.connection = TelnetConnection( - self.host, self.port, self.username, self.password) + from aioasuswrt.asuswrt import AsusWrt self.last_results = {} + self.success_init = False + self.connection = AsusWrt(config[CONF_HOST], config[CONF_PORT], + config[CONF_PROTOCOL] == 'telnet', + config[CONF_USERNAME], + config.get(CONF_PASSWORD, ''), + config.get('ssh_key', + config.get('pub_key', '')), + config[CONF_MODE], config[CONF_REQUIRE_IP]) + async def async_connect(self): + """Initialize connection to the router.""" # Test the router is accessible. - data = self.get_asuswrt_data() + data = await self.connection.async_get_connected_devices() self.success_init = data is not None - def scan_devices(self): + async def async_scan_devices(self): """Scan for new devices and return a list with found device IDs.""" - self._update_info() + await self.async_update_info() return list(self.last_results.keys()) - def get_device_name(self, device): + async def async_get_device_name(self, device): """Return the name of the given device or None if we don't know.""" if device not in self.last_results: return None return self.last_results[device].name - def _update_info(self): + async def async_update_info(self): """Ensure the information from the ASUSWRT router is up to date. Return boolean if scanning successful. """ - if not self.success_init: - return False - _LOGGER.info('Checking Devices') - data = self.get_asuswrt_data() - if not data: - return False - self.last_results = data - return True - - def get_asuswrt_data(self): - """Retrieve data from ASUSWRT. - - Calls various commands on the router and returns the superset of all - responses. Some commands will not work on some routers. - """ - devices = {} - devices.update(self._get_wl()) - devices.update(self._get_arp()) - devices.update(self._get_neigh(devices)) - if not self.mode == 'ap': - devices.update(self._get_leases(devices)) - - ret_devices = {} - for key in devices: - if not self.require_ip or devices[key].ip is not None: - ret_devices[key] = devices[key] - return ret_devices - - def _get_wl(self): - lines = self.connection.run_command(_WL_CMD) - if not lines: - return {} - result = _parse_lines(lines, _WL_REGEX) - devices = {} - for device in result: - mac = device['mac'].upper() - devices[mac] = Device(mac, None, None) - return devices - - def _get_leases(self, cur_devices): - lines = self.connection.run_command(_LEASES_CMD) - if not lines: - return {} - lines = [line for line in lines if not line.startswith('duid ')] - result = _parse_lines(lines, _LEASES_REGEX) - devices = {} - for device in result: - # For leases where the client doesn't set a hostname, ensure it - # is blank and not '*', which breaks entity_id down the line. - host = device['host'] - if host == '*': - host = '' - mac = device['mac'].upper() - if mac in cur_devices: - devices[mac] = Device(mac, device['ip'], host) - return devices - - def _get_neigh(self, cur_devices): - lines = self.connection.run_command(_IP_NEIGH_CMD) - if not lines: - return {} - result = _parse_lines(lines, _IP_NEIGH_REGEX) - devices = {} - for device in result: - status = device['status'] - if status is None or status.upper() != 'REACHABLE': - continue - if device['mac'] is not None: - mac = device['mac'].upper() - old_device = cur_devices.get(mac) - old_ip = old_device.ip if old_device else None - devices[mac] = Device(mac, device.get('ip', old_ip), None) - return devices - - def _get_arp(self): - lines = self.connection.run_command(_ARP_CMD) - if not lines: - return {} - result = _parse_lines(lines, _ARP_REGEX) - devices = {} - for device in result: - if device['mac'] is not None: - mac = device['mac'].upper() - devices[mac] = Device(mac, device['ip'], None) - return devices - - -class _Connection: - def __init__(self): - self._connected = False - - @property - def connected(self): - """Return connection state.""" - return self._connected - - def connect(self): - """Mark current connection state as connected.""" - self._connected = True - - def disconnect(self): - """Mark current connection state as disconnected.""" - self._connected = False - - -class SshConnection(_Connection): - """Maintains an SSH connection to an ASUS-WRT router.""" - - def __init__(self, host, port, username, password, ssh_key): - """Initialize the SSH connection properties.""" - super().__init__() - - self._ssh = None - self._host = host - self._port = port - self._username = username - self._password = password - self._ssh_key = ssh_key - - def run_command(self, command): - """Run commands through an SSH connection. - - Connect to the SSH server if not currently connected, otherwise - use the existing connection. - """ - from pexpect import pxssh, exceptions - - try: - if not self.connected: - self.connect() - self._ssh.sendline(command) - self._ssh.prompt() - lines = self._ssh.before.split(b'\n')[1:-1] - return [line.decode('utf-8') for line in lines] - except exceptions.EOF as err: - _LOGGER.error("Connection refused. %s", self._ssh.before) - self.disconnect() - return None - except pxssh.ExceptionPxssh as err: - _LOGGER.error("Unexpected SSH error: %s", err) - self.disconnect() - return None - except AssertionError as err: - _LOGGER.error("Connection to router unavailable: %s", err) - self.disconnect() - return None - - def connect(self): - """Connect to the ASUS-WRT SSH server.""" - from pexpect import pxssh - - self._ssh = pxssh.pxssh() - if self._ssh_key: - self._ssh.login(self._host, self._username, quiet=False, - ssh_key=self._ssh_key, port=self._port) - else: - self._ssh.login(self._host, self._username, quiet=False, - password=self._password, port=self._port) - - super().connect() - - def disconnect(self): - """Disconnect the current SSH connection.""" - try: - self._ssh.logout() - except Exception: # pylint: disable=broad-except - pass - finally: - self._ssh = None - - super().disconnect() - - -class TelnetConnection(_Connection): - """Maintains a Telnet connection to an ASUS-WRT router.""" - - def __init__(self, host, port, username, password): - """Initialize the Telnet connection properties.""" - super().__init__() - - self._telnet = None - self._host = host - self._port = port - self._username = username - self._password = password - self._prompt_string = None - - def run_command(self, command): - """Run a command through a Telnet connection. - - Connect to the Telnet server if not currently connected, otherwise - use the existing connection. - """ - try: - if not self.connected: - self.connect() - self._telnet.write('{}\n'.format(command).encode('ascii')) - data = (self._telnet.read_until(self._prompt_string). - split(b'\n')[1:-1]) - return [line.decode('utf-8') for line in data] - except EOFError: - _LOGGER.error("Unexpected response from router") - self.disconnect() - return None - except ConnectionRefusedError: - _LOGGER.error("Connection refused by router. Telnet enabled?") - self.disconnect() - return None - except socket.gaierror as exc: - _LOGGER.error("Socket exception: %s", exc) - self.disconnect() - return None - except OSError as exc: - _LOGGER.error("OSError: %s", exc) - self.disconnect() - return None - - def connect(self): - """Connect to the ASUS-WRT Telnet server.""" - self._telnet = telnetlib.Telnet(self._host) - self._telnet.read_until(b'login: ') - self._telnet.write((self._username + '\n').encode('ascii')) - self._telnet.read_until(b'Password: ') - self._telnet.write((self._password + '\n').encode('ascii')) - self._prompt_string = self._telnet.read_until(b'#').split(b'\n')[-1] - - super().connect() - - def disconnect(self): - """Disconnect the current Telnet connection.""" - try: - self._telnet.write('exit\n'.encode('ascii')) - except Exception: # pylint: disable=broad-except - pass - - super().disconnect() + self.last_results = await self.connection.async_get_connected_devices() diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index 47b86ab9ab2..a07fdfdcf81 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -44,7 +44,10 @@ def setup_scanner(hass, config, see, discovery_info=None): new_devices[address] = 1 return - see(mac=BLE_PREFIX + address, host_name=name.strip("\x00"), + if name is not None: + name = name.strip("\x00") + + see(mac=BLE_PREFIX + address, host_name=name, source_type=SOURCE_TYPE_BLUETOOTH_LE) def discover_ble_devices(): diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index 2e1b96dffad..12d026a35cd 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL, CONF_DEVICES, CONF_EXCLUDE) -REQUIREMENTS = ['pynetgear==0.4.2'] +REQUIREMENTS = ['pynetgear==0.5.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/dyson.py b/homeassistant/components/dyson.py index 3989c0bbe3e..791f990d9ad 100644 --- a/homeassistant/components/dyson.py +++ b/homeassistant/components/dyson.py @@ -102,5 +102,6 @@ def setup(hass, config): discovery.load_platform(hass, "sensor", DOMAIN, {}, config) discovery.load_platform(hass, "fan", DOMAIN, {}, config) discovery.load_platform(hass, "vacuum", DOMAIN, {}, config) + discovery.load_platform(hass, "climate", DOMAIN, {}, config) return True diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index df25803b4e0..55aa0700bef 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==20181018.0'] +REQUIREMENTS = ['home-assistant-frontend==20181023.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 485b98e8e22..d8ab231c96b 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -14,7 +14,8 @@ CONF_ROOM_HINT = 'room' DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ - 'switch', 'light', 'group', 'media_player', 'fan', 'cover', 'climate' + 'climate', 'cover', 'fan', 'group', 'input_boolean', 'light', + 'media_player', 'scene', 'script', 'switch' ] CLIMATE_MODE_HEATCOOL = 'heatcool' CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_HEATCOOL} diff --git a/homeassistant/components/hangouts/.translations/hu.json b/homeassistant/components/hangouts/.translations/hu.json index 2631843c784..f6e46e25985 100644 --- a/homeassistant/components/hangouts/.translations/hu.json +++ b/homeassistant/components/hangouts/.translations/hu.json @@ -14,6 +14,7 @@ "data": { "2fa": "2FA Pin" }, + "description": "\u00dcres", "title": "K\u00e9tfaktoros Hiteles\u00edt\u00e9s" }, "user": { @@ -21,6 +22,7 @@ "email": "E-Mail C\u00edm", "password": "Jelsz\u00f3" }, + "description": "\u00dcres", "title": "Google Hangouts Bejelentkez\u00e9s" } }, diff --git a/homeassistant/components/hangouts/.translations/ro.json b/homeassistant/components/hangouts/.translations/ro.json new file mode 100644 index 00000000000..d1c3ed767ce --- /dev/null +++ b/homeassistant/components/hangouts/.translations/ro.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts este deja configurat", + "unknown": "Sa produs o eroare necunoscut\u0103." + }, + "error": { + "invalid_2fa_method": "Metoda 2FA invalid\u0103 (Verifica\u021bi pe telefon).", + "invalid_login": "Conectare invalid\u0103, \u00eencerca\u021bi din nou." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + } + }, + "user": { + "data": { + "email": "Adresa de email", + "password": "Parol\u0103" + }, + "description": "Gol", + "title": "Conectare Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ro.json b/homeassistant/components/homematicip_cloud/.translations/ro.json new file mode 100644 index 00000000000..a5399e7e68c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ro.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Punctul de acces este deja configurat", + "unknown": "Sa produs o eroare necunoscut\u0103." + }, + "error": { + "invalid_pin": "Cod PIN invalid, \u00eencerca\u021bi din nou.", + "press_the_button": "V\u0103 rug\u0103m s\u0103 ap\u0103sa\u021bi butonul albastru." + }, + "step": { + "init": { + "data": { + "pin": "Cod PIN (op\u021bional)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/ro.json b/homeassistant/components/hue/.translations/ro.json index 69cee1198d3..a2ecf8964b6 100644 --- a/homeassistant/components/hue/.translations/ro.json +++ b/homeassistant/components/hue/.translations/ro.json @@ -2,6 +2,8 @@ "config": { "abort": { "all_configured": "Toate pun\u021bile Philips Hue sunt deja configurate", + "already_configured": "Gateway-ul este deja configurat", + "cannot_connect": "Nu se poate conecta la gateway.", "discover_timeout": "Imposibil de descoperit podurile Hue" }, "error": { diff --git a/homeassistant/components/ifttt/.translations/es.json b/homeassistant/components/ifttt/.translations/es.json new file mode 100644 index 00000000000..13240ccefb1 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/es.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "description": "\u00bfEst\u00e1s seguro de que quieres configurar IFTTT?", + "title": "Configurar el applet de webhook IFTTT" + } + }, + "title": "IFTTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/.translations/hu.json b/homeassistant/components/ifttt/.translations/hu.json index a131f848d45..6ecf654ff47 100644 --- a/homeassistant/components/ifttt/.translations/hu.json +++ b/homeassistant/components/ifttt/.translations/hu.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "not_internet_accessible": "A Home Assistant-nek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l az IFTTT \u00fczenetek fogad\u00e1s\u00e1hoz." + }, "step": { "user": { "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az IFTTT-t?", diff --git a/homeassistant/components/ifttt/.translations/pt.json b/homeassistant/components/ifttt/.translations/pt.json new file mode 100644 index 00000000000..34c6496d7b1 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/pt.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "A sua inst\u00e2ncia Home Assistent precisa de ser acess\u00edvel a partir da internet para receber mensagens IFTTT.", + "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "Para enviar eventos para o Home Assistente, precisa de utilizar a a\u00e7\u00e3o \"Make a web request\" no [IFTTT Webhook applet]({applet_url}).\n\nPreencha com a seguinte informa\u00e7\u00e3o:\n\n- URL: `{webhook_url}`\n- Method: POST \n- Content Type: application/json \n\nConsulte [a documenta\u00e7\u00e3o]({docs_url}) sobre como configurar automa\u00e7\u00f5es para lidar com dados de entrada." + }, + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o IFTTT?", + "title": "Configurar o IFTTT Webhook Applet" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/.translations/ro.json b/homeassistant/components/ifttt/.translations/ro.json index 03c77426671..dd7ae5f72cb 100644 --- a/homeassistant/components/ifttt/.translations/ro.json +++ b/homeassistant/components/ifttt/.translations/ro.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "not_internet_accessible": "Instan\u021ba Home Assistant trebuie s\u0103 fie accesibil\u0103 de pe internet pentru a primi mesaje IFTTT.", + "one_instance_allowed": "Este necesar\u0103 o singur\u0103 instan\u021b\u0103." + }, "step": { "user": { "description": "Sigur dori\u021bi s\u0103 configura\u021bi IFTTT?" diff --git a/homeassistant/components/ifttt/.translations/tr.json b/homeassistant/components/ifttt/.translations/tr.json new file mode 100644 index 00000000000..80188b637f9 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/tr.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "IFTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 76f01ad0aca..85ee6b9fa1c 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -4,18 +4,15 @@ Support to trigger Maker IFTTT recipes. For more details about this component, please refer to the documentation at https://home-assistant.io/components/ifttt/ """ -from ipaddress import ip_address import json import logging -from urllib.parse import urlparse import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant import config_entries from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.util.network import is_local +from homeassistant.helpers import config_entry_flow REQUIREMENTS = ['pyfttt==0.3'] DEPENDENCIES = ['webhook'] @@ -100,43 +97,11 @@ async def async_unload_entry(hass, entry): hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) return True - -@config_entries.HANDLERS.register(DOMAIN) -class ConfigFlow(config_entries.ConfigFlow): - """Handle an IFTTT config flow.""" - - async def async_step_user(self, user_input=None): - """Handle a user initiated set up flow.""" - if self._async_current_entries(): - return self.async_abort(reason='one_instance_allowed') - - try: - url_parts = urlparse(self.hass.config.api.base_url) - - if is_local(ip_address(url_parts.hostname)): - return self.async_abort(reason='not_internet_accessible') - except ValueError: - # If it's not an IP address, it's very likely publicly accessible - pass - - if user_input is None: - return self.async_show_form( - step_id='user', - ) - - webhook_id = self.hass.components.webhook.async_generate_id() - webhook_url = \ - self.hass.components.webhook.async_generate_url(webhook_id) - - return self.async_create_entry( - title='IFTTT Webhook', - data={ - CONF_WEBHOOK_ID: webhook_id - }, - description_placeholders={ - 'applet_url': 'https://ifttt.com/maker_webhooks', - 'webhook_url': webhook_url, - 'docs_url': - 'https://www.home-assistant.io/components/ifttt/' - } - ) +config_entry_flow.register_webhook_flow( + DOMAIN, + 'IFTTT Webhook', + { + 'applet_url': 'https://ifttt.com/maker_webhooks', + 'docs_url': 'https://www.home-assistant.io/components/ifttt/' + } +) diff --git a/homeassistant/components/ios/.translations/ro.json b/homeassistant/components/ios/.translations/ro.json new file mode 100644 index 00000000000..5a83b5cd732 --- /dev/null +++ b/homeassistant/components/ios/.translations/ro.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Este necesar\u0103 numai o singur\u0103 configurare a aplica\u021biei Home Assistant iOS." + }, + "step": { + "confirm": { + "description": "Dori\u021bi s\u0103 configura\u021bi componenta Home Assistant iOS?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/lifx/.translations/es.json b/homeassistant/components/lifx/.translations/es.json new file mode 100644 index 00000000000..f897c673432 --- /dev/null +++ b/homeassistant/components/lifx/.translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos LIFX en la red.", + "single_instance_allowed": "S\u00f3lo es posible una \u00fanica configuraci\u00f3n de LIFX." + }, + "step": { + "confirm": { + "description": "\u00bfQuieres configurar LIFX?", + "title": "LIFX" + } + }, + "title": "LIFX" + } +} \ No newline at end of file diff --git a/homeassistant/components/lifx/.translations/pt.json b/homeassistant/components/lifx/.translations/pt.json new file mode 100644 index 00000000000..5d7fdf356ef --- /dev/null +++ b/homeassistant/components/lifx/.translations/pt.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo LIFX encontrado na rede." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index f389d34cd5d..cab6957c265 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -REQUIREMENTS = ['flux_led==0.21'] +REQUIREMENTS = ['flux_led==0.22'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index a5aeabba84d..2e2971cfdc2 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -20,7 +20,7 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin, color_hs_to_RGB) from homeassistant.helpers.restore_state import async_get_last_state -REQUIREMENTS = ['limitlessled==1.1.2'] +REQUIREMENTS = ['limitlessled==1.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/rflink.py b/homeassistant/components/light/rflink.py index 885239a51c3..3b60280c582 100644 --- a/homeassistant/components/light/rflink.py +++ b/homeassistant/components/light/rflink.py @@ -6,16 +6,18 @@ https://home-assistant.io/components/light.rflink/ """ import logging +import voluptuous as vol + from homeassistant.components.light import ( ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light) from homeassistant.components.rflink import ( CONF_ALIASES, CONF_ALIASSES, CONF_AUTOMATIC_ADD, CONF_DEVICE_DEFAULTS, CONF_DEVICES, CONF_FIRE_EVENT, CONF_GROUP, CONF_GROUP_ALIASES, - CONF_GROUP_ALIASSES, CONF_IGNORE_DEVICES, CONF_NOGROUP_ALIASES, - CONF_NOGROUP_ALIASSES, CONF_SIGNAL_REPETITIONS, DATA_DEVICE_REGISTER, - DEVICE_DEFAULTS_SCHEMA, - EVENT_KEY_COMMAND, EVENT_KEY_ID, SwitchableRflinkDevice, cv, - remove_deprecated, vol) + CONF_GROUP_ALIASSES, CONF_NOGROUP_ALIASES, CONF_NOGROUP_ALIASSES, + CONF_SIGNAL_REPETITIONS, DATA_DEVICE_REGISTER, DEVICE_DEFAULTS_SCHEMA, + EVENT_KEY_COMMAND, EVENT_KEY_ID, SwitchableRflinkDevice, + remove_deprecated) +import homeassistant.helpers.config_validation as cv from homeassistant.const import (CONF_NAME, CONF_TYPE) DEPENDENCIES = ['rflink'] @@ -28,7 +30,6 @@ TYPE_HYBRID = 'hybrid' TYPE_TOGGLE = 'toggle' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_IGNORE_DEVICES): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})): DEVICE_DEFAULTS_SCHEMA, vol.Optional(CONF_AUTOMATIC_ADD, default=True): cv.boolean, diff --git a/homeassistant/components/lock/august.py b/homeassistant/components/lock/august.py index e8949255ee9..ce6792ceb39 100644 --- a/homeassistant/components/lock/august.py +++ b/homeassistant/components/lock/august.py @@ -40,6 +40,7 @@ class AugustLock(LockDevice): self._lock_status = None self._lock_detail = None self._changed_by = None + self._available = False def lock(self, **kwargs): """Lock the device.""" @@ -52,6 +53,8 @@ class AugustLock(LockDevice): def update(self): """Get the latest state of the sensor.""" self._lock_status = self._data.get_lock_status(self._lock.device_id) + self._available = self._lock_status is not None + self._lock_detail = self._data.get_lock_detail(self._lock.device_id) from august.activity import ActivityType @@ -67,6 +70,11 @@ class AugustLock(LockDevice): """Return the name of this device.""" return self._lock.device_name + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + @property def is_locked(self): """Return true if device is on.""" diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index e3f4522580b..2c28b52ec6e 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -2,9 +2,10 @@ import logging import uuid import os -from os import O_WRONLY, O_CREAT, O_TRUNC +from os import O_CREAT, O_TRUNC, O_WRONLY from collections import OrderedDict -from typing import Union, List, Dict +from typing import Dict, List, Union + import voluptuous as vol from homeassistant.components import websocket_api @@ -14,21 +15,45 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'lovelace' REQUIREMENTS = ['ruamel.yaml==0.15.72'] +LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' +JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name + OLD_WS_TYPE_GET_LOVELACE_UI = 'frontend/lovelace_config' WS_TYPE_GET_LOVELACE_UI = 'lovelace/config' +WS_TYPE_GET_CARD = 'lovelace/config/card/get' +WS_TYPE_SET_CARD = 'lovelace/config/card/set' SCHEMA_GET_LOVELACE_UI = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): vol.Any(WS_TYPE_GET_LOVELACE_UI, OLD_WS_TYPE_GET_LOVELACE_UI), }) -JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name +SCHEMA_GET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_GET_CARD, + vol.Required('card_id'): str, + vol.Optional('format', default='yaml'): str, +}) + +SCHEMA_SET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SET_CARD, + vol.Required('card_id'): str, + vol.Required('card_config'): vol.Any(str, Dict), + vol.Optional('format', default='yaml'): str, +}) class WriteError(HomeAssistantError): """Error writing the data.""" +class CardNotFoundError(HomeAssistantError): + """Card not found in data.""" + + +class UnsupportedYamlError(HomeAssistantError): + """Unsupported YAML.""" + + def save_yaml(fname: str, data: JSON_TYPE): """Save a YAML file.""" from ruamel.yaml import YAML @@ -45,7 +70,7 @@ def save_yaml(fname: str, data: JSON_TYPE): _LOGGER.error(str(exc)) raise HomeAssistantError(exc) except OSError as exc: - _LOGGER.exception('Saving YAML file failed: %s', fname) + _LOGGER.exception('Saving YAML file %s failed: %s', fname, exc) raise WriteError(exc) finally: if os.path.exists(tmp_fname): @@ -57,18 +82,29 @@ def save_yaml(fname: str, data: JSON_TYPE): _LOGGER.error("YAML replacement cleanup failed: %s", exc) +def _yaml_unsupported(loader, node): + raise UnsupportedYamlError( + 'Unsupported YAML, you can not use {} in ui-lovelace.yaml' + .format(node.tag)) + + def load_yaml(fname: str) -> JSON_TYPE: """Load a YAML file.""" from ruamel.yaml import YAML + from ruamel.yaml.constructor import RoundTripConstructor from ruamel.yaml.error import YAMLError + + RoundTripConstructor.add_constructor(None, _yaml_unsupported) + yaml = YAML(typ='rt') + try: with open(fname, encoding='utf-8') as conf_file: # If configuration file is empty YAML returns None # We convert that to an empty dict return yaml.load(conf_file) or OrderedDict() except YAMLError as exc: - _LOGGER.error("YAML error: %s", exc) + _LOGGER.error("YAML error in %s: %s", fname, exc) raise HomeAssistantError(exc) except UnicodeDecodeError as exc: _LOGGER.error("Unable to read file %s: %s", fname, exc) @@ -76,21 +112,86 @@ def load_yaml(fname: str) -> JSON_TYPE: def load_config(fname: str) -> JSON_TYPE: - """Load a YAML file and adds id to card if not present.""" + """Load a YAML file and adds id to views and cards if not present.""" config = load_yaml(fname) - # Check if all cards have an ID or else add one + # Check if all views and cards have an id or else add one updated = False + index = 0 for view in config.get('views', []): + if 'id' not in view: + updated = True + view.insert(0, 'id', index, + comment="Automatically created id") for card in view.get('cards', []): if 'id' not in card: updated = True - card['id'] = uuid.uuid4().hex - card.move_to_end('id', last=False) + card.insert(0, 'id', uuid.uuid4().hex, + comment="Automatically created id") + index += 1 if updated: save_yaml(fname, config) return config +def object_to_yaml(data: JSON_TYPE) -> str: + """Create yaml string from object.""" + from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError + from ruamel.yaml.compat import StringIO + yaml = YAML(typ='rt') + yaml.indent(sequence=4, offset=2) + stream = StringIO() + try: + yaml.dump(data, stream) + return stream.getvalue() + except YAMLError as exc: + _LOGGER.error("YAML error: %s", exc) + raise HomeAssistantError(exc) + + +def yaml_to_object(data: str) -> JSON_TYPE: + """Create object from yaml string.""" + from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError + yaml = YAML(typ='rt') + try: + return yaml.load(data) + except YAMLError as exc: + _LOGGER.error("YAML error: %s", exc) + raise HomeAssistantError(exc) + + +def get_card(fname: str, card_id: str, data_format: str) -> JSON_TYPE: + """Load a specific card config for id.""" + config = load_yaml(fname) + for view in config.get('views', []): + for card in view.get('cards', []): + if card.get('id') == card_id: + if data_format == 'yaml': + return object_to_yaml(card) + return card + + raise CardNotFoundError( + "Card with ID: {} was not found in {}.".format(card_id, fname)) + + +def set_card(fname: str, card_id: str, card_config: str, data_format: str)\ + -> bool: + """Save a specific card config for id.""" + config = load_yaml(fname) + for view in config.get('views', []): + for card in view.get('cards', []): + if card.get('id') == card_id: + if data_format == 'yaml': + card_config = yaml_to_object(card_config) + card.update(card_config) + save_yaml(fname, config) + return True + + raise CardNotFoundError( + "Card with ID: {} was not found in {}.".format(card_id, fname)) + + async def async_setup(hass, config): """Set up the Lovelace commands.""" # Backwards compat. Added in 0.80. Remove after 0.85 @@ -102,6 +203,14 @@ async def async_setup(hass, config): WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, SCHEMA_GET_LOVELACE_UI) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_CARD, websocket_lovelace_get_card, + SCHEMA_GET_CARD) + + hass.components.websocket_api.async_register_command( + WS_TYPE_SET_CARD, websocket_lovelace_set_card, + SCHEMA_SET_CARD) + return True @@ -111,13 +220,15 @@ async def websocket_lovelace_config(hass, connection, msg): error = None try: config = await hass.async_add_executor_job( - load_config, hass.config.path('ui-lovelace.yaml')) + load_config, hass.config.path(LOVELACE_CONFIG_FILE)) message = websocket_api.result_message( msg['id'], config ) except FileNotFoundError: error = ('file_not_found', 'Could not find ui-lovelace.yaml in your config dir.') + except UnsupportedYamlError as err: + error = 'unsupported_error', str(err) except HomeAssistantError as err: error = 'load_error', str(err) @@ -125,3 +236,59 @@ async def websocket_lovelace_config(hass, connection, msg): message = websocket_api.error_message(msg['id'], *error) connection.send_message(message) + + +@websocket_api.async_response +async def websocket_lovelace_get_card(hass, connection, msg): + """Send lovelace card config over websocket config.""" + error = None + try: + card = await hass.async_add_executor_job( + get_card, hass.config.path(LOVELACE_CONFIG_FILE), msg['card_id'], + msg.get('format', 'yaml')) + message = websocket_api.result_message( + msg['id'], card + ) + except FileNotFoundError: + error = ('file_not_found', + 'Could not find ui-lovelace.yaml in your config dir.') + except UnsupportedYamlError as err: + error = 'unsupported_error', str(err) + except CardNotFoundError: + error = ('card_not_found', + 'Could not find card in ui-lovelace.yaml.') + except HomeAssistantError as err: + error = 'load_error', str(err) + + if error is not None: + message = websocket_api.error_message(msg['id'], *error) + + connection.send_message(message) + + +@websocket_api.async_response +async def websocket_lovelace_set_card(hass, connection, msg): + """Receive lovelace card config over websocket and save.""" + error = None + try: + result = await hass.async_add_executor_job( + set_card, hass.config.path(LOVELACE_CONFIG_FILE), + msg['card_id'], msg['card_config'], msg.get('format', 'yaml')) + message = websocket_api.result_message( + msg['id'], result + ) + except FileNotFoundError: + error = ('file_not_found', + 'Could not find ui-lovelace.yaml in your config dir.') + except UnsupportedYamlError as err: + error = 'unsupported_error', str(err) + except CardNotFoundError: + error = ('card_not_found', + 'Could not find card in ui-lovelace.yaml.') + except HomeAssistantError as err: + error = 'save_error', str(err) + + if error is not None: + message = websocket_api.error_message(msg['id'], *error) + + connection.send_message(message) diff --git a/homeassistant/components/mailgun.py b/homeassistant/components/mailgun.py deleted file mode 100644 index 7cb7ef7151d..00000000000 --- a/homeassistant/components/mailgun.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Support for Mailgun. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/mailgun/ -""" -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_API_KEY, CONF_DOMAIN -from homeassistant.core import callback -from homeassistant.components.http import HomeAssistantView - - -DOMAIN = 'mailgun' -API_PATH = '/api/{}'.format(DOMAIN) -DATA_MAILGUN = DOMAIN -DEPENDENCIES = ['http'] -MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN) -CONF_SANDBOX = 'sandbox' -DEFAULT_SANDBOX = False - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_DOMAIN): cv.string, - vol.Optional(CONF_SANDBOX, default=DEFAULT_SANDBOX): cv.boolean - }), -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up the Mailgun component.""" - hass.data[DATA_MAILGUN] = config[DOMAIN] - hass.http.register_view(MailgunReceiveMessageView()) - return True - - -class MailgunReceiveMessageView(HomeAssistantView): - """Handle data from Mailgun inbound messages.""" - - url = API_PATH - name = 'api:{}'.format(DOMAIN) - - @callback - def post(self, request): # pylint: disable=no-self-use - """Handle Mailgun message POST.""" - hass = request.app['hass'] - data = yield from request.post() - hass.bus.async_fire(MESSAGE_RECEIVED, dict(data)) diff --git a/homeassistant/components/mailgun/.translations/en.json b/homeassistant/components/mailgun/.translations/en.json new file mode 100644 index 00000000000..3abb8aba726 --- /dev/null +++ b/homeassistant/components/mailgun/.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 Mailgun messages.", + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + }, + "step": { + "user": { + "description": "Are you sure you want to set up Mailgun?", + "title": "Set up the Mailgun Webhook" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/lb.json b/homeassistant/components/mailgun/.translations/lb.json new file mode 100644 index 00000000000..f84225444d9 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Mailgun 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, mussen [Webhooks mat Mailgun]({mailgun_url}) ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nLiest [Dokumentatioun]({docs_url}) w\u00e9i een Automatiounen ariicht welch eingehend Donn\u00e9\u00eb trait\u00e9ieren." + }, + "step": { + "user": { + "description": "S\u00e9cher fir Mailgun anzeriichten?", + "title": "Mailgun Webhook ariichten" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py new file mode 100644 index 00000000000..25f697084d3 --- /dev/null +++ b/homeassistant/components/mailgun/__init__.py @@ -0,0 +1,67 @@ +""" +Support for Mailgun. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mailgun/ +""" + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID +from homeassistant.helpers import config_entry_flow + +DOMAIN = 'mailgun' +API_PATH = '/api/{}'.format(DOMAIN) +DEPENDENCIES = ['webhook'] +MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN) +CONF_SANDBOX = 'sandbox' +DEFAULT_SANDBOX = False + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN): vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_DOMAIN): cv.string, + vol.Optional(CONF_SANDBOX, default=DEFAULT_SANDBOX): cv.boolean, + vol.Optional(CONF_WEBHOOK_ID): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Mailgun component.""" + if DOMAIN not in config: + return True + + hass.data[DOMAIN] = config[DOMAIN] + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook with Mailgun inbound messages.""" + data = dict(await request.post()) + data['webhook_id'] = webhook_id + hass.bus.async_fire(MESSAGE_RECEIVED, data) + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + 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, + 'Mailgun Webhook', + { + 'mailgun_url': + 'https://www.mailgun.com/blog/a-guide-to-using-mailguns-webhooks', + 'docs_url': 'https://www.home-assistant.io/components/mailgun/' + } +) diff --git a/homeassistant/components/mailgun/strings.json b/homeassistant/components/mailgun/strings.json new file mode 100644 index 00000000000..0e993bef5d4 --- /dev/null +++ b/homeassistant/components/mailgun/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Mailgun", + "step": { + "user": { + "title": "Set up the Mailgun Webhook", + "description": "Are you sure you want to set up Mailgun?" + } + }, + "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 Mailgun messages." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + } + } +} diff --git a/homeassistant/components/mqtt/.translations/es.json b/homeassistant/components/mqtt/.translations/es.json index e9e869ae966..182cce86057 100644 --- a/homeassistant/components/mqtt/.translations/es.json +++ b/homeassistant/components/mqtt/.translations/es.json @@ -2,6 +2,10 @@ "config": { "step": { "hassio_confirm": { + "data": { + "discovery": "Habilitar descubrimiento" + }, + "description": "\u00bfDesea configurar Home Assistant para conectarse al agente MQTT provisto por el complemento hass.io {addon} ?", "title": "MQTT Broker a trav\u00e9s del complemento Hass.io" } } diff --git a/homeassistant/components/mqtt/.translations/hu.json b/homeassistant/components/mqtt/.translations/hu.json index ba08d36d581..f08c601633e 100644 --- a/homeassistant/components/mqtt/.translations/hu.json +++ b/homeassistant/components/mqtt/.translations/hu.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "Br\u00f3ker", + "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/mqtt/.translations/pt.json b/homeassistant/components/mqtt/.translations/pt.json index 1b8c3946b7c..3b36345994d 100644 --- a/homeassistant/components/mqtt/.translations/pt.json +++ b/homeassistant/components/mqtt/.translations/pt.json @@ -17,6 +17,13 @@ }, "description": "Por favor, insira os detalhes de liga\u00e7\u00e3o ao seu broker MQTT.", "title": "" + }, + "hassio_confirm": { + "data": { + "discovery": "Ativar descoberta" + }, + "description": "Deseja configurar o Home Assistant para se ligar ao broker MQTT fornecido pelo add-on hass.io {addon}?", + "title": "MQTT Broker atrav\u00e9s do add-on Hass.io" } }, "title": "" diff --git a/homeassistant/components/mqtt/.translations/ro.json b/homeassistant/components/mqtt/.translations/ro.json new file mode 100644 index 00000000000..bcd150e3063 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/ro.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Este permis\u0103 numai o singur\u0103 configura\u021bie de MQTT." + }, + "error": { + "cannot_connect": "Imposibil de conectat la broker." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "discovery": "Activa\u021bi descoperirea", + "password": "Parol\u0103", + "port": "Port", + "username": "Nume de utilizator" + }, + "description": "Introduce\u021bi informa\u021biile de conectare ale brokerului dvs. MQTT.", + "title": "MQTT" + }, + "hassio_confirm": { + "data": { + "discovery": "Activa\u021bi descoperirea" + }, + "description": "Dori\u021bi s\u0103 configura\u021bi Home Assistant pentru a v\u0103 conecta la brokerul MQTT furnizat de addon-ul {addon} ?", + "title": "MQTT Broker, prin intermediul Hass.io add-on" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/tr.json b/homeassistant/components/mqtt/.translations/tr.json new file mode 100644 index 00000000000..1b73b94d5a4 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/tr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "hassio_confirm": { + "data": { + "discovery": "Ke\u015ffetmeyi etkinle\u015ftir" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/hu.json b/homeassistant/components/nest/.translations/hu.json index 142747a016f..aa99b46e576 100644 --- a/homeassistant/components/nest/.translations/hu.json +++ b/homeassistant/components/nest/.translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_setup": "Csak egy Nest-fi\u00f3kot konfigur\u00e1lhat.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n." }, "error": { @@ -18,7 +19,8 @@ "link": { "data": { "code": "PIN-k\u00f3d" - } + }, + "title": "Nest fi\u00f3k \u00f6sszekapcsol\u00e1sa" } }, "title": "Nest" diff --git a/homeassistant/components/nest/.translations/ro.json b/homeassistant/components/nest/.translations/ro.json new file mode 100644 index 00000000000..f315cf549fb --- /dev/null +++ b/homeassistant/components/nest/.translations/ro.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "link": { + "data": { + "code": "Cod PIN" + }, + "title": "Leg\u0103tur\u0103 cont Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/notify/clicksend.py b/homeassistant/components/notify/clicksend.py index c028da2c579..faf30ac7cc6 100644 --- a/homeassistant/components/notify/clicksend.py +++ b/homeassistant/components/notify/clicksend.py @@ -7,49 +7,41 @@ https://home-assistant.io/components/notify.clicksend/ import json import logging -from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol +from aiohttp.hdrs import CONTENT_TYPE +import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import ( CONF_API_KEY, CONF_RECIPIENT, CONF_SENDER, CONF_USERNAME, CONTENT_TYPE_JSON) -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) BASE_API_URL = 'https://rest.clicksend.com/v3' +DEFAULT_SENDER = 'hass' +TIMEOUT = 5 HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} -def validate_sender(config): - """Set the optional sender name if sender name is not provided.""" - if CONF_SENDER in config: - return config - config[CONF_SENDER] = config[CONF_RECIPIENT] - return config - - PLATFORM_SCHEMA = vol.Schema( vol.All(PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_RECIPIENT, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_SENDER): cv.string, - }), validate_sender)) + vol.Optional(CONF_SENDER, default=DEFAULT_SENDER): cv.string, + }),)) def get_service(hass, config, discovery_info=None): """Get the ClickSend notification service.""" - print("#### ", config) - if _authenticate(config) is False: - _LOGGER.exception("You are not authorized to access ClickSend") + if not _authenticate(config): + _LOGGER.error("You are not authorized to access ClickSend") return None - return ClicksendNotificationService(config) @@ -58,10 +50,10 @@ class ClicksendNotificationService(BaseNotificationService): def __init__(self, config): """Initialize the service.""" - self.username = config.get(CONF_USERNAME) - self.api_key = config.get(CONF_API_KEY) - self.recipients = config.get(CONF_RECIPIENT) - self.sender = config.get(CONF_SENDER, CONF_RECIPIENT) + self.username = config[CONF_USERNAME] + self.api_key = config[CONF_API_KEY] + self.recipients = config[CONF_RECIPIENT] + self.sender = config[CONF_SENDER] def send_message(self, message="", **kwargs): """Send a message to a user.""" @@ -75,28 +67,29 @@ class ClicksendNotificationService(BaseNotificationService): }) api_url = "{}/sms/send".format(BASE_API_URL) - - resp = requests.post( - api_url, data=json.dumps(data), headers=HEADERS, - auth=(self.username, self.api_key), timeout=5) + resp = requests.post(api_url, + data=json.dumps(data), + headers=HEADERS, + auth=(self.username, self.api_key), + timeout=TIMEOUT) + if resp.status_code == 200: + return obj = json.loads(resp.text) - response_msg = obj['response_msg'] - response_code = obj['response_code'] - - if resp.status_code != 200: - _LOGGER.error("Error %s : %s (Code %s)", resp.status_code, - response_msg, response_code) + response_msg = obj.get('response_msg') + response_code = obj.get('response_code') + _LOGGER.error("Error %s : %s (Code %s)", resp.status_code, + response_msg, response_code) def _authenticate(config): """Authenticate with ClickSend.""" api_url = '{}/account'.format(BASE_API_URL) - resp = requests.get( - api_url, headers=HEADERS, auth=(config.get(CONF_USERNAME), - config.get(CONF_API_KEY)), timeout=5) - + resp = requests.get(api_url, + headers=HEADERS, + auth=(config[CONF_USERNAME], + config[CONF_API_KEY]), + timeout=TIMEOUT) if resp.status_code != 200: return False - return True diff --git a/homeassistant/components/notify/hangouts.py b/homeassistant/components/notify/hangouts.py index eb2880e8a46..01f98146f4c 100644 --- a/homeassistant/components/notify/hangouts.py +++ b/homeassistant/components/notify/hangouts.py @@ -11,10 +11,10 @@ import voluptuous as vol from homeassistant.components.notify import (ATTR_TARGET, PLATFORM_SCHEMA, NOTIFY_SERVICE_SCHEMA, BaseNotificationService, - ATTR_MESSAGE) + ATTR_MESSAGE, ATTR_DATA) from homeassistant.components.hangouts.const \ - import (DOMAIN, SERVICE_SEND_MESSAGE, + import (DOMAIN, SERVICE_SEND_MESSAGE, MESSAGE_DATA_SCHEMA, TARGETS_SCHEMA, CONF_DEFAULT_CONVERSATIONS) _LOGGER = logging.getLogger(__name__) @@ -26,7 +26,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) NOTIFY_SERVICE_SCHEMA = NOTIFY_SERVICE_SCHEMA.extend({ - vol.Optional(ATTR_TARGET): [TARGETS_SCHEMA] + vol.Optional(ATTR_TARGET): [TARGETS_SCHEMA], + vol.Optional(ATTR_DATA, default={}): MESSAGE_DATA_SCHEMA }) @@ -59,7 +60,8 @@ class HangoutsNotificationService(BaseNotificationService): messages.append({'text': message, 'parse_str': True}) service_data = { ATTR_TARGET: target_conversations, - ATTR_MESSAGE: messages + ATTR_MESSAGE: messages, + ATTR_DATA: kwargs[ATTR_DATA] } return self.hass.services.call( diff --git a/homeassistant/components/notify/mailgun.py b/homeassistant/components/notify/mailgun.py index 1aa403f0ba8..56b0ab7e333 100644 --- a/homeassistant/components/notify/mailgun.py +++ b/homeassistant/components/notify/mailgun.py @@ -8,7 +8,8 @@ import logging import voluptuous as vol -from homeassistant.components.mailgun import CONF_SANDBOX, DATA_MAILGUN +from homeassistant.components.mailgun import ( + CONF_SANDBOX, DOMAIN as MAILGUN_DOMAIN) from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA) @@ -35,7 +36,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_service(hass, config, discovery_info=None): """Get the Mailgun notification service.""" - data = hass.data[DATA_MAILGUN] + data = hass.data[MAILGUN_DOMAIN] mailgun_service = MailgunNotificationService( data.get(CONF_DOMAIN), data.get(CONF_SANDBOX), data.get(CONF_API_KEY), config.get(CONF_SENDER), diff --git a/homeassistant/components/openuv/.translations/ro.json b/homeassistant/components/openuv/.translations/ro.json new file mode 100644 index 00000000000..976221188d3 --- /dev/null +++ b/homeassistant/components/openuv/.translations/ro.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Coordonatele deja \u00eenregistrate", + "invalid_api_key": "Cheie API invalid\u0103" + }, + "step": { + "user": { + "data": { + "api_key": "Cheie API OpenUV", + "elevation": "Altitudine", + "latitude": "Latitudine", + "longitude": "Longitudine" + }, + "title": "Completa\u021bi informa\u021biile dvs." + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index a45d9ceb0d6..52cf0ba75d5 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -210,7 +210,7 @@ class OpenUV: if data.get('from_time') and data.get('to_time'): self.data[DATA_PROTECTION_WINDOW] = data else: - _LOGGER.error( + _LOGGER.debug( 'No valid protection window data for this location') self.data[DATA_PROTECTION_WINDOW] = {} diff --git a/homeassistant/components/sensor/.translations/moon.ro.json b/homeassistant/components/sensor/.translations/moon.ro.json new file mode 100644 index 00000000000..6f64e497c74 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.ro.json @@ -0,0 +1,6 @@ +{ + "state": { + "full_moon": "Lun\u0103 plin\u0103", + "new_moon": "Lun\u0103 nou\u0103" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/rflink.py b/homeassistant/components/sensor/rflink.py index 4065e0a439f..f3ec776fda8 100644 --- a/homeassistant/components/sensor/rflink.py +++ b/homeassistant/components/sensor/rflink.py @@ -6,13 +6,16 @@ https://home-assistant.io/components/sensor.rflink/ """ import logging +import voluptuous as vol + from homeassistant.components.rflink import ( CONF_ALIASES, CONF_ALIASSES, CONF_AUTOMATIC_ADD, CONF_DEVICES, DATA_DEVICE_REGISTER, DATA_ENTITY_LOOKUP, EVENT_KEY_ID, - EVENT_KEY_SENSOR, EVENT_KEY_UNIT, RflinkDevice, cv, remove_deprecated, vol, + EVENT_KEY_SENSOR, EVENT_KEY_UNIT, RflinkDevice, remove_deprecated, SIGNAL_AVAILABILITY, SIGNAL_HANDLE_EVENT, TMP_ENTITY) from homeassistant.components.sensor import ( PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_UNIT_OF_MEASUREMENT) from homeassistant.helpers.dispatcher import (async_dispatcher_connect) diff --git a/homeassistant/components/sensor/rtorrent.py b/homeassistant/components/sensor/rtorrent.py new file mode 100644 index 00000000000..f71b9c6dbdb --- /dev/null +++ b/homeassistant/components/sensor/rtorrent.py @@ -0,0 +1,127 @@ +"""Support for monitoring the rtorrent BitTorrent client API.""" +import logging +import xmlrpc.client + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_URL, CONF_NAME, + CONF_MONITORED_VARIABLES, STATE_IDLE) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPE_CURRENT_STATUS = 'current_status' +SENSOR_TYPE_DOWNLOAD_SPEED = 'download_speed' +SENSOR_TYPE_UPLOAD_SPEED = 'upload_speed' + +DEFAULT_NAME = 'rtorrent' +SENSOR_TYPES = { + SENSOR_TYPE_CURRENT_STATUS: ['Status', None], + SENSOR_TYPE_DOWNLOAD_SPEED: ['Down Speed', 'kB/s'], + SENSOR_TYPE_UPLOAD_SPEED: ['Up Speed', 'kB/s'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): cv.url, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the rtorrent sensors.""" + url = config[CONF_URL] + name = config[CONF_NAME] + + try: + rtorrent = xmlrpc.client.ServerProxy(url) + except (xmlrpc.client.ProtocolError, ConnectionRefusedError): + _LOGGER.error("Connection to rtorrent daemon failed") + raise PlatformNotReady + dev = [] + for variable in config[CONF_MONITORED_VARIABLES]: + dev.append(RTorrentSensor(variable, rtorrent, name)) + + add_entities(dev) + + +def format_speed(speed): + """Return a bytes/s measurement as a human readable string.""" + kb_spd = float(speed) / 1024 + return round(kb_spd, 2 if kb_spd < 0.1 else 1) + + +class RTorrentSensor(Entity): + """Representation of an rtorrent sensor.""" + + def __init__(self, sensor_type, rtorrent_client, client_name): + """Initialize the sensor.""" + self._name = SENSOR_TYPES[sensor_type][0] + self.client = rtorrent_client + self.type = sensor_type + self.client_name = client_name + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.data = None + self._available = False + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self.client_name, self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def available(self): + """Return true if device is available.""" + return self._available + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from rtorrent and updates the state.""" + multicall = xmlrpc.client.MultiCall(self.client) + multicall.throttle.global_up.rate() + multicall.throttle.global_down.rate() + + try: + self.data = multicall() + self._available = True + except (xmlrpc.client.ProtocolError, ConnectionRefusedError): + _LOGGER.error("Connection to rtorrent lost") + self._available = False + return + + upload = self.data[0] + download = self.data[1] + + if self.type == SENSOR_TYPE_CURRENT_STATUS: + if self.data: + if upload > 0 and download > 0: + self._state = 'Up/Down' + elif upload > 0 and download == 0: + self._state = 'Seeding' + elif upload == 0 and download > 0: + self._state = 'Downloading' + else: + self._state = STATE_IDLE + else: + self._state = None + + if self.data: + if self.type == SENSOR_TYPE_DOWNLOAD_SPEED: + self._state = format_speed(download) + elif self.type == SENSOR_TYPE_UPLOAD_SPEED: + self._state = format_speed(upload) diff --git a/homeassistant/components/simplisafe/.translations/es.json b/homeassistant/components/simplisafe/.translations/es.json new file mode 100644 index 00000000000..12d0f63356f --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Cuenta ya registrada", + "invalid_credentials": "Credenciales no v\u00e1lidas" + }, + "step": { + "user": { + "data": { + "code": "C\u00f3digo (para Home Assistant)", + "password": "Contrase\u00f1a", + "username": "Direcci\u00f3n de correo electr\u00f3nico" + }, + "title": "Rellene sus datos" + } + }, + "title": "SimpliSafe" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/hu.json b/homeassistant/components/simplisafe/.translations/hu.json index ff2c2fc87b5..103bf4e18d0 100644 --- a/homeassistant/components/simplisafe/.translations/hu.json +++ b/homeassistant/components/simplisafe/.translations/hu.json @@ -1,10 +1,15 @@ { "config": { + "error": { + "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok" + }, "step": { "user": { "data": { - "password": "Jelsz\u00f3" - } + "password": "Jelsz\u00f3", + "username": "Email c\u00edm" + }, + "title": "T\u00f6ltsd ki az adataid" } } } diff --git a/homeassistant/components/simplisafe/.translations/nl.json b/homeassistant/components/simplisafe/.translations/nl.json new file mode 100644 index 00000000000..c84593c0b23 --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Account bestaat al", + "invalid_credentials": "Ongeldige gebruikersgegevens" + }, + "step": { + "user": { + "data": { + "code": "Code (voor Home Assistant)", + "password": "Wachtwoord", + "username": "E-mailadres" + }, + "title": "Vul uw gegevens in" + } + }, + "title": "SimpliSafe" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/pt.json b/homeassistant/components/simplisafe/.translations/pt.json new file mode 100644 index 00000000000..47929161976 --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Conta j\u00e1 registada", + "invalid_credentials": "Credenciais inv\u00e1lidas" + }, + "step": { + "user": { + "data": { + "code": "C\u00f3digo (para Home Assistant)", + "password": "Palavra-passe", + "username": "Endere\u00e7o de e-mail" + }, + "title": "Preencha as suas informa\u00e7\u00f5es" + } + }, + "title": "SimpliSafe" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/ro.json b/homeassistant/components/simplisafe/.translations/ro.json index 7046b0992b1..b7e281a2bc2 100644 --- a/homeassistant/components/simplisafe/.translations/ro.json +++ b/homeassistant/components/simplisafe/.translations/ro.json @@ -7,11 +7,13 @@ "step": { "user": { "data": { + "code": "Cod (pentru Home Assistant)", "password": "Parola", "username": "Adresa de email" }, "title": "Completa\u021bi informa\u021biile dvs." } - } + }, + "title": "SimpliSafe" } } \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/tr.json b/homeassistant/components/simplisafe/.translations/tr.json new file mode 100644 index 00000000000..ec84b1b7c1c --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/tr.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parola", + "username": "E-posta adresi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index de6277c2ef1..aaa8e3a19f9 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers import config_validation as cv from .config_flow import configured_instances from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_UPDATE -REQUIREMENTS = ['simplisafe-python==3.1.12'] +REQUIREMENTS = ['simplisafe-python==3.1.13'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smhi/.translations/es.json b/homeassistant/components/smhi/.translations/es.json new file mode 100644 index 00000000000..627c534f6dd --- /dev/null +++ b/homeassistant/components/smhi/.translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "name_exists": "Nombre ya existe", + "wrong_location": "Ubicaci\u00f3n Suecia solamente" + }, + "step": { + "user": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre" + }, + "title": "Ubicaci\u00f3n en Suecia" + } + }, + "title": "Servicio meteorol\u00f3gico sueco (SMHI)" + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/hu.json b/homeassistant/components/smhi/.translations/hu.json new file mode 100644 index 00000000000..740fc1a8179 --- /dev/null +++ b/homeassistant/components/smhi/.translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/pt.json b/homeassistant/components/smhi/.translations/pt.json new file mode 100644 index 00000000000..a5c71885906 --- /dev/null +++ b/homeassistant/components/smhi/.translations/pt.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "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/smhi/.translations/ro.json b/homeassistant/components/smhi/.translations/ro.json index 6fe28787655..6249e49d2d7 100644 --- a/homeassistant/components/smhi/.translations/ro.json +++ b/homeassistant/components/smhi/.translations/ro.json @@ -1,7 +1,8 @@ { "config": { "error": { - "name_exists": "Numele exist\u0103 deja" + "name_exists": "Numele exist\u0103 deja", + "wrong_location": "Loca\u021bia numai \u00een Suedia" }, "step": { "user": { diff --git a/homeassistant/components/smhi/.translations/tr.json b/homeassistant/components/smhi/.translations/tr.json new file mode 100644 index 00000000000..bb50f1e2a8d --- /dev/null +++ b/homeassistant/components/smhi/.translations/tr.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "name_exists": "Bu ad zaten var", + "wrong_location": "Konum sadece \u0130sve\u00e7" + }, + "step": { + "user": { + "data": { + "latitude": "Enlem", + "longitude": "Boylam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/ro.json b/homeassistant/components/sonos/.translations/ro.json new file mode 100644 index 00000000000..e442ab9504e --- /dev/null +++ b/homeassistant/components/sonos/.translations/ro.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nu exist\u0103 dispozitive Sonos g\u0103site \u00een re\u021bea.", + "single_instance_allowed": "Este necesar\u0103 o singur\u0103 configurare a Sonos." + }, + "step": { + "confirm": { + "description": "Dori\u021bi s\u0103 configura\u021bi Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/rflink.py b/homeassistant/components/switch/rflink.py index 1f217b1c39c..51bf5543584 100644 --- a/homeassistant/components/switch/rflink.py +++ b/homeassistant/components/switch/rflink.py @@ -6,15 +6,16 @@ https://home-assistant.io/components/switch.rflink/ """ import logging +import voluptuous as vol + from homeassistant.components.rflink import ( CONF_ALIASES, CONF_ALIASSES, CONF_DEVICE_DEFAULTS, CONF_DEVICES, CONF_FIRE_EVENT, CONF_GROUP, CONF_GROUP_ALIASES, CONF_GROUP_ALIASSES, CONF_NOGROUP_ALIASES, CONF_NOGROUP_ALIASSES, CONF_SIGNAL_REPETITIONS, - DEVICE_DEFAULTS_SCHEMA, SwitchableRflinkDevice, cv, - remove_deprecated, vol) + DEVICE_DEFAULTS_SCHEMA, SwitchableRflinkDevice, remove_deprecated) from homeassistant.components.switch import ( PLATFORM_SCHEMA, SwitchDevice) - +import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_NAME DEPENDENCIES = ['rflink'] diff --git a/homeassistant/components/switch/switchmate.py b/homeassistant/components/switch/switchmate.py index 2ec77a38267..7f00964cd20 100644 --- a/homeassistant/components/switch/switchmate.py +++ b/homeassistant/components/switch/switchmate.py @@ -52,6 +52,11 @@ class Switchmate(SwitchDevice): """Return a unique, HASS-friendly identifier for this entity.""" return self._mac.replace(':', '') + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._device.available + @property def name(self) -> str: """Return the name of the switch.""" diff --git a/homeassistant/components/tradfri/.translations/ro.json b/homeassistant/components/tradfri/.translations/ro.json new file mode 100644 index 00000000000..cea0e6d938f --- /dev/null +++ b/homeassistant/components/tradfri/.translations/ro.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge-ul este deja configurat" + }, + "error": { + "cannot_connect": "Nu se poate conecta la gateway.", + "invalid_key": "Nu s-a \u00eenregistrat cu cheia furnizat\u0103. Dac\u0103 acest lucru se \u00eent\u00e2mpl\u0103 \u00een continuare, \u00eencerca\u021bi s\u0103 reporni\u021bi gateway-ul.", + "timeout": "Timeout la validarea codului." + }, + "step": { + "auth": { + "data": { + "host": "Gazd\u0103", + "security_code": "Cod de securitate" + }, + "description": "Pute\u021bi g\u0103si codul de securitate pe spatele gateway-ului.", + "title": "Introduce\u021bi codul de securitate" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tts/microsoft.py b/homeassistant/components/tts/microsoft.py index 3043e9f418b..3cce7c1a78d 100644 --- a/homeassistant/components/tts/microsoft.py +++ b/homeassistant/components/tts/microsoft.py @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) SUPPORTED_LANGUAGES = [ 'ar-eg', 'ar-sa', 'ca-es', 'cs-cz', 'da-dk', 'de-at', 'de-ch', 'de-de', 'el-gr', 'en-au', 'en-ca', 'en-gb', 'en-ie', 'en-in', 'en-us', 'es-es', - 'en-mx', 'fi-fi', 'fr-ca', 'fr-ch', 'fr-fr', 'he-il', 'hi-in', 'hu-hu', + 'es-mx', 'fi-fi', 'fr-ca', 'fr-ch', 'fr-fr', 'he-il', 'hi-in', 'hu-hu', 'id-id', 'it-it', 'ja-jp', 'ko-kr', 'nb-no', 'nl-nl', 'pl-pl', 'pt-br', 'pt-pt', 'ro-ro', 'ru-ru', 'sk-sk', 'sv-se', 'th-th', 'tr-tr', 'zh-cn', 'zh-hk', 'zh-tw', diff --git a/homeassistant/components/unifi/.translations/es.json b/homeassistant/components/unifi/.translations/es.json new file mode 100644 index 00000000000..4f570fe1386 --- /dev/null +++ b/homeassistant/components/unifi/.translations/es.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "El sitio del controlador ya est\u00e1 configurado", + "user_privilege": "El usuario debe ser administrador" + }, + "error": { + "faulty_credentials": "Credenciales de usuario incorrectas", + "service_unavailable": "Servicio No disponible" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "port": "Puerto", + "site": "ID del sitio", + "username": "Nombre de usuario", + "verify_ssl": "Controlador usando el certificado adecuado" + }, + "title": "Configurar el controlador UniFi" + } + }, + "title": "Controlador UniFi" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/hu.json b/homeassistant/components/unifi/.translations/hu.json index f5827c47353..06104c6ed6c 100644 --- a/homeassistant/components/unifi/.translations/hu.json +++ b/homeassistant/components/unifi/.translations/hu.json @@ -1,10 +1,18 @@ { "config": { + "abort": { + "user_privilege": "A felhaszn\u00e1l\u00f3nak rendszergazd\u00e1nak kell lennie" + }, + "error": { + "faulty_credentials": "Rossz felhaszn\u00e1l\u00f3i hiteles\u00edt\u0151 adatok", + "service_unavailable": "Nincs el\u00e9rhet\u0151 szolg\u00e1ltat\u00e1s" + }, "step": { "user": { "data": { "password": "Jelsz\u00f3", - "port": "Port" + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } } } diff --git a/homeassistant/components/unifi/.translations/nl.json b/homeassistant/components/unifi/.translations/nl.json new file mode 100644 index 00000000000..8e87dc4b2a6 --- /dev/null +++ b/homeassistant/components/unifi/.translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "user_privilege": "Gebruiker moet beheerder zijn" + }, + "error": { + "faulty_credentials": "Foutieve gebruikersgegevens", + "service_unavailable": "Geen service beschikbaar" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" + }, + "title": "Stel de UniFi-controller in" + } + }, + "title": "UniFi-controller" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/pt.json b/homeassistant/components/unifi/.translations/pt.json new file mode 100644 index 00000000000..6730a3d258e --- /dev/null +++ b/homeassistant/components/unifi/.translations/pt.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "O site do controlador j\u00e1 se encontra configurado", + "user_privilege": "Utilizador tem que ser administrador" + }, + "error": { + "faulty_credentials": "Credenciais do utilizador erradas", + "service_unavailable": "Nenhum servi\u00e7o dispon\u00edvel" + }, + "step": { + "user": { + "data": { + "host": "Servidor", + "password": "Palavra-passe", + "port": "Porto", + "site": "Site ID", + "username": "Nome do utilizador", + "verify_ssl": "Controlador com certificados adequados" + }, + "title": "Configurar o controlador UniFi" + } + }, + "title": "Controlador UniFi" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/ro.json b/homeassistant/components/unifi/.translations/ro.json new file mode 100644 index 00000000000..99b1ac57e0b --- /dev/null +++ b/homeassistant/components/unifi/.translations/ro.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "user_privilege": "Utilizatorul trebuie s\u0103 fie administrator" + }, + "error": { + "faulty_credentials": "Credentiale utilizator invalide", + "service_unavailable": "Nici un serviciu disponibil" + }, + "step": { + "user": { + "data": { + "host": "Gazd\u0103", + "password": "Parol\u0103", + "port": "Port", + "username": "Nume de utilizator", + "verify_ssl": "Controler utiliz\u00e2nd certificatul adecvat" + }, + "title": "Configura\u021bi un controler UniFi" + } + }, + "title": "Controler UniFi" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/tr.json b/homeassistant/components/unifi/.translations/tr.json new file mode 100644 index 00000000000..667a5e676fb --- /dev/null +++ b/homeassistant/components/unifi/.translations/tr.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/es.json b/homeassistant/components/upnp/.translations/es.json new file mode 100644 index 00000000000..e4cabf4cd50 --- /dev/null +++ b/homeassistant/components/upnp/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "UPnP / IGD ya est\u00e1 configurado", + "no_devices_discovered": "No se descubrieron UPnP / IGDs", + "no_sensors_or_port_mapping": "Habilitar al menos sensores o mapeo de puertos" + }, + "step": { + "init": { + "title": "UPnP / IGD" + }, + "user": { + "data": { + "enable_port_mapping": "Habilitar la asignaci\u00f3n de puertos para Home Assistant", + "enable_sensors": "A\u00f1adir sensores de tr\u00e1fico", + "igd": "UPnP / IGD" + }, + "title": "Opciones de configuraci\u00f3n para UPnP/IGD" + } + }, + "title": "UPnP / IGD" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/hu.json b/homeassistant/components/upnp/.translations/hu.json new file mode 100644 index 00000000000..a2bf78a7f3e --- /dev/null +++ b/homeassistant/components/upnp/.translations/hu.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "init": { + "title": "UPnP/IGD" + }, + "user": { + "data": { + "igd": "UPnP/IGD" + }, + "title": "Az UPnP/IGD be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gei" + } + }, + "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 new file mode 100644 index 00000000000..5e9b516d1c2 --- /dev/null +++ b/homeassistant/components/upnp/.translations/pt.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "UPnP/IGD j\u00e1 est\u00e1 configurado", + "no_devices_discovered": "Nenhum UPnP/IGDs descoberto", + "no_sensors_or_port_mapping": "Ative pelo menos os sensores ou o mapeamento de porta" + }, + "error": { + "one": "um", + "other": "v\u00e1rios" + }, + "step": { + "init": { + "title": "" + }, + "user": { + "data": { + "enable_port_mapping": "Ativar o mapeamento de porta para o Home Assistant", + "enable_sensors": "Adicionar sensores de tr\u00e1fego", + "igd": "" + }, + "title": "Op\u00e7\u00f5es de configura\u00e7\u00e3o para o UPnP/IGD" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/tr.json b/homeassistant/components/upnp/.translations/tr.json new file mode 100644 index 00000000000..91503c17a07 --- /dev/null +++ b/homeassistant/components/upnp/.translations/tr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "enable_sensors": "Trafik sens\u00f6rleri ekleyin" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/es.json b/homeassistant/components/zwave/.translations/es.json new file mode 100644 index 00000000000..8c287d9a539 --- /dev/null +++ b/homeassistant/components/zwave/.translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "one_instance_only": "El componente solo admite una instancia de Z-Wave" + }, + "error": { + "option_error": "Z-Wave error de validaci\u00f3n. \u00bfLa ruta de acceso a la memoria USB escorrecta?" + }, + "step": { + "user": { + "data": { + "network_key": "Clave de red (d\u00e9jelo en blanco para generar autom\u00e1ticamente)", + "usb_path": "Ruta USB" + }, + "description": "Consulte https://www.home-assistant.io/docs/z-wave/installation/ para obtener informaci\u00f3n sobre las variables de configuraci\u00f3n", + "title": "Configurar Z-Wave" + } + }, + "title": "Z-Wave" + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/hu.json b/homeassistant/components/zwave/.translations/hu.json index 16c25cb7cab..e2acc5f9115 100644 --- a/homeassistant/components/zwave/.translations/hu.json +++ b/homeassistant/components/zwave/.translations/hu.json @@ -1,5 +1,16 @@ { "config": { + "abort": { + "already_configured": "A Z-Wave m\u00e1r konfigur\u00e1lva van" + }, + "step": { + "user": { + "data": { + "network_key": "H\u00e1l\u00f3zati kulcs (hagyja \u00fcresen az automatikus gener\u00e1l\u00e1shoz)", + "usb_path": "USB el\u00e9r\u00e9si \u00fat" + } + } + }, "title": "Z-Wave" } } \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/pt.json b/homeassistant/components/zwave/.translations/pt.json new file mode 100644 index 00000000000..6962f077498 --- /dev/null +++ b/homeassistant/components/zwave/.translations/pt.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "O Z-Wave j\u00e1 est\u00e1 configurado", + "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia Z-Wave" + }, + "error": { + "option_error": "A valida\u00e7\u00e3o Z-Wave falhou. O caminho para o stick USB est\u00e1 correto?" + }, + "step": { + "user": { + "data": { + "network_key": "Network Key (deixe em branco para auto-gera\u00e7\u00e3o)", + "usb_path": "Endere\u00e7o 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/.translations/tr.json b/homeassistant/components/zwave/.translations/tr.json new file mode 100644 index 00000000000..c9762784d52 --- /dev/null +++ b/homeassistant/components/zwave/.translations/tr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "network_key": "A\u011f Anajtar\u0131 (otomatik \u00fcretilmesi i\u00e7in bo\u015f b\u0131rak\u0131n\u0131z)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index c1c0fbbf775..e00215b8126 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -143,6 +143,7 @@ FLOWS = [ 'ifttt', 'ios', 'lifx', + 'mailgun', 'mqtt', 'nest', 'openuv', diff --git a/homeassistant/const.py b/homeassistant/const.py index 4dcc171d35c..b5ca708f1b2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -129,6 +129,7 @@ CONF_SENSOR_TYPE = 'sensor_type' CONF_SENSORS = 'sensors' CONF_SHOW_ON_MAP = 'show_on_map' CONF_SLAVE = 'slave' +CONF_SOURCE = 'source' CONF_SSL = 'ssl' CONF_STATE = 'state' CONF_STATE_TEMPLATE = 'state_template' diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 569a101b3dd..31d9907d315 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -1,7 +1,10 @@ """Helpers for data entry flows for config entries.""" from functools import partial +from ipaddress import ip_address +from urllib.parse import urlparse from homeassistant import config_entries +from homeassistant.util.network import is_local def register_discovery_flow(domain, title, discovery_function, @@ -12,6 +15,14 @@ def register_discovery_flow(domain, title, discovery_function, connection_class)) +def register_webhook_flow(domain, title, description_placeholder, + allow_multiple=False): + """Register flow for webhook integrations.""" + config_entries.HANDLERS.register(domain)( + partial(WebhookFlowHandler, domain, title, description_placeholder, + allow_multiple)) + + class DiscoveryFlowHandler(config_entries.ConfigFlow): """Handle a discovery config flow.""" @@ -84,3 +95,50 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): title=self._title, data={}, ) + + +class WebhookFlowHandler(config_entries.ConfigFlow): + """Handle a webhook config flow.""" + + VERSION = 1 + + def __init__(self, domain, title, description_placeholder, + allow_multiple): + """Initialize the discovery config flow.""" + self._domain = domain + self._title = title + self._description_placeholder = description_placeholder + self._allow_multiple = allow_multiple + + async def async_step_user(self, user_input=None): + """Handle a user initiated set up flow to create a webhook.""" + if not self._allow_multiple and self._async_current_entries(): + return self.async_abort(reason='one_instance_allowed') + + try: + url_parts = urlparse(self.hass.config.api.base_url) + + if is_local(ip_address(url_parts.hostname)): + return self.async_abort(reason='not_internet_accessible') + except ValueError: + # If it's not an IP address, it's very likely publicly accessible + pass + + if user_input is None: + return self.async_show_form( + step_id='user', + ) + + webhook_id = self.hass.components.webhook.async_generate_id() + webhook_url = \ + self.hass.components.webhook.async_generate_url(webhook_id) + + self._description_placeholder['webhook_url'] = webhook_url + + return self.async_create_entry( + title=self._title, + data={ + 'webhook_id': webhook_id + }, + description_placeholders=self._description_placeholder + ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9217e3b3961..fa0d675f4b1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ aiohttp==3.4.4 astral==1.6.1 -async_timeout==3.0.0 +async_timeout==3.0.1 attrs==18.2.0 bcrypt==3.1.4 certifi>=2018.04.16 diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 0a2a2a1edf3..b002c8e3147 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -4,7 +4,7 @@ from typing import Union, List, Dict import json import os -from os import O_WRONLY, O_CREAT, O_TRUNC +import tempfile from homeassistant.exceptions import HomeAssistantError @@ -46,13 +46,17 @@ def save_json(filename: str, data: Union[List, Dict], Returns True on success. """ - tmp_filename = filename + "__TEMP__" + tmp_filename = "" + tmp_path = os.path.split(filename)[0] try: json_data = json.dumps(data, sort_keys=True, indent=4) - mode = 0o600 if private else 0o644 - with open(os.open(tmp_filename, O_WRONLY | O_CREAT | O_TRUNC, mode), - 'w', encoding='utf-8') as fdesc: + # Modern versions of Python tempfile create this file with mode 0o600 + with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8', + dir=tmp_path, delete=False) as fdesc: fdesc.write(json_data) + tmp_filename = fdesc.name + if not private: + os.chmod(tmp_filename, 0o644) os.replace(tmp_filename, filename) except TypeError as error: _LOGGER.exception('Failed to serialize to JSON: %s', diff --git a/readthedocs.yml b/readthedocs.yml new file mode 100644 index 00000000000..6a06f655513 --- /dev/null +++ b/readthedocs.yml @@ -0,0 +1,8 @@ +# .readthedocs.yml + +build: + image: latest + +python: + version: 3.6 + setup_py_install: true \ No newline at end of file diff --git a/requirements_docs.txt b/requirements_docs.txt index 1a809c2fb85..cd2eb1a0be6 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.7.8 +Sphinx==1.8.1 sphinx-autodoc-typehints==1.3.0 sphinx-autodoc-annotation==1.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 911403245ed..d19fd5afa87 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,10 +94,10 @@ hbmqtt==0.9.4 hdate==0.6.5 # homeassistant.components.binary_sensor.workday -holidays==0.9.7 +holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181018.0 +home-assistant-frontend==20181023.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 @@ -125,7 +125,6 @@ numpy==1.15.2 paho-mqtt==1.4.0 # homeassistant.components.device_tracker.aruba -# homeassistant.components.device_tracker.asuswrt # homeassistant.components.device_tracker.cisco_ios # homeassistant.components.device_tracker.unifi_direct # homeassistant.components.media_player.pandora @@ -222,7 +221,7 @@ ruamel.yaml==0.15.72 rxv==0.5.1 # homeassistant.components.simplisafe -simplisafe-python==3.1.12 +simplisafe-python==3.1.13 # homeassistant.components.sleepiq sleepyq==0.6 diff --git a/setup.py b/setup.py index 727badb1d94..90f2e8357fd 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ PACKAGES = find_packages(exclude=['tests', 'tests.*']) REQUIRES = [ 'aiohttp==3.4.4', 'astral==1.6.1', - 'async_timeout==3.0.0', + 'async_timeout==3.0.1', 'attrs==18.2.0', 'bcrypt==3.1.4', 'certifi>=2018.04.16', diff --git a/tests/components/automation/test_geo_location.py b/tests/components/automation/test_geo_location.py new file mode 100644 index 00000000000..130cdeef99c --- /dev/null +++ b/tests/components/automation/test_geo_location.py @@ -0,0 +1,271 @@ +"""The tests for the geo location trigger.""" +import unittest + +from homeassistant.components import automation, zone +from homeassistant.core import callback, Context +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant, mock_component +from tests.components.automation import common + + +class TestAutomationGeoLocation(unittest.TestCase): + """Test the geo location trigger.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + mock_component(self.hass, 'group') + assert setup_component(self.hass, zone.DOMAIN, { + 'zone': { + 'name': 'test', + 'latitude': 32.880837, + 'longitude': -117.237561, + 'radius': 250, + } + }) + + self.calls = [] + + @callback + def record_call(service): + """Record calls.""" + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_if_fires_on_zone_enter(self): + """Test for firing on zone enter.""" + context = Context() + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }, context=context) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + assert self.calls[0].context is context + self.assertEqual( + 'geo_location - geo_location.entity - hello - hello - test', + self.calls[0].data['some']) + + # Set out of zone again so we can trigger call + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.block_till_done() + + common.turn_off(self.hass) + self.hass.block_till_done() + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_for_enter_on_zone_leave(self): + """Test for not firing on zone leave.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_zone_leave(self): + """Test for firing on zone leave.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_for_leave_on_zone_enter(self): + """Test for not firing on zone enter.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_zone_appear(self): + """Test for firing if entity appears in zone.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + # Entity appears in zone without previously existing outside the zone. + context = Context() + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }, context=context) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + assert self.calls[0].context is context + self.assertEqual( + 'geo_location - geo_location.entity - - hello - test', + self.calls[0].data['some']) + + def test_if_fires_on_zone_disappear(self): + """Test for firing if entity disappears from zone.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + # Entity disappears from zone without new coordinates outside the zone. + self.hass.states.async_remove('geo_location.entity') + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + self.assertEqual( + 'geo_location - geo_location.entity - hello - - test', + self.calls[0].data['some']) diff --git a/tests/components/climate/test_dyson.py b/tests/components/climate/test_dyson.py new file mode 100644 index 00000000000..6e8b63d64c4 --- /dev/null +++ b/tests/components/climate/test_dyson.py @@ -0,0 +1,358 @@ +"""Test the Dyson fan component.""" +import unittest +from unittest import mock + +from libpurecoollink.const import (FocusMode, HeatMode, HeatState, HeatTarget, + TiltState) +from libpurecoollink.dyson_pure_state import DysonPureHotCoolState +from libpurecoollink.dyson_pure_hotcool_link import DysonPureHotCoolLink +from homeassistant.components.climate import dyson +from homeassistant.components import dyson as dyson_parent +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + + +class MockDysonState(DysonPureHotCoolState): + """Mock Dyson state.""" + + def __init__(self): + """Create new Mock Dyson State.""" + pass + + +def _get_device_with_no_state(): + """Return a device with no state.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = None + device.environmental_state = None + return device + + +def _get_device_off(): + """Return a device with state off.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.environmental_state = mock.Mock() + return device + + +def _get_device_focus(): + """Return a device with fan state of focus mode.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.focus_mode = FocusMode.FOCUS_ON.value + return device + + +def _get_device_diffuse(): + """Return a device with fan state of diffuse mode.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.focus_mode = FocusMode.FOCUS_OFF.value + return device + + +def _get_device_cool(): + """Return a device with state of cooling.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_OFF.value + device.state.heat_target = HeatTarget.celsius(12) + device.state.heat_mode = HeatMode.HEAT_OFF.value + device.state.heat_state = HeatState.HEAT_STATE_OFF.value + device.environmental_state.temperature = 288 + device.environmental_state.humidity = 53 + return device + + +def _get_device_heat_off(): + """Return a device with state of heat reached target.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_ON.value + device.state.heat_target = HeatTarget.celsius(20) + device.state.heat_mode = HeatMode.HEAT_ON.value + device.state.heat_state = HeatState.HEAT_STATE_OFF.value + device.environmental_state.temperature = 293 + device.environmental_state.humidity = 53 + return device + + +def _get_device_heat_on(): + """Return a device with state of heating.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_ON.value + device.state.heat_target = HeatTarget.celsius(23) + device.state.heat_mode = HeatMode.HEAT_ON.value + device.state.heat_state = HeatState.HEAT_STATE_ON.value + device.environmental_state.temperature = 289 + device.environmental_state.humidity = 53 + return device + + +class DysonTest(unittest.TestCase): + """Dyson Climate 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() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @mock.patch('libpurecoollink.dyson.DysonAccount.devices', + return_value=[_get_device_heat_on(), _get_device_cool()]) + @mock.patch('libpurecoollink.dyson.DysonAccount.login', return_value=True) + def test_setup_component_with_parent_discovery(self, mocked_login, + mocked_devices): + """Test setup_component using discovery.""" + setup_component(self.hass, dyson_parent.DOMAIN, { + dyson_parent.DOMAIN: { + dyson_parent.CONF_USERNAME: "email", + dyson_parent.CONF_PASSWORD: "password", + dyson_parent.CONF_LANGUAGE: "US", + } + }) + self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 2) + self.hass.block_till_done() + for m in mocked_devices.return_value: + assert m.add_message_listener.called + + def test_setup_component_without_devices(self): + """Test setup component with no devices.""" + self.hass.data[dyson.DYSON_DEVICES] = [] + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices) + add_devices.assert_not_called() + + def test_setup_component_with_devices(self): + """Test setup component with valid devices.""" + devices = [ + _get_device_with_no_state(), + _get_device_off(), + _get_device_heat_on() + ] + self.hass.data[dyson.DYSON_DEVICES] = devices + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices, discovery_info={}) + self.assertTrue(add_devices.called) + + def test_setup_component_with_invalid_devices(self): + """Test setup component with invalid devices.""" + devices = [ + None, + "foo_bar" + ] + self.hass.data[dyson.DYSON_DEVICES] = devices + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices, discovery_info={}) + add_devices.assert_called_with([]) + + def test_setup_component(self): + """Test setup component with devices.""" + device_fan = _get_device_heat_on() + device_non_fan = _get_device_off() + + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "Device_name" + + self.hass.data[dyson.DYSON_DEVICES] = [device_fan, device_non_fan] + dyson.setup_platform(self.hass, None, _add_device) + + def test_dyson_set_temperature(self): + """Test set climate temperature.""" + device = _get_device_heat_on() + device.temp_unit = TEMP_CELSIUS + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + # Without target temp. + kwargs = {} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_not_called() + + kwargs = {ATTR_TEMPERATURE: 23} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(23)) + + # Should clip the target temperature between 1 and 37 inclusive. + kwargs = {ATTR_TEMPERATURE: 50} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(37)) + + kwargs = {ATTR_TEMPERATURE: -5} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(1)) + + def test_dyson_set_temperature_when_cooling_mode(self): + """Test set climate temperature when heating is off.""" + device = _get_device_cool() + device.temp_unit = TEMP_CELSIUS + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.schedule_update_ha_state = mock.Mock() + + kwargs = {ATTR_TEMPERATURE: 23} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(23)) + + def test_dyson_set_fan_mode(self): + """Test set fan mode.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + entity.set_fan_mode(dyson.STATE_FOCUS) + set_config = device.set_configuration + set_config.assert_called_with(focus_mode=FocusMode.FOCUS_ON) + + entity.set_fan_mode(dyson.STATE_DIFFUSE) + set_config = device.set_configuration + set_config.assert_called_with(focus_mode=FocusMode.FOCUS_OFF) + + def test_dyson_fan_list(self): + """Test get fan list.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(len(entity.fan_list), 2) + self.assertTrue(dyson.STATE_FOCUS in entity.fan_list) + self.assertTrue(dyson.STATE_DIFFUSE in entity.fan_list) + + def test_dyson_fan_mode_focus(self): + """Test fan focus mode.""" + device = _get_device_focus() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_fan_mode, dyson.STATE_FOCUS) + + def test_dyson_fan_mode_diffuse(self): + """Test fan diffuse mode.""" + device = _get_device_diffuse() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_fan_mode, dyson.STATE_DIFFUSE) + + def test_dyson_set_operation_mode(self): + """Test set operation mode.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + entity.set_operation_mode(dyson.STATE_HEAT) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_ON) + + entity.set_operation_mode(dyson.STATE_COOL) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_OFF) + + def test_dyson_operation_list(self): + """Test get operation list.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(len(entity.operation_list), 2) + self.assertTrue(dyson.STATE_HEAT in entity.operation_list) + self.assertTrue(dyson.STATE_COOL in entity.operation_list) + + def test_dyson_heat_off(self): + """Test turn off heat.""" + device = _get_device_heat_off() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.set_operation_mode(dyson.STATE_COOL) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_OFF) + + def test_dyson_heat_on(self): + """Test turn on heat.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.set_operation_mode(dyson.STATE_HEAT) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_ON) + + def test_dyson_heat_value_on(self): + """Test get heat value on.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_HEAT) + + def test_dyson_heat_value_off(self): + """Test get heat value off.""" + device = _get_device_cool() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_COOL) + + def test_dyson_heat_value_idle(self): + """Test get heat value idle.""" + device = _get_device_heat_off() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_IDLE) + + def test_on_message(self): + """Test when message is received.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.schedule_update_ha_state = mock.Mock() + entity.on_message(MockDysonState()) + entity.schedule_update_ha_state.assert_called_with() + + def test_general_properties(self): + """Test properties of entity.""" + device = _get_device_with_no_state() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.should_poll, False) + self.assertEqual(entity.supported_features, dyson.SUPPORT_FLAGS) + self.assertEqual(entity.temperature_unit, TEMP_CELSIUS) + + def test_property_current_humidity(self): + """Test properties of current humidity.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, 53) + + def test_property_current_humidity_with_invalid_env_state(self): + """Test properties of current humidity with invalid env state.""" + device = _get_device_off() + device.environmental_state.humidity = 0 + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, None) + + def test_property_current_humidity_without_env_state(self): + """Test properties of current humidity without env state.""" + device = _get_device_with_no_state() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, None) + + def test_property_current_temperature(self): + """Test properties of current temperature.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + # Result should be in celsius, hence then subtraction of 273. + self.assertEqual(entity.current_temperature, 289 - 273) + + def test_property_target_temperature(self): + """Test properties of target temperature.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.target_temperature, 23) diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 47ec621aeb5..8bbcbc8f840 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -221,6 +221,24 @@ class TestClimateGenericThermostat(unittest.TestCase): state = self.hass.states.get(ENTITY) self.assertEqual(23, state.attributes.get('temperature')) + def test_set_away_mode_twice_and_restore_prev_temp(self): + """Test the setting away mode twice in a row. + + Verify original temperature is restored. + """ + common.set_temperature(self.hass, 23) + self.hass.block_till_done() + common.set_away_mode(self.hass, True) + self.hass.block_till_done() + common.set_away_mode(self.hass, True) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(16, state.attributes.get('temperature')) + common.set_away_mode(self.hass, False) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(23, state.attributes.get('temperature')) + def test_sensor_bad_value(self): """Test sensor that have None as state.""" state = self.hass.states.get(ENTITY) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 5d4b356b9b2..e27760bd6ed 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -7,6 +7,7 @@ from jose import jwt from homeassistant.components.cloud import ( DOMAIN, auth_api, iot, STORAGE_ENABLE_GOOGLE, STORAGE_ENABLE_ALEXA) +from homeassistant.util import dt as dt_util from tests.common import mock_coro @@ -352,24 +353,89 @@ async def test_websocket_status_not_logged_in(hass, hass_ws_client): } -async def test_websocket_subscription(hass, hass_ws_client, aioclient_mock, - mock_auth): - """Test querying the status.""" - aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'return': 'value'}) +async def test_websocket_subscription_reconnect( + hass, hass_ws_client, aioclient_mock, mock_auth): + """Test querying the status and connecting because valid account.""" + aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': dt_util.utcnow().date().isoformat() + }, 'test') + client = await hass_ws_client(hass) + + with patch( + 'homeassistant.components.cloud.auth_api.renew_access_token' + ) as mock_renew, patch( + 'homeassistant.components.cloud.iot.CloudIoT.connect' + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() + + assert response['result'] == { + 'provider': 'stripe' + } + assert len(mock_renew.mock_calls) == 1 + assert len(mock_connect.mock_calls) == 1 + + +async def test_websocket_subscription_no_reconnect_if_connected( + hass, hass_ws_client, aioclient_mock, mock_auth): + """Test querying the status and not reconnecting because still expired.""" + aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) + hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': dt_util.utcnow().date().isoformat() + }, 'test') + client = await hass_ws_client(hass) + + with patch( + 'homeassistant.components.cloud.auth_api.renew_access_token' + ) as mock_renew, patch( + 'homeassistant.components.cloud.iot.CloudIoT.connect' + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() + + assert response['result'] == { + 'provider': 'stripe' + } + assert len(mock_renew.mock_calls) == 0 + assert len(mock_connect.mock_calls) == 0 + + +async def test_websocket_subscription_no_reconnect_if_expired( + hass, hass_ws_client, aioclient_mock, mock_auth): + """Test querying the status and not reconnecting because still expired.""" + aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) hass.data[DOMAIN].id_token = jwt.encode({ 'email': 'hello@home-assistant.io', 'custom:sub-exp': '2018-01-03' }, 'test') client = await hass_ws_client(hass) - await client.send_json({ - 'id': 5, - 'type': 'cloud/subscription' - }) - response = await client.receive_json() + + with patch( + 'homeassistant.components.cloud.auth_api.renew_access_token' + ) as mock_renew, patch( + 'homeassistant.components.cloud.iot.CloudIoT.connect' + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() assert response['result'] == { - 'return': 'value' + 'provider': 'stripe' } + assert len(mock_renew.mock_calls) == 1 + assert len(mock_connect.mock_calls) == 1 async def test_websocket_subscription_fail(hass, hass_ws_client, diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 8695830eae9..61518f0f0e8 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -155,14 +155,14 @@ def test_subscription_expired(hass): with patch.object(cl, '_decode_claims', return_value=token_val), \ patch('homeassistant.util.dt.utcnow', return_value=utcnow().replace( - year=2017, month=11, day=15, hour=23, minute=59, + year=2017, month=11, day=19, hour=23, minute=59, second=59)): assert not cl.subscription_expired with patch.object(cl, '_decode_claims', return_value=token_val), \ patch('homeassistant.util.dt.utcnow', return_value=utcnow().replace( - year=2017, month=11, day=16, hour=0, minute=0, + year=2017, month=11, day=20, hour=0, minute=0, second=0)): assert cl.subscription_expired diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 8c5af618288..d43a7d53969 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -3,9 +3,6 @@ import os from datetime import timedelta import unittest from unittest import mock -import socket - -import voluptuous as vol from homeassistant.setup import setup_component from homeassistant.components import device_tracker @@ -13,9 +10,7 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_NEW_DEVICE_DEFAULTS, CONF_AWAY_HIDE) from homeassistant.components.device_tracker.asuswrt import ( - CONF_PROTOCOL, CONF_MODE, CONF_PUB_KEY, DOMAIN, _ARP_REGEX, - CONF_PORT, PLATFORM_SCHEMA, Device, get_scanner, AsusWrtDeviceScanner, - _parse_lines, SshConnection, TelnetConnection, CONF_REQUIRE_IP) + CONF_PROTOCOL, CONF_MODE, DOMAIN, CONF_PORT) from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, CONF_HOST) @@ -35,85 +30,6 @@ VALID_CONFIG_ROUTER_SSH = {DOMAIN: { CONF_PORT: '22' }} -WL_DATA = [ - 'assoclist 01:02:03:04:06:08\r', - 'assoclist 08:09:10:11:12:14\r', - 'assoclist 08:09:10:11:12:15\r' -] - -WL_DEVICES = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip=None, name=None), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip=None, name=None), - '08:09:10:11:12:15': Device( - mac='08:09:10:11:12:15', ip=None, name=None) -} - -ARP_DATA = [ - '? (123.123.123.125) at 01:02:03:04:06:08 [ether] on eth0\r', - '? (123.123.123.126) at 08:09:10:11:12:14 [ether] on br0\r', - '? (123.123.123.127) at on br0\r', -] - -ARP_DEVICES = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name=None), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name=None) -} - -NEIGH_DATA = [ - '123.123.123.125 dev eth0 lladdr 01:02:03:04:06:08 REACHABLE\r', - '123.123.123.126 dev br0 lladdr 08:09:10:11:12:14 REACHABLE\r', - '123.123.123.127 dev br0 FAILED\r', - '123.123.123.128 dev br0 lladdr 08:09:15:15:15:15 DELAY\r', - 'fe80::feff:a6ff:feff:12ff dev br0 lladdr fc:ff:a6:ff:12:ff STALE\r', -] - -NEIGH_DEVICES = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name=None), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name=None) -} - -LEASES_DATA = [ - '51910 01:02:03:04:06:08 123.123.123.125 TV 01:02:03:04:06:08\r', - '79986 01:02:03:04:06:10 123.123.123.127 android 01:02:03:04:06:15\r', - '23523 08:09:10:11:12:14 123.123.123.126 * 08:09:10:11:12:14\r', -] - -LEASES_DEVICES = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name='TV'), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name='') -} - -WAKE_DEVICES = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name='TV'), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name='') -} - -WAKE_DEVICES_AP = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name=None), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name=None) -} - -WAKE_DEVICES_NO_IP = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name=None), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name=None), - '08:09:10:11:12:15': Device( - mac='08:09:10:11:12:15', ip=None, name=None) -} - def setup_module(): """Set up the test module.""" @@ -150,24 +66,6 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): except FileNotFoundError: pass - def test_parse_lines_wrong_input(self): - """Testing parse lines.""" - output = _parse_lines("asdf asdfdfsafad", _ARP_REGEX) - self.assertEqual(output, []) - - def test_get_device_name(self): - """Test for getting name.""" - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.last_results = WAKE_DEVICES - self.assertEqual('TV', scanner.get_device_name('01:02:03:04:06:08')) - self.assertEqual(None, scanner.get_device_name('01:02:03:04:08:08')) - - def test_scan_devices(self): - """Test for scan devices.""" - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.last_results = WAKE_DEVICES - self.assertEqual(list(WAKE_DEVICES), scanner.scan_devices()) - def test_password_or_pub_key_required(self): """Test creating an AsusWRT scanner without a pass or pubkey.""" with assert_setup_component(0, DOMAIN): @@ -207,377 +105,3 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): conf_dict[DOMAIN][CONF_PORT] = 22 self.assertEqual(asuswrt_mock.call_count, 1) self.assertEqual(asuswrt_mock.call_args, mock.call(conf_dict[DOMAIN])) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', - return_value=mock.MagicMock()) - def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): - """Test creating an AsusWRT scanner with a pubkey and no password.""" - conf_dict = { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PUB_KEY: FAKEFILE, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - CONF_NEW_DEVICE_DEFAULTS: { - CONF_TRACK_NEW: True, - CONF_AWAY_HIDE: False - } - } - } - - with assert_setup_component(1, DOMAIN): - assert setup_component(self.hass, DOMAIN, conf_dict) - - conf_dict[DOMAIN][CONF_MODE] = 'router' - conf_dict[DOMAIN][CONF_PROTOCOL] = 'ssh' - conf_dict[DOMAIN][CONF_PORT] = 22 - self.assertEqual(asuswrt_mock.call_count, 1) - self.assertEqual(asuswrt_mock.call_args, mock.call(conf_dict[DOMAIN])) - - def test_ssh_login_with_pub_key(self): - """Test that login is done with pub_key when configured to.""" - ssh = mock.MagicMock() - ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) - ssh_mock.start() - self.addCleanup(ssh_mock.stop) - conf_dict = PLATFORM_SCHEMA({ - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PUB_KEY: FAKEFILE - }) - update_mock = mock.patch( - 'homeassistant.components.device_tracker.asuswrt.' - 'AsusWrtDeviceScanner.get_asuswrt_data') - update_mock.start() - self.addCleanup(update_mock.stop) - asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.run_command('ls') - self.assertEqual(ssh.login.call_count, 1) - self.assertEqual( - ssh.login.call_args, - mock.call('fake_host', 'fake_user', quiet=False, - ssh_key=FAKEFILE, port=22) - ) - - def test_ssh_login_with_password(self): - """Test that login is done with password when configured to.""" - ssh = mock.MagicMock() - ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) - ssh_mock.start() - self.addCleanup(ssh_mock.stop) - conf_dict = PLATFORM_SCHEMA({ - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass' - }) - update_mock = mock.patch( - 'homeassistant.components.device_tracker.asuswrt.' - 'AsusWrtDeviceScanner.get_asuswrt_data') - update_mock.start() - self.addCleanup(update_mock.stop) - asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.run_command('ls') - self.assertEqual(ssh.login.call_count, 1) - self.assertEqual( - ssh.login.call_args, - mock.call('fake_host', 'fake_user', quiet=False, - password='fake_pass', port=22) - ) - - def test_ssh_login_without_password_or_pubkey(self): - """Test that login is not called without password or pub_key.""" - ssh = mock.MagicMock() - ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) - ssh_mock.start() - self.addCleanup(ssh_mock.stop) - - conf_dict = { - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - } - - with self.assertRaises(vol.Invalid): - conf_dict = PLATFORM_SCHEMA(conf_dict) - - update_mock = mock.patch( - 'homeassistant.components.device_tracker.asuswrt.' - 'AsusWrtDeviceScanner.get_asuswrt_data') - update_mock.start() - self.addCleanup(update_mock.stop) - - with assert_setup_component(0, DOMAIN): - assert setup_component(self.hass, DOMAIN, - {DOMAIN: conf_dict}) - ssh.login.assert_not_called() - - def test_telnet_login_with_password(self): - """Test that login is done with password when configured to.""" - telnet = mock.MagicMock() - telnet_mock = mock.patch('telnetlib.Telnet', return_value=telnet) - telnet_mock.start() - self.addCleanup(telnet_mock.stop) - conf_dict = PLATFORM_SCHEMA({ - CONF_PLATFORM: 'asuswrt', - CONF_PROTOCOL: 'telnet', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass' - }) - update_mock = mock.patch( - 'homeassistant.components.device_tracker.asuswrt.' - 'AsusWrtDeviceScanner.get_asuswrt_data') - update_mock.start() - self.addCleanup(update_mock.stop) - asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.run_command('ls') - self.assertEqual(telnet.read_until.call_count, 4) - self.assertEqual(telnet.write.call_count, 3) - self.assertEqual( - telnet.read_until.call_args_list[0], - mock.call(b'login: ') - ) - self.assertEqual( - telnet.write.call_args_list[0], - mock.call(b'fake_user\n') - ) - self.assertEqual( - telnet.read_until.call_args_list[1], - mock.call(b'Password: ') - ) - self.assertEqual( - telnet.write.call_args_list[1], - mock.call(b'fake_pass\n') - ) - self.assertEqual( - telnet.read_until.call_args_list[2], - mock.call(b'#') - ) - - def test_telnet_login_without_password(self): - """Test that login is not called without password or pub_key.""" - telnet = mock.MagicMock() - telnet_mock = mock.patch('telnetlib.Telnet', return_value=telnet) - telnet_mock.start() - self.addCleanup(telnet_mock.stop) - - conf_dict = { - CONF_PLATFORM: 'asuswrt', - CONF_PROTOCOL: 'telnet', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - } - - with self.assertRaises(vol.Invalid): - conf_dict = PLATFORM_SCHEMA(conf_dict) - - update_mock = mock.patch( - 'homeassistant.components.device_tracker.asuswrt.' - 'AsusWrtDeviceScanner.get_asuswrt_data') - update_mock.start() - self.addCleanup(update_mock.stop) - - with assert_setup_component(0, DOMAIN): - assert setup_component(self.hass, DOMAIN, - {DOMAIN: conf_dict}) - telnet.login.assert_not_called() - - def test_get_asuswrt_data(self): - """Test asuswrt data fetch.""" - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner._get_wl = mock.Mock() - scanner._get_arp = mock.Mock() - scanner._get_neigh = mock.Mock() - scanner._get_leases = mock.Mock() - scanner._get_wl.return_value = WL_DEVICES - scanner._get_arp.return_value = ARP_DEVICES - scanner._get_neigh.return_value = NEIGH_DEVICES - scanner._get_leases.return_value = LEASES_DEVICES - self.assertEqual(WAKE_DEVICES, scanner.get_asuswrt_data()) - - def test_get_asuswrt_data_ap(self): - """Test for get asuswrt_data in ap mode.""" - conf = VALID_CONFIG_ROUTER_SSH.copy()[DOMAIN] - conf[CONF_MODE] = 'ap' - scanner = AsusWrtDeviceScanner(conf) - scanner._get_wl = mock.Mock() - scanner._get_arp = mock.Mock() - scanner._get_neigh = mock.Mock() - scanner._get_leases = mock.Mock() - scanner._get_wl.return_value = WL_DEVICES - scanner._get_arp.return_value = ARP_DEVICES - scanner._get_neigh.return_value = NEIGH_DEVICES - scanner._get_leases.return_value = LEASES_DEVICES - self.assertEqual(WAKE_DEVICES_AP, scanner.get_asuswrt_data()) - - def test_get_asuswrt_data_no_ip(self): - """Test for get asuswrt_data and not requiring ip.""" - conf = VALID_CONFIG_ROUTER_SSH.copy()[DOMAIN] - conf[CONF_REQUIRE_IP] = False - scanner = AsusWrtDeviceScanner(conf) - scanner._get_wl = mock.Mock() - scanner._get_arp = mock.Mock() - scanner._get_neigh = mock.Mock() - scanner._get_leases = mock.Mock() - scanner._get_wl.return_value = WL_DEVICES - scanner._get_arp.return_value = ARP_DEVICES - scanner._get_neigh.return_value = NEIGH_DEVICES - scanner._get_leases.return_value = LEASES_DEVICES - self.assertEqual(WAKE_DEVICES_NO_IP, scanner.get_asuswrt_data()) - - def test_update_info(self): - """Test for update info.""" - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.get_asuswrt_data = mock.Mock() - scanner.get_asuswrt_data.return_value = WAKE_DEVICES - self.assertTrue(scanner._update_info()) - self.assertTrue(scanner.last_results, WAKE_DEVICES) - scanner.success_init = False - self.assertFalse(scanner._update_info()) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.SshConnection') - def test_get_wl(self, mocked_ssh): - """Testing wl.""" - mocked_ssh.run_command.return_value = WL_DATA - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.connection = mocked_ssh - self.assertEqual(WL_DEVICES, scanner._get_wl()) - mocked_ssh.run_command.return_value = '' - self.assertEqual({}, scanner._get_wl()) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.SshConnection') - def test_get_arp(self, mocked_ssh): - """Testing arp.""" - mocked_ssh.run_command.return_value = ARP_DATA - - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.connection = mocked_ssh - self.assertEqual(ARP_DEVICES, scanner._get_arp()) - mocked_ssh.run_command.return_value = '' - self.assertEqual({}, scanner._get_arp()) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.SshConnection') - def test_get_neigh(self, mocked_ssh): - """Testing neigh.""" - mocked_ssh.run_command.return_value = NEIGH_DATA - - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.connection = mocked_ssh - self.assertEqual(NEIGH_DEVICES, scanner._get_neigh(ARP_DEVICES.copy())) - self.assertEqual(NEIGH_DEVICES, scanner._get_neigh({ - 'UN:KN:WN:DE:VI:CE': Device('UN:KN:WN:DE:VI:CE', None, None), - })) - mocked_ssh.run_command.return_value = '' - self.assertEqual({}, scanner._get_neigh(ARP_DEVICES.copy())) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.SshConnection') - def test_get_leases(self, mocked_ssh): - """Testing leases.""" - mocked_ssh.run_command.return_value = LEASES_DATA - - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.connection = mocked_ssh - self.assertEqual( - LEASES_DEVICES, scanner._get_leases(NEIGH_DEVICES.copy())) - mocked_ssh.run_command.return_value = '' - self.assertEqual({}, scanner._get_leases(NEIGH_DEVICES.copy())) - - -@pytest.mark.skip( - reason="These tests are performing actual failing network calls. They " - "need to be cleaned up before they are re-enabled. They're frequently " - "failing in Travis.") -class TestSshConnection(unittest.TestCase): - """Testing SshConnection.""" - - def setUp(self): - """Set up test env.""" - self.connection = SshConnection( - 'fake', 'fake', 'fake', 'fake', 'fake') - self.connection._connected = True - - def test_run_command_exception_eof(self): - """Testing exception in run_command.""" - from pexpect import exceptions - self.connection._ssh = mock.Mock() - self.connection._ssh.sendline = mock.Mock() - self.connection._ssh.sendline.side_effect = exceptions.EOF('except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - self.assertIsNone(self.connection._ssh) - - def test_run_command_exception_pxssh(self): - """Testing exception in run_command.""" - from pexpect import pxssh - self.connection._ssh = mock.Mock() - self.connection._ssh.sendline = mock.Mock() - self.connection._ssh.sendline.side_effect = pxssh.ExceptionPxssh( - 'except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - self.assertIsNone(self.connection._ssh) - - def test_run_command_assertion_error(self): - """Testing exception in run_command.""" - self.connection._ssh = mock.Mock() - self.connection._ssh.sendline = mock.Mock() - self.connection._ssh.sendline.side_effect = AssertionError('except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - self.assertIsNone(self.connection._ssh) - - -@pytest.mark.skip( - reason="These tests are performing actual failing network calls. They " - "need to be cleaned up before they are re-enabled. They're frequently " - "failing in Travis.") -class TestTelnetConnection(unittest.TestCase): - """Testing TelnetConnection.""" - - def setUp(self): - """Set up test env.""" - self.connection = TelnetConnection( - 'fake', 'fake', 'fake', 'fake') - self.connection._connected = True - - def test_run_command_exception_eof(self): - """Testing EOFException in run_command.""" - self.connection._telnet = mock.Mock() - self.connection._telnet.write = mock.Mock() - self.connection._telnet.write.side_effect = EOFError('except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - - def test_run_command_exception_connection_refused(self): - """Testing ConnectionRefusedError in run_command.""" - self.connection._telnet = mock.Mock() - self.connection._telnet.write = mock.Mock() - self.connection._telnet.write.side_effect = ConnectionRefusedError( - 'except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - - def test_run_command_exception_gaierror(self): - """Testing socket.gaierror in run_command.""" - self.connection._telnet = mock.Mock() - self.connection._telnet.write = mock.Mock() - self.connection._telnet.write.side_effect = socket.gaierror('except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - - def test_run_command_exception_oserror(self): - """Testing OSError in run_command.""" - self.connection._telnet = mock.Mock() - self.connection._telnet.write = mock.Mock() - self.connection._telnet.write.side_effect = OSError('except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 9b0a5cd9052..3de8e969140 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -1,7 +1,5 @@ """Test the Emulated Hue component.""" -import json - -from unittest.mock import patch, Mock, mock_open, MagicMock +from unittest.mock import patch, Mock, MagicMock from homeassistant.components.emulated_hue import Config @@ -14,30 +12,30 @@ def test_config_google_home_entity_id_to_number(): 'type': 'google_home' }) - mop = mock_open(read_data=json.dumps({'1': 'light.test2'})) - handle = mop() + with patch('homeassistant.components.emulated_hue.load_json', + return_value={'1': 'light.test2'}) as json_loader: + with patch('homeassistant.components.emulated_hue' + '.save_json') as json_saver: + number = conf.entity_id_to_number('light.test') + assert number == '2' - with patch('homeassistant.util.json.open', mop, create=True): - with patch('homeassistant.util.json.os.open', return_value=0): - with patch('homeassistant.util.json.os.replace'): - number = conf.entity_id_to_number('light.test') - assert number == '2' - assert handle.write.call_count == 1 - assert json.loads(handle.write.mock_calls[0][1][0]) == { - '1': 'light.test2', - '2': 'light.test', - } + assert json_saver.mock_calls[0][1][1] == { + '1': 'light.test2', '2': 'light.test' + } - number = conf.entity_id_to_number('light.test') - assert number == '2' - assert handle.write.call_count == 1 + assert json_saver.call_count == 1 + assert json_loader.call_count == 1 - number = conf.entity_id_to_number('light.test2') - assert number == '1' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test') + assert number == '2' + assert json_saver.call_count == 1 - entity_id = conf.number_to_entity_id('1') - assert entity_id == 'light.test2' + number = conf.entity_id_to_number('light.test2') + assert number == '1' + assert json_saver.call_count == 1 + + entity_id = conf.number_to_entity_id('1') + assert entity_id == 'light.test2' def test_config_google_home_entity_id_to_number_altered(): @@ -48,30 +46,30 @@ def test_config_google_home_entity_id_to_number_altered(): 'type': 'google_home' }) - mop = mock_open(read_data=json.dumps({'21': 'light.test2'})) - handle = mop() + with patch('homeassistant.components.emulated_hue.load_json', + return_value={'21': 'light.test2'}) as json_loader: + with patch('homeassistant.components.emulated_hue' + '.save_json') as json_saver: + number = conf.entity_id_to_number('light.test') + assert number == '22' + assert json_saver.call_count == 1 + assert json_loader.call_count == 1 - with patch('homeassistant.util.json.open', mop, create=True): - with patch('homeassistant.util.json.os.open', return_value=0): - with patch('homeassistant.util.json.os.replace'): - number = conf.entity_id_to_number('light.test') - assert number == '22' - assert handle.write.call_count == 1 - assert json.loads(handle.write.mock_calls[0][1][0]) == { - '21': 'light.test2', - '22': 'light.test', - } + assert json_saver.mock_calls[0][1][1] == { + '21': 'light.test2', + '22': 'light.test', + } - number = conf.entity_id_to_number('light.test') - assert number == '22' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test') + assert number == '22' + assert json_saver.call_count == 1 - number = conf.entity_id_to_number('light.test2') - assert number == '21' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test2') + assert number == '21' + assert json_saver.call_count == 1 - entity_id = conf.number_to_entity_id('21') - assert entity_id == 'light.test2' + entity_id = conf.number_to_entity_id('21') + assert entity_id == 'light.test2' def test_config_google_home_entity_id_to_number_empty(): @@ -82,29 +80,29 @@ def test_config_google_home_entity_id_to_number_empty(): 'type': 'google_home' }) - mop = mock_open(read_data='') - handle = mop() + with patch('homeassistant.components.emulated_hue.load_json', + return_value={}) as json_loader: + with patch('homeassistant.components.emulated_hue' + '.save_json') as json_saver: + number = conf.entity_id_to_number('light.test') + assert number == '1' + assert json_saver.call_count == 1 + assert json_loader.call_count == 1 - with patch('homeassistant.util.json.open', mop, create=True): - with patch('homeassistant.util.json.os.open', return_value=0): - with patch('homeassistant.util.json.os.replace'): - number = conf.entity_id_to_number('light.test') - assert number == '1' - assert handle.write.call_count == 1 - assert json.loads(handle.write.mock_calls[0][1][0]) == { - '1': 'light.test', - } + assert json_saver.mock_calls[0][1][1] == { + '1': 'light.test', + } - number = conf.entity_id_to_number('light.test') - assert number == '1' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test') + assert number == '1' + assert json_saver.call_count == 1 - number = conf.entity_id_to_number('light.test2') - assert number == '2' - assert handle.write.call_count == 2 + number = conf.entity_id_to_number('light.test2') + assert number == '2' + assert json_saver.call_count == 2 - entity_id = conf.number_to_entity_id('2') - assert entity_id == 'light.test2' + entity_id = conf.number_to_entity_id('2') + assert entity_id == 'light.test2' def test_config_alexa_entity_id_to_number(): diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py index 61d6654ba55..21417c99c5b 100644 --- a/tests/components/ifttt/test_init.py +++ b/tests/components/ifttt/test_init.py @@ -1,5 +1,5 @@ """Test the init file of IFTTT.""" -from unittest.mock import Mock, patch +from unittest.mock import patch from homeassistant import data_entry_flow from homeassistant.core import callback @@ -36,13 +36,3 @@ async def test_config_flow_registers_webhook(hass, aiohttp_client): assert len(ifttt_events) == 1 assert ifttt_events[0].data['webhook_id'] == webhook_id assert ifttt_events[0].data['hello'] == 'ifttt' - - -async def test_config_flow_aborts_external_url(hass, aiohttp_client): - """Test setting up IFTTT and sending webhook.""" - hass.config.api = Mock(base_url='http://192.168.1.10') - result = await hass.config_entries.flow.async_init('ifttt', context={ - 'source': 'user' - }) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == 'not_internet_accessible' diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 5e4cf2d8037..c637267cc7e 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -9,7 +9,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.components.lovelace import (load_yaml, - save_yaml, load_config) + save_yaml, load_config, + UnsupportedYamlError) TEST_YAML_A = """\ title: My Awesome Home @@ -55,6 +56,8 @@ views: # Title of the view. Will be used as the tooltip for tab icon title: Second view cards: + - id: test + type: entities # Entities card will take a list of entities and show their state. - type: entities # Title of the entities card @@ -79,6 +82,7 @@ TEST_YAML_B = """\ title: Home views: - title: Dashboard + id: dashboard icon: mdi:home cards: - id: testid @@ -102,6 +106,15 @@ views: type: vertical-stack """ +# Test unsupported YAML +TEST_UNSUP_YAML = """\ +title: Home +views: + - title: Dashboard + icon: mdi:home + cards: !include cards.yaml +""" + class TestYAML(unittest.TestCase): """Test lovelace.yaml save and load.""" @@ -147,9 +160,11 @@ class TestYAML(unittest.TestCase): """Test if id is added.""" fname = self._path_for("test6") with patch('homeassistant.components.lovelace.load_yaml', - return_value=self.yaml.load(TEST_YAML_A)): + return_value=self.yaml.load(TEST_YAML_A)), \ + patch('homeassistant.components.lovelace.save_yaml'): data = load_config(fname) assert 'id' in data['views'][0]['cards'][0] + assert 'id' in data['views'][1] def test_id_not_changed(self): """Test if id is not changed if already exists.""" @@ -256,7 +271,7 @@ async def test_lovelace_ui_not_found(hass, hass_ws_client): async def test_lovelace_ui_load_err(hass, hass_ws_client): - """Test lovelace_ui command cannot find file.""" + """Test lovelace_ui command load error.""" await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) @@ -272,3 +287,156 @@ async def test_lovelace_ui_load_err(hass, hass_ws_client): assert msg['type'] == TYPE_RESULT assert msg['success'] is False assert msg['error']['code'] == 'load_error' + + +async def test_lovelace_ui_load_json_err(hass, hass_ws_client): + """Test lovelace_ui command load error.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + + with patch('homeassistant.components.lovelace.load_config', + side_effect=UnsupportedYamlError): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'unsupported_error' + + +async def test_lovelace_get_card(hass, hass_ws_client): + """Test get_card command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/get', + 'card_id': 'test', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert msg['result'] == 'id: test\ntype: entities\n' + + +async def test_lovelace_get_card_not_found(hass, hass_ws_client): + """Test get_card command cannot find card.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/get', + 'card_id': 'not_found', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'card_not_found' + + +async def test_lovelace_get_card_bad_yaml(hass, hass_ws_client): + """Test get_card command bad yaml.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + + with patch('homeassistant.components.lovelace.load_yaml', + side_effect=HomeAssistantError): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/get', + 'card_id': 'testid', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'load_error' + + +async def test_lovelace_set_card(hass, hass_ws_client): + """Test set_card command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.components.lovelace.save_yaml') \ + as save_yaml_mock: + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/set', + 'card_id': 'test', + 'card_config': 'id: test\ntype: glance\n', + }) + msg = await client.receive_json() + + result = save_yaml_mock.call_args_list[0][0][1] + assert result.mlget(['views', 1, 'cards', 0, 'type'], + list_ok=True) == 'glance' + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + + +async def test_lovelace_set_card_not_found(hass, hass_ws_client): + """Test set_card command cannot find card.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/set', + 'card_id': 'not_found', + 'card_config': 'id: test\ntype: glance\n', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'card_not_found' + + +async def test_lovelace_set_card_bad_yaml(hass, hass_ws_client): + """Test set_card command bad yaml.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.components.lovelace.yaml_to_object', + side_effect=HomeAssistantError): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/set', + 'card_id': 'test', + 'card_config': 'id: test\ntype: glance\n', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'save_error' diff --git a/tests/components/mailgun/__init__.py b/tests/components/mailgun/__init__.py new file mode 100644 index 00000000000..3999bce717c --- /dev/null +++ b/tests/components/mailgun/__init__.py @@ -0,0 +1 @@ +"""Tests for the Mailgun component.""" diff --git a/tests/components/mailgun/test_init.py b/tests/components/mailgun/test_init.py new file mode 100644 index 00000000000..312e3e22bfd --- /dev/null +++ b/tests/components/mailgun/test_init.py @@ -0,0 +1,39 @@ +"""Test the init file of Mailgun.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components import mailgun + +from homeassistant.core import callback + + +async def test_config_flow_registers_webhook(hass, aiohttp_client): + """Test setting up Mailgun and sending webhook.""" + with patch('homeassistant.util.get_local_ip', return_value='example.com'): + result = await hass.config_entries.flow.async_init('mailgun', 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 + webhook_id = result['result'].data['webhook_id'] + + mailgun_events = [] + + @callback + def handle_event(event): + """Handle Mailgun event.""" + mailgun_events.append(event) + + hass.bus.async_listen(mailgun.MESSAGE_RECEIVED, handle_event) + + client = await aiohttp_client(hass.http.app) + await client.post('/api/webhook/{}'.format(webhook_id), data={ + 'hello': 'mailgun' + }) + + assert len(mailgun_events) == 1 + assert mailgun_events[0].data['webhook_id'] == webhook_id + assert mailgun_events[0].data['hello'] == 'mailgun' diff --git a/tests/components/test_dyson.py b/tests/components/test_dyson.py index 19c39754eb2..0352551aec9 100644 --- a/tests/components/test_dyson.py +++ b/tests/components/test_dyson.py @@ -87,7 +87,7 @@ class DysonTest(unittest.TestCase): self.assertEqual(mocked_login.call_count, 1) self.assertEqual(mocked_devices.call_count, 1) self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 1) - self.assertEqual(mocked_discovery.call_count, 3) + self.assertEqual(mocked_discovery.call_count, 4) @mock.patch('libpurecoollink.dyson.DysonAccount.devices', return_value=[_get_dyson_account_device_not_available()]) @@ -172,7 +172,7 @@ class DysonTest(unittest.TestCase): self.assertEqual(mocked_login.call_count, 1) self.assertEqual(mocked_devices.call_count, 1) self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 1) - self.assertEqual(mocked_discovery.call_count, 3) + self.assertEqual(mocked_discovery.call_count, 4) @mock.patch('libpurecoollink.dyson.DysonAccount.devices', return_value=[_get_dyson_account_device_not_available()]) diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 9d858e31a06..8e38f76f1c0 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,5 +1,5 @@ """Tests for the Config Entry Flow helper.""" -from unittest.mock import patch +from unittest.mock import patch, Mock import pytest @@ -9,7 +9,7 @@ from tests.common import MockConfigEntry, MockModule @pytest.fixture -def flow_conf(hass): +def discovery_flow_conf(hass): """Register a handler.""" handler_conf = { 'discovered': False, @@ -26,7 +26,18 @@ def flow_conf(hass): yield handler_conf -async def test_single_entry_allowed(hass, flow_conf): +@pytest.fixture +def webhook_flow_conf(hass): + """Register a handler.""" + with patch.dict(config_entries.HANDLERS): + config_entry_flow.register_webhook_flow( + 'test_single', 'Test Single', {}, False) + config_entry_flow.register_webhook_flow( + 'test_multiple', 'Test Multiple', {}, True) + yield {} + + +async def test_single_entry_allowed(hass, discovery_flow_conf): """Test only a single entry is allowed.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -38,7 +49,7 @@ async def test_single_entry_allowed(hass, flow_conf): assert result['reason'] == 'single_instance_allowed' -async def test_user_no_devices_found(hass, flow_conf): +async def test_user_no_devices_found(hass, discovery_flow_conf): """Test if no devices found.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -51,18 +62,18 @@ async def test_user_no_devices_found(hass, flow_conf): assert result['reason'] == 'no_devices_found' -async def test_user_has_confirmation(hass, flow_conf): +async def test_user_has_confirmation(hass, discovery_flow_conf): """Test user requires no confirmation to setup.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - flow_conf['discovered'] = True + discovery_flow_conf['discovered'] = True result = await flow.async_step_user() assert result['type'] == data_entry_flow.RESULT_TYPE_FORM -async def test_discovery_single_instance(hass, flow_conf): +async def test_discovery_single_instance(hass, discovery_flow_conf): """Test we ask for confirmation via discovery.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -74,7 +85,7 @@ async def test_discovery_single_instance(hass, flow_conf): assert result['reason'] == 'single_instance_allowed' -async def test_discovery_confirmation(hass, flow_conf): +async def test_discovery_confirmation(hass, discovery_flow_conf): """Test we ask for confirmation via discovery.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -88,7 +99,7 @@ async def test_discovery_confirmation(hass, flow_conf): assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_multiple_discoveries(hass, flow_conf): +async def test_multiple_discoveries(hass, discovery_flow_conf): """Test we only create one instance for multiple discoveries.""" loader.set_component(hass, 'test', MockModule('test')) @@ -102,7 +113,7 @@ async def test_multiple_discoveries(hass, flow_conf): assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT -async def test_only_one_in_progress(hass, flow_conf): +async def test_only_one_in_progress(hass, discovery_flow_conf): """Test a user initialized one will finish and cancel discovered one.""" loader.set_component(hass, 'test', MockModule('test')) @@ -127,22 +138,71 @@ async def test_only_one_in_progress(hass, flow_conf): assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_import_no_confirmation(hass, flow_conf): +async def test_import_no_confirmation(hass, discovery_flow_conf): """Test import requires no confirmation to set up.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - flow_conf['discovered'] = True + discovery_flow_conf['discovered'] = True result = await flow.async_step_import(None) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_import_single_instance(hass, flow_conf): +async def test_import_single_instance(hass, discovery_flow_conf): """Test import doesn't create second instance.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - flow_conf['discovered'] = True + discovery_flow_conf['discovered'] = True MockConfigEntry(domain='test').add_to_hass(hass) result = await flow.async_step_import(None) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_webhook_single_entry_allowed(hass, webhook_flow_conf): + """Test only a single entry is allowed.""" + flow = config_entries.HANDLERS['test_single']() + flow.hass = hass + + MockConfigEntry(domain='test_single').add_to_hass(hass) + result = await flow.async_step_user() + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'one_instance_allowed' + + +async def test_webhook_multiple_entries_allowed(hass, webhook_flow_conf): + """Test multiple entries are allowed when specified.""" + flow = config_entries.HANDLERS['test_multiple']() + flow.hass = hass + + MockConfigEntry(domain='test_multiple').add_to_hass(hass) + hass.config.api = Mock(base_url='http://example.com') + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_webhook_config_flow_aborts_external_url(hass, + webhook_flow_conf): + """Test configuring a webhook without an external url.""" + flow = config_entries.HANDLERS['test_single']() + flow.hass = hass + + hass.config.api = Mock(base_url='http://192.168.1.10') + result = await flow.async_step_user() + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'not_internet_accessible' + + +async def test_webhook_config_flow_registers_webhook(hass, webhook_flow_conf): + """Test setting up an entry creates a webhook.""" + flow = config_entries.HANDLERS['test_single']() + flow.hass = hass + + hass.config.api = Mock(base_url='http://example.com') + result = await flow.async_step_user(user_input={}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['webhook_id'] is not None