diff --git a/.coveragerc b/.coveragerc index 857b6b90a22..f1ff7715580 100644 --- a/.coveragerc +++ b/.coveragerc @@ -19,6 +19,9 @@ omit = homeassistant/components/alarmdecoder.py homeassistant/components/*/alarmdecoder.py + homeassistant/components/ambient_station/__init__.py + homeassistant/components/ambient_station/sensor.py + homeassistant/components/amcrest.py homeassistant/components/*/amcrest.py @@ -80,17 +83,24 @@ omit = homeassistant/components/digital_ocean.py homeassistant/components/*/digital_ocean.py + homeassistant/components/danfoss_air/* + homeassistant/components/dominos.py homeassistant/components/doorbird.py homeassistant/components/*/doorbird.py + homeassistant/components/dovado/* + homeassistant/components/dweet.py homeassistant/components/*/dweet.py homeassistant/components/eight_sleep.py homeassistant/components/*/eight_sleep.py + homeassistant/components/ecoal_boiler.py + homeassistant/components/*/ecoal_boiler.py + homeassistant/components/ecobee.py homeassistant/components/*/ecobee.py @@ -163,13 +173,13 @@ omit = homeassistant/components/hlk_sw16.py homeassistant/components/*/hlk_sw16.py - homeassistant/components/homekit_controller/__init__.py - homeassistant/components/*/homekit_controller.py + homeassistant/components/homekit_controller/* homeassistant/components/homematic/__init__.py homeassistant/components/*/homematic.py - homeassistant/components/homematicip_cloud.py + homeassistant/components/homematicip_cloud/hap.py + homeassistant/components/homematicip_cloud/device.py homeassistant/components/*/homematicip_cloud.py homeassistant/components/homeworks.py @@ -384,6 +394,9 @@ omit = homeassistant/components/tradfri.py homeassistant/components/*/tradfri.py + + homeassistant/components/transmission.py + homeassistant/components/*/transmission.py homeassistant/components/notify/twilio_sms.py homeassistant/components/notify/twilio_call.py @@ -443,15 +456,19 @@ omit = homeassistant/components/zha/sensor.py homeassistant/components/zha/switch.py homeassistant/components/zha/api.py - homeassistant/components/zha/entities/* - homeassistant/components/zha/helpers.py + homeassistant/components/zha/entity.py + homeassistant/components/zha/device_entity.py + homeassistant/components/zha/core/helpers.py + homeassistant/components/zha/core/const.py + homeassistant/components/zha/core/device.py + homeassistant/components/zha/core/listeners.py + homeassistant/components/zha/core/gateway.py homeassistant/components/*/zha.py homeassistant/components/zigbee.py homeassistant/components/*/zigbee.py homeassistant/components/zoneminder/* - homeassistant/components/*/zoneminder.py homeassistant/components/tuya.py homeassistant/components/*/tuya.py @@ -460,6 +477,7 @@ omit = homeassistant/components/*/spider.py homeassistant/components/air_quality/opensensemap.py + homeassistant/components/air_quality/nilu.py homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py @@ -552,6 +570,7 @@ omit = homeassistant/components/device_tracker/sky_hub.py homeassistant/components/device_tracker/snmp.py homeassistant/components/device_tracker/swisscom.py + homeassistant/components/device_tracker/synology_srm.py homeassistant/components/device_tracker/tado.py homeassistant/components/device_tracker/thomson.py homeassistant/components/device_tracker/tile.py @@ -574,6 +593,7 @@ omit = homeassistant/components/image_processing/dlib_face_identify.py homeassistant/components/image_processing/seven_segments.py homeassistant/components/image_processing/tensorflow.py + homeassistant/components/image_processing/qrcode.py homeassistant/components/keyboard_remote.py homeassistant/components/keyboard.py homeassistant/components/light/avion.py @@ -581,6 +601,7 @@ omit = homeassistant/components/light/blinkt.py homeassistant/components/light/decora_wifi.py homeassistant/components/light/decora.py + homeassistant/components/light/everlights.py homeassistant/components/light/flux_led.py homeassistant/components/light/futurenow.py homeassistant/components/light/greenwave.py @@ -719,7 +740,6 @@ omit = homeassistant/components/sensor/aftership.py homeassistant/components/sensor/airvisual.py homeassistant/components/sensor/alpha_vantage.py - homeassistant/components/sensor/ambient_station.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py @@ -744,7 +764,6 @@ omit = homeassistant/components/sensor/dht.py homeassistant/components/sensor/discogs.py homeassistant/components/sensor/dnsip.py - homeassistant/components/sensor/dovado.py homeassistant/components/sensor/domain_expiry.py homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/dublin_bus_transport.py @@ -781,6 +800,7 @@ omit = homeassistant/components/sensor/hp_ilo.py homeassistant/components/sensor/htu21d.py homeassistant/components/sensor/upnp.py + homeassistant/components/sensor/iliad_italy.py homeassistant/components/sensor/imap_email_content.py homeassistant/components/sensor/imap.py homeassistant/components/sensor/influxdb.py @@ -835,7 +855,9 @@ omit = homeassistant/components/sensor/qnap.py homeassistant/components/sensor/radarr.py homeassistant/components/sensor/rainbird.py + homeassistant/components/sensor/recollect_waste.py homeassistant/components/sensor/ripple.py + homeassistant/components/sensor/rova.py homeassistant/components/sensor/rtorrent.py homeassistant/components/sensor/ruter.py homeassistant/components/sensor/scrape.py @@ -874,7 +896,6 @@ omit = homeassistant/components/sensor/time_date.py homeassistant/components/sensor/torque.py homeassistant/components/sensor/trafikverket_weatherstation.py - homeassistant/components/sensor/transmission.py homeassistant/components/sensor/travisci.py homeassistant/components/sensor/twitch.py homeassistant/components/sensor/uber.py @@ -919,7 +940,6 @@ omit = homeassistant/components/switch/switchmate.py homeassistant/components/switch/telnet.py homeassistant/components/switch/tplink.py - homeassistant/components/switch/transmission.py homeassistant/components/switch/vesync.py homeassistant/components/telegram_bot/* homeassistant/components/thingspeak.py diff --git a/CODEOWNERS b/CODEOWNERS index cfb83919b9c..98eaca90076 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -95,6 +95,7 @@ homeassistant/components/notify/syslog.py @fabaff homeassistant/components/notify/xmpp.py @fabaff homeassistant/components/notify/yessssms.py @flowolf homeassistant/components/plant.py @ChristianKuehnel +homeassistant/components/remote/harmony.py @ehendrix23 homeassistant/components/scene/lifx_cloud.py @amelchio homeassistant/components/sensor/airvisual.py @bachya homeassistant/components/sensor/alpha_vantage.py @fabaff @@ -152,6 +153,7 @@ homeassistant/components/weather/openweathermap.py @fabaff homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi # A +homeassistant/components/ambient_station/* @bachya homeassistant/components/arduino.py @fabaff homeassistant/components/*/arduino.py @fabaff homeassistant/components/*/arest.py @fabaff @@ -234,6 +236,7 @@ homeassistant/components/*/rfxtrx.py @danielhiversen # S homeassistant/components/simplisafe/* @bachya +homeassistant/components/smartthings/* @andrewsayre # T homeassistant/components/tahoma.py @philklei @@ -268,8 +271,7 @@ homeassistant/components/*/xiaomi_aqara.py @danielhiversen @syssi homeassistant/components/*/xiaomi_miio.py @rytilahti @syssi # Z -homeassistant/components/zoneminder/ @rohankapoorcom -homeassistant/components/*/zoneminder.py @rohankapoorcom +homeassistant/components/zoneminder/* @rohankapoorcom # Other code homeassistant/scripts/check_config.py @kellerza diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index f5605886628..b22f93f11f1 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -2,7 +2,8 @@ import base64 from collections import OrderedDict import logging -from typing import Any, Dict, List, Optional, cast + +from typing import Any, Dict, List, Optional, Set, cast # noqa: F401 import bcrypt import voluptuous as vol @@ -52,6 +53,9 @@ class Data: self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY, private=True) self._data = None # type: Optional[Dict[str, Any]] + # Legacy mode will allow usernames to start/end with whitespace + # and will compare usernames case-insensitive. + # Remove in 2020 or when we launch 1.0. self.is_legacy = False @callback @@ -60,7 +64,7 @@ class Data: if self.is_legacy: return username - return username.strip() + return username.strip().casefold() async def async_load(self) -> None: """Load stored data.""" @@ -71,9 +75,26 @@ class Data: 'users': [] } + seen = set() # type: Set[str] + for user in data['users']: username = user['username'] + # check if we have duplicates + folded = username.casefold() + + if folded in seen: + self.is_legacy = True + + logging.getLogger(__name__).warning( + "Home Assistant auth provider is running in legacy mode " + "because we detected usernames that are case-insensitive" + "equivalent. Please change the username: '%s'.", username) + + break + + seen.add(folded) + # check if we have unstripped usernames if username != username.strip(): self.is_legacy = True @@ -81,7 +102,7 @@ class Data: logging.getLogger(__name__).warning( "Home Assistant auth provider is running in legacy mode " "because we detected usernames that start or end in a " - "space. Please change the username.") + "space. Please change the username: '%s'.", username) break @@ -103,7 +124,7 @@ class Data: # Compare all users to avoid timing attacks. for user in self.users: - if username == user['username']: + if self.normalize_username(user['username']) == username: found = user if found is None: diff --git a/homeassistant/components/air_quality/__init__.py b/homeassistant/components/air_quality/__init__.py index 7aed61ee11c..5f770e84b37 100644 --- a/homeassistant/components/air_quality/__init__.py +++ b/homeassistant/components/air_quality/__init__.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_AQI = 'air_quality_index' ATTR_ATTRIBUTION = 'attribution' -ATTR_C02 = 'carbon_dioxide' +ATTR_CO2 = 'carbon_dioxide' ATTR_CO = 'carbon_monoxide' ATTR_N2O = 'nitrogen_oxide' ATTR_NO = 'nitrogen_monoxide' @@ -35,7 +35,7 @@ SCAN_INTERVAL = timedelta(seconds=30) PROP_TO_ATTR = { 'air_quality_index': ATTR_AQI, 'attribution': ATTR_ATTRIBUTION, - 'carbon_dioxide': ATTR_C02, + 'carbon_dioxide': ATTR_CO2, 'carbon_monoxide': ATTR_CO, 'nitrogen_oxide': ATTR_N2O, 'nitrogen_monoxide': ATTR_NO, diff --git a/homeassistant/components/air_quality/nilu.py b/homeassistant/components/air_quality/nilu.py new file mode 100644 index 00000000000..2ab38c1ad95 --- /dev/null +++ b/homeassistant/components/air_quality/nilu.py @@ -0,0 +1,252 @@ +""" +Sensor for checking the air quality around Norway. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/air_quality.nilu/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.air_quality import ( + PLATFORM_SCHEMA, AirQualityEntity) +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_SHOW_ON_MAP) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +REQUIREMENTS = ['niluclient==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_AREA = 'area' +ATTR_POLLUTION_INDEX = 'nilu_pollution_index' +ATTRIBUTION = "Data provided by luftkvalitet.info and nilu.no" + +CONF_AREA = 'area' +CONF_STATION = 'stations' + +DEFAULT_NAME = 'NILU' + +SCAN_INTERVAL = timedelta(minutes=30) + +CONF_ALLOWED_AREAS = [ + 'Bergen', + 'Birkenes', + 'Bodø', + 'Brumunddal', + 'Bærum', + 'Drammen', + 'Elverum', + 'Fredrikstad', + 'Gjøvik', + 'Grenland', + 'Halden', + 'Hamar', + 'Harstad', + 'Hurdal', + 'Karasjok', + 'Kristiansand', + 'Kårvatn', + 'Lillehammer', + 'Lillesand', + 'Lillestrøm', + 'Lørenskog', + 'Mo i Rana', + 'Moss', + 'Narvik', + 'Oslo', + 'Prestebakke', + 'Sandve', + 'Sarpsborg', + 'Stavanger', + 'Sør-Varanger', + 'Tromsø', + 'Trondheim', + 'Tustervatn', + 'Zeppelinfjellet', + 'Ålesund', +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Inclusive(CONF_LATITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.longitude, + vol.Exclusive(CONF_AREA, 'station_collection', + 'Can only configure one specific station or ' + 'stations in a specific area pr sensor. ' + 'Please only configure station or area.' + ): vol.All(cv.string, vol.In(CONF_ALLOWED_AREAS)), + vol.Exclusive(CONF_STATION, 'station_collection', + 'Can only configure one specific station or ' + 'stations in a specific area pr sensor. ' + 'Please only configure station or area.' + ): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the NILU air quality sensor.""" + import niluclient as nilu + name = config.get(CONF_NAME) + area = config.get(CONF_AREA) + stations = config.get(CONF_STATION) + show_on_map = config.get(CONF_SHOW_ON_MAP) + + sensors = [] + + if area: + stations = nilu.lookup_stations_in_area(area) + elif not area and not stations: + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + location_client = nilu.create_location_client(latitude, longitude) + stations = location_client.station_names + + for station in stations: + client = NiluData(nilu.create_station_client(station)) + client.update() + if client.data.sensors: + sensors.append(NiluSensor(client, name, show_on_map)) + else: + _LOGGER.warning("%s didn't give any sensors results", station) + + add_entities(sensors, True) + + +class NiluData: + """Class for handling the data retrieval.""" + + def __init__(self, api): + """Initialize the data object.""" + self.api = api + + @property + def data(self): + """Get data cached in client.""" + return self.api.data + + @Throttle(SCAN_INTERVAL) + def update(self): + """Get the latest data from nilu API.""" + self.api.update() + + +class NiluSensor(AirQualityEntity): + """Single nilu station air sensor.""" + + def __init__(self, api_data: NiluData, name: str, show_on_map: bool): + """Initialize the sensor.""" + self._api = api_data + self._name = "{} {}".format(name, api_data.data.name) + self._max_aqi = None + self._attrs = {} + + if show_on_map: + self._attrs[CONF_LATITUDE] = api_data.data.latitude + self._attrs[CONF_LONGITUDE] = api_data.data.longitude + + @property + def attribution(self) -> str: + """Return the attribution.""" + return ATTRIBUTION + + @property + def device_state_attributes(self) -> dict: + """Return other details about the sensor state.""" + return self._attrs + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def air_quality_index(self) -> str: + """Return the Air Quality Index (AQI).""" + return self._max_aqi + + @property + def carbon_monoxide(self) -> str: + """Return the CO (carbon monoxide) level.""" + from niluclient import CO + return self.get_component_state(CO) + + @property + def carbon_dioxide(self) -> str: + """Return the CO2 (carbon dioxide) level.""" + from niluclient import CO2 + return self.get_component_state(CO2) + + @property + def nitrogen_oxide(self) -> str: + """Return the N2O (nitrogen oxide) level.""" + from niluclient import NOX + return self.get_component_state(NOX) + + @property + def nitrogen_monoxide(self) -> str: + """Return the NO (nitrogen monoxide) level.""" + from niluclient import NO + return self.get_component_state(NO) + + @property + def nitrogen_dioxide(self) -> str: + """Return the NO2 (nitrogen dioxide) level.""" + from niluclient import NO2 + return self.get_component_state(NO2) + + @property + def ozone(self) -> str: + """Return the O3 (ozone) level.""" + from niluclient import OZONE + return self.get_component_state(OZONE) + + @property + def particulate_matter_2_5(self) -> str: + """Return the particulate matter 2.5 level.""" + from niluclient import PM25 + return self.get_component_state(PM25) + + @property + def particulate_matter_10(self) -> str: + """Return the particulate matter 10 level.""" + from niluclient import PM10 + return self.get_component_state(PM10) + + @property + def particulate_matter_0_1(self) -> str: + """Return the particulate matter 0.1 level.""" + from niluclient import PM1 + return self.get_component_state(PM1) + + @property + def sulphur_dioxide(self) -> str: + """Return the SO2 (sulphur dioxide) level.""" + from niluclient import SO2 + return self.get_component_state(SO2) + + def get_component_state(self, component_name: str) -> str: + """Return formatted value of specified component.""" + if component_name in self._api.data.sensors: + sensor = self._api.data.sensors[component_name] + return sensor.value + return None + + def update(self) -> None: + """Update the sensor.""" + import niluclient as nilu + self._api.update() + + sensors = self._api.data.sensors.values() + if sensors: + max_index = max([s.pollution_index for s in sensors]) + self._max_aqi = max_index + self._attrs[ATTR_POLLUTION_INDEX] = \ + nilu.POLLUTION_INDEX[self._max_aqi] + + self._attrs[ATTR_AREA] = self._api.data.area diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 7f3dc2ac8f5..e02e074189c 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -13,7 +13,8 @@ from homeassistant.const import ( ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, SERVICE_ALARM_TRIGGER, SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_CUSTOM_BYPASS) -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +from homeassistant.helpers.config_validation import ( # noqa + PLATFORM_SCHEMA_BASE, PLATFORM_SCHEMA_2 as PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/alarm_control_panel/abode.py b/homeassistant/components/alarm_control_panel/abode.py index c57666d4fe6..6d4e28243ea 100644 --- a/homeassistant/components/alarm_control_panel/abode.py +++ b/homeassistant/components/alarm_control_panel/abode.py @@ -6,9 +6,9 @@ https://home-assistant.io/components/alarm_control_panel.abode/ """ import logging +import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.abode import CONF_ATTRIBUTION, AbodeDevice from homeassistant.components.abode import DOMAIN as ABODE_DOMAIN -from homeassistant.components.alarm_control_panel import AlarmControlPanel from homeassistant.const import ( ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) @@ -31,7 +31,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(alarm_devices) -class AbodeAlarm(AbodeDevice, AlarmControlPanel): +class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanel): """An alarm_control_panel implementation for Abode.""" def __init__(self, data, device, name): @@ -57,6 +57,11 @@ class AbodeAlarm(AbodeDevice, AlarmControlPanel): state = None return state + @property + def code_format(self): + """Return one or more digits/characters.""" + return alarm.FORMAT_NUMBER + def alarm_disarm(self, code=None): """Send disarm command.""" self._device.set_standby() diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py index 03cf9c1ddf8..4f2913771b1 100644 --- a/homeassistant/components/alarm_control_panel/alarmdotcom.py +++ b/homeassistant/components/alarm_control_panel/alarmdotcom.py @@ -13,7 +13,7 @@ import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -57,7 +57,7 @@ class AlarmDotCom(alarm.AlarmControlPanel): self._username = username self._password = password self._websession = async_get_clientsession(self._hass) - self._state = STATE_UNKNOWN + self._state = None self._alarm = Alarmdotcom( username, password, self._websession, hass.loop) @@ -93,7 +93,7 @@ class AlarmDotCom(alarm.AlarmControlPanel): return STATE_ALARM_ARMED_HOME if self._alarm.state.lower() == 'armed away': return STATE_ALARM_ARMED_AWAY - return STATE_UNKNOWN + return None @property def device_state_attributes(self): diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index 015b3cfce33..155d6b6ae49 100644 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -5,18 +5,17 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.concord232/ """ import datetime -from datetime import timedelta import logging import requests import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm +import homeassistant.helpers.config_validation as cv from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) -import homeassistant.helpers.config_validation as cv + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) REQUIREMENTS = ['concord232==0.15'] @@ -26,7 +25,7 @@ DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'CONCORD232' DEFAULT_PORT = 5007 -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = datetime.timedelta(seconds=10) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, @@ -44,33 +43,24 @@ def setup_platform(hass, config, add_entities, discovery_info=None): url = 'http://{}:{}'.format(host, port) try: - add_entities([Concord232Alarm(hass, url, name)]) + add_entities([Concord232Alarm(url, name)], True) except requests.exceptions.ConnectionError as ex: _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) - return class Concord232Alarm(alarm.AlarmControlPanel): """Representation of the Concord232-based alarm panel.""" - def __init__(self, hass, url, name): + def __init__(self, url, name): """Initialize the Concord232 alarm panel.""" from concord232 import client as concord232_client - self._state = STATE_UNKNOWN - self._hass = hass + self._state = None self._name = name self._url = url - - try: - client = concord232_client.Client(self._url) - except requests.exceptions.ConnectionError as ex: - _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) - - self._alarm = client + self._alarm = concord232_client.Client(self._url) self._alarm.partitions = self._alarm.list_partitions() self._alarm.last_partition_update = datetime.datetime.now() - self.update() @property def name(self): @@ -94,22 +84,17 @@ class Concord232Alarm(alarm.AlarmControlPanel): except requests.exceptions.ConnectionError as ex: _LOGGER.error("Unable to connect to %(host)s: %(reason)s", dict(host=self._url, reason=ex)) - newstate = STATE_UNKNOWN + return except IndexError: _LOGGER.error("Concord232 reports no partitions") - newstate = STATE_UNKNOWN + return if part['arming_level'] == 'Off': - newstate = STATE_ALARM_DISARMED + self._state = STATE_ALARM_DISARMED elif 'Home' in part['arming_level']: - newstate = STATE_ALARM_ARMED_HOME + self._state = STATE_ALARM_ARMED_HOME else: - newstate = STATE_ALARM_ARMED_AWAY - - if not newstate == self._state: - _LOGGER.info("State change from %s to %s", self._state, newstate) - self._state = newstate - return self._state + self._state = STATE_ALARM_ARMED_AWAY def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/alarm_control_panel/homematicip_cloud.py b/homeassistant/components/alarm_control_panel/homematicip_cloud.py index 8c483121650..3fdfc768c52 100644 --- a/homeassistant/components/alarm_control_panel/homematicip_cloud.py +++ b/homeassistant/components/alarm_control_panel/homematicip_cloud.py @@ -76,7 +76,7 @@ class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel): async def async_alarm_arm_home(self, code=None): """Send arm home command.""" - await self._home.set_security_zones_activation(True, False) + await self._home.set_security_zones_activation(False, True) async def async_alarm_arm_away(self, code=None): """Send arm away command.""" diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 97f46cb0dfd..3b0725658d4 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME, + STATE_ALARM_ARMING, STATE_ALARM_DISARMING, CONF_NAME, STATE_ALARM_ARMED_CUSTOM_BYPASS) @@ -52,7 +52,7 @@ class TotalConnect(alarm.AlarmControlPanel): self._name = name self._username = username self._password = password - self._state = STATE_UNKNOWN + self._state = None self._client = TotalConnectClient.TotalConnectClient( username, password) @@ -85,7 +85,7 @@ class TotalConnect(alarm.AlarmControlPanel): elif status == self._client.DISARMING: state = STATE_ALARM_DISARMING else: - state = STATE_UNKNOWN + state = None self._state = state diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py index 6b381ef5a47..160f152ef8a 100644 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -11,8 +11,7 @@ import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.verisure import CONF_ALARM, CONF_CODE_DIGITS from homeassistant.components.verisure import HUB as hub from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_UNKNOWN) + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) _LOGGER = logging.getLogger(__name__) @@ -44,7 +43,7 @@ class VerisureAlarm(alarm.AlarmControlPanel): def __init__(self): """Initialize the Verisure alarm panel.""" - self._state = STATE_UNKNOWN + self._state = None self._digits = hub.config.get(CONF_CODE_DIGITS) self._changed_by = None diff --git a/homeassistant/components/alarm_control_panel/wink.py b/homeassistant/components/alarm_control_panel/wink.py index 001c6fad85c..b2ae3578133 100644 --- a/homeassistant/components/alarm_control_panel/wink.py +++ b/homeassistant/components/alarm_control_panel/wink.py @@ -9,8 +9,7 @@ import logging import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.wink import DOMAIN, WinkDevice from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_UNKNOWN) + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) _LOGGER = logging.getLogger(__name__) @@ -52,7 +51,7 @@ class WinkCameraDevice(WinkDevice, alarm.AlarmControlPanel): elif wink_state == "night": state = STATE_ALARM_ARMED_HOME else: - state = STATE_UNKNOWN + state = None return state def alarm_disarm(self, code=None): diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py index 3a18281e49b..579a19c1b52 100644 --- a/homeassistant/components/alert.py +++ b/homeassistant/components/alert.py @@ -5,19 +5,19 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/alert/ """ import asyncio -from datetime import datetime, timedelta import logging +from datetime import datetime, timedelta import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_MESSAGE, DOMAIN as DOMAIN_NOTIFY) + ATTR_MESSAGE, ATTR_TITLE, ATTR_DATA, DOMAIN as DOMAIN_NOTIFY) from homeassistant.const import ( CONF_ENTITY_ID, STATE_IDLE, CONF_NAME, CONF_STATE, STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_ENTITY_ID) -from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers import service, event -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) @@ -30,6 +30,8 @@ CONF_REPEAT = 'repeat' CONF_SKIP_FIRST = 'skip_first' CONF_ALERT_MESSAGE = 'message' CONF_DONE_MESSAGE = 'done_message' +CONF_TITLE = 'title' +CONF_DATA = 'data' DEFAULT_CAN_ACK = True DEFAULT_SKIP_FIRST = False @@ -43,13 +45,14 @@ ALERT_SCHEMA = vol.Schema({ vol.Required(CONF_SKIP_FIRST, default=DEFAULT_SKIP_FIRST): cv.boolean, vol.Optional(CONF_ALERT_MESSAGE): cv.template, vol.Optional(CONF_DONE_MESSAGE): cv.template, + vol.Optional(CONF_TITLE): cv.template, + vol.Optional(CONF_DATA): dict, vol.Required(CONF_NOTIFIERS): cv.ensure_list}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: cv.schema_with_slug_keys(ALERT_SCHEMA), }, extra=vol.ALLOW_EXTRA) - ALERT_SERVICE_SCHEMA = vol.Schema({ vol.Required(ATTR_ENTITY_ID): cv.entity_ids, }) @@ -77,12 +80,14 @@ async def async_setup(hass, config): done_message_template = cfg.get(CONF_DONE_MESSAGE) notifiers = cfg.get(CONF_NOTIFIERS) can_ack = cfg.get(CONF_CAN_ACK) + title_template = cfg.get(CONF_TITLE) + data = cfg.get(CONF_DATA) entities.append(Alert(hass, object_id, name, watched_entity_id, alert_state, repeat, skip_first, message_template, done_message_template, notifiers, - can_ack)) + can_ack, title_template, data)) if not entities: return False @@ -127,12 +132,14 @@ class Alert(ToggleEntity): def __init__(self, hass, entity_id, name, watched_entity_id, state, repeat, skip_first, message_template, - done_message_template, notifiers, can_ack): + done_message_template, notifiers, can_ack, title_template, + data): """Initialize the alert.""" self.hass = hass self._name = name self._alert_state = state self._skip_first = skip_first + self._data = data self._message_template = message_template if self._message_template is not None: @@ -142,6 +149,10 @@ class Alert(ToggleEntity): if self._done_message_template is not None: self._done_message_template.hass = hass + self._title_template = title_template + if self._title_template is not None: + self._title_template.hass = hass + self._notifiers = notifiers self._can_ack = can_ack @@ -251,9 +262,20 @@ class Alert(ToggleEntity): await self._send_notification_message(message) async def _send_notification_message(self, message): + + msg_payload = {ATTR_MESSAGE: message} + + if self._title_template is not None: + title = self._title_template.async_render() + msg_payload.update({ATTR_TITLE: title}) + if self._data: + msg_payload.update({ATTR_DATA: self._data}) + + _LOGGER.debug(msg_payload) + for target in self._notifiers: await self.hass.services.async_call( - DOMAIN_NOTIFY, target, {ATTR_MESSAGE: message}) + DOMAIN_NOTIFY, target, msg_payload) async def async_turn_on(self, **kwargs): """Async Unacknowledge alert.""" diff --git a/homeassistant/components/ambient_station/.translations/ca.json b/homeassistant/components/ambient_station/.translations/ca.json new file mode 100644 index 00000000000..d3c451f3e3f --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Clau d'aplicaci\u00f3 i/o clau API ja registrada", + "invalid_key": "Clau API i/o clau d'aplicaci\u00f3 inv\u00e0lida/es", + "no_devices": "No s'ha trobat cap dispositiu al compte" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "app_key": "Clau d'aplicaci\u00f3" + }, + "title": "Introdueix la teva informaci\u00f3" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/en.json b/homeassistant/components/ambient_station/.translations/en.json new file mode 100644 index 00000000000..5bd643da55c --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Application Key and/or API Key already registered", + "invalid_key": "Invalid API Key and/or Application Key", + "no_devices": "No devices found in account" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "app_key": "Application Key" + }, + "title": "Fill in your information" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/ko.json b/homeassistant/components/ambient_station/.translations/ko.json new file mode 100644 index 00000000000..51a09514159 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Application \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_key": "Application \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "no_devices": "\uacc4\uc815\uc5d0 \uae30\uae30\uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "api_key": "API \ud0a4", + "app_key": "Application \ud0a4" + }, + "title": "\uc0ac\uc6a9\uc790 \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/lb.json b/homeassistant/components/ambient_station/.translations/lb.json new file mode 100644 index 00000000000..0f0d60d4458 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/lb.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Applikatioun's Schl\u00ebssel an/oder API Schl\u00ebssel ass scho registr\u00e9iert", + "invalid_key": "Ong\u00ebltegen API Schl\u00ebssel an/oder Applikatioun's Schl\u00ebssel", + "no_devices": "Keng Apparater am Kont fonnt" + }, + "step": { + "user": { + "data": { + "api_key": "API Schl\u00ebssel", + "app_key": "Applikatioun's Schl\u00ebssel" + }, + "title": "F\u00ebllt \u00e4r Informatiounen aus" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/ru.json b/homeassistant/components/ambient_station/.translations/ru.json new file mode 100644 index 00000000000..d1264010b75 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\u041a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 API \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d", + "invalid_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f", + "no_devices": "\u0412 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b" + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "app_key": "\u041a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f" + }, + "title": "Ambient PWS" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/zh-Hant.json b/homeassistant/components/ambient_station/.translations/zh-Hant.json new file mode 100644 index 00000000000..7e3ed3ef888 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u5df2\u8a3b\u518a", + "invalid_key": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u7121\u6548", + "no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u88dd\u7f6e" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "app_key": "\u61c9\u7528\u5bc6\u9470" + }, + "title": "\u586b\u5beb\u8cc7\u8a0a" + } + }, + "title": "\u74b0\u5883 PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py new file mode 100644 index 00000000000..0991336f42a --- /dev/null +++ b/homeassistant/components/ambient_station/__init__.py @@ -0,0 +1,205 @@ +""" +Support for Ambient Weather Station Service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ambient_station/ +""" +import logging + +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + ATTR_NAME, ATTR_LOCATION, CONF_API_KEY, CONF_MONITORED_CONDITIONS, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later + +from .config_flow import configured_instances +from .const import ( + ATTR_LAST_DATA, CONF_APP_KEY, DATA_CLIENT, DOMAIN, TOPIC_UPDATE) + +REQUIREMENTS = ['aioambient==0.1.0'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SOCKET_MIN_RETRY = 15 + +SENSOR_TYPES = { + '24hourrainin': ('24 Hr Rain', 'in'), + 'baromabsin': ('Abs Pressure', 'inHg'), + 'baromrelin': ('Rel Pressure', 'inHg'), + 'battout': ('Battery', ''), + 'co2': ('co2', 'ppm'), + 'dailyrainin': ('Daily Rain', 'in'), + 'dewPoint': ('Dew Point', '°F'), + 'eventrainin': ('Event Rain', 'in'), + 'feelsLike': ('Feels Like', '°F'), + 'hourlyrainin': ('Hourly Rain Rate', 'in/hr'), + 'humidity': ('Humidity', '%'), + 'humidityin': ('Humidity In', '%'), + 'lastRain': ('Last Rain', ''), + 'maxdailygust': ('Max Gust', 'mph'), + 'monthlyrainin': ('Monthly Rain', 'in'), + 'solarradiation': ('Solar Rad', 'W/m^2'), + 'tempf': ('Temp', '°F'), + 'tempinf': ('Inside Temp', '°F'), + 'totalrainin': ('Lifetime Rain', 'in'), + 'uv': ('uv', 'Index'), + 'weeklyrainin': ('Weekly Rain', 'in'), + 'winddir': ('Wind Dir', '°'), + 'winddir_avg10m': ('Wind Dir Avg 10m', '°'), + 'winddir_avg2m': ('Wind Dir Avg 2m', 'mph'), + 'windgustdir': ('Gust Dir', '°'), + 'windgustmph': ('Wind Gust', 'mph'), + 'windspdmph_avg10m': ('Wind Avg 10m', 'mph'), + 'windspdmph_avg2m': ('Wind Avg 2m', 'mph'), + 'windspeedmph': ('Wind Speed', 'mph'), + 'yearlyrainin': ('Yearly Rain', 'in'), +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: + vol.Schema({ + vol.Required(CONF_APP_KEY): + cv.string, + vol.Required(CONF_API_KEY): + cv.string, + vol.Optional( + CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Ambient PWS component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + if conf[CONF_APP_KEY] in configured_instances(hass): + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={'source': SOURCE_IMPORT}, data=conf)) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the Ambient PWS as config entry.""" + from aioambient import Client + from aioambient.errors import WebsocketConnectionError + + session = aiohttp_client.async_get_clientsession(hass) + + try: + ambient = AmbientStation( + hass, + config_entry, + Client( + config_entry.data[CONF_API_KEY], + config_entry.data[CONF_APP_KEY], session), + config_entry.data.get( + CONF_MONITORED_CONDITIONS, list(SENSOR_TYPES))) + hass.loop.create_task(ambient.ws_connect()) + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = ambient + except WebsocketConnectionError as err: + _LOGGER.error('Config entry failed: %s', err) + raise ConfigEntryNotReady + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, ambient.client.websocket.disconnect()) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an Ambient PWS config entry.""" + ambient = hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + hass.async_create_task(ambient.ws_disconnect()) + + await hass.config_entries.async_forward_entry_unload( + config_entry, 'sensor') + + return True + + +class AmbientStation: + """Define a class to handle the Ambient websocket.""" + + def __init__(self, hass, config_entry, client, monitored_conditions): + """Initialize.""" + self._config_entry = config_entry + self._hass = hass + self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + self.client = client + self.monitored_conditions = monitored_conditions + self.stations = {} + + async def ws_connect(self): + """Register handlers and connect to the websocket.""" + from aioambient.errors import WebsocketError + + def on_connect(): + """Define a handler to fire when the websocket is connected.""" + _LOGGER.info('Connected to websocket') + + def on_data(data): + """Define a handler to fire when the data is received.""" + mac_address = data['macAddress'] + if data != self.stations[mac_address][ATTR_LAST_DATA]: + _LOGGER.debug('New data received: %s', data) + self.stations[mac_address][ATTR_LAST_DATA] = data + async_dispatcher_send(self._hass, TOPIC_UPDATE) + + def on_disconnect(): + """Define a handler to fire when the websocket is disconnected.""" + _LOGGER.info('Disconnected from websocket') + + def on_subscribed(data): + """Define a handler to fire when the subscription is set.""" + for station in data['devices']: + if station['macAddress'] in self.stations: + continue + + _LOGGER.debug('New station subscription: %s', data) + + self.stations[station['macAddress']] = { + ATTR_LAST_DATA: station['lastData'], + ATTR_LOCATION: station['info']['location'], + ATTR_NAME: station['info']['name'], + } + + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, 'sensor')) + + self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + + self.client.websocket.on_connect(on_connect) + self.client.websocket.on_data(on_data) + self.client.websocket.on_disconnect(on_disconnect) + self.client.websocket.on_subscribed(on_subscribed) + + try: + await self.client.websocket.connect() + except WebsocketError as err: + _LOGGER.error("Error with the websocket connection: %s", err) + + self._ws_reconnect_delay = min( + 2 * self._ws_reconnect_delay, 480) + + async_call_later( + self._hass, self._ws_reconnect_delay, self.ws_connect) + + async def ws_disconnect(self): + """Disconnect from the websocket.""" + await self.client.websocket.disconnect() diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py new file mode 100644 index 00000000000..56e747ce5e0 --- /dev/null +++ b/homeassistant/components/ambient_station/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow to configure the Ambient PWS component.""" + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .const import CONF_APP_KEY, DOMAIN + + +@callback +def configured_instances(hass): + """Return a set of configured Ambient PWS instances.""" + return set( + entry.data[CONF_APP_KEY] + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class AmbientStationFlowHandler(config_entries.ConfigFlow): + """Handle an Ambient PWS config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + async def _show_form(self, errors=None): + """Show the form to the user.""" + data_schema = vol.Schema({ + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_APP_KEY): str, + }) + + return self.async_show_form( + step_id='user', + data_schema=data_schema, + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + from aioambient import Client + from aioambient.errors import AmbientError + + if not user_input: + return await self._show_form() + + if user_input[CONF_APP_KEY] in configured_instances(self.hass): + return await self._show_form({CONF_APP_KEY: 'identifier_exists'}) + + session = aiohttp_client.async_get_clientsession(self.hass) + client = Client( + user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session) + + try: + devices = await client.api.get_devices() + except AmbientError: + return await self._show_form({'base': 'invalid_key'}) + + if not devices: + return await self._show_form({'base': 'no_devices'}) + + # The Application Key (which identifies each config entry) is too long + # to show nicely in the UI, so we take the first 12 characters (similar + # to how GitHub does it): + return self.async_create_entry( + title=user_input[CONF_APP_KEY][:12], data=user_input) diff --git a/homeassistant/components/ambient_station/const.py b/homeassistant/components/ambient_station/const.py new file mode 100644 index 00000000000..75606a1c699 --- /dev/null +++ b/homeassistant/components/ambient_station/const.py @@ -0,0 +1,10 @@ +"""Define constants for the Ambient PWS component.""" +DOMAIN = 'ambient_station' + +ATTR_LAST_DATA = 'last_data' + +CONF_APP_KEY = 'app_key' + +DATA_CLIENT = 'data_client' + +TOPIC_UPDATE = 'update' diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py new file mode 100644 index 00000000000..9e0833e3441 --- /dev/null +++ b/homeassistant/components/ambient_station/sensor.py @@ -0,0 +1,102 @@ +""" +Support for Ambient Weather Station Service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.ambient_station/ +""" +import logging + +from homeassistant.components.ambient_station import SENSOR_TYPES +from homeassistant.helpers.entity import Entity +from homeassistant.const import ATTR_NAME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN, TOPIC_UPDATE + +DEPENDENCIES = ['ambient_station'] +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up an Ambient PWS sensor based on existing config.""" + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up an Ambient PWS sensor based on a config entry.""" + ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + + sensor_list = [] + for mac_address, station in ambient.stations.items(): + for condition in ambient.monitored_conditions: + name, unit = SENSOR_TYPES[condition] + sensor_list.append( + AmbientWeatherSensor( + ambient, mac_address, station[ATTR_NAME], condition, name, + unit)) + + async_add_entities(sensor_list, True) + + +class AmbientWeatherSensor(Entity): + """Define an Ambient sensor.""" + + def __init__( + self, ambient, mac_address, station_name, sensor_type, sensor_name, + unit): + """Initialize the sensor.""" + self._ambient = ambient + self._async_unsub_dispatcher_connect = None + self._mac_address = mac_address + self._sensor_name = sensor_name + self._sensor_type = sensor_type + self._state = None + self._station_name = station_name + self._unit = unit + + @property + def name(self): + """Return the name of the sensor.""" + return '{0}_{1}'.format(self._station_name, self._sensor_name) + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def unique_id(self): + """Return a unique, unchanging string that represents this sensor.""" + return '{0}_{1}'.format(self._mac_address, self._sensor_name) + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + + async def async_update(self): + """Fetch new state data for the sensor.""" + self._state = self._ambient.stations[ + self._mac_address][ATTR_LAST_DATA].get(self._sensor_type) diff --git a/homeassistant/components/ambient_station/strings.json b/homeassistant/components/ambient_station/strings.json new file mode 100644 index 00000000000..657b3477bb2 --- /dev/null +++ b/homeassistant/components/ambient_station/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "Ambient PWS", + "step": { + "user": { + "title": "Fill in your information", + "data": { + "api_key": "API Key", + "app_key": "Application Key" + } + } + }, + "error": { + "identifier_exists": "Application Key and/or API Key already registered", + "invalid_key": "Invalid API Key and/or Application Key", + "no_devices": "No devices found in account" + } + } +} diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py index b8a2d461489..1cf46174371 100644 --- a/homeassistant/components/android_ip_webcam.py +++ b/homeassistant/components/android_ip_webcam.py @@ -123,8 +123,9 @@ ICON_MAP = { 'whitebalance_lock': 'mdi:white-balance-auto' } -SWITCHES = ['exposure_lock', 'ffc', 'focus', 'gps_active', 'night_vision', - 'overlay', 'torch', 'whitebalance_lock', 'video_recording'] +SWITCHES = ['exposure_lock', 'ffc', 'focus', 'gps_active', + 'motion_detect', 'night_vision', 'overlay', + 'torch', 'whitebalance_lock', 'video_recording'] SENSORS = ['audio_connections', 'battery_level', 'battery_temp', 'battery_voltage', 'light', 'motion', 'pressure', 'proximity', diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py index 898485b5cb3..0069b3c0d73 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -14,7 +14,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['aioasuswrt==1.1.18'] +REQUIREMENTS = ['aioasuswrt==1.1.20'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/auth/.translations/pl.json b/homeassistant/components/auth/.translations/pl.json index 6adaaa019c5..f0e9f7b71ea 100644 --- a/homeassistant/components/auth/.translations/pl.json +++ b/homeassistant/components/auth/.translations/pl.json @@ -9,7 +9,7 @@ }, "step": { "init": { - "description": "Prosz\u0119 wybra\u0107 jedn\u0105 z us\u0142ugi powiadamiania:", + "description": "Prosz\u0119 wybra\u0107 jedn\u0105 us\u0142ug\u0119 powiadamiania:", "title": "Skonfiguruj has\u0142o jednorazowe dostarczone przez komponent powiadomie\u0144" }, "setup": { diff --git a/homeassistant/components/auth/.translations/sl.json b/homeassistant/components/auth/.translations/sl.json index 223dc91a480..f70bb81e700 100644 --- a/homeassistant/components/auth/.translations/sl.json +++ b/homeassistant/components/auth/.translations/sl.json @@ -21,7 +21,7 @@ }, "totp": { "error": { - "invalid_code": "Neveljavna koda, prosimo, poskusite znova. \u010ce dobite to sporo\u010dilo ve\u010dkrat, prosimo poskrbite, da bo ura va\u0161ega Home Assistenta to\u010dna." + "invalid_code": "Neveljavna koda, prosimo, poskusite znova. \u010ce dobite to sporo\u010dilo ve\u010dkrat, prosimo poskrbite, da bo ura va\u0161ega Home Assistanta to\u010dna." }, "step": { "init": { diff --git a/homeassistant/components/auth/.translations/uk.json b/homeassistant/components/auth/.translations/uk.json index 3d4d9a5b151..f826075078e 100644 --- a/homeassistant/components/auth/.translations/uk.json +++ b/homeassistant/components/auth/.translations/uk.json @@ -3,6 +3,11 @@ "notify": { "error": { "invalid_code": "\u041d\u0435\u0432\u0456\u0440\u043d\u0438\u0439 \u043a\u043e\u0434, \u0441\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0449\u0435 \u0440\u0430\u0437." + }, + "step": { + "setup": { + "title": "\u041f\u0435\u0440\u0435\u0432\u0456\u0440\u0442\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f" + } } } } diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 67c538154e5..1fa0d540610 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -15,19 +15,23 @@ import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['mqtt'] +CONF_ENCODING = 'encoding' CONF_TOPIC = 'topic' +DEFAULT_ENCODING = 'utf-8' TRIGGER_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): mqtt.DOMAIN, vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_PAYLOAD): cv.string, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, }) async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - topic = config.get(CONF_TOPIC) + topic = config[CONF_TOPIC] payload = config.get(CONF_PAYLOAD) + encoding = config[CONF_ENCODING] or None @callback def mqtt_automation_listener(msg_topic, msg_payload, qos): @@ -50,5 +54,5 @@ async def async_trigger(hass, config, action, automation_info): }) remove = await mqtt.async_subscribe( - hass, topic, mqtt_automation_listener) + hass, topic, mqtt_automation_listener, encoding=encoding) return remove diff --git a/homeassistant/components/binary_sensor/homematicip_cloud.py b/homeassistant/components/binary_sensor/homematicip_cloud.py index aba979b94f2..910666f93cb 100644 --- a/homeassistant/components/binary_sensor/homematicip_cloud.py +++ b/homeassistant/components/binary_sensor/homematicip_cloud.py @@ -15,8 +15,6 @@ DEPENDENCIES = ['homematicip_cloud'] _LOGGER = logging.getLogger(__name__) -STATE_SMOKE_OFF = 'IDLE_OFF' - async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): @@ -65,7 +63,7 @@ class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice): return True if self._device.windowState is None: return None - return self._device.windowState == WindowState.OPEN + return self._device.windowState != WindowState.CLOSED class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice): @@ -95,7 +93,9 @@ class HomematicipSmokeDetector(HomematicipGenericDevice, BinarySensorDevice): @property def is_on(self): """Return true if smoke is detected.""" - return self._device.smokeDetectorAlarmType != STATE_SMOKE_OFF + from homematicip.base.enums import SmokeDetectorAlarmType + return (self._device.smokeDetectorAlarmType + != SmokeDetectorAlarmType.IDLE_OFF) class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/maxcube.py b/homeassistant/components/binary_sensor/maxcube.py index 6bb9278d8d5..850a416acc5 100644 --- a/homeassistant/components/binary_sensor/maxcube.py +++ b/homeassistant/components/binary_sensor/maxcube.py @@ -8,7 +8,6 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.maxcube import DATA_KEY -from homeassistant.const import STATE_UNKNOWN _LOGGER = logging.getLogger(__name__) @@ -40,7 +39,7 @@ class MaxCubeShutter(BinarySensorDevice): self._sensor_type = 'window' self._rf_address = rf_address self._cubehandle = handler - self._state = STATE_UNKNOWN + self._state = None @property def should_poll(self): diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 8f3ff5d798e..494c3154b84 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import async_track_state_change from homeassistant.util import utcnow -REQUIREMENTS = ['numpy==1.15.4'] +REQUIREMENTS = ['numpy==1.16.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 57500fcc8a6..82815d11a6e 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) -REQUIREMENTS = ['blinkpy==0.11.2'] +REQUIREMENTS = ['blinkpy==0.12.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/camera/amcrest.py b/homeassistant/components/camera/amcrest.py index 4ba527b4805..3b3368c2f5c 100644 --- a/homeassistant/components/camera/amcrest.py +++ b/homeassistant/components/camera/amcrest.py @@ -82,7 +82,7 @@ class AmcrestCam(Camera): try: return await async_aiohttp_proxy_stream( self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') + self._ffmpeg.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index d56616218e7..7857995b4af 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -104,7 +104,7 @@ class ArloCam(Camera): try: return await async_aiohttp_proxy_stream( self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') + self._ffmpeg.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/camera/canary.py b/homeassistant/components/camera/canary.py index 7a83e2da4d1..eb0c8f3fc6d 100644 --- a/homeassistant/components/camera/canary.py +++ b/homeassistant/components/camera/canary.py @@ -101,7 +101,7 @@ class CanaryCamera(Camera): try: return await async_aiohttp_proxy_stream( self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') + self._ffmpeg.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 6bd68b05bb5..db9e73f3e1b 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -68,7 +68,7 @@ class FFmpegCamera(Camera): try: return await async_aiohttp_proxy_stream( self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') + self._manager.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index d1afd39ca7b..da0bae7c50b 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -213,7 +213,8 @@ class ONVIFHassCamera(Camera): if not self._input: return None - stream = CameraMjpeg(self.hass.data[DATA_FFMPEG].binary, + ffmpeg_manager = self.hass.data[DATA_FFMPEG] + stream = CameraMjpeg(ffmpeg_manager.binary, loop=self.hass.loop) await stream.open_camera( self._input, extra_cmd=self._ffmpeg_arguments) @@ -221,7 +222,7 @@ class ONVIFHassCamera(Camera): try: return await async_aiohttp_proxy_stream( self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') + ffmpeg_manager.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/camera/ring.py b/homeassistant/components/camera/ring.py index ad351fb59cf..da1119281b3 100644 --- a/homeassistant/components/camera/ring.py +++ b/homeassistant/components/camera/ring.py @@ -142,7 +142,7 @@ class RingCam(Camera): try: return await async_aiohttp_proxy_stream( self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') + self._ffmpeg.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/camera/xiaomi.py b/homeassistant/components/camera/xiaomi.py index 207dd17ed9b..93e9dd4a07c 100644 --- a/homeassistant/components/camera/xiaomi.py +++ b/homeassistant/components/camera/xiaomi.py @@ -161,6 +161,6 @@ class XiaomiCamera(Camera): try: return await async_aiohttp_proxy_stream( self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') + self._manager.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/camera/yi.py b/homeassistant/components/camera/yi.py index 8b5b865ee57..7d731d2a433 100644 --- a/homeassistant/components/camera/yi.py +++ b/homeassistant/components/camera/yi.py @@ -147,6 +147,6 @@ class YiCamera(Camera): try: return await async_aiohttp_proxy_stream( self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') + self._manager.ffmpeg_stream_content_type) finally: await stream.close() diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index d116a885319..6d7f9432e39 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_ON, SERVICE_TURN_OFF, - STATE_ON, STATE_OFF, STATE_UNKNOWN, TEMP_CELSIUS, PRECISION_WHOLE, + STATE_ON, STATE_OFF, TEMP_CELSIUS, PRECISION_WHOLE, PRECISION_TENTHS) DEFAULT_MIN_TEMP = 7 @@ -208,7 +208,7 @@ class ClimateDevice(Entity): return self.current_operation if self.is_on: return STATE_ON - return STATE_UNKNOWN + return None @property def precision(self): diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index afd655bed22..b735927cb80 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,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.3.3'] +REQUIREMENTS = ['millheater==0.3.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index e580476e56a..bd6bb2991cc 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -18,7 +18,7 @@ from homeassistant.components.climate import ( SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE) from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, - CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF, STATE_UNKNOWN) + CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF) from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['nest'] @@ -163,7 +163,7 @@ class NestThermostat(ClimateDevice): return self._mode if self._mode == NEST_MODE_HEAT_COOL: return STATE_AUTO - return STATE_UNKNOWN + return None @property def target_temperature(self): diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py index f0423d32c96..a72bf711242 100644 --- a/homeassistant/components/climate/radiotherm.py +++ b/homeassistant/components/climate/radiotherm.py @@ -219,6 +219,11 @@ class RadioThermostat(ClimateDevice): """Return true if away mode is on.""" return self._away + @property + def is_on(self): + """Return true if on.""" + return self._tstate != STATE_IDLE + def update(self): """Update and validate the data from the thermostat.""" # Radio thermostats are very slow, and sometimes don't respond diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index d938dd20e67..98e649e1742 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -106,6 +106,7 @@ async def async_setup(hass, config): ) cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) + await auth_api.async_setup(hass, cloud) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, cloud.async_start) await http_api.async_setup(hass) return True @@ -263,7 +264,7 @@ class Cloud: self.access_token = info['access_token'] self.refresh_token = info['refresh_token'] - self.hass.add_job(self.iot.connect()) + self.hass.async_create_task(self.iot.connect()) def _decode_claims(self, token): # pylint: disable=no-self-use """Decode the claims in a token.""" diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 954d28b803f..6019dac87b9 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -1,4 +1,10 @@ """Package to communicate with the authentication API.""" +import asyncio +import logging +import random + + +_LOGGER = logging.getLogger(__name__) class CloudError(Exception): @@ -39,6 +45,40 @@ AWS_EXCEPTIONS = { } +async def async_setup(hass, cloud): + """Configure the auth api.""" + refresh_task = None + + async def handle_token_refresh(): + """Handle Cloud access token refresh.""" + sleep_time = 5 + sleep_time = random.randint(2400, 3600) + while True: + try: + await asyncio.sleep(sleep_time) + await hass.async_add_executor_job(renew_access_token, cloud) + except CloudError as err: + _LOGGER.error("Can't refresh cloud token: %s", err) + except asyncio.CancelledError: + # Task is canceled, stop it. + break + + sleep_time = random.randint(3100, 3600) + + async def on_connect(): + """When the instance is connected.""" + nonlocal refresh_task + refresh_task = hass.async_create_task(handle_token_refresh()) + + async def on_disconnect(): + """When the instance is disconnected.""" + nonlocal refresh_task + refresh_task.cancel() + + cloud.iot.register_on_connect(on_connect) + cloud.iot.register_on_disconnect(on_disconnect) + + def _map_aws_exception(err): """Map AWS exception to our exceptions.""" ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError) @@ -47,7 +87,7 @@ def _map_aws_exception(err): def register(cloud, email, password): """Register a new account.""" - from botocore.exceptions import ClientError + from botocore.exceptions import ClientError, EndpointConnectionError cognito = _cognito(cloud) # Workaround for bug in Warrant. PR with fix: @@ -55,13 +95,16 @@ def register(cloud, email, password): cognito.add_base_attributes() try: cognito.register(email, password) + except ClientError as err: raise _map_aws_exception(err) + except EndpointConnectionError: + raise UnknownError() def resend_email_confirm(cloud, email): """Resend email confirmation.""" - from botocore.exceptions import ClientError + from botocore.exceptions import ClientError, EndpointConnectionError cognito = _cognito(cloud, username=email) @@ -72,18 +115,23 @@ def resend_email_confirm(cloud, email): ) except ClientError as err: raise _map_aws_exception(err) + except EndpointConnectionError: + raise UnknownError() def forgot_password(cloud, email): """Initialize forgotten password flow.""" - from botocore.exceptions import ClientError + from botocore.exceptions import ClientError, EndpointConnectionError cognito = _cognito(cloud, username=email) try: cognito.initiate_forgot_password() + except ClientError as err: raise _map_aws_exception(err) + except EndpointConnectionError: + raise UnknownError() def login(cloud, email, password): @@ -97,7 +145,7 @@ def login(cloud, email, password): def check_token(cloud): """Check that the token is valid and verify if needed.""" - from botocore.exceptions import ClientError + from botocore.exceptions import ClientError, EndpointConnectionError cognito = _cognito( cloud, @@ -109,13 +157,17 @@ def check_token(cloud): 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) + except EndpointConnectionError: + raise UnknownError() + def renew_access_token(cloud): """Renew access token.""" - from botocore.exceptions import ClientError + from botocore.exceptions import ClientError, EndpointConnectionError cognito = _cognito( cloud, @@ -127,13 +179,17 @@ def renew_access_token(cloud): 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) + except EndpointConnectionError: + raise UnknownError() + def _authenticate(cloud, email, password): """Log in and return an authenticated Cognito instance.""" - from botocore.exceptions import ClientError + from botocore.exceptions import ClientError, EndpointConnectionError from warrant.exceptions import ForceChangePasswordException assert not cloud.is_logged_in, 'Cannot login if already logged in.' @@ -145,11 +201,14 @@ def _authenticate(cloud, email, password): return cognito except ForceChangePasswordException: - raise PasswordChangeRequired + raise PasswordChangeRequired() except ClientError as err: raise _map_aws_exception(err) + except EndpointConnectionError: + raise UnknownError() + def _cognito(cloud, **kwargs): """Get the client credentials.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 03a77c08d4b..a2825eb6d7b 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -107,13 +107,16 @@ def _handle_cloud_errors(handler): result = await handler(view, request, *args, **kwargs) return result - except (auth_api.CloudError, asyncio.TimeoutError) as err: + except Exception as err: # pylint: disable=broad-except err_info = _CLOUD_ERRORS.get(err.__class__) if err_info is None: + _LOGGER.exception( + "Unexpected error processing request for %s", request.path) err_info = (502, 'Unexpected error: {}'.format(err)) status, msg = err_info - return view.json_message(msg, status_code=status, - message_code=err.__class__.__name__) + return view.json_message( + msg, status_code=status, + message_code=err.__class__.__name__.lower()) return error_handler diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index ed24fe48d40..055c4dbaa64 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -12,9 +12,10 @@ from homeassistant.components.alexa import smart_home as alexa from homeassistant.components.google_assistant import smart_home as ga from homeassistant.core import callback from homeassistant.util.decorator import Registry -from homeassistant.util.aiohttp import MockRequest, serialize_response +from homeassistant.util.aiohttp import MockRequest from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import auth_api +from . import utils from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL HANDLERS = Registry() @@ -61,12 +62,18 @@ class CloudIoT: # Local code waiting for a response self._response_handler = {} self._on_connect = [] + self._on_disconnect = [] @callback def register_on_connect(self, on_connect_cb): """Register an async on_connect callback.""" self._on_connect.append(on_connect_cb) + @callback + def register_on_disconnect(self, on_disconnect_cb): + """Register an async on_disconnect callback.""" + self._on_disconnect.append(on_disconnect_cb) + @property def connected(self): """Return if we're currently connected.""" @@ -101,6 +108,17 @@ class CloudIoT: # Still adding it here to make sure we can always reconnect _LOGGER.exception("Unexpected error") + if self.state == STATE_CONNECTED and self._on_disconnect: + try: + yield from asyncio.wait([ + cb() for cb in self._on_disconnect + ]) + except Exception: # pylint: disable=broad-except + # Safety net. This should never hit. + # Still adding it here to make sure we don't break the flow + _LOGGER.exception( + "Unexpected error in on_disconnect callbacks") + if self.close_requested: break @@ -191,7 +209,13 @@ class CloudIoT: self.state = STATE_CONNECTED if self._on_connect: - yield from asyncio.wait([cb() for cb in self._on_connect]) + try: + yield from asyncio.wait([cb() for cb in self._on_connect]) + except Exception: # pylint: disable=broad-except + # Safety net. This should never hit. + # Still adding it here to make sure we don't break the flow + _LOGGER.exception( + "Unexpected error in on_connect callbacks") while not client.closed: msg = yield from client.receive() @@ -325,11 +349,6 @@ async def async_handle_cloud(hass, cloud, payload): await cloud.logout() _LOGGER.error("You have been logged out from Home Assistant cloud: %s", payload['reason']) - elif action == 'refresh_auth': - # Refresh the auth token between now and payload['seconds'] - hass.helpers.event.async_call_later( - random.randint(0, payload['seconds']), - lambda now: auth_api.check_token(cloud)) else: _LOGGER.warning("Received unknown cloud action: %s", action) @@ -360,10 +379,8 @@ async def async_handle_webhook(hass, cloud, payload): response = await hass.components.webhook.async_handle_webhook( found['webhook_id'], request) - response_dict = serialize_response(response) + response_dict = utils.aiohttp_serialize_response(response) body = response_dict.get('body') - if body: - body = body.decode('utf-8') return { 'body': body, diff --git a/homeassistant/components/cloud/utils.py b/homeassistant/components/cloud/utils.py new file mode 100644 index 00000000000..da1d3809989 --- /dev/null +++ b/homeassistant/components/cloud/utils.py @@ -0,0 +1,13 @@ +"""Helper functions for cloud components.""" +from typing import Any, Dict + +from aiohttp import web + + +def aiohttp_serialize_response(response: web.Response) -> Dict[str, Any]: + """Serialize an aiohttp response to a dictionary.""" + return { + 'status': response.status, + 'body': response.text, + 'headers': dict(response.headers), + } diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 4154ca337a3..65a4d50be84 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,6 +14,7 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] SECTIONS = ( + 'area_registry', 'auth', 'auth_provider_homeassistant', 'automation', diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py new file mode 100644 index 00000000000..7f1bb938228 --- /dev/null +++ b/homeassistant/components/config/area_registry.py @@ -0,0 +1,126 @@ +"""HTTP views to interact with the area registry.""" +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.decorators import ( + async_response, require_admin) +from homeassistant.core import callback +from homeassistant.helpers.area_registry import async_get_registry + + +DEPENDENCIES = ['websocket_api'] + +WS_TYPE_LIST = 'config/area_registry/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + +WS_TYPE_CREATE = 'config/area_registry/create' +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CREATE, + vol.Required('name'): str, +}) + +WS_TYPE_DELETE = 'config/area_registry/delete' +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE, + vol.Required('area_id'): str, +}) + +WS_TYPE_UPDATE = 'config/area_registry/update' +SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_UPDATE, + vol.Required('area_id'): str, + vol.Required('name'): str, +}) + + +async def async_setup(hass): + """Enable the Area Registry views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list_areas, SCHEMA_WS_LIST + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create_area, SCHEMA_WS_CREATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete_area, SCHEMA_WS_DELETE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE, websocket_update_area, SCHEMA_WS_UPDATE + ) + return True + + +@async_response +async def websocket_list_areas(hass, connection, msg): + """Handle list areas command.""" + registry = await async_get_registry(hass) + connection.send_message(websocket_api.result_message( + msg['id'], [{ + 'name': entry.name, + 'area_id': entry.id, + } for entry in registry.async_list_areas()] + )) + + +@require_admin +@async_response +async def websocket_create_area(hass, connection, msg): + """Create area command.""" + registry = await async_get_registry(hass) + try: + entry = registry.async_create(msg['name']) + except ValueError as err: + connection.send_message(websocket_api.error_message( + msg['id'], 'invalid_info', str(err) + )) + else: + connection.send_message(websocket_api.result_message( + msg['id'], _entry_dict(entry) + )) + + +@require_admin +@async_response +async def websocket_delete_area(hass, connection, msg): + """Delete area command.""" + registry = await async_get_registry(hass) + + try: + await registry.async_delete(msg['area_id']) + except KeyError: + connection.send_message(websocket_api.error_message( + msg['id'], 'invalid_info', "Area ID doesn't exist" + )) + else: + connection.send_message(websocket_api.result_message( + msg['id'], 'success' + )) + + +@require_admin +@async_response +async def websocket_update_area(hass, connection, msg): + """Handle update area websocket command.""" + registry = await async_get_registry(hass) + + try: + entry = registry.async_update(msg['area_id'], msg['name']) + except ValueError as err: + connection.send_message(websocket_api.error_message( + msg['id'], 'invalid_info', str(err) + )) + else: + connection.send_message(websocket_api.result_message( + msg['id'], _entry_dict(entry) + )) + + +@callback +def _entry_dict(entry): + """Convert entry to API format.""" + return { + 'area_id': entry.id, + 'name': entry.name + } diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index ecbac703296..0677531242a 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -1,8 +1,11 @@ """HTTP views to interact with the device registry.""" import voluptuous as vol -from homeassistant.helpers.device_registry import async_get_registry from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.decorators import ( + async_response, require_admin) +from homeassistant.core import callback +from homeassistant.helpers.device_registry import async_get_registry DEPENDENCIES = ['websocket_api'] @@ -11,29 +14,60 @@ SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_LIST, }) +WS_TYPE_UPDATE = 'config/device_registry/update' +SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_UPDATE, + vol.Required('device_id'): str, + vol.Optional('area_id'): vol.Any(str, None), +}) + async def async_setup(hass): - """Enable the Entity Registry views.""" + """Enable the Device Registry views.""" hass.components.websocket_api.async_register_command( WS_TYPE_LIST, websocket_list_devices, SCHEMA_WS_LIST ) + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE, websocket_update_device, SCHEMA_WS_UPDATE + ) return True -@websocket_api.async_response +@async_response async def websocket_list_devices(hass, connection, msg): """Handle list devices command.""" registry = await async_get_registry(hass) connection.send_message(websocket_api.result_message( - msg['id'], [{ - 'config_entries': list(entry.config_entries), - 'connections': list(entry.connections), - 'manufacturer': entry.manufacturer, - 'model': entry.model, - 'name': entry.name, - 'sw_version': entry.sw_version, - 'id': entry.id, - 'hub_device_id': entry.hub_device_id, - } for entry in registry.devices.values()] + msg['id'], [_entry_dict(entry) for entry in registry.devices.values()] )) + + +@require_admin +@async_response +async def websocket_update_device(hass, connection, msg): + """Handle update area websocket command.""" + registry = await async_get_registry(hass) + + entry = registry.async_update_device( + msg['device_id'], area_id=msg['area_id']) + + connection.send_message(websocket_api.result_message( + msg['id'], _entry_dict(entry) + )) + + +@callback +def _entry_dict(entry): + """Convert entry to API format.""" + return { + 'config_entries': list(entry.config_entries), + 'connections': list(entry.connections), + 'manufacturer': entry.manufacturer, + 'model': entry.model, + 'name': entry.name, + 'sw_version': entry.sw_version, + 'id': entry.id, + 'hub_device_id': entry.hub_device_id, + 'area_id': entry.area_id, + } diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index fdac1ad95da..39dd622540d 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -5,7 +5,8 @@ from homeassistant.core import callback from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.components import websocket_api from homeassistant.components.websocket_api.const import ERR_NOT_FOUND -from homeassistant.components.websocket_api.decorators import async_response +from homeassistant.components.websocket_api.decorators import ( + async_response, require_admin) from homeassistant.helpers import config_validation as cv DEPENDENCIES = ['websocket_api'] @@ -30,6 +31,12 @@ SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Optional('new_entity_id'): str, }) +WS_TYPE_REMOVE = 'config/entity_registry/remove' +SCHEMA_WS_REMOVE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_REMOVE, + vol.Required('entity_id'): cv.entity_id +}) + async def async_setup(hass): """Enable the Entity Registry views.""" @@ -45,6 +52,10 @@ async def async_setup(hass): WS_TYPE_UPDATE, websocket_update_entity, SCHEMA_WS_UPDATE ) + hass.components.websocket_api.async_register_command( + WS_TYPE_REMOVE, websocket_remove_entity, + SCHEMA_WS_REMOVE + ) return True @@ -56,14 +67,7 @@ async def websocket_list_entities(hass, connection, msg): """ registry = await async_get_registry(hass) connection.send_message(websocket_api.result_message( - msg['id'], [{ - 'config_entry_id': entry.config_entry_id, - 'device_id': entry.device_id, - 'disabled_by': entry.disabled_by, - 'entity_id': entry.entity_id, - 'name': entry.name, - 'platform': entry.platform, - } for entry in registry.entities.values()] + msg['id'], [_entry_dict(entry) for entry in registry.entities.values()] )) @@ -86,6 +90,7 @@ async def websocket_get_entity(hass, connection, msg): )) +@require_admin @async_response async def websocket_update_entity(hass, connection, msg): """Handle update entity websocket command. @@ -125,10 +130,32 @@ async def websocket_update_entity(hass, connection, msg): )) +@require_admin +@async_response +async def websocket_remove_entity(hass, connection, msg): + """Handle remove entity websocket command. + + Async friendly. + """ + registry = await async_get_registry(hass) + + if msg['entity_id'] not in registry.entities: + connection.send_message(websocket_api.error_message( + msg['id'], ERR_NOT_FOUND, 'Entity not found')) + return + + registry.async_remove(msg['entity_id']) + connection.send_message(websocket_api.result_message(msg['id'])) + + @callback def _entry_dict(entry): """Convert entry to API format.""" return { + 'config_entry_id': entry.config_entry_id, + 'device_id': entry.device_id, + 'disabled_by': entry.disabled_by, 'entity_id': entry.entity_id, - 'name': entry.name + 'name': entry.name, + 'platform': entry.platform, } diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index ef8fcc42302..b5b2a91b097 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_CLOSE_COVER_TILT, SERVICE_STOP_COVER_TILT, SERVICE_SET_COVER_TILT_POSITION, STATE_OPEN, - STATE_CLOSED, STATE_UNKNOWN, STATE_OPENING, STATE_CLOSING, ATTR_ENTITY_ID) + STATE_CLOSED, STATE_OPENING, STATE_CLOSING, ATTR_ENTITY_ID) _LOGGER = logging.getLogger(__name__) @@ -178,7 +178,7 @@ class CoverDevice(Entity): closed = self.is_closed if closed is None: - return STATE_UNKNOWN + return None return STATE_CLOSED if closed else STATE_OPEN diff --git a/homeassistant/components/cover/garadget.py b/homeassistant/components/cover/garadget.py index 28be3dc6b82..426afc6d314 100644 --- a/homeassistant/components/cover/garadget.py +++ b/homeassistant/components/cover/garadget.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA from homeassistant.helpers.event import track_utc_time_change from homeassistant.const import ( CONF_DEVICE, CONF_USERNAME, CONF_PASSWORD, CONF_ACCESS_TOKEN, CONF_NAME, - STATE_UNKNOWN, STATE_CLOSED, STATE_OPEN, CONF_COVERS) + STATE_CLOSED, STATE_OPEN, CONF_COVERS) _LOGGER = logging.getLogger(__name__) @@ -83,7 +83,7 @@ class GaradgetCover(CoverDevice): self.obtained_token = False self._username = args['username'] self._password = args['password'] - self._state = STATE_UNKNOWN + self._state = None self.time_in_state = None self.signal = None self.sensor = None @@ -156,7 +156,7 @@ class GaradgetCover(CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - if self._state == STATE_UNKNOWN: + if self._state is None: return None return self._state == STATE_CLOSED @@ -226,7 +226,7 @@ class GaradgetCover(CoverDevice): try: status = self._get_variable('doorStatus') _LOGGER.debug("Current Status: %s", status['status']) - self._state = STATES_MAP.get(status['status'], STATE_UNKNOWN) + self._state = STATES_MAP.get(status['status'], None) self.time_in_state = status['time'] self.signal = status['signal'] self.sensor = status['sensor'] diff --git a/homeassistant/components/cover/homematicip_cloud.py b/homeassistant/components/cover/homematicip_cloud.py new file mode 100644 index 00000000000..27f26805e81 --- /dev/null +++ b/homeassistant/components/cover/homematicip_cloud.py @@ -0,0 +1,70 @@ +""" +Support for HomematicIP Cloud cover devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.homematicip_cloud/ +""" +import logging + +from homeassistant.components.cover import ( + ATTR_POSITION, CoverDevice) +from homeassistant.components.homematicip_cloud import ( + HMIPC_HAPID, HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN) + +DEPENDENCIES = ['homematicip_cloud'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the HomematicIP Cloud cover devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the HomematicIP cover from a config entry.""" + from homematicip.aio.device import AsyncFullFlushShutter + + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home + devices = [] + for device in home.devices: + if isinstance(device, AsyncFullFlushShutter): + devices.append(HomematicipCoverShutter(home, device)) + + if devices: + async_add_entities(devices) + + +class HomematicipCoverShutter(HomematicipGenericDevice, CoverDevice): + """Representation of a HomematicIP Cloud cover device.""" + + @property + def current_cover_position(self): + """Return current position of cover.""" + return int(self._device.shutterLevel * 100) + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + level = position / 100.0 + await self._device.set_shutter_level(level) + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self._device.shutterLevel is not None: + return self._device.shutterLevel == 0 + return None + + async def async_open_cover(self, **kwargs): + """Open the cover.""" + await self._device.set_shutter_level(1) + + async def async_close_cover(self, **kwargs): + """Close the cover.""" + await self._device.set_shutter_level(0) + + async def async_stop_cover(self, **kwargs): + """Stop the device if in motion.""" + await self._device.set_shutter_stop() diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py index bdff232fec9..b2587c06512 100644 --- a/homeassistant/components/cover/myq.py +++ b/homeassistant/components/cover/myq.py @@ -15,7 +15,7 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING) from homeassistant.helpers import aiohttp_client, config_validation as cv -REQUIREMENTS = ['pymyq==1.0.0'] +REQUIREMENTS = ['pymyq==1.1.0'] _LOGGER = logging.getLogger(__name__) MYQ_TO_HASS = { diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py index 857283b9b6c..3cf9c753e3a 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -4,8 +4,7 @@ Support for Wink Covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.wink/ """ -from homeassistant.components.cover import CoverDevice, STATE_UNKNOWN, \ - ATTR_POSITION +from homeassistant.components.cover import CoverDevice, ATTR_POSITION from homeassistant.components.wink import WinkDevice, DOMAIN DEPENDENCIES = ['wink'] @@ -54,7 +53,7 @@ class WinkCoverDevice(WinkDevice, CoverDevice): """Return the current position of cover shutter.""" if self.wink.state() is not None: return int(self.wink.state()*100) - return STATE_UNKNOWN + return None @property def is_closed(self): diff --git a/homeassistant/components/daikin/.translations/zh-Hans.json b/homeassistant/components/daikin/.translations/zh-Hans.json new file mode 100644 index 00000000000..1330e3a932d --- /dev/null +++ b/homeassistant/components/daikin/.translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u914d\u7f6e\u5b8c\u6210", + "device_fail": "\u521b\u5efa\u8bbe\u5907\u65f6\u51fa\u73b0\u610f\u5916\u9519\u8bef\u3002", + "device_timeout": "\u8fde\u63a5\u8bbe\u5907\u8d85\u65f6\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a" + }, + "description": "\u8f93\u5165\u60a8\u7684 Daikin \u7a7a\u8c03\u7684IP\u5730\u5740\u3002", + "title": "\u914d\u7f6e Daikin \u7a7a\u8c03" + } + }, + "title": "Daikin \u7a7a\u8c03" + } +} \ No newline at end of file diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py new file mode 100644 index 00000000000..80c36b6f0c6 --- /dev/null +++ b/homeassistant/components/danfoss_air/__init__.py @@ -0,0 +1,80 @@ +""" +Support for Danfoss Air HRV. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/danfoss_air/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +REQUIREMENTS = ['pydanfossair==0.0.6'] + +_LOGGER = logging.getLogger(__name__) + +DANFOSS_AIR_PLATFORMS = ['sensor', 'binary_sensor'] +DOMAIN = 'danfoss_air' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Danfoss Air component.""" + conf = config[DOMAIN] + + hass.data[DOMAIN] = DanfossAir(conf[CONF_HOST]) + + for platform in DANFOSS_AIR_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + + return True + + +class DanfossAir: + """Handle all communication with Danfoss Air CCM unit.""" + + def __init__(self, host): + """Initialize the Danfoss Air CCM connection.""" + self._data = {} + + from pydanfossair.danfossclient import DanfossClient + + self._client = DanfossClient(host) + + def get_value(self, item): + """Get value for sensor.""" + return self._data.get(item) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Use the data from Danfoss Air API.""" + _LOGGER.debug("Fetching data from Danfoss Air CCM module") + from pydanfossair.commands import ReadCommand + self._data[ReadCommand.exhaustTemperature] \ + = self._client.command(ReadCommand.exhaustTemperature) + self._data[ReadCommand.outdoorTemperature] \ + = self._client.command(ReadCommand.outdoorTemperature) + self._data[ReadCommand.supplyTemperature] \ + = self._client.command(ReadCommand.supplyTemperature) + self._data[ReadCommand.extractTemperature] \ + = self._client.command(ReadCommand.extractTemperature) + self._data[ReadCommand.humidity] \ + = round(self._client.command(ReadCommand.humidity), 2) + self._data[ReadCommand.filterPercent] \ + = round(self._client.command(ReadCommand.filterPercent), 2) + self._data[ReadCommand.bypass] \ + = self._client.command(ReadCommand.bypass) + + _LOGGER.debug("Done fetching data from Danfoss Air CCM module") diff --git a/homeassistant/components/danfoss_air/binary_sensor.py b/homeassistant/components/danfoss_air/binary_sensor.py new file mode 100644 index 00000000000..bf8fe952993 --- /dev/null +++ b/homeassistant/components/danfoss_air/binary_sensor.py @@ -0,0 +1,56 @@ +""" +Support for the for Danfoss Air HRV binary sensor platform. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.danfoss_air/ +""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.danfoss_air import DOMAIN \ + as DANFOSS_AIR_DOMAIN + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the available Danfoss Air sensors etc.""" + from pydanfossair.commands import ReadCommand + data = hass.data[DANFOSS_AIR_DOMAIN] + + sensors = [["Danfoss Air Bypass Active", ReadCommand.bypass]] + + dev = [] + + for sensor in sensors: + dev.append(DanfossAirBinarySensor(data, sensor[0], sensor[1])) + + add_entities(dev, True) + + +class DanfossAirBinarySensor(BinarySensorDevice): + """Representation of a Danfoss Air binary sensor.""" + + def __init__(self, data, name, sensor_type): + """Initialize the Danfoss Air binary sensor.""" + self._data = data + self._name = name + self._state = None + self._type = sensor_type + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_class(self): + """Type of device class.""" + return "opening" + + def update(self): + """Fetch new state data for the sensor.""" + self._data.update() + + self._state = self._data.get_value(self._type) diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py new file mode 100644 index 00000000000..2f3807c4999 --- /dev/null +++ b/homeassistant/components/danfoss_air/sensor.py @@ -0,0 +1,76 @@ +""" +Support for the for Danfoss Air HRV sensor platform. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.danfoss_air/ +""" +from homeassistant.components.danfoss_air import DOMAIN \ + as DANFOSS_AIR_DOMAIN +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the available Danfoss Air sensors etc.""" + from pydanfossair.commands import ReadCommand + + data = hass.data[DANFOSS_AIR_DOMAIN] + + sensors = [ + ["Danfoss Air Exhaust Temperature", TEMP_CELSIUS, + ReadCommand.exhaustTemperature], + ["Danfoss Air Outdoor Temperature", TEMP_CELSIUS, + ReadCommand.outdoorTemperature], + ["Danfoss Air Supply Temperature", TEMP_CELSIUS, + ReadCommand.supplyTemperature], + ["Danfoss Air Extract Temperature", TEMP_CELSIUS, + ReadCommand.extractTemperature], + ["Danfoss Air Remaining Filter", '%', + ReadCommand.filterPercent], + ["Danfoss Air Humidity", '%', + ReadCommand.humidity] + ] + + dev = [] + + for sensor in sensors: + dev.append(DanfossAir(data, sensor[0], sensor[1], sensor[2])) + + add_entities(dev, True) + + +class DanfossAir(Entity): + """Representation of a Sensor.""" + + def __init__(self, data, name, sensor_unit, sensor_type): + """Initialize the sensor.""" + self._data = data + self._name = name + self._state = None + self._type = sensor_type + self._unit = sensor_unit + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + def update(self): + """Update the new state of the sensor. + + This is done through the DanfossAir object that does the actual + communication with the Air CCM. + """ + self._data.update() + + self._state = self._data.get_value(self._type) diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index 0c60953db56..d8bcc95a115 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -17,7 +17,7 @@ "title": "Define deCONZ gateway" }, "link": { - "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ system settings\n2. Press \"Unlock Gateway\" button", + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button", "title": "Link with deCONZ" }, "options": { diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json index 5dd87d9e462..5a8b710c006 100644 --- a/homeassistant/components/deconz/.translations/pl.json +++ b/homeassistant/components/deconz/.translations/pl.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "Host", - "port": "Port (warto\u015b\u0107 domy\u015blna: \"80\")" + "port": "Port" }, "title": "Zdefiniuj bramk\u0119 deCONZ" }, @@ -28,6 +28,6 @@ "title": "Dodatkowe opcje konfiguracji dla deCONZ" } }, - "title": "deCONZ" + "title": "Brama deCONZ Zigbee" } } \ No newline at end of file diff --git a/homeassistant/components/device_tracker/ee_brightbox.py b/homeassistant/components/device_tracker/ee_brightbox.py new file mode 100644 index 00000000000..fc23abda1db --- /dev/null +++ b/homeassistant/components/device_tracker/ee_brightbox.py @@ -0,0 +1,107 @@ +""" +Support for EE Brightbox router. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.ee_brightbox/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['eebrightbox==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +CONF_VERSION = 'version' + +CONF_DEFAULT_IP = '192.168.1.1' +CONF_DEFAULT_USERNAME = 'admin' +CONF_DEFAULT_VERSION = 2 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_VERSION, default=CONF_DEFAULT_VERSION): cv.positive_int, + vol.Required(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, + vol.Required(CONF_USERNAME, default=CONF_DEFAULT_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +def get_scanner(hass, config): + """Return a router scanner instance.""" + scanner = EEBrightBoxScanner(config[DOMAIN]) + + return scanner if scanner.check_config() else None + + +class EEBrightBoxScanner(DeviceScanner): + """Scan EE Brightbox router.""" + + def __init__(self, config): + """Initialise the scanner.""" + self.config = config + self.devices = {} + + def check_config(self): + """Check if provided configuration and credentials are correct.""" + from eebrightbox import EEBrightBox, EEBrightBoxException + + try: + with EEBrightBox(self.config) as ee_brightbox: + return bool(ee_brightbox.get_devices()) + except EEBrightBoxException: + _LOGGER.exception("Failed to connect to the router") + return False + + def scan_devices(self): + """Scan for devices.""" + from eebrightbox import EEBrightBox + + with EEBrightBox(self.config) as ee_brightbox: + self.devices = {d['mac']: d for d in ee_brightbox.get_devices()} + + macs = [d['mac'] for d in self.devices.values() if d['activity_ip']] + + _LOGGER.debug('Scan devices %s', macs) + + return macs + + def get_device_name(self, device): + """Get the name of a device from hostname.""" + if device in self.devices: + return self.devices[device]['hostname'] or None + + return None + + def get_extra_attributes(self, device): + """ + Get the extra attributes of a device. + + Extra attributes include: + - ip + - mac + - port - ethX or wifiX + - last_active + """ + port_map = { + 'wl1': 'wifi5Ghz', + 'wl0': 'wifi2.4Ghz', + 'eth0': 'eth0', + 'eth1': 'eth1', + 'eth2': 'eth2', + 'eth3': 'eth3', + } + + if device in self.devices: + return { + 'ip': self.devices[device]['ip'], + 'mac': self.devices[device]['mac'], + 'port': port_map[self.devices[device]['port']], + 'last_active': self.devices[device]['time_last_active'], + } + + return {} diff --git a/homeassistant/components/device_tracker/gpslogger.py b/homeassistant/components/device_tracker/gpslogger.py deleted file mode 100644 index e0d9b37bf84..00000000000 --- a/homeassistant/components/device_tracker/gpslogger.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Support for the GPSLogger platform. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.gpslogger/ -""" -import logging - -from homeassistant.components.gpslogger import TRACKER_UPDATE -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import HomeAssistantType, ConfigType - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['gpslogger'] - - -async def async_setup_scanner(hass: HomeAssistantType, config: ConfigType, - async_see, discovery_info=None): - """Set up an endpoint for the GPSLogger device tracker.""" - async def _set_location(device, gps_location, battery, accuracy, attrs): - """Fire HA event to set location.""" - await async_see( - dev_id=device, - gps=gps_location, - battery=battery, - gps_accuracy=accuracy, - attributes=attrs - ) - - async_dispatcher_connect(hass, TRACKER_UPDATE, _set_location) - return True diff --git a/homeassistant/components/device_tracker/locative.py b/homeassistant/components/device_tracker/locative.py deleted file mode 100644 index e7a63077a3a..00000000000 --- a/homeassistant/components/device_tracker/locative.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -Support for the Locative platform. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.locative/ -""" -import logging - -from homeassistant.components.locative import TRACKER_UPDATE -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['locative'] - - -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up an endpoint for the Locative device tracker.""" - async def _set_location(device, gps_location, location_name): - """Fire HA event to set location.""" - await async_see( - dev_id=device, - gps=gps_location, - location_name=location_name - ) - - async_dispatcher_connect(hass, TRACKER_UPDATE, _set_location) - return True diff --git a/homeassistant/components/device_tracker/synology_srm.py b/homeassistant/components/device_tracker/synology_srm.py new file mode 100644 index 00000000000..cc931b797d4 --- /dev/null +++ b/homeassistant/components/device_tracker/synology_srm.py @@ -0,0 +1,100 @@ +"""Device tracker for Synology SRM routers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.synology_srm/ +""" +import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, + CONF_PORT, CONF_SSL, CONF_VERIFY_SSL) + +REQUIREMENTS = ['synology-srm==0.0.3'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_USERNAME = 'admin' +DEFAULT_PORT = 8001 +DEFAULT_SSL = True +DEFAULT_VERIFY_SSL = False + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) + + +def get_scanner(hass, config): + """Validate the configuration and return Synology SRM scanner.""" + scanner = SynologySrmDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class SynologySrmDeviceScanner(DeviceScanner): + """This class scans for devices connected to a Synology SRM router.""" + + def __init__(self, config): + """Initialize the scanner.""" + import synology_srm + + self.client = synology_srm.Client( + host=config[CONF_HOST], + port=config[CONF_PORT], + username=config[CONF_USERNAME], + password=config[CONF_PASSWORD], + https=config[CONF_SSL] + ) + + if not config[CONF_VERIFY_SSL]: + self.client.http.disable_https_verify() + + self.last_results = [] + self.success_init = self._update_info() + + _LOGGER.info("Synology SRM scanner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device['mac'] for device in self.last_results] + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + filter_named = [result['hostname'] for result in self.last_results if + result['mac'] == device] + + if filter_named: + return filter_named[0] + + return None + + def _update_info(self): + """Check the router for connected devices.""" + _LOGGER.debug("Scanning for connected devices") + + devices = self.client.mesh.network_wifidevice() + last_results = [] + + for device in devices: + last_results.append({ + 'mac': device['mac'], + 'hostname': device['hostname'] + }) + + _LOGGER.debug( + "Found %d device(s) connected to the router", + len(devices) + ) + + self.last_results = last_results + return True diff --git a/homeassistant/components/dialogflow/.translations/sl.json b/homeassistant/components/dialogflow/.translations/sl.json index 597e65a7658..18a476b6870 100644 --- a/homeassistant/components/dialogflow/.translations/sl.json +++ b/homeassistant/components/dialogflow/.translations/sl.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "not_internet_accessible": " \u010ce \u017eelite prejemati sporo\u010dila dialogflow, mora biti Home Assistent dostopen prek interneta.", + "not_internet_accessible": "\u010ce \u017eelite prejemati sporo\u010dila dialogflow, mora biti Home Assistant dostopen prek interneta.", "one_instance_allowed": "Potrebna je samo ena instanca." }, "create_entry": { - "default": "Za po\u0161iljanje dogodkov Home Assistent-u, boste morali nastaviti [webhook z dialogflow]({twilio_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/x-www-form-urlencoded\n\nGlej [dokumentacijo]({docs_url}) za nadaljna navodila." + "default": "Za po\u0161iljanje dogodkov Home Assistant-u, boste morali nastaviti [webhook z dialogflow]({twilio_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/x-www-form-urlencoded\n\nGlej [dokumentacijo]({docs_url}) za nadaljna navodila." }, "step": { "user": { diff --git a/homeassistant/components/dialogflow/.translations/zh-Hans.json b/homeassistant/components/dialogflow/.translations/zh-Hans.json index 6eecbed54ac..8a542dd0d62 100644 --- a/homeassistant/components/dialogflow/.translations/zh-Hans.json +++ b/homeassistant/components/dialogflow/.translations/zh-Hans.json @@ -1,10 +1,15 @@ { "config": { + "abort": { + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u63a5\u5165\u4e92\u8054\u7f51\u4ee5\u63a5\u6536 Dialogflow \u6d88\u606f\u3002", + "one_instance_allowed": "\u4ec5\u9700\u4e00\u4e2a\u5b9e\u4f8b" + }, "create_entry": { "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Dialogflow \u7684 Webhook \u96c6\u6210]({dialogflow_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002" }, "step": { "user": { + "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e Dialogflow \u5417?", "title": "\u8bbe\u7f6e Dialogflow Webhook" } }, diff --git a/homeassistant/components/dovado/__init__.py b/homeassistant/components/dovado/__init__.py new file mode 100644 index 00000000000..7a50ac815b1 --- /dev/null +++ b/homeassistant/components/dovado/__init__.py @@ -0,0 +1,79 @@ +""" +Support for Dovado router. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/dovado/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT, + DEVICE_DEFAULT_NAME) +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['dovado==0.4.1'] + +DOMAIN = 'dovado' + +CONFIG_SCHEMA = vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + + +def setup(hass, config): + """Set up the Dovado component.""" + import dovado + + hass.data[DOMAIN] = DovadoData( + dovado.Dovado( + config[CONF_USERNAME], + config[CONF_PASSWORD], + config.get(CONF_HOST), + config.get(CONF_PORT) + ) + ) + return True + + +class DovadoData: + """Maintains a connection to the router.""" + + def __init__(self, client): + """Set up a new Dovado connection.""" + self._client = client + self.state = {} + + @property + def name(self): + """Name of the router.""" + return self.state.get("product name", DEVICE_DEFAULT_NAME) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update device state.""" + try: + self.state = self._client.state or {} + if not self.state: + return False + self.state.update( + connected=self.state.get("modem status") == "CONNECTED") + _LOGGER.debug("Received: %s", self.state) + return True + except OSError as error: + _LOGGER.warning("Could not contact the router: %s", error) + + @property + def client(self): + """Dovado client instance.""" + return self._client diff --git a/homeassistant/components/dovado/notify.py b/homeassistant/components/dovado/notify.py new file mode 100644 index 00000000000..00036378a78 --- /dev/null +++ b/homeassistant/components/dovado/notify.py @@ -0,0 +1,38 @@ +""" +Support for SMS notifications from the Dovado router. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.dovado/ +""" +import logging + +from homeassistant.components.dovado import DOMAIN as DOVADO_DOMAIN +from homeassistant.components.notify import BaseNotificationService, \ + ATTR_TARGET + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['dovado'] + + +def get_service(hass, config, discovery_info=None): + """Get the Dovado Router SMS notification service.""" + return DovadoSMSNotificationService(hass.data[DOVADO_DOMAIN].client) + + +class DovadoSMSNotificationService(BaseNotificationService): + """Implement the notification service for the Dovado SMS component.""" + + def __init__(self, client): + """Initialize the service.""" + self._client = client + + def send_message(self, message, **kwargs): + """Send SMS to the specified target phone number.""" + target = kwargs.get(ATTR_TARGET) + + if not target: + _LOGGER.error("One target is required") + return + + self._client.send_sms(target, message) diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py new file mode 100644 index 00000000000..b89275b1795 --- /dev/null +++ b/homeassistant/components/dovado/sensor.py @@ -0,0 +1,116 @@ +""" +Support for sensors from the Dovado router. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dovado/ +""" +import logging +import re +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.dovado import DOMAIN as DOVADO_DOMAIN +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_SENSORS +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['dovado'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +SENSOR_UPLOAD = 'upload' +SENSOR_DOWNLOAD = 'download' +SENSOR_SIGNAL = 'signal' +SENSOR_NETWORK = 'network' +SENSOR_SMS_UNREAD = 'sms' + +SENSORS = { + SENSOR_NETWORK: ('signal strength', 'Network', None, + 'mdi:access-point-network'), + SENSOR_SIGNAL: ('signal strength', 'Signal Strength', '%', + 'mdi:signal'), + SENSOR_SMS_UNREAD: ('sms unread', 'SMS unread', '', + 'mdi:message-text-outline'), + SENSOR_UPLOAD: ('traffic modem tx', 'Sent', 'GB', + 'mdi:cloud-upload'), + SENSOR_DOWNLOAD: ('traffic modem rx', 'Received', 'GB', + 'mdi:cloud-download'), +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSORS): vol.All( + cv.ensure_list, [vol.In(SENSORS)] + ), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dovado sensor platform.""" + dovado = hass.data[DOVADO_DOMAIN] + + entities = [] + for sensor in config[CONF_SENSORS]: + entities.append(DovadoSensor(dovado, sensor)) + + add_entities(entities) + + +class DovadoSensor(Entity): + """Representation of a Dovado sensor.""" + + def __init__(self, data, sensor): + """Initialize the sensor.""" + self._data = data + self._sensor = sensor + self._state = self._compute_state() + + def _compute_state(self): + state = self._data.state.get(SENSORS[self._sensor][0]) + if self._sensor == SENSOR_NETWORK: + match = re.search(r"\((.+)\)", state) + return match.group(1) if match else None + if self._sensor == SENSOR_SIGNAL: + try: + return int(state.split()[0]) + except ValueError: + return None + if self._sensor == SENSOR_SMS_UNREAD: + return int(state) + if self._sensor in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: + return round(float(state) / 1e6, 1) + return state + + def update(self): + """Update sensor values.""" + self._data.update() + self._state = self._compute_state() + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format(self._data.name, SENSORS[self._sensor][1]) + + @property + def state(self): + """Return the sensor state.""" + return self._state + + @property + def icon(self): + """Return the icon for the sensor.""" + return SENSORS[self._sensor][3] + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return SENSORS[self._sensor][2] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {k: v for k, v in self._data.state.items() + if k not in ['date', 'time']} diff --git a/homeassistant/components/ecoal_boiler.py b/homeassistant/components/ecoal_boiler.py new file mode 100644 index 00000000000..bd08024e64a --- /dev/null +++ b/homeassistant/components/ecoal_boiler.py @@ -0,0 +1,98 @@ +""" +Component to control ecoal/esterownik.pl coal/wood boiler controller. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ecoal_boiler/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import (CONF_HOST, CONF_PASSWORD, CONF_USERNAME, + CONF_MONITORED_CONDITIONS, CONF_SENSORS, + CONF_SWITCHES) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform + +REQUIREMENTS = ['ecoaliface==0.4.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "ecoal_boiler" +DATA_ECOAL_BOILER = 'data_' + DOMAIN + +DEFAULT_USERNAME = "admin" +DEFAULT_PASSWORD = "admin" + + +# Available pump ids with assigned HA names +# Available as switches +AVAILABLE_PUMPS = { + "central_heating_pump": "Central heating pump", + "central_heating_pump2": "Central heating pump2", + "domestic_hot_water_pump": "Domestic hot water pump", +} + +# Available temp sensor ids with assigned HA names +# Available as sensors +AVAILABLE_SENSORS = { + "outdoor_temp": 'Outdoor temperature', + "indoor_temp": 'Indoor temperature', + "indoor2_temp": 'Indoor temperature 2', + "domestic_hot_water_temp": 'Domestic hot water temperature', + "target_domestic_hot_water_temp": 'Target hot water temperature', + "feedwater_in_temp": 'Feedwater input temperature', + "feedwater_out_temp": 'Feedwater output temperature', + "target_feedwater_temp": 'Target feedwater temperature', + "fuel_feeder_temp": 'Fuel feeder temperature', + "exhaust_temp": 'Exhaust temperature', +} + +SWITCH_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(AVAILABLE_PUMPS)): + vol.All(cv.ensure_list, [vol.In(AVAILABLE_PUMPS)]) +}) + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(AVAILABLE_SENSORS)): + vol.All(cv.ensure_list, [vol.In(AVAILABLE_SENSORS)]) +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_USERNAME, + default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, + default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_SWITCHES, default={}): SWITCH_SCHEMA, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, hass_config): + """Set up global ECoalController instance same for sensors and switches.""" + from ecoaliface.simple import ECoalController + + conf = hass_config[DOMAIN] + host = conf[CONF_HOST] + username = conf[CONF_USERNAME] + passwd = conf[CONF_PASSWORD] + # Creating ECoalController instance makes HTTP request to controller. + ecoal_contr = ECoalController(host, username, passwd) + if ecoal_contr.version is None: + # Wrong credentials nor network config + _LOGGER.error("Unable to read controller status from %s@%s" + " (wrong host/credentials)", username, host, ) + return False + _LOGGER.debug("Detected controller version: %r @%s", + ecoal_contr.version, host, ) + hass.data[DATA_ECOAL_BOILER] = ecoal_contr + # Setup switches + switches = conf[CONF_SWITCHES][CONF_MONITORED_CONDITIONS] + load_platform(hass, 'switch', DOMAIN, switches, hass_config) + # Setup temp sensors + sensors = conf[CONF_SENSORS][CONF_MONITORED_CONDITIONS] + load_platform(hass, 'sensor', DOMAIN, sensors, hass_config) + return True diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 9c0df0f9f03..07ecb9d265a 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -22,7 +22,7 @@ from homeassistant.components.http import real_ip from .hue_api import ( HueUsernameView, HueAllLightsStateView, HueOneLightStateView, - HueOneLightChangeView, HueGroupView) + HueOneLightChangeView, HueGroupView, HueAllGroupsStateView) from .upnp import DescriptionXmlView, UPNPResponderThread DOMAIN = 'emulated_hue' @@ -105,6 +105,7 @@ async def async_setup(hass, yaml_config): HueAllLightsStateView(config).register(app, app.router) HueOneLightStateView(config).register(app, app.router) HueOneLightChangeView(config).register(app, app.router) + HueAllGroupsStateView(config).register(app, app.router) HueGroupView(config).register(app, app.router) upnp_listener = UPNPResponderThread( diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 3699a45ef30..815e28b4fa4 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -56,6 +56,28 @@ class HueUsernameView(HomeAssistantView): return self.json([{'success': {'username': '12345678901234567890'}}]) +class HueAllGroupsStateView(HomeAssistantView): + """Group handler.""" + + url = '/api/{username}/groups' + name = 'emulated_hue:all_groups:state' + requires_auth = False + + def __init__(self, config): + """Initialize the instance of the view.""" + self.config = config + + @core.callback + def get(self, request, username): + """Process a request to make the Brilliant Lightpad work.""" + if not is_local(request[KEY_REAL_IP]): + return self.json_message('only local IPs allowed', + HTTP_BAD_REQUEST) + + return self.json({ + }) + + class HueGroupView(HomeAssistantView): """Group handler to get Logitech Pop working.""" diff --git a/homeassistant/components/emulated_roku/.translations/es.json b/homeassistant/components/emulated_roku/.translations/es.json new file mode 100644 index 00000000000..3491c784c19 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "name_exists": "El nombre ya existe" + }, + "step": { + "user": { + "data": { + "host_ip": "IP del host", + "listen_port": "Puerto de escucha", + "name": "Nombre" + }, + "title": "Definir la configuraci\u00f3n del servidor" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/ko.json b/homeassistant/components/emulated_roku/.translations/ko.json index 54c3e079386..ddee892039f 100644 --- a/homeassistant/components/emulated_roku/.translations/ko.json +++ b/homeassistant/components/emulated_roku/.translations/ko.json @@ -11,7 +11,7 @@ "host_ip": "\ud638\uc2a4\ud2b8 IP", "listen_port": "\uc218\uc2e0 \ud3ec\ud2b8", "name": "\uc774\ub984", - "upnp_bind_multicast": "\uba40\ud2f0 \uce90\uc2a4\ud2b8 \ubc14\uc778\ub4dc (\ucc38/\uac70\uc9d3)" + "upnp_bind_multicast": "\uba40\ud2f0 \uce90\uc2a4\ud2b8 \ud560\ub2f9 (\ucc38/\uac70\uc9d3)" }, "title": "\uc11c\ubc84 \uad6c\uc131 \uc815\uc758" } diff --git a/homeassistant/components/emulated_roku/.translations/lb.json b/homeassistant/components/emulated_roku/.translations/lb.json new file mode 100644 index 00000000000..11d1aa3ff7a --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Numm g\u00ebtt et schonn" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP annonc\u00e9ieren", + "advertise_port": "Port annonc\u00e9ieren", + "host_ip": "IP vum Apparat", + "listen_port": "Port lauschteren", + "name": "Numm", + "upnp_bind_multicast": "Multicast abannen (Richteg/Falsch)" + }, + "title": "Server Konfiguratioun d\u00e9fin\u00e9ieren" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/pl.json b/homeassistant/components/emulated_roku/.translations/pl.json new file mode 100644 index 00000000000..0ed3cc3d14a --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Nazwa ju\u017c istnieje" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP rozg\u0142aszania", + "advertise_port": "Port rozg\u0142aszania", + "host_ip": "IP hosta", + "listen_port": "Port nas\u0142uchu", + "name": "Nazwa", + "upnp_bind_multicast": "Powi\u0105\u017c multicast (prawda/fa\u0142sz)" + }, + "title": "Zdefiniuj konfiguracj\u0119 serwera" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/sl.json b/homeassistant/components/emulated_roku/.translations/sl.json new file mode 100644 index 00000000000..768feb83747 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Ime \u017ee obstaja" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Advertise IP", + "advertise_port": "Advertise port", + "host_ip": "IP gostitelja", + "listen_port": "Vrata naprave", + "name": "Ime", + "upnp_bind_multicast": "Vezava multicasta (True / False)" + }, + "title": "Dolo\u010dite konfiguracijo stre\u017enika" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/zh-Hans.json b/homeassistant/components/emulated_roku/.translations/zh-Hans.json new file mode 100644 index 00000000000..9cb4cc33431 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/zh-Hans.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728" + }, + "step": { + "user": { + "data": { + "host_ip": "\u4e3b\u673aIP", + "listen_port": "\u76d1\u542c\u7aef\u53e3", + "name": "\u59d3\u540d" + }, + "title": "\u5b9a\u4e49\u670d\u52a1\u5668\u914d\u7f6e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/zh-Hant.json b/homeassistant/components/emulated_roku/.translations/zh-Hant.json new file mode 100644 index 00000000000..40b4307ae02 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728" + }, + "step": { + "user": { + "data": { + "advertise_ip": "\u5ee3\u64ad\u901a\u8a0a\u57e0", + "advertise_port": "\u5ee3\u64ad\u901a\u8a0a\u57e0", + "host_ip": "\u4e3b\u6a5f IP", + "listen_port": "\u76e3\u807d\u901a\u8a0a\u57e0", + "name": "\u540d\u7a31", + "upnp_bind_multicast": "\u7d81\u5b9a\u7fa4\u64ad\uff08Multicast\uff09True/False" + }, + "title": "\u5b9a\u7fa9\u4f3a\u670d\u5668\u8a2d\u5b9a" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py index 8ebaa5e4b26..4dec1d5602a 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -16,7 +16,7 @@ from .const import ( CONF_ADVERTISE_IP, CONF_ADVERTISE_PORT, CONF_HOST_IP, CONF_LISTEN_PORT, CONF_SERVERS, CONF_UPNP_BIND_MULTICAST, DOMAIN) -REQUIREMENTS = ['emulated_roku==0.1.7'] +REQUIREMENTS = ['emulated_roku==0.1.8'] SERVER_CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index c6b694c7f5f..8b89b307db9 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -146,19 +146,19 @@ async def async_setup(hass, config): @callback def zones_updated_callback(data): """Handle zone timer updates.""" - _LOGGER.info("Envisalink sent a zone update event. Updating zones...") + _LOGGER.debug("Envisalink sent a zone update event. Updating zones...") async_dispatcher_send(hass, SIGNAL_ZONE_UPDATE, data) @callback def alarm_data_updated_callback(data): """Handle non-alarm based info updates.""" - _LOGGER.info("Envisalink sent new alarm info. Updating alarms...") + _LOGGER.debug("Envisalink sent new alarm info. Updating alarms...") async_dispatcher_send(hass, SIGNAL_KEYPAD_UPDATE, data) @callback def partition_updated_callback(data): """Handle partition changes thrown by evl (including alarms).""" - _LOGGER.info("The envisalink sent a partition update event") + _LOGGER.debug("The envisalink sent a partition update event") async_dispatcher_send(hass, SIGNAL_PARTITION_UPDATE, data) @callback diff --git a/homeassistant/components/esphome/.translations/es.json b/homeassistant/components/esphome/.translations/es.json new file mode 100644 index 00000000000..8010b330b88 --- /dev/null +++ b/homeassistant/components/esphome/.translations/es.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "ESP ya est\u00e1 configurado" + }, + "error": { + "invalid_password": "\u00a1Contrase\u00f1a incorrecta!" + }, + "step": { + "authenticate": { + "data": { + "password": "Contrase\u00f1a" + }, + "description": "Escribe la contrase\u00f1a que hayas establecido en tu configuraci\u00f3n.", + "title": "Escribe la contrase\u00f1a" + }, + "user": { + "data": { + "host": "Host", + "port": "Puerto" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/ko.json b/homeassistant/components/esphome/.translations/ko.json index 514acbbbf18..24f84851254 100644 --- a/homeassistant/components/esphome/.translations/ko.json +++ b/homeassistant/components/esphome/.translations/ko.json @@ -4,7 +4,7 @@ "already_configured": "ESP \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { - "connection_error": "ESP \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. YAML \ud30c\uc77c\uc5d0 'api :' \ub97c \uad6c\uc131\ud588\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", + "connection_error": "ESP \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. YAML \ud30c\uc77c\uc5d0 'api:' \ub97c \uad6c\uc131\ud588\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", "invalid_password": "\uc798\ubabb\ub41c \ube44\ubc00\ubc88\ud638", "resolve_error": "ESP \uc758 \uc8fc\uc18c\ub97c \ud655\uc778\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uac00 \uacc4\uc18d \ubc1c\uc0dd\ud558\uba74 \uace0\uc815 IP \uc8fc\uc18c (https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips)\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694" }, diff --git a/homeassistant/components/esphome/.translations/pl.json b/homeassistant/components/esphome/.translations/pl.json index 4f2a8b0e1bb..19fb581eb3f 100644 --- a/homeassistant/components/esphome/.translations/pl.json +++ b/homeassistant/components/esphome/.translations/pl.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "ESP jest ju\u017c skonfigurowany" + "already_configured": "ESP jest ju\u017c skonfigurowane" }, "error": { - "connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z ESP. Upewnij si\u0119, \u017ce Tw\u00f3j plik YAML zawiera lini\u0119 \"api:\".", + "connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z ESP. Upewnij si\u0119, \u017ce Tw\u00f3j plik YAML zawiera lini\u0119 'api:'.", "invalid_password": "Nieprawid\u0142owe has\u0142o!", "resolve_error": "Nie mo\u017cna rozpozna\u0107 adresu ESP. Je\u015bli ten b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, nale\u017cy ustawi\u0107 statyczny adres IP: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, @@ -21,7 +21,7 @@ "host": "Host", "port": "Port" }, - "description": "Wprowad\u017a ustawienia po\u0142\u0105czenia swojego [ESPHome] (https://esphomelib.com/) w\u0119z\u0142a.", + "description": "Wprowad\u017a ustawienia po\u0142\u0105czenia swojego [ESPHome](https://esphomelib.com/) w\u0119z\u0142a.", "title": "ESPHome" } }, diff --git a/homeassistant/components/esphome/.translations/zh-Hans.json b/homeassistant/components/esphome/.translations/zh-Hans.json new file mode 100644 index 00000000000..8e5ca59fcef --- /dev/null +++ b/homeassistant/components/esphome/.translations/zh-Hans.json @@ -0,0 +1,27 @@ +{ + "config": { + "error": { + "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u5230ESP\u3002\u8bf7\u786e\u4fdd\u60a8\u7684YAML\u6587\u4ef6\u5305\u542b'api:'\u884c\u3002", + "invalid_password": "\u65e0\u6548\u7684\u5bc6\u7801\uff01", + "resolve_error": "\u65e0\u6cd5\u89e3\u6790ESP\u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u4ecd\u7136\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "step": { + "authenticate": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "\u8bf7\u8f93\u5165\u60a8\u5728\u914d\u7f6e\u4e2d\u8bbe\u7f6e\u7684\u5bc6\u7801\u3002", + "title": "\u8f93\u5165\u5bc6\u7801" + }, + "user": { + "data": { + "host": "\u4e3b\u673a", + "port": "\u7aef\u53e3" + }, + "description": "\u8bf7\u8f93\u5165\u60a8\u7684 [ESPHome](https://esphomelib.com/) \u8282\u70b9\u7684\u8fde\u63a5\u8bbe\u7f6e\u3002", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index a54d52f4b12..3525b95c007 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -12,8 +12,7 @@ import voluptuous as vol from homeassistant.components import group from homeassistant.const import (SERVICE_TURN_ON, SERVICE_TOGGLE, - SERVICE_TURN_OFF, ATTR_ENTITY_ID, - STATE_UNKNOWN) + SERVICE_TURN_OFF, ATTR_ENTITY_ID) from homeassistant.loader import bind_hass from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent @@ -94,7 +93,7 @@ def is_on(hass, entity_id: str = None) -> bool: """Return if the fans are on based on the statemachine.""" entity_id = entity_id or ENTITY_ID_ALL_FANS state = hass.states.get(entity_id) - return state.attributes[ATTR_SPEED] not in [SPEED_OFF, STATE_UNKNOWN] + return state.attributes[ATTR_SPEED] not in [SPEED_OFF, None] async def async_setup(hass, config: dict): @@ -199,7 +198,7 @@ class FanEntity(ToggleEntity): @property def is_on(self): """Return true if the entity is on.""" - return self.speed not in [SPEED_OFF, STATE_UNKNOWN] + return self.speed not in [SPEED_OFF, None] @property def speed(self) -> str: diff --git a/homeassistant/components/fan/comfoconnect.py b/homeassistant/components/fan/comfoconnect.py index a1f13da6c09..0e0ac8c80b6 100644 --- a/homeassistant/components/fan/comfoconnect.py +++ b/homeassistant/components/fan/comfoconnect.py @@ -11,7 +11,6 @@ from homeassistant.components.comfoconnect import ( from homeassistant.components.fan import ( FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED) -from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers.dispatcher import (dispatcher_connect) _LOGGER = logging.getLogger(__name__) @@ -79,7 +78,7 @@ class ComfoConnectFan(FanEntity): speed = self._ccb.data[SENSOR_FAN_SPEED_MODE] return SPEED_MAPPING[speed] except KeyError: - return STATE_UNKNOWN + return None @property def speed_list(self): diff --git a/homeassistant/components/fan/wink.py b/homeassistant/components/fan/wink.py index d0dc386d74d..eca985a8d1e 100644 --- a/homeassistant/components/fan/wink.py +++ b/homeassistant/components/fan/wink.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/fan.wink/ import logging from homeassistant.components.fan import ( - SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, STATE_UNKNOWN, SUPPORT_DIRECTION, + SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SUPPORT_DIRECTION, SUPPORT_SET_SPEED, FanEntity) from homeassistant.components.wink import DOMAIN, WinkDevice @@ -71,7 +71,7 @@ class WinkFanDevice(WinkDevice, FanEntity): return SPEED_MEDIUM if SPEED_HIGH == current_wink_speed: return SPEED_HIGH - return STATE_UNKNOWN + return None @property def current_direction(self): diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index e0d51279bbf..2e0b1657d23 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -38,7 +38,7 @@ MODEL_AIRPURIFIER_MA1 = 'zhimi.airpurifier.ma1' MODEL_AIRPURIFIER_MA2 = 'zhimi.airpurifier.ma2' MODEL_AIRPURIFIER_SA1 = 'zhimi.airpurifier.sa1' MODEL_AIRPURIFIER_SA2 = 'zhimi.airpurifier.sa2' -MODEL_AIRPURIFIER_MC1 = 'zhimi.airpurifier.mc1' +MODEL_AIRPURIFIER_2S = 'zhimi.airpurifier.mc1' MODEL_AIRHUMIDIFIER_V1 = 'zhimi.humidifier.v1' MODEL_AIRHUMIDIFIER_CA = 'zhimi.humidifier.ca1' @@ -62,7 +62,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ MODEL_AIRPURIFIER_MA2, MODEL_AIRPURIFIER_SA1, MODEL_AIRPURIFIER_SA2, - MODEL_AIRPURIFIER_MC1, + MODEL_AIRPURIFIER_2S, MODEL_AIRHUMIDIFIER_V1, MODEL_AIRHUMIDIFIER_CA, MODEL_AIRFRESH_VA2, @@ -175,6 +175,15 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 = { ATTR_VOLUME: 'volume', } +AVAILABLE_ATTRIBUTES_AIRPURIFIER_2S = { + **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, + ATTR_BUZZER: 'buzzer', + ATTR_FILTER_RFID_PRODUCT_ID: 'filter_rfid_product_id', + ATTR_FILTER_RFID_TAG: 'filter_rfid_tag', + ATTR_FILTER_TYPE: 'filter_type', + ATTR_ILLUMINANCE: 'illuminance', +} + AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { # Common set isn't used here. It's a very basic version of the device. ATTR_AIR_QUALITY_INDEX: 'aqi', @@ -249,6 +258,7 @@ AVAILABLE_ATTRIBUTES_AIRFRESH = { OPERATION_MODES_AIRPURIFIER = ['Auto', 'Silent', 'Favorite', 'Idle'] OPERATION_MODES_AIRPURIFIER_PRO = ['Auto', 'Silent', 'Favorite'] OPERATION_MODES_AIRPURIFIER_PRO_V7 = OPERATION_MODES_AIRPURIFIER_PRO +OPERATION_MODES_AIRPURIFIER_2S = ['Auto', 'Silent', 'Favorite'] OPERATION_MODES_AIRPURIFIER_V3 = ['Auto', 'Silent', 'Favorite', 'Idle', 'Medium', 'High', 'Strong'] OPERATION_MODES_AIRFRESH = ['Auto', 'Silent', 'Interval', 'Low', @@ -289,6 +299,11 @@ FEATURE_FLAGS_AIRPURIFIER_PRO_V7 = (FEATURE_SET_CHILD_LOCK | FEATURE_SET_FAVORITE_LEVEL | FEATURE_SET_VOLUME) +FEATURE_FLAGS_AIRPURIFIER_2S = (FEATURE_SET_BUZZER | + FEATURE_SET_CHILD_LOCK | + FEATURE_SET_LED | + FEATURE_SET_FAVORITE_LEVEL) + FEATURE_FLAGS_AIRPURIFIER_V3 = (FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED) @@ -619,6 +634,10 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self._available_attributes = \ AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 self._speed_list = OPERATION_MODES_AIRPURIFIER_PRO_V7 + elif self._model == MODEL_AIRPURIFIER_2S: + self._device_features = FEATURE_FLAGS_AIRPURIFIER_2S + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_2S + self._speed_list = OPERATION_MODES_AIRPURIFIER_2S elif self._model == MODEL_AIRPURIFIER_V3: self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index a2f0ca19231..3184b5a5d54 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/ffmpeg/ """ import logging +import re import voluptuous as vol @@ -16,7 +17,7 @@ from homeassistant.helpers.dispatcher import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['ha-ffmpeg==1.9'] +REQUIREMENTS = ['ha-ffmpeg==1.11'] DOMAIN = 'ffmpeg' @@ -60,6 +61,8 @@ async def async_setup(hass, config): conf.get(CONF_FFMPEG_BIN, DEFAULT_BINARY) ) + await manager.async_get_version() + # Register service async def async_service_handle(service): """Handle service ffmpeg process.""" @@ -96,12 +99,37 @@ class FFmpegManager: self.hass = hass self._cache = {} self._bin = ffmpeg_bin + self._version = None + self._major_version = None @property def binary(self): """Return ffmpeg binary from config.""" return self._bin + async def async_get_version(self): + """Return ffmpeg version.""" + from haffmpeg.tools import FFVersion + + ffversion = FFVersion(self._bin, self.hass.loop) + self._version = await ffversion.get_version() + + self._major_version = None + if self._version is not None: + result = re.search(r"(\d+)\.", self._version) + if result is not None: + self._major_version = int(result.group(1)) + + return self._version, self._major_version + + @property + def ffmpeg_stream_content_type(self): + """Return HTTP content type for ffmpeg stream.""" + if self._major_version is not None and self._major_version > 3: + return 'multipart/x-mixed-replace;boundary=ffmpeg' + + return 'multipart/x-mixed-replace;boundary=ffserver' + class FFmpegBase(Entity): """Interface object for FFmpeg.""" diff --git a/homeassistant/components/freedns.py b/homeassistant/components/freedns.py index 0b5cbeda01a..ec38bb59cc7 100644 --- a/homeassistant/components/freedns.py +++ b/homeassistant/components/freedns.py @@ -12,7 +12,8 @@ import aiohttp import async_timeout import voluptuous as vol -from homeassistant.const import (CONF_URL, CONF_ACCESS_TOKEN) +from homeassistant.const import (CONF_URL, CONF_ACCESS_TOKEN, + CONF_UPDATE_INTERVAL) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -24,8 +25,6 @@ DEFAULT_INTERVAL = timedelta(minutes=10) TIMEOUT = 10 UPDATE_URL = 'https://freedns.afraid.org/dynamic/update.php' -CONF_UPDATE_INTERVAL = 'update_interval' - CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Exclusive(CONF_URL, DOMAIN): cv.string, diff --git a/homeassistant/components/fritzbox.py b/homeassistant/components/fritzbox.py index e6f121799df..ad3c7bc1929 100644 --- a/homeassistant/components/fritzbox.py +++ b/homeassistant/components/fritzbox.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pyfritzhome==0.4.0'] -SUPPORTED_DOMAINS = ['binary_sensor', 'climate', 'switch'] +SUPPORTED_DOMAINS = ['binary_sensor', 'climate', 'switch', 'sensor'] DOMAIN = 'fritzbox' diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index f5cc33b63a0..46652b4d7b0 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==20190121.1'] +REQUIREMENTS = ['home-assistant-frontend==20190203.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 4e05c5b41fe..4597a56c61a 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -22,15 +22,12 @@ DOMAIN = 'geo_location' ENTITY_ID_FORMAT = DOMAIN + '.{}' -GROUP_NAME_ALL_EVENTS = 'All Geolocation Events' - SCAN_INTERVAL = timedelta(seconds=60) async def async_setup(hass, config): """Set up the Geolocation component.""" - component = EntityComponent( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_EVENTS) + component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) await component.async_setup(config) return True diff --git a/homeassistant/components/geofency/.translations/es.json b/homeassistant/components/geofency/.translations/es.json new file mode 100644 index 00000000000..cd14e21db10 --- /dev/null +++ b/homeassistant/components/geofency/.translations/es.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tu Home Assistant debe ser accesible desde Internet para recibir mensajes de GPSLogger.", + "one_instance_allowed": "Solo se necesita una instancia." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/pl.json b/homeassistant/components/geofency/.translations/pl.json new file mode 100644 index 00000000000..09d93e6911e --- /dev/null +++ b/homeassistant/components/geofency/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tw\u00f3j Home Assistant musi by\u0107 dost\u0119pny z Internetu, aby odbiera\u0107 komunikaty z Geofency.", + "one_instance_allowed": "Wymagana jest tylko jedna instancja." + }, + "create_entry": { + "default": "Aby wysy\u0142a\u0107 zdarzenia do Home Assistant'a, musisz skonfigurowa\u0107 webhook w Geofency. \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}) by pozna\u0107 szczeg\u00f3\u0142y." + }, + "step": { + "user": { + "description": "Czy chcesz skonfigurowa\u0107 Geofency?", + "title": "Konfiguracja Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/sl.json b/homeassistant/components/geofency/.translations/sl.json new file mode 100644 index 00000000000..e56d41d4f1a --- /dev/null +++ b/homeassistant/components/geofency/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161 Home Assistant mora biti dostopen prek interneta, da boste lahko prejemali Geofency sporo\u010dila.", + "one_instance_allowed": "Potrebna je samo ena instanca." + }, + "create_entry": { + "default": "\u010ce \u017eelite dogodke poslati v Home Assistant, morate v Geofency-ju nastaviti funkcijo webhook. \n\n Izpolnite naslednje podatke: \n\n - URL: ` {webhook_url} ` \n - Metoda: POST \n\n Za ve\u010d podrobnosti si oglejte [dokumentacijo] ( {docs_url} )." + }, + "step": { + "user": { + "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti geofency webhook?", + "title": "Nastavite Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/zh-Hans.json b/homeassistant/components/geofency/.translations/zh-Hans.json new file mode 100644 index 00000000000..7ab8a128980 --- /dev/null +++ b/homeassistant/components/geofency/.translations/zh-Hans.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u63a5\u5165\u4e92\u8054\u7f51\u4ee5\u63a5\u6536 Geofency \u6d88\u606f\u3002", + "one_instance_allowed": "\u4ec5\u9700\u4e00\u4e2a\u5b9e\u4f8b" + }, + "step": { + "user": { + "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e Geofency Webhook \u5417?", + "title": "\u8bbe\u7f6e Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/zh-Hant.json b/homeassistant/components/geofency/.translations/zh-Hant.json new file mode 100644 index 00000000000..bec33c26d10 --- /dev/null +++ b/homeassistant/components/geofency/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Geofency \u8a0a\u606f\u3002", + "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002" + }, + "create_entry": { + "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u65bc Geofency \u5167\u8a2d\u5b9a webhook \u529f\u80fd\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002" + }, + "step": { + "user": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Geofency Webhook\uff1f", + "title": "\u8a2d\u5b9a Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 093ebaa2fd3..f58580b83c7 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -10,10 +10,10 @@ import voluptuous as vol from aiohttp import web import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME, \ ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, HTTP_OK, ATTR_NAME from homeassistant.helpers import config_entry_flow -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import slugify @@ -68,13 +68,9 @@ WEBHOOK_SCHEMA = vol.Schema({ async def async_setup(hass, hass_config): """Set up the Geofency component.""" - config = hass_config[DOMAIN] - mobile_beacons = config[CONF_MOBILE_BEACONS] + config = hass_config.get(DOMAIN, {}) + mobile_beacons = config.get(CONF_MOBILE_BEACONS, []) hass.data[DOMAIN] = [slugify(beacon) for beacon in mobile_beacons] - - hass.async_create_task( - async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config) - ) return True @@ -136,12 +132,18 @@ async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( DOMAIN, 'Geofency', entry.data[CONF_WEBHOOK_ID], handle_webhook) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, DEVICE_TRACKER) + ) return True async def async_unload_entry(hass, entry): """Unload a config entry.""" hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + + await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER) return True config_entry_flow.register_webhook_flow( diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index af11194c1d6..eea0960ec11 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -6,16 +6,21 @@ https://home-assistant.io/components/device_tracker.geofency/ """ import logging -from homeassistant.components.geofency import TRACKER_UPDATE +from homeassistant.components.device_tracker import DOMAIN as \ + DEVICE_TRACKER_DOMAIN +from homeassistant.components.geofency import TRACKER_UPDATE, \ + DOMAIN as GEOFENCY_DOMAIN from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['geofency'] +DATA_KEY = '{}.{}'.format(GEOFENCY_DOMAIN, DEVICE_TRACKER_DOMAIN) -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up the Geofency device tracker.""" + +async def async_setup_entry(hass, entry, async_see): + """Configure a dispatcher connection based on a config entry.""" async def _set_location(device, gps, location_name, attributes): """Fire HA event to set location.""" await async_see( @@ -25,5 +30,13 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): attributes=attributes ) - async_dispatcher_connect(hass, TRACKER_UPDATE, _set_location) + hass.data[DATA_KEY] = async_dispatcher_connect( + hass, TRACKER_UPDATE, _set_location + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload the config entry and remove the dispatcher connection.""" + hass.data[DATA_KEY]() return True diff --git a/homeassistant/components/gpslogger/.translations/ca.json b/homeassistant/components/gpslogger/.translations/ca.json new file mode 100644 index 00000000000..2d3b08d236e --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de GPSLogger.", + "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." + }, + "create_entry": { + "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar l'opci\u00f3 webhook de GPSLogger.\n\nCompleta la seg\u00fcent informaci\u00f3:\n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." + }, + "step": { + "user": { + "description": "Est\u00e0s segur que vols configurar el Webhook GPSLogger?", + "title": "Configuraci\u00f3 del Webhook GPSLogger" + } + }, + "title": "Webhook GPSLogger" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/en.json b/homeassistant/components/gpslogger/.translations/en.json index d5641ef5db8..ad8f978bc59 100644 --- a/homeassistant/components/gpslogger/.translations/en.json +++ b/homeassistant/components/gpslogger/.translations/en.json @@ -1,18 +1,18 @@ { - "config": { - "title": "GPSLogger Webhook", - "step": { - "user": { - "title": "Set up the GPSLogger Webhook", - "description": "Are you sure you want to set up the GPSLogger Webhook?" - } - }, - "abort": { - "one_instance_allowed": "Only a single instance is necessary.", - "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from GPSLogger." - }, - "create_entry": { - "default": "To send events to Home Assistant, you will need to setup the webhook feature in GPSLogger.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + "config": { + "abort": { + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from GPSLogger.", + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in GPSLogger.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + }, + "step": { + "user": { + "description": "Are you sure you want to set up the GPSLogger Webhook?", + "title": "Set up the GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" } - } } \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/es.json b/homeassistant/components/gpslogger/.translations/es.json new file mode 100644 index 00000000000..cd14e21db10 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/es.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tu Home Assistant debe ser accesible desde Internet para recibir mensajes de GPSLogger.", + "one_instance_allowed": "Solo se necesita una instancia." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/ko.json b/homeassistant/components/gpslogger/.translations/ko.json new file mode 100644 index 00000000000..a65e51d7cae --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "GPSLogger \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.", + "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + }, + "create_entry": { + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 GPSLogger \uc5d0\uc11c Webhook \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + }, + "step": { + "user": { + "description": "GPSLogger Webhook \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "GPSLogger Webhook \uc124\uc815" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/lb.json b/homeassistant/components/gpslogger/.translations/lb.json new file mode 100644 index 00000000000..78df911c868 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir GPSLogger Noriichten z'empf\u00e4nken.", + "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg." + }, + "create_entry": { + "default": "Fir Evenementer un Home Assistant ze sch\u00e9cken, muss den Webhook Feature am GPSLogger ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nLiest [Dokumentatioun]({docs_url}) fir w\u00e9ider D\u00e9tailer." + }, + "step": { + "user": { + "description": "S\u00e9cher fir GPSLogger Webhook anzeriichten?", + "title": "GPSLogger Webhook ariichten" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/no.json b/homeassistant/components/gpslogger/.translations/no.json new file mode 100644 index 00000000000..836b5c8bc68 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta meldinger fra GPSLogger.", + "one_instance_allowed": "Kun en enkelt forekomst er n\u00f8dvendig." + }, + "create_entry": { + "default": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du sette opp webhook-funksjonen i GPSLogger. \n\n Fyll ut f\u00f8lgende informasjon: \n\n - URL: `{webhook_url}` \n - Metode: POST \n\n Se [dokumentasjonen]({docs_url}) for ytterligere detaljer." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil sette opp GPSLogger Webhook?", + "title": "Sett opp GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/pl.json b/homeassistant/components/gpslogger/.translations/pl.json new file mode 100644 index 00000000000..3d82ac6fa5a --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Twoja instancja Home Assistant musi by\u0107 dost\u0119pna z Internetu, aby otrzymywa\u0107 wiadomo\u015bci z GPSlogger.", + "one_instance_allowed": "Wymagana jest tylko jedna instancja." + }, + "create_entry": { + "default": "Aby wysy\u0142a\u0107 lokalizacje do Home Assistant'a, musisz skonfigurowa\u0107 webhook w aplikacji GPSLogger. \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}) by pozna\u0107 szczeg\u00f3\u0142y." + }, + "step": { + "user": { + "description": "Czy chcesz skonfigurowa\u0107 Geofency?", + "title": "Konfiguracja Geofency Webhook" + } + }, + "title": "Konfiguracja Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/ru.json b/homeassistant/components/gpslogger/.translations/ru.json new file mode 100644 index 00000000000..34b7e907288 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/ru.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 GPSLogger." + }, + "create_entry": { + "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0432\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c webhooks \u0434\u043b\u044f GPSLogger.\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c GPSLogger?", + "title": "GPSLogger" + } + }, + "title": "GPSLogger" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/sl.json b/homeassistant/components/gpslogger/.translations/sl.json new file mode 100644 index 00000000000..8e205bef437 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161 Home Assistant mora biti dostopek prek interneta, da boste lahko prejemali GPSlogger sporo\u010dila.", + "one_instance_allowed": "Potrebna je samo ena instanca." + }, + "create_entry": { + "default": "\u010ce \u017eelite dogodke poslati v Home Assistant, morate v GPSLoggerju nastaviti funkcijo webhook. \n\n Izpolnite naslednje podatke: \n\n - URL: ` {webhook_url} ` \n - Metoda: POST \n\n Za ve\u010d podrobnosti si oglejte [dokumentacijo] ( {docs_url} )." + }, + "step": { + "user": { + "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti GPSloggerWebhook?", + "title": "Nastavite GPSlogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/zh-Hans.json b/homeassistant/components/gpslogger/.translations/zh-Hans.json new file mode 100644 index 00000000000..dd5db73f582 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/zh-Hant.json b/homeassistant/components/gpslogger/.translations/zh-Hant.json new file mode 100644 index 00000000000..c9d98da1afc --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 GPSLogger \u8a0a\u606f\u3002", + "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002" + }, + "create_entry": { + "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u65bc GPSLogger \u5167\u8a2d\u5b9a webhook \u529f\u80fd\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002" + }, + "step": { + "user": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a GPSLogger Webhook\uff1f", + "title": "\u8a2d\u5b9a GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 4d1a5708331..d4150900223 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -15,8 +15,8 @@ from homeassistant.components.device_tracker.tile import ATTR_ALTITUDE from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, \ HTTP_OK, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID from homeassistant.helpers import config_entry_flow -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER _LOGGER = logging.getLogger(__name__) @@ -57,9 +57,6 @@ WEBHOOK_SCHEMA = vol.Schema({ async def async_setup(hass, hass_config): """Set up the GPSLogger component.""" - hass.async_create_task( - async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config) - ) return True @@ -103,12 +100,18 @@ async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( DOMAIN, 'GPSLogger', entry.data[CONF_WEBHOOK_ID], handle_webhook) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, DEVICE_TRACKER) + ) return True async def async_unload_entry(hass, entry): """Unload a config entry.""" hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + + await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER) return True config_entry_flow.register_webhook_flow( diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py new file mode 100644 index 00000000000..8a312afa024 --- /dev/null +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -0,0 +1,44 @@ +""" +Support for the GPSLogger platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.gpslogger/ +""" +import logging + +from homeassistant.components.device_tracker import DOMAIN as \ + DEVICE_TRACKER_DOMAIN +from homeassistant.components.gpslogger import DOMAIN as GPSLOGGER_DOMAIN, \ + TRACKER_UPDATE +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['gpslogger'] + +DATA_KEY = '{}.{}'.format(GPSLOGGER_DOMAIN, DEVICE_TRACKER_DOMAIN) + + +async def async_setup_entry(hass: HomeAssistantType, entry, async_see): + """Configure a dispatcher connection based on a config entry.""" + async def _set_location(device, gps_location, battery, accuracy, attrs): + """Fire HA event to set location.""" + await async_see( + dev_id=device, + gps=gps_location, + battery=battery, + gps_accuracy=accuracy, + attributes=attrs + ) + + hass.data[DATA_KEY] = async_dispatcher_connect( + hass, TRACKER_UPDATE, _set_location + ) + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry): + """Unload the config entry and remove the dispatcher connection.""" + hass.data[DATA_KEY]() + return True diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index a630a9ef1ad..5fb2e19edcf 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER from homeassistant.components.switch import DOMAIN as SWITCH -from homeassistant.const import (EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, +from homeassistant.const import (EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_ON, STATE_OFF, CONF_DEVICES, CONF_PLATFORM, STATE_PLAYING, STATE_IDLE, @@ -324,7 +324,7 @@ class CecDevice(Entity): """Initialize the device.""" self._device = device self._icon = None - self._state = STATE_UNKNOWN + self._state = None self._logical_address = logical self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index fb211617ecf..22c47d59c62 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -4,8 +4,8 @@ import logging from pyhap.const import CATEGORY_DOOR_LOCK from homeassistant.components.lock import ( - ATTR_ENTITY_ID, DOMAIN, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN) -from homeassistant.const import ATTR_CODE + ATTR_ENTITY_ID, DOMAIN, STATE_LOCKED, STATE_UNLOCKED) +from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from . import TYPES from .accessories import HomeAccessory diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 6fdde7ddd50..72b7a502aa2 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import call_later -REQUIREMENTS = ['homekit==0.12.0'] +REQUIREMENTS = ['homekit==0.12.2'] DOMAIN = 'homekit_controller' HOMEKIT_DIR = '.homekit' @@ -28,7 +28,8 @@ HOMEKIT_ACCESSORY_DISPATCH = { 'garage-door-opener': 'cover', 'window': 'cover', 'window-covering': 'cover', - 'lock-mechanism': 'lock' + 'lock-mechanism': 'lock', + 'motion': 'binary_sensor', } HOMEKIT_IGNORE = [ @@ -49,10 +50,6 @@ RETRY_INTERVAL = 60 # seconds PAIRING_FILE = "pairing.json" -class HomeKitConnectionError(ConnectionError): - """Raised when unable to connect to target device.""" - - def get_serial(accessory): """Obtain the serial number of a HomeKit device.""" # pylint: disable=import-error @@ -72,6 +69,11 @@ def get_serial(accessory): return None +def escape_characteristic_name(char_name): + """Escape any dash or dots in a characteristics name.""" + return char_name.replace('-', '_').replace('.', '_') + + class HKDevice(): """HomeKit device.""" @@ -101,13 +103,14 @@ class HKDevice(): """Handle setup of a HomeKit accessory.""" # pylint: disable=import-error from homekit.model.services import ServicesTypes + from homekit.exceptions import AccessoryDisconnectedError self.pairing.pairing_data['AccessoryIP'] = self.host self.pairing.pairing_data['AccessoryPort'] = self.port try: data = self.pairing.list_accessories_and_characteristics() - except HomeKitConnectionError: + except AccessoryDisconnectedError: call_later( self.hass, RETRY_INTERVAL, lambda _: self.accessory_setup()) return @@ -196,22 +199,80 @@ class HomeKitEntity(Entity): self._address = "homekit-{}-{}".format(devinfo['serial'], self._iid) self._features = 0 self._chars = {} + self.setup() - def update(self): - """Obtain a HomeKit device's state.""" - try: - pairing = self._accessory.pairing - data = pairing.list_accessories_and_characteristics() - except HomeKitConnectionError: - return - for accessory in data: + def setup(self): + """Configure an entity baed on its HomeKit characterstics metadata.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + pairing_data = self._accessory.pairing.pairing_data + + get_uuid = CharacteristicsTypes.get_uuid + characteristic_types = [ + get_uuid(c) for c in self.get_characteristic_types() + ] + + self._chars_to_poll = [] + self._chars = {} + self._char_names = {} + + for accessory in pairing_data.get('accessories', []): if accessory['aid'] != self._aid: continue for service in accessory['services']: if service['iid'] != self._iid: continue - self.update_characteristics(service['characteristics']) - break + for char in service['characteristics']: + uuid = CharacteristicsTypes.get_uuid(char['type']) + if uuid not in characteristic_types: + continue + self._setup_characteristic(char) + + def _setup_characteristic(self, char): + """Configure an entity based on a HomeKit characteristics metadata.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + # Build up a list of (aid, iid) tuples to poll on update() + self._chars_to_poll.append((self._aid, char['iid'])) + + # Build a map of ctype -> iid + short_name = CharacteristicsTypes.get_short(char['type']) + self._chars[short_name] = char['iid'] + self._char_names[char['iid']] = short_name + + # Callback to allow entity to configure itself based on this + # characteristics metadata (valid values, value ranges, features, etc) + setup_fn_name = escape_characteristic_name(short_name) + setup_fn = getattr(self, '_setup_{}'.format(setup_fn_name), None) + if not setup_fn: + return + # pylint: disable=not-callable + setup_fn(char) + + def update(self): + """Obtain a HomeKit device's state.""" + # pylint: disable=import-error + from homekit.exceptions import AccessoryDisconnectedError + + pairing = self._accessory.pairing + + try: + new_values_dict = pairing.get_characteristics(self._chars_to_poll) + except AccessoryDisconnectedError: + return + + for (_, iid), result in new_values_dict.items(): + if 'value' not in result: + continue + # Callback to update the entity with this characteristic value + char_name = escape_characteristic_name(self._char_names[iid]) + update_fn = getattr(self, '_update_{}'.format(char_name), None) + if not update_fn: + continue + # pylint: disable=not-callable + update_fn(result['value']) @property def unique_id(self): @@ -228,9 +289,13 @@ class HomeKitEntity(Entity): """Return True if entity is available.""" return self._accessory.pairing is not None + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + raise NotImplementedError + def update_characteristics(self, characteristics): """Synchronise a HomeKit device state with Home Assistant.""" - raise NotImplementedError + pass def put_characteristics(self, characteristics): """Control a HomeKit device state from Home Assistant.""" diff --git a/homeassistant/components/alarm_control_panel/homekit_controller.py b/homeassistant/components/homekit_controller/alarm_control_panel.py similarity index 78% rename from homeassistant/components/alarm_control_panel/homekit_controller.py rename to homeassistant/components/homekit_controller/alarm_control_panel.py index cc760a851cf..3a2e5170453 100644 --- a/homeassistant/components/alarm_control_panel/homekit_controller.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -54,24 +54,21 @@ class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): self._state = None self._battery_level = None - def update_characteristics(self, characteristics): - """Synchronise the Alarm Control Panel state with Home Assistant.""" + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" # pylint: disable=import-error from homekit.model.characteristics import CharacteristicsTypes + return [ + CharacteristicsTypes.SECURITY_SYSTEM_STATE_CURRENT, + CharacteristicsTypes.SECURITY_SYSTEM_STATE_TARGET, + CharacteristicsTypes.BATTERY_LEVEL, + ] - for characteristic in characteristics: - ctype = characteristic['type'] - ctype = CharacteristicsTypes.get_short(ctype) - if ctype == "security-system-state.current": - self._chars['security-system-state.current'] = \ - characteristic['iid'] - self._state = CURRENT_STATE_MAP[characteristic['value']] - elif ctype == "security-system-state.target": - self._chars['security-system-state.target'] = \ - characteristic['iid'] - elif ctype == "battery-level": - self._chars['battery-level'] = characteristic['iid'] - self._battery_level = characteristic['value'] + def _update_security_system_state_current(self, value): + self._state = CURRENT_STATE_MAP[value] + + def _update_battery_level(self, value): + self._battery_level = value @property def icon(self): diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py new file mode 100644 index 00000000000..531297dc911 --- /dev/null +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -0,0 +1,53 @@ +""" +Support for Homekit motion sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.homekit_controller/ +""" +import logging + +from homeassistant.components.homekit_controller import (HomeKitEntity, + KNOWN_ACCESSORIES) +from homeassistant.components.binary_sensor import BinarySensorDevice + +DEPENDENCIES = ['homekit_controller'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Homekit motion sensor support.""" + if discovery_info is not None: + accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + add_entities([HomeKitMotionSensor(accessory, discovery_info)], True) + + +class HomeKitMotionSensor(HomeKitEntity, BinarySensorDevice): + """Representation of a Homekit sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._on = False + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + return [ + CharacteristicsTypes.MOTION_DETECTED, + ] + + def _update_motion_detected(self, value): + self._on = value + + @property + def device_class(self): + """Define this binary_sensor as a motion sensor.""" + return 'motion' + + @property + def is_on(self): + """Has motion been detected.""" + return self._on diff --git a/homeassistant/components/climate/homekit_controller.py b/homeassistant/components/homekit_controller/climate.py similarity index 69% rename from homeassistant/components/climate/homekit_controller.py rename to homeassistant/components/homekit_controller/climate.py index e703bfe182d..15378e2b046 100644 --- a/homeassistant/components/climate/homekit_controller.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -27,6 +27,8 @@ MODE_HOMEKIT_TO_HASS = { # Map of hass operation modes to homekit modes MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()} +DEFAULT_VALID_MODES = list(MODE_HOMEKIT_TO_HASS) + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Homekit climate.""" @@ -47,43 +49,54 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): self._current_temp = None self._target_temp = None - def update_characteristics(self, characteristics): - """Synchronise device state with Home Assistant.""" + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" # pylint: disable=import-error - from homekit.models.characteristics import CharacteristicsTypes + from homekit.model.characteristics import CharacteristicsTypes + return [ + CharacteristicsTypes.HEATING_COOLING_CURRENT, + CharacteristicsTypes.HEATING_COOLING_TARGET, + CharacteristicsTypes.TEMPERATURE_CURRENT, + CharacteristicsTypes.TEMPERATURE_TARGET, + ] - for characteristic in characteristics: - ctype = characteristic['type'] - if ctype == CharacteristicsTypes.HEATING_COOLING_CURRENT: - self._state = MODE_HOMEKIT_TO_HASS.get( - characteristic['value']) - if ctype == CharacteristicsTypes.HEATING_COOLING_TARGET: - self._chars['target_mode'] = characteristic['iid'] - self._features |= SUPPORT_OPERATION_MODE - self._current_mode = MODE_HOMEKIT_TO_HASS.get( - characteristic['value']) - self._valid_modes = [MODE_HOMEKIT_TO_HASS.get( - mode) for mode in characteristic['valid-values']] - elif ctype == CharacteristicsTypes.TEMPERATURE_CURRENT: - self._current_temp = characteristic['value'] - elif ctype == CharacteristicsTypes.TEMPERATURE_TARGET: - self._chars['target_temp'] = characteristic['iid'] - self._features |= SUPPORT_TARGET_TEMPERATURE - self._target_temp = characteristic['value'] + def _setup_heating_cooling_target(self, characteristic): + self._features |= SUPPORT_OPERATION_MODE + + valid_values = characteristic.get( + 'valid-values', DEFAULT_VALID_MODES) + self._valid_modes = [ + MODE_HOMEKIT_TO_HASS.get(mode) for mode in valid_values + ] + + def _setup_temperature_target(self, characteristic): + self._features |= SUPPORT_TARGET_TEMPERATURE + + def _update_heating_cooling_current(self, value): + self._state = MODE_HOMEKIT_TO_HASS.get(value) + + def _update_heating_cooling_target(self, value): + self._current_mode = MODE_HOMEKIT_TO_HASS.get(value) + + def _update_temperature_current(self, value): + self._current_temp = value + + def _update_temperature_target(self, value): + self._target_temp = value def set_temperature(self, **kwargs): """Set new target temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) characteristics = [{'aid': self._aid, - 'iid': self._chars['target_temp'], + 'iid': self._chars['temperature.target'], 'value': temp}] self.put_characteristics(characteristics) def set_operation_mode(self, operation_mode): """Set new target operation mode.""" characteristics = [{'aid': self._aid, - 'iid': self._chars['target_mode'], + 'iid': self._chars['heating-cooling.target'], 'value': MODE_HASS_TO_HOMEKIT[operation_mode]}] self.put_characteristics(characteristics) diff --git a/homeassistant/components/cover/homekit_controller.py b/homeassistant/components/homekit_controller/cover.py similarity index 67% rename from homeassistant/components/cover/homekit_controller.py rename to homeassistant/components/homekit_controller/cover.py index cd3bc511291..c8f087254bb 100644 --- a/homeassistant/components/cover/homekit_controller.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -62,7 +62,6 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): def __init__(self, accessory, discovery_info): """Initialise the Cover.""" super().__init__(accessory, discovery_info) - self._name = None self._state = None self._obstruction_detected = None self.lock_state = None @@ -72,32 +71,28 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): """Define this cover as a garage door.""" return 'garage' - def update_characteristics(self, characteristics): - """Synchronise the Cover state with Home Assistant.""" + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" # pylint: disable=import-error from homekit.model.characteristics import CharacteristicsTypes + return [ + CharacteristicsTypes.DOOR_STATE_CURRENT, + CharacteristicsTypes.DOOR_STATE_TARGET, + CharacteristicsTypes.OBSTRUCTION_DETECTED, + CharacteristicsTypes.NAME, + ] - for characteristic in characteristics: - ctype = characteristic['type'] - ctype = CharacteristicsTypes.get_short(ctype) - if ctype == "door-state.current": - self._chars['door-state.current'] = \ - characteristic['iid'] - self._state = CURRENT_GARAGE_STATE_MAP[characteristic['value']] - elif ctype == "door-state.target": - self._chars['door-state.target'] = \ - characteristic['iid'] - elif ctype == "obstruction-detected": - self._chars['obstruction-detected'] = characteristic['iid'] - self._obstruction_detected = characteristic['value'] - elif ctype == "name": - self._chars['name'] = characteristic['iid'] - self._name = characteristic['value'] + def _setup_name(self, char): + self._name = char['value'] - @property - def name(self): - """Return the name of the cover.""" - return self._name + def _update_door_state_current(self, value): + self._state = CURRENT_GARAGE_STATE_MAP[value] + + def _update_obstruction_detected(self, value): + self._obstruction_detected = value + + def _update_name(self, value): + self._name = value @property def available(self): @@ -156,7 +151,6 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): def __init__(self, accessory, discovery_info): """Initialise the Cover.""" super().__init__(accessory, discovery_info) - self._name = None self._state = None self._position = None self._tilt_position = None @@ -169,57 +163,46 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): """Return True if entity is available.""" return self._state is not None - def update_characteristics(self, characteristics): - """Synchronise the Cover state with Home Assistant.""" + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" # pylint: disable=import-error from homekit.model.characteristics import CharacteristicsTypes + return [ + CharacteristicsTypes.POSITION_STATE, + CharacteristicsTypes.POSITION_CURRENT, + CharacteristicsTypes.POSITION_TARGET, + CharacteristicsTypes.POSITION_HOLD, + CharacteristicsTypes.VERTICAL_TILT_CURRENT, + CharacteristicsTypes.VERTICAL_TILT_TARGET, + CharacteristicsTypes.HORIZONTAL_TILT_CURRENT, + CharacteristicsTypes.HORIZONTAL_TILT_TARGET, + CharacteristicsTypes.OBSTRUCTION_DETECTED, + CharacteristicsTypes.NAME, + ] - for characteristic in characteristics: - ctype = characteristic['type'] - ctype = CharacteristicsTypes.get_short(ctype) - if ctype == "position.state": - self._chars['position.state'] = \ - characteristic['iid'] - if 'value' in characteristic: - self._state = \ - CURRENT_WINDOW_STATE_MAP[characteristic['value']] - elif ctype == "position.current": - self._chars['position.current'] = \ - characteristic['iid'] - self._position = characteristic['value'] - elif ctype == "position.target": - self._chars['position.target'] = \ - characteristic['iid'] - elif ctype == "position.hold": - self._chars['position.hold'] = characteristic['iid'] - if 'value' in characteristic: - self._hold = characteristic['value'] - elif ctype == "vertical-tilt.current": - self._chars['vertical-tilt.current'] = characteristic['iid'] - if characteristic['value'] is not None: - self._tilt_position = characteristic['value'] - elif ctype == "horizontal-tilt.current": - self._chars['horizontal-tilt.current'] = characteristic['iid'] - if characteristic['value'] is not None: - self._tilt_position = characteristic['value'] - elif ctype == "vertical-tilt.target": - self._chars['vertical-tilt.target'] = \ - characteristic['iid'] - elif ctype == "horizontal-tilt.target": - self._chars['vertical-tilt.target'] = \ - characteristic['iid'] - elif ctype == "obstruction-detected": - self._chars['obstruction-detected'] = characteristic['iid'] - self._obstruction_detected = characteristic['value'] - elif ctype == "name": - self._chars['name'] = characteristic['iid'] - if 'value' in characteristic: - self._name = characteristic['value'] + def _setup_name(self, char): + self._name = char['value'] - @property - def name(self): - """Return the name of the cover.""" - return self._name + def _update_position_state(self, value): + self._state = CURRENT_WINDOW_STATE_MAP[value] + + def _update_position_current(self, value): + self._position = value + + def _update_position_hold(self, value): + self._hold = value + + def _update_vertical_tilt_current(self, value): + self._tilt_position = value + + def _update_horizontal_tilt_current(self, value): + self._tilt_position = value + + def _update_obstruction_detected(self, value): + self._obstruction_detected = value + + def _update_name(self, value): + self._hold = value @property def supported_features(self): diff --git a/homeassistant/components/light/homekit_controller.py b/homeassistant/components/homekit_controller/light.py similarity index 74% rename from homeassistant/components/light/homekit_controller.py rename to homeassistant/components/homekit_controller/light.py index 7c8119f6e89..74ef8948f45 100644 --- a/homeassistant/components/light/homekit_controller.py +++ b/homeassistant/components/homekit_controller/light.py @@ -36,33 +36,44 @@ class HomeKitLight(HomeKitEntity, Light): self._hue = None self._saturation = None - def update_characteristics(self, characteristics): - """Synchronise light state with Home Assistant.""" + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" # pylint: disable=import-error from homekit.model.characteristics import CharacteristicsTypes + return [ + CharacteristicsTypes.ON, + CharacteristicsTypes.BRIGHTNESS, + CharacteristicsTypes.COLOR_TEMPERATURE, + CharacteristicsTypes.HUE, + CharacteristicsTypes.SATURATION, + ] - for characteristic in characteristics: - ctype = characteristic['type'] - ctype = CharacteristicsTypes.get_short(ctype) - if ctype == "on": - self._chars['on'] = characteristic['iid'] - self._on = characteristic['value'] - elif ctype == 'brightness': - self._chars['brightness'] = characteristic['iid'] - self._features |= SUPPORT_BRIGHTNESS - self._brightness = characteristic['value'] - elif ctype == 'color-temperature': - self._chars['color_temperature'] = characteristic['iid'] - self._features |= SUPPORT_COLOR_TEMP - self._color_temperature = characteristic['value'] - elif ctype == "hue": - self._chars['hue'] = characteristic['iid'] - self._features |= SUPPORT_COLOR - self._hue = characteristic['value'] - elif ctype == "saturation": - self._chars['saturation'] = characteristic['iid'] - self._features |= SUPPORT_COLOR - self._saturation = characteristic['value'] + def _setup_brightness(self, char): + self._features |= SUPPORT_BRIGHTNESS + + def _setup_color_temperature(self, char): + self._features |= SUPPORT_COLOR_TEMP + + def _setup_hue(self, char): + self._features |= SUPPORT_COLOR + + def _setup_saturation(self, char): + self._features |= SUPPORT_COLOR + + def _update_on(self, value): + self._on = value + + def _update_brightness(self, value): + self._brightness = value + + def _update_color_temperature(self, value): + self._color_temperature = value + + def _update_hue(self, value): + self._hue = value + + def _update_saturation(self, value): + self._saturation = value @property def is_on(self): diff --git a/homeassistant/components/lock/homekit_controller.py b/homeassistant/components/homekit_controller/lock.py similarity index 76% rename from homeassistant/components/lock/homekit_controller.py rename to homeassistant/components/homekit_controller/lock.py index 910567ed182..e27ed444528 100644 --- a/homeassistant/components/lock/homekit_controller.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -51,24 +51,21 @@ class HomeKitLock(HomeKitEntity, LockDevice): self._name = discovery_info['model'] self._battery_level = None - def update_characteristics(self, characteristics): - """Synchronise the Lock state with Home Assistant.""" + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" # pylint: disable=import-error from homekit.model.characteristics import CharacteristicsTypes + return [ + CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE, + CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE, + CharacteristicsTypes.BATTERY_LEVEL, + ] - for characteristic in characteristics: - ctype = characteristic['type'] - ctype = CharacteristicsTypes.get_short(ctype) - if ctype == "lock-mechanism.current-state": - self._chars['lock-mechanism.current-state'] = \ - characteristic['iid'] - self._state = CURRENT_STATE_MAP[characteristic['value']] - elif ctype == "lock-mechanism.target-state": - self._chars['lock-mechanism.target-state'] = \ - characteristic['iid'] - elif ctype == "battery-level": - self._chars['battery-level'] = characteristic['iid'] - self._battery_level = characteristic['value'] + def _update_lock_mechanism_current_state(self, value): + self._state = CURRENT_STATE_MAP[value] + + def _update_battery_level(self, value): + self._battery_level = value @property def name(self): diff --git a/homeassistant/components/switch/homekit_controller.py b/homeassistant/components/homekit_controller/switch.py similarity index 78% rename from homeassistant/components/switch/homekit_controller.py rename to homeassistant/components/homekit_controller/switch.py index 51a71163bad..ba4a04022f0 100644 --- a/homeassistant/components/switch/homekit_controller.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -33,20 +33,20 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): self._on = None self._outlet_in_use = None - def update_characteristics(self, characteristics): - """Synchronise the switch state with Home Assistant.""" + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" # pylint: disable=import-error from homekit.model.characteristics import CharacteristicsTypes + return [ + CharacteristicsTypes.ON, + CharacteristicsTypes.OUTLET_IN_USE, + ] - for characteristic in characteristics: - ctype = characteristic['type'] - ctype = CharacteristicsTypes.get_short(ctype) - if ctype == "on": - self._chars['on'] = characteristic['iid'] - self._on = characteristic['value'] - elif ctype == "outlet-in-use": - self._chars['outlet-in-use'] = characteristic['iid'] - self._outlet_in_use = characteristic['value'] + def _update_on(self, value): + self._on = value + + def _update_outlet_in_use(self, value): + self._outlet_in_use = value @property def is_on(self): diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index e2709bde92a..9a496d914fc 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -19,7 +19,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyhomematic==0.1.54'] +REQUIREMENTS = ['pyhomematic==0.1.55'] _LOGGER = logging.getLogger(__name__) @@ -65,7 +65,7 @@ HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ 'Switch', 'SwitchPowermeter', 'IOSwitch', 'IPSwitch', 'RFSiren', 'IPSwitchPowermeter', 'HMWIOSwitch', 'Rain', 'EcoLogic', - 'IPKeySwitchPowermeter', 'IPGarage'], + 'IPKeySwitchPowermeter', 'IPGarage', 'IPKeySwitch', 'IPMultiIO'], DISCOVER_LIGHTS: ['Dimmer', 'KeyDimmer', 'IPKeyDimmer', 'IPDimmer', 'ColorEffectLight'], DISCOVER_SENSORS: [ @@ -79,7 +79,7 @@ HM_DEVICE_TYPES = { 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus', 'IPWeatherSensorBasic', 'IPBrightnessSensor', 'IPGarage', - 'UniversalSensor', 'MotionIPV2'], + 'UniversalSensor', 'MotionIPV2', 'IPMultiIO'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', @@ -89,7 +89,8 @@ HM_DEVICE_TYPES = { 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', 'WiredSensor', 'PresenceIP', 'IPWeatherSensor', 'IPPassageSensor', - 'SmartwareMotion', 'IPWeatherSensorPlus', 'MotionIPV2'], + 'SmartwareMotion', 'IPWeatherSensorPlus', 'MotionIPV2', 'WaterIP', + 'IPMultiIO'], DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], DISCOVER_LOCKS: ['KeyMatic'] } diff --git a/homeassistant/components/homematicip_cloud/.translations/sl.json b/homeassistant/components/homematicip_cloud/.translations/sl.json index eabb31ac833..cdde0f12d78 100644 --- a/homeassistant/components/homematicip_cloud/.translations/sl.json +++ b/homeassistant/components/homematicip_cloud/.translations/sl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dostopna to\u010dka je \u017ee konfigurirana", + "already_configured": "Dostopna to\u010dka je \u017ee nastavljena", "connection_aborted": "Povezava s stre\u017enikom HMIP ni bila mogo\u010da", "unknown": "Pri\u0161lo je do neznane napake" }, @@ -21,8 +21,8 @@ "title": "Izberite dostopno to\u010dko HomematicIP" }, "link": { - "description": "Pritisnite modro tipko na dostopni to\u010dko in gumb po\u0161lji, da registrirate homematicIP s Home Assistentom. \n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", - "title": "Pove\u017eite dostopno to\u010dno" + "description": "Pritisnite modro tipko na dostopni to\u010dko in gumb po\u0161lji, da registrirate homematicIP s Home Assistantom. \n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Pove\u017eite dostopno to\u010dko" } }, "title": "HomematicIP Cloud" diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json index 629ee4347fe..4c2b6268eec 100644 --- a/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json +++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\u63a5\u5165\u70b9\u5df2\u7ecf\u914d\u7f6e\u5b8c\u6210", + "already_configured": "\u63a5\u5165\u70b9\u5df2\u914d\u7f6e", "connection_aborted": "\u65e0\u6cd5\u8fde\u63a5\u5230 HMIP \u670d\u52a1\u5668", "unknown": "\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002" }, diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 700e6274c35..b0ea1a3b348 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -19,7 +19,7 @@ from .const import ( from .device import HomematicipGenericDevice # noqa: F401 from .hap import HomematicipAuth, HomematicipHAP # noqa: F401 -REQUIREMENTS = ['homematicip==0.10.3'] +REQUIREMENTS = ['homematicip==0.10.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index ba9c37b83d7..06864d50ad1 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -9,6 +9,7 @@ COMPONENTS = [ 'alarm_control_panel', 'binary_sensor', 'climate', + 'cover', 'light', 'sensor', 'switch', diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index a6b9588fce3..02b9affefd4 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -25,8 +25,7 @@ from .auth import setup_auth from .ban import setup_bans from .cors import setup_cors from .real_ip import setup_real_ip -from .static import ( - CachingFileResponse, CachingStaticResource, staticresource_middleware) +from .static import CachingFileResponse, CachingStaticResource # Import as alias from .const import KEY_AUTHENTICATED, KEY_REAL_IP # noqa @@ -99,6 +98,7 @@ class ApiConfig: self.port = port self.api_password = api_password + host = host.rstrip('/') if host.startswith(("http://", "https://")): self.base_url = host elif use_ssl: @@ -191,8 +191,7 @@ class HomeAssistantHTTP: use_x_forwarded_for, trusted_proxies, trusted_networks, login_threshold, is_ban_enabled, ssl_profile): """Initialize the HTTP Home Assistant server.""" - app = self.app = web.Application( - middlewares=[staticresource_middleware]) + app = self.app = web.Application(middlewares=[]) # This order matters setup_real_ip(app, use_x_forwarded_for, trusted_proxies) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index d6d7168ce6d..0d748c91c66 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -104,7 +104,7 @@ async def process_wrong_login(request): request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] += 1 - if (request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > + if (request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] >= request.app[KEY_LOGIN_THRESHOLD]): new_ban = IpBan(remote_addr) request.app[KEY_BANNED_IPS].append(new_ban) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 8b28a7cf288..54e72c88ff3 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -1,15 +1,10 @@ """Static file handling for HTTP component.""" - -import re - from aiohttp import hdrs -from aiohttp.web import FileResponse, middleware +from aiohttp.web import FileResponse from aiohttp.web_exceptions import HTTPNotFound from aiohttp.web_urldispatcher import StaticResource from yarl import URL -_FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) - class CachingStaticResource(StaticResource): """Static Resource handler that will add cache headers.""" @@ -56,19 +51,3 @@ class CachingFileResponse(FileResponse): # Overwriting like this because __init__ can change implementation. self._sendfile = sendfile - - -@middleware -async def staticresource_middleware(request, handler): - """Middleware to strip out fingerprint from fingerprinted assets.""" - path = request.path - if not path.startswith('/static/') and not path.startswith('/frontend'): - return await handler(request) - - fingerprinted = _FINGERPRINT.match(request.match_info['filename']) - - if fingerprinted: - request.match_info['filename'] = \ - '{}.{}'.format(*fingerprinted.groups()) - - return await handler(request) diff --git a/homeassistant/components/hue/.translations/pl.json b/homeassistant/components/hue/.translations/pl.json index 784fa0d99a6..63cbbe016a2 100644 --- a/homeassistant/components/hue/.translations/pl.json +++ b/homeassistant/components/hue/.translations/pl.json @@ -24,6 +24,6 @@ "title": "Hub Link" } }, - "title": "Mostek Philips Hue" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/sl.json b/homeassistant/components/hue/.translations/sl.json index 05d52d5c37e..7ad7a2e6ade 100644 --- a/homeassistant/components/hue/.translations/sl.json +++ b/homeassistant/components/hue/.translations/sl.json @@ -20,7 +20,7 @@ "title": "Izberite Hue most" }, "link": { - "description": "Pritisnite gumb na mostu, da registrirate Philips Hue s Home Assistentom. \n\n ! [Polo\u017eaj gumba na mostu] (/static/images/config_philips_hue.jpg)", + "description": "Pritisnite gumb na mostu, da registrirate Philips Hue s Home Assistantom. \n\n ! [Polo\u017eaj gumba na mostu] (/static/images/config_philips_hue.jpg)", "title": "Link Hub" } }, diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index b10e5bb29de..7618e702d04 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -19,7 +19,7 @@ from .bridge import HueBridge # Loading the config flow file will register the flow from .config_flow import configured_hosts -REQUIREMENTS = ['aiohue==1.8.0'] +REQUIREMENTS = ['aiohue==1.9.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 3327d0f9dc2..51e50f629b5 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -230,12 +230,18 @@ class HueLight(Light): self.gamut_typ = self.light.colorgamuttype self.gamut = self.light.colorgamut _LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut)) + if self.light.swupdatestate == "readytoinstall": + err = ( + "Please check for software updates of the bridge " + "and/or the bulb: %s, in the Philips Hue App." + ) + _LOGGER.warning(err, self.name) if self.gamut: if not color.check_valid_gamut(self.gamut): - err = "Please check for software updates of the bridge " \ - "and/or bulb in the Philips Hue App, " \ - "Color gamut of %s: %s, not valid, " \ - "setting gamut to None." + err = ( + "Color gamut of %s: %s, not valid, " + "setting gamut to None." + ) _LOGGER.warning(err, self.name, str(self.gamut)) self.gamut_typ = GAMUT_TYPE_UNAVAILABLE self.gamut = None diff --git a/homeassistant/components/ifttt/.translations/sl.json b/homeassistant/components/ifttt/.translations/sl.json index f5cc1dc572e..efb966880eb 100644 --- a/homeassistant/components/ifttt/.translations/sl.json +++ b/homeassistant/components/ifttt/.translations/sl.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "not_internet_accessible": "Va\u0161 Home Assistent mora biti dostopek prek interneta, da boste lahko prejemali IFTTT sporo\u010dila.", + "not_internet_accessible": "Va\u0161 Home Assistant mora biti dostopek prek interneta, da boste lahko prejemali IFTTT sporo\u010dila.", "one_instance_allowed": "Potrebna je samo ena instanca." }, "create_entry": { - "default": "\u010ce \u017eelite poslati dogodke Home Assistent-u, boste morali uporabiti akcijo \u00bbNaredi spletno zahtevo\u00ab iz orodja [IFTTT Webhook applet] ( {applet_url} ). \n\n Izpolnite naslednje podatke: \n\n - URL: ` {webhook_url} ` \n - Metoda: POST \n - Vrsta vsebine: application/json \n\n Poglejte si [dokumentacijo] ( {docs_url} ) o tem, kako konfigurirati avtomatizacijo za obdelavo dohodnih podatkov." + "default": "\u010ce \u017eelite poslati dogodke Home Assistant-u, boste morali uporabiti akcijo \u00bbNaredi spletno zahtevo\u00ab iz orodja [IFTTT Webhook applet] ( {applet_url} ). \n\n Izpolnite naslednje podatke: \n\n - URL: ` {webhook_url} ` \n - Metoda: POST \n - Vrsta vsebine: application/json \n\n Poglejte si [dokumentacijo] ( {docs_url} ) o tem, kako konfigurirati avtomatizacijo za obdelavo dohodnih podatkov." }, "step": { "user": { diff --git a/homeassistant/components/image_processing/openalpr_local.py b/homeassistant/components/image_processing/openalpr_local.py index 9d5ebf2e2b9..4a98594d50c 100644 --- a/homeassistant/components/image_processing/openalpr_local.py +++ b/homeassistant/components/image_processing/openalpr_local.py @@ -13,7 +13,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.core import split_entity_id, callback -from homeassistant.const import STATE_UNKNOWN, CONF_REGION +from homeassistant.const import CONF_REGION from homeassistant.components.image_processing import ( PLATFORM_SCHEMA, ImageProcessingEntity, CONF_CONFIDENCE, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME, ATTR_ENTITY_ID, ATTR_CONFIDENCE) @@ -82,7 +82,7 @@ class ImageProcessingAlprEntity(ImageProcessingEntity): def state(self): """Return the state of the entity.""" confidence = 0 - plate = STATE_UNKNOWN + plate = None # search high plate for i_pl, i_co in self.plates.items(): diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index 7694dbd6735..8ca6e4d8a53 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -16,7 +16,7 @@ from homeassistant.components.image_processing import ( from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.4'] +REQUIREMENTS = ['numpy==1.16.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/qrcode.py b/homeassistant/components/image_processing/qrcode.py new file mode 100644 index 00000000000..00f4ad025b2 --- /dev/null +++ b/homeassistant/components/image_processing/qrcode.py @@ -0,0 +1,69 @@ +""" +Support for the QR image processing. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/image_processing.qr/ +""" +from homeassistant.core import split_entity_id +from homeassistant.components.image_processing import ( + ImageProcessingEntity, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME) + +REQUIREMENTS = ['pyzbar==0.1.7', 'pillow==5.4.1'] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the demo image processing platform.""" + # pylint: disable=unused-argument + entities = [] + for camera in config[CONF_SOURCE]: + entities.append(QrEntity( + camera[CONF_ENTITY_ID], camera.get(CONF_NAME) + )) + + add_entities(entities) + + +class QrEntity(ImageProcessingEntity): + """QR image processing entity.""" + + def __init__(self, camera_entity, name): + """Initialize QR image processing entity.""" + super().__init__() + + self._camera = camera_entity + if name: + self._name = name + else: + self._name = "QR {0}".format( + split_entity_id(camera_entity)[1]) + self._state = None + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def state(self): + """Return the state of the entity.""" + return self._state + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + def process_image(self, image): + """Process image.""" + import io + from pyzbar import pyzbar + from PIL import Image + + stream = io.BytesIO(image) + img = Image.open(stream) + + barcodes = pyzbar.decode(img) + if barcodes: + self._state = barcodes[0].data.decode("utf-8") + else: + self._state = None diff --git a/homeassistant/components/image_processing/tensorflow.py b/homeassistant/components/image_processing/tensorflow.py index 939b5a821cb..cc25756f2d0 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -20,7 +20,7 @@ from homeassistant.core import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.4', 'pillow==5.4.1', 'protobuf==3.6.1'] +REQUIREMENTS = ['numpy==1.16.0', 'pillow==5.4.1', 'protobuf==3.6.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 9b539b0690a..a9916ed54fe 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -17,7 +17,7 @@ from homeassistant.helpers import discovery, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, Dict -REQUIREMENTS = ['PyISY==1.1.0'] +REQUIREMENTS = ['PyISY==1.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lcn.py b/homeassistant/components/lcn.py index 597acb3bb02..8efdcc99794 100644 --- a/homeassistant/components/lcn.py +++ b/homeassistant/components/lcn.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_ADDRESS, CONF_HOST, CONF_LIGHTS, CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_USERNAME) + CONF_SWITCHES, CONF_USERNAME) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.entity import Entity @@ -32,8 +32,10 @@ CONF_TRANSITION = 'transition' CONF_DIMMABLE = 'dimmable' CONF_CONNECTIONS = 'connections' -DIM_MODES = ['steps50', 'steps200'] -OUTPUT_PORTS = ['output1', 'output2', 'output3', 'output4'] +DIM_MODES = ['STEPS50', 'STEPS200'] +OUTPUT_PORTS = ['OUTPUT1', 'OUTPUT2', 'OUTPUT3', 'OUTPUT4'] +RELAY_PORTS = ['RELAY1', 'RELAY2', 'RELAY3', 'RELAY4', + 'RELAY5', 'RELAY6', 'RELAY7', 'RELAY8'] # Regex for address validation PATTERN_ADDRESS = re.compile('^((?P\\w+)\\.)?s?(?P\\d+)' @@ -85,21 +87,29 @@ def is_address(value): LIGHTS_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): is_address, - vol.Required(CONF_OUTPUT): vol.All(vol.In(OUTPUT_PORTS), vol.Upper), + vol.Required(CONF_OUTPUT): vol.All(vol.Upper, + vol.In(OUTPUT_PORTS + RELAY_PORTS)), vol.Optional(CONF_DIMMABLE, default=False): vol.Coerce(bool), vol.Optional(CONF_TRANSITION, default=0): vol.All(vol.Coerce(float), vol.Range(min=0., max=486.), lambda value: value * 1000), }) +SWITCHES_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ADDRESS): is_address, + vol.Required(CONF_OUTPUT): vol.All(vol.Upper, + vol.In(OUTPUT_PORTS + RELAY_PORTS)) +}) + CONNECTION_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_SK_NUM_TRIES, default=3): cv.positive_int, - vol.Optional(CONF_DIM_MODE, default='steps50'): vol.All(vol.In(DIM_MODES), - vol.Upper), + vol.Optional(CONF_DIM_MODE, default='steps50'): vol.All(vol.Upper, + vol.In(DIM_MODES)), vol.Optional(CONF_NAME): cv.string }) @@ -107,7 +117,8 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_CONNECTIONS): vol.All( cv.ensure_list, has_unique_connection_names, [CONNECTION_SCHEMA]), - vol.Required(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHTS_SCHEMA]) + vol.Optional(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHTS_SCHEMA]), + vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCHES_SCHEMA]) }) }, extra=vol.ALLOW_EXTRA) @@ -165,6 +176,10 @@ async def async_setup(hass, config): async_load_platform(hass, 'light', DOMAIN, config[DOMAIN][CONF_LIGHTS], config)) + hass.async_create_task( + async_load_platform(hass, 'switch', DOMAIN, + config[DOMAIN][CONF_SWITCHES], config)) + return True @@ -180,11 +195,11 @@ class LcnDevice(Entity): self._name = config[CONF_NAME] @property - def should_poll(self) -> bool: + def should_poll(self): """Lcn device entity pushes its state to HA.""" return False - async def async_added_to_hass(self) -> None: + async def async_added_to_hass(self): """Run when entity about to be added to hass.""" self.address_connection.register_for_inputs( self.input_received) diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index f2713197ed1..a2ae6266a8d 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -3,6 +3,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant import config_entries +from homeassistant.const import CONF_PORT from homeassistant.helpers import config_entry_flow from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN @@ -15,6 +16,7 @@ CONF_BROADCAST = 'broadcast' INTERFACE_SCHEMA = vol.Schema({ vol.Optional(CONF_SERVER): cv.string, + vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_BROADCAST): cv.string, }) diff --git a/homeassistant/components/light/everlights.py b/homeassistant/components/light/everlights.py new file mode 100644 index 00000000000..31e72c78fd6 --- /dev/null +++ b/homeassistant/components/light/everlights.py @@ -0,0 +1,177 @@ +""" +Support for EverLights lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.everlights/ +""" +import logging +from datetime import timedelta +from typing import Tuple + +import voluptuous as vol + +from homeassistant.const import CONF_HOSTS +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_EFFECT, + SUPPORT_BRIGHTNESS, SUPPORT_EFFECT, SUPPORT_COLOR, + Light, PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.exceptions import PlatformNotReady + +REQUIREMENTS = ['pyeverlights==0.1.0'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_EVERLIGHTS = (SUPPORT_EFFECT | SUPPORT_BRIGHTNESS | SUPPORT_COLOR) + +SCAN_INTERVAL = timedelta(minutes=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOSTS): vol.All(cv.ensure_list, [cv.string]), +}) + +NAME_FORMAT = "EverLights {} Zone {}" + + +def color_rgb_to_int(red: int, green: int, blue: int) -> int: + """Return a RGB color as an integer.""" + return red*256*256+green*256+blue + + +def color_int_to_rgb(value: int) -> Tuple[int, int, int]: + """Return an RGB tuple from an integer.""" + return (value >> 16, (value >> 8) & 0xff, value & 0xff) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the EverLights lights from configuration.yaml.""" + import pyeverlights + lights = [] + + for ipaddr in config[CONF_HOSTS]: + api = pyeverlights.EverLights(ipaddr, + async_get_clientsession(hass)) + + try: + status = await api.get_status() + + effects = await api.get_all_patterns() + + except pyeverlights.ConnectionError: + raise PlatformNotReady + + else: + lights.append(EverLightsLight(api, pyeverlights.ZONE_1, + status, effects)) + lights.append(EverLightsLight(api, pyeverlights.ZONE_2, + status, effects)) + + async_add_entities(lights) + + +class EverLightsLight(Light): + """Representation of a Flux light.""" + + def __init__(self, api, channel, status, effects): + """Initialize the light.""" + self._api = api + self._channel = channel + self._status = status + self._effects = effects + self._mac = status['mac'] + self._error_reported = False + self._hs_color = [255, 255] + self._brightness = 255 + self._effect = None + self._available = True + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return '{}-{}'.format(self._mac, self._channel) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def name(self): + """Return the name of the device.""" + return NAME_FORMAT.format(self._mac, self._channel) + + @property + def is_on(self): + """Return true if device is on.""" + return self._status['ch{}Active'.format(self._channel)] == 1 + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def hs_color(self): + """Return the color property.""" + return self._hs_color + + @property + def effect(self): + """Return the effect property.""" + return self._effect + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_EVERLIGHTS + + @property + def effect_list(self): + """Return the list of supported effects.""" + return self._effects + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + hs_color = kwargs.get(ATTR_HS_COLOR, self._hs_color) + brightness = kwargs.get(ATTR_BRIGHTNESS, self._brightness) + effect = kwargs.get(ATTR_EFFECT) + + if effect is not None: + colors = await self._api.set_pattern_by_id(self._channel, effect) + + rgb = color_int_to_rgb(colors[0]) + hsv = color_util.color_RGB_to_hsv(*rgb) + hs_color = hsv[:2] + brightness = hsv[2] / 100 * 255 + + else: + rgb = color_util.color_hsv_to_RGB(*hs_color, brightness/255*100) + colors = [color_rgb_to_int(*rgb)] + + await self._api.set_pattern(self._channel, colors) + + self._hs_color = hs_color + self._brightness = brightness + self._effect = effect + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + await self._api.clear_pattern(self._channel) + + async def async_update(self): + """Synchronize state with control box.""" + import pyeverlights + + try: + self._status = await self._api.get_status() + except pyeverlights.ConnectionError: + if self._available: + _LOGGER.warning("EverLights control box connection lost.") + self._available = False + else: + if not self._available: + _LOGGER.warning("EverLights control box connection restored.") + self._available = True diff --git a/homeassistant/components/light/hyperion.py b/homeassistant/components/light/hyperion.py index 16be7d45825..ebe209c745e 100644 --- a/homeassistant/components/light/hyperion.py +++ b/homeassistant/components/light/hyperion.py @@ -177,11 +177,6 @@ class Hyperion(Light): def turn_off(self, **kwargs): """Disconnect all remotes.""" self.json_request({'command': 'clearall'}) - self.json_request({ - 'command': 'color', - 'priority': self._priority, - 'color': [0, 0, 0] - }) def update(self): """Get the lights status.""" diff --git a/homeassistant/components/light/lcn.py b/homeassistant/components/light/lcn.py index 3f00d305a14..b9457b7b7d9 100644 --- a/homeassistant/components/light/lcn.py +++ b/homeassistant/components/light/lcn.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/light.lcn/ from homeassistant.components.lcn import ( CONF_CONNECTIONS, CONF_DIMMABLE, CONF_OUTPUT, CONF_TRANSITION, DATA_LCN, - LcnDevice, get_connection) + OUTPUT_PORTS, LcnDevice, get_connection) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION, Light) @@ -19,6 +19,9 @@ DEPENDENCIES = ['lcn'] async def async_setup_platform(hass, hass_config, async_add_entities, discovery_info=None): """Set up the LCN light platform.""" + if discovery_info is None: + return + import pypck devices = [] @@ -29,7 +32,13 @@ async def async_setup_platform(hass, hass_config, async_add_entities, connection = get_connection(connections, connection_id) address_connection = connection.get_address_conn(addr) - devices.append(LcnOutputLight(config, address_connection)) + if config[CONF_OUTPUT] in OUTPUT_PORTS: + device = LcnOutputLight(config, address_connection) + else: # in RELAY_PORTS + device = LcnRelayLight(config, address_connection) + + devices.append(device) + async_add_entities(devices) @@ -50,7 +59,7 @@ class LcnOutputLight(LcnDevice, Light): self._is_on = None self._is_dimming_to_zero = False - async def async_added_to_hass(self) -> None: + async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() self.hass.async_create_task( @@ -119,3 +128,55 @@ class LcnOutputLight(LcnDevice, Light): if not self._is_dimming_to_zero: self._is_on = self.brightness > 0 self.async_schedule_update_ha_state() + + +class LcnRelayLight(LcnDevice, Light): + """Representation of a LCN light for relay ports.""" + + def __init__(self, config, address_connection): + """Initialize the LCN light.""" + super().__init__(config, address_connection) + + self.output = self.pypck.lcn_defs.RelayPort[config[CONF_OUTPUT]] + + self._is_on = None + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self.hass.async_create_task( + self.address_connection.activate_status_request_handler( + self.output)) + + @property + def is_on(self): + """Return True if entity is on.""" + return self._is_on + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + self._is_on = True + + states = [self.pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 + states[self.output.value] = self.pypck.lcn_defs.RelayStateModifier.ON + self.address_connection.control_relays(states) + + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + self._is_on = False + + states = [self.pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 + states[self.output.value] = self.pypck.lcn_defs.RelayStateModifier.OFF + self.address_connection.control_relays(states) + + await self.async_update_ha_state() + + def input_received(self, input_obj): + """Set light state when LCN input object (command) is received.""" + if not isinstance(input_obj, self.pypck.inputs.ModStatusRelays): + return + + self._is_on = input_obj.get_state(self.output.value) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 8951b2876a2..f0cd7b7a7fe 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -22,7 +22,8 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, Light, preprocess_turn_on_alternatives) from homeassistant.components.lifx import ( - DOMAIN as LIFX_DOMAIN, DATA_LIFX_MANAGER, CONF_SERVER, CONF_BROADCAST) + DOMAIN as LIFX_DOMAIN, DATA_LIFX_MANAGER, CONF_SERVER, CONF_PORT, + CONF_BROADCAST) from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -230,6 +231,9 @@ class LIFXManager: listen_ip = interface.get(CONF_SERVER) if listen_ip: kwargs['listen_ip'] = listen_ip + listen_port = interface.get(CONF_PORT) + if listen_port: + kwargs['listen_port'] = listen_port lifx_discovery.start(**kwargs) self.discoveries.append(lifx_discovery) @@ -710,3 +714,7 @@ class LIFXStrip(LIFXColor): if resp: zone += 8 top = resp.count + + # We only await multizone responses so don't ask for just one + if zone == top-1: + zone -= 1 diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 3507c6d2cda..9836bf97f90 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -192,3 +192,16 @@ yeelight_set_mode: mode: description: Operation mode. Valid values are 'last', 'normal', 'rgb', 'hsv', 'color_flow', 'moonlight'. example: 'moonlight' + +yeelight_start_flow: + description: Start a custom flow, using transitions from https://yeelight.readthedocs.io/en/stable/yeelight.html#flow-objects + fields: + entity_id: + description: Name of the light entity. + example: 'light.yeelight' + count: + description: The number of times to run this flow (0 to run forever). + example: 0 + transitions: + description: Array of transitions, for desired effect. Examples https://yeelight.readthedocs.io/en/stable/flow.html + example: '[{ "TemperatureTransition": [1900, 1000, 80] }, { "TemperatureTransition": [1900, 1000, 10] }]' diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 25704eea0cc..b678fcd2799 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -39,15 +39,48 @@ CONF_MODEL = 'model' CONF_TRANSITION = 'transition' CONF_SAVE_ON_CHANGE = 'save_on_change' CONF_MODE_MUSIC = 'use_music_mode' +CONF_CUSTOM_EFFECTS = 'custom_effects' +CONF_FLOW_PARAMS = 'flow_params' DATA_KEY = 'light.yeelight' +ATTR_MODE = 'mode' +ATTR_COUNT = 'count' +ATTR_TRANSITIONS = 'transitions' + +YEELIGHT_RGB_TRANSITION = 'RGBTransition' +YEELIGHT_HSV_TRANSACTION = 'HSVTransition' +YEELIGHT_TEMPERATURE_TRANSACTION = 'TemperatureTransition' +YEELIGHT_SLEEP_TRANSACTION = 'SleepTransition' + +YEELIGHT_SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, +}) + +YEELIGHT_FLOW_TRANSITION_SCHEMA = { + vol.Optional(ATTR_COUNT, default=0): cv.positive_int, + vol.Required(ATTR_TRANSITIONS): [{ + vol.Exclusive(YEELIGHT_RGB_TRANSITION, CONF_TRANSITION): + vol.All(cv.ensure_list, [cv.positive_int]), + vol.Exclusive(YEELIGHT_HSV_TRANSACTION, CONF_TRANSITION): + vol.All(cv.ensure_list, [cv.positive_int]), + vol.Exclusive(YEELIGHT_TEMPERATURE_TRANSACTION, CONF_TRANSITION): + vol.All(cv.ensure_list, [cv.positive_int]), + vol.Exclusive(YEELIGHT_SLEEP_TRANSACTION, CONF_TRANSITION): + vol.All(cv.ensure_list, [cv.positive_int]), + }] +} + DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.positive_int, vol.Optional(CONF_MODE_MUSIC, default=False): cv.boolean, vol.Optional(CONF_SAVE_ON_CHANGE, default=False): cv.boolean, vol.Optional(CONF_MODEL): cv.string, + vol.Optional(CONF_CUSTOM_EFFECTS): [{ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_FLOW_PARAMS): YEELIGHT_FLOW_TRANSITION_SCHEMA + }] }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -103,11 +136,7 @@ YEELIGHT_EFFECT_LIST = [ EFFECT_STOP] SERVICE_SET_MODE = 'yeelight_set_mode' -ATTR_MODE = 'mode' - -YEELIGHT_SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, -}) +SERVICE_START_FLOW = 'yeelight_start_flow' def _cmd(func): @@ -123,6 +152,19 @@ def _cmd(func): return _wrap +def _parse_custom_effects(effects_config): + effects = {} + for config in effects_config: + params = config[CONF_FLOW_PARAMS] + transitions = YeelightLight.transitions_config_parser( + params[ATTR_TRANSITIONS]) + + effects[config[CONF_NAME]] = \ + {ATTR_COUNT: params[ATTR_COUNT], ATTR_TRANSITIONS: transitions} + + return effects + + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Yeelight bulbs.""" from yeelight.enums import PowerMode @@ -152,7 +194,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.debug("Adding configured %s", name) device = {'name': name, 'ipaddr': ipaddr} - light = YeelightLight(device, device_config) + + if CONF_CUSTOM_EFFECTS in config: + custom_effects = \ + _parse_custom_effects(config[CONF_CUSTOM_EFFECTS]) + else: + custom_effects = None + + light = YeelightLight(device, device_config, + custom_effects=custom_effects) lights.append(light) hass.data[DATA_KEY][name] = light @@ -163,15 +213,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): params = {key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID} entity_ids = service.data.get(ATTR_ENTITY_ID) - if entity_ids: - target_devices = [dev for dev in hass.data[DATA_KEY].values() - if dev.entity_id in entity_ids] - else: - target_devices = hass.data[DATA_KEY].values() + target_devices = [dev for dev in hass.data[DATA_KEY].values() + if dev.entity_id in entity_ids] for target_device in target_devices: if service.service == SERVICE_SET_MODE: target_device.set_mode(**params) + elif service.service == SERVICE_START_FLOW: + target_device.start_flow(**params) service_schema_set_mode = YEELIGHT_SERVICE_SCHEMA.extend({ vol.Required(ATTR_MODE): @@ -181,11 +230,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): DOMAIN, SERVICE_SET_MODE, service_handler, schema=service_schema_set_mode) + service_schema_start_flow = YEELIGHT_SERVICE_SCHEMA.extend( + YEELIGHT_FLOW_TRANSITION_SCHEMA + ) + hass.services.register( + DOMAIN, SERVICE_START_FLOW, service_handler, + schema=service_schema_start_flow) + class YeelightLight(Light): """Representation of a Yeelight light.""" - def __init__(self, device, config): + def __init__(self, device, config, custom_effects=None): """Initialize the Yeelight light.""" self.config = config self._name = device['name'] @@ -204,6 +260,11 @@ class YeelightLight(Light): self._min_mireds = None self._max_mireds = None + if custom_effects: + self._custom_effects = custom_effects + else: + self._custom_effects = {} + @property def available(self) -> bool: """Return if bulb is available.""" @@ -217,7 +278,7 @@ class YeelightLight(Light): @property def effect_list(self): """Return the list of supported effects.""" - return YEELIGHT_EFFECT_LIST + return YEELIGHT_EFFECT_LIST + self.custom_effects_names @property def color_temp(self) -> int: @@ -249,6 +310,16 @@ class YeelightLight(Light): """Return maximum supported color temperature.""" return self._max_mireds + @property + def custom_effects(self): + """Return dict with custom effects.""" + return self._custom_effects + + @property + def custom_effects_names(self): + """Return list with custom effects names.""" + return list(self.custom_effects.keys()) + def _get_hs_from_properties(self): rgb = self._properties.get('rgb', None) color_mode = self._properties.get('color_mode', None) @@ -435,15 +506,17 @@ class YeelightLight(Light): EFFECT_SLOWDOWN: slowdown, } - if effect in effects_map: + if effect in self.custom_effects_names: + flow = Flow(**self.custom_effects[effect]) + elif effect in effects_map: flow = Flow(count=0, transitions=effects_map[effect]()) - if effect == EFFECT_FAST_RANDOM_LOOP: + elif effect == EFFECT_FAST_RANDOM_LOOP: flow = Flow(count=0, transitions=randomloop(duration=250)) - if effect == EFFECT_WHATSAPP: + elif effect == EFFECT_WHATSAPP: flow = Flow(count=2, transitions=pulse(37, 211, 102)) - if effect == EFFECT_FACEBOOK: + elif effect == EFFECT_FACEBOOK: flow = Flow(count=2, transitions=pulse(59, 89, 152)) - if effect == EFFECT_TWITTER: + elif effect == EFFECT_TWITTER: flow = Flow(count=2, transitions=pulse(0, 172, 237)) try: @@ -518,3 +591,28 @@ class YeelightLight(Light): self.async_schedule_update_ha_state(True) except yeelight.BulbException as ex: _LOGGER.error("Unable to set the power mode: %s", ex) + + @staticmethod + def transitions_config_parser(transitions): + """Parse transitions config into initialized objects.""" + import yeelight + + transition_objects = [] + for transition_config in transitions: + transition, params = list(transition_config.items())[0] + transition_objects.append(getattr(yeelight, transition)(*params)) + + return transition_objects + + def start_flow(self, transitions, count=0): + """Start flow.""" + import yeelight + + try: + flow = yeelight.Flow( + count=count, + transitions=self.transitions_config_parser(transitions)) + + self._bulb.start_flow(flow) + except yeelight.BulbException as ex: + _LOGGER.error("Unable to set effect: %s", ex) diff --git a/homeassistant/components/locative/.translations/ca.json b/homeassistant/components/locative/.translations/ca.json new file mode 100644 index 00000000000..a08907a51ef --- /dev/null +++ b/homeassistant/components/locative/.translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Geofency.", + "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." + }, + "create_entry": { + "default": "Per enviar ubicacions a Home Assistant, haur\u00e0s de configurar l'opci\u00f3 webhook de l'aplicaci\u00f3 Locative.\n\nCompleta la seg\u00fcent informaci\u00f3:\n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." + }, + "step": { + "user": { + "description": "Est\u00e0s segur que vols configurar el Webhook Locative?", + "title": "Configuraci\u00f3 del Webhook Locative" + } + }, + "title": "Webhook Locative" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/en.json b/homeassistant/components/locative/.translations/en.json index b2a538a0fa5..052557408d8 100644 --- a/homeassistant/components/locative/.translations/en.json +++ b/homeassistant/components/locative/.translations/en.json @@ -1,18 +1,18 @@ { - "config": { - "title": "Locative Webhook", - "step": { - "user": { - "title": "Set up the Locative Webhook", - "description": "Are you sure you want to set up the Locative Webhook?" - } - }, - "abort": { - "one_instance_allowed": "Only a single instance is necessary.", - "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency." - }, - "create_entry": { - "default": "To send locations to Home Assistant, you will need to setup the webhook feature in the Locative app.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + "config": { + "abort": { + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency.", + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "To send locations to Home Assistant, you will need to setup the webhook feature in the Locative app.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + }, + "step": { + "user": { + "description": "Are you sure you want to set up the Locative Webhook?", + "title": "Set up the Locative Webhook" + } + }, + "title": "Locative Webhook" } - } } \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/ko.json b/homeassistant/components/locative/.translations/ko.json new file mode 100644 index 00000000000..a57b27cdd75 --- /dev/null +++ b/homeassistant/components/locative/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Locative \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.", + "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + }, + "create_entry": { + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 Locative \uc571\uc5d0\uc11c Webhook \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + }, + "step": { + "user": { + "description": "Locative Webhook \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Locative Webhook \uc124\uc815" + } + }, + "title": "Locative Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/lb.json b/homeassistant/components/locative/.translations/lb.json new file mode 100644 index 00000000000..25db0ecef81 --- /dev/null +++ b/homeassistant/components/locative/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Geofency Noriichten z'empf\u00e4nken.", + "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg." + }, + "create_entry": { + "default": "Fir Plazen un Home Assistant ze sch\u00e9cken, muss den Webhook Feature an der Locative App ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nLiest [Dokumentatioun]({docs_url}) fir w\u00e9ider D\u00e9tailer." + }, + "step": { + "user": { + "description": "S\u00e9cher fir Locative Webhook anzeriichten?", + "title": "Locative Webhook ariichten" + } + }, + "title": "Locative Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/no.json b/homeassistant/components/locative/.translations/no.json new file mode 100644 index 00000000000..00e3337dfe1 --- /dev/null +++ b/homeassistant/components/locative/.translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta meldinger fra Geofency.", + "one_instance_allowed": "Kun en enkelt forekomst er n\u00f8dvendig." + }, + "create_entry": { + "default": "For \u00e5 kunne sende steder til Home Assistant, m\u00e5 du sette opp webhook-funksjonen i Locative. \n\n Fyll ut f\u00f8lgende informasjon: \n\n - URL: `{webhook_url}` \n - Metode: POST \n\n Se [dokumentasjonen]({docs_url}) for ytterligere detaljer." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil sette opp Locative Webhook?", + "title": "Sett opp Lokative Webhook" + } + }, + "title": "Lokative Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/pl.json b/homeassistant/components/locative/.translations/pl.json new file mode 100644 index 00000000000..89f6881593a --- /dev/null +++ b/homeassistant/components/locative/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Twoja instancja Home Assistant musi by\u0107 dost\u0119pna z Internetu, aby otrzymywa\u0107 wiadomo\u015bci z Geofency.", + "one_instance_allowed": "Wymagana jest tylko jedna instancja." + }, + "create_entry": { + "default": "Aby wysy\u0142a\u0107 lokalizacje do Home Assistant'a, musisz skonfigurowa\u0107 webhook w aplikacji Locative. \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}) by pozna\u0107 szczeg\u00f3\u0142y." + }, + "step": { + "user": { + "description": "Czy na pewno chcesz skonfigurowa\u0107 Locative Webhook?", + "title": "Skonfiguruj Locative Webhook" + } + }, + "title": "Locative Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/ru.json b/homeassistant/components/locative/.translations/ru.json new file mode 100644 index 00000000000..d8b8d55a608 --- /dev/null +++ b/homeassistant/components/locative/.translations/ru.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 Locative." + }, + "create_entry": { + "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0432\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c webhooks \u0434\u043b\u044f Locative.\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Locative?", + "title": "Locative" + } + }, + "title": "Locative" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/sl.json b/homeassistant/components/locative/.translations/sl.json new file mode 100644 index 00000000000..0b0bd45b7d6 --- /dev/null +++ b/homeassistant/components/locative/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161 Home Assistant mora biti dostopek prek interneta, da boste lahko prejemali Geofency sporo\u010dila.", + "one_instance_allowed": "Potrebna je samo ena instanca." + }, + "create_entry": { + "default": "Za po\u0161iljanje lokacij v Home Assistant, morate namestiti funkcijo webhook v aplikaciji Locative. \n\n Izpolnite naslednje podatke: \n\n - URL: ` {webhook_url} ` \n - Metoda: POST \n\n Za ve\u010d podrobnosti si oglejte [dokumentacijo] ( {docs_url} )." + }, + "step": { + "user": { + "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti Locative Webhook?", + "title": "Nastavite Locative Webhook" + } + }, + "title": "Locative Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/zh-Hans.json b/homeassistant/components/locative/.translations/zh-Hans.json new file mode 100644 index 00000000000..d98793d96e5 --- /dev/null +++ b/homeassistant/components/locative/.translations/zh-Hans.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u60a8\u7684Home Assistant\u5b9e\u4f8b\u9700\u8981\u53ef\u4ee5\u4eceInternet\u8bbf\u95ee\u4ee5\u63a5\u6536\u6765\u81eaGeofency\u7684\u6d88\u606f\u3002", + "one_instance_allowed": "\u53ea\u9700\u8981\u4e00\u4e2a\u5b9e\u4f8b\u3002" + }, + "step": { + "user": { + "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e\u5b9a\u4f4d Webhook\u5417\uff1f", + "title": "\u8bbe\u7f6e\u5b9a\u4f4d Webhook" + } + }, + "title": "\u5b9a\u4f4d Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/zh-Hant.json b/homeassistant/components/locative/.translations/zh-Hant.json new file mode 100644 index 00000000000..62bb6bb9d96 --- /dev/null +++ b/homeassistant/components/locative/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Locative \u8a0a\u606f\u3002", + "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002" + }, + "create_entry": { + "default": "\u6b32\u50b3\u9001\u4f4d\u7f6e\u81f3 Home Assistant\uff0c\u5c07\u9700\u65bc Locative App \u5167\u8a2d\u5b9a webhook \u529f\u80fd\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002" + }, + "step": { + "user": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Locative Webhook\uff1f", + "title": "\u8a2d\u5b9a Locative Webhook" + } + }, + "title": "Locative Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index 1cc47270ba3..1f7f9c3a686 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -12,11 +12,10 @@ from aiohttp import web import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import \ - DOMAIN as DEVICE_TRACKER_DOMAIN + DOMAIN as DEVICE_TRACKER from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, ATTR_LATITUDE, \ ATTR_LONGITUDE, STATE_NOT_HOME, CONF_WEBHOOK_ID, ATTR_ID, HTTP_OK from homeassistant.helpers import config_entry_flow -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send _LOGGER = logging.getLogger(__name__) @@ -49,17 +48,14 @@ WEBHOOK_SCHEMA = vol.All( vol.Required(ATTR_LONGITUDE): cv.longitude, vol.Required(ATTR_DEVICE_ID): cv.string, vol.Required(ATTR_TRIGGER): cv.string, - vol.Optional(ATTR_ID): vol.All(cv.string, _id) - }), + vol.Optional(ATTR_ID): vol.All(cv.string, _id), + }, extra=vol.ALLOW_EXTRA), _validate_test_mode ) async def async_setup(hass, hass_config): """Set up the Locative component.""" - hass.async_create_task( - async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config) - ) return True @@ -93,7 +89,7 @@ async def handle_webhook(hass, webhook_id, request): if direction == 'exit': current_state = hass.states.get( - '{}.{}'.format(DEVICE_TRACKER_DOMAIN, device)) + '{}.{}'.format(DEVICE_TRACKER, device)) if current_state is None or current_state.state == location_name: location_name = STATE_NOT_HOME @@ -140,12 +136,18 @@ async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( DOMAIN, 'Locative', entry.data[CONF_WEBHOOK_ID], handle_webhook) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, DEVICE_TRACKER) + ) return True async def async_unload_entry(hass, entry): """Unload a config entry.""" hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + + await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER) return True config_entry_flow.register_webhook_flow( diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py new file mode 100644 index 00000000000..78090914b2c --- /dev/null +++ b/homeassistant/components/locative/device_tracker.py @@ -0,0 +1,42 @@ +""" +Support for the Locative platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.locative/ +""" +import logging + +from homeassistant.components.device_tracker import \ + DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.locative import DOMAIN as LOCATIVE_DOMAIN +from homeassistant.components.locative import TRACKER_UPDATE +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['locative'] + +DATA_KEY = '{}.{}'.format(LOCATIVE_DOMAIN, DEVICE_TRACKER_DOMAIN) + + +async def async_setup_entry(hass, entry, async_see): + """Configure a dispatcher connection based on a config entry.""" + async def _set_location(device, gps_location, location_name): + """Fire HA event to set location.""" + await async_see( + dev_id=slugify(device), + gps=gps_location, + location_name=location_name + ) + + hass.data[DATA_KEY] = async_dispatcher_connect( + hass, TRACKER_UPDATE, _set_location + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload the config entry and remove the dispatcher connection.""" + hass.data[DATA_KEY]() + return True diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 72e87f763d2..750977fac87 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, - STATE_UNKNOWN, SERVICE_LOCK, SERVICE_UNLOCK, SERVICE_OPEN) + SERVICE_LOCK, SERVICE_UNLOCK, SERVICE_OPEN) from homeassistant.components import group ATTR_CHANGED_BY = 'changed_by' @@ -150,5 +150,5 @@ class LockDevice(Entity): """Return the state.""" locked = self.is_locked if locked is None: - return STATE_UNKNOWN + return None return STATE_LOCKED if locked else STATE_UNLOCKED diff --git a/homeassistant/components/lock/verisure.py b/homeassistant/components/lock/verisure.py index 25c7e1aa8ea..cf7d58b17a8 100644 --- a/homeassistant/components/lock/verisure.py +++ b/homeassistant/components/lock/verisure.py @@ -12,7 +12,7 @@ from homeassistant.components.verisure import ( CONF_LOCKS, CONF_DEFAULT_LOCK_CODE, CONF_CODE_DIGITS) from homeassistant.components.lock import LockDevice from homeassistant.const import ( - ATTR_CODE, STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED) + ATTR_CODE, STATE_LOCKED, STATE_UNLOCKED) _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,7 @@ class VerisureDoorlock(LockDevice): def __init__(self, device_label): """Initialize the Verisure lock.""" self._device_label = device_label - self._state = STATE_UNKNOWN + self._state = None self._digits = hub.config.get(CONF_CODE_DIGITS) self._changed_by = None self._change_timestamp = 0 @@ -80,7 +80,7 @@ class VerisureDoorlock(LockDevice): "$.doorLockStatusList[?(@.deviceLabel=='%s')].lockedState", self._device_label) if status == 'UNLOCKED': - self._state = STATE_UNLOCKED + self._state = None elif status == 'LOCKED': self._state = STATE_LOCKED elif status != 'PENDING': @@ -96,7 +96,7 @@ class VerisureDoorlock(LockDevice): def unlock(self, **kwargs): """Send unlock command.""" - if self._state == STATE_UNLOCKED: + if self._state is None: return code = kwargs.get(ATTR_CODE, self._default_lock_code) diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 77afe688c2e..c907d5101a9 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -39,8 +39,8 @@ DEVICE_MAPPINGS = { # Kwikset 914TRL ZW500 (0x0090, 0x440): WORKAROUND_DEVICE_STATE, (0x0090, 0x446): WORKAROUND_DEVICE_STATE, - # Yale YRD210 - (0x0129, 0x0209): WORKAROUND_DEVICE_STATE, + # Yale YRD210, Yale YRD240 + (0x0129, 0x0209): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, (0x0129, 0xAA00): WORKAROUND_DEVICE_STATE, (0x0129, 0x0000): WORKAROUND_DEVICE_STATE, # Yale YRD220 (as reported by adrum in PR #17386) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index c3254d84a73..b4cb2b18dca 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -75,6 +75,9 @@ async def async_setup(hass, config): WS_TYPE_SAVE_CONFIG, websocket_lovelace_save_config, SCHEMA_SAVE_CONFIG) + hass.components.system_health.async_register_info( + DOMAIN, system_health_info) + return True @@ -86,11 +89,22 @@ class LovelaceStorage: self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) self._data = None + async def async_get_info(self): + """Return the YAML storage mode.""" + if self._data is None: + await self._load() + + if self._data['config'] is None: + return { + 'mode': 'auto-gen' + } + + return _config_info('storage', self._data['config']) + async def async_load(self, force): """Load config.""" if self._data is None: - data = await self._store.async_load() - self._data = data if data else {'config': None} + await self._load() config = self._data['config'] @@ -102,10 +116,15 @@ class LovelaceStorage: async def async_save(self, config): """Save config.""" if self._data is None: - self._data = {'config': None} + await self._load() self._data['config'] = config await self._store.async_save(self._data) + async def _load(self): + """Load the config.""" + data = await self._store.async_load() + self._data = data if data else {'config': None} + class LovelaceYAML: """Class to handle YAML-based Lovelace config.""" @@ -115,6 +134,19 @@ class LovelaceYAML: self.hass = hass self._cache = None + async def async_get_info(self): + """Return the YAML storage mode.""" + try: + config = await self.async_load(False) + except ConfigNotFound: + return { + 'mode': 'yaml', + 'error': '{} not found'.format( + self.hass.config.path(LOVELACE_CONFIG_FILE)) + } + + return _config_info('yaml', config) + async def async_load(self, force): """Load config.""" return await self.hass.async_add_executor_job(self._load_config, force) @@ -177,3 +209,17 @@ async def websocket_lovelace_config(hass, connection, msg): async def websocket_lovelace_save_config(hass, connection, msg): """Save Lovelace UI configuration.""" await hass.data[DOMAIN].async_save(msg['config']) + + +async def system_health_info(hass): + """Get info for the info page.""" + return await hass.data[DOMAIN].async_get_info() + + +def _config_info(mode, config): + """Generate info about the config.""" + return { + 'mode': mode, + 'resources': len(config.get('resources', [])), + 'views': len(config.get('views', [])) + } diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index b00fca7d3c0..45d75b90f7f 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -12,13 +12,14 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SHOW_ON_MAP, TEMP_CELSIUS) +from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from .config_flow import configured_sensors +from .config_flow import configured_sensors, duplicate_stations from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN REQUIREMENTS = ['luftdaten==0.3.4'] @@ -67,6 +68,14 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +@callback +def _async_fixup_sensor_id(hass, config_entry, sensor_id): + hass.config_entries.async_update_entry( + config_entry, data={ + **config_entry.data, CONF_SENSOR_ID: int(sensor_id) + }) + + async def async_setup(hass, config): """Set up the Luftdaten component.""" hass.data[DOMAIN] = {} @@ -77,7 +86,7 @@ async def async_setup(hass, config): return True conf = config[DOMAIN] - station_id = conf.get(CONF_SENSOR_ID) + station_id = conf[CONF_SENSOR_ID] if station_id not in configured_sensors(hass): hass.async_create_task( @@ -102,6 +111,18 @@ async def async_setup_entry(hass, config_entry): from luftdaten import Luftdaten from luftdaten.exceptions import LuftdatenError + if not isinstance(config_entry.data[CONF_SENSOR_ID], int): + _async_fixup_sensor_id(hass, config_entry, + config_entry.data[CONF_SENSOR_ID]) + + if (config_entry.data[CONF_SENSOR_ID] in + duplicate_stations(hass) and config_entry.source == SOURCE_IMPORT): + _LOGGER.warning("Removing duplicate sensors for station %s", + config_entry.data[CONF_SENSOR_ID]) + hass.async_create_task(hass.config_entries.async_remove( + config_entry.entry_id)) + return False + session = async_get_clientsession(hass) try: diff --git a/homeassistant/components/luftdaten/config_flow.py b/homeassistant/components/luftdaten/config_flow.py index 33715c3c0c1..b4ebc93da9c 100644 --- a/homeassistant/components/luftdaten/config_flow.py +++ b/homeassistant/components/luftdaten/config_flow.py @@ -7,6 +7,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SHOW_ON_MAP from homeassistant.core import callback from homeassistant.helpers import aiohttp_client +import homeassistant.helpers.config_validation as cv from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -15,10 +16,18 @@ from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN def configured_sensors(hass): """Return a set of configured Luftdaten sensors.""" return set( - '{0}'.format(entry.data[CONF_SENSOR_ID]) + entry.data[CONF_SENSOR_ID] for entry in hass.config_entries.async_entries(DOMAIN)) +@callback +def duplicate_stations(hass): + """Return a set of duplicate configured Luftdaten stations.""" + stations = [int(entry.data[CONF_SENSOR_ID]) + for entry in hass.config_entries.async_entries(DOMAIN)] + return {x for x in stations if stations.count(x) > 1} + + @config_entries.HANDLERS.register(DOMAIN) class LuftDatenFlowHandler(config_entries.ConfigFlow): """Handle a Luftdaten config flow.""" @@ -30,7 +39,7 @@ class LuftDatenFlowHandler(config_entries.ConfigFlow): def _show_form(self, errors=None): """Show the form to the user.""" data_schema = OrderedDict() - data_schema[vol.Required(CONF_SENSOR_ID)] = str + data_schema[vol.Required(CONF_SENSOR_ID)] = cv.positive_int data_schema[vol.Optional(CONF_SHOW_ON_MAP, default=False)] = bool return self.async_show_form( @@ -72,4 +81,4 @@ class LuftDatenFlowHandler(config_entries.ConfigFlow): CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) user_input.update({CONF_SCAN_INTERVAL: scan_interval.seconds}) - return self.async_create_entry(title=sensor_id, data=user_input) + return self.async_create_entry(title=str(sensor_id), data=user_input) diff --git a/homeassistant/components/lutron.py b/homeassistant/components/lutron.py index 7f1e1d25ae1..435039ce4bd 100644 --- a/homeassistant/components/lutron.py +++ b/homeassistant/components/lutron.py @@ -9,9 +9,11 @@ import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + ATTR_ID, CONF_HOST, CONF_PASSWORD, CONF_USERNAME) from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify REQUIREMENTS = ['pylutron==0.2.0'] @@ -19,9 +21,13 @@ DOMAIN = 'lutron' _LOGGER = logging.getLogger(__name__) +LUTRON_BUTTONS = 'lutron_buttons' LUTRON_CONTROLLER = 'lutron_controller' LUTRON_DEVICES = 'lutron_devices' +# Attribute on events that indicates what action was taken with the button. +ATTR_ACTION = 'action' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_HOST): cv.string, @@ -35,6 +41,7 @@ def setup(hass, base_config): """Set up the Lutron component.""" from pylutron import Lutron + hass.data[LUTRON_BUTTONS] = [] hass.data[LUTRON_CONTROLLER] = None hass.data[LUTRON_DEVICES] = {'light': [], 'cover': [], @@ -70,6 +77,9 @@ def setup(hass, base_config): hass.data[LUTRON_DEVICES]['scene'].append( (area.name, keypad.name, button, led)) + hass.data[LUTRON_BUTTONS].append( + LutronButton(hass, keypad, button)) + for component in ('light', 'cover', 'switch', 'scene'): discovery.load_platform(hass, component, DOMAIN, None, base_config) return True @@ -105,3 +115,43 @@ class LutronDevice(Entity): def should_poll(self): """No polling needed.""" return False + + +class LutronButton: + """Representation of a button on a Lutron keypad. + + This is responsible for firing events as keypad buttons are pressed + (and possibly released, depending on the button type). It is not + represented as an entity; it simply fires events. + """ + + def __init__(self, hass, keypad, button): + """Register callback for activity on the button.""" + name = '{}: {}'.format(keypad.name, button.name) + self._hass = hass + self._has_release_event = 'RaiseLower' in button.button_type + self._id = slugify(name) + self._event = 'lutron_event' + + button.subscribe(self.button_callback, None) + + def button_callback(self, button, context, event, params): + """Fire an event about a button being pressed or released.""" + from pylutron import Button + + if self._has_release_event: + # A raise/lower button; we will get callbacks when the button is + # pressed and when it's released, so fire events for each. + if event == Button.Event.PRESSED: + action = 'pressed' + else: + action = 'released' + else: + # A single-action button; the Lutron controller won't tell us + # when the button is released, so use a different action name + # than for buttons where we expect a release event. + action = 'single' + + data = {ATTR_ID: self._id, ATTR_ACTION: action} + + self._hass.bus.fire(self._event, data) diff --git a/homeassistant/components/mailgun/.translations/sl.json b/homeassistant/components/mailgun/.translations/sl.json index 4eb12d7343c..2f526826d31 100644 --- a/homeassistant/components/mailgun/.translations/sl.json +++ b/homeassistant/components/mailgun/.translations/sl.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "not_internet_accessible": " \u010ce \u017eelite prejemati sporo\u010dila Mailgun, mora biti Home Assistent dostopen prek interneta.", + "not_internet_accessible": "\u010ce \u017eelite prejemati sporo\u010dila Mailgun, mora biti Home Assistant dostopen prek interneta.", "one_instance_allowed": "Potrebna je samo ena instanca." }, "create_entry": { - "default": "Za po\u0161iljanje dogodkov Home Assistentu boste morali nastaviti [Webhooks z Mailgun]({mailgun_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/json\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite automations za obravnavo dohodnih podatkov." + "default": "Za po\u0161iljanje dogodkov Home Assistantu boste morali nastaviti [Webhooks z Mailgun]({mailgun_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/json\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite automations za obravnavo dohodnih podatkov." }, "step": { "user": { diff --git a/homeassistant/components/mailgun/.translations/zh-Hans.json b/homeassistant/components/mailgun/.translations/zh-Hans.json index 06c1d3624f4..5dd0a7aeabf 100644 --- a/homeassistant/components/mailgun/.translations/zh-Hans.json +++ b/homeassistant/components/mailgun/.translations/zh-Hans.json @@ -6,6 +6,13 @@ }, "create_entry": { "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Mailgun \u7684 Webhook]({mailgun_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u6709\u5173\u5982\u4f55\u914d\u7f6e\u81ea\u52a8\u5316\u4ee5\u5904\u7406\u4f20\u5165\u7684\u6570\u636e\uff0c\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u3002" - } + }, + "step": { + "user": { + "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e Mailgun \u5417\uff1f", + "title": "\u8bbe\u7f6e Mailgun Webhook" + } + }, + "title": "Mailgun" } } \ No newline at end of file diff --git a/homeassistant/components/map.py b/homeassistant/components/map.py index c0184239a1a..d30a7568452 100644 --- a/homeassistant/components/map.py +++ b/homeassistant/components/map.py @@ -10,5 +10,5 @@ DOMAIN = 'map' async def async_setup(hass, config): """Register the built-in map panel.""" await hass.components.frontend.async_register_built_in_panel( - 'map', 'map', 'hass:account-location') + 'map', 'map', 'hass:tooltip-account') return True diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 940f2dd79ca..333f62a9aa7 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2019.01.10'] +REQUIREMENTS = ['youtube_dl==2019.01.24'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index cd109cce7d3..b526d1659ba 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -27,7 +27,7 @@ from homeassistant.const import ( SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, SERVICE_SHUFFLE_SET, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, STATE_IDLE, - STATE_OFF, STATE_PLAYING, STATE_UNKNOWN) + STATE_OFF, STATE_PLAYING) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa @@ -317,7 +317,7 @@ class MediaPlayerDevice(Entity): @property def state(self): """State of the player.""" - return STATE_UNKNOWN + return None @property def access_token(self): diff --git a/homeassistant/components/media_player/anthemav.py b/homeassistant/components/media_player/anthemav.py index 0a9f208dae4..a0bc3d05dcb 100644 --- a/homeassistant/components/media_player/anthemav.py +++ b/homeassistant/components/media_player/anthemav.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_OFF, - STATE_ON, STATE_UNKNOWN) + STATE_ON) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['anthemav==1.1.8'] @@ -100,7 +100,7 @@ class AnthemAVR(MediaPlayerDevice): return STATE_ON if pwrstate is False: return STATE_OFF - return STATE_UNKNOWN + return None @property def is_volume_muted(self): diff --git a/homeassistant/components/media_player/aquostv.py b/homeassistant/components/media_player/aquostv.py index ac399307126..5c1994e65fc 100644 --- a/homeassistant/components/media_player/aquostv.py +++ b/homeassistant/components/media_player/aquostv.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_TIMEOUT, - CONF_USERNAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) + CONF_USERNAME, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['sharp_aquos_rc==0.3.2'] @@ -113,7 +113,7 @@ class SharpAquosTVDevice(MediaPlayerDevice): self._name = name # Assume that the TV is not muted self._muted = False - self._state = STATE_UNKNOWN + self._state = None self._remote = remote self._volume = 0 self._source = None diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index d6515b9476d..20a44c0e910 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -613,7 +613,7 @@ class CastDevice(MediaPlayerDevice): return self.media_status.artist if self.media_status else None @property - def media_album(self): + def media_album_name(self): """Album of current playing media (Music track only).""" return self.media_status.album_name if self.media_status else None diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py index c0f296c2fb8..79b69b551ce 100644 --- a/homeassistant/components/media_player/denon.py +++ b/homeassistant/components/media_player/denon.py @@ -14,8 +14,7 @@ from homeassistant.components.media_player import ( SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) -from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) +from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -169,7 +168,7 @@ class DenonDevice(MediaPlayerDevice): if self._pwstate == 'PWON': return STATE_ON - return STATE_UNKNOWN + return None @property def volume_level(self): diff --git a/homeassistant/components/media_player/dlna_dmr.py b/homeassistant/components/media_player/dlna_dmr.py index 479445e9a89..802b2b597fc 100644 --- a/homeassistant/components/media_player/dlna_dmr.py +++ b/homeassistant/components/media_player/dlna_dmr.py @@ -26,7 +26,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.util import get_local_ip -REQUIREMENTS = ['async-upnp-client==0.13.8'] +REQUIREMENTS = ['async-upnp-client==0.14.4'] _LOGGER = logging.getLogger(__name__) @@ -145,7 +145,7 @@ async def async_setup_platform( raise PlatformNotReady() # wrap with DmrDevice - from async_upnp_client.dlna import DmrDevice + from async_upnp_client.profiles.dlna import DmrDevice dlna_device = DmrDevice(upnp_device, event_handler) # create our own device @@ -314,8 +314,8 @@ class DlnaDmrDevice(MediaPlayerDevice): await self._device.async_wait_for_can_play() # If already playing, no need to call Play - from async_upnp_client import dlna - if self._device.state == dlna.STATE_PLAYING: + from async_upnp_client.profiles.dlna import DeviceState + if self._device.state == DeviceState.PLAYING: return # Play it @@ -355,12 +355,12 @@ class DlnaDmrDevice(MediaPlayerDevice): if not self._available: return STATE_OFF - from async_upnp_client import dlna + from async_upnp_client.profiles.dlna import DeviceState if self._device.state is None: return STATE_ON - if self._device.state == dlna.STATE_PLAYING: + if self._device.state == DeviceState.PLAYING: return STATE_PLAYING - if self._device.state == dlna.STATE_PAUSED: + if self._device.state == DeviceState.PAUSED: return STATE_PAUSED return STATE_IDLE diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py index 80be58c04e1..c04ed96d6e0 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/media_player/firetv.py @@ -81,7 +81,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): device = FireTVDevice(ftv, name, get_source, get_sources) add_entities([device]) - _LOGGER.info("Setup Fire TV at %s%s", host, adb_log) + _LOGGER.debug("Setup Fire TV at %s%s", host, adb_log) def adb_decorator(override_available=False): @@ -96,17 +96,17 @@ def adb_decorator(override_available=False): # If an ADB command is already running, skip this command if not self.adb_lock.acquire(blocking=False): - _LOGGER.info('Skipping an ADB command because a previous ' - 'command is still running') + _LOGGER.info("Skipping an ADB command because a previous " + "command is still running") return None # Additional ADB commands will be prevented while trying this one try: returns = func(self, *args, **kwargs) - except self.exceptions: - _LOGGER.error('Failed to execute an ADB command; will attempt ' - 'to re-establish the ADB connection in the next ' - 'update') + except self.exceptions as err: + _LOGGER.error( + "Failed to execute an ADB command. ADB connection re-" + "establishing attempt in the next update. Error: %s", err) returns = None self._available = False # pylint: disable=protected-access finally: @@ -137,9 +137,9 @@ class FireTVDevice(MediaPlayerDevice): self.adb_lock = threading.Lock() # ADB exceptions to catch - self.exceptions = (AttributeError, BrokenPipeError, TypeError, - ValueError, InvalidChecksumError, - InvalidCommandError, InvalidResponseError) + self.exceptions = ( + AttributeError, BrokenPipeError, TypeError, ValueError, + InvalidChecksumError, InvalidCommandError, InvalidResponseError) self._state = None self._available = self.firetv.available @@ -231,8 +231,7 @@ class FireTVDevice(MediaPlayerDevice): self._running_apps = None # Check if the launcher is active. - if self._current_app in [PACKAGE_LAUNCHER, - PACKAGE_SETTINGS]: + if self._current_app in [PACKAGE_LAUNCHER, PACKAGE_SETTINGS]: self._state = STATE_STANDBY # Check for a wake lock (device is playing). diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py index 92f48411401..c2f63c71f89 100644 --- a/homeassistant/components/media_player/lg_netcast.py +++ b/homeassistant/components/media_player/lg_netcast.py @@ -18,7 +18,7 @@ from homeassistant.components.media_player import ( MediaPlayerDevice) from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, STATE_OFF, STATE_PAUSED, - STATE_PLAYING, STATE_UNKNOWN) + STATE_PLAYING) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pylgnetcast-homeassistant==0.2.0.dev0'] @@ -68,7 +68,7 @@ class LgTVDevice(MediaPlayerDevice): self._volume = 0 self._channel_name = '' self._program_name = '' - self._state = STATE_UNKNOWN + self._state = None self._sources = {} self._source_names = [] diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index d006b5692f1..09d0a976b82 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -300,14 +300,13 @@ class MpdDevice(MediaPlayerDevice): def play_media(self, media_type, media_id, **kwargs): """Send the media player the command for playing a playlist.""" - _LOGGER.debug(str.format("Playing playlist: {0}", media_id)) + _LOGGER.debug("Playing playlist: %s", media_id) if media_type == MEDIA_TYPE_PLAYLIST: if media_id in self._playlists: self._currentplaylist = media_id else: self._currentplaylist = None - _LOGGER.warning(str.format("Unknown playlist name %s.", - media_id)) + _LOGGER.warning("Unknown playlist name %s", media_id) self._client.clear() self._client.load(media_id) self._client.play() diff --git a/homeassistant/components/media_player/panasonic_bluray.py b/homeassistant/components/media_player/panasonic_bluray.py index bcd34f162c7..041efed74bf 100644 --- a/homeassistant/components/media_player/panasonic_bluray.py +++ b/homeassistant/components/media_player/panasonic_bluray.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( PLATFORM_SCHEMA, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_IDLE, STATE_OFF, STATE_PLAYING, STATE_UNKNOWN) + CONF_HOST, CONF_NAME, STATE_IDLE, STATE_OFF, STATE_PLAYING) import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow @@ -101,7 +101,7 @@ class PanasonicBluRay(MediaPlayerDevice): state = self._device.get_play_status() if state[0] == 'error': - self._state = STATE_UNKNOWN + self._state = None elif state[0] in ['off', 'standby']: # We map both of these to off. If it's really off we can't # turn it on, but from standby we can go to idle by pressing diff --git a/homeassistant/components/media_player/panasonic_viera.py b/homeassistant/components/media_player/panasonic_viera.py index d3e56c4dfb1..bff108d70d7 100644 --- a/homeassistant/components/media_player/panasonic_viera.py +++ b/homeassistant/components/media_player/panasonic_viera.py @@ -14,8 +14,7 @@ from homeassistant.components.media_player import ( SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON, - STATE_UNKNOWN) + CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['panasonic_viera==0.3.1', 'wakeonlan==1.1.6'] @@ -81,7 +80,7 @@ class PanasonicVieraTVDevice(MediaPlayerDevice): self._uuid = uuid self._muted = False self._playing = True - self._state = STATE_UNKNOWN + self._state = None self._remote = remote self._volume = 0 diff --git a/homeassistant/components/media_player/philips_js.py b/homeassistant/components/media_player/philips_js.py index 7d434ab480e..506e5a9e479 100644 --- a/homeassistant/components/media_player/philips_js.py +++ b/homeassistant/components/media_player/philips_js.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - CONF_API_VERSION, CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) + CONF_API_VERSION, CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script from homeassistant.util import Throttle @@ -70,7 +70,7 @@ class PhilipsTV(MediaPlayerDevice): """Initialize the Philips TV.""" self._tv = tv self._name = name - self._state = STATE_UNKNOWN + self._state = None self._min_volume = None self._max_volume = None self._volume = None diff --git a/homeassistant/components/media_player/pioneer.py b/homeassistant/components/media_player/pioneer.py index 29e4068f1d4..171343d4adb 100644 --- a/homeassistant/components/media_player/pioneer.py +++ b/homeassistant/components/media_player/pioneer.py @@ -14,8 +14,7 @@ from homeassistant.components.media_player import ( SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF, STATE_ON, - STATE_UNKNOWN) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -157,7 +156,7 @@ class PioneerDevice(MediaPlayerDevice): if self._pwstate == "PWR0": return STATE_ON - return STATE_UNKNOWN + return None @property def volume_level(self): diff --git a/homeassistant/components/media_player/pjlink.py b/homeassistant/components/media_player/pjlink.py index 168cde4a792..0609b75f98d 100644 --- a/homeassistant/components/media_player/pjlink.py +++ b/homeassistant/components/media_player/pjlink.py @@ -93,15 +93,31 @@ class PjLinkDevice(MediaPlayerDevice): def update(self): """Get the latest state from the device.""" + from pypjlink.projector import ProjectorError with self.projector() as projector: - pwstate = projector.get_power() - if pwstate == 'off': - self._pwstate = STATE_OFF - else: - self._pwstate = STATE_ON - self._muted = projector.get_mute()[1] - self._current_source = \ - format_input_source(*projector.get_input()) + try: + pwstate = projector.get_power() + if pwstate in ('on', 'warm-up'): + self._pwstate = STATE_ON + else: + self._pwstate = STATE_OFF + self._muted = projector.get_mute()[1] + self._current_source = \ + format_input_source(*projector.get_input()) + except KeyError as err: + if str(err) == "'OK'": + self._pwstate = STATE_OFF + self._muted = False + self._current_source = None + else: + raise + except ProjectorError as err: + if str(err) == 'unavailable time': + self._pwstate = STATE_OFF + self._muted = False + self._current_source = None + else: + raise @property def name(self): diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index b70c1ffbf28..2110c42d371 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -174,11 +174,11 @@ def setup_plexserver( # add devices with a session and no client (ex. PlexConnect Apple TV's) if config.get(CONF_INCLUDE_NON_CLIENTS): - for machine_identifier, session in plex_sessions.items(): + for machine_identifier, (session, player) in plex_sessions.items(): if (machine_identifier not in plex_clients and machine_identifier is not None): new_client = PlexClient( - config, None, session, plex_sessions, update_devices, + config, player, session, plex_sessions, update_devices, update_sessions) plex_clients[machine_identifier] = new_client new_plex_clients.append(new_client) @@ -192,7 +192,9 @@ def setup_plexserver( client.force_idle() client.set_availability(client.machine_identifier - in available_client_ids) + in available_client_ids + or client.machine_identifier + in plex_sessions) if not config.get(CONF_REMOVE_UNAVAILABLE_CLIENTS) \ or client.available: @@ -225,7 +227,7 @@ def setup_plexserver( plex_sessions.clear() for session in sessions: for player in session.players: - plex_sessions[player.machineIdentifier] = session + plex_sessions[player.machineIdentifier] = session, player update_sessions() update_devices() @@ -363,6 +365,8 @@ class PlexClient(MediaPlayerDevice): def refresh(self, device, session): """Refresh key device data.""" + import plexapi.exceptions + # new data refresh self._clear_media_details() @@ -370,7 +374,11 @@ class PlexClient(MediaPlayerDevice): self._session = session if device: self._device = device - if "127.0.0.1" in self._device.url("/"): + try: + device_url = self._device.url("/") + except plexapi.exceptions.BadRequest: + device_url = '127.0.0.1' + if "127.0.0.1" in device_url: self._device.proxyThroughServer() self._session = None self._machine_identifier = self._device.machineIdentifier @@ -379,12 +387,13 @@ class PlexClient(MediaPlayerDevice): self._device.protocolCapabilities) # set valid session, preferring device session - if self.plex_sessions.get(self._device.machineIdentifier, None): + if self._device.machineIdentifier in self.plex_sessions: self._session = self.plex_sessions.get( - self._device.machineIdentifier, None) + self._device.machineIdentifier, [None, None])[0] if self._session: - if self._device.machineIdentifier is not None and \ + if self._device is not None and\ + self._device.machineIdentifier is not None and \ self._session.players: self._is_player_available = True self._player = [p for p in self._session.players diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index 20a6f42d729..9dc1151064d 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -11,8 +11,8 @@ from homeassistant.components.media_player import ( MEDIA_TYPE_MOVIE, SUPPORT_NEXT_TRACK, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) -from homeassistant.const import ( - CONF_HOST, STATE_HOME, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN) +from homeassistant.const import (CONF_HOST, STATE_HOME, STATE_IDLE, + STATE_PLAYING) DEPENDENCIES = ['roku'] @@ -83,7 +83,7 @@ class RokuDevice(MediaPlayerDevice): def state(self): """Return the state of the device.""" if self.current_app is None: - return STATE_UNKNOWN + return None if (self.current_app.name == "Power Saver" or self.current_app.is_screensaver): @@ -93,7 +93,7 @@ class RokuDevice(MediaPlayerDevice): if self.current_app.name is not None: return STATE_PLAYING - return STATE_UNKNOWN + return None @property def supported_features(self): diff --git a/homeassistant/components/media_player/songpal.py b/homeassistant/components/media_player/songpal.py index 165ef668a95..e67578539ad 100644 --- a/homeassistant/components/media_player/songpal.py +++ b/homeassistant/components/media_player/songpal.py @@ -180,6 +180,8 @@ class SongpalDevice(MediaPlayerDevice): await self.async_update_ha_state(force_refresh=True) delay = min(2*delay, 300) + _LOGGER.info("Reconnected to %s", self.name) + self.dev.on_notification(VolumeChange, _volume_changed) self.dev.on_notification(ContentChange, _source_changed) self.dev.on_notification(PowerChange, _power_changed) @@ -286,7 +288,8 @@ class SongpalDevice(MediaPlayerDevice): @property def source(self): """Return currently active source.""" - return self._active_source.title + # Avoid a KeyError when _active_source is not (yet) populated + return getattr(self._active_source, 'title', None) @property def volume_level(self): diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 8a4ffeeb157..4fbd43f3f16 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( - CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN) + CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -132,7 +132,7 @@ class SpotifyMediaPlayer(MediaPlayerDevice): self._artist = None self._uri = None self._image_url = None - self._state = STATE_UNKNOWN + self._state = None self._current_device = None self._devices = {} self._volume = None diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index f8347830141..73b6a070419 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -21,7 +21,7 @@ from homeassistant.components.media_player import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( ATTR_COMMAND, CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, - STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN) + STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow @@ -242,7 +242,7 @@ class SqueezeBoxDevice(MediaPlayerDevice): return STATE_PLAYING if self._status['mode'] == 'stop': return STATE_IDLE - return STATE_UNKNOWN + return None def async_query(self, *parameters): """Send a command to the LMS. diff --git a/homeassistant/components/media_player/vizio.py b/homeassistant/components/media_player/vizio.py index e3f426cc5c6..5aae8661bd8 100644 --- a/homeassistant/components/media_player/vizio.py +++ b/homeassistant/components/media_player/vizio.py @@ -16,8 +16,7 @@ from homeassistant.components.media_player import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, - STATE_UNKNOWN) + CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) from homeassistant.helpers import config_validation as cv REQUIREMENTS = ['pyvizio==0.0.4'] @@ -82,7 +81,7 @@ class VizioDevice(MediaPlayerDevice): import pyvizio self._device = pyvizio.Vizio(DEVICE_ID, host, DEFAULT_NAME, token) self._name = name - self._state = STATE_UNKNOWN + self._state = None self._volume_level = None self._volume_step = volume_step self._current_input = None @@ -93,7 +92,7 @@ class VizioDevice(MediaPlayerDevice): """Retrieve latest state of the TV.""" is_on = self._device.get_power_state() if is_on is None: - self._state = STATE_UNKNOWN + self._state = None return if is_on is False: self._state = STATE_OFF diff --git a/homeassistant/components/media_player/volumio.py b/homeassistant/components/media_player/volumio.py index 373d3c380fc..bd43e6c3710 100644 --- a/homeassistant/components/media_player/volumio.py +++ b/homeassistant/components/media_player/volumio.py @@ -189,7 +189,7 @@ class Volumio(MediaPlayerDevice): """Volume level of the media player (0..1).""" volume = self._state.get('volume', None) if volume is not None and volume != "": - volume = volume / 100 + volume = int(volume) / 100 return volume @property diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 946e0517435..f80e29d35a0 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -20,7 +20,7 @@ from homeassistant.components.media_player import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_FILENAME, CONF_HOST, CONF_NAME, CONF_TIMEOUT, - STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN) + STATE_OFF, STATE_PAUSED, STATE_PLAYING) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script @@ -168,7 +168,7 @@ class LgWebOSDevice(MediaPlayerDevice): self._volume = 0 self._current_source = None self._current_source_id = None - self._state = STATE_UNKNOWN + self._state = None self._source_list = {} self._app_list = {} self._channel = None @@ -181,7 +181,7 @@ class LgWebOSDevice(MediaPlayerDevice): current_input = self._client.get_input() if current_input is not None: self._current_source_id = current_input - if self._state in (STATE_UNKNOWN, STATE_OFF): + if self._state in (None, STATE_OFF): self._state = STATE_PLAYING else: self._state = STATE_OFF diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index c8d71af71b4..40ede019c10 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -16,7 +16,7 @@ from homeassistant.const import ( DOMAIN = 'modbus' -REQUIREMENTS = ['pymodbus==1.3.1'] +REQUIREMENTS = ['pymodbus==1.5.2'] # Type of network CONF_BAUDRATE = 'baudrate' diff --git a/homeassistant/components/mqtt/.translations/sl.json b/homeassistant/components/mqtt/.translations/sl.json index d8d331449a2..0050d1b040d 100644 --- a/homeassistant/components/mqtt/.translations/sl.json +++ b/homeassistant/components/mqtt/.translations/sl.json @@ -22,7 +22,7 @@ "data": { "discovery": "Omogo\u010di odkrivanje" }, - "description": "\u017delite konfigurirati Home Assistent-a za povezavo s posrednikom MQTT, ki ga ponuja hass.io add-on {addon} ?", + "description": "\u017delite konfigurirati Home Assistant-a za povezavo s posrednikom MQTT, ki ga ponuja hass.io add-on {addon} ?", "title": "MQTT Broker prek dodatka Hass.io" } }, diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index fcaa05f7921..ed2a3cd6c52 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -21,12 +21,12 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( - CONF_PASSWORD, CONF_PAYLOAD, CONF_PORT, CONF_PROTOCOL, CONF_USERNAME, - CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP, CONF_NAME) + CONF_DEVICE, CONF_NAME, CONF_PASSWORD, CONF_PAYLOAD, CONF_PORT, + CONF_PROTOCOL, CONF_USERNAME, CONF_VALUE_TEMPLATE, + EVENT_HOMEASSISTANT_STOP) from homeassistant.core import Event, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import template +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ( ConfigType, HomeAssistantType, ServiceDataType) @@ -76,6 +76,7 @@ CONF_JSON_ATTRS_TOPIC = 'json_attributes_topic' CONF_QOS = 'qos' CONF_RETAIN = 'retain' +CONF_UNIQUE_ID = 'unique_id' CONF_IDENTIFIERS = 'identifiers' CONF_CONNECTIONS = 'connections' CONF_MANUFACTURER = 'manufacturer' @@ -233,7 +234,7 @@ MQTT_JSON_ATTRS_SCHEMA = vol.Schema({ vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, }) -MQTT_BASE_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(SCHEMA_BASE) +MQTT_BASE_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA_2.extend(SCHEMA_BASE) # Sensor type platforms subscribe to MQTT events MQTT_RO_PLATFORM_SCHEMA = MQTT_BASE_PLATFORM_SCHEMA.extend({ @@ -984,6 +985,7 @@ class MqttDiscoveryUpdate(Entity): elif self._discovery_update: # Non-empty payload: Notify component _LOGGER.info("Updating component: %s", self.entity_id) + payload.pop(ATTR_DISCOVERY_HASH) self.hass.async_create_task(self._discovery_update(payload)) if self._discovery_hash: @@ -996,9 +998,23 @@ class MqttDiscoveryUpdate(Entity): class MqttEntityDeviceInfo(Entity): """Mixin used for mqtt platforms that support the device registry.""" - def __init__(self, device_config: Optional[ConfigType]) -> None: + def __init__(self, device_config: Optional[ConfigType], + config_entry=None) -> None: """Initialize the device mixin.""" self._device_config = device_config + self._config_entry = config_entry + + async def device_info_discovery_update(self, config: dict): + """Handle updated discovery message.""" + self._device_config = config.get(CONF_DEVICE) + device_registry = await \ + self.hass.helpers.device_registry.async_get_registry() + config_entry_id = self._config_entry.entry_id + device_info = self.device_info + + if config_entry_id is not None and device_info is not None: + device_info['config_entry_id'] = config_entry_id + device_registry.async_get_or_create(**device_info) @property def device_info(self): diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 5bd4117ecee..b3e4d452b5c 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -9,29 +9,28 @@ import re import voluptuous as vol -from homeassistant.core import callback -import homeassistant.components.alarm_control_panel as alarm from homeassistant.components import mqtt +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.mqtt import ( + ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, + CONF_STATE_TOPIC, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, + MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) from homeassistant.const import ( CONF_CODE, CONF_DEVICE, CONF_NAME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, - STATE_ALARM_TRIGGERED, STATE_UNKNOWN) -from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, - CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, - MqttEntityDeviceInfo, subscription) -from homeassistant.components.mqtt.discovery import ( - MQTT_DISCOVERY_NEW, clear_discovery_hash) + STATE_ALARM_TRIGGERED) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' -CONF_UNIQUE_ID = 'unique_id' DEFAULT_ARM_AWAY = 'ARM_AWAY' DEFAULT_ARM_HOME = 'ARM_HOME' @@ -41,6 +40,7 @@ DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, vol.Required(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_CODE): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -49,7 +49,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, @@ -63,9 +64,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT alarm control panel.""" try: - discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, + await _async_setup_entity(config, async_add_entities, config_entry, discovery_hash) except Exception: if discovery_hash: @@ -77,29 +78,30 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_discover) -async def _async_setup_entity(config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, config_entry=None, discovery_hash=None): """Set up the MQTT Alarm Control Panel platform.""" - async_add_entities([MqttAlarm(config, discovery_hash)]) + async_add_entities([MqttAlarm(config, config_entry, discovery_hash)]) -class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - alarm.AlarmControlPanel): +class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, alarm.AlarmControlPanel): """Representation of a MQTT alarm status.""" - def __init__(self, config, discovery_hash): + def __init__(self, config, config_entry, discovery_hash): """Init the MQTT Alarm Control Panel.""" - self._state = STATE_UNKNOWN + self._state = None self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) self._sub_state = None device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Subscribe mqtt events.""" @@ -110,7 +112,9 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) self._config = config + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -137,6 +141,7 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Unsubscribe when removed.""" self._sub_state = await subscription.async_unsubscribe_topics( self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) @property diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 95886a46299..cb93712776c 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -8,28 +8,28 @@ import logging import voluptuous as vol -from homeassistant.core import callback -from homeassistant.components import mqtt, binary_sensor +from homeassistant.components import binary_sensor, mqtt from homeassistant.components.binary_sensor import ( - BinarySensorDevice, DEVICE_CLASSES_SCHEMA) -from homeassistant.const import ( - CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, - CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS, CONF_DEVICE) + DEVICE_CLASSES_SCHEMA, BinarySensorDevice) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_QOS, CONF_STATE_TOPIC, MqttAttributes, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) + ATTR_DISCOVERY_HASH, CONF_QOS, CONF_STATE_TOPIC, CONF_UNIQUE_ID, + MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import ( MQTT_DISCOVERY_NEW, clear_discovery_hash) +from homeassistant.const import ( + CONF_DEVICE, CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, CONF_NAME, + CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_VALUE_TEMPLATE) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.helpers.event as evt -from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'MQTT Binary sensor' CONF_OFF_DELAY = 'off_delay' -CONF_UNIQUE_ID = 'unique_id' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_FORCE_UPDATE = False @@ -63,9 +63,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT binary sensor.""" try: - discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, + await _async_setup_entity(config, async_add_entities, config_entry, discovery_hash) except Exception: if discovery_hash: @@ -77,16 +77,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_discover) -async def _async_setup_entity(config, async_add_entities, discovery_hash=None): +async def _async_setup_entity(config, async_add_entities, config_entry=None, + discovery_hash=None): """Set up the MQTT binary sensor.""" - async_add_entities([MqttBinarySensor(config, discovery_hash)]) + async_add_entities([MqttBinarySensor(config, config_entry, + discovery_hash)]) class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" - def __init__(self, config, discovery_hash): + def __init__(self, config, config_entry, discovery_hash): """Initialize the MQTT binary sensor.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -100,7 +102,7 @@ class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Subscribe mqtt events.""" @@ -113,6 +115,7 @@ class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._config = config await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 7bda891e921..be176a39a25 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -10,19 +10,21 @@ import logging import voluptuous as vol -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import HomeAssistantType, ConfigType -from homeassistant.core import callback +from homeassistant.components import camera, mqtt +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.mqtt import ( + ATTR_DISCOVERY_HASH, CONF_UNIQUE_ID, MqttDiscoveryUpdate, subscription) +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) from homeassistant.const import CONF_NAME -from homeassistant.components import mqtt, camera -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) CONF_TOPIC = 'topic' -CONF_UNIQUE_ID = 'unique_id' DEFAULT_NAME = 'MQTT Camera' DEPENDENCIES = ['mqtt'] @@ -37,43 +39,79 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT camera through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT camera dynamically through MQTT discovery.""" async def async_discover(discovery_payload): """Discover and add a MQTT camera.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities) + try: + discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(camera.DOMAIN, 'mqtt'), async_discover) -async def _async_setup_entity(hass, config, async_add_entities): +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT Camera.""" - async_add_entities([MqttCamera( - config.get(CONF_NAME), - config.get(CONF_UNIQUE_ID), - config.get(CONF_TOPIC) - )]) + async_add_entities([MqttCamera(config, discovery_hash)]) -class MqttCamera(Camera): +class MqttCamera(MqttDiscoveryUpdate, Camera): """representation of a MQTT camera.""" - def __init__(self, name, unique_id, topic): + def __init__(self, config, discovery_hash): """Initialize the MQTT Camera.""" - super().__init__() + self._config = config + self._unique_id = config.get(CONF_UNIQUE_ID) + self._sub_state = None - self._name = name - self._unique_id = unique_id - self._topic = topic self._qos = 0 self._last_image = None + Camera.__init__(self) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + + async def async_added_to_hass(self): + """Subscribe MQTT events.""" + await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._config = config + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + @callback + def message_received(topic, payload, qos): + """Handle new MQTT messages.""" + self._last_image = payload + + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._config.get(CONF_TOPIC), + 'msg_callback': message_received, + 'qos': self._qos}}) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + self._sub_state = await subscription.async_unsubscribe_topics( + self.hass, self._sub_state) + @asyncio.coroutine def async_camera_image(self): """Return image response.""" @@ -82,19 +120,9 @@ class MqttCamera(Camera): @property def name(self): """Return the name of this camera.""" - return self._name + return self._config.get(CONF_NAME) @property def unique_id(self): """Return a unique ID.""" return self._unique_id - - async def async_added_to_hass(self): - """Subscribe MQTT events.""" - @callback - def message_received(topic, payload, qos): - """Handle new MQTT messages.""" - self._last_image = payload - - await mqtt.async_subscribe( - self.hass, self._topic, message_received, self._qos, None) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 71950f9b1b7..db46f11b88e 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -8,28 +8,27 @@ import logging import voluptuous as vol -from homeassistant.core import callback -from homeassistant.components import mqtt, climate - +from homeassistant.components import climate, mqtt from homeassistant.components.climate import ( - STATE_HEAT, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, ClimateDevice, - PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, STATE_AUTO, - ATTR_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, - SUPPORT_SWING_MODE, SUPPORT_FAN_MODE, SUPPORT_AWAY_MODE, SUPPORT_HOLD_MODE, - SUPPORT_AUX_HEAT, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP) -from homeassistant.const import ( - ATTR_TEMPERATURE, CONF_DEVICE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_ON, - STATE_OFF) + ATTR_OPERATION_MODE, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, STATE_AUTO, STATE_COOL, + STATE_DRY, STATE_FAN_ONLY, STATE_HEAT, SUPPORT_AUX_HEAT, SUPPORT_AWAY_MODE, + SUPPORT_FAN_MODE, SUPPORT_HOLD_MODE, SUPPORT_OPERATION_MODE, + SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) +from homeassistant.components.fan import SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_QOS, CONF_RETAIN, MQTT_BASE_PLATFORM_SCHEMA, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) + ATTR_DISCOVERY_HASH, CONF_QOS, CONF_RETAIN, CONF_UNIQUE_ID, + MQTT_BASE_PLATFORM_SCHEMA, MqttAttributes, MqttAvailability, + MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import ( MQTT_DISCOVERY_NEW, clear_discovery_hash) +from homeassistant.const import ( + ATTR_TEMPERATURE, CONF_DEVICE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_OFF, + STATE_ON) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import HomeAssistantType, ConfigType -from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, - SPEED_HIGH) +from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) @@ -78,8 +77,6 @@ CONF_MIN_TEMP = 'min_temp' CONF_MAX_TEMP = 'max_temp' CONF_TEMP_STEP = 'temp_step' -CONF_UNIQUE_ID = 'unique_id' - TEMPLATE_KEYS = ( CONF_POWER_STATE_TEMPLATE, CONF_MODE_STATE_TEMPLATE, @@ -144,7 +141,8 @@ PLATFORM_SCHEMA = SCHEMA_BASE.extend({ vol.Optional(CONF_TEMP_STEP, default=1.0): vol.Coerce(float), vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, @@ -161,7 +159,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity(hass, config, async_add_entities, - discovery_hash) + config_entry, discovery_hash) except Exception: if discovery_hash: clear_discovery_hash(hass, discovery_hash) @@ -173,21 +171,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity(hass, config, async_add_entities, - discovery_hash=None): + config_entry=None, discovery_hash=None): """Set up the MQTT climate devices.""" - async_add_entities([ - MqttClimate( - hass, - config, - discovery_hash, - )]) + async_add_entities([MqttClimate(hass, config, config_entry, + discovery_hash,)]) -class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - ClimateDevice): +class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, ClimateDevice): """Representation of an MQTT climate device.""" - def __init__(self, hass, config, discovery_hash): + def __init__(self, hass, config, config_entry, discovery_hash): """Initialize the climate device.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -210,10 +204,11 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Handle being added to home assistant.""" @@ -225,7 +220,9 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, config = PLATFORM_SCHEMA(discovery_payload) self._config = config self._setup_from_config(config) + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -463,6 +460,7 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Unsubscribe when removed.""" self._sub_state = await subscription.async_unsubscribe_topics( self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) @property diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index aee825d06de..54f00d70658 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( - CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_PROTOCOL, CONF_HOST) + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, CONF_USERNAME) from .const import CONF_BROKER, CONF_DISCOVERY, DEFAULT_DISCOVERY diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 5ebe51a3bce..75fae0e9c15 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -8,70 +8,67 @@ import logging import voluptuous as vol -from homeassistant.core import callback -from homeassistant.components import mqtt, cover +from homeassistant.components import cover, mqtt from homeassistant.components.cover import ( - CoverDevice, ATTR_TILT_POSITION, SUPPORT_OPEN_TILT, - SUPPORT_CLOSE_TILT, SUPPORT_STOP_TILT, SUPPORT_SET_TILT_POSITION, - SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP, SUPPORT_SET_POSITION, - ATTR_POSITION) -from homeassistant.exceptions import TemplateError -from homeassistant.const import ( - CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, - STATE_CLOSED, STATE_UNKNOWN, CONF_DEVICE) + ATTR_POSITION, ATTR_TILT_POSITION, SUPPORT_CLOSE, SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, SUPPORT_OPEN_TILT, SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, SUPPORT_STOP_TILT, CoverDevice) from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, - CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, - MqttEntityDeviceInfo, subscription) + CONF_STATE_TOPIC, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, + MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import ( MQTT_DISCOVERY_NEW, clear_discovery_hash) +from homeassistant.const import ( + CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, STATE_CLOSED, + STATE_OPEN, STATE_UNKNOWN) +from homeassistant.core import callback +from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['mqtt'] CONF_GET_POSITION_TOPIC = 'position_topic' - +CONF_SET_POSITION_TEMPLATE = 'set_position_template' +CONF_SET_POSITION_TOPIC = 'set_position_topic' CONF_TILT_COMMAND_TOPIC = 'tilt_command_topic' CONF_TILT_STATUS_TOPIC = 'tilt_status_topic' -CONF_SET_POSITION_TOPIC = 'set_position_topic' -CONF_SET_POSITION_TEMPLATE = 'set_position_template' -CONF_PAYLOAD_OPEN = 'payload_open' CONF_PAYLOAD_CLOSE = 'payload_close' +CONF_PAYLOAD_OPEN = 'payload_open' CONF_PAYLOAD_STOP = 'payload_stop' -CONF_STATE_OPEN = 'state_open' -CONF_STATE_CLOSED = 'state_closed' -CONF_POSITION_OPEN = 'position_open' CONF_POSITION_CLOSED = 'position_closed' +CONF_POSITION_OPEN = 'position_open' +CONF_STATE_CLOSED = 'state_closed' +CONF_STATE_OPEN = 'state_open' CONF_TILT_CLOSED_POSITION = 'tilt_closed_value' -CONF_TILT_OPEN_POSITION = 'tilt_opened_value' -CONF_TILT_MIN = 'tilt_min' -CONF_TILT_MAX = 'tilt_max' -CONF_TILT_STATE_OPTIMISTIC = 'tilt_optimistic' CONF_TILT_INVERT_STATE = 'tilt_invert_state' -CONF_UNIQUE_ID = 'unique_id' +CONF_TILT_MAX = 'tilt_max' +CONF_TILT_MIN = 'tilt_min' +CONF_TILT_OPEN_POSITION = 'tilt_opened_value' +CONF_TILT_STATE_OPTIMISTIC = 'tilt_optimistic' -TILT_PAYLOAD = "tilt" -COVER_PAYLOAD = "cover" +TILT_PAYLOAD = 'tilt' +COVER_PAYLOAD = 'cover' DEFAULT_NAME = 'MQTT Cover' -DEFAULT_PAYLOAD_OPEN = 'OPEN' -DEFAULT_PAYLOAD_CLOSE = 'CLOSE' -DEFAULT_PAYLOAD_STOP = 'STOP' -DEFAULT_POSITION_OPEN = 100 -DEFAULT_POSITION_CLOSED = 0 DEFAULT_OPTIMISTIC = False +DEFAULT_PAYLOAD_CLOSE = 'CLOSE' +DEFAULT_PAYLOAD_OPEN = 'OPEN' +DEFAULT_PAYLOAD_STOP = 'STOP' +DEFAULT_POSITION_CLOSED = 0 +DEFAULT_POSITION_OPEN = 100 DEFAULT_RETAIN = False DEFAULT_TILT_CLOSED_POSITION = 0 -DEFAULT_TILT_OPEN_POSITION = 100 -DEFAULT_TILT_MIN = 0 -DEFAULT_TILT_MAX = 100 -DEFAULT_TILT_OPTIMISTIC = False DEFAULT_TILT_INVERT_STATE = False +DEFAULT_TILT_MAX = 100 +DEFAULT_TILT_MIN = 0 +DEFAULT_TILT_OPEN_POSITION = 100 +DEFAULT_TILT_OPTIMISTIC = False OPEN_CLOSE_FEATURES = (SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP) TILT_FEATURES = (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT | @@ -123,7 +120,8 @@ PLATFORM_SCHEMA = vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ default=DEFAULT_TILT_INVERT_STATE): cv.boolean, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema), validate_options) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema), validate_options) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, @@ -137,9 +135,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT cover.""" try: - discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, + await _async_setup_entity(config, async_add_entities, config_entry, discovery_hash) except Exception: if discovery_hash: @@ -151,16 +149,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_discover) -async def _async_setup_entity(config, async_add_entities, discovery_hash=None): +async def _async_setup_entity(config, async_add_entities, config_entry=None, + discovery_hash=None): """Set up the MQTT Cover.""" - async_add_entities([MqttCover(config, discovery_hash)]) + async_add_entities([MqttCover(config, config_entry, discovery_hash)]) -class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - CoverDevice): +class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, CoverDevice): """Representation of a cover that can be controlled using MQTT.""" - def __init__(self, config, discovery_hash): + def __init__(self, config, config_entry, discovery_hash): """Initialize the cover.""" self._unique_id = config.get(CONF_UNIQUE_ID) self._position = None @@ -176,10 +175,11 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, - self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttDiscoveryUpdate.__init__( + self, discovery_hash, self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Subscribe MQTT events.""" @@ -190,7 +190,9 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) self._setup_from_config(config) + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -290,6 +292,7 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Unsubscribe when removed.""" self._sub_state = await subscription.async_unsubscribe_topics( self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) @property diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index fc8b9091763..9a2daf388cb 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -10,7 +10,7 @@ import logging import re from homeassistant.components import mqtt -from homeassistant.components.mqtt import CONF_STATE_TOPIC, ATTR_DISCOVERY_HASH +from homeassistant.components.mqtt import ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC from homeassistant.const import CONF_PLATFORM from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -216,8 +216,8 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, key = ABBREVIATIONS.get(key, key) payload[key] = payload.pop(abbreviated_key) - if TOPIC_BASE in payload: - base = payload[TOPIC_BASE] + base = payload.pop(TOPIC_BASE, None) + if base: for key, value in payload.items(): if isinstance(value, str) and value: if value[0] == TOPIC_BASE and key.endswith('_topic'): diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 22f89a40e04..d15b236038e 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -8,24 +8,23 @@ import logging import voluptuous as vol -from homeassistant.core import callback from homeassistant.components import fan, mqtt -from homeassistant.const import ( - CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, STATE_ON, STATE_OFF, - CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_DEVICE) +from homeassistant.components.fan import ( + ATTR_SPEED, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, + SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity) from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, - CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, - MqttEntityDeviceInfo, subscription) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import HomeAssistantType, ConfigType -from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, - SPEED_HIGH, FanEntity, - SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, - SPEED_OFF, ATTR_SPEED) + CONF_STATE_TOPIC, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, + MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import ( MQTT_DISCOVERY_NEW, clear_discovery_hash) +from homeassistant.const import ( + CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, + CONF_STATE, STATE_OFF, STATE_ON) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) @@ -44,7 +43,6 @@ CONF_PAYLOAD_LOW_SPEED = 'payload_low_speed' CONF_PAYLOAD_MEDIUM_SPEED = 'payload_medium_speed' CONF_PAYLOAD_HIGH_SPEED = 'payload_high_speed' CONF_SPEED_LIST = 'speeds' -CONF_UNIQUE_ID = 'unique_id' DEFAULT_NAME = 'MQTT Fan' DEFAULT_PAYLOAD_ON = 'ON' @@ -80,7 +78,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, @@ -94,9 +93,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT fan.""" try: - discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, + await _async_setup_entity(config, async_add_entities, config_entry, discovery_hash) except Exception: if discovery_hash: @@ -108,20 +107,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_discover) -async def _async_setup_entity(config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, config_entry=None, discovery_hash=None): """Set up the MQTT fan.""" - async_add_entities([MqttFan( - config, - discovery_hash, - )]) + async_add_entities([MqttFan(config, config_entry, discovery_hash)]) -class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - FanEntity): +# pylint: disable=too-many-ancestors +class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, FanEntity): """A MQTT fan component.""" - def __init__(self, config, discovery_hash): + def __init__(self, config, config_entry, discovery_hash): """Initialize the MQTT fan.""" self._unique_id = config.get(CONF_UNIQUE_ID) self._state = False @@ -142,10 +139,11 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Subscribe to MQTT events.""" @@ -156,7 +154,9 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) self._setup_from_config(config) + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -200,8 +200,6 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, self._supported_features |= (self._topic[CONF_SPEED_STATE_TOPIC] is not None and SUPPORT_SET_SPEED) - self._unique_id = config.get(CONF_UNIQUE_ID) - async def _subscribe_topics(self): """(Re)Subscribe to topics.""" topics = {} @@ -273,6 +271,7 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Unsubscribe when removed.""" self._sub_state = await subscription.async_unsubscribe_topics( self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) @property diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 93f32cd2791..4ff6efb8643 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -10,14 +10,11 @@ import voluptuous as vol from homeassistant.components import light from homeassistant.components.mqtt import ATTR_DISCOVERY_HASH -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType -from . import schema_basic -from . import schema_json -from . import schema_template - _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['mqtt'] @@ -27,6 +24,10 @@ CONF_SCHEMA = 'schema' def validate_mqtt_light(value): """Validate MQTT light schema.""" + from . import schema_basic + from . import schema_json + from . import schema_template + schemas = { 'basic': schema_basic.PLATFORM_SCHEMA_BASIC, 'json': schema_json.PLATFORM_SCHEMA_JSON, @@ -35,38 +36,51 @@ def validate_mqtt_light(value): return schemas[value[CONF_SCHEMA]](value) -PLATFORM_SCHEMA = vol.All(vol.Schema({ +MQTT_LIGHT_SCHEMA_SCHEMA = vol.Schema({ vol.Optional(CONF_SCHEMA, default='basic'): vol.All( vol.Lower, vol.Any('basic', 'json', 'template')) +}) + +PLATFORM_SCHEMA = vol.All(MQTT_LIGHT_SCHEMA_SCHEMA.extend({ }, extra=vol.ALLOW_EXTRA), validate_mqtt_light) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT light through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT light dynamically through MQTT discovery.""" async def async_discover(discovery_payload): """Discover and add a MQTT light.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, - discovery_payload[ATTR_DISCOVERY_HASH]) + try: + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, config_entry, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(light.DOMAIN, 'mqtt'), async_discover) -async def _async_setup_entity(hass, config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, config_entry=None, discovery_hash=None): """Set up a MQTT Light.""" + from . import schema_basic + from . import schema_json + from . import schema_template + setup_entity = { 'basic': schema_basic.async_setup_entity_basic, 'json': schema_json.async_setup_entity_json, 'template': schema_template.async_setup_entity_template, } - await setup_entity[config['schema']]( - hass, config, async_add_entities, discovery_hash) + await setup_entity[config[CONF_SCHEMA]]( + config, async_add_entities, config_entry, discovery_hash) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index fdfc1961db3..4aee026a2f6 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -20,11 +20,14 @@ from homeassistant.const import ( CONF_RGB, CONF_STATE, CONF_VALUE_TEMPLATE, CONF_WHITE_VALUE, CONF_XY) from homeassistant.components.mqtt import ( CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) + CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, subscription) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util +from . import MQTT_LIGHT_SCHEMA_SCHEMA + _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['mqtt'] @@ -57,7 +60,6 @@ CONF_WHITE_VALUE_SCALE = 'white_value_scale' CONF_WHITE_VALUE_STATE_TOPIC = 'white_value_state_topic' CONF_WHITE_VALUE_TEMPLATE = 'white_value_template' CONF_ON_COMMAND_TYPE = 'on_command_type' -CONF_UNIQUE_ID = 'unique_id' DEFAULT_BRIGHTNESS_SCALE = 255 DEFAULT_NAME = 'MQTT Light' @@ -107,24 +109,25 @@ PLATFORM_SCHEMA_BASIC = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE): vol.In(VALUES_ON_COMMAND_TYPE), vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema).extend(MQTT_LIGHT_SCHEMA_SCHEMA.schema) -async def async_setup_entity_basic(hass, config, async_add_entities, +async def async_setup_entity_basic(config, async_add_entities, config_entry, discovery_hash=None): """Set up a MQTT Light.""" config.setdefault( CONF_STATE_VALUE_TEMPLATE, config.get(CONF_VALUE_TEMPLATE)) - async_add_entities([MqttLight(config, discovery_hash)]) + async_add_entities([MqttLight(config, config_entry, discovery_hash)]) # pylint: disable=too-many-ancestors -class MqttLight(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - Light, RestoreEntity): +class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, Light, RestoreEntity): """Representation of a MQTT light.""" - def __init__(self, config, discovery_hash): + def __init__(self, config, config_entry, discovery_hash): """Initialize MQTT light.""" self._state = False self._sub_state = None @@ -152,10 +155,11 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Subscribe to MQTT events.""" @@ -166,7 +170,9 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Handle updated discovery message.""" config = PLATFORM_SCHEMA_BASIC(discovery_payload) self._setup_from_config(config) + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -467,6 +473,7 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Unsubscribe when removed.""" self._sub_state = await subscription.async_unsubscribe_topics( self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) @property diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 6c986cbf49f..4a97eeea520 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -17,16 +17,18 @@ from homeassistant.components.light import ( SUPPORT_FLASH, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, Light) from homeassistant.components.mqtt import ( CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) + CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, subscription) from homeassistant.const import ( CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_DEVICE, CONF_EFFECT, CONF_NAME, CONF_OPTIMISTIC, CONF_RGB, CONF_WHITE_VALUE, CONF_XY, STATE_ON) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util +from . import MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import CONF_BRIGHTNESS_SCALE _LOGGER = logging.getLogger(__name__) @@ -53,7 +55,6 @@ CONF_EFFECT_LIST = 'effect_list' CONF_FLASH_TIME_LONG = 'flash_time_long' CONF_FLASH_TIME_SHORT = 'flash_time_short' CONF_HS = 'hs' -CONF_UNIQUE_ID = 'unique_id' # Stealing some of these from the base MQTT configs. PLATFORM_SCHEMA_JSON = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ @@ -80,21 +81,22 @@ PLATFORM_SCHEMA_JSON = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HS, default=DEFAULT_HS): cv.boolean, vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema).extend(MQTT_LIGHT_SCHEMA_SCHEMA.schema) -async def async_setup_entity_json(hass: HomeAssistantType, config: ConfigType, - async_add_entities, discovery_hash): +async def async_setup_entity_json(config: ConfigType, async_add_entities, + config_entry, discovery_hash): """Set up a MQTT JSON Light.""" - async_add_entities([MqttLightJson(config, discovery_hash)]) + async_add_entities([MqttLightJson(config, config_entry, discovery_hash)]) # pylint: disable=too-many-ancestors -class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, +class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, Light, RestoreEntity): """Representation of a MQTT JSON light.""" - def __init__(self, config, discovery_hash): + def __init__(self, config, config_entry, discovery_hash): """Initialize MQTT JSON light.""" self._state = False self._sub_state = None @@ -115,10 +117,11 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Subscribe to MQTT events.""" @@ -129,7 +132,9 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, """Handle updated discovery message.""" config = PLATFORM_SCHEMA_JSON(discovery_payload) self._setup_from_config(config) + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -297,6 +302,7 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, """Unsubscribe when removed.""" self._sub_state = await subscription.async_unsubscribe_topics( self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) @property diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 53423679050..4d086fd73e1 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -17,12 +17,15 @@ from homeassistant.components.light import ( from homeassistant.const import ( CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, STATE_ON, STATE_OFF) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) + CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, subscription) import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util from homeassistant.helpers.restore_state import RestoreEntity +from . import MQTT_LIGHT_SCHEMA_SCHEMA + _LOGGER = logging.getLogger(__name__) DOMAIN = 'mqtt_template' @@ -43,7 +46,6 @@ CONF_GREEN_TEMPLATE = 'green_template' CONF_RED_TEMPLATE = 'red_template' CONF_STATE_TEMPLATE = 'state_template' CONF_WHITE_VALUE_TEMPLATE = 'white_value_template' -CONF_UNIQUE_ID = 'unique_id' PLATFORM_SCHEMA_TEMPLATE = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BLUE_TEMPLATE): cv.template, @@ -66,21 +68,22 @@ PLATFORM_SCHEMA_TEMPLATE = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.All(vol.Coerce(int), vol.In([0, 1, 2])), vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema).extend(MQTT_LIGHT_SCHEMA_SCHEMA.schema) -async def async_setup_entity_template(hass, config, async_add_entities, +async def async_setup_entity_template(config, async_add_entities, config_entry, discovery_hash): """Set up a MQTT Template light.""" - async_add_entities([MqttTemplate(config, discovery_hash)]) + async_add_entities([MqttTemplate(config, config_entry, discovery_hash)]) # pylint: disable=too-many-ancestors -class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - Light, RestoreEntity): +class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, Light, RestoreEntity): """Representation of a MQTT Template light.""" - def __init__(self, config, discovery_hash): + def __init__(self, config, config_entry, discovery_hash): """Initialize a MQTT Template light.""" self._state = False self._sub_state = None @@ -102,10 +105,11 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Subscribe to MQTT events.""" @@ -116,7 +120,9 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Handle updated discovery message.""" config = PLATFORM_SCHEMA_TEMPLATE(discovery_payload) self._setup_from_config(config) + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -270,6 +276,7 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Unsubscribe when removed.""" self._sub_state = await subscription.async_unsubscribe_topics( self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) @property diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index e82498a9b12..82462b8171f 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -8,26 +8,25 @@ import logging import voluptuous as vol -from homeassistant.core import callback +from homeassistant.components import lock, mqtt from homeassistant.components.lock import LockDevice from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, - CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, - MqttEntityDeviceInfo, subscription) -from homeassistant.const import ( - CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE) -from homeassistant.components import mqtt, lock + CONF_STATE_TOPIC, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, + MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import ( MQTT_DISCOVERY_NEW, clear_discovery_hash) +from homeassistant.const import ( + CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) CONF_PAYLOAD_LOCK = 'payload_lock' CONF_PAYLOAD_UNLOCK = 'payload_unlock' -CONF_UNIQUE_ID = 'unique_id' DEFAULT_NAME = 'MQTT Lock' DEFAULT_OPTIMISTIC = False @@ -44,7 +43,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, @@ -58,9 +58,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT lock.""" try: - discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, + await _async_setup_entity(config, async_add_entities, config_entry, discovery_hash) except Exception: if discovery_hash: @@ -72,17 +72,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_discover) -async def _async_setup_entity(config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, config_entry=None, discovery_hash=None): """Set up the MQTT Lock platform.""" - async_add_entities([MqttLock(config, discovery_hash)]) + async_add_entities([MqttLock(config, config_entry, discovery_hash)]) -class MqttLock(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - LockDevice): +class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, LockDevice): """Representation of a lock that can be toggled using MQTT.""" - def __init__(self, config, discovery_hash): + def __init__(self, config, config_entry, discovery_hash): """Initialize the lock.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -92,10 +92,11 @@ class MqttLock(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Subscribe to MQTT events.""" @@ -106,7 +107,9 @@ class MqttLock(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) self._config = config + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -143,6 +146,7 @@ class MqttLock(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Unsubscribe when removed.""" self._sub_state = await subscription.async_unsubscribe_topics( self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) @property diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 688352b1ef6..02a4de9cad4 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -4,37 +4,36 @@ Support for MQTT sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.mqtt/ """ -import logging -import json from datetime import timedelta +import json +import logging from typing import Optional import voluptuous as vol -from homeassistant.core import callback -from homeassistant.components import sensor +from homeassistant.components import mqtt, sensor from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_QOS, CONF_STATE_TOPIC, MqttAttributes, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) + ATTR_DISCOVERY_HASH, CONF_QOS, CONF_STATE_TOPIC, CONF_UNIQUE_ID, + MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import ( MQTT_DISCOVERY_NEW, clear_discovery_hash) from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA from homeassistant.const import ( - CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, - CONF_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_DEVICE_CLASS, CONF_DEVICE) -from homeassistant.helpers.entity import Entity -from homeassistant.components import mqtt + CONF_DEVICE, CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, CONF_ICON, CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import HomeAssistantType, ConfigType from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) CONF_EXPIRE_AFTER = 'expire_after' CONF_JSON_ATTRS = 'json_attributes' -CONF_UNIQUE_ID = 'unique_id' DEFAULT_NAME = 'MQTT Sensor' DEFAULT_FORCE_UPDATE = False @@ -67,9 +66,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover_sensor(discovery_payload): """Discover and add a discovered MQTT sensor.""" try: - discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, + await _async_setup_entity(config, async_add_entities, config_entry, discovery_hash) except Exception: if discovery_hash: @@ -82,20 +81,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity(config: ConfigType, async_add_entities, - discovery_hash=None): + config_entry=None, discovery_hash=None): """Set up MQTT sensor.""" - async_add_entities([MqttSensor(config, discovery_hash)]) + async_add_entities([MqttSensor(config, config_entry, discovery_hash)]) class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, Entity): """Representation of a sensor that can be updated using MQTT.""" - def __init__(self, config, discovery_hash): + def __init__(self, config, config_entry, discovery_hash): """Initialize the sensor.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) - self._state = STATE_UNKNOWN + self._state = None self._sub_state = None self._expiration_trigger = None self._attributes = None @@ -110,7 +109,7 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Subscribe to MQTT events.""" @@ -123,6 +122,7 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._config = config await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -188,7 +188,7 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, def value_is_expired(self, *_): """Triggered when value is expired.""" self._expiration_trigger = None - self._state = STATE_UNKNOWN + self._state = None self.async_schedule_update_ha_state() @property diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index bc8eac86a6d..c9f8c880573 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -8,22 +8,22 @@ import logging import voluptuous as vol -from homeassistant.core import callback +from homeassistant.components import mqtt, switch from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, - CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate, - MqttEntityDeviceInfo, subscription) + CONF_STATE_TOPIC, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, + MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import ( MQTT_DISCOVERY_NEW, clear_discovery_hash) from homeassistant.components.switch import SwitchDevice from homeassistant.const import ( - CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF, - CONF_PAYLOAD_ON, CONF_ICON, STATE_ON, CONF_DEVICE) -from homeassistant.components import mqtt, switch + CONF_DEVICE, CONF_ICON, CONF_NAME, CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, CONF_VALUE_TEMPLATE, STATE_ON) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import HomeAssistantType, ConfigType from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,6 @@ DEFAULT_NAME = 'MQTT Switch' DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_OPTIMISTIC = False -CONF_UNIQUE_ID = 'unique_id' CONF_STATE_ON = "state_on" CONF_STATE_OFF = "state_off" @@ -47,7 +46,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, @@ -62,9 +62,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT switch.""" try: - discovery_hash = discovery_payload[ATTR_DISCOVERY_HASH] + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, + await _async_setup_entity(config, async_add_entities, config_entry, discovery_hash) except Exception: if discovery_hash: @@ -76,18 +76,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_discover) -async def _async_setup_entity(config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, config_entry=None, discovery_hash=None): """Set up the MQTT switch.""" - async_add_entities([MqttSwitch(config, discovery_hash)]) + async_add_entities([MqttSwitch(config, config_entry, discovery_hash)]) # pylint: disable=too-many-ancestors -class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - SwitchDevice, RestoreEntity): +class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, SwitchDevice, RestoreEntity): """Representation of a switch that can be toggled using MQTT.""" - def __init__(self, config, discovery_hash): + def __init__(self, config, config_entry, discovery_hash): """Initialize the MQTT switch.""" self._state = False self._sub_state = None @@ -102,10 +102,11 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Subscribe to MQTT events.""" @@ -116,7 +117,9 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) self._setup_from_config(config) + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -172,6 +175,7 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Unsubscribe when removed.""" self._sub_state = await subscription.async_unsubscribe_topics( self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) @property diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 612737c990d..3d53f32c6f6 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -10,16 +10,16 @@ import voluptuous as vol from homeassistant.components import mqtt from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, MqttAvailability, MqttDiscoveryUpdate, - MqttEntityDeviceInfo, subscription) -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW + ATTR_DISCOVERY_HASH, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, + MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_NEW, clear_discovery_hash) from homeassistant.components.vacuum import ( - SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, + DOMAIN, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - VacuumDevice, DOMAIN) -from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_DEVICE) + VacuumDevice) +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_DEVICE, CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -94,7 +94,6 @@ CONF_FAN_SPEED_TEMPLATE = 'fan_speed_template' CONF_SET_FAN_SPEED_TOPIC = 'set_fan_speed_topic' CONF_FAN_SPEED_LIST = 'fan_speed_list' CONF_SEND_COMMAND_TOPIC = 'send_command_topic' -CONF_UNIQUE_ID = 'unique_id' DEFAULT_NAME = 'MQTT Vacuum' DEFAULT_RETAIN = False @@ -147,7 +146,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SEND_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) async def async_setup_platform(hass, config, async_add_entities, @@ -161,26 +161,32 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT vacuum dynamically through MQTT discovery.""" async def async_discover(discovery_payload): """Discover and add a MQTT vacuum.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, - discovery_payload[ATTR_DISCOVERY_HASH]) + try: + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(config, async_add_entities, config_entry, + discovery_hash) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(DOMAIN, 'mqtt'), async_discover) -async def _async_setup_entity(config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, config_entry, discovery_hash=None): """Set up the MQTT vacuum.""" - async_add_entities([MqttVacuum(config, discovery_hash)]) + async_add_entities([MqttVacuum(config, config_entry, discovery_hash)]) # pylint: disable=too-many-ancestors -class MqttVacuum(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - VacuumDevice): +class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, VacuumDevice): """Representation of a MQTT-controlled vacuum.""" - def __init__(self, config, discovery_info): + def __init__(self, config, config_entry, discovery_info): """Initialize the vacuum.""" self._cleaning = False self._charging = False @@ -198,10 +204,11 @@ class MqttVacuum(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_info, self.discovery_update) - MqttEntityDeviceInfo.__init__(self, device_config) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) def _setup_from_config(self, config): self._name = config.get(CONF_NAME) @@ -253,7 +260,9 @@ class MqttVacuum(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) self._setup_from_config(config) + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -265,6 +274,7 @@ class MqttVacuum(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, async def async_will_remove_from_hass(self): """Unsubscribe when removed.""" await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) async def _subscribe_topics(self): diff --git a/homeassistant/components/mythicbeastsdns.py b/homeassistant/components/mythicbeastsdns.py index ff45fc8a530..d73e4619c78 100644 --- a/homeassistant/components/mythicbeastsdns.py +++ b/homeassistant/components/mythicbeastsdns.py @@ -9,7 +9,8 @@ import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_HOST, CONF_DOMAIN, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_DOMAIN, CONF_PASSWORD, \ + CONF_UPDATE_INTERVAL from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -21,8 +22,6 @@ DOMAIN = 'mythicbeastsdns' DEFAULT_INTERVAL = timedelta(minutes=10) -CONF_UPDATE_INTERVAL = 'update_interval' - CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/nest/.translations/zh-Hans.json b/homeassistant/components/nest/.translations/zh-Hans.json index 05ba5bdf155..0b5cbc989fd 100644 --- a/homeassistant/components/nest/.translations/zh-Hans.json +++ b/homeassistant/components/nest/.translations/zh-Hans.json @@ -24,8 +24,8 @@ "data": { "code": "PIN \u7801" }, - "description": "\u8981\u5173\u8054 Nest \u5e10\u6237\uff0c\u8bf7[\u6388\u6743\u5e10\u6237]({url})\u3002\n\n\u5b8c\u6210\u6388\u6743\u540e\uff0c\u5728\u4e0b\u9762\u7c98\u8d34\u83b7\u5f97\u7684 PIN \u7801\u3002", - "title": "\u5173\u8054 Nest \u5e10\u6237" + "description": "\u8981\u5173\u8054 Nest \u8d26\u6237\uff0c\u8bf7[\u6388\u6743\u8d26\u6237]({url})\u3002\n\n\u5b8c\u6210\u6388\u6743\u540e\uff0c\u5728\u4e0b\u9762\u7c98\u8d34\u83b7\u5f97\u7684 PIN \u7801\u3002", + "title": "\u5173\u8054 Nest \u8d26\u6237" } }, "title": "Nest" diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 5bbd36f4b9d..7f0fe27df73 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN from . import local_auth -REQUIREMENTS = ['python-nest==4.0.5'] +REQUIREMENTS = ['python-nest==4.1.0'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 6a486bb6362..17f7e316357 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -17,6 +17,7 @@ from voluptuous.humanize import humanize_error from homeassistant.util.json import load_json, save_json from homeassistant.exceptions import HomeAssistantError +from homeassistant.components import websocket_api from homeassistant.components.frontend import add_manifest_json_key from homeassistant.components.http import HomeAssistantView from homeassistant.components.notify import ( @@ -39,10 +40,16 @@ SERVICE_DISMISS = 'html5_dismiss' ATTR_GCM_SENDER_ID = 'gcm_sender_id' ATTR_GCM_API_KEY = 'gcm_api_key' +ATTR_VAPID_PUB_KEY = 'vapid_pub_key' +ATTR_VAPID_PRV_KEY = 'vapid_prv_key' +ATTR_VAPID_EMAIL = 'vapid_email' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(ATTR_GCM_SENDER_ID): cv.string, vol.Optional(ATTR_GCM_API_KEY): cv.string, + vol.Optional(ATTR_VAPID_PUB_KEY): cv.string, + vol.Optional(ATTR_VAPID_PRV_KEY): cv.string, + vol.Optional(ATTR_VAPID_EMAIL): cv.string, }) ATTR_SUBSCRIPTION = 'subscription' @@ -64,6 +71,11 @@ ATTR_DISMISS = 'dismiss' ATTR_JWT = 'jwt' +WS_TYPE_APPKEY = 'notify/html5/appkey' +SCHEMA_WS_APPKEY = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_APPKEY +}) + # The number of days after the moment a notification is sent that a JWT # is valid. JWT_VALID_DAYS = 7 @@ -120,6 +132,18 @@ def get_service(hass, config, discovery_info=None): if registrations is None: return None + vapid_pub_key = config.get(ATTR_VAPID_PUB_KEY) + vapid_prv_key = config.get(ATTR_VAPID_PRV_KEY) + vapid_email = config.get(ATTR_VAPID_EMAIL) + + def websocket_appkey(hass, connection, msg): + connection.send_message( + websocket_api.result_message(msg['id'], vapid_pub_key)) + + hass.components.websocket_api.async_register_command( + WS_TYPE_APPKEY, websocket_appkey, SCHEMA_WS_APPKEY + ) + hass.http.register_view( HTML5PushRegistrationView(registrations, json_path)) hass.http.register_view(HTML5PushCallbackView(registrations)) @@ -132,7 +156,8 @@ def get_service(hass, config, discovery_info=None): ATTR_GCM_SENDER_ID, config.get(ATTR_GCM_SENDER_ID)) return HTML5NotificationService( - hass, gcm_api_key, registrations, json_path) + hass, gcm_api_key, vapid_prv_key, vapid_email, registrations, + json_path) def _load_config(filename): @@ -336,9 +361,12 @@ class HTML5PushCallbackView(HomeAssistantView): class HTML5NotificationService(BaseNotificationService): """Implement the notification service for HTML5.""" - def __init__(self, hass, gcm_key, registrations, json_path): + def __init__(self, hass, gcm_key, vapid_prv, vapid_email, registrations, + json_path): """Initialize the service.""" self._gcm_key = gcm_key + self._vapid_prv = vapid_prv + self._vapid_claims = {"sub": "mailto:{}".format(vapid_email)} self.registrations = registrations self.registrations_json_path = json_path @@ -425,7 +453,7 @@ class HTML5NotificationService(BaseNotificationService): def _push_message(self, payload, **kwargs): """Send the message.""" import jwt - from pywebpush import WebPusher + from pywebpush import WebPusher, webpush timestamp = int(time.time()) @@ -452,14 +480,23 @@ class HTML5NotificationService(BaseNotificationService): jwt_token = jwt.encode(jwt_claims, jwt_secret).decode('utf-8') payload[ATTR_DATA][ATTR_JWT] = jwt_token - # Only pass the gcm key if we're actually using GCM - # If we don't, notifications break on FireFox - gcm_key = self._gcm_key \ - if 'googleapis.com' in info[ATTR_SUBSCRIPTION][ATTR_ENDPOINT] \ - else None - response = WebPusher(info[ATTR_SUBSCRIPTION]).send( - json.dumps(payload), gcm_key=gcm_key, ttl='86400' - ) + if self._vapid_prv and self._vapid_claims: + response = webpush( + info[ATTR_SUBSCRIPTION], + json.dumps(payload), + vapid_private_key=self._vapid_prv, + vapid_claims=self._vapid_claims + ) + else: + # Only pass the gcm key if we're actually using GCM + # If we don't, notifications break on FireFox + gcm_key = self._gcm_key \ + if 'googleapis.com' \ + in info[ATTR_SUBSCRIPTION][ATTR_ENDPOINT] \ + else None + response = WebPusher(info[ATTR_SUBSCRIPTION]).send( + json.dumps(payload), gcm_key=gcm_key, ttl='86400' + ) if response.status_code == 410: _LOGGER.info("Notification channel has expired") diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 8f80156c436..d494952716e 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -19,7 +19,7 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME from homeassistant.helpers.event import async_track_point_in_time -REQUIREMENTS = ['TwitterAPI==2.5.8'] +REQUIREMENTS = ['TwitterAPI==2.5.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index 853ee67db9d..869f3bd7d6e 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -13,8 +13,8 @@ from aiohttp.hdrs import CONTENT_TYPE from homeassistant.components.discovery import SERVICE_OCTOPRINT from homeassistant.const import ( - CONF_API_KEY, CONF_HOST, CONTENT_TYPE_JSON, CONF_NAME, CONF_PORT, - CONF_SSL, TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, CONF_SENSORS, + CONF_API_KEY, CONF_HOST, CONTENT_TYPE_JSON, CONF_NAME, CONF_PATH, + CONF_PORT, CONF_SSL, TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, CONF_SENSORS, CONF_BINARY_SENSORS) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv @@ -36,10 +36,20 @@ def has_all_unique_names(value): return value +def ensure_valid_path(value): + """Validate the path, ensuring it starts and ends with a /.""" + vol.Schema(cv.string)(value) + if value[0] != '/': + value = '/' + value + if value[-1] != '/': + value += '/' + return value + + BINARY_SENSOR_TYPES = { # API Endpoint, Group, Key, unit 'Printing': ['printer', 'state', 'printing', None], - 'Printing Error': ['printer', 'state', 'error', None] + "Printing Error": ['printer', 'state', 'error', None] } BINARY_SENSOR_SCHEMA = vol.Schema({ @@ -51,12 +61,12 @@ BINARY_SENSOR_SCHEMA = vol.Schema({ SENSOR_TYPES = { # API Endpoint, Group, Key, unit, icon 'Temperatures': ['printer', 'temperature', '*', TEMP_CELSIUS], - 'Current State': ['printer', 'state', 'text', None, 'mdi:printer-3d'], - 'Job Percentage': ['job', 'progress', 'completion', '%', + "Current State": ['printer', 'state', 'text', None, 'mdi:printer-3d'], + "Job Percentage": ['job', 'progress', 'completion', '%', 'mdi:file-percent'], - 'Time Remaining': ['job', 'progress', 'printTimeLeft', 'seconds', + "Time Remaining": ['job', 'progress', 'printTimeLeft', 'seconds', 'mdi:clock-end'], - 'Time Elapsed': ['job', 'progress', 'printTime', 'seconds', + "Time Elapsed": ['job', 'progress', 'printTime', 'seconds', 'mdi:clock-start'], } @@ -72,6 +82,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_SSL, default=False): cv.boolean, vol.Optional(CONF_PORT, default=80): cv.port, + vol.Optional(CONF_PATH, default='/'): ensure_valid_path, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_NUMBER_OF_TOOLS, default=0): cv.positive_int, vol.Optional(CONF_BED, default=False): cv.boolean, @@ -88,7 +99,7 @@ def setup(hass, config): def device_discovered(service, info): """Get called when an Octoprint server has been discovered.""" - _LOGGER.debug('Found an Octoprint server: %s', info) + _LOGGER.debug("Found an Octoprint server: %s", info) discovery.listen(hass, SERVICE_OCTOPRINT, device_discovered) @@ -99,9 +110,10 @@ def setup(hass, config): for printer in config[DOMAIN]: name = printer[CONF_NAME] ssl = 's' if printer[CONF_SSL] else '' - base_url = 'http{}://{}:{}/api/'.format(ssl, - printer[CONF_HOST], - printer[CONF_PORT]) + base_url = 'http{}://{}:{}{}api/'.format(ssl, + printer[CONF_HOST], + printer[CONF_PORT], + printer[CONF_PATH]) api_key = printer[CONF_API_KEY] number_of_tools = printer[CONF_NUMBER_OF_TOOLS] bed = printer[CONF_BED] @@ -154,7 +166,7 @@ class OctoPrintAPI: tools = [] if self.number_of_tools > 0: for tool_number in range(0, self.number_of_tools): - tools.append("tool" + str(tool_number)) + tools.append('tool' + str(tool_number)) if self.bed: tools.append('bed') if not self.bed and self.number_of_tools == 0: @@ -167,12 +179,12 @@ class OctoPrintAPI: """Send a get request, and return the response as a dict.""" # Only query the API at most every 30 seconds now = time.time() - if endpoint == "job": + if endpoint == 'job': last_time = self.job_last_reading[1] if last_time is not None: if now - last_time < 30.0: return self.job_last_reading[0] - elif endpoint == "printer": + elif endpoint == 'printer': last_time = self.printer_last_reading[1] if last_time is not None: if now - last_time < 30.0: @@ -183,11 +195,11 @@ class OctoPrintAPI: response = requests.get( url, headers=self.headers, timeout=9) response.raise_for_status() - if endpoint == "job": + if endpoint == 'job': self.job_last_reading[0] = response.json() self.job_last_reading[1] = time.time() self.job_available = True - elif endpoint == "printer": + elif endpoint == 'printer': self.printer_last_reading[0] = response.json() self.printer_last_reading[1] = time.time() self.printer_available = True @@ -200,13 +212,13 @@ class OctoPrintAPI: log_string = "Failed to update OctoPrint status. " + \ " Error: %s" % (conn_exc) # Only log the first failure - if endpoint == "job": + if endpoint == 'job': log_string = "Endpoint: job " + log_string if not self.job_error_logged: _LOGGER.error(log_string) self.job_error_logged = True self.job_available = False - elif endpoint == "printer": + elif endpoint == 'printer': log_string = "Endpoint: printer " + log_string if not self.printer_error_logged: _LOGGER.error(log_string) @@ -229,7 +241,7 @@ def get_value_from_json(json_dict, sensor_type, group, tool): return None if sensor_type in json_dict[group]: - if sensor_type == "target" and json_dict[sensor_type] is None: + if sensor_type == 'target' and json_dict[sensor_type] is None: return 0 return json_dict[group][sensor_type] diff --git a/homeassistant/components/openuv/.translations/ca.json b/homeassistant/components/openuv/.translations/ca.json index 5cb9a8ce5a5..ad2f391886a 100644 --- a/homeassistant/components/openuv/.translations/ca.json +++ b/homeassistant/components/openuv/.translations/ca.json @@ -2,12 +2,12 @@ "config": { "error": { "identifier_exists": "Les coordenades ja estan registrades", - "invalid_api_key": "Contrasenya API no v\u00e0lida" + "invalid_api_key": "Clau API no v\u00e0lida" }, "step": { "user": { "data": { - "api_key": "Contrasenya API d'OpenUV", + "api_key": "Clau API d'OpenUV", "elevation": "Elevaci\u00f3", "latitude": "Latitud", "longitude": "Longitud" diff --git a/homeassistant/components/plant.py b/homeassistant/components/plant.py index 55793558fd9..ed43562d221 100644 --- a/homeassistant/components/plant.py +++ b/homeassistant/components/plant.py @@ -167,7 +167,7 @@ class Plant(Entity): for reading, entity_id in config['sensors'].items(): self._sensormap[entity_id] = reading self._readingmap[reading] = entity_id - self._state = STATE_UNKNOWN + self._state = None self._name = name self._battery = None self._moisture = None diff --git a/homeassistant/components/point/.translations/zh-Hans.json b/homeassistant/components/point/.translations/zh-Hans.json index 6b5cb91cfeb..ebd2b88b10e 100644 --- a/homeassistant/components/point/.translations/zh-Hans.json +++ b/homeassistant/components/point/.translations/zh-Hans.json @@ -1,8 +1,17 @@ { "config": { + "abort": { + "authorize_url_fail": "\u751f\u6210\u6388\u6743URL\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002", + "authorize_url_timeout": "\u751f\u6210\u6388\u6743URL\u8d85\u65f6" + }, + "error": { + "follow_link": "\u8bf7\u5728\u70b9\u51fb\u63d0\u4ea4\u524d\u6309\u7167\u94fe\u63a5\u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1", + "no_token": "\u672a\u7ecfMinut\u9a8c\u8bc1" + }, "step": { "auth": { - "description": "\u8bf7\u8bbf\u95ee\u4e0b\u65b9\u7684\u94fe\u63a5\u5e76\u5141\u8bb8\u8bbf\u95ee\u60a8\u7684 Minut \u8d26\u6237\uff0c\u7136\u540e\u56de\u6765\u70b9\u51fb\u4e0b\u9762\u7684\u63d0\u4ea4\u3002\n\n[\u94fe\u63a5]({authorization_url})" + "description": "\u8bf7\u8bbf\u95ee\u4e0b\u65b9\u7684\u94fe\u63a5\u5e76\u5141\u8bb8\u8bbf\u95ee\u60a8\u7684 Minut \u8d26\u6237\uff0c\u7136\u540e\u56de\u6765\u70b9\u51fb\u4e0b\u9762\u7684\u63d0\u4ea4\u3002\n\n[\u94fe\u63a5]({authorization_url})", + "title": "\u8ba4\u8bc1\u70b9" }, "user": { "data": { diff --git a/homeassistant/components/rainmachine/.translations/zh-Hans.json b/homeassistant/components/rainmachine/.translations/zh-Hans.json index 7c6f07a7edd..e7171ca2867 100644 --- a/homeassistant/components/rainmachine/.translations/zh-Hans.json +++ b/homeassistant/components/rainmachine/.translations/zh-Hans.json @@ -1,11 +1,13 @@ { "config": { "error": { - "identifier_exists": "\u5e10\u6237\u5df2\u6ce8\u518c" + "identifier_exists": "\u8d26\u6237\u5df2\u6ce8\u518c", + "invalid_credentials": "\u65e0\u6548\u7684\u8eab\u4efd\u8ba4\u8bc1" }, "step": { "user": { "data": { + "ip_address": "\u4e3b\u673a\u540d\u6216IP\u5730\u5740", "password": "\u5bc6\u7801", "port": "\u7aef\u53e3" }, diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 4142e72337a..eb97d197e3e 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -33,7 +33,7 @@ from . import migration, purge from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.2.15'] +REQUIREMENTS = ['sqlalchemy==1.2.16'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 17909194793..3ee733c26ea 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -12,28 +12,27 @@ _LOGGER = logging.getLogger(__name__) def purge_old_data(instance, purge_days, repack): """Purge events and states older than purge_days ago.""" from .models import States, Events + from sqlalchemy.exc import SQLAlchemyError purge_before = dt_util.utcnow() - timedelta(days=purge_days) _LOGGER.debug("Purging events before %s", purge_before) - with session_scope(session=instance.get_session()) as session: - deleted_rows = session.query(States) \ - .filter((States.last_updated < purge_before)) \ - .delete(synchronize_session=False) - _LOGGER.debug("Deleted %s states", deleted_rows) + try: + with session_scope(session=instance.get_session()) as session: + deleted_rows = session.query(States) \ + .filter((States.last_updated < purge_before)) \ + .delete(synchronize_session=False) + _LOGGER.debug("Deleted %s states", deleted_rows) - deleted_rows = session.query(Events) \ - .filter((Events.time_fired < purge_before)) \ - .delete(synchronize_session=False) - _LOGGER.debug("Deleted %s events", deleted_rows) + deleted_rows = session.query(Events) \ + .filter((Events.time_fired < purge_before)) \ + .delete(synchronize_session=False) + _LOGGER.debug("Deleted %s events", deleted_rows) - # Execute sqlite vacuum command to free up space on disk - _LOGGER.debug("DB engine driver: %s", instance.engine.driver) - if repack and instance.engine.driver == 'pysqlite': - from sqlalchemy import exc - - _LOGGER.debug("Vacuuming SQLite to free space") - try: + # Execute sqlite vacuum command to free up space on disk + if repack and instance.engine.driver == 'pysqlite': + _LOGGER.debug("Vacuuming SQLite to free space") instance.engine.execute("VACUUM") - except exc.OperationalError as err: - _LOGGER.error("Error vacuuming SQLite: %s.", err) + + except SQLAlchemyError as err: + _LOGGER.warning("Error purging history: %s.", err) diff --git a/homeassistant/components/rest_command.py b/homeassistant/components/rest_command.py index c8fb1568152..c1ccc73b81c 100644 --- a/homeassistant/components/rest_command.py +++ b/homeassistant/components/rest_command.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_TIMEOUT, CONF_USERNAME, CONF_PASSWORD, CONF_URL, CONF_PAYLOAD, - CONF_METHOD, CONF_HEADERS) + CONF_METHOD, CONF_HEADERS, CONF_VERIFY_SSL) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -24,6 +24,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10 DEFAULT_METHOD = 'get' +DEFAULT_VERIFY_SSL = True SUPPORT_REST_METHODS = [ 'get', @@ -43,7 +44,8 @@ COMMAND_SCHEMA = vol.Schema({ vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, vol.Optional(CONF_PAYLOAD): cv.template, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), - vol.Optional(CONF_CONTENT_TYPE): cv.string + vol.Optional(CONF_CONTENT_TYPE): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, }) CONFIG_SCHEMA = vol.Schema({ @@ -53,10 +55,12 @@ CONFIG_SCHEMA = vol.Schema({ async def async_setup(hass, config): """Set up the REST command component.""" - websession = async_get_clientsession(hass) - def async_register_rest_command(name, command_config): """Create service for rest command.""" + websession = async_get_clientsession( + hass, + command_config.get(CONF_VERIFY_SSL) + ) timeout = command_config[CONF_TIMEOUT] method = command_config[CONF_METHOD] diff --git a/homeassistant/components/sense.py b/homeassistant/components/sense.py index 8ddeb3d2ecc..2fac2820230 100644 --- a/homeassistant/components/sense.py +++ b/homeassistant/components/sense.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ['sense_energy==0.5.1'] +REQUIREMENTS = ['sense_energy==0.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/ambient_station.py b/homeassistant/components/sensor/ambient_station.py deleted file mode 100644 index bc44f83d764..00000000000 --- a/homeassistant/components/sensor/ambient_station.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -Support for Ambient Weather Station Service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.ambient_station/ -""" - -import asyncio -from datetime import timedelta -import logging - -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle - -REQUIREMENTS = ['ambient_api==1.5.2'] - -CONF_APP_KEY = 'app_key' - -SENSOR_NAME = 0 -SENSOR_UNITS = 1 - -CONF_UNITS = 'units' -UNITS_US = 'us' -UNITS_SI = 'si' -UNIT_SYSTEM = {UNITS_US: 0, UNITS_SI: 1} - -SCAN_INTERVAL = timedelta(seconds=300) - -SENSOR_TYPES = { - 'winddir': ['Wind Dir', '°'], - 'windspeedmph': ['Wind Speed', 'mph'], - 'windgustmph': ['Wind Gust', 'mph'], - 'maxdailygust': ['Max Gust', 'mph'], - 'windgustdir': ['Gust Dir', '°'], - 'windspdmph_avg2m': ['Wind Avg 2m', 'mph'], - 'winddir_avg2m': ['Wind Dir Avg 2m', 'mph'], - 'windspdmph_avg10m': ['Wind Avg 10m', 'mph'], - 'winddir_avg10m': ['Wind Dir Avg 10m', '°'], - 'humidity': ['Humidity', '%'], - 'humidityin': ['Humidity In', '%'], - 'tempf': ['Temp', ['°F', '°C']], - 'tempinf': ['Inside Temp', ['°F', '°C']], - 'battout': ['Battery', ''], - 'hourlyrainin': ['Hourly Rain Rate', 'in/hr'], - 'dailyrainin': ['Daily Rain', 'in'], - '24hourrainin': ['24 Hr Rain', 'in'], - 'weeklyrainin': ['Weekly Rain', 'in'], - 'monthlyrainin': ['Monthly Rain', 'in'], - 'yearlyrainin': ['Yearly Rain', 'in'], - 'eventrainin': ['Event Rain', 'in'], - 'totalrainin': ['Lifetime Rain', 'in'], - 'baromrelin': ['Rel Pressure', 'inHg'], - 'baromabsin': ['Abs Pressure', 'inHg'], - 'uv': ['uv', 'Index'], - 'solarradiation': ['Solar Rad', 'W/m^2'], - 'co2': ['co2', 'ppm'], - 'lastRain': ['Last Rain', ''], - 'dewPoint': ['Dew Point', ['°F', '°C']], - 'feelsLike': ['Feels Like', ['°F', '°C']], -} - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_APP_KEY): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Optional(CONF_UNITS): vol.In([UNITS_SI, UNITS_US]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Initialze each sensor platform for each monitored condition.""" - api_key = config[CONF_API_KEY] - app_key = config[CONF_APP_KEY] - station_data = AmbientStationData(hass, api_key, app_key) - if not station_data.connect_success: - _LOGGER.error("Could not connect to weather station API") - return - - sensor_list = [] - - if CONF_UNITS in config: - sys_units = config[CONF_UNITS] - elif hass.config.units.is_metric: - sys_units = UNITS_SI - else: - sys_units = UNITS_US - - for condition in config[CONF_MONITORED_CONDITIONS]: - # create a sensor object for each monitored condition - sensor_params = SENSOR_TYPES[condition] - name = sensor_params[SENSOR_NAME] - units = sensor_params[SENSOR_UNITS] - if isinstance(units, list): - units = sensor_params[SENSOR_UNITS][UNIT_SYSTEM[sys_units]] - - sensor_list.append(AmbientWeatherSensor(station_data, condition, - name, units)) - - add_entities(sensor_list) - - -class AmbientWeatherSensor(Entity): - """Representation of a Sensor.""" - - def __init__(self, station_data, condition, name, units): - """Initialize the sensor.""" - self._state = None - self.station_data = station_data - self._condition = condition - self._name = name - self._units = units - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._units - - async def async_update(self): - """Fetch new state data for the sensor. - - This is the only method that should fetch new data for Home Assistant. - """ - _LOGGER.debug("Getting data for sensor: %s", self._name) - data = await self.station_data.get_data() - if data is None: - # update likely got throttled and returned None, so use the cached - # data from the station_data object - self._state = self.station_data.data[self._condition] - else: - if self._condition in data: - self._state = data[self._condition] - else: - _LOGGER.warning("%s sensor data not available from the " - "station", self._condition) - - _LOGGER.debug("Sensor: %s | Data: %s", self._name, self._state) - - -class AmbientStationData: - """Class to interface with ambient-api library.""" - - def __init__(self, hass, api_key, app_key): - """Initialize station data object.""" - self.hass = hass - self._api_keys = { - 'AMBIENT_ENDPOINT': - 'https://api.ambientweather.net/v1', - 'AMBIENT_API_KEY': api_key, - 'AMBIENT_APPLICATION_KEY': app_key, - 'log_level': 'DEBUG' - } - - self.data = None - self._station = None - self._api = None - self._devices = None - self.connect_success = False - - self.get_data = Throttle(SCAN_INTERVAL)(self.async_update) - self._connect_api() # attempt to connect to API - - async def async_update(self): - """Get new data.""" - # refresh API connection since servers turn over nightly - _LOGGER.debug("Getting new data from server") - new_data = None - await self.hass.async_add_executor_job(self._connect_api) - await asyncio.sleep(2) # need minimum 2 seconds between API calls - if self._station is not None: - data = await self.hass.async_add_executor_job( - self._station.get_data) - if data is not None: - new_data = data[0] - self.data = new_data - else: - _LOGGER.debug("data is None type") - else: - _LOGGER.debug("Station is None type") - - return new_data - - def _connect_api(self): - """Connect to the API and capture new data.""" - from ambient_api.ambientapi import AmbientAPI - - self._api = AmbientAPI(**self._api_keys) - self._devices = self._api.get_devices() - - if self._devices: - self._station = self._devices[0] - if self._station is not None: - self.connect_success = True - else: - _LOGGER.debug("No station devices available") diff --git a/homeassistant/components/sensor/amcrest.py b/homeassistant/components/sensor/amcrest.py index 50d6e9b7fa9..22e13d05e20 100644 --- a/homeassistant/components/sensor/amcrest.py +++ b/homeassistant/components/sensor/amcrest.py @@ -9,7 +9,7 @@ import logging from homeassistant.components.amcrest import DATA_AMCREST, SENSORS from homeassistant.helpers.entity import Entity -from homeassistant.const import CONF_NAME, CONF_SENSORS, STATE_UNKNOWN +from homeassistant.const import CONF_NAME, CONF_SENSORS DEPENDENCIES = ['amcrest'] @@ -48,7 +48,7 @@ class AmcrestSensor(Entity): self._name = '{0}_{1}'.format(name, SENSORS.get(self._sensor_type)[0]) self._icon = 'mdi:{}'.format(SENSORS.get(self._sensor_type)[2]) - self._state = STATE_UNKNOWN + self._state = None @property def name(self): diff --git a/homeassistant/components/sensor/arest.py b/homeassistant/components/sensor/arest.py index 3d85a331f6f..e0c5ef129ce 100644 --- a/homeassistant/components/sensor/arest.py +++ b/homeassistant/components/sensor/arest.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, CONF_RESOURCE, - CONF_MONITORED_VARIABLES, CONF_NAME, STATE_UNKNOWN) + CONF_MONITORED_VARIABLES, CONF_NAME) from homeassistant.exceptions import TemplateError from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -116,7 +116,7 @@ class ArestSensor(Entity): self._name = '{} {}'.format(location.title(), name.title()) self._variable = variable self._pin = pin - self._state = STATE_UNKNOWN + self._state = None self._unit_of_measurement = unit_of_measurement self._renderer = renderer @@ -145,7 +145,7 @@ class ArestSensor(Entity): return values['error'] value = self._renderer( - values.get('value', values.get(self._variable, STATE_UNKNOWN))) + values.get('value', values.get(self._variable, None))) return value def update(self): diff --git a/homeassistant/components/sensor/asuswrt.py b/homeassistant/components/sensor/asuswrt.py index 6af59ec1809..08e2ec27cb2 100644 --- a/homeassistant/components/sensor/asuswrt.py +++ b/homeassistant/components/sensor/asuswrt.py @@ -60,7 +60,7 @@ class AsuswrtSensor(Entity): async def async_update(self): """Fetch status from asuswrt.""" - self._rates = await self._api.async_get_packets_total() + self._rates = await self._api.async_get_bytes_total() self._speed = await self._api.async_get_current_transfer_rates() diff --git a/homeassistant/components/sensor/broadlink.py b/homeassistant/components/sensor/broadlink.py index 21e5b0ee1d9..50f9f955148 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS, - CONF_TIMEOUT) + CONF_TIMEOUT, CONF_UPDATE_INTERVAL) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -23,7 +23,6 @@ REQUIREMENTS = ['broadlink==0.9.0'] _LOGGER = logging.getLogger(__name__) -CONF_UPDATE_INTERVAL = 'update_interval' DEVICE_DEFAULT_NAME = 'Broadlink sensor' DEFAULT_TIMEOUT = 10 diff --git a/homeassistant/components/sensor/brottsplatskartan.py b/homeassistant/components/sensor/brottsplatskartan.py index dc338e6b84a..c308f2eac53 100644 --- a/homeassistant/components/sensor/brottsplatskartan.py +++ b/homeassistant/components/sensor/brottsplatskartan.py @@ -69,11 +69,9 @@ class BrottsplatskartanSensor(Entity): def __init__(self, bpk, name): """Initialize the Brottsplatskartan sensor.""" - import brottsplatskartan - self._attributes = {ATTR_ATTRIBUTION: brottsplatskartan.ATTRIBUTION} + self._attributes = {} self._brottsplatskartan = bpk self._name = name - self._previous_incidents = set() self._state = None @property @@ -93,6 +91,7 @@ class BrottsplatskartanSensor(Entity): def update(self): """Update device state.""" + import brottsplatskartan incident_counts = defaultdict(int) incidents = self._brottsplatskartan.get_incidents() @@ -100,13 +99,10 @@ class BrottsplatskartanSensor(Entity): _LOGGER.debug("Problems fetching incidents") return - if len(incidents) < len(self._previous_incidents): - self._previous_incidents = set() - for incident in incidents: incident_type = incident.get('title_type') incident_counts[incident_type] += 1 - self._previous_incidents.add(incident.get('id')) + self._attributes = {ATTR_ATTRIBUTION: brottsplatskartan.ATTRIBUTION} self._attributes.update(incident_counts) self._state = len(incidents) diff --git a/homeassistant/components/sensor/co2signal.py b/homeassistant/components/sensor/co2signal.py new file mode 100644 index 00000000000..ad46f3b494f --- /dev/null +++ b/homeassistant/components/sensor/co2signal.py @@ -0,0 +1,113 @@ +""" +Support for the CO2signal platform. + +For more details about this platform, please refer to the documentation +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_TOKEN, CONF_LATITUDE, CONF_LONGITUDE) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity + +CONF_COUNTRY_CODE = "country_code" + +REQUIREMENTS = ['co2signal==0.4.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = 'Data provided by CO2signal' + +MSG_LOCATION = "Please use either coordinates or the country code. " \ + "For the coordinates, " \ + "you need to use both latitude and longitude." +CO2_INTENSITY_UNIT = "CO2eq/kWh" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Inclusive(CONF_LATITUDE, 'coords', msg=MSG_LOCATION): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coords', msg=MSG_LOCATION): cv.longitude, + vol.Optional(CONF_COUNTRY_CODE): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the CO2signal sensor.""" + token = config[CONF_TOKEN] + lat = config.get(CONF_LATITUDE, hass.config.latitude) + lon = config.get(CONF_LONGITUDE, hass.config.longitude) + country_code = config.get(CONF_COUNTRY_CODE) + + _LOGGER.debug("Setting up the sensor using the %s", country_code) + + devs = [] + + devs.append(CO2Sensor(token, + country_code, + lat, + lon)) + add_entities(devs, True) + + +class CO2Sensor(Entity): + """Implementation of the CO2Signal sensor.""" + + def __init__(self, token, country_code, lat, lon): + """Initialize the sensor.""" + self._token = token + self._country_code = country_code + self._latitude = lat + self._longitude = lon + self._data = None + + if country_code is not None: + device_name = country_code + else: + device_name = '{lat}/{lon}'\ + .format(lat=round(self._latitude, 2), + lon=round(self._longitude, 2)) + + self._friendly_name = 'CO2 intensity - {}'.format(device_name) + + @property + def name(self): + """Return the name of the sensor.""" + return self._friendly_name + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return 'mdi:periodic-table-co2' + + @property + def state(self): + """Return the state of the device.""" + return self._data + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return CO2_INTENSITY_UNIT + + @property + def device_state_attributes(self): + """Return the state attributes of the last update.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + def update(self): + """Get the latest data and updates the states.""" + import CO2Signal + + _LOGGER.debug("Update data for %s", self._friendly_name) + + if self._country_code is not None: + self._data = CO2Signal.get_latest_carbon_intensity( + self._token, country_code=self._country_code) + else: + self._data = CO2Signal.get_latest_carbon_intensity( + self._token, + latitude=self._latitude, longitude=self._longitude) + + self._data = round(self._data, 2) diff --git a/homeassistant/components/sensor/comed_hourly_pricing.py b/homeassistant/components/sensor/comed_hourly_pricing.py index b5d230d8517..12b8e917f9d 100644 --- a/homeassistant/components/sensor/comed_hourly_pricing.py +++ b/homeassistant/components/sensor/comed_hourly_pricing.py @@ -15,7 +15,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET, STATE_UNKNOWN) + ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -120,7 +120,7 @@ class ComedHourlyPricingSensor(Entity): float(data[0]['price']) + self.offset, 2) else: - self._state = STATE_UNKNOWN + self._state = None except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Could not get data from ComEd API: %s", err) diff --git a/homeassistant/components/sensor/comfoconnect.py b/homeassistant/components/sensor/comfoconnect.py index b13c8d8d263..9ae6a4de091 100644 --- a/homeassistant/components/sensor/comfoconnect.py +++ b/homeassistant/components/sensor/comfoconnect.py @@ -11,8 +11,7 @@ from homeassistant.components.comfoconnect import ( ATTR_CURRENT_HUMIDITY, ATTR_OUTSIDE_TEMPERATURE, ATTR_OUTSIDE_HUMIDITY, ATTR_AIR_FLOW_SUPPLY, ATTR_AIR_FLOW_EXHAUST, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED) -from homeassistant.const import ( - CONF_RESOURCES, TEMP_CELSIUS, STATE_UNKNOWN) +from homeassistant.const import CONF_RESOURCES, TEMP_CELSIUS from homeassistant.helpers.dispatcher import dispatcher_connect from homeassistant.helpers.entity import Entity @@ -122,7 +121,7 @@ class ComfoConnectSensor(Entity): try: return self._ccb.data[self._sensor_id] except KeyError: - return STATE_UNKNOWN + return None @property def name(self): diff --git a/homeassistant/components/sensor/darksky.py b/homeassistant/components/sensor/darksky.py index 06232baca4e..28a51bd8ef2 100644 --- a/homeassistant/components/sensor/darksky.py +++ b/homeassistant/components/sensor/darksky.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS, CONF_NAME, UNIT_UV_INDEX) + CONF_MONITORED_CONDITIONS, CONF_NAME, UNIT_UV_INDEX, CONF_UPDATE_INTERVAL) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -28,7 +28,6 @@ ATTRIBUTION = "Powered by Dark Sky" CONF_FORECAST = 'forecast' CONF_LANGUAGE = 'language' CONF_UNITS = 'units' -CONF_UPDATE_INTERVAL = 'update_interval' DEFAULT_LANGUAGE = 'en' diff --git a/homeassistant/components/sensor/dnsip.py b/homeassistant/components/sensor/dnsip.py index c3ec5fd4ce2..48cf8debea6 100644 --- a/homeassistant/components/sensor/dnsip.py +++ b/homeassistant/components/sensor/dnsip.py @@ -9,7 +9,6 @@ from datetime import timedelta import voluptuous as vol -from homeassistant.const import STATE_UNKNOWN from homeassistant.components.sensor import PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -73,7 +72,7 @@ class WanIpSensor(Entity): self.resolver = aiodns.DNSResolver(loop=self.hass.loop) self.resolver.nameservers = [resolver] self.querytype = 'AAAA' if ipv6 else 'A' - self._state = STATE_UNKNOWN + self._state = None @property def name(self): @@ -97,4 +96,4 @@ class WanIpSensor(Entity): if response: self._state = response[0].host else: - self._state = STATE_UNKNOWN + self._state = None diff --git a/homeassistant/components/sensor/dovado.py b/homeassistant/components/sensor/dovado.py deleted file mode 100644 index 03c2ad601df..00000000000 --- a/homeassistant/components/sensor/dovado.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -Support for Dovado router. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.dovado/ -""" -import logging -import re -from datetime import timedelta - -import voluptuous as vol - -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle -from homeassistant.util import slugify -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT, CONF_SENSORS, - DEVICE_DEFAULT_NAME) -from homeassistant.components.sensor import (DOMAIN, PLATFORM_SCHEMA) - -_LOGGER = logging.getLogger(__name__) - -REQUIREMENTS = ['dovado==0.4.1'] - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) - -SENSOR_UPLOAD = 'upload' -SENSOR_DOWNLOAD = 'download' -SENSOR_SIGNAL = 'signal' -SENSOR_NETWORK = 'network' -SENSOR_SMS_UNREAD = 'sms' - -SENSORS = { - SENSOR_NETWORK: ('signal strength', 'Network', None, - 'mdi:access-point-network'), - SENSOR_SIGNAL: ('signal strength', 'Signal Strength', '%', - 'mdi:signal'), - SENSOR_SMS_UNREAD: ('sms unread', 'SMS unread', '', - 'mdi:message-text-outline'), - SENSOR_UPLOAD: ('traffic modem tx', 'Sent', 'GB', - 'mdi:cloud-upload'), - SENSOR_DOWNLOAD: ('traffic modem rx', 'Received', 'GB', - 'mdi:cloud-download'), -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_SENSORS): - vol.All(cv.ensure_list, [vol.In(SENSORS)]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Dovado platform for sensors.""" - return Dovado().setup(hass, config, add_entities) - - -class Dovado: - """A connection to the router.""" - - def __init__(self): - """Initialize.""" - self.state = {} - self._dovado = None - - def setup(self, hass, config, add_entities): - """Set up the connection.""" - import dovado - self._dovado = dovado.Dovado( - config.get(CONF_USERNAME), config.get(CONF_PASSWORD), - config.get(CONF_HOST), config.get(CONF_PORT)) - - if not self.update(): - return False - - def send_sms(service): - """Send SMS through the router.""" - number = service.data.get('number') - message = service.data.get('message') - _LOGGER.debug("message for %s: %s", number, message) - self._dovado.send_sms(number, message) - - if self.state.get('sms') == 'enabled': - service_name = slugify("{} {}".format(self.name, 'send_sms')) - hass.services.register(DOMAIN, service_name, send_sms) - - for sensor in SENSORS: - if sensor in config.get(CONF_SENSORS, [sensor]): - add_entities([DovadoSensor(self, sensor)]) - - return True - - @property - def name(self): - """Name of the router.""" - return self.state.get("product name", DEVICE_DEFAULT_NAME) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update device state.""" - _LOGGER.info("Updating") - try: - self.state = self._dovado.state or {} - if not self.state: - return False - self.state.update( - connected=self.state.get("modem status") == "CONNECTED") - _LOGGER.debug("Received: %s", self.state) - return True - except OSError as error: - _LOGGER.warning("Could not contact the router: %s", error) - - -class DovadoSensor(Entity): - """Representation of a Dovado sensor.""" - - def __init__(self, dovado, sensor): - """Initialize the sensor.""" - self._dovado = dovado - self._sensor = sensor - self._state = self._compute_state() - - def _compute_state(self): - state = self._dovado.state.get(SENSORS[self._sensor][0]) - if self._sensor == SENSOR_NETWORK: - match = re.search(r"\((.+)\)", state) - return match.group(1) if match else None - if self._sensor == SENSOR_SIGNAL: - try: - return int(state.split()[0]) - except ValueError: - return 0 - if self._sensor == SENSOR_SMS_UNREAD: - return int(state) - if self._sensor in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: - return round(float(state) / 1e6, 1) - return state - - def update(self): - """Update sensor values.""" - self._dovado.update() - self._state = self._compute_state() - - @property - def name(self): - """Return the name of the sensor.""" - return "{} {}".format(self._dovado.name, SENSORS[self._sensor][1]) - - @property - def state(self): - """Return the sensor state.""" - return self._state - - @property - def icon(self): - """Return the icon for the sensor.""" - return SENSORS[self._sensor][3] - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSORS[self._sensor][2] - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return {k: v for k, v in self._dovado.state.items() - if k not in ['date', 'time']} diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index e3cf704d432..ed3b409c49d 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) + CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import CoreState import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -270,7 +270,7 @@ class DSMREntity(Entity): if value is not None: return value - return STATE_UNKNOWN + return None @property def unit_of_measurement(self): @@ -287,7 +287,7 @@ class DSMREntity(Entity): if value == '0001': return 'low' - return STATE_UNKNOWN + return None class DerivativeDSMREntity(DSMREntity): @@ -300,7 +300,7 @@ class DerivativeDSMREntity(DSMREntity): _previous_reading = None _previous_timestamp = None - _state = STATE_UNKNOWN + _state = None @property def state(self): diff --git a/homeassistant/components/sensor/dweet.py b/homeassistant/components/sensor/dweet.py index 25bcaa18bab..5f85164f35d 100644 --- a/homeassistant/components/sensor/dweet.py +++ b/homeassistant/components/sensor/dweet.py @@ -13,7 +13,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT) + CONF_NAME, CONF_VALUE_TEMPLATE, CONF_UNIT_OF_MEASUREMENT) from homeassistant.helpers.entity import Entity REQUIREMENTS = ['dweepy==0.3.0'] @@ -69,7 +69,7 @@ class DweetSensor(Entity): self.dweet = dweet self._name = name self._value_template = value_template - self._state = STATE_UNKNOWN + self._state = None self._unit_of_measurement = unit_of_measurement @property @@ -92,11 +92,11 @@ class DweetSensor(Entity): self.dweet.update() if self.dweet.data is None: - self._state = STATE_UNKNOWN + self._state = None else: values = json.dumps(self.dweet.data[0]['content']) self._state = self._value_template.render_with_possible_json_value( - values, STATE_UNKNOWN) + values, None) class DweetData: diff --git a/homeassistant/components/sensor/ecoal_boiler.py b/homeassistant/components/sensor/ecoal_boiler.py new file mode 100644 index 00000000000..de81d16470c --- /dev/null +++ b/homeassistant/components/sensor/ecoal_boiler.py @@ -0,0 +1,63 @@ +""" +Allows reading temperatures from ecoal/esterownik.pl controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.ecoal_boiler/ +""" +import logging + +from homeassistant.components.ecoal_boiler import ( + DATA_ECOAL_BOILER, AVAILABLE_SENSORS, ) +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['ecoal_boiler'] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the ecoal sensors.""" + if discovery_info is None: + return + devices = [] + ecoal_contr = hass.data[DATA_ECOAL_BOILER] + for sensor_id in discovery_info: + name = AVAILABLE_SENSORS[sensor_id] + devices.append(EcoalTempSensor(ecoal_contr, name, sensor_id)) + add_entities(devices, True) + + +class EcoalTempSensor(Entity): + """Representation of a temperature sensor using ecoal status data.""" + + def __init__(self, ecoal_contr, name, status_attr): + """Initialize the sensor.""" + self._ecoal_contr = ecoal_contr + self._name = name + self._status_attr = status_attr + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + def update(self): + """Fetch new state data for the sensor. + + This is the only method that should fetch new data for Home Assistant. + """ + # Old values read 0.5 back can still be used + status = self._ecoal_contr.get_cached_status() + self._state = getattr(status, self._status_attr) diff --git a/homeassistant/components/sensor/fedex.py b/homeassistant/components/sensor/fedex.py index 7d5f47b3631..02938ff837b 100644 --- a/homeassistant/components/sensor/fedex.py +++ b/homeassistant/components/sensor/fedex.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, CONF_USERNAME, CONF_PASSWORD, - ATTR_ATTRIBUTION) + ATTR_ATTRIBUTION, CONF_UPDATE_INTERVAL) from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from homeassistant.util import Throttle @@ -23,7 +23,6 @@ REQUIREMENTS = ['fedexdeliverymanager==1.0.6'] _LOGGER = logging.getLogger(__name__) -CONF_UPDATE_INTERVAL = 'update_interval' COOKIE = 'fedexdeliverymanager_cookies.pickle' DOMAIN = 'fedex' diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 42589d6bed3..3d05dd28e79 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -32,6 +32,7 @@ FILTER_NAME_RANGE = 'range' FILTER_NAME_LOWPASS = 'lowpass' FILTER_NAME_OUTLIER = 'outlier' FILTER_NAME_THROTTLE = 'throttle' +FILTER_NAME_TIME_THROTTLE = 'time_throttle' FILTER_NAME_TIME_SMA = 'time_simple_moving_average' FILTERS = Registry() @@ -101,6 +102,12 @@ FILTER_THROTTLE_SCHEMA = FILTER_SCHEMA.extend({ default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), }) +FILTER_TIME_THROTTLE_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_TIME_THROTTLE, + vol.Required(CONF_FILTER_WINDOW_SIZE): vol.All(cv.time_period, + cv.positive_timedelta) +}) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_NAME): cv.string, @@ -109,6 +116,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ FILTER_LOWPASS_SCHEMA, FILTER_TIME_SMA_SCHEMA, FILTER_THROTTLE_SCHEMA, + FILTER_TIME_THROTTLE_SCHEMA, FILTER_RANGE_SCHEMA)]) }) @@ -444,7 +452,7 @@ class TimeSMAFilter(Filter): The window_size is determined by time, and SMA is time weighted. Args: - variant (enum): type of argorithm used to connect discrete values + type (enum): type of algorithm used to connect discrete values """ def __init__(self, window_size, precision, entity, @@ -502,3 +510,29 @@ class ThrottleFilter(Filter): self._skip_processing = True return new_state + + +@FILTERS.register(FILTER_NAME_TIME_THROTTLE) +class TimeThrottleFilter(Filter): + """Time Throttle Filter. + + One sample per time period. + """ + + def __init__(self, window_size, precision, entity): + """Initialize Filter.""" + super().__init__(FILTER_NAME_TIME_THROTTLE, + window_size, precision, entity) + self._time_window = window_size + self._last_emitted_at = None + + def _filter_state(self, new_state): + """Implement the filter.""" + window_start = new_state.timestamp - self._time_window + if not self._last_emitted_at or self._last_emitted_at <= window_start: + self._last_emitted_at = new_state.timestamp + self._skip_processing = False + else: + self._skip_processing = True + + return new_state diff --git a/homeassistant/components/sensor/fritzbox.py b/homeassistant/components/sensor/fritzbox.py new file mode 100644 index 00000000000..66c515c2bfd --- /dev/null +++ b/homeassistant/components/sensor/fritzbox.py @@ -0,0 +1,77 @@ +""" +Support for AVM Fritz!Box smarthome temperature sensor only devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/sensor.fritzbox/ +""" +import logging + +import requests + +from homeassistant.components.fritzbox import DOMAIN as FRITZBOX_DOMAIN +from homeassistant.components.fritzbox import ( + ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_LOCKED) +from homeassistant.helpers.entity import Entity +from homeassistant.const import TEMP_CELSIUS + +DEPENDENCIES = ['fritzbox'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fritzbox smarthome sensor platform.""" + _LOGGER.debug("Initializing fritzbox temperature sensors") + devices = [] + fritz_list = hass.data[FRITZBOX_DOMAIN] + + for fritz in fritz_list: + device_list = fritz.get_devices() + for device in device_list: + if (device.has_temperature_sensor + and not device.has_switch + and not device.has_thermostat): + devices.append(FritzBoxTempSensor(device, fritz)) + + add_entities(devices) + + +class FritzBoxTempSensor(Entity): + """The entity class for Fritzbox temperature sensors.""" + + def __init__(self, device, fritz): + """Initialize the switch.""" + self._device = device + self._fritz = fritz + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def state(self): + """Return the state of the sensor.""" + return self._device.temperature + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + def update(self): + """Get latest data and states from the device.""" + try: + self._device.update() + except requests.exceptions.HTTPError as ex: + _LOGGER.warning("Fritzhome connection error: %s", ex) + self._fritz.login() + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attrs = { + ATTR_STATE_DEVICE_LOCKED: self._device.device_lock, + ATTR_STATE_LOCKED: self._device.lock, + } + return attrs diff --git a/homeassistant/components/sensor/geo_rss_events.py b/homeassistant/components/sensor/geo_rss_events.py index e5e2f3d46f1..ab406f9241e 100644 --- a/homeassistant/components/sensor/geo_rss_events.py +++ b/homeassistant/components/sensor/geo_rss_events.py @@ -16,7 +16,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT, CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL) from homeassistant.helpers.entity import Entity @@ -89,7 +89,7 @@ class GeoRssServiceSensor(Entity): """Initialize the sensor.""" self._category = category self._service_name = service_name - self._state = STATE_UNKNOWN + self._state = None self._state_attributes = None self._unit_of_measurement = unit_of_measurement from georss_client.generic_feed import GenericFeed diff --git a/homeassistant/components/sensor/google_wifi.py b/homeassistant/components/sensor/google_wifi.py index 35db8f7c9e8..b78e9afb8b9 100644 --- a/homeassistant/components/sensor/google_wifi.py +++ b/homeassistant/components/sensor/google_wifi.py @@ -97,7 +97,7 @@ class GoogleWifiSensor(Entity): """Initialize a Google Wifi sensor.""" self._api = api self._name = name - self._state = STATE_UNKNOWN + self._state = None variable_info = MONITORED_CONDITIONS[variable] self._var_name = variable @@ -135,7 +135,7 @@ class GoogleWifiSensor(Entity): if self.available: self._state = self._api.data[self._var_name] else: - self._state = STATE_UNKNOWN + self._state = None class GoogleWifiAPI: diff --git a/homeassistant/components/sensor/gpsd.py b/homeassistant/components/sensor/gpsd.py index 0504cf7a511..b1ce428e1f2 100644 --- a/homeassistant/components/sensor/gpsd.py +++ b/homeassistant/components/sensor/gpsd.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_LATITUDE, ATTR_LONGITUDE, STATE_UNKNOWN, CONF_HOST, CONF_PORT, + ATTR_LATITUDE, ATTR_LONGITUDE, CONF_HOST, CONF_PORT, CONF_NAME) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -93,7 +93,7 @@ class GpsdSensor(Entity): return "3D Fix" if self.agps_thread.data_stream.mode == 2: return "2D Fix" - return STATE_UNKNOWN + return None @property def device_state_attributes(self): diff --git a/homeassistant/components/sensor/history_stats.py b/homeassistant/components/sensor/history_stats.py index a8d9276edc8..f5b76c89aba 100644 --- a/homeassistant/components/sensor/history_stats.py +++ b/homeassistant/components/sensor/history_stats.py @@ -282,6 +282,13 @@ class HistoryStatsSensor(Entity): if end is None: end = start + self._duration + if start > dt_util.now(): + # History hasn't been written yet for this period + return + if dt_util.now() < end: + # No point in making stats of the future + end = dt_util.now() + self._period = start, end diff --git a/homeassistant/components/sensor/iliad_italy.py b/homeassistant/components/sensor/iliad_italy.py new file mode 100644 index 00000000000..1e1e5077e80 --- /dev/null +++ b/homeassistant/components/sensor/iliad_italy.py @@ -0,0 +1,119 @@ +""" +Sensor to get Iliad Italy personal data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.iliad_italy/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util + +REQUIREMENTS = ['aioiliad==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by Iliad Italy" + +ICON = 'mdi:phone' + +SCAN_INTERVAL = timedelta(minutes=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +async def async_setup_platform( + hass, conf, async_add_entities, discovery_info=None): + """Set up the Iliad Italy sensor platform.""" + from aioiliad import Iliad + iliad = Iliad(conf[CONF_USERNAME], conf[CONF_PASSWORD], + async_get_clientsession(hass), hass.loop) + await iliad.login() + + if not iliad.is_logged_in(): + _LOGGER.error("Check username and password") + return + + async_add_entities([IliadSensor(iliad)], True) + + +class IliadSensor(Entity): + """Representation of a Iliad Italy Sensor.""" + + def __init__(self, iliad): + """Initialize the Iliad Italy sensor.""" + from aioiliad.IliadData import IliadData + self._iliad = iliad + self._iliaddata = IliadData(self._iliad) + self._data = None + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return "Iliad {}".format(self._data['info']['utente']) + + @property + def icon(self): + """Return the icon of the sensor.""" + return ICON + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + return '€' + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + attr = { + ATTR_ATTRIBUTION: ATTRIBUTION, + 'next_renewal': + dt_util.utc_from_timestamp( + self._data['info']['rinnovo']).isoformat(), + 'italy_sent_sms': self._data['italy']['sms'], + 'italy_over_plan_sms': self._data['italy']['sms_extra'], + 'italy_sent_mms': self._data['italy']['mms'], + 'italy_over_plan_mms': self._data['italy']['mms_extra'], + 'italy_calls_seconds': self._data['italy']['chiamate'], + 'italy_over_plan_calls': self._data['italy']['chiamate_extra'], + 'italy_data': self._data['italy']['internet'], + 'italy_data_max': self._data['italy']['internet_max'], + 'italy_data_over_plan': self._data['italy']['internet_over'], + + 'abroad_sent_sms': self._data['estero']['sms'], + 'abroad_over_plan_sms': self._data['estero']['sms_extra'], + 'abroad_sent_mms': self._data['estero']['mms'], + 'abroad_over_plan_mms': self._data['estero']['mms_extra'], + 'abroad_calls_seconds': self._data['estero']['chiamate'], + 'abroad_over_plan_calls': self._data['estero']['chiamate_extra'], + 'abroad_data': self._data['estero']['internet'], + 'abroad_data_max': self._data['estero']['internet_max'], + 'abroad_data_over_plan': self._data['estero']['internet_over'], + } + return attr + + async def async_update(self): + """Update device state.""" + await self._iliaddata.update() + self._data = { + 'italy': self._iliaddata.get_italy(), + 'estero': self._iliaddata.get_estero(), + 'info': self._iliaddata.get_general_info() + } + self._state = self._data['info']['credito'].replace('€', '') diff --git a/homeassistant/components/sensor/imap.py b/homeassistant/components/sensor/imap.py index 2ea1fd576e6..b8d363417c2 100644 --- a/homeassistant/components/sensor/imap.py +++ b/homeassistant/components/sensor/imap.py @@ -24,6 +24,7 @@ REQUIREMENTS = ['aioimaplib==0.7.13'] CONF_SERVER = 'server' CONF_FOLDER = 'folder' +CONF_SEARCH = 'search' DEFAULT_PORT = 993 @@ -36,6 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SERVER): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_FOLDER, default='INBOX'): cv.string, + vol.Optional(CONF_SEARCH, default='UnSeen UnDeleted'): cv.string, }) @@ -49,7 +51,8 @@ async def async_setup_platform(hass, config.get(CONF_PASSWORD), config.get(CONF_SERVER), config.get(CONF_PORT), - config.get(CONF_FOLDER)) + config.get(CONF_FOLDER), + config.get(CONF_SEARCH)) if not await sensor.connection(): raise PlatformNotReady @@ -60,7 +63,7 @@ async def async_setup_platform(hass, class ImapSensor(Entity): """Representation of an IMAP sensor.""" - def __init__(self, name, user, password, server, port, folder): + def __init__(self, name, user, password, server, port, folder, search): """Initialize the sensor.""" self._name = name or user self._user = user @@ -68,7 +71,8 @@ class ImapSensor(Entity): self._server = server self._port = port self._folder = folder - self._unread_count = 0 + self._email_count = None + self._search = search self._connection = None self._does_push = None self._idle_loop_task = None @@ -90,8 +94,8 @@ class ImapSensor(Entity): @property def state(self): - """Return the number of unread emails.""" - return self._unread_count + """Return the number of emails found.""" + return self._email_count @property def available(self): @@ -127,7 +131,7 @@ class ImapSensor(Entity): while True: try: if await self.connection(): - await self.refresh_unread_count() + await self.refresh_email_count() await self.async_update_ha_state() idle = await self._connection.idle_start() @@ -146,16 +150,22 @@ class ImapSensor(Entity): try: if await self.connection(): - await self.refresh_unread_count() + await self.refresh_email_count() except (aioimaplib.AioImapException, asyncio.TimeoutError): self.disconnected() - async def refresh_unread_count(self): - """Check the number of unread emails.""" + async def refresh_email_count(self): + """Check the number of found emails.""" if self._connection: await self._connection.noop() - _, lines = await self._connection.search('UnSeen UnDeleted') - self._unread_count = len(lines[0].split()) + result, lines = await self._connection.search(self._search) + + if result == 'OK': + self._email_count = len(lines[0].split()) + else: + _LOGGER.error("Can't parse IMAP server response to search " + "'%s': %s / %s", + self._search, result, lines[0]) def disconnected(self): """Forget the connection after it was lost.""" diff --git a/homeassistant/components/sensor/influxdb.py b/homeassistant/components/sensor/influxdb.py index 9f34580344c..35229c2a805 100644 --- a/homeassistant/components/sensor/influxdb.py +++ b/homeassistant/components/sensor/influxdb.py @@ -111,7 +111,7 @@ class InfluxSensor(Entity): database=database, ssl=influx_conf['ssl'], verify_ssl=influx_conf['verify_ssl']) try: - influx.query("SHOW DIAGNOSTICS;") + influx.query("SHOW SERIES LIMIT 1;") self.connected = True self.data = InfluxSensorData( influx, query.get(CONF_GROUP_FUNCTION), query.get(CONF_FIELD), diff --git a/homeassistant/components/sensor/integration.py b/homeassistant/components/sensor/integration.py new file mode 100644 index 00000000000..9426730be35 --- /dev/null +++ b/homeassistant/components/sensor/integration.py @@ -0,0 +1,172 @@ +""" +Numeric integration of data coming from a source sensor over time. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.integration/ +""" +import logging + +from decimal import Decimal, DecimalException +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, STATE_UNAVAILABLE) +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +ATTR_SOURCE_ID = 'source' + +CONF_SOURCE_SENSOR = 'source' +CONF_ROUND_DIGITS = 'round' +CONF_UNIT_PREFIX = 'unit_prefix' +CONF_UNIT_TIME = 'unit_time' +CONF_UNIT_OF_MEASUREMENT = 'unit' + +# SI Metric prefixes +UNIT_PREFIXES = {None: 1, + "k": 10**3, + "G": 10**6, + "T": 10**9} + +# SI Time prefixes +UNIT_TIME = {'s': 1, + 'min': 60, + 'h': 60*60, + 'd': 24*60*60} + +ICON = 'mdi:char-histogram' + +DEFAULT_ROUND = 3 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, + vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), + vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), + vol.Optional(CONF_UNIT_TIME, default='h'): vol.In(UNIT_TIME), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the integration sensor.""" + integral = IntegrationSensor(config[CONF_SOURCE_SENSOR], + config.get(CONF_NAME), + config[CONF_ROUND_DIGITS], + config[CONF_UNIT_PREFIX], + config[CONF_UNIT_TIME], + config.get(CONF_UNIT_OF_MEASUREMENT)) + + async_add_entities([integral]) + + +class IntegrationSensor(RestoreEntity): + """Representation of an integration sensor.""" + + def __init__(self, source_entity, name, round_digits, unit_prefix, + unit_time, unit_of_measurement): + """Initialize the integration sensor.""" + self._sensor_source_id = source_entity + self._round_digits = round_digits + self._state = 0 + + self._name = name if name is not None\ + else '{} integral'.format(source_entity) + + if unit_of_measurement is None: + self._unit_template = "{}{}{}".format( + "" if unit_prefix is None else unit_prefix, + "{}", + unit_time) + # we postpone the definition of unit_of_measurement to later + self._unit_of_measurement = None + else: + self._unit_of_measurement = unit_of_measurement + + self._unit_prefix = UNIT_PREFIXES[unit_prefix] + self._unit_time = UNIT_TIME[unit_time] + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state: + try: + self._state = Decimal(state.state) + except ValueError as err: + _LOGGER.warning("Could not restore last state: %s", err) + + @callback + def calc_integration(entity, old_state, new_state): + """Handle the sensor state changes.""" + if old_state is None or\ + old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] or\ + new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + return + + if self._unit_of_measurement is None: + unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + self._unit_of_measurement = self._unit_template.format( + "" if unit is None else unit) + + try: + # integration as the Riemann integral of previous measures. + elapsed_time = (new_state.last_updated + - old_state.last_updated).total_seconds() + area = (Decimal(new_state.state) + + Decimal(old_state.state))*Decimal(elapsed_time)/2 + integral = area / (self._unit_prefix * self._unit_time) + + assert isinstance(integral, Decimal) + except ValueError as err: + _LOGGER.warning("While calculating integration: %s", err) + except DecimalException as err: + _LOGGER.warning("Invalid state (%s > %s): %s", + old_state.state, new_state.state, err) + except AssertionError as err: + _LOGGER.error("Could not calculate integral: %s", err) + else: + self._state += integral + self.async_schedule_update_ha_state() + + async_track_state_change( + self.hass, self._sensor_source_id, calc_integration) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return round(self._state, self._round_digits) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + state_attr = { + ATTR_SOURCE_ID: self._sensor_source_id, + } + return state_attr + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON diff --git a/homeassistant/components/sensor/islamic_prayer_times.py b/homeassistant/components/sensor/islamic_prayer_times.py index a1ea5212461..50331435491 100644 --- a/homeassistant/components/sensor/islamic_prayer_times.py +++ b/homeassistant/components/sensor/islamic_prayer_times.py @@ -6,13 +6,15 @@ https://home-assistant.io/components/sensor.islamic_prayer_times/ """ import logging from datetime import datetime, timedelta + import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA + import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util -from homeassistant.helpers.event import async_track_point_in_time +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_point_in_time REQUIREMENTS = ['prayer_times_calculator==0.0.3'] @@ -118,27 +120,26 @@ async def schedule_future_update(hass, sensors, midnight_time, _LOGGER.debug("Next update scheduled for: %s", str(next_update_at)) + async def update_sensors(_): + """Update sensors with new prayer times.""" + # Update prayer times + prayer_times = prayer_times_data.get_new_prayer_times() + + _LOGGER.debug("New prayer times retrieved. Updating sensors.") + + # Update all prayer times sensors + for sensor in sensors: + sensor.async_schedule_update_ha_state(True) + + # Schedule next update + await schedule_future_update(hass, sensors, prayer_times['Midnight'], + prayer_times_data) + async_track_point_in_time(hass, - update_sensors(hass, sensors, prayer_times_data), + update_sensors, next_update_at) -async def update_sensors(hass, sensors, prayer_times_data): - """Update sensors with new prayer times.""" - # Update prayer times - prayer_times = prayer_times_data.get_new_prayer_times() - - _LOGGER.debug("New prayer times retrieved. Updating sensors.") - - # Update all prayer times sensors - for sensor in sensors: - sensor.async_schedule_update_ha_state(True) - - # Schedule next update - await schedule_future_update(hass, sensors, prayer_times['Midnight'], - prayer_times_data) - - class IslamicPrayerTimesData: """Data object for Islamic prayer times.""" diff --git a/homeassistant/components/sensor/kwb.py b/homeassistant/components/sensor/kwb.py index 20e5bc7f4ac..f490fbd5b14 100644 --- a/homeassistant/components/sensor/kwb.py +++ b/homeassistant/components/sensor/kwb.py @@ -9,8 +9,7 @@ import logging import voluptuous as vol from homeassistant.const import (CONF_HOST, CONF_PORT, CONF_DEVICE, - CONF_NAME, EVENT_HOMEASSISTANT_STOP, - STATE_UNKNOWN) + CONF_NAME, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv @@ -105,7 +104,7 @@ class KWBSensor(Entity): """Return the state of value.""" if self._sensor.value is not None and self._sensor.available: return self._sensor.value - return STATE_UNKNOWN + return None @property def unit_of_measurement(self): diff --git a/homeassistant/components/sensor/london_air.py b/homeassistant/components/sensor/london_air.py index 6c96bb48e97..afb50d766f4 100644 --- a/homeassistant/components/sensor/london_air.py +++ b/homeassistant/components/sensor/london_air.py @@ -11,7 +11,6 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import STATE_UNKNOWN import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -143,7 +142,7 @@ class AirSensor(Entity): if sites_status: self._state = max(set(sites_status), key=sites_status.count) else: - self._state = STATE_UNKNOWN + self._state = None def parse_species(species_data): diff --git a/homeassistant/components/sensor/lyft.py b/homeassistant/components/sensor/lyft.py index 671308871e5..6fb4a6bf8be 100644 --- a/homeassistant/components/sensor/lyft.py +++ b/homeassistant/components/sensor/lyft.py @@ -33,8 +33,8 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Required(CONF_START_LATITUDE): cv.latitude, - vol.Required(CONF_START_LONGITUDE): cv.longitude, + vol.Optional(CONF_START_LATITUDE): cv.latitude, + vol.Optional(CONF_START_LONGITUDE): cv.longitude, vol.Optional(CONF_END_LATITUDE): cv.latitude, vol.Optional(CONF_END_LONGITUDE): cv.longitude, vol.Optional(CONF_PRODUCT_IDS): @@ -56,7 +56,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): session = auth_flow.get_session() timeandpriceest = LyftEstimate( - session, config[CONF_START_LATITUDE], config[CONF_START_LONGITUDE], + session, config.get(CONF_START_LATITUDE, hass.config.latitude), + config.get(CONF_START_LONGITUDE, hass.config.longitude), config.get(CONF_END_LATITUDE), config.get(CONF_END_LONGITUDE)) timeandpriceest.fetch_data() except APIError as exc: diff --git a/homeassistant/components/sensor/magicseaweed.py b/homeassistant/components/sensor/magicseaweed.py index e14af6c3392..0500597b96a 100644 --- a/homeassistant/components/sensor/magicseaweed.py +++ b/homeassistant/components/sensor/magicseaweed.py @@ -23,7 +23,6 @@ _LOGGER = logging.getLogger(__name__) CONF_HOURS = 'hours' CONF_SPOT_ID = 'spot_id' CONF_UNITS = 'units' -CONF_UPDATE_INTERVAL = 'update_interval' DEFAULT_UNIT = 'us' DEFAULT_NAME = 'MSW' diff --git a/homeassistant/components/sensor/min_max.py b/homeassistant/components/sensor/min_max.py index f0334ef3255..e18c67471d9 100644 --- a/homeassistant/components/sensor/min_max.py +++ b/homeassistant/components/sensor/min_max.py @@ -70,20 +70,20 @@ async def async_setup_platform(hass, config, async_add_entities, def calc_min(sensor_values): """Calculate min value, honoring unknown states.""" - val = STATE_UNKNOWN + val = None for sval in sensor_values: if sval != STATE_UNKNOWN: - if val == STATE_UNKNOWN or val > sval: + if val is None or val > sval: val = sval return val def calc_max(sensor_values): """Calculate max value, honoring unknown states.""" - val = STATE_UNKNOWN + val = None for sval in sensor_values: if sval != STATE_UNKNOWN: - if val == STATE_UNKNOWN or val < sval: + if val is None or val < sval: val = sval return val @@ -97,7 +97,7 @@ def calc_mean(sensor_values, round_digits): val += sval count += 1 if count == 0: - return STATE_UNKNOWN + return None return round(val/count, round_digits) @@ -119,7 +119,7 @@ class MinMaxSensor(Entity): if self._sensor_type == v)).capitalize() self._unit_of_measurement = None self._unit_of_measurement_mismatch = False - self.min_value = self.max_value = self.mean = self.last = STATE_UNKNOWN + self.min_value = self.max_value = self.mean = self.last = None self.count_sensors = len(self._entity_ids) self.states = {} @@ -164,7 +164,7 @@ class MinMaxSensor(Entity): def state(self): """Return the state of the sensor.""" if self._unit_of_measurement_mismatch: - return STATE_UNKNOWN + return None return getattr(self, next( k for k, v in SENSOR_TYPES.items() if self._sensor_type == v)) diff --git a/homeassistant/components/sensor/mvglive.py b/homeassistant/components/sensor/mvglive.py index 5ec66aafe2f..71690f643f4 100644 --- a/homeassistant/components/sensor/mvglive.py +++ b/homeassistant/components/sensor/mvglive.py @@ -14,8 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, ATTR_ATTRIBUTION, STATE_UNKNOWN - ) + CONF_NAME, ATTR_ATTRIBUTION) REQUIREMENTS = ['PyMVGLive==1.1.4'] @@ -87,7 +86,7 @@ class MVGLiveSensor(Entity): self._name = name self.data = MVGLiveData(station, destinations, directions, lines, products, timeoffset, number) - self._state = STATE_UNKNOWN + self._state = None self._icon = ICONS['-'] @property diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index cd4552d91a4..d593d93729b 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, - STATE_UNKNOWN) + DEVICE_CLASS_BATTERY) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -39,7 +39,7 @@ SENSOR_TYPES = { 'sum_rain_24': ['sum_rain_24', 'mm', 'mdi:weather-rainy', None], 'battery_vp': ['Battery', '', 'mdi:battery', None], 'battery_lvl': ['Battery_lvl', '', 'mdi:battery', None], - 'battery_percent': ['battery_percent', '%', 'mdi:battery', None], + 'battery_percent': ['battery_percent', '%', None, DEVICE_CLASS_BATTERY], 'min_temp': ['Min Temp.', TEMP_CELSIUS, 'mdi:thermometer', None], 'max_temp': ['Max Temp.', TEMP_CELSIUS, 'mdi:thermometer', None], 'windangle': ['Angle', '', 'mdi:compass', None], @@ -161,7 +161,7 @@ class NetAtmoSensor(Entity): if data is None: _LOGGER.warning("No data found for %s", self.module_name) - self._state = STATE_UNKNOWN + self._state = None return if self.type == 'temperature': diff --git a/homeassistant/components/sensor/pilight.py b/homeassistant/components/sensor/pilight.py index 85843018c01..ddcbe018f8e 100644 --- a/homeassistant/components/sensor/pilight.py +++ b/homeassistant/components/sensor/pilight.py @@ -9,7 +9,7 @@ import logging import voluptuous as vol from homeassistant.const import ( - CONF_NAME, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT, CONF_PAYLOAD) + CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_PAYLOAD) from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.components import pilight @@ -46,7 +46,7 @@ class PilightSensor(Entity): def __init__(self, hass, name, variable, payload, unit_of_measurement): """Initialize the sensor.""" - self._state = STATE_UNKNOWN + self._state = None self._hass = hass self._name = name self._variable = variable diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 8f187b82fd2..eab5a14b8ca 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -18,7 +18,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['numpy==1.15.4', 'pypollencom==2.2.2'] +REQUIREMENTS = ['numpy==1.16.0', 'pypollencom==2.2.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/recollect_waste.py b/homeassistant/components/sensor/recollect_waste.py new file mode 100644 index 00000000000..9122973c919 --- /dev/null +++ b/homeassistant/components/sensor/recollect_waste.py @@ -0,0 +1,105 @@ +""" +Support for Recollect Waste curbside collection pickup. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/sensor.recollect_waste/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_NAME) +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['recollect-waste==1.0.1'] + +_LOGGER = logging.getLogger(__name__) +ATTR_PICKUP_TYPES = 'pickup_types' +ATTR_AREA_NAME = 'area_name' +CONF_PLACE_ID = 'place_id' +CONF_SERVICE_ID = 'service_id' +DEFAULT_NAME = 'recollect_waste' +ICON = 'mdi:trash-can-outline' +SCAN_INTERVAL = 86400 + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PLACE_ID): cv.string, + vol.Required(CONF_SERVICE_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Recollect Waste platform.""" + import recollect_waste + + # pylint: disable=no-member + client = recollect_waste.RecollectWasteClient(config[CONF_PLACE_ID], + config[CONF_SERVICE_ID]) + + # Ensure the client can connect to the API successfully + # with given place_id and service_id. + try: + client.get_next_pickup() + # pylint: disable=no-member + except recollect_waste.RecollectWasteException as ex: + _LOGGER.error('Recollect Waste platform error. %s', ex) + return + + add_entities([RecollectWasteSensor( + config.get(CONF_NAME), + client)], True) + + +class RecollectWasteSensor(Entity): + """Recollect Waste Sensor.""" + + def __init__(self, name, client): + """Initialize the sensor.""" + self._attributes = {} + self._name = name + self._state = None + self.client = client + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "{}{}".format(self.client.place_id, self.client.service_id) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + @property + def icon(self): + """Icon to use in the frontend.""" + return ICON + + def update(self): + """Update device state.""" + import recollect_waste + + try: + pickup_event = self.client.get_next_pickup() + self._state = pickup_event.event_date + self._attributes.update({ + ATTR_PICKUP_TYPES: pickup_event.pickup_types, + ATTR_AREA_NAME: pickup_event.area_name + }) + # pylint: disable=no-member + except recollect_waste.RecollectWasteException as ex: + _LOGGER.error('Recollect Waste platform error. %s', ex) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 5b92753eb90..4eb4b940095 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_METHOD, CONF_PASSWORD, CONF_PAYLOAD, CONF_RESOURCE, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, CONF_DEVICE_CLASS, - HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, STATE_UNKNOWN) + HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -100,7 +100,7 @@ class RestSensor(Entity): self._hass = hass self.rest = rest self._name = name - self._state = STATE_UNKNOWN + self._state = None self._unit_of_measurement = unit_of_measurement self._device_class = device_class self._value_template = value_template @@ -159,11 +159,9 @@ class RestSensor(Entity): _LOGGER.debug("Erroneous JSON: %s", value) else: _LOGGER.warning("Empty reply found when expecting JSON data") - if value is None: - value = STATE_UNKNOWN - elif self._value_template is not None: + if value is not None and self._value_template is not None: value = self._value_template.render_with_possible_json_value( - value, STATE_UNKNOWN) + value, None) self._state = value diff --git a/homeassistant/components/sensor/ring.py b/homeassistant/components/sensor/ring.py index 92c033241e0..9478768f889 100644 --- a/homeassistant/components/sensor/ring.py +++ b/homeassistant/components/sensor/ring.py @@ -15,7 +15,7 @@ from homeassistant.components.ring import ( from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS, - STATE_UNKNOWN, ATTR_ATTRIBUTION) + ATTR_ATTRIBUTION) from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level @@ -98,7 +98,7 @@ class RingSensor(Entity): self._kind = SENSOR_TYPES.get(self._sensor_type)[4] self._name = "{0} {1}".format( self._data.name, SENSOR_TYPES.get(self._sensor_type)[0]) - self._state = STATE_UNKNOWN + self._state = None self._tz = str(hass.config.time_zone) self._unique_id = '{}-{}'.format(self._data.id, self._sensor_type) @@ -141,7 +141,7 @@ class RingSensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" - if self._sensor_type == 'battery' and self._state is not STATE_UNKNOWN: + if self._sensor_type == 'battery' and self._state is not None: return icon_for_battery_level(battery_level=int(self._state), charging=False) return self._icon diff --git a/homeassistant/components/sensor/rova.py b/homeassistant/components/sensor/rova.py new file mode 100644 index 00000000000..0b7f43f0973 --- /dev/null +++ b/homeassistant/components/sensor/rova.py @@ -0,0 +1,149 @@ +""" +Support for Rova garbage calendar. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.rova/ +""" + +from datetime import datetime, timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_MONITORED_CONDITIONS, CONF_NAME, + DEVICE_CLASS_TIMESTAMP) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['rova==0.0.2'] + +# Config for rova requests. +CONF_ZIP_CODE = 'zip_code' +CONF_HOUSE_NUMBER = 'house_number' + +UPDATE_DELAY = timedelta(hours=12) +SCAN_INTERVAL = timedelta(hours=12) + +# Supported sensor types: +# Key: [json_key, name, icon] +SENSOR_TYPES = { + 'bio': ['gft', 'Biowaste', 'mdi:recycle'], + 'paper': ['papier', 'Paper', 'mdi:recycle'], + 'plastic': ['plasticplus', 'PET', 'mdi:recycle'], + 'residual': ['rest', 'Residual', 'mdi:recycle']} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ZIP_CODE): cv.string, + vol.Required(CONF_HOUSE_NUMBER): cv.string, + vol.Optional(CONF_NAME, default='Rova'): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=['bio']): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]) +}) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Create the Rova data service and sensors.""" + from rova.rova import Rova + from requests.exceptions import HTTPError, ConnectTimeout + + zip_code = config[CONF_ZIP_CODE] + house_number = config[CONF_HOUSE_NUMBER] + platform_name = config[CONF_NAME] + + # Create new Rova object to retrieve data + api = Rova(zip_code, house_number) + + try: + if not api.is_rova_area(): + _LOGGER.error("ROVA does not collect garbage in this area") + return + except (ConnectTimeout, HTTPError): + _LOGGER.error("Could not retrieve details from ROVA API") + return + + # Create rova data service which will retrieve and update the data. + data_service = RovaData(api) + + # Create a new sensor for each garbage type. + entities = [] + for sensor_key in config[CONF_MONITORED_CONDITIONS]: + sensor = RovaSensor(platform_name, sensor_key, data_service) + entities.append(sensor) + + add_entities(entities, True) + + +class RovaSensor(Entity): + """Representation of a Rova sensor.""" + + def __init__(self, platform_name, sensor_key, data_service): + """Initialize the sensor.""" + self.sensor_key = sensor_key + self.platform_name = platform_name + self.data_service = data_service + + self._state = None + + self._json_key = SENSOR_TYPES[self.sensor_key][0] + + @property + def name(self): + """Return the name.""" + return "{}_{}".format(self.platform_name, self.sensor_key) + + @property + def icon(self): + """Return the sensor icon.""" + return SENSOR_TYPES[self.sensor_key][2] + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEVICE_CLASS_TIMESTAMP + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Get the latest data from the sensor and update the state.""" + self.data_service.update() + pickup_date = self.data_service.data.get(self._json_key) + if pickup_date is not None: + self._state = pickup_date.isoformat() + + +class RovaData: + """Get and update the latest data from the Rova API.""" + + def __init__(self, api): + """Initialize the data object.""" + self.api = api + self.data = {} + + @Throttle(UPDATE_DELAY) + def update(self): + """Update the data from the Rova API.""" + from requests.exceptions import HTTPError, ConnectTimeout + + try: + items = self.api.get_calendar_items() + except (ConnectTimeout, HTTPError): + _LOGGER.error("Could not retrieve data, retry again later") + return + + self.data = {} + + for item in items: + date = datetime.strptime(item['Date'], '%Y-%m-%dT%H:%M:%S') + code = item['GarbageTypeCode'].lower() + + if code not in self.data and date > datetime.now(): + self.data[code] = date + + _LOGGER.debug("Updated Rova calendar: %s", self.data) diff --git a/homeassistant/components/sensor/scrape.py b/homeassistant/components/sensor/scrape.py index 9a67d381d42..6dd52789f71 100644 --- a/homeassistant/components/sensor/scrape.py +++ b/homeassistant/components/sensor/scrape.py @@ -12,7 +12,7 @@ from requests.auth import HTTPBasicAuth, HTTPDigestAuth from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.components.sensor.rest import RestData from homeassistant.const import ( - CONF_NAME, CONF_RESOURCE, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, + CONF_NAME, CONF_RESOURCE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, CONF_USERNAME, CONF_HEADERS, CONF_PASSWORD, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) @@ -87,7 +87,7 @@ class ScrapeSensor(Entity): """Initialize a web scrape sensor.""" self.rest = rest self._name = name - self._state = STATE_UNKNOWN + self._state = None self._select = select self._attr = attr self._value_template = value_template @@ -129,6 +129,6 @@ class ScrapeSensor(Entity): if self._value_template is not None: self._state = self._value_template.render_with_possible_json_value( - value, STATE_UNKNOWN) + value, None) else: self._state = value diff --git a/homeassistant/components/sensor/solaredge.py b/homeassistant/components/sensor/solaredge.py index 1cabe7c0445..fa49cdb3bfe 100644 --- a/homeassistant/components/sensor/solaredge.py +++ b/homeassistant/components/sensor/solaredge.py @@ -28,14 +28,14 @@ SCAN_INTERVAL = timedelta(minutes=10) # Supported sensor types: # Key: ['json_key', 'name', unit, icon] SENSOR_TYPES = { - 'life_time_data': ['lifeTimeData', "Lifetime energy", 'Wh', - 'mdi:solar-power'], - 'last_year_data': ['lastYearData', "Energy this year", 'Wh', - 'mdi:solar-power'], - 'last_month_data': ['lastMonthData', "Energy this month", 'Wh', + 'lifetime_energy': ['lifeTimeData', "Lifetime energy", 'Wh', 'mdi:solar-power'], - 'last_day_data': ['lastDayData', "Energy today", 'Wh', - 'mdi:solar-power'], + 'energy_this_year': ['lastYearData', "Energy this year", 'Wh', + 'mdi:solar-power'], + 'energy_this_month': ['lastMonthData', "Energy this month", 'Wh', + 'mdi:solar-power'], + 'energy_today': ['lastDayData', "Energy today", 'Wh', + 'mdi:solar-power'], 'current_power': ['currentPower', "Current Power", 'W', 'mdi:solar-power'] } @@ -106,7 +106,8 @@ class SolarEdgeSensor(Entity): @property def name(self): """Return the name.""" - return "{}_{}".format(self.platform_name, self.sensor_key) + return "{} ({})".format(self.platform_name, + SENSOR_TYPES[self.sensor_key][1]) @property def unit_of_measurement(self): diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index 1533aa94822..136e3cac23b 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['sqlalchemy==1.2.15'] +REQUIREMENTS = ['sqlalchemy==1.2.16'] CONF_COLUMN_NAME = 'column' CONF_QUERIES = 'queries' diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index dbb2f4ed032..d0b3df5dd0e 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.4.8'] +REQUIREMENTS = ['psutil==5.5.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/tank_utility.py b/homeassistant/components/sensor/tank_utility.py index 55928a80f13..c807f1aa4c7 100644 --- a/homeassistant/components/sensor/tank_utility.py +++ b/homeassistant/components/sensor/tank_utility.py @@ -13,8 +13,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, - STATE_UNKNOWN) +from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD from homeassistant.helpers.entity import Entity @@ -78,7 +77,7 @@ class TankUtilitySensor(Entity): self._password = password self._token = token self._device = device - self._state = STATE_UNKNOWN + self._state = None self._name = "Tank Utility " + self.device self._unit_of_measurement = SENSOR_UNIT_OF_MEASUREMENT self._attributes = {} diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 0ba470ca778..1c3ef601633 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -29,14 +29,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Tibber sensor.""" if discovery_info is None: - _LOGGER.error("Tibber sensor configuration has changed." - " Check https://home-assistant.io/components/tibber/") return tibber_connection = hass.data.get(TIBBER_DOMAIN) dev = [] - for home in tibber_connection.get_homes(): + for home in tibber_connection.get_homes(only_active=False): try: await home.update_info() except asyncio.TimeoutError as err: @@ -45,11 +43,12 @@ async def async_setup_platform(hass, config, async_add_entities, except aiohttp.ClientError as err: _LOGGER.error("Error connecting to Tibber home: %s ", err) raise PlatformNotReady() - dev.append(TibberSensorElPrice(home)) + if home.has_active_subscription: + dev.append(TibberSensorElPrice(home)) if home.has_real_time_consumption: dev.append(TibberSensorRT(home)) - async_add_entities(dev, False) + async_add_entities(dev, True) class TibberSensorElPrice(Entity): diff --git a/homeassistant/components/sensor/transmission.py b/homeassistant/components/sensor/transmission.py index a669db0e5be..efe32b07fc0 100644 --- a/homeassistant/components/sensor/transmission.py +++ b/homeassistant/components/sensor/transmission.py @@ -4,76 +4,38 @@ Support for monitoring the Transmission BitTorrent client API. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.transmission/ """ -from datetime import timedelta import logging -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_HOST, CONF_MONITORED_VARIABLES, CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_USERNAME, STATE_IDLE) -import homeassistant.helpers.config_validation as cv +from homeassistant.components.transmission import ( + DATA_TRANSMISSION, SENSOR_TYPES, SCAN_INTERVAL) +from homeassistant.const import STATE_IDLE from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from homeassistant.exceptions import PlatformNotReady -REQUIREMENTS = ['transmissionrpc==0.11'] +DEPENDENCIES = ['transmission'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Transmission' -DEFAULT_PORT = 9091 - -SENSOR_TYPES = { - 'active_torrents': ['Active Torrents', None], - 'current_status': ['Status', None], - 'download_speed': ['Down Speed', 'MB/s'], - 'paused_torrents': ['Paused Torrents', None], - 'total_torrents': ['Total Torrents', None], - 'upload_speed': ['Up Speed', 'MB/s'], -} - -SCAN_INTERVAL = timedelta(minutes=2) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_MONITORED_VARIABLES, default=['torrents']): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_USERNAME): cv.string, -}) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Transmission sensors.""" - import transmissionrpc - from transmissionrpc.error import TransmissionError + if discovery_info is None: + return - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - port = config.get(CONF_PORT) - - try: - transmission = transmissionrpc.Client( - host, port=port, user=username, password=password) - transmission_api = TransmissionData(transmission) - except TransmissionError as error: - if str(error).find("401: Unauthorized"): - _LOGGER.error("Credentials for Transmission client are not valid") - return - - _LOGGER.warning( - "Unable to connect to Transmission client: %s:%s", host, port) - raise PlatformNotReady + transmission_api = hass.data[DATA_TRANSMISSION] + monitored_variables = discovery_info['sensors'] + name = discovery_info['client_name'] dev = [] - for variable in config[CONF_MONITORED_VARIABLES]: - dev.append(TransmissionSensor(variable, transmission_api, name)) + for sensor_type in monitored_variables: + dev.append(TransmissionSensor( + sensor_type, + transmission_api, + name, + SENSOR_TYPES[sensor_type][0], + SENSOR_TYPES[sensor_type][1])) add_entities(dev, True) @@ -81,12 +43,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class TransmissionSensor(Entity): """Representation of a Transmission sensor.""" - def __init__(self, sensor_type, transmission_api, client_name): + def __init__( + self, + sensor_type, + transmission_api, + client_name, + sensor_name, + unit_of_measurement): """Initialize the sensor.""" - self._name = SENSOR_TYPES[sensor_type][0] + self._name = sensor_name self._state = None self._transmission_api = transmission_api - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._unit_of_measurement = unit_of_measurement self._data = None self.client_name = client_name self.type = sensor_type @@ -111,11 +79,17 @@ class TransmissionSensor(Entity): """Could the device be accessed during the last update call.""" return self._transmission_api.available + @Throttle(SCAN_INTERVAL) def update(self): """Get the latest data from Transmission and updates the state.""" self._transmission_api.update() self._data = self._transmission_api.data + if self.type == 'completed_torrents': + self._state = self._transmission_api.get_completed_torrent_count() + elif self.type == 'started_torrents': + self._state = self._transmission_api.get_started_torrent_count() + if self.type == 'current_status': if self._data: upload = self._data.uploadSpeed @@ -146,25 +120,3 @@ class TransmissionSensor(Entity): self._state = self._data.pausedTorrentCount elif self.type == 'total_torrents': self._state = self._data.torrentCount - - -class TransmissionData: - """Get the latest data and update the states.""" - - def __init__(self, api): - """Initialize the Transmission data object.""" - self.data = None - self.available = True - self._api = api - - @Throttle(SCAN_INTERVAL) - def update(self): - """Get the latest data from Transmission instance.""" - from transmissionrpc.error import TransmissionError - - try: - self.data = self._api.session_stats() - self.available = True - except TransmissionError: - self.available = False - _LOGGER.error("Unable to connect to Transmission client") diff --git a/homeassistant/components/sensor/travisci.py b/homeassistant/components/sensor/travisci.py index 40ae130d150..e1bd74b993c 100644 --- a/homeassistant/components/sensor/travisci.py +++ b/homeassistant/components/sensor/travisci.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, CONF_SCAN_INTERVAL, - CONF_MONITORED_CONDITIONS, STATE_UNKNOWN) + CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity REQUIREMENTS = ['TravisPy==0.3.5'] @@ -107,7 +107,7 @@ class TravisCISensor(Entity): self._repo_name = repo_name self._user = user self._branch = branch - self._state = STATE_UNKNOWN + self._state = None self._name = "{0} {1}".format(self._repo_name, SENSOR_TYPES[self._sensor_type][0]) @@ -132,7 +132,7 @@ class TravisCISensor(Entity): attrs = {} attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION - if self._build and self._state is not STATE_UNKNOWN: + if self._build and self._state is not None: if self._user and self._sensor_type == 'state': attrs['Owner Name'] = self._user.name attrs['Owner Email'] = self._user.email diff --git a/homeassistant/components/sensor/ups.py b/homeassistant/components/sensor/ups.py index aa6ce930619..44ecdc433c5 100644 --- a/homeassistant/components/sensor/ups.py +++ b/homeassistant/components/sensor/ups.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, CONF_USERNAME, CONF_PASSWORD, - ATTR_ATTRIBUTION) + ATTR_ATTRIBUTION, CONF_UPDATE_INTERVAL) from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from homeassistant.util import Throttle @@ -25,7 +25,6 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'ups' COOKIE = 'upsmychoice_cookies.pickle' -CONF_UPDATE_INTERVAL = 'update_interval' ICON = 'mdi:package-variant-closed' STATUS_DELIVERED = 'delivered' diff --git a/homeassistant/components/sensor/xbox_live.py b/homeassistant/components/sensor/xbox_live.py index 3432927dda0..9670b4b2f9c 100644 --- a/homeassistant/components/sensor/xbox_live.py +++ b/homeassistant/components/sensor/xbox_live.py @@ -57,7 +57,7 @@ class XboxSensor(Entity): def __init__(self, hass, api, xuid): """Initialize the sensor.""" self._hass = hass - self._state = STATE_UNKNOWN + self._state = None self._presence = {} self._xuid = xuid self._api = api @@ -117,5 +117,5 @@ class XboxSensor(Entity): def update(self): """Update state data from Xbox API.""" presence = self._api.get_user_presence(self._xuid) - self._state = presence.get('state', STATE_UNKNOWN) + self._state = presence.get('state') self._presence = presence.get('devices', {}) diff --git a/homeassistant/components/sensor/yweather.py b/homeassistant/components/sensor/yweather.py index e84a77b7bb6..243329680e1 100644 --- a/homeassistant/components/sensor/yweather.py +++ b/homeassistant/components/sensor/yweather.py @@ -12,7 +12,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, CONF_NAME, STATE_UNKNOWN, + TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, CONF_NAME, ATTR_ATTRIBUTION) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -99,7 +99,7 @@ class YahooWeatherSensor(Entity): self._client = name self._name = SENSOR_TYPES[sensor_type][0] self._type = sensor_type - self._state = STATE_UNKNOWN + self._state = None self._unit = SENSOR_TYPES[sensor_type][1] self._data = weather_data self._forecast = forecast diff --git a/homeassistant/components/simplisafe/.translations/zh-Hans.json b/homeassistant/components/simplisafe/.translations/zh-Hans.json index 2316f5c7454..4c57baea77f 100644 --- a/homeassistant/components/simplisafe/.translations/zh-Hans.json +++ b/homeassistant/components/simplisafe/.translations/zh-Hans.json @@ -1,7 +1,7 @@ { "config": { "error": { - "identifier_exists": "\u5e10\u6237\u5df2\u6ce8\u518c", + "identifier_exists": "\u8d26\u6237\u5df2\u6ce8\u518c", "invalid_credentials": "\u65e0\u6548\u7684\u8eab\u4efd\u8ba4\u8bc1" }, "step": { diff --git a/homeassistant/components/smartthings/.translations/ca.json b/homeassistant/components/smartthings/.translations/ca.json new file mode 100644 index 00000000000..3c0ca05a8d5 --- /dev/null +++ b/homeassistant/components/smartthings/.translations/ca.json @@ -0,0 +1,27 @@ +{ + "config": { + "error": { + "app_not_installed": "Assegura't que has instal\u00b7lat i autoritzat l'aplicaci\u00f3 SmartApp de Home Assistant i torna-ho a provar.", + "app_setup_error": "No s'ha pogut configurar SmartApp. Siusplau, torna-ho a provar.", + "base_url_not_https": "L'`base_url` per al component `http` ha d'estar configurat i comen\u00e7ar amb `https://`.", + "token_already_setup": "El testimoni d'autenticaci\u00f3 ja ha estat configurat.", + "token_forbidden": "El testimoni d'autenticaci\u00f3 no t\u00e9 cont\u00e9 els apartats OAuth obligatoris.", + "token_invalid_format": "El testimoni d'autenticaci\u00f3 ha d'estar en format UID/GUID", + "token_unauthorized": "El testimoni d'autenticaci\u00f3 no \u00e9s v\u00e0lid o ja no t\u00e9 autoritzaci\u00f3." + }, + "step": { + "user": { + "data": { + "access_token": "Testimoni d'acc\u00e9s" + }, + "description": "Introdueix un [testimoni d'autenticaci\u00f3 d'acc\u00e9s personal] ({token_url}) de SmartThings que s'ha creat a trav\u00e9s les [instruccions] ({component_url}).", + "title": "Introdueix el testimoni d'autenticaci\u00f3 d'acc\u00e9s personal" + }, + "wait_install": { + "description": "Instal\u00b7la l'SmartApp de Home Assistant en almenys una ubicaci\u00f3 i fes clic a Enviar.", + "title": "Instal\u00b7laci\u00f3 de SmartApp" + } + }, + "title": "SmartThings" + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/.translations/en.json b/homeassistant/components/smartthings/.translations/en.json new file mode 100644 index 00000000000..f2775b30ae2 --- /dev/null +++ b/homeassistant/components/smartthings/.translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "error": { + "app_not_installed": "Please ensure you have installed and authorized the Home Assistant SmartApp and try again.", + "app_setup_error": "Unable to setup the SmartApp. Please try again.", + "base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`.", + "token_already_setup": "The token has already been setup.", + "token_forbidden": "The token does not have the required OAuth scopes.", + "token_invalid_format": "The token must be in the UID/GUID format", + "token_unauthorized": "The token is invalid or no longer authorized." + }, + "step": { + "user": { + "data": { + "access_token": "Access Token" + }, + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}).", + "title": "Enter Personal Access Token" + }, + "wait_install": { + "description": "Please install the Home Assistant SmartApp in at least one location and click submit.", + "title": "Install SmartApp" + } + }, + "title": "SmartThings" + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/.translations/lb.json b/homeassistant/components/smartthings/.translations/lb.json new file mode 100644 index 00000000000..fd59d187314 --- /dev/null +++ b/homeassistant/components/smartthings/.translations/lb.json @@ -0,0 +1,27 @@ +{ + "config": { + "error": { + "app_not_installed": "Stellt w.e.g s\u00e9cher dass d'Home Assistant SmartApp install\u00e9iert an autoris\u00e9iert ass, a prob\u00e9iert nach emol.", + "app_setup_error": "Kann SmartApp net install\u00e9ieren. Prob\u00e9iert w.e.g. nach emol.", + "base_url_not_https": "`base_url` fir den `http` Komponent muss konfigur\u00e9iert sinn a mat `https://`uf\u00e4nken.", + "token_already_setup": "Den Jeton gouf schonn ageriicht.", + "token_forbidden": "De Jeton huet net d\u00e9i n\u00e9ideg OAuth M\u00e9iglechkeeten.", + "token_invalid_format": "De Jeton muss am UID/GUID Format sinn", + "token_unauthorized": "De Jeton ass ong\u00eblteg oder net m\u00e9i autoris\u00e9iert." + }, + "step": { + "user": { + "data": { + "access_token": "Acc\u00e8ss Jeton" + }, + "description": "Gitt w.e.g. ee [Pers\u00e9inlechen Acc\u00e8s Jeton]({token_url}) vu SmartThings an dee via [d'Instruktiounen] ({component_url}) erstallt gouf.", + "title": "Pers\u00e9inlechen Acc\u00e8ss Jeton uginn" + }, + "wait_install": { + "description": "Install\u00e9iert d'Home Assistant SmartApp op mannst ee mol a klickt op Ofsch\u00e9cken.", + "title": "SmartApp install\u00e9ieren" + } + }, + "title": "SmartThings" + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/.translations/pl.json b/homeassistant/components/smartthings/.translations/pl.json new file mode 100644 index 00000000000..379cdf699b7 --- /dev/null +++ b/homeassistant/components/smartthings/.translations/pl.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "wait_install": { + "title": "Zainstaluj SmartApp" + } + }, + "title": "SmartThings" + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/.translations/zh-Hant.json b/homeassistant/components/smartthings/.translations/zh-Hant.json new file mode 100644 index 00000000000..952eafec60c --- /dev/null +++ b/homeassistant/components/smartthings/.translations/zh-Hant.json @@ -0,0 +1,27 @@ +{ + "config": { + "error": { + "app_not_installed": "\u8acb\u78ba\u8a8d\u5df2\u7d93\u5b89\u88dd\u4e26\u6388\u6b0a Home Assistant Smartapp \u5f8c\u518d\u8a66\u4e00\u6b21\u3002", + "app_setup_error": "\u7121\u6cd5\u8a2d\u5b9a SmartApp\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "base_url_not_https": "\u5fc5\u9808\u8a2d\u5b9a\u300chttp\u300d\u5143\u4ef6\u4e4b\u300cbase_url\u300d\uff0c\u4e26\u4ee5\u300chttps://\u300d\u70ba\u958b\u982d\u3002", + "token_already_setup": "\u5bc6\u9470\u5df2\u8a2d\u5b9a\u904e\u3002", + "token_forbidden": "\u5bc6\u9470\u4e0d\u5177\u6240\u9700\u7684 OAuth \u7bc4\u570d\u3002", + "token_invalid_format": "\u5bc6\u9470\u5fc5\u9808\u70ba UID/GUID \u683c\u5f0f", + "token_unauthorized": "\u5bc6\u9470\u7121\u6548\u6216\u4e0d\u518d\u5177\u6709\u6388\u6b0a\u3002" + }, + "step": { + "user": { + "data": { + "access_token": "\u5b58\u53d6\u5bc6\u9470" + }, + "description": "\u8acb\u8f38\u5165\u8ddf\u8457[ \u6307\u5f15]({component_url})\u6240\u7522\u751f\u7684 SmartThings [\u500b\u4eba\u5b58\u53d6\u5bc6\u9470]({token_url})\u3002", + "title": "\u8f38\u5165\u500b\u4eba\u5b58\u53d6\u5bc6\u9470" + }, + "wait_install": { + "description": "\u8acb\u81f3\u5c11\u65bc\u4e00\u500b\u4f4d\u7f6e\u4e2d\u5b89\u88dd Home Assistant Smartapp\uff0c\u4e26\u9ede\u9078\u50b3\u9001\u3002", + "title": "\u5b89\u88dd SmartApp" + } + }, + "title": "SmartThings" + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py new file mode 100644 index 00000000000..d86524ef62b --- /dev/null +++ b/homeassistant/components/smartthings/__init__.py @@ -0,0 +1,226 @@ +"""SmartThings Cloud integration for Home Assistant.""" + +import asyncio +import logging +from typing import Iterable + +from aiohttp.client_exceptions import ( + ClientConnectionError, ClientResponseError) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .config_flow import SmartThingsFlowHandler # noqa +from .const import ( + CONF_APP_ID, CONF_INSTALLED_APP_ID, DATA_BROKERS, DATA_MANAGER, DOMAIN, + EVENT_BUTTON, SIGNAL_SMARTTHINGS_UPDATE, SUPPORTED_PLATFORMS) +from .smartapp import ( + setup_smartapp, setup_smartapp_endpoint, validate_installed_app) + +REQUIREMENTS = ['pysmartapp==0.3.0', 'pysmartthings==0.4.2'] +DEPENDENCIES = ['webhook'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Initialize the SmartThings platform.""" + await setup_smartapp_endpoint(hass) + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Initialize config entry which represents an installed SmartApp.""" + from pysmartthings import SmartThings + + if not hass.config.api.base_url.lower().startswith('https://'): + _LOGGER.warning("The 'base_url' of the 'http' component must be " + "configured and start with 'https://'") + return False + + api = SmartThings(async_get_clientsession(hass), + entry.data[CONF_ACCESS_TOKEN]) + + remove_entry = False + try: + # See if the app is already setup. This occurs when there are + # installs in multiple SmartThings locations (valid use-case) + manager = hass.data[DOMAIN][DATA_MANAGER] + smart_app = manager.smartapps.get(entry.data[CONF_APP_ID]) + if not smart_app: + # Validate and setup the app. + app = await api.app(entry.data[CONF_APP_ID]) + smart_app = setup_smartapp(hass, app) + + # Validate and retrieve the installed app. + installed_app = await validate_installed_app( + api, entry.data[CONF_INSTALLED_APP_ID]) + + # Get devices and their current status + devices = await api.devices( + location_ids=[installed_app.location_id]) + + async def retrieve_device_status(device): + try: + await device.status.refresh() + except ClientResponseError: + _LOGGER.debug("Unable to update status for device: %s (%s), " + "the device will be ignored", + device.label, device.device_id, exc_info=True) + devices.remove(device) + + await asyncio.gather(*[retrieve_device_status(d) + for d in devices.copy()]) + + # Setup device broker + broker = DeviceBroker(hass, devices, + installed_app.installed_app_id) + broker.event_handler_disconnect = \ + smart_app.connect_event(broker.event_handler) + hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker + + except ClientResponseError as ex: + if ex.status in (401, 403): + _LOGGER.exception("Unable to setup config entry '%s' - please " + "reconfigure the integration", entry.title) + remove_entry = True + else: + _LOGGER.debug(ex, exc_info=True) + raise ConfigEntryNotReady + except (ClientConnectionError, RuntimeWarning) as ex: + _LOGGER.debug(ex, exc_info=True) + raise ConfigEntryNotReady + + if remove_entry: + hass.async_create_task( + hass.config_entries.async_remove(entry.entry_id)) + # only create new flow if there isn't a pending one for SmartThings. + flows = hass.config_entries.flow.async_progress() + if not [flow for flow in flows if flow['handler'] == DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={'source': 'import'})) + return False + + for component in SUPPORTED_PLATFORMS: + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, component)) + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + broker = hass.data[DOMAIN][DATA_BROKERS].pop(entry.entry_id, None) + if broker and broker.event_handler_disconnect: + broker.event_handler_disconnect() + + tasks = [hass.config_entries.async_forward_entry_unload(entry, component) + for component in SUPPORTED_PLATFORMS] + return all(await asyncio.gather(*tasks)) + + +class DeviceBroker: + """Manages an individual SmartThings config entry.""" + + def __init__(self, hass: HomeAssistantType, devices: Iterable, + installed_app_id: str): + """Create a new instance of the DeviceBroker.""" + self._hass = hass + self._installed_app_id = installed_app_id + self.devices = {device.device_id: device for device in devices} + self.event_handler_disconnect = None + + async def event_handler(self, req, resp, app): + """Broker for incoming events.""" + from pysmartapp.event import EVENT_TYPE_DEVICE + + # Do not process events received from a different installed app + # under the same parent SmartApp (valid use-scenario) + if req.installed_app_id != self._installed_app_id: + return + + updated_devices = set() + for evt in req.events: + if evt.event_type != EVENT_TYPE_DEVICE: + continue + device = self.devices.get(evt.device_id) + if not device: + continue + device.status.apply_attribute_update( + evt.component_id, evt.capability, evt.attribute, evt.value) + + # Fire events for buttons + if evt.capability == 'button' and evt.attribute == 'button': + data = { + 'component_id': evt.component_id, + 'device_id': evt.device_id, + 'location_id': evt.location_id, + 'value': evt.value, + 'name': device.label + } + self._hass.bus.async_fire(EVENT_BUTTON, data) + _LOGGER.debug("Fired button event: %s", data) + + updated_devices.add(device.device_id) + _LOGGER.debug("Update received with %s events and updated %s devices", + len(req.events), len(updated_devices)) + + async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE, + updated_devices) + + +class SmartThingsEntity(Entity): + """Defines a SmartThings entity.""" + + def __init__(self, device): + """Initialize the instance.""" + self._device = device + self._dispatcher_remove = None + + async def async_added_to_hass(self): + """Device added to hass.""" + async def async_update_state(devices): + """Update device state.""" + if self._device.device_id in devices: + await self.async_update_ha_state(True) + + self._dispatcher_remove = async_dispatcher_connect( + self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect the device when removed.""" + if self._dispatcher_remove: + self._dispatcher_remove() + + @property + def device_info(self): + """Get attributes about the device.""" + return { + 'identifiers': { + (DOMAIN, self._device.device_id) + }, + 'name': self._device.label, + 'model': self._device.device_type_name, + 'manufacturer': 'Unavailable' + } + + @property + def name(self) -> str: + """Return the name of the device.""" + return self._device.label + + @property + def should_poll(self) -> bool: + """No polling needed for this device.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._device.device_id diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py new file mode 100644 index 00000000000..045944ccfa9 --- /dev/null +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -0,0 +1,81 @@ +""" +Support for binary sensors through the SmartThings cloud API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/smartthings.binary_sensor/ +""" +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import SmartThingsEntity +from .const import DATA_BROKERS, DOMAIN + +DEPENDENCIES = ['smartthings'] + +CAPABILITY_TO_ATTRIB = { + 'accelerationSensor': 'acceleration', + 'contactSensor': 'contact', + 'filterStatus': 'filterStatus', + 'motionSensor': 'motion', + 'presenceSensor': 'presence', + 'soundSensor': 'sound', + 'tamperAlert': 'tamper', + 'valve': 'valve', + 'waterSensor': 'water' +} +ATTRIB_TO_CLASS = { + 'acceleration': 'moving', + 'contact': 'opening', + 'filterStatus': 'problem', + 'motion': 'motion', + 'presence': 'presence', + 'sound': 'sound', + 'tamper': 'problem', + 'valve': 'opening', + 'water': 'moisture' +} + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add binary sensors for a config entry.""" + broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + sensors = [] + for device in broker.devices.values(): + for capability, attrib in CAPABILITY_TO_ATTRIB.items(): + if capability in device.capabilities: + sensors.append(SmartThingsBinarySensor(device, attrib)) + async_add_entities(sensors) + + +class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorDevice): + """Define a SmartThings Binary Sensor.""" + + def __init__(self, device, attribute): + """Init the class.""" + super().__init__(device) + self._attribute = attribute + + @property + def name(self) -> str: + """Return the name of the binary sensor.""" + return '{} {}'.format(self._device.label, self._attribute) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return '{}.{}'.format(self._device.device_id, self._attribute) + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._device.status.is_on(self._attribute) + + @property + def device_class(self): + """Return the class of this device.""" + return ATTRIB_TO_CLASS[self._attribute] diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py new file mode 100644 index 00000000000..b280036a615 --- /dev/null +++ b/homeassistant/components/smartthings/config_flow.py @@ -0,0 +1,179 @@ +"""Config flow to configure SmartThings.""" +import logging + +from aiohttp.client_exceptions import ClientResponseError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, + VAL_UID_MATCHER) +from .smartapp import ( + create_app, find_app, setup_smartapp, setup_smartapp_endpoint, update_app) + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class SmartThingsFlowHandler(config_entries.ConfigFlow): + """ + Handle configuration of SmartThings integrations. + + Any number of integrations are supported. The high level flow follows: + 1) Flow initiated + a) User initiates through the UI + b) Re-configuration of a failed entry setup + 2) Enter access token + a) Check not already setup + b) Validate format + c) Setup SmartApp + 3) Wait for Installation + a) Check user installed into one or more locations + b) Config entries setup for all installations + """ + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + def __init__(self): + """Create a new instance of the flow handler.""" + self.access_token = None + self.app_id = None + self.api = None + + async def async_step_import(self, user_input=None): + """Occurs when a previously entry setup fails and is re-initiated.""" + return await self.async_step_user(user_input) + + async def async_step_user(self, user_input=None): + """Get access token and validate it.""" + from pysmartthings import SmartThings + + errors = {} + if not self.hass.config.api.base_url.lower().startswith('https://'): + errors['base'] = "base_url_not_https" + return self._show_step_user(errors) + + if user_input is None or CONF_ACCESS_TOKEN not in user_input: + return self._show_step_user(errors) + + self.access_token = user_input.get(CONF_ACCESS_TOKEN, '') + self.api = SmartThings(async_get_clientsession(self.hass), + self.access_token) + + # Ensure token is a UUID + if not VAL_UID_MATCHER.match(self.access_token): + errors[CONF_ACCESS_TOKEN] = "token_invalid_format" + return self._show_step_user(errors) + # Check not already setup in another entry + if any(entry.data.get(CONF_ACCESS_TOKEN) == self.access_token + for entry + in self.hass.config_entries.async_entries(DOMAIN)): + errors[CONF_ACCESS_TOKEN] = "token_already_setup" + return self._show_step_user(errors) + + # Setup end-point + await setup_smartapp_endpoint(self.hass) + + try: + app = await find_app(self.hass, self.api) + if app: + await app.refresh() # load all attributes + await update_app(self.hass, app) + else: + app = await create_app(self.hass, self.api) + setup_smartapp(self.hass, app) + self.app_id = app.app_id + except ClientResponseError as ex: + if ex.status == 401: + errors[CONF_ACCESS_TOKEN] = "token_unauthorized" + elif ex.status == 403: + errors[CONF_ACCESS_TOKEN] = "token_forbidden" + else: + errors['base'] = "app_setup_error" + return self._show_step_user(errors) + except Exception: # pylint:disable=broad-except + errors['base'] = "app_setup_error" + _LOGGER.exception("Unexpected error setting up the SmartApp") + return self._show_step_user(errors) + + return await self.async_step_wait_install() + + async def async_step_wait_install(self, user_input=None): + """Wait for SmartApp installation.""" + from pysmartthings import InstalledAppStatus + + errors = {} + if user_input is None: + return self._show_step_wait_install(errors) + + # Find installed apps that were authorized + installed_apps = [app for app in await self.api.installed_apps( + installed_app_status=InstalledAppStatus.AUTHORIZED) + if app.app_id == self.app_id] + if not installed_apps: + errors['base'] = 'app_not_installed' + return self._show_step_wait_install(errors) + + # User may have installed the SmartApp in more than one SmartThings + # location. Config flows are created for the additional installations + for installed_app in installed_apps[1:]: + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, context={'source': 'install'}, + data={ + CONF_APP_ID: installed_app.app_id, + CONF_INSTALLED_APP_ID: installed_app.installed_app_id, + CONF_LOCATION_ID: installed_app.location_id, + CONF_ACCESS_TOKEN: self.access_token + })) + + # return entity for the first one. + installed_app = installed_apps[0] + return await self.async_step_install({ + CONF_APP_ID: installed_app.app_id, + CONF_INSTALLED_APP_ID: installed_app.installed_app_id, + CONF_LOCATION_ID: installed_app.location_id, + CONF_ACCESS_TOKEN: self.access_token + }) + + def _show_step_user(self, errors): + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_ACCESS_TOKEN, + default=self.access_token): str + }), + errors=errors, + description_placeholders={ + 'token_url': 'https://account.smartthings.com/tokens', + 'component_url': + 'https://www.home-assistant.io/components/smartthings/' + } + ) + + def _show_step_wait_install(self, errors): + return self.async_show_form( + step_id='wait_install', + errors=errors + ) + + async def async_step_install(self, data=None): + """ + Create a config entry at completion of a flow. + + Launched when the user completes the flow or when the SmartApp + is installed into an additional location. + """ + from pysmartthings import SmartThings + + if not self.api: + # Launched from the SmartApp install event handler + self.api = SmartThings( + async_get_clientsession(self.hass), data[CONF_ACCESS_TOKEN]) + + location = await self.api.location(data[CONF_LOCATION_ID]) + return self.async_create_entry(title=location.name, data=data) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py new file mode 100644 index 00000000000..3d0e5cb95f8 --- /dev/null +++ b/homeassistant/components/smartthings/const.py @@ -0,0 +1,46 @@ +"""Constants used by the SmartThings component and platforms.""" +import re + +APP_OAUTH_SCOPES = [ + 'r:devices:*' +] +APP_NAME_PREFIX = 'homeassistant.' +CONF_APP_ID = 'app_id' +CONF_INSTALLED_APP_ID = 'installed_app_id' +CONF_INSTANCE_ID = 'instance_id' +CONF_LOCATION_ID = 'location_id' +DATA_MANAGER = 'manager' +DATA_BROKERS = 'brokers' +DOMAIN = 'smartthings' +EVENT_BUTTON = "smartthings.button" +SIGNAL_SMARTTHINGS_UPDATE = 'smartthings_update' +SIGNAL_SMARTAPP_PREFIX = 'smartthings_smartap_' +SETTINGS_INSTANCE_ID = "hassInstanceId" +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +SUPPORTED_PLATFORMS = [ + 'binary_sensor', + 'fan', + 'light', + 'switch' +] +SUPPORTED_CAPABILITIES = [ + 'accelerationSensor', + 'button', + 'colorControl', + 'colorTemperature', + 'contactSensor', + 'fanSpeed', + 'filterStatus', + 'motionSensor', + 'presenceSensor', + 'soundSensor', + 'switch', + 'switchLevel', + 'tamperAlert', + 'valve', + 'waterSensor' +] +VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]" \ + "{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$" +VAL_UID_MATCHER = re.compile(VAL_UID) diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py new file mode 100644 index 00000000000..7862736e60b --- /dev/null +++ b/homeassistant/components/smartthings/fan.py @@ -0,0 +1,96 @@ +""" +Support for fans through the SmartThings cloud API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/smartthings.fan/ +""" + +from homeassistant.components.fan import ( + SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, + FanEntity) + +from . import SmartThingsEntity +from .const import DATA_BROKERS, DOMAIN + +DEPENDENCIES = ['smartthings'] + +VALUE_TO_SPEED = { + 0: SPEED_OFF, + 1: SPEED_LOW, + 2: SPEED_MEDIUM, + 3: SPEED_HIGH, +} +SPEED_TO_VALUE = { + v: k for k, v in VALUE_TO_SPEED.items()} + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add fans for a config entry.""" + broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + async_add_entities( + [SmartThingsFan(device) for device in broker.devices.values() + if is_fan(device)]) + + +def is_fan(device): + """Determine if the device should be represented as a fan.""" + from pysmartthings import Capability + # Must have switch and fan_speed + return all(capability in device.capabilities + for capability in [Capability.switch, Capability.fan_speed]) + + +class SmartThingsFan(SmartThingsEntity, FanEntity): + """Define a SmartThings Fan.""" + + async def async_set_speed(self, speed: str): + """Set the speed of the fan.""" + value = SPEED_TO_VALUE[speed] + await self._device.set_fan_speed(value, set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_turn_on(self, speed: str = None, **kwargs) -> None: + """Turn the fan on.""" + if speed is not None: + value = SPEED_TO_VALUE[speed] + await self._device.set_fan_speed(value, set_status=True) + else: + await self._device.switch_on(set_status=True) + # State is set optimistically in the commands above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the fan off.""" + await self._device.switch_off(set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + @property + def is_on(self) -> bool: + """Return true if fan is on.""" + return self._device.status.switch + + @property + def speed(self) -> str: + """Return the current speed.""" + return VALUE_TO_SPEED[self._device.status.fan_speed] + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py new file mode 100644 index 00000000000..8495be62a73 --- /dev/null +++ b/homeassistant/components/smartthings/light.py @@ -0,0 +1,215 @@ +""" +Support for lights through the SmartThings cloud API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/smartthings.light/ +""" +import asyncio + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION, + Light) +import homeassistant.util.color as color_util + +from . import SmartThingsEntity +from .const import DATA_BROKERS, DOMAIN + +DEPENDENCIES = ['smartthings'] + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add lights for a config entry.""" + broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + async_add_entities( + [SmartThingsLight(device) for device in broker.devices.values() + if is_light(device)], True) + + +def is_light(device): + """Determine if the device should be represented as a light.""" + from pysmartthings import Capability + + # Must be able to be turned on/off. + if Capability.switch not in device.capabilities: + return False + # Not a fan (which might also have switch_level) + if Capability.fan_speed in device.capabilities: + return False + # Must have one of these + light_capabilities = [ + Capability.color_control, + Capability.color_temperature, + Capability.switch_level + ] + if any(capability in device.capabilities + for capability in light_capabilities): + return True + return False + + +def convert_scale(value, value_scale, target_scale, round_digits=4): + """Convert a value to a different scale.""" + return round(value * target_scale / value_scale, round_digits) + + +class SmartThingsLight(SmartThingsEntity, Light): + """Define a SmartThings Light.""" + + def __init__(self, device): + """Initialize a SmartThingsLight.""" + super().__init__(device) + self._brightness = None + self._color_temp = None + self._hs_color = None + self._supported_features = self._determine_features() + + def _determine_features(self): + """Get features supported by the device.""" + from pysmartthings.device import Capability + + features = 0 + # Brightness and transition + if Capability.switch_level in self._device.capabilities: + features |= \ + SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION + # Color Temperature + if Capability.color_temperature in self._device.capabilities: + features |= SUPPORT_COLOR_TEMP + # Color + if Capability.color_control in self._device.capabilities: + features |= SUPPORT_COLOR + + return features + + async def async_turn_on(self, **kwargs) -> None: + """Turn the light on.""" + tasks = [] + # Color temperature + if self._supported_features & SUPPORT_COLOR_TEMP \ + and ATTR_COLOR_TEMP in kwargs: + tasks.append(self.async_set_color_temp( + kwargs[ATTR_COLOR_TEMP])) + # Color + if self._supported_features & SUPPORT_COLOR \ + and ATTR_HS_COLOR in kwargs: + tasks.append(self.async_set_color( + kwargs[ATTR_HS_COLOR])) + if tasks: + # Set temp/color first + await asyncio.gather(*tasks) + + # Switch/brightness/transition + if self._supported_features & SUPPORT_BRIGHTNESS \ + and ATTR_BRIGHTNESS in kwargs: + await self.async_set_level( + kwargs[ATTR_BRIGHTNESS], + kwargs.get(ATTR_TRANSITION, 0)) + else: + await self._device.switch_on(set_status=True) + + # State is set optimistically in the commands above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state(True) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the light off.""" + # Switch/transition + if self._supported_features & SUPPORT_TRANSITION \ + and ATTR_TRANSITION in kwargs: + await self.async_set_level(0, int(kwargs[ATTR_TRANSITION])) + else: + await self._device.switch_off(set_status=True) + + # State is set optimistically in the commands above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state(True) + + async def async_update(self): + """Update entity attributes when the device status has changed.""" + # Brightness and transition + if self._supported_features & SUPPORT_BRIGHTNESS: + self._brightness = convert_scale( + self._device.status.level, 100, 255) + # Color Temperature + if self._supported_features & SUPPORT_COLOR_TEMP: + self._color_temp = color_util.color_temperature_kelvin_to_mired( + self._device.status.color_temperature) + # Color + if self._supported_features & SUPPORT_COLOR: + self._hs_color = ( + convert_scale(self._device.status.hue, 100, 360), + self._device.status.saturation + ) + + async def async_set_color(self, hs_color): + """Set the color of the device.""" + hue = convert_scale(float(hs_color[0]), 360, 100) + hue = max(min(hue, 100.0), 0.0) + saturation = max(min(float(hs_color[1]), 100.0), 0.0) + await self._device.set_color( + hue, saturation, set_status=True) + + async def async_set_color_temp(self, value: float): + """Set the color temperature of the device.""" + kelvin = color_util.color_temperature_mired_to_kelvin(value) + kelvin = max(min(kelvin, 30000.0), 1.0) + await self._device.set_color_temperature( + kelvin, set_status=True) + + async def async_set_level(self, brightness: int, transition: int): + """Set the brightness of the light over transition.""" + level = int(convert_scale(brightness, 255, 100, 0)) + # Due to rounding, set level to 1 (one) so we don't inadvertently + # turn off the light when a low brightness is set. + level = 1 if level == 0 and brightness > 0 else level + level = max(min(level, 100), 0) + duration = int(transition) + await self._device.set_level(level, duration, set_status=True) + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def color_temp(self): + """Return the CT color value in mireds.""" + return self._color_temp + + @property + def hs_color(self): + """Return the hue and saturation color value [float, float].""" + return self._hs_color + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._device.status.switch + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + # SmartThings does not expose this attribute, instead it's + # implemented within each device-type handler. This value is the + # lowest kelvin found supported across 20+ handlers. + return 500 # 2000K + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + # SmartThings does not expose this attribute, instead it's + # implemented within each device-type handler. This value is the + # highest kelvin found supported across 20+ handlers. + return 111 # 9000K + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py new file mode 100644 index 00000000000..9d9dacf8460 --- /dev/null +++ b/homeassistant/components/smartthings/smartapp.py @@ -0,0 +1,275 @@ +""" +SmartApp functionality to receive cloud-push notifications. + +This module defines the functions to manage the SmartApp integration +within the SmartThings ecosystem in order to receive real-time webhook-based +callbacks when device states change. +""" +import asyncio +import functools +import logging +from uuid import uuid4 + +from aiohttp import web + +from homeassistant.components import webhook +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_WEBHOOK_ID +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + APP_NAME_PREFIX, APP_OAUTH_SCOPES, CONF_APP_ID, CONF_INSTALLED_APP_ID, + CONF_INSTANCE_ID, CONF_LOCATION_ID, DATA_BROKERS, DATA_MANAGER, DOMAIN, + SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX, STORAGE_KEY, STORAGE_VERSION, + SUPPORTED_CAPABILITIES) + +_LOGGER = logging.getLogger(__name__) + + +async def find_app(hass: HomeAssistantType, api): + """Find an existing SmartApp for this installation of hass.""" + apps = await api.apps() + for app in [app for app in apps + if app.app_name.startswith(APP_NAME_PREFIX)]: + # Load settings to compare instance id + settings = await app.settings() + if settings.settings.get(SETTINGS_INSTANCE_ID) == \ + hass.data[DOMAIN][CONF_INSTANCE_ID]: + return app + + +async def validate_installed_app(api, installed_app_id: str): + """ + Ensure the specified installed SmartApp is valid and functioning. + + Query the API for the installed SmartApp and validate that it is tied to + the specified app_id and is in an authorized state. + """ + from pysmartthings import InstalledAppStatus + + installed_app = await api.installed_app(installed_app_id) + if installed_app.installed_app_status != InstalledAppStatus.AUTHORIZED: + raise RuntimeWarning("Installed SmartApp instance '{}' ({}) is not " + "AUTHORIZED but instead {}" + .format(installed_app.display_name, + installed_app.installed_app_id, + installed_app.installed_app_status)) + return installed_app + + +def _get_app_template(hass: HomeAssistantType): + from pysmartthings import APP_TYPE_WEBHOOK, CLASSIFICATION_AUTOMATION + + return { + 'app_name': APP_NAME_PREFIX + str(uuid4()), + 'display_name': 'Home Assistant', + 'description': "Home Assistant at " + hass.config.api.base_url, + 'webhook_target_url': webhook.async_generate_url( + hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]), + 'app_type': APP_TYPE_WEBHOOK, + 'single_instance': True, + 'classifications': [CLASSIFICATION_AUTOMATION] + } + + +async def create_app(hass: HomeAssistantType, api): + """Create a SmartApp for this instance of hass.""" + from pysmartthings import App, AppOAuth, AppSettings + from pysmartapp.const import SETTINGS_APP_ID + + # Create app from template attributes + template = _get_app_template(hass) + app = App() + for key, value in template.items(): + setattr(app, key, value) + app = (await api.create_app(app))[0] + _LOGGER.debug("Created SmartApp '%s' (%s)", app.app_name, app.app_id) + + # Set unique hass id in settings + settings = AppSettings(app.app_id) + settings.settings[SETTINGS_APP_ID] = app.app_id + settings.settings[SETTINGS_INSTANCE_ID] = \ + hass.data[DOMAIN][CONF_INSTANCE_ID] + await api.update_app_settings(settings) + _LOGGER.debug("Updated App Settings for SmartApp '%s' (%s)", + app.app_name, app.app_id) + + # Set oauth scopes + oauth = AppOAuth(app.app_id) + oauth.client_name = 'Home Assistant' + oauth.scope.extend(APP_OAUTH_SCOPES) + await api.update_app_oauth(oauth) + _LOGGER.debug("Updated App OAuth for SmartApp '%s' (%s)", + app.app_name, app.app_id) + return app + + +async def update_app(hass: HomeAssistantType, app): + """Ensure the SmartApp is up-to-date and update if necessary.""" + template = _get_app_template(hass) + template.pop('app_name') # don't update this + update_required = False + for key, value in template.items(): + if getattr(app, key) != value: + update_required = True + setattr(app, key, value) + if update_required: + await app.save() + _LOGGER.debug("SmartApp '%s' (%s) updated with latest settings", + app.app_name, app.app_id) + + +def setup_smartapp(hass, app): + """ + Configure an individual SmartApp in hass. + + Register the SmartApp with the SmartAppManager so that hass will service + lifecycle events (install, event, etc...). A unique SmartApp is created + for each SmartThings account that is configured in hass. + """ + manager = hass.data[DOMAIN][DATA_MANAGER] + smartapp = manager.smartapps.get(app.app_id) + if smartapp: + # already setup + return smartapp + smartapp = manager.register(app.app_id, app.webhook_public_key) + smartapp.name = app.display_name + smartapp.description = app.description + smartapp.permissions.extend(APP_OAUTH_SCOPES) + return smartapp + + +async def setup_smartapp_endpoint(hass: HomeAssistantType): + """ + Configure the SmartApp webhook in hass. + + SmartApps are an extension point within the SmartThings ecosystem and + is used to receive push updates (i.e. device updates) from the cloud. + """ + from pysmartapp import Dispatcher, SmartAppManager + + data = hass.data.get(DOMAIN) + if data: + # already setup + return + + # Get/create config to store a unique id for this hass instance. + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + config = await store.async_load() + if not config: + # Create config + config = { + CONF_INSTANCE_ID: str(uuid4()), + CONF_WEBHOOK_ID: webhook.generate_secret() + } + await store.async_save(config) + + # SmartAppManager uses a dispatcher to invoke callbacks when push events + # occur. Use hass' implementation instead of the built-in one. + dispatcher = Dispatcher( + signal_prefix=SIGNAL_SMARTAPP_PREFIX, + connect=functools.partial(async_dispatcher_connect, hass), + send=functools.partial(async_dispatcher_send, hass)) + manager = SmartAppManager( + webhook.async_generate_path(config[CONF_WEBHOOK_ID]), + dispatcher=dispatcher) + manager.connect_install(functools.partial(smartapp_install, hass)) + manager.connect_uninstall(functools.partial(smartapp_uninstall, hass)) + + webhook.async_register(hass, DOMAIN, 'SmartApp', + config[CONF_WEBHOOK_ID], smartapp_webhook) + + hass.data[DOMAIN] = { + DATA_MANAGER: manager, + CONF_INSTANCE_ID: config[CONF_INSTANCE_ID], + DATA_BROKERS: {}, + CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID] + } + + +async def smartapp_install(hass: HomeAssistantType, req, resp, app): + """ + Handle when a SmartApp is installed by the user into a location. + + Setup subscriptions using the access token SmartThings provided in the + event. An explicit subscription is required for each 'capability' in order + to receive the related attribute updates. Finally, create a config entry + representing the installation if this is not the first installation under + the account. + """ + from pysmartthings import SmartThings, Subscription, SourceType + + # This access token is a temporary 'SmartApp token' that expires in 5 min + # and is used to create subscriptions only. + api = SmartThings(async_get_clientsession(hass), req.auth_token) + + async def create_subscription(target): + sub = Subscription() + sub.installed_app_id = req.installed_app_id + sub.location_id = req.location_id + sub.source_type = SourceType.CAPABILITY + sub.capability = target + try: + await api.create_subscription(sub) + _LOGGER.debug("Created subscription for '%s' under app '%s'", + target, req.installed_app_id) + except Exception: # pylint:disable=broad-except + _LOGGER.exception("Failed to create subscription for '%s' under " + "app '%s'", target, req.installed_app_id) + + tasks = [create_subscription(c) for c in SUPPORTED_CAPABILITIES] + await asyncio.gather(*tasks) + _LOGGER.debug("SmartApp '%s' under parent app '%s' was installed", + req.installed_app_id, app.app_id) + + # The permanent access token is copied from another config flow with the + # same parent app_id. If one is not found, that means the user is within + # the initial config flow and the entry at the conclusion. + access_token = next(( + entry.data.get(CONF_ACCESS_TOKEN) for entry + in hass.config_entries.async_entries(DOMAIN) + if entry.data[CONF_APP_ID] == app.app_id), None) + if access_token: + # Add as job not needed because the current coroutine was invoked + # from the dispatcher and is not being awaited. + await hass.config_entries.flow.async_init( + DOMAIN, context={'source': 'install'}, + data={ + CONF_APP_ID: app.app_id, + CONF_INSTALLED_APP_ID: req.installed_app_id, + CONF_LOCATION_ID: req.location_id, + CONF_ACCESS_TOKEN: access_token + }) + + +async def smartapp_uninstall(hass: HomeAssistantType, req, resp, app): + """ + Handle when a SmartApp is removed from a location by the user. + + Find and delete the config entry representing the integration. + """ + entry = next((entry for entry in hass.config_entries.async_entries(DOMAIN) + if entry.data.get(CONF_INSTALLED_APP_ID) == + req.installed_app_id), + None) + if entry: + _LOGGER.debug("SmartApp '%s' under parent app '%s' was removed", + req.installed_app_id, app.app_id) + # Add as job not needed because the current coroutine was invoked + # from the dispatcher and is not being awaited. + await hass.config_entries.async_remove(entry.entry_id) + + +async def smartapp_webhook(hass: HomeAssistantType, webhook_id: str, request): + """ + Handle a smartapp lifecycle event callback from SmartThings. + + Requests from SmartThings are digitally signed and the SmartAppManager + validates the signature for authenticity. + """ + manager = hass.data[DOMAIN][DATA_MANAGER] + data = await request.json() + result = await manager.handle_request(data, request.headers) + return web.json_response(result) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json new file mode 100644 index 00000000000..1fb4e878cb4 --- /dev/null +++ b/homeassistant/components/smartthings/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "title": "SmartThings", + "step": { + "user": { + "title": "Enter Personal Access Token", + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}).", + "data": { + "access_token": "Access Token" + } + }, + "wait_install": { + "title": "Install SmartApp", + "description": "Please install the Home Assistant SmartApp in at least one location and click submit." + } + }, + "error": { + "token_invalid_format": "The token must be in the UID/GUID format", + "token_unauthorized": "The token is invalid or no longer authorized.", + "token_forbidden": "The token does not have the required OAuth scopes.", + "token_already_setup": "The token has already been setup.", + "app_setup_error": "Unable to setup the SmartApp. Please try again.", + "app_not_installed": "Please ensure you have installed and authorized the Home Assistant SmartApp and try again.", + "base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py new file mode 100644 index 00000000000..1fccfcd3619 --- /dev/null +++ b/homeassistant/components/smartthings/switch.py @@ -0,0 +1,70 @@ +""" +Support for switches through the SmartThings cloud API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/smartthings.switch/ +""" +from homeassistant.components.switch import SwitchDevice + +from . import SmartThingsEntity +from .const import DATA_BROKERS, DOMAIN + +DEPENDENCIES = ['smartthings'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add switches for a config entry.""" + broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + async_add_entities( + [SmartThingsSwitch(device) for device in broker.devices.values() + if is_switch(device)]) + + +def is_switch(device): + """Determine if the device should be represented as a switch.""" + from pysmartthings import Capability + + # Must be able to be turned on/off. + if Capability.switch not in device.capabilities: + return False + # Must not have a capability represented by other types. + non_switch_capabilities = [ + Capability.color_control, + Capability.color_temperature, + Capability.fan_speed, + Capability.switch_level + ] + if any(capability in device.capabilities + for capability in non_switch_capabilities): + return False + + return True + + +class SmartThingsSwitch(SmartThingsEntity, SwitchDevice): + """Define a SmartThings switch.""" + + async def async_turn_off(self, **kwargs) -> None: + """Turn the switch off.""" + await self._device.switch_off(set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_turn_on(self, **kwargs) -> None: + """Turn the switch on.""" + await self._device.switch_on(set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._device.status.switch diff --git a/homeassistant/components/switch/ecoal_boiler.py b/homeassistant/components/switch/ecoal_boiler.py new file mode 100644 index 00000000000..d8d6c98bb8b --- /dev/null +++ b/homeassistant/components/switch/ecoal_boiler.py @@ -0,0 +1,85 @@ +""" +Allows to configuration ecoal (esterownik.pl) pumps as switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.ecoal_boiler/ +""" +import logging +from typing import Optional + +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.ecoal_boiler import ( + DATA_ECOAL_BOILER, AVAILABLE_PUMPS, ) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['ecoal_boiler'] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up switches based on ecoal interface.""" + if discovery_info is None: + return + ecoal_contr = hass.data[DATA_ECOAL_BOILER] + switches = [] + for pump_id in discovery_info: + name = AVAILABLE_PUMPS[pump_id] + switches.append(EcoalSwitch(ecoal_contr, name, pump_id)) + add_entities(switches, True) + + +class EcoalSwitch(SwitchDevice): + """Representation of Ecoal switch.""" + + def __init__(self, ecoal_contr, name, state_attr): + """ + Initialize switch. + + Sets HA switch to state as read from controller. + """ + self._ecoal_contr = ecoal_contr + self._name = name + self._state_attr = state_attr + # Ecoalcotroller holds convention that same postfix is used + # to set attribute + # set_() + # as attribute name in status instance: + # status. + self._contr_set_fun = getattr(self._ecoal_contr, "set_" + state_attr) + # No value set, will be read from controller instead + self._state = None + + @property + def name(self) -> Optional[str]: + """Return the name of the switch.""" + return self._name + + def update(self): + """Fetch new state data for the sensor. + + This is the only method that should fetch new data for Home Assistant. + """ + status = self._ecoal_contr.get_cached_status() + self._state = getattr(status, self._state_attr) + + def invalidate_ecoal_cache(self): + """Invalidate ecoal interface cache. + + Forces that next read from ecaol interface to not use cache. + """ + self._ecoal_contr.status = None + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs) -> None: + """Turn the device on.""" + self._contr_set_fun(1) + self.invalidate_ecoal_cache() + + def turn_off(self, **kwargs) -> None: + """Turn the device off.""" + self._contr_set_fun(0) + self.invalidate_ecoal_cache() diff --git a/homeassistant/components/switch/lcn.py b/homeassistant/components/switch/lcn.py new file mode 100755 index 00000000000..468afe178b5 --- /dev/null +++ b/homeassistant/components/switch/lcn.py @@ -0,0 +1,135 @@ +""" +Support for LCN switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.lcn/ +""" + +from homeassistant.components.lcn import ( + CONF_CONNECTIONS, CONF_OUTPUT, DATA_LCN, OUTPUT_PORTS, LcnDevice, + get_connection) +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import CONF_ADDRESS + +DEPENDENCIES = ['lcn'] + + +async def async_setup_platform(hass, hass_config, async_add_entities, + discovery_info=None): + """Set up the LCN switch platform.""" + if discovery_info is None: + return + + import pypck + + devices = [] + for config in discovery_info: + address, connection_id = config[CONF_ADDRESS] + addr = pypck.lcn_addr.LcnAddr(*address) + connections = hass.data[DATA_LCN][CONF_CONNECTIONS] + connection = get_connection(connections, connection_id) + address_connection = connection.get_address_conn(addr) + + if config[CONF_OUTPUT] in OUTPUT_PORTS: + device = LcnOutputSwitch(config, address_connection) + else: # in RELAY_PORTS + device = LcnRelaySwitch(config, address_connection) + + devices.append(device) + + async_add_entities(devices) + + +class LcnOutputSwitch(LcnDevice, SwitchDevice): + """Representation of a LCN switch for output ports.""" + + def __init__(self, config, address_connection): + """Initialize the LCN switch.""" + super().__init__(config, address_connection) + + self.output = self.pypck.lcn_defs.OutputPort[config[CONF_OUTPUT]] + + self._is_on = None + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self.hass.async_create_task( + self.address_connection.activate_status_request_handler( + self.output)) + + @property + def is_on(self): + """Return True if entity is on.""" + return self._is_on + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + self._is_on = True + self.address_connection.dim_output(self.output.value, 100, 0) + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + self._is_on = False + self.address_connection.dim_output(self.output.value, 0, 0) + await self.async_update_ha_state() + + def input_received(self, input_obj): + """Set switch state when LCN input object (command) is received.""" + if not isinstance(input_obj, self.pypck.inputs.ModStatusOutput) or \ + input_obj.get_output_id() != self.output.value: + return + + self._is_on = input_obj.get_percent() > 0 + self.async_schedule_update_ha_state() + + +class LcnRelaySwitch(LcnDevice, SwitchDevice): + """Representation of a LCN switch for relay ports.""" + + def __init__(self, config, address_connection): + """Initialize the LCN switch.""" + super().__init__(config, address_connection) + + self.output = self.pypck.lcn_defs.RelayPort[config[CONF_OUTPUT]] + + self._is_on = None + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self.hass.async_create_task( + self.address_connection.activate_status_request_handler( + self.output)) + + @property + def is_on(self): + """Return True if entity is on.""" + return self._is_on + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + self._is_on = True + + states = [self.pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 + states[self.output.value] = self.pypck.lcn_defs.RelayStateModifier.ON + self.address_connection.control_relays(states) + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + self._is_on = False + + states = [self.pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 + states[self.output.value] = self.pypck.lcn_defs.RelayStateModifier.OFF + self.address_connection.control_relays(states) + await self.async_update_ha_state() + + def input_received(self, input_obj): + """Set switch state when LCN input object (command) is received.""" + if not isinstance(input_obj, self.pypck.inputs.ModStatusRelays): + return + + self._is_on = input_obj.get_state(self.output.value) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/switch/rest.py b/homeassistant/components/switch/rest.py index 9b8f889a8ae..5f1920ae1af 100644 --- a/homeassistant/components/switch/rest.py +++ b/homeassistant/components/switch/rest.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HEADERS, CONF_NAME, CONF_RESOURCE, CONF_TIMEOUT, CONF_METHOD, - CONF_USERNAME, CONF_PASSWORD) + CONF_USERNAME, CONF_PASSWORD, CONF_VERIFY_SSL) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -29,6 +29,7 @@ DEFAULT_BODY_OFF = 'OFF' DEFAULT_BODY_ON = 'ON' DEFAULT_NAME = 'REST Switch' DEFAULT_TIMEOUT = 10 +DEFAULT_VERIFY_SSL = True SUPPORT_REST_METHODS = ['post', 'put'] @@ -44,6 +45,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, }) @@ -59,6 +61,7 @@ async def async_setup_platform(hass, config, async_add_entities, username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) resource = config.get(CONF_RESOURCE) + verify_ssl = config.get(CONF_VERIFY_SSL) auth = None if username: @@ -74,7 +77,7 @@ async def async_setup_platform(hass, config, async_add_entities, try: switch = RestSwitch(name, resource, method, headers, auth, body_on, - body_off, is_on_template, timeout) + body_off, is_on_template, timeout, verify_ssl) req = await switch.get_device_state(hass) if req.status >= 400: @@ -92,7 +95,7 @@ class RestSwitch(SwitchDevice): """Representation of a switch that can be toggled using REST.""" def __init__(self, name, resource, method, headers, auth, body_on, - body_off, is_on_template, timeout): + body_off, is_on_template, timeout, verify_ssl): """Initialize the REST switch.""" self._state = None self._name = name @@ -104,6 +107,7 @@ class RestSwitch(SwitchDevice): self._body_off = body_off self._is_on_template = is_on_template self._timeout = timeout + self._verify_ssl = verify_ssl @property def name(self): @@ -148,7 +152,7 @@ class RestSwitch(SwitchDevice): async def set_device_state(self, body): """Send a state update to the device.""" - websession = async_get_clientsession(self.hass) + websession = async_get_clientsession(self.hass, self._verify_ssl) with async_timeout.timeout(self._timeout, loop=self.hass.loop): req = await getattr(websession, self._method)( @@ -167,7 +171,7 @@ class RestSwitch(SwitchDevice): async def get_device_state(self, hass): """Get the latest data from REST API and update the state.""" - websession = async_get_clientsession(hass) + websession = async_get_clientsession(hass, self._verify_ssl) with async_timeout.timeout(self._timeout, loop=hass.loop): req = await websession.get(self._resource, auth=self._auth, diff --git a/homeassistant/components/switch/transmission.py b/homeassistant/components/switch/transmission.py index 10ab0903dcf..3ce3c7a98f9 100644 --- a/homeassistant/components/switch/transmission.py +++ b/homeassistant/components/switch/transmission.py @@ -6,54 +6,30 @@ https://home-assistant.io/components/switch.transmission/ """ import logging -import voluptuous as vol - -from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.components.transmission import ( + DATA_TRANSMISSION, SCAN_INTERVAL) from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, STATE_OFF, - STATE_ON) + STATE_OFF, STATE_ON) from homeassistant.helpers.entity import ToggleEntity -import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle -REQUIREMENTS = ['transmissionrpc==0.11'] +DEPENDENCIES = ['transmission'] _LOGGING = logging.getLogger(__name__) DEFAULT_NAME = 'Transmission Turtle Mode' -DEFAULT_PORT = 9091 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_USERNAME): cv.string, -}) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Transmission switch.""" - import transmissionrpc - from transmissionrpc.error import TransmissionError + if discovery_info is None: + return - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - port = config.get(CONF_PORT) + component_name = DATA_TRANSMISSION + transmission_api = hass.data[component_name] + name = discovery_info['client_name'] - try: - transmission_api = transmissionrpc.Client( - host, port=port, user=username, password=password) - transmission_api.session_stats() - except TransmissionError as error: - _LOGGING.error( - "Connection to Transmission API failed on %s:%s with message %s", - host, port, error.original - ) - return False - - add_entities([TransmissionSwitch(transmission_api, name)]) + add_entities([TransmissionSwitch(transmission_api, name)], True) class TransmissionSwitch(ToggleEntity): @@ -88,14 +64,15 @@ class TransmissionSwitch(ToggleEntity): def turn_on(self, **kwargs): """Turn the device on.""" _LOGGING.debug("Turning Turtle Mode of Transmission on") - self.transmission_client.set_session(alt_speed_enabled=True) + self.transmission_client.set_alt_speed_enabled(True) def turn_off(self, **kwargs): """Turn the device off.""" _LOGGING.debug("Turning Turtle Mode of Transmission off") - self.transmission_client.set_session(alt_speed_enabled=False) + self.transmission_client.set_alt_speed_enabled(False) + @Throttle(SCAN_INTERVAL) def update(self): """Get the latest data from Transmission and updates the state.""" - active = self.transmission_client.get_session().alt_speed_enabled + active = self.transmission_client.get_alt_speed_enabled() self._state = STATE_ON if active else STATE_OFF diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py new file mode 100644 index 00000000000..fca433550d7 --- /dev/null +++ b/homeassistant/components/system_health/__init__.py @@ -0,0 +1,73 @@ +"""System health component.""" +import asyncio +from collections import OrderedDict +import logging +from typing import Callable, Dict + +import async_timeout +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.loader import bind_hass +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.components import websocket_api + +DEPENDENCIES = ['http'] +DOMAIN = 'system_health' +INFO_CALLBACK_TIMEOUT = 5 +_LOGGER = logging.getLogger(__name__) + + +@bind_hass +@callback +def async_register_info(hass: HomeAssistantType, domain: str, + info_callback: Callable[[HomeAssistantType], Dict]): + """Register an info callback.""" + data = hass.data.setdefault( + DOMAIN, OrderedDict()).setdefault('info', OrderedDict()) + data[domain] = info_callback + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Set up the System Health component.""" + hass.components.websocket_api.async_register_command(handle_info) + return True + + +async def _info_wrapper(hass, info_callback): + """Wrap info callback.""" + try: + with async_timeout.timeout(INFO_CALLBACK_TIMEOUT): + return await info_callback(hass) + except asyncio.TimeoutError: + return { + 'error': 'Fetching info timed out' + } + except Exception as err: # pylint: disable=W0703 + _LOGGER.exception("Error fetching info") + return { + 'error': str(err) + } + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'system_health/info' +}) +async def handle_info(hass: HomeAssistantType, + connection: websocket_api.ActiveConnection, + msg: Dict): + """Handle an info request.""" + info_callbacks = hass.data.get(DOMAIN, {}).get('info', {}) + data = OrderedDict() + data['homeassistant'] = \ + await hass.helpers.system_info.async_get_system_info() + + if info_callbacks: + for domain, domain_data in zip(info_callbacks, await asyncio.gather(*[ + _info_wrapper(hass, info_callback) for info_callback + in info_callbacks.values() + ])): + data[domain] = domain_data + + connection.send_message(websocket_api.result_message(msg['id'], data)) diff --git a/homeassistant/components/tellduslive/.translations/ca.json b/homeassistant/components/tellduslive/.translations/ca.json index db97b1ad6d8..75915735882 100644 --- a/homeassistant/components/tellduslive/.translations/ca.json +++ b/homeassistant/components/tellduslive/.translations/ca.json @@ -2,10 +2,14 @@ "config": { "abort": { "all_configured": "TelldusLive ja est\u00e0 configurat", + "already_setup": "TelldusLive ja est\u00e0 configurat", "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.", "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.", "unknown": "S'ha produ\u00eft un error desconegut" }, + "error": { + "auth_error": "Error d'autenticaci\u00f3, torna-ho a provar" + }, "step": { "auth": { "description": "Passos per enlla\u00e7ar el teu compte de TelldusLive:\n 1. Clica l'enlla\u00e7 de sota.\n 2. Inicia sessi\u00f3 a Telldus Live.\n 3. Autoritza **{app_name}** (clica **Yes**).\n 4. Torna aqu\u00ed i clica **SUBMIT**.\n \n [Enlla\u00e7 al compte de TelldusLive]({auth_url})", diff --git a/homeassistant/components/tellduslive/.translations/en.json b/homeassistant/components/tellduslive/.translations/en.json index 4ed9ef597f4..c2b00561858 100644 --- a/homeassistant/components/tellduslive/.translations/en.json +++ b/homeassistant/components/tellduslive/.translations/en.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "all_configured": "TelldusLive is already configured", "already_setup": "TelldusLive is already configured", "authorize_url_fail": "Unknown error generating an authorize url.", "authorize_url_timeout": "Timeout generating authorize url.", diff --git a/homeassistant/components/tellduslive/.translations/es.json b/homeassistant/components/tellduslive/.translations/es.json new file mode 100644 index 00000000000..4e7de72edc4 --- /dev/null +++ b/homeassistant/components/tellduslive/.translations/es.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_setup": "TelldusLive ya est\u00e1 configurado" + }, + "error": { + "auth_error": "Error de autenticaci\u00f3n, por favor int\u00e9ntalo de nuevo" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/ko.json b/homeassistant/components/tellduslive/.translations/ko.json index a7b68bbf8be..29f64a87cb3 100644 --- a/homeassistant/components/tellduslive/.translations/ko.json +++ b/homeassistant/components/tellduslive/.translations/ko.json @@ -2,10 +2,14 @@ "config": { "abort": { "all_configured": "TelldusLive \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_setup": "TelldusLive \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, + "error": { + "auth_error": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574 \uc8fc\uc138\uc694." + }, "step": { "auth": { "description": "TelldusLive \uacc4\uc815\uc744 \uc5f0\uacb0\ud558\ub824\uba74:\n 1. \ud558\ub2e8\uc758 \ub9c1\ud06c\ub97c \ud074\ub9ad\ud574\uc8fc\uc138\uc694\n 2. Telldus Live \uc5d0 \ub85c\uadf8\uc778 \ud558\uc138\uc694\n 3. Authorize **{app_name}** (**Yes** \ub97c \ud074\ub9ad\ud558\uc138\uc694).\n 4. \ub2e4\uc2dc \uc5ec\uae30\ub85c \ub3cc\uc544\uc640\uc11c **SUBMIT** \uc744 \ud074\ub9ad\ud558\uc138\uc694.\n\n [TelldusLive \uacc4\uc815 \uc5f0\uacb0\ud558\uae30]({auth_url})", diff --git a/homeassistant/components/tellduslive/.translations/lb.json b/homeassistant/components/tellduslive/.translations/lb.json index 85de49776c1..5eb4d1b978a 100644 --- a/homeassistant/components/tellduslive/.translations/lb.json +++ b/homeassistant/components/tellduslive/.translations/lb.json @@ -2,10 +2,14 @@ "config": { "abort": { "all_configured": "TelldusLive ass scho konfigur\u00e9iert", + "already_setup": "TelldusLive ass scho konfigur\u00e9iert", "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.", "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.", "unknown": "Onbekannten Fehler opgetrueden" }, + "error": { + "auth_error": "Feeler bei der Authentifikatioun, prob\u00e9iert w.e.g. nach emol" + }, "step": { "auth": { "description": "Fir den TelldusLive Kont ze verbannen:\n1. Klickt op de Link \u00ebnnen\n2. Verbannt iech mat TelldusLive\n3. Autoris\u00e9iert **{app_name}** (klickt **Yes**)\n4. Kommt heihinner zer\u00e9ck a klickt **Ofsch\u00e9cken**\n\n[Tellduslive Kont verbannen]({auth_url})", diff --git a/homeassistant/components/tellduslive/.translations/no.json b/homeassistant/components/tellduslive/.translations/no.json index 5c3d343dd03..2c6439b364f 100644 --- a/homeassistant/components/tellduslive/.translations/no.json +++ b/homeassistant/components/tellduslive/.translations/no.json @@ -2,10 +2,14 @@ "config": { "abort": { "all_configured": "TelldusLive er allerede konfigurert", + "already_setup": "TelldusLive er allerede konfigurert", "authorize_url_fail": "Ukjent feil ved generering av autoriseringsadresse.", "authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.", "unknown": "Ukjent feil oppstod" }, + "error": { + "auth_error": "Autentiseringsfeil, vennligst pr\u00f8v igjen" + }, "step": { "auth": { "description": "For \u00e5 koble TelldusLive-kontoen din:\n 1. Klikk p\u00e5 linken under\n 2. Logg inn p\u00e5 Telldus Live \n 3. Tillat **{app_name}** (klikk**Ja**). \n 4. Kom tilbake hit og klikk **SUBMIT**. \n\n [Link TelldusLive-konto]({auth_url})", diff --git a/homeassistant/components/tellduslive/.translations/pl.json b/homeassistant/components/tellduslive/.translations/pl.json index 5ee9ac221a7..9d791e0e786 100644 --- a/homeassistant/components/tellduslive/.translations/pl.json +++ b/homeassistant/components/tellduslive/.translations/pl.json @@ -2,10 +2,14 @@ "config": { "abort": { "all_configured": "TelldusLive jest ju\u017c skonfigurowany", + "already_setup": "TelldusLive jest ju\u017c skonfigurowany", "authorize_url_fail": "Nieznany b\u0142\u0105d podczas generowania url autoryzacji.", "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.", "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d" }, + "error": { + "auth_error": "B\u0142\u0105d uwierzytelniania, spr\u00f3buj ponownie" + }, "step": { "auth": { "description": "Aby po\u0142\u0105czy\u0107 konto TelldusLive: \n 1. Kliknij poni\u017cszy link \n 2. Zaloguj si\u0119 do Telldus Live \n 3. Autoryzuj **{app_name}** (kliknij **Tak**). \n 4. Wr\u00f3\u0107 tutaj i kliknij **SUBMIT**. \n\n [Link do konta TelldusLive]({auth_url})", diff --git a/homeassistant/components/tellduslive/.translations/ru.json b/homeassistant/components/tellduslive/.translations/ru.json index 2e319b9400b..80dff6dc88a 100644 --- a/homeassistant/components/tellduslive/.translations/ru.json +++ b/homeassistant/components/tellduslive/.translations/ru.json @@ -2,10 +2,14 @@ "config": { "abort": { "all_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", "authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" }, + "error": { + "auth_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443" + }, "step": { "auth": { "description": "\u0414\u043b\u044f \u0442\u043e\u0433\u043e, \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0430\u043a\u043a\u0430\u0443\u043d\u0442 TelldusLive:\n 1. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435, \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u0439 \u043d\u0438\u0436\u0435\n 2. \u0412\u043e\u0439\u0434\u0438\u0442\u0435 \u0432 Telldus Live\n 3. Authorize **{app_name}** (\u043d\u0430\u0436\u043c\u0438\u0442\u0435 **Yes**).\n 4. \u0412\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c**.\n\n [\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 TelldusLive]({auth_url})", diff --git a/homeassistant/components/tellduslive/.translations/sl.json b/homeassistant/components/tellduslive/.translations/sl.json index f4b9f0fda98..16e6ddcb5f4 100644 --- a/homeassistant/components/tellduslive/.translations/sl.json +++ b/homeassistant/components/tellduslive/.translations/sl.json @@ -2,10 +2,14 @@ "config": { "abort": { "all_configured": "TelldusLive je \u017ee konfiguriran", + "already_setup": "TelldusLive je \u017ee konfiguriran", "authorize_url_fail": "Neznana napaka pri generiranju potrditvenega URL-ja.", "authorize_url_timeout": "\u010casovna omejitev za generiranje URL-ja je potekla.", "unknown": "Pri\u0161lo je do neznane napake" }, + "error": { + "auth_error": "Napaka pri preverjanju pristnosti, poskusite znova" + }, "step": { "auth": { "description": "\u010ce \u017eelite povezati svoj ra\u010dun TelldusLive: \n 1. Kliknite spodnjo povezavo \n 2. Prijavite se v Telldus Live \n 3. Dovolite ** {app_name} ** (kliknite ** Da **). \n 4. Pridi nazaj in kliknite ** SUBMIT **. \n\n [Link TelldusLive ra\u010dun] ( {auth_url} )", diff --git a/homeassistant/components/tellduslive/.translations/zh-Hans.json b/homeassistant/components/tellduslive/.translations/zh-Hans.json new file mode 100644 index 00000000000..f707b1f15f8 --- /dev/null +++ b/homeassistant/components/tellduslive/.translations/zh-Hans.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "all_configured": "Tellduslive \u5df2\u914d\u7f6e\u5b8c\u6210", + "authorize_url_fail": "\u751f\u6210\u6388\u6743URL\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002", + "authorize_url_timeout": "\u751f\u6210\u6388\u6743URL\u8d85\u65f6", + "unknown": "\u53d1\u751f\u672a\u77e5\u7684\u9519\u8bef" + }, + "error": { + "auth_error": "\u53cc\u91cd\u8ba4\u8bc1\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002" + }, + "step": { + "auth": { + "description": "\u8981\u94fe\u63a5\u60a8\u7684TelldusLive\u8d26\u6237\uff1a \n 1.\u5355\u51fb\u4e0b\u9762\u7684\u94fe\u63a5\n 2.\u767b\u5f55Telldus Live \n 3.\u6388\u6743 **{app_name}** (\u70b9\u51fb **\u662f**)\u3002 \n 4.\u56de\u5230\u8fd9\u91cc\uff0c\u7136\u540e\u70b9\u51fb**\u63d0\u4ea4**\u3002 \n\n [TelldusLive\u8d26\u6237\u94fe\u63a5]({auth_url})" + }, + "user": { + "data": { + "host": "\u4e3b\u673a" + }, + "description": "\u7a7a\u767d" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/zh-Hant.json b/homeassistant/components/tellduslive/.translations/zh-Hant.json index a5e3c652c0c..c632b543634 100644 --- a/homeassistant/components/tellduslive/.translations/zh-Hant.json +++ b/homeassistant/components/tellduslive/.translations/zh-Hant.json @@ -2,10 +2,14 @@ "config": { "abort": { "all_configured": "TelldusLive \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_setup": "TelldusLive \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "authorize_url_fail": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4", "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642", "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" }, + "error": { + "auth_error": "\u8a8d\u8b49\u932f\u8aa4\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" + }, "step": { "auth": { "description": "\u6b32\u9023\u7d50 TelldusLive \u5e33\u865f\uff1a\n 1. \u9ede\u9078\u4e0b\u65b9\u9023\u7d50\n 2. \u767b\u5165\u81f3 Telldus Live\n 3. \u5c0d **{app_name}** \u9032\u884c\u6388\u6b0a\uff08\u9ede\u9078 **Yes**\uff09\u3002\n 4. \u56de\u5230\u672c\u9801\u9762\u4e26\u9ede\u9078 **\u50b3\u9001**\u3002\n\n [Link TelldusLive account]({auth_url})", diff --git a/homeassistant/components/tellduslive/__init__.py b/homeassistant/components/tellduslive/__init__.py index 472ba3d36f3..2a57a78ee9e 100644 --- a/homeassistant/components/tellduslive/__init__.py +++ b/homeassistant/components/tellduslive/__init__.py @@ -5,26 +5,26 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/tellduslive/ """ import asyncio -from datetime import timedelta from functools import partial import logging import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_UPDATE_INTERVAL import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import async_call_later from . import config_flow # noqa pylint_disable=unused-import from .const import ( - CONF_HOST, CONF_UPDATE_INTERVAL, DOMAIN, KEY_HOST, KEY_SCAN_INTERVAL, - KEY_SESSION, MIN_UPDATE_INTERVAL, NOT_SO_PRIVATE_KEY, PUBLIC_KEY, - SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, TELLDUS_DISCOVERY_NEW) + CONF_HOST, DOMAIN, KEY_HOST, KEY_SCAN_INTERVAL, KEY_SESSION, + MIN_UPDATE_INTERVAL, NOT_SO_PRIVATE_KEY, PUBLIC_KEY, SCAN_INTERVAL, + SIGNAL_UPDATE_ENTITY, TELLDUS_DISCOVERY_NEW) APPLICATION_NAME = 'Home Assistant' -REQUIREMENTS = ['tellduslive==0.10.8'] +REQUIREMENTS = ['tellduslive==0.10.10'] _LOGGER = logging.getLogger(__name__) @@ -44,6 +44,7 @@ CONFIG_SCHEMA = vol.Schema( DATA_CONFIG_ENTRY_LOCK = 'tellduslive_config_entry_lock' CONFIG_ENTRY_IS_SETUP = 'telldus_config_entry_is_setup' +NEW_CLIENT_TASK = 'telldus_new_client_task' INTERVAL_TRACKER = '{}_INTERVAL'.format(DOMAIN) @@ -70,33 +71,30 @@ async def async_setup_entry(hass, entry): hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() hass.data[CONFIG_ENTRY_IS_SETUP] = set() - - client = TelldusLiveClient(hass, entry, session) - hass.data[DOMAIN] = client - await async_add_hubs(hass, client, entry.entry_id) - hass.async_create_task(client.update()) - - interval = timedelta(seconds=entry.data[KEY_SCAN_INTERVAL]) - _LOGGER.debug('Update interval %s', interval) - hass.data[INTERVAL_TRACKER] = async_track_time_interval( - hass, client.update, interval) + hass.data[NEW_CLIENT_TASK] = hass.loop.create_task( + async_new_client(hass, session, entry)) return True -async def async_add_hubs(hass, client, entry_id): +async def async_new_client(hass, session, entry): """Add the hubs associated with the current client to device_registry.""" + interval = entry.data[KEY_SCAN_INTERVAL] + _LOGGER.debug('Update interval %s seconds.', interval) + client = TelldusLiveClient(hass, entry, session, interval) + hass.data[DOMAIN] = client dev_reg = await hass.helpers.device_registry.async_get_registry() for hub in await client.async_get_hubs(): _LOGGER.debug("Connected hub %s", hub['name']) dev_reg.async_get_or_create( - config_entry_id=entry_id, + config_entry_id=entry.entry_id, identifiers={(DOMAIN, hub['id'])}, manufacturer='Telldus', name=hub['name'], model=hub['type'], sw_version=hub['version'], ) + await client.update() async def async_setup(hass, config): @@ -117,6 +115,8 @@ async def async_setup(hass, config): async def async_unload_entry(hass, config_entry): """Unload a config entry.""" + if not hass.data[NEW_CLIENT_TASK].done(): + hass.data[NEW_CLIENT_TASK].cancel() interval_tracker = hass.data.pop(INTERVAL_TRACKER) interval_tracker() await asyncio.wait([ @@ -131,7 +131,7 @@ async def async_unload_entry(hass, config_entry): class TelldusLiveClient: """Get the latest data and update the states.""" - def __init__(self, hass, config_entry, session): + def __init__(self, hass, config_entry, session, interval): """Initialize the Tellus data object.""" self._known_devices = set() self._device_infos = {} @@ -139,6 +139,7 @@ class TelldusLiveClient: self._hass = hass self._config_entry = config_entry self._client = session + self._interval = interval async def async_get_hubs(self): """Return hubs registered for the user.""" @@ -194,16 +195,21 @@ class TelldusLiveClient: async def update(self, *args): """Periodically poll the servers for current state.""" - if not await self._hass.async_add_executor_job(self._client.update): - _LOGGER.warning('Failed request') - - dev_ids = {dev.device_id for dev in self._client.devices} - new_devices = dev_ids - self._known_devices - # just await each discover as `gather` use up all HTTPAdapter pools - for d_id in new_devices: - await self._discover(d_id) - self._known_devices |= new_devices - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) + try: + if not await self._hass.async_add_executor_job( + self._client.update): + _LOGGER.warning('Failed request') + return + dev_ids = {dev.device_id for dev in self._client.devices} + new_devices = dev_ids - self._known_devices + # just await each discover as `gather` use up all HTTPAdapter pools + for d_id in new_devices: + await self._discover(d_id) + self._known_devices |= new_devices + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) + finally: + self._hass.data[INTERVAL_TRACKER] = async_call_later( + self._hass, self._interval, self.update) def device(self, device_id): """Return device representation.""" diff --git a/homeassistant/components/tellduslive/const.py b/homeassistant/components/tellduslive/const.py index 81b3abefdee..80b0513b763 100644 --- a/homeassistant/components/tellduslive/const.py +++ b/homeassistant/components/tellduslive/const.py @@ -18,7 +18,6 @@ KEY_SESSION = 'session' KEY_SCAN_INTERVAL = 'scan_interval' CONF_TOKEN_SECRET = 'token_secret' -CONF_UPDATE_INTERVAL = 'update_interval' PUBLIC_KEY = 'THUPUNECH5YEQA3RE6UYUPRUZ2DUGUGA' NOT_SO_PRIVATE_KEY = 'PHES7U2RADREWAFEBUSTUBAWRASWUTUS' diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 8967021a0cf..c2d1daa584c 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_ACCESS_TOKEN, from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.9.0'] +REQUIREMENTS = ['pyTibber==0.9.4'] DOMAIN = 'tibber' diff --git a/homeassistant/components/transmission.py b/homeassistant/components/transmission.py new file mode 100644 index 00000000000..cdf55c8e049 --- /dev/null +++ b/homeassistant/components/transmission.py @@ -0,0 +1,188 @@ +""" +Component for monitoring the Transmission BitTorrent client API. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/transmission/ +""" +from datetime import timedelta + +import logging +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME +) +from homeassistant.helpers import discovery, config_validation as cv +from homeassistant.helpers.event import track_time_interval + + +REQUIREMENTS = ['transmissionrpc==0.11'] +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'transmission' +DATA_TRANSMISSION = 'data_transmission' + +DEFAULT_NAME = 'Transmission' +DEFAULT_PORT = 9091 +TURTLE_MODE = 'turtle_mode' + +SENSOR_TYPES = { + 'active_torrents': ['Active Torrents', None], + 'current_status': ['Status', None], + 'download_speed': ['Down Speed', 'MB/s'], + 'paused_torrents': ['Paused Torrents', None], + 'total_torrents': ['Total Torrents', None], + 'upload_speed': ['Up Speed', 'MB/s'], + 'completed_torrents': ['Completed Torrents', None], + 'started_torrents': ['Started Torrents', None], +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(TURTLE_MODE, default=False): cv.boolean, + vol.Optional(CONF_MONITORED_CONDITIONS, default=['current_status']): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + }) +}, extra=vol.ALLOW_EXTRA) + +SCAN_INTERVAL = timedelta(minutes=2) + + +def setup(hass, config): + """Set up the Transmission Component.""" + host = config[DOMAIN][CONF_HOST] + username = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + port = config[DOMAIN][CONF_PORT] + + import transmissionrpc + from transmissionrpc.error import TransmissionError + try: + api = transmissionrpc.Client( + host, port=port, user=username, password=password) + api.session_stats() + except TransmissionError as error: + if str(error).find("401: Unauthorized"): + _LOGGER.error("Credentials for" + " Transmission client are not valid") + return False + + tm_data = hass.data[DATA_TRANSMISSION] = TransmissionData( + hass, config, api) + tm_data.init_torrent_list() + + def refresh(event_time): + """Get the latest data from Transmission.""" + tm_data.update() + + track_time_interval(hass, refresh, SCAN_INTERVAL) + + sensorconfig = { + 'sensors': config[DOMAIN][CONF_MONITORED_CONDITIONS], + 'client_name': config[DOMAIN][CONF_NAME]} + discovery.load_platform(hass, 'sensor', DOMAIN, sensorconfig, config) + + if config[DOMAIN][TURTLE_MODE]: + discovery.load_platform(hass, 'switch', DOMAIN, sensorconfig, config) + return True + + +class TransmissionData: + """Get the latest data and update the states.""" + + def __init__(self, hass, config, api): + """Initialize the Transmission RPC API.""" + self.data = None + self.torrents = None + self.session = None + self.available = True + self._api = api + self.completed_torrents = [] + self.started_torrents = [] + self.hass = hass + + def update(self): + """Get the latest data from Transmission instance.""" + from transmissionrpc.error import TransmissionError + + try: + self.data = self._api.session_stats() + self.torrents = self._api.get_torrents() + self.session = self._api.get_session() + + self.check_completed_torrent() + self.check_started_torrent() + + _LOGGER.debug("Torrent Data updated") + self.available = True + except TransmissionError: + self.available = False + _LOGGER.error("Unable to connect to Transmission client") + + def init_torrent_list(self): + """Initialize torrent lists.""" + self.torrents = self._api.get_torrents() + self.completed_torrents = [ + x.name for x in self.torrents if x.status == "seeding"] + self.started_torrents = [ + x.name for x in self.torrents if x.status == "downloading"] + + def check_completed_torrent(self): + """Get completed torrent functionality.""" + actual_torrents = self.torrents + actual_completed_torrents = [ + var.name for var in actual_torrents if var.status == "seeding"] + + tmp_completed_torrents = list( + set(actual_completed_torrents).difference( + self.completed_torrents)) + + for var in tmp_completed_torrents: + self.hass.bus.fire( + 'transmission_downloaded_torrent', { + 'name': var}) + + self.completed_torrents = actual_completed_torrents + + def check_started_torrent(self): + """Get started torrent functionality.""" + actual_torrents = self.torrents + actual_started_torrents = [ + var.name for var + in actual_torrents if var.status == "downloading"] + + tmp_started_torrents = list( + set(actual_started_torrents).difference( + self.started_torrents)) + + for var in tmp_started_torrents: + self.hass.bus.fire( + 'transmission_started_torrent', { + 'name': var}) + self.started_torrents = actual_started_torrents + + def get_started_torrent_count(self): + """Get the number of started torrents.""" + return len(self.started_torrents) + + def get_completed_torrent_count(self): + """Get the number of completed torrents.""" + return len(self.completed_torrents) + + def set_alt_speed_enabled(self, is_enabled): + """Set the alternative speed flag.""" + self._api.set_session(alt_speed_enabled=is_enabled) + + def get_alt_speed_enabled(self): + """Get the alternative speed flag.""" + return self.session.alt_speed_enabled diff --git a/homeassistant/components/twilio/.translations/sl.json b/homeassistant/components/twilio/.translations/sl.json index 0321cb05452..86d2c44f11c 100644 --- a/homeassistant/components/twilio/.translations/sl.json +++ b/homeassistant/components/twilio/.translations/sl.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "not_internet_accessible": " \u010ce \u017eelite prejemati sporo\u010dila Twilio, mora biti Home Assistent dostopen prek interneta.", + "not_internet_accessible": "\u010ce \u017eelite prejemati sporo\u010dila Twilio, mora biti Home Assistant dostopen prek interneta.", "one_instance_allowed": "Potrebna je samo ena instanca." }, "create_entry": { - "default": "Za po\u0161iljanje dogodkov Home Assistent-u, boste morali nastaviti [Webhooks z Twilio]({twilio_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/x-www-form-urlencoded\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite avtomatizacijo za obravnavo dohodnih podatkov." + "default": "Za po\u0161iljanje dogodkov Home Assistant-u, boste morali nastaviti [Webhooks z Twilio]({twilio_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/x-www-form-urlencoded\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite avtomatizacijo za obravnavo dohodnih podatkov." }, "step": { "user": { diff --git a/homeassistant/components/twilio/.translations/zh-Hans.json b/homeassistant/components/twilio/.translations/zh-Hans.json index e108fe12498..6fda9f0143c 100644 --- a/homeassistant/components/twilio/.translations/zh-Hans.json +++ b/homeassistant/components/twilio/.translations/zh-Hans.json @@ -1,7 +1,18 @@ { "config": { + "abort": { + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u63a5\u5165\u4e92\u8054\u7f51\u4ee5\u63a5\u6536 Twilio \u6d88\u606f\u3002", + "one_instance_allowed": "\u4ec5\u9700\u4e00\u4e2a\u5b9e\u4f8b" + }, "create_entry": { "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Twilio \u7684 Webhook]({twilio_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u6709\u5173\u5982\u4f55\u914d\u7f6e\u81ea\u52a8\u5316\u4ee5\u5904\u7406\u4f20\u5165\u7684\u6570\u636e\uff0c\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u3002" - } + }, + "step": { + "user": { + "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e Twilio \u5417\uff1f", + "title": "\u8bbe\u7f6e Twilio Webhook" + } + }, + "title": "Twilio" } } \ No newline at end of file diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 9e21956536f..11529cbe171 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -114,7 +114,7 @@ async def get_controller( ) try: - with async_timeout.timeout(5): + with async_timeout.timeout(10): await controller.login() return controller diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py index 2e32960573d..daa85a2425e 100644 --- a/homeassistant/components/updater.py +++ b/homeassistant/components/updater.py @@ -10,21 +10,18 @@ from datetime import timedelta from distutils.version import StrictVersion import json import logging -import os -import platform import uuid import aiohttp import async_timeout import voluptuous as vol -from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.const import __version__ as current_version +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, __version__ as current_version) from homeassistant.helpers import event from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -from homeassistant.util.package import is_virtual_env REQUIREMENTS = ['distro==1.3.0'] @@ -124,44 +121,22 @@ async def async_setup(hass, config): return True -async def get_system_info(hass, include_components): - """Return info about the system.""" - info_object = { - 'arch': platform.machine(), - 'dev': 'dev' in current_version, - 'docker': False, - 'os_name': platform.system(), - 'python_version': platform.python_version(), - 'timezone': dt_util.DEFAULT_TIME_ZONE.zone, - 'version': current_version, - 'virtualenv': is_virtual_env(), - 'hassio': hass.components.hassio.is_hassio(), - } - - if include_components: - info_object['components'] = list(hass.config.components) - - if platform.system() == 'Windows': - info_object['os_version'] = platform.win32_ver()[0] - elif platform.system() == 'Darwin': - info_object['os_version'] = platform.mac_ver()[0] - elif platform.system() == 'FreeBSD': - info_object['os_version'] = platform.release() - elif platform.system() == 'Linux': - import distro - linux_dist = await hass.async_add_job( - distro.linux_distribution, False) - info_object['distribution'] = linux_dist[0] - info_object['os_version'] = linux_dist[1] - info_object['docker'] = os.path.isfile('/.dockerenv') - - return info_object - - async def get_newest_version(hass, huuid, include_components): """Get the newest Home Assistant version.""" if huuid: - info_object = await get_system_info(hass, include_components) + info_object = \ + await hass.helpers.system_info.async_get_system_info() + + if include_components: + info_object['components'] = list(hass.config.components) + + import distro + + linux_dist = await hass.async_add_executor_job( + distro.linux_distribution, False) + info_object['distribution'] = linux_dist[0] + info_object['os_version'] = linux_dist[1] + info_object['huuid'] = huuid else: info_object = {} diff --git a/homeassistant/components/upnp/.translations/sl.json b/homeassistant/components/upnp/.translations/sl.json index 4bf6501bd2a..4c019d8f207 100644 --- a/homeassistant/components/upnp/.translations/sl.json +++ b/homeassistant/components/upnp/.translations/sl.json @@ -24,7 +24,7 @@ }, "user": { "data": { - "enable_port_mapping": "Omogo\u010dajo preslikavo vrat (port mapping) za Home Assistent-a", + "enable_port_mapping": "Omogo\u010dajo preslikavo vrat (port mapping) za Home Assistant-a", "enable_sensors": "Dodaj prometne senzorje", "igd": "UPnP/IGD" }, diff --git a/homeassistant/components/upnp/.translations/zh-Hans.json b/homeassistant/components/upnp/.translations/zh-Hans.json index b16172e97d7..2194a2dc264 100644 --- a/homeassistant/components/upnp/.translations/zh-Hans.json +++ b/homeassistant/components/upnp/.translations/zh-Hans.json @@ -4,9 +4,15 @@ "already_configured": "UPnP/IGD \u5df2\u914d\u7f6e\u5b8c\u6210", "incomplete_device": "\u5ffd\u7565\u4e0d\u5b8c\u6574\u7684 UPnP \u8bbe\u5907", "no_devices_discovered": "\u672a\u53d1\u73b0 UPnP/IGD", - "no_sensors_or_port_mapping": "\u81f3\u5c11\u542f\u7528\u4f20\u611f\u5668\u6216\u7aef\u53e3\u6620\u5c04" + "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 UPnP/IGD \u8bbe\u5907\u3002", + "no_sensors_or_port_mapping": "\u81f3\u5c11\u542f\u7528\u4f20\u611f\u5668\u6216\u7aef\u53e3\u6620\u5c04", + "single_instance_allowed": "UPnP/IGD \u53ea\u9700\u8981\u914d\u7f6e\u4e00\u6b21\u3002" }, "step": { + "confirm": { + "description": "\u60a8\u60f3\u8981\u914d\u7f6e UPnP/IGD \u5417\uff1f", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index d44cf2a8683..2a1b8c52d79 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -29,7 +29,7 @@ from .const import LOGGER as _LOGGER from .device import Device -REQUIREMENTS = ['async-upnp-client==0.13.8'] +REQUIREMENTS = ['async-upnp-client==0.14.4'] NOTIFICATION_ID = 'upnp_notification' NOTIFICATION_TITLE = 'UPnP/IGD Setup' diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index a99123129aa..6bbf0a3dd53 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -8,6 +8,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import HomeAssistantType from .const import LOGGER as _LOGGER +from .const import (DOMAIN, CONF_LOCAL_IP) class Device: @@ -22,15 +23,18 @@ class Device: async def async_discover(cls, hass: HomeAssistantType): """Discovery UPNP/IGD devices.""" _LOGGER.debug('Discovering UPnP/IGD devices') + local_ip = hass.data[DOMAIN]['config'].get(CONF_LOCAL_IP) + if local_ip: + local_ip = IPv4Address(local_ip) # discover devices - from async_upnp_client.igd import IgdDevice - discovery_infos = await IgdDevice.async_discover() + from async_upnp_client.profiles.igd import IgdDevice + discovery_infos = await IgdDevice.async_search(source_ip=local_ip) # add extra info and store devices devices = [] for discovery_info in discovery_infos: - discovery_info['udn'] = discovery_info['usn'].split('::')[0] + discovery_info['udn'] = discovery_info['_udn'] discovery_info['ssdp_description'] = discovery_info['location'] discovery_info['source'] = 'async_upnp_client' _LOGGER.debug('Discovered device: %s', discovery_info) @@ -56,7 +60,7 @@ class Device: upnp_device = await factory.async_create_device(ssdp_description) # wrap with async_upnp_client.IgdDevice - from async_upnp_client.igd import IgdDevice + from async_upnp_client.profiles.igd import IgdDevice igd_device = IgdDevice(upnp_device, None) return cls(igd_device) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py new file mode 100644 index 00000000000..8a8e669ba88 --- /dev/null +++ b/homeassistant/components/utility_meter/__init__.py @@ -0,0 +1,176 @@ +""" +Component to track utility consumption over given periods of time. + +For more details about this component, please refer to the documentation +at https://www.home-assistant.io/components/utility_meter/ +""" + +import logging + +import voluptuous as vol + +from homeassistant.const import (ATTR_ENTITY_ID, CONF_NAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from .const import ( + DOMAIN, SIGNAL_RESET_METER, METER_TYPES, CONF_SOURCE_SENSOR, + CONF_METER_TYPE, CONF_METER_OFFSET, CONF_TARIFF_ENTITY, CONF_TARIFF, + CONF_TARIFFS, CONF_METER, DATA_UTILITY, SERVICE_RESET, + SERVICE_SELECT_TARIFF, SERVICE_SELECT_NEXT_TARIFF, + ATTR_TARIFF) + +_LOGGER = logging.getLogger(__name__) + +TARIFF_ICON = "mdi:clock-outline" + +ATTR_TARIFFS = 'tariffs' + +SERVICE_METER_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, +}) + +SERVICE_SELECT_TARIFF_SCHEMA = SERVICE_METER_SCHEMA.extend({ + vol.Required(ATTR_TARIFF): cv.string +}) + +METER_CONFIG_SCHEMA = vol.Schema({ + vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES), + vol.Optional(CONF_METER_OFFSET, default=0): cv.positive_int, + vol.Optional(CONF_TARIFFS, default=[]): vol.All( + cv.ensure_list, [cv.string]), +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: METER_CONFIG_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up an Utility Meter.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + hass.data[DATA_UTILITY] = {} + + for meter, conf in config.get(DOMAIN).items(): + _LOGGER.debug("Setup %s.%s", DOMAIN, meter) + + hass.data[DATA_UTILITY][meter] = conf + + if not conf[CONF_TARIFFS]: + # only one entity is required + hass.async_create_task(discovery.async_load_platform( + hass, SENSOR_DOMAIN, DOMAIN, + [{CONF_METER: meter, CONF_NAME: meter}], config)) + else: + # create tariff selection + await component.async_add_entities([ + TariffSelect(meter, list(conf[CONF_TARIFFS])) + ]) + hass.data[DATA_UTILITY][meter][CONF_TARIFF_ENTITY] =\ + "{}.{}".format(DOMAIN, meter) + + # add one meter for each tariff + tariff_confs = [] + for tariff in conf[CONF_TARIFFS]: + tariff_confs.append({ + CONF_METER: meter, + CONF_NAME: "{} {}".format(meter, tariff), + CONF_TARIFF: tariff, + }) + hass.async_create_task(discovery.async_load_platform( + hass, SENSOR_DOMAIN, DOMAIN, tariff_confs, config)) + + component.async_register_entity_service( + SERVICE_RESET, SERVICE_METER_SCHEMA, + 'async_reset_meters' + ) + + component.async_register_entity_service( + SERVICE_SELECT_TARIFF, SERVICE_SELECT_TARIFF_SCHEMA, + 'async_select_tariff' + ) + + component.async_register_entity_service( + SERVICE_SELECT_NEXT_TARIFF, SERVICE_METER_SCHEMA, + 'async_next_tariff' + ) + + return True + + +class TariffSelect(RestoreEntity): + """Representation of a Tariff selector.""" + + def __init__(self, name, tariffs): + """Initialize a tariff selector.""" + self._name = name + self._current_tariff = None + self._tariffs = tariffs + self._icon = TARIFF_ICON + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + if self._current_tariff is not None: + return + + state = await self.async_get_last_state() + if not state or state.state not in self._tariffs: + self._current_tariff = self._tariffs[0] + else: + self._current_tariff = state.state + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the select input.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + return self._current_tariff + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_TARIFFS: self._tariffs, + } + + async def async_reset_meters(self): + """Reset all sensors of this meter.""" + async_dispatcher_send(self.hass, SIGNAL_RESET_METER, + self.entity_id) + + async def async_select_tariff(self, tariff): + """Select new option.""" + if tariff not in self._tariffs: + _LOGGER.warning('Invalid tariff: %s (possible tariffs: %s)', + tariff, ', '.join(self._tariffs)) + return + self._current_tariff = tariff + await self.async_update_ha_state() + + async def async_next_tariff(self): + """Offset current index.""" + current_index = self._tariffs.index(self._current_tariff) + new_index = (current_index + 1) % len(self._tariffs) + self._current_tariff = self._tariffs[new_index] + await self.async_update_ha_state() diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py new file mode 100644 index 00000000000..4d2df0372b5 --- /dev/null +++ b/homeassistant/components/utility_meter/const.py @@ -0,0 +1,30 @@ +"""Constants for the utility meter component.""" +DOMAIN = 'utility_meter' + +HOURLY = 'hourly' +DAILY = 'daily' +WEEKLY = 'weekly' +MONTHLY = 'monthly' +YEARLY = 'yearly' + +METER_TYPES = [HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY] + +DATA_UTILITY = 'utility_meter_data' + +CONF_METER = 'meter' +CONF_SOURCE_SENSOR = 'source' +CONF_METER_TYPE = 'cycle' +CONF_METER_OFFSET = 'offset' +CONF_PAUSED = 'paused' +CONF_TARIFFS = 'tariffs' +CONF_TARIFF = 'tariff' +CONF_TARIFF_ENTITY = 'tariff_entity' + +ATTR_TARIFF = 'tariff' + +SIGNAL_START_PAUSE_METER = 'utility_meter_start_pause' +SIGNAL_RESET_METER = 'utility_meter_reset' + +SERVICE_RESET = 'reset' +SERVICE_SELECT_TARIFF = 'select_tariff' +SERVICE_SELECT_NEXT_TARIFF = 'next_tariff' diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py new file mode 100644 index 00000000000..cd86f9c0bd0 --- /dev/null +++ b/homeassistant/components/utility_meter/sensor.py @@ -0,0 +1,243 @@ +""" +Utility meter from sensors providing raw data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.utility_meter/ +""" +import logging + +from decimal import Decimal, DecimalException + +import homeassistant.util.dt as dt_util +from homeassistant.const import ( + CONF_NAME, ATTR_UNIT_OF_MEASUREMENT, + EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, STATE_UNAVAILABLE) +from homeassistant.core import callback +from homeassistant.helpers.event import ( + async_track_state_change, async_track_time_change) +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect) +from homeassistant.helpers.restore_state import RestoreEntity +from .const import ( + DATA_UTILITY, SIGNAL_RESET_METER, + HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY, + CONF_SOURCE_SENSOR, CONF_METER_TYPE, CONF_METER_OFFSET, + CONF_TARIFF, CONF_TARIFF_ENTITY, CONF_METER) + +_LOGGER = logging.getLogger(__name__) + +ATTR_SOURCE_ID = 'source' +ATTR_STATUS = 'status' +ATTR_PERIOD = 'meter_period' +ATTR_LAST_PERIOD = 'last_period' +ATTR_LAST_RESET = 'last_reset' +ATTR_TARIFF = 'tariff' + +ICON = 'mdi:counter' + +PRECISION = 3 +PAUSED = 'paused' +COLLECTING = 'collecting' + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the utility meter sensor.""" + if discovery_info is None: + _LOGGER.error("This platform is only available through discovery") + return + + meters = [] + for conf in discovery_info: + meter = conf[CONF_METER] + conf_meter_source = hass.data[DATA_UTILITY][meter][CONF_SOURCE_SENSOR] + conf_meter_type = hass.data[DATA_UTILITY][meter].get(CONF_METER_TYPE) + conf_meter_offset = hass.data[DATA_UTILITY][meter][CONF_METER_OFFSET] + conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get( + CONF_TARIFF_ENTITY) + + meters.append(UtilityMeterSensor(conf_meter_source, + conf.get(CONF_NAME), + conf_meter_type, + conf_meter_offset, + conf.get(CONF_TARIFF), + conf_meter_tariff_entity)) + + async_add_entities(meters) + + +class UtilityMeterSensor(RestoreEntity): + """Representation of an utility meter sensor.""" + + def __init__(self, source_entity, name, meter_type, meter_offset=0, + tariff=None, tariff_entity=None): + """Initialize the Utility Meter sensor.""" + self._sensor_source_id = source_entity + self._state = 0 + self._last_period = 0 + self._last_reset = dt_util.now() + self._collecting = None + if name: + self._name = name + else: + self._name = '{} meter'.format(source_entity) + self._unit_of_measurement = None + self._period = meter_type + self._period_offset = meter_offset + self._tariff = tariff + self._tariff_entity = tariff_entity + + @callback + def async_reading(self, entity, old_state, new_state): + """Handle the sensor state changes.""" + if any([old_state is None, + old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE], + new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]]): + return + + if self._unit_of_measurement is None and\ + new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is not None: + self._unit_of_measurement = new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT) + + try: + diff = Decimal(new_state.state) - Decimal(old_state.state) + + if diff < 0: + # Source sensor just rolled over for unknow reasons, + return + self._state += diff + + except ValueError as err: + _LOGGER.warning("While processing state changes: %s", err) + except DecimalException as err: + _LOGGER.warning("Invalid state (%s > %s): %s", + old_state.state, new_state.state, err) + self.async_schedule_update_ha_state() + + @callback + def async_tariff_change(self, entity, old_state, new_state): + """Handle tariff changes.""" + if self._tariff == new_state.state: + self._collecting = async_track_state_change( + self.hass, self._sensor_source_id, self.async_reading) + else: + self._collecting() + self._collecting = None + + _LOGGER.debug("%s - %s - source <%s>", self._name, + COLLECTING if self._collecting is not None + else PAUSED, self._sensor_source_id) + + self.async_schedule_update_ha_state() + + async def _async_reset_meter(self, event): + """Determine cycle - Helper function for larger then daily cycles.""" + now = dt_util.now() + if self._period == WEEKLY and now.weekday() != self._period_offset: + return + if self._period == MONTHLY and\ + now.day != (1 + self._period_offset): + return + if self._period == YEARLY and\ + (now.month != (1 + self._period_offset) or now.day != 1): + return + await self.async_reset_meter(self._tariff_entity) + + async def async_reset_meter(self, entity_id): + """Reset meter.""" + if self._tariff_entity != entity_id: + return + _LOGGER.debug("Reset utility meter <%s>", self.entity_id) + self._last_reset = dt_util.now() + self._last_period = str(self._state) + self._state = 0 + await self.async_update_ha_state() + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + await super().async_added_to_hass() + + if self._period == HOURLY: + async_track_time_change(self.hass, self._async_reset_meter, + minute=self._period_offset, second=0) + elif self._period == DAILY: + async_track_time_change(self.hass, self._async_reset_meter, + hour=self._period_offset, minute=0, + second=0) + elif self._period in [WEEKLY, MONTHLY, YEARLY]: + async_track_time_change(self.hass, self._async_reset_meter, + hour=0, minute=0, second=0) + + async_dispatcher_connect( + self.hass, SIGNAL_RESET_METER, self.async_reset_meter) + + state = await self.async_get_last_state() + if state: + self._state = Decimal(state.state) + self._unit_of_measurement = state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT) + self._last_period = state.attributes.get(ATTR_LAST_PERIOD) + self._last_reset = state.attributes.get(ATTR_LAST_RESET) + await self.async_update_ha_state() + if state.attributes.get(ATTR_STATUS) == PAUSED: + # Fake cancelation function to init the meter paused + self._collecting = lambda: None + + @callback + def async_source_tracking(event): + """Wait for source to be ready, then start meter.""" + if self._tariff_entity is not None: + _LOGGER.debug("track %s", self._tariff_entity) + async_track_state_change(self.hass, self._tariff_entity, + self.async_tariff_change) + + tariff_entity_state = self.hass.states.get(self._tariff_entity) + if self._tariff != tariff_entity_state.state: + return + + self._collecting = async_track_state_change( + self.hass, self._sensor_source_id, self.async_reading) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_source_tracking) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + state_attr = { + ATTR_SOURCE_ID: self._sensor_source_id, + ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING, + ATTR_LAST_PERIOD: self._last_period, + ATTR_LAST_RESET: self._last_reset, + } + if self._period is not None: + state_attr[ATTR_PERIOD] = self._period + if self._tariff is not None: + state_attr[ATTR_TARIFF] = self._tariff + return state_attr + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON diff --git a/homeassistant/components/utility_meter/services.yaml b/homeassistant/components/utility_meter/services.yaml new file mode 100644 index 00000000000..7c09117d48f --- /dev/null +++ b/homeassistant/components/utility_meter/services.yaml @@ -0,0 +1,25 @@ +# Describes the format for available switch services + +reset: + description: Resets the counter of an utility meter. + fields: + entity_id: + description: Name(s) of the utility meter to reset + example: 'utility_meter.energy' + +next_tariff: + description: Changes the tariff to the next one. + fields: + entity_id: + description: Name(s) of entities to reset + example: 'utility_meter.energy' + +select_tariff: + description: selects the current tariff of an utility meter. + fields: + entity_id: + description: Name of the entity to set the tariff for + example: 'utility_meter.energy' + tariff: + description: Name of the tariff to switch to + example: 'offpeak' diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index ce4dccbaf75..0d89537b8e8 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -10,7 +10,8 @@ import logging import voluptuous as vol from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, - CONF_NAME, CONF_RESOURCES) + CONF_NAME, CONF_RESOURCES, + CONF_UPDATE_INTERVAL) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -32,7 +33,6 @@ _LOGGER = logging.getLogger(__name__) MIN_UPDATE_INTERVAL = timedelta(minutes=1) DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) -CONF_UPDATE_INTERVAL = 'update_interval' CONF_REGION = 'region' CONF_SERVICE_URL = 'service_url' CONF_SCANDINAVIAN_MILES = 'scandinavian_miles' diff --git a/homeassistant/components/weather/darksky.py b/homeassistant/components/weather/darksky.py index c753c0249ca..4ac3d2a1d22 100644 --- a/homeassistant/components/weather/darksky.py +++ b/homeassistant/components/weather/darksky.py @@ -71,7 +71,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): units = config.get(CONF_UNITS) if not units: - units = 'si' if hass.config.units.is_metric else 'us' + units = 'ca' if hass.config.units.is_metric else 'us' dark_sky = DarkSkyData( config.get(CONF_API_KEY), latitude, longitude, units) diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py index 6742f33c72d..9ec6d0298ea 100644 --- a/homeassistant/components/webhook.py +++ b/homeassistant/components/webhook.py @@ -19,6 +19,7 @@ DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) +URL_WEBHOOK_PATH = "/api/webhook/{webhook_id}" WS_TYPE_LIST = 'webhook/list' SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_LIST, @@ -58,8 +59,15 @@ def async_generate_id(): @callback @bind_hass def async_generate_url(hass, webhook_id): - """Generate a webhook_id.""" - return "{}/api/webhook/{}".format(hass.config.api.base_url, webhook_id) + """Generate the full URL for a webhook_id.""" + return "{}{}".format(hass.config.api.base_url, + async_generate_path(webhook_id)) + + +@callback +def async_generate_path(webhook_id): + """Generate the path component for a webhook_id.""" + return URL_WEBHOOK_PATH.format(webhook_id=webhook_id) @bind_hass @@ -97,7 +105,7 @@ async def async_setup(hass, config): class WebhookView(HomeAssistantView): """Handle incoming webhook requests.""" - url = "/api/webhook/{webhook_id}" + url = URL_WEBHOOK_PATH name = "api:webhook" requires_auth = False diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index 9c67af820f4..48c8f27996a 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -22,13 +22,22 @@ result_message = messages.result_message async_response = decorators.async_response require_admin = decorators.require_admin ws_require_user = decorators.ws_require_user +websocket_command = decorators.websocket_command # pylint: enable=invalid-name @bind_hass @callback -def async_register_command(hass, command, handler, schema): +def async_register_command(hass, command_or_handler, handler=None, + schema=None): """Register a websocket command.""" + # pylint: disable=protected-access + if handler is None: + handler = command_or_handler + command = handler._ws_command + schema = handler._ws_schema + else: + command = command_or_handler handlers = hass.data.get(DOMAIN) if handlers is None: handlers = hass.data[DOMAIN] = {} diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index d91b884541d..08619f6d15f 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -98,3 +98,17 @@ def ws_require_user( return check_current_user return validator + + +def websocket_command(schema): + """Tag a function as a websocket command.""" + command = schema['type'] + + def decorate(func): + """Decorate ws command function.""" + # pylint: disable=protected-access + func._ws_schema = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend(schema) + func._ws_command = command + return func + + return decorate diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index b80ddb017ce..3ec9b8920c3 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -20,8 +20,8 @@ REQUIREMENTS = ['pywemo==0.4.34'] DOMAIN = 'wemo' -# Mapping from Wemo device type to Home Assistant component type. -WEMO_DEVICE_TYPE_DISPATCH = { +# Mapping from Wemo model_name to component. +WEMO_MODEL_DISPATCH = { 'Bridge': 'light', 'CoffeeMaker': 'switch', 'Dimmer': 'light', @@ -30,7 +30,8 @@ WEMO_DEVICE_TYPE_DISPATCH = { 'LightSwitch': 'switch', 'Maker': 'switch', 'Motion': 'binary_sensor', - 'Switch': 'switch' + 'Sensor': 'binary_sensor', + 'Socket': 'switch' } SUBSCRIPTION_REGISTRY = None @@ -109,7 +110,7 @@ def setup(hass, config): def discovery_dispatch(service, discovery_info): """Dispatcher for incoming WeMo discovery events.""" # name, model, location, mac - device_type = discovery_info.get('device_type') + model_name = discovery_info.get('model_name') serial = discovery_info.get('serial') # Only register a device once @@ -121,7 +122,7 @@ def setup(hass, config): _LOGGER.debug('Discovered unique WeMo device: %s', serial) KNOWN_DEVICES.append(serial) - component = WEMO_DEVICE_TYPE_DISPATCH.get(device_type, 'switch') + component = WEMO_MODEL_DISPATCH.get(model_name, 'switch') discovery.load_platform(hass, component, DOMAIN, discovery_info, config) @@ -165,7 +166,7 @@ def setup(hass, config): device.host, device.port) discovery_info = { - 'device_type': device.__class__.__name__, + 'model_name': device.model_name, 'serial': device.serialnumber, 'mac_address': device.mac, 'ssdp_description': url, diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index 90e717efd9c..3cedb0b126b 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -25,7 +25,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import track_time_interval from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['python-wink==1.10.1', 'pubnubsub-handler==1.0.3'] +REQUIREMENTS = ['python-wink==1.10.3', 'pubnubsub-handler==1.0.3'] _LOGGER = logging.getLogger(__name__) @@ -358,7 +358,9 @@ def setup(hass, config): time.sleep(1) pywink.set_user_agent(USER_AGENT) _temp_response = pywink.wink_api_fetch() - _LOGGER.debug(str(json.dumps(_temp_response))) + _LOGGER.debug("%s", _temp_response) + _temp_response = pywink.post_session() + _LOGGER.debug("%s", _temp_response) # Call the Wink API every hour to keep PubNub updates flowing track_time_interval(hass, keep_alive_call, timedelta(minutes=60)) diff --git a/homeassistant/components/zha/.translations/zh-Hans.json b/homeassistant/components/zha/.translations/zh-Hans.json index 8befb2ee114..ce458fa32f1 100644 --- a/homeassistant/components/zha/.translations/zh-Hans.json +++ b/homeassistant/components/zha/.translations/zh-Hans.json @@ -1,11 +1,20 @@ { "config": { + "abort": { + "single_instance_allowed": "\u53ea\u5141\u8bb8\u4e00\u4e2a ZHA \u914d\u7f6e\u3002" + }, + "error": { + "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 ZHA \u8bbe\u5907\u3002" + }, "step": { "user": { "data": { "usb_path": "USB \u8bbe\u5907\u8def\u5f84" - } + }, + "description": "\u7a7a\u767d", + "title": "ZHA" } - } + }, + "title": "ZHA" } } \ No newline at end of file diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 335295b2c2c..4f9b5b04362 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -4,7 +4,6 @@ Support for Zigbee Home Automation devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ -import collections import logging import os import types @@ -14,26 +13,18 @@ import voluptuous as vol from homeassistant import config_entries, const as ha_const import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity_component import EntityComponent # Loading the config flow file will register the flow from . import config_flow # noqa # pylint: disable=unused-import -from . import const as zha_const -from .event import ZhaEvent, ZhaRelayEvent from . import api -from .helpers import convert_ieee -from .entities import ZhaDeviceEntity +from .core.gateway import ZHAGateway from .const import ( COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_DEVICE_CONFIG, CONF_RADIO_TYPE, CONF_USB_PATH, DATA_ZHA, DATA_ZHA_BRIDGE_ID, DATA_ZHA_CONFIG, DATA_ZHA_CORE_COMPONENT, DATA_ZHA_DISPATCHERS, DATA_ZHA_RADIO, DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, - DEFAULT_RADIO_TYPE, DOMAIN, ZHA_DISCOVERY_NEW, RadioType, - EVENTABLE_CLUSTERS, DATA_ZHA_CORE_EVENTS, ENABLE_QUIRKS, - DEVICE_CLASS, SINGLE_INPUT_CLUSTER_DEVICE_CLASS, - SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, CUSTOM_CLUSTER_MAPPINGS, - COMPONENT_CLUSTERS) + DEFAULT_RADIO_TYPE, DOMAIN, RadioType, DATA_ZHA_CORE_EVENTS, + ENABLE_QUIRKS) REQUIREMENTS = [ 'bellows==0.7.0', @@ -96,7 +87,6 @@ async def async_setup_entry(hass, config_entry): Will automatically load components to support devices found on the network. """ - establish_device_mappings() hass.data[DATA_ZHA] = hass.data.get(DATA_ZHA, {}) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = [] @@ -148,13 +138,13 @@ async def async_setup_entry(hass, config_entry): ) application_controller = ControllerApplication(radio, database) - listener = ApplicationListener(hass, config) - application_controller.add_listener(listener) + zha_gateway = ZHAGateway(hass, config) + application_controller.add_listener(zha_gateway) await application_controller.startup(auto_form=True) for device in application_controller.devices.values(): hass.async_create_task( - listener.async_device_initialized(device, False)) + zha_gateway.async_device_initialized(device, False)) device_registry = await \ hass.helpers.device_registry.async_get_registry() @@ -175,7 +165,7 @@ async def async_setup_entry(hass, config_entry): config_entry, component) ) - api.async_load_api(hass, application_controller, listener) + api.async_load_api(hass, application_controller, zha_gateway) def zha_shutdown(event): """Close radio.""" @@ -211,321 +201,3 @@ async def async_unload_entry(hass, config_entry): del hass.data[DATA_ZHA] return True - - -def establish_device_mappings(): - """Establish mappings between ZCL objects and HA ZHA objects. - - These cannot be module level, as importing bellows must be done in a - in a function. - """ - from zigpy import zcl, quirks - from zigpy.profiles import PROFILES, zha, zll - from .sensor import RelativeHumiditySensor - - if zha.PROFILE_ID not in DEVICE_CLASS: - DEVICE_CLASS[zha.PROFILE_ID] = {} - if zll.PROFILE_ID not in DEVICE_CLASS: - DEVICE_CLASS[zll.PROFILE_ID] = {} - - EVENTABLE_CLUSTERS.append(zcl.clusters.general.AnalogInput.cluster_id) - EVENTABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) - EVENTABLE_CLUSTERS.append(zcl.clusters.general.MultistateInput.cluster_id) - EVENTABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) - - DEVICE_CLASS[zha.PROFILE_ID].update({ - zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', - zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', - zha.DeviceType.REMOTE_CONTROL: 'binary_sensor', - zha.DeviceType.SMART_PLUG: 'switch', - zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: 'light', - zha.DeviceType.ON_OFF_LIGHT: 'light', - zha.DeviceType.DIMMABLE_LIGHT: 'light', - zha.DeviceType.COLOR_DIMMABLE_LIGHT: 'light', - zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'binary_sensor', - zha.DeviceType.DIMMER_SWITCH: 'binary_sensor', - zha.DeviceType.COLOR_DIMMER_SWITCH: 'binary_sensor', - }) - DEVICE_CLASS[zll.PROFILE_ID].update({ - zll.DeviceType.ON_OFF_LIGHT: 'light', - zll.DeviceType.ON_OFF_PLUGIN_UNIT: 'switch', - zll.DeviceType.DIMMABLE_LIGHT: 'light', - zll.DeviceType.DIMMABLE_PLUGIN_UNIT: 'light', - zll.DeviceType.COLOR_LIGHT: 'light', - zll.DeviceType.EXTENDED_COLOR_LIGHT: 'light', - zll.DeviceType.COLOR_TEMPERATURE_LIGHT: 'light', - zll.DeviceType.COLOR_CONTROLLER: 'binary_sensor', - zll.DeviceType.COLOR_SCENE_CONTROLLER: 'binary_sensor', - zll.DeviceType.CONTROLLER: 'binary_sensor', - zll.DeviceType.SCENE_CONTROLLER: 'binary_sensor', - zll.DeviceType.ON_OFF_SENSOR: 'binary_sensor', - }) - - SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ - zcl.clusters.general.OnOff: 'switch', - zcl.clusters.measurement.RelativeHumidity: 'sensor', - zcl.clusters.measurement.TemperatureMeasurement: 'sensor', - zcl.clusters.measurement.PressureMeasurement: 'sensor', - zcl.clusters.measurement.IlluminanceMeasurement: 'sensor', - zcl.clusters.smartenergy.Metering: 'sensor', - zcl.clusters.homeautomation.ElectricalMeasurement: 'sensor', - zcl.clusters.general.PowerConfiguration: 'sensor', - zcl.clusters.security.IasZone: 'binary_sensor', - zcl.clusters.measurement.OccupancySensing: 'binary_sensor', - zcl.clusters.hvac.Fan: 'fan', - }) - SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({ - zcl.clusters.general.OnOff: 'binary_sensor', - }) - - # A map of device/cluster to component/sub-component - CUSTOM_CLUSTER_MAPPINGS.update({ - (quirks.smartthings.SmartthingsTemperatureHumiditySensor, 64581): - ('sensor', RelativeHumiditySensor) - }) - - # A map of hass components to all Zigbee clusters it could use - for profile_id, classes in DEVICE_CLASS.items(): - profile = PROFILES[profile_id] - for device_type, component in classes.items(): - if component not in COMPONENT_CLUSTERS: - COMPONENT_CLUSTERS[component] = (set(), set()) - clusters = profile.CLUSTERS[device_type] - COMPONENT_CLUSTERS[component][0].update(clusters[0]) - COMPONENT_CLUSTERS[component][1].update(clusters[1]) - - -class ApplicationListener: - """All handlers for events that happen on the ZigBee application.""" - - def __init__(self, hass, config): - """Initialize the listener.""" - self._hass = hass - self._config = config - self._component = EntityComponent(_LOGGER, DOMAIN, hass) - self._device_registry = collections.defaultdict(list) - self._events = {} - - for component in COMPONENTS: - hass.data[DATA_ZHA][component] = ( - hass.data[DATA_ZHA].get(component, {}) - ) - hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] = self._component - hass.data[DATA_ZHA][DATA_ZHA_CORE_EVENTS] = self._events - - def device_joined(self, device): - """Handle device joined. - - At this point, no information about the device is known other than its - address - """ - # Wait for device_initialized, instead - pass - - def raw_device_initialized(self, device): - """Handle a device initialization without quirks loaded.""" - # Wait for device_initialized, instead - pass - - def device_initialized(self, device): - """Handle device joined and basic information discovered.""" - self._hass.async_create_task( - self.async_device_initialized(device, True)) - - def device_left(self, device): - """Handle device leaving the network.""" - pass - - def device_removed(self, device): - """Handle device being removed from the network.""" - for device_entity in self._device_registry[device.ieee]: - self._hass.async_create_task(device_entity.async_remove()) - if device.ieee in self._events: - self._events.pop(device.ieee) - - def get_device_entity(self, ieee_str): - """Return ZHADeviceEntity for given ieee.""" - ieee = convert_ieee(ieee_str) - if ieee in self._device_registry: - entities = self._device_registry[ieee] - entity = next( - ent for ent in entities if isinstance(ent, ZhaDeviceEntity)) - return entity - return None - - def get_entities_for_ieee(self, ieee_str): - """Return list of entities for given ieee.""" - ieee = convert_ieee(ieee_str) - if ieee in self._device_registry: - return self._device_registry[ieee] - return [] - - @property - def device_registry(self) -> str: - """Return devices.""" - return self._device_registry - - async def async_device_initialized(self, device, join): - """Handle device joined and basic information discovered (async).""" - import zigpy.profiles - - device_manufacturer = device_model = None - - for endpoint_id, endpoint in device.endpoints.items(): - if endpoint_id == 0: # ZDO - continue - - if endpoint.manufacturer is not None: - device_manufacturer = endpoint.manufacturer - if endpoint.model is not None: - device_model = endpoint.model - - component = None - profile_clusters = ([], []) - device_key = "{}-{}".format(device.ieee, endpoint_id) - node_config = {} - if CONF_DEVICE_CONFIG in self._config: - node_config = self._config[CONF_DEVICE_CONFIG].get( - device_key, {} - ) - - if endpoint.profile_id in zigpy.profiles.PROFILES: - profile = zigpy.profiles.PROFILES[endpoint.profile_id] - if zha_const.DEVICE_CLASS.get(endpoint.profile_id, - {}).get(endpoint.device_type, - None): - profile_clusters = profile.CLUSTERS[endpoint.device_type] - profile_info = zha_const.DEVICE_CLASS[endpoint.profile_id] - component = profile_info[endpoint.device_type] - - if ha_const.CONF_TYPE in node_config: - component = node_config[ha_const.CONF_TYPE] - profile_clusters = zha_const.COMPONENT_CLUSTERS[component] - - if component: - in_clusters = [endpoint.in_clusters[c] - for c in profile_clusters[0] - if c in endpoint.in_clusters] - out_clusters = [endpoint.out_clusters[c] - for c in profile_clusters[1] - if c in endpoint.out_clusters] - discovery_info = { - 'application_listener': self, - 'endpoint': endpoint, - 'in_clusters': {c.cluster_id: c for c in in_clusters}, - 'out_clusters': {c.cluster_id: c for c in out_clusters}, - 'manufacturer': endpoint.manufacturer, - 'model': endpoint.model, - 'new_join': join, - 'unique_id': device_key, - } - - if join: - async_dispatcher_send( - self._hass, - ZHA_DISCOVERY_NEW.format(component), - discovery_info - ) - else: - self._hass.data[DATA_ZHA][component][device_key] = ( - discovery_info - ) - - for cluster in endpoint.in_clusters.values(): - await self._attempt_single_cluster_device( - endpoint, - cluster, - profile_clusters[0], - device_key, - zha_const.SINGLE_INPUT_CLUSTER_DEVICE_CLASS, - 'in_clusters', - join, - ) - - for cluster in endpoint.out_clusters.values(): - await self._attempt_single_cluster_device( - endpoint, - cluster, - profile_clusters[1], - device_key, - zha_const.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, - 'out_clusters', - join, - ) - - endpoint_entity = ZhaDeviceEntity( - device, - device_manufacturer, - device_model, - self, - ) - await self._component.async_add_entities([endpoint_entity]) - - def register_entity(self, ieee, entity_obj): - """Record the creation of a hass entity associated with ieee.""" - self._device_registry[ieee].append(entity_obj) - - async def _attempt_single_cluster_device(self, endpoint, cluster, - profile_clusters, device_key, - device_classes, discovery_attr, - is_new_join): - """Try to set up an entity from a "bare" cluster.""" - if cluster.cluster_id in EVENTABLE_CLUSTERS: - if cluster.endpoint.device.ieee not in self._events: - self._events.update({cluster.endpoint.device.ieee: []}) - from zigpy.zcl.clusters.general import OnOff, LevelControl - if discovery_attr == 'out_clusters' and \ - (cluster.cluster_id == OnOff.cluster_id or - cluster.cluster_id == LevelControl.cluster_id): - self._events[cluster.endpoint.device.ieee].append( - ZhaRelayEvent(self._hass, cluster) - ) - else: - self._events[cluster.endpoint.device.ieee].append(ZhaEvent( - self._hass, - cluster - )) - - if cluster.cluster_id in profile_clusters: - return - - component = sub_component = None - for cluster_type, candidate_component in device_classes.items(): - if isinstance(cluster, cluster_type): - component = candidate_component - break - - for signature, comp in zha_const.CUSTOM_CLUSTER_MAPPINGS.items(): - if (isinstance(endpoint.device, signature[0]) and - cluster.cluster_id == signature[1]): - component = comp[0] - sub_component = comp[1] - break - - if component is None: - return - - cluster_key = "{}-{}".format(device_key, cluster.cluster_id) - discovery_info = { - 'application_listener': self, - 'endpoint': endpoint, - 'in_clusters': {}, - 'out_clusters': {}, - 'manufacturer': endpoint.manufacturer, - 'model': endpoint.model, - 'new_join': is_new_join, - 'unique_id': cluster_key, - 'entity_suffix': '_{}'.format(cluster.cluster_id), - } - discovery_info[discovery_attr] = {cluster.cluster_id: cluster} - if sub_component: - discovery_info.update({'sub_component': sub_component}) - - if is_new_join: - async_dispatcher_send( - self._hass, - ZHA_DISCOVERY_NEW.format(component), - discovery_info - ) - else: - self._hass.data[DATA_ZHA][component][cluster_key] = discovery_info diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 308c221bf2f..0312a40967f 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.const import ATTR_ENTITY_ID import homeassistant.helpers.config_validation as cv -from .entities import ZhaDeviceEntity +from .device_entity import ZhaDeviceEntity from .const import ( DOMAIN, ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_MANUFACTURER, ATTR_COMMAND, ATTR_COMMAND_TYPE, ATTR_ARGS, IN, OUT, diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index fce9376700e..d0f23ff3dd2 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -10,11 +10,11 @@ from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice from homeassistant.const import STATE_ON from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity -from . import helpers -from .const import ( +from .core import helpers +from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) -from .entities import ZhaEntity -from .entities.listeners import ( +from .entity import ZhaEntity +from .core.listeners import ( OnOffListener, LevelListener ) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 1c903ec3056..d995a2179fe 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -6,9 +6,9 @@ import voluptuous as vol from homeassistant import config_entries -from .const import ( +from .core.const import ( CONF_RADIO_TYPE, CONF_USB_PATH, DEFAULT_DATABASE_NAME, DOMAIN, RadioType) -from .helpers import check_zigpy_connection +from .core.helpers import check_zigpy_connection @config_entries.HANDLERS.register(DOMAIN) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 47c3982c5d6..abcd17a0461 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -1,95 +1,8 @@ -"""All constants related to the ZHA component.""" -import enum +""" +Backwards compatible constants bridge. -DOMAIN = 'zha' - -BAUD_RATES = [ - 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000 -] - -DATA_ZHA = 'zha' -DATA_ZHA_CONFIG = 'config' -DATA_ZHA_BRIDGE_ID = 'zha_bridge_id' -DATA_ZHA_RADIO = 'zha_radio' -DATA_ZHA_DISPATCHERS = 'zha_dispatchers' -DATA_ZHA_CORE_COMPONENT = 'zha_core_component' -DATA_ZHA_CORE_EVENTS = 'zha_core_events' -ZHA_DISCOVERY_NEW = 'zha_discovery_new_{}' - -COMPONENTS = [ - 'binary_sensor', - 'fan', - 'light', - 'sensor', - 'switch', -] - -CONF_BAUDRATE = 'baudrate' -CONF_DATABASE = 'database_path' -CONF_DEVICE_CONFIG = 'device_config' -CONF_RADIO_TYPE = 'radio_type' -CONF_USB_PATH = 'usb_path' -DATA_DEVICE_CONFIG = 'zha_device_config' -ENABLE_QUIRKS = 'enable_quirks' - -DEFAULT_RADIO_TYPE = 'ezsp' -DEFAULT_BAUDRATE = 57600 -DEFAULT_DATABASE_NAME = 'zigbee.db' - -ATTR_CLUSTER_ID = 'cluster_id' -ATTR_CLUSTER_TYPE = 'cluster_type' -ATTR_ATTRIBUTE = 'attribute' -ATTR_VALUE = 'value' -ATTR_MANUFACTURER = 'manufacturer' -ATTR_COMMAND = 'command' -ATTR_COMMAND_TYPE = 'command_type' -ATTR_ARGS = 'args' - -IN = 'in' -OUT = 'out' -CLIENT_COMMANDS = 'client_commands' -SERVER_COMMANDS = 'server_commands' -SERVER = 'server' - - -class RadioType(enum.Enum): - """Possible options for radio type.""" - - ezsp = 'ezsp' - xbee = 'xbee' - deconz = 'deconz' - - @classmethod - def list(cls): - """Return list of enum's values.""" - return [e.value for e in RadioType] - - -DISCOVERY_KEY = 'zha_discovery_info' -DEVICE_CLASS = {} -SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {} -SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {} -CUSTOM_CLUSTER_MAPPINGS = {} -COMPONENT_CLUSTERS = {} -EVENTABLE_CLUSTERS = [] - -REPORT_CONFIG_MAX_INT = 900 -REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800 -REPORT_CONFIG_MIN_INT = 30 -REPORT_CONFIG_MIN_INT_ASAP = 1 -REPORT_CONFIG_MIN_INT_IMMEDIATE = 0 -REPORT_CONFIG_MIN_INT_OP = 5 -REPORT_CONFIG_MIN_INT_BATTERY_SAVE = 3600 -REPORT_CONFIG_RPT_CHANGE = 1 -REPORT_CONFIG_DEFAULT = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_RPT_CHANGE) -REPORT_CONFIG_ASAP = (REPORT_CONFIG_MIN_INT_ASAP, REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_RPT_CHANGE) -REPORT_CONFIG_BATTERY_SAVE = (REPORT_CONFIG_MIN_INT_BATTERY_SAVE, - REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_RPT_CHANGE) -REPORT_CONFIG_IMMEDIATE = (REPORT_CONFIG_MIN_INT_IMMEDIATE, - REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_RPT_CHANGE) -REPORT_CONFIG_OP = (REPORT_CONFIG_MIN_INT_OP, REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_RPT_CHANGE) +For more details on this platform, please refer to the documentation +at https://home-assistant.io/components/fan.zha/ +""" +# pylint: disable=W0614,W0401 +from .core.const import * # noqa: F401,F403 diff --git a/homeassistant/components/zha/entities/__init__.py b/homeassistant/components/zha/core/__init__.py similarity index 50% rename from homeassistant/components/zha/entities/__init__.py rename to homeassistant/components/zha/core/__init__.py index c3c3ea163ed..47e6ed2b0ee 100644 --- a/homeassistant/components/zha/entities/__init__.py +++ b/homeassistant/components/zha/core/__init__.py @@ -1,10 +1,6 @@ """ -Entities for Zigbee Home Automation. +Core module for Zigbee Home Automation. For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ - -# flake8: noqa -from .device_entity import ZhaDeviceEntity -from .entity import ZhaEntity diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py new file mode 100644 index 00000000000..3069ebf02db --- /dev/null +++ b/homeassistant/components/zha/core/const.py @@ -0,0 +1,104 @@ +"""All constants related to the ZHA component.""" +import enum + +DOMAIN = 'zha' + +BAUD_RATES = [ + 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000 +] + +DATA_ZHA = 'zha' +DATA_ZHA_CONFIG = 'config' +DATA_ZHA_BRIDGE_ID = 'zha_bridge_id' +DATA_ZHA_RADIO = 'zha_radio' +DATA_ZHA_DISPATCHERS = 'zha_dispatchers' +DATA_ZHA_CORE_COMPONENT = 'zha_core_component' +DATA_ZHA_CORE_EVENTS = 'zha_core_events' +ZHA_DISCOVERY_NEW = 'zha_discovery_new_{}' + +COMPONENTS = [ + 'binary_sensor', + 'fan', + 'light', + 'sensor', + 'switch', +] + +CONF_BAUDRATE = 'baudrate' +CONF_DATABASE = 'database_path' +CONF_DEVICE_CONFIG = 'device_config' +CONF_RADIO_TYPE = 'radio_type' +CONF_USB_PATH = 'usb_path' +DATA_DEVICE_CONFIG = 'zha_device_config' +ENABLE_QUIRKS = 'enable_quirks' + +DEFAULT_RADIO_TYPE = 'ezsp' +DEFAULT_BAUDRATE = 57600 +DEFAULT_DATABASE_NAME = 'zigbee.db' + +ATTR_CLUSTER_ID = 'cluster_id' +ATTR_CLUSTER_TYPE = 'cluster_type' +ATTR_ATTRIBUTE = 'attribute' +ATTR_VALUE = 'value' +ATTR_MANUFACTURER = 'manufacturer' +ATTR_COMMAND = 'command' +ATTR_COMMAND_TYPE = 'command_type' +ATTR_ARGS = 'args' +ATTR_ENDPOINT_ID = 'endpoint_id' + +IN = 'in' +OUT = 'out' +CLIENT_COMMANDS = 'client_commands' +SERVER_COMMANDS = 'server_commands' +SERVER = 'server' +IEEE = 'ieee' +MODEL = 'model' +NAME = 'name' + +LISTENER_BATTERY = 'battery' + +SIGNAL_ATTR_UPDATED = 'attribute_updated' +SIGNAL_AVAILABLE = 'available' + + +class RadioType(enum.Enum): + """Possible options for radio type.""" + + ezsp = 'ezsp' + xbee = 'xbee' + deconz = 'deconz' + + @classmethod + def list(cls): + """Return list of enum's values.""" + return [e.value for e in RadioType] + + +DISCOVERY_KEY = 'zha_discovery_info' +DEVICE_CLASS = {} +SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {} +SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {} +CUSTOM_CLUSTER_MAPPINGS = {} +COMPONENT_CLUSTERS = {} +EVENTABLE_CLUSTERS = [] + +REPORT_CONFIG_MAX_INT = 900 +REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800 +REPORT_CONFIG_MIN_INT = 30 +REPORT_CONFIG_MIN_INT_ASAP = 1 +REPORT_CONFIG_MIN_INT_IMMEDIATE = 0 +REPORT_CONFIG_MIN_INT_OP = 5 +REPORT_CONFIG_MIN_INT_BATTERY_SAVE = 3600 +REPORT_CONFIG_RPT_CHANGE = 1 +REPORT_CONFIG_DEFAULT = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE) +REPORT_CONFIG_ASAP = (REPORT_CONFIG_MIN_INT_ASAP, REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE) +REPORT_CONFIG_BATTERY_SAVE = (REPORT_CONFIG_MIN_INT_BATTERY_SAVE, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE) +REPORT_CONFIG_IMMEDIATE = (REPORT_CONFIG_MIN_INT_IMMEDIATE, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE) +REPORT_CONFIG_OP = (REPORT_CONFIG_MIN_INT_OP, REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py new file mode 100644 index 00000000000..c7dabced24b --- /dev/null +++ b/homeassistant/components/zha/core/device.py @@ -0,0 +1,316 @@ +""" +Device for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import asyncio +import logging + +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send +) +from .const import ( + ATTR_MANUFACTURER, LISTENER_BATTERY, SIGNAL_AVAILABLE, IN, OUT, + ATTR_CLUSTER_ID, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_COMMAND, SERVER, + ATTR_COMMAND_TYPE, ATTR_ARGS, CLIENT_COMMANDS, SERVER_COMMANDS, + ATTR_ENDPOINT_ID, IEEE, MODEL, NAME +) +from .listeners import EventRelayListener + +_LOGGER = logging.getLogger(__name__) + + +class ZHADevice: + """ZHA Zigbee device object.""" + + def __init__(self, hass, zigpy_device, zha_gateway): + """Initialize the gateway.""" + self.hass = hass + self._zigpy_device = zigpy_device + # Get first non ZDO endpoint id to use to get manufacturer and model + endpoint_ids = zigpy_device.endpoints.keys() + ept_id = next(ept_id for ept_id in endpoint_ids if ept_id != 0) + self._manufacturer = zigpy_device.endpoints[ept_id].manufacturer + self._model = zigpy_device.endpoints[ept_id].model + self._zha_gateway = zha_gateway + self._cluster_listeners = {} + self._relay_listeners = [] + self._all_listeners = [] + self._name = "{} {}".format( + self.manufacturer, + self.model + ) + self._available = False + self._available_signal = "{}_{}_{}".format( + self.name, self.ieee, SIGNAL_AVAILABLE) + self._unsub = async_dispatcher_connect( + self.hass, + self._available_signal, + self.async_initialize + ) + + @property + def name(self): + """Return device name.""" + return self._name + + @property + def ieee(self): + """Return ieee address for device.""" + return self._zigpy_device.ieee + + @property + def manufacturer(self): + """Return ieee address for device.""" + return self._manufacturer + + @property + def model(self): + """Return ieee address for device.""" + return self._model + + @property + def nwk(self): + """Return nwk for device.""" + return self._zigpy_device.nwk + + @property + def lqi(self): + """Return lqi for device.""" + return self._zigpy_device.lqi + + @property + def rssi(self): + """Return rssi for device.""" + return self._zigpy_device.rssi + + @property + def last_seen(self): + """Return last_seen for device.""" + return self._zigpy_device.last_seen + + @property + def manufacturer_code(self): + """Return manufacturer code for device.""" + # will eventually get this directly from Zigpy + return None + + @property + def gateway(self): + """Return the gateway for this device.""" + return self._zha_gateway + + @property + def cluster_listeners(self): + """Return cluster listeners for device.""" + return self._cluster_listeners.values() + + @property + def all_listeners(self): + """Return cluster listeners and relay listeners for device.""" + return self._all_listeners + + @property + def cluster_listener_keys(self): + """Return cluster listeners for device.""" + return self._cluster_listeners.keys() + + @property + def available_signal(self): + """Signal to use to subscribe to device availability changes.""" + return self._available_signal + + @property + def available(self): + """Return True if sensor is available.""" + return self._available + + def update_available(self, available): + """Set sensor availability.""" + if self._available != available and available: + # Update the state the first time the device comes online + async_dispatcher_send( + self.hass, + self._available_signal, + False + ) + async_dispatcher_send( + self.hass, + "{}_{}".format(self._available_signal, 'entity'), + True + ) + self._available = available + + @property + def device_info(self): + """Return a device description for device.""" + ieee = str(self.ieee) + return { + IEEE: ieee, + ATTR_MANUFACTURER: self.manufacturer, + MODEL: self.model, + NAME: self.name or ieee + } + + def add_cluster_listener(self, cluster_listener): + """Add cluster listener to device.""" + # only keep 1 power listener + if cluster_listener.name is LISTENER_BATTERY and \ + LISTENER_BATTERY in self._cluster_listeners: + return + self._all_listeners.append(cluster_listener) + if isinstance(cluster_listener, EventRelayListener): + self._relay_listeners.append(cluster_listener) + else: + self._cluster_listeners[cluster_listener.name] = cluster_listener + + def get_cluster_listener(self, name): + """Get cluster listener by name.""" + return self._cluster_listeners.get(name, None) + + async def async_configure(self): + """Configure the device.""" + _LOGGER.debug('%s: started configuration', self.name) + await self._execute_listener_tasks('async_configure') + _LOGGER.debug('%s: completed configuration', self.name) + + async def async_initialize(self, from_cache): + """Initialize listeners.""" + _LOGGER.debug('%s: started initialization', self.name) + await self._execute_listener_tasks('async_initialize', from_cache) + _LOGGER.debug('%s: completed initialization', self.name) + + async def async_accept_messages(self): + """Start accepting messages from the zigbee network.""" + await self._execute_listener_tasks('accept_messages') + + async def _execute_listener_tasks(self, task_name, *args): + """Gather and execute a set of listener tasks.""" + listener_tasks = [] + for listener in self.all_listeners: + listener_tasks.append( + self._async_create_task(listener, task_name, *args)) + await asyncio.gather(*listener_tasks) + + async def _async_create_task(self, listener, func_name, *args): + """Configure a single listener on this device.""" + try: + await getattr(listener, func_name)(*args) + _LOGGER.debug('%s: listener: %s %s stage succeeded', + self.name, + "{}-{}".format( + listener.name, listener.unique_id), + func_name) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.warning( + '%s listener: %s %s stage failed ex: %s', + self.name, + "{}-{}".format(listener.name, listener.unique_id), + func_name, + ex + ) + + async def async_unsub_dispatcher(self): + """Unsubscribe the dispatcher.""" + if self._unsub: + self._unsub() + + async def get_clusters(self): + """Get all clusters for this device.""" + return { + ep_id: { + IN: endpoint.in_clusters, + OUT: endpoint.out_clusters + } for (ep_id, endpoint) in self._zigpy_device.endpoints.items() + if ep_id != 0 + } + + async def get_cluster(self, endpooint_id, cluster_id, cluster_type=IN): + """Get zigbee cluster from this entity.""" + clusters = await self.get_clusters() + return clusters[endpooint_id][cluster_type][cluster_id] + + async def get_cluster_attributes(self, endpooint_id, cluster_id, + cluster_type=IN): + """Get zigbee attributes for specified cluster.""" + cluster = await self.get_cluster(endpooint_id, cluster_id, + cluster_type) + if cluster is None: + return None + return cluster.attributes + + async def get_cluster_commands(self, endpooint_id, cluster_id, + cluster_type=IN): + """Get zigbee commands for specified cluster.""" + cluster = await self.get_cluster(endpooint_id, cluster_id, + cluster_type) + if cluster is None: + return None + return { + CLIENT_COMMANDS: cluster.client_commands, + SERVER_COMMANDS: cluster.server_commands, + } + + async def write_zigbee_attribute(self, endpooint_id, cluster_id, + attribute, value, cluster_type=IN, + manufacturer=None): + """Write a value to a zigbee attribute for a cluster in this entity.""" + cluster = await self.get_cluster( + endpooint_id, cluster_id, cluster_type) + if cluster is None: + return None + + from zigpy.exceptions import DeliveryError + try: + response = await cluster.write_attributes( + {attribute: value}, + manufacturer=manufacturer + ) + _LOGGER.debug( + 'set: %s for attr: %s to cluster: %s for entity: %s - res: %s', + value, + attribute, + cluster_id, + endpooint_id, + response + ) + return response + except DeliveryError as exc: + _LOGGER.debug( + 'failed to set attribute: %s %s %s %s %s', + '{}: {}'.format(ATTR_VALUE, value), + '{}: {}'.format(ATTR_ATTRIBUTE, attribute), + '{}: {}'.format(ATTR_CLUSTER_ID, cluster_id), + '{}: {}'.format(ATTR_ENDPOINT_ID, endpooint_id), + exc + ) + return None + + async def issue_cluster_command(self, endpooint_id, cluster_id, command, + command_type, args, cluster_type=IN, + manufacturer=None): + """Issue a command against specified zigbee cluster on this entity.""" + cluster = await self.get_cluster( + endpooint_id, cluster_id, cluster_type) + if cluster is None: + return None + response = None + if command_type == SERVER: + response = await cluster.command(command, *args, + manufacturer=manufacturer, + expect_reply=True) + else: + response = await cluster.client_command(command, *args) + + _LOGGER.debug( + 'Issued cluster command: %s %s %s %s %s %s %s', + '{}: {}'.format(ATTR_CLUSTER_ID, cluster_id), + '{}: {}'.format(ATTR_COMMAND, command), + '{}: {}'.format(ATTR_COMMAND_TYPE, command_type), + '{}: {}'.format(ATTR_ARGS, args), + '{}: {}'.format(ATTR_CLUSTER_ID, cluster_type), + '{}: {}'.format(ATTR_MANUFACTURER, manufacturer), + '{}: {}'.format(ATTR_ENDPOINT_ID, endpooint_id) + ) + return response diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py new file mode 100644 index 00000000000..479b2f79b26 --- /dev/null +++ b/homeassistant/components/zha/core/gateway.py @@ -0,0 +1,342 @@ +""" +Virtual gateway for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" + +import collections +import logging +from homeassistant import const as ha_const +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_component import EntityComponent +from . import const as zha_const +from .const import ( + COMPONENTS, CONF_DEVICE_CONFIG, DATA_ZHA, DATA_ZHA_CORE_COMPONENT, DOMAIN, + ZHA_DISCOVERY_NEW, EVENTABLE_CLUSTERS, DATA_ZHA_CORE_EVENTS, DEVICE_CLASS, + SINGLE_INPUT_CLUSTER_DEVICE_CLASS, SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, + CUSTOM_CLUSTER_MAPPINGS, COMPONENT_CLUSTERS) +from ..device_entity import ZhaDeviceEntity +from ..event import ZhaEvent, ZhaRelayEvent +from .helpers import convert_ieee + +_LOGGER = logging.getLogger(__name__) + + +class ZHAGateway: + """Gateway that handles events that happen on the ZHA Zigbee network.""" + + def __init__(self, hass, config): + """Initialize the gateway.""" + self._hass = hass + self._config = config + self._component = EntityComponent(_LOGGER, DOMAIN, hass) + self._device_registry = collections.defaultdict(list) + self._events = {} + establish_device_mappings() + + for component in COMPONENTS: + hass.data[DATA_ZHA][component] = ( + hass.data[DATA_ZHA].get(component, {}) + ) + hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] = self._component + hass.data[DATA_ZHA][DATA_ZHA_CORE_EVENTS] = self._events + + def device_joined(self, device): + """Handle device joined. + + At this point, no information about the device is known other than its + address + """ + # Wait for device_initialized, instead + pass + + def raw_device_initialized(self, device): + """Handle a device initialization without quirks loaded.""" + # Wait for device_initialized, instead + pass + + def device_initialized(self, device): + """Handle device joined and basic information discovered.""" + self._hass.async_create_task( + self.async_device_initialized(device, True)) + + def device_left(self, device): + """Handle device leaving the network.""" + pass + + def device_removed(self, device): + """Handle device being removed from the network.""" + for device_entity in self._device_registry[device.ieee]: + self._hass.async_create_task(device_entity.async_remove()) + if device.ieee in self._events: + self._events.pop(device.ieee) + + def get_device_entity(self, ieee_str): + """Return ZHADeviceEntity for given ieee.""" + ieee = convert_ieee(ieee_str) + if ieee in self._device_registry: + entities = self._device_registry[ieee] + entity = next( + ent for ent in entities if isinstance(ent, ZhaDeviceEntity)) + return entity + return None + + def get_entities_for_ieee(self, ieee_str): + """Return list of entities for given ieee.""" + ieee = convert_ieee(ieee_str) + if ieee in self._device_registry: + return self._device_registry[ieee] + return [] + + @property + def device_registry(self) -> str: + """Return devices.""" + return self._device_registry + + async def async_device_initialized(self, device, join): + """Handle device joined and basic information discovered (async).""" + import zigpy.profiles + + device_manufacturer = device_model = None + + for endpoint_id, endpoint in device.endpoints.items(): + if endpoint_id == 0: # ZDO + continue + + if endpoint.manufacturer is not None: + device_manufacturer = endpoint.manufacturer + if endpoint.model is not None: + device_model = endpoint.model + + component = None + profile_clusters = ([], []) + device_key = "{}-{}".format(device.ieee, endpoint_id) + node_config = {} + if CONF_DEVICE_CONFIG in self._config: + node_config = self._config[CONF_DEVICE_CONFIG].get( + device_key, {} + ) + + if endpoint.profile_id in zigpy.profiles.PROFILES: + profile = zigpy.profiles.PROFILES[endpoint.profile_id] + if zha_const.DEVICE_CLASS.get(endpoint.profile_id, + {}).get(endpoint.device_type, + None): + profile_clusters = profile.CLUSTERS[endpoint.device_type] + profile_info = zha_const.DEVICE_CLASS[endpoint.profile_id] + component = profile_info[endpoint.device_type] + + if ha_const.CONF_TYPE in node_config: + component = node_config[ha_const.CONF_TYPE] + profile_clusters = zha_const.COMPONENT_CLUSTERS[component] + + if component: + in_clusters = [endpoint.in_clusters[c] + for c in profile_clusters[0] + if c in endpoint.in_clusters] + out_clusters = [endpoint.out_clusters[c] + for c in profile_clusters[1] + if c in endpoint.out_clusters] + discovery_info = { + 'application_listener': self, + 'endpoint': endpoint, + 'in_clusters': {c.cluster_id: c for c in in_clusters}, + 'out_clusters': {c.cluster_id: c for c in out_clusters}, + 'manufacturer': endpoint.manufacturer, + 'model': endpoint.model, + 'new_join': join, + 'unique_id': device_key, + } + + if join: + async_dispatcher_send( + self._hass, + ZHA_DISCOVERY_NEW.format(component), + discovery_info + ) + else: + self._hass.data[DATA_ZHA][component][device_key] = ( + discovery_info + ) + + for cluster in endpoint.in_clusters.values(): + await self._attempt_single_cluster_device( + endpoint, + cluster, + profile_clusters[0], + device_key, + zha_const.SINGLE_INPUT_CLUSTER_DEVICE_CLASS, + 'in_clusters', + join, + ) + + for cluster in endpoint.out_clusters.values(): + await self._attempt_single_cluster_device( + endpoint, + cluster, + profile_clusters[1], + device_key, + zha_const.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, + 'out_clusters', + join, + ) + + endpoint_entity = ZhaDeviceEntity( + device, + device_manufacturer, + device_model, + self, + ) + await self._component.async_add_entities([endpoint_entity]) + + def register_entity(self, ieee, entity_obj): + """Record the creation of a hass entity associated with ieee.""" + self._device_registry[ieee].append(entity_obj) + + async def _attempt_single_cluster_device(self, endpoint, cluster, + profile_clusters, device_key, + device_classes, discovery_attr, + is_new_join): + """Try to set up an entity from a "bare" cluster.""" + if cluster.cluster_id in EVENTABLE_CLUSTERS: + if cluster.endpoint.device.ieee not in self._events: + self._events.update({cluster.endpoint.device.ieee: []}) + from zigpy.zcl.clusters.general import OnOff, LevelControl + if discovery_attr == 'out_clusters' and \ + (cluster.cluster_id == OnOff.cluster_id or + cluster.cluster_id == LevelControl.cluster_id): + self._events[cluster.endpoint.device.ieee].append( + ZhaRelayEvent(self._hass, cluster) + ) + else: + self._events[cluster.endpoint.device.ieee].append(ZhaEvent( + self._hass, + cluster + )) + + if cluster.cluster_id in profile_clusters: + return + + component = sub_component = None + for cluster_type, candidate_component in device_classes.items(): + if isinstance(cluster, cluster_type): + component = candidate_component + break + + for signature, comp in zha_const.CUSTOM_CLUSTER_MAPPINGS.items(): + if (isinstance(endpoint.device, signature[0]) and + cluster.cluster_id == signature[1]): + component = comp[0] + sub_component = comp[1] + break + + if component is None: + return + + cluster_key = "{}-{}".format(device_key, cluster.cluster_id) + discovery_info = { + 'application_listener': self, + 'endpoint': endpoint, + 'in_clusters': {}, + 'out_clusters': {}, + 'manufacturer': endpoint.manufacturer, + 'model': endpoint.model, + 'new_join': is_new_join, + 'unique_id': cluster_key, + 'entity_suffix': '_{}'.format(cluster.cluster_id), + } + discovery_info[discovery_attr] = {cluster.cluster_id: cluster} + if sub_component: + discovery_info.update({'sub_component': sub_component}) + + if is_new_join: + async_dispatcher_send( + self._hass, + ZHA_DISCOVERY_NEW.format(component), + discovery_info + ) + else: + self._hass.data[DATA_ZHA][component][cluster_key] = discovery_info + + +def establish_device_mappings(): + """Establish mappings between ZCL objects and HA ZHA objects. + + These cannot be module level, as importing bellows must be done in a + in a function. + """ + from zigpy import zcl, quirks + from zigpy.profiles import PROFILES, zha, zll + from ..sensor import RelativeHumiditySensor + + if zha.PROFILE_ID not in DEVICE_CLASS: + DEVICE_CLASS[zha.PROFILE_ID] = {} + if zll.PROFILE_ID not in DEVICE_CLASS: + DEVICE_CLASS[zll.PROFILE_ID] = {} + + EVENTABLE_CLUSTERS.append(zcl.clusters.general.AnalogInput.cluster_id) + EVENTABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) + EVENTABLE_CLUSTERS.append(zcl.clusters.general.MultistateInput.cluster_id) + EVENTABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) + + DEVICE_CLASS[zha.PROFILE_ID].update({ + zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', + zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', + zha.DeviceType.REMOTE_CONTROL: 'binary_sensor', + zha.DeviceType.SMART_PLUG: 'switch', + zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: 'light', + zha.DeviceType.ON_OFF_LIGHT: 'light', + zha.DeviceType.DIMMABLE_LIGHT: 'light', + zha.DeviceType.COLOR_DIMMABLE_LIGHT: 'light', + zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'binary_sensor', + zha.DeviceType.DIMMER_SWITCH: 'binary_sensor', + zha.DeviceType.COLOR_DIMMER_SWITCH: 'binary_sensor', + }) + DEVICE_CLASS[zll.PROFILE_ID].update({ + zll.DeviceType.ON_OFF_LIGHT: 'light', + zll.DeviceType.ON_OFF_PLUGIN_UNIT: 'switch', + zll.DeviceType.DIMMABLE_LIGHT: 'light', + zll.DeviceType.DIMMABLE_PLUGIN_UNIT: 'light', + zll.DeviceType.COLOR_LIGHT: 'light', + zll.DeviceType.EXTENDED_COLOR_LIGHT: 'light', + zll.DeviceType.COLOR_TEMPERATURE_LIGHT: 'light', + zll.DeviceType.COLOR_CONTROLLER: 'binary_sensor', + zll.DeviceType.COLOR_SCENE_CONTROLLER: 'binary_sensor', + zll.DeviceType.CONTROLLER: 'binary_sensor', + zll.DeviceType.SCENE_CONTROLLER: 'binary_sensor', + zll.DeviceType.ON_OFF_SENSOR: 'binary_sensor', + }) + + SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ + zcl.clusters.general.OnOff: 'switch', + zcl.clusters.measurement.RelativeHumidity: 'sensor', + zcl.clusters.measurement.TemperatureMeasurement: 'sensor', + zcl.clusters.measurement.PressureMeasurement: 'sensor', + zcl.clusters.measurement.IlluminanceMeasurement: 'sensor', + zcl.clusters.smartenergy.Metering: 'sensor', + zcl.clusters.homeautomation.ElectricalMeasurement: 'sensor', + zcl.clusters.general.PowerConfiguration: 'sensor', + zcl.clusters.security.IasZone: 'binary_sensor', + zcl.clusters.measurement.OccupancySensing: 'binary_sensor', + zcl.clusters.hvac.Fan: 'fan', + }) + SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({ + zcl.clusters.general.OnOff: 'binary_sensor', + }) + + # A map of device/cluster to component/sub-component + CUSTOM_CLUSTER_MAPPINGS.update({ + (quirks.smartthings.SmartthingsTemperatureHumiditySensor, 64581): + ('sensor', RelativeHumiditySensor) + }) + + # A map of hass components to all Zigbee clusters it could use + for profile_id, classes in DEVICE_CLASS.items(): + profile = PROFILES[profile_id] + for device_type, component in classes.items(): + if component not in COMPONENT_CLUSTERS: + COMPONENT_CLUSTERS[component] = (set(), set()) + clusters = profile.CLUSTERS[device_type] + COMPONENT_CLUSTERS[component][0].update(clusters[0]) + COMPONENT_CLUSTERS[component][1].update(clusters[1]) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/core/helpers.py similarity index 82% rename from homeassistant/components/zha/helpers.py rename to homeassistant/components/zha/core/helpers.py index a182479d221..6957edc4f3f 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -55,7 +55,7 @@ async def bind_cluster(entity_id, cluster): ) -async def configure_reporting(entity_id, cluster, attr, skip_bind=False, +async def configure_reporting(entity_id, cluster, attr, min_report=REPORT_CONFIG_MIN_INT, max_report=REPORT_CONFIG_MAX_INT, reportable_change=REPORT_CONFIG_RPT_CHANGE, @@ -68,12 +68,13 @@ async def configure_reporting(entity_id, cluster, attr, skip_bind=False, from zigpy.exceptions import DeliveryError attr_name = cluster.attributes.get(attr, [attr])[0] + attr_id = get_attr_id_by_name(cluster, attr_name) cluster_name = cluster.ep_attribute kwargs = {} if manufacturer: kwargs['manufacturer'] = manufacturer try: - res = await cluster.configure_reporting(attr, min_report, + res = await cluster.configure_reporting(attr_id, min_report, max_report, reportable_change, **kwargs) _LOGGER.debug( @@ -101,11 +102,11 @@ async def bind_configure_reporting(entity_id, cluster, attr, skip_bind=False, if not skip_bind: await bind_cluster(entity_id, cluster) - await configure_reporting(entity_id, cluster, attr, skip_bind=False, - min_report=REPORT_CONFIG_MIN_INT, - max_report=REPORT_CONFIG_MAX_INT, - reportable_change=REPORT_CONFIG_RPT_CHANGE, - manufacturer=None) + await configure_reporting(entity_id, cluster, attr, + min_report=min_report, + max_report=max_report, + reportable_change=reportable_change, + manufacturer=manufacturer) async def check_zigpy_connection(usb_path, radio_type, database_path): @@ -136,3 +137,18 @@ def convert_ieee(ieee_str): """Convert given ieee string to EUI64.""" from zigpy.types import EUI64, uint8_t return EUI64([uint8_t(p, base=16) for p in ieee_str.split(':')]) + + +def construct_unique_id(cluster): + """Construct a unique id from a cluster.""" + return "0x{:04x}:{}:0x{:04x}".format( + cluster.endpoint.device.nwk, + cluster.endpoint.endpoint_id, + cluster.cluster_id + ) + + +def get_attr_id_by_name(cluster, attr_name): + """Get the attribute id for a cluster attribute by its name.""" + return next((attrid for attrid, (attrname, datatype) in + cluster.attributes.items() if attr_name == attrname), None) diff --git a/homeassistant/components/zha/entities/listeners.py b/homeassistant/components/zha/core/listeners.py similarity index 75% rename from homeassistant/components/zha/entities/listeners.py rename to homeassistant/components/zha/core/listeners.py index d4fce491563..4f60ea83d6f 100644 --- a/homeassistant/components/zha/entities/listeners.py +++ b/homeassistant/components/zha/core/listeners.py @@ -7,6 +7,9 @@ https://home-assistant.io/components/zha/ import logging +from homeassistant.core import callback +from .const import SIGNAL_ATTR_UPDATED + _LOGGER = logging.getLogger(__name__) @@ -108,3 +111,35 @@ class LevelListener(ClusterListener): """Handle attribute updates on this cluster.""" if attrid == self.CURRENT_LEVEL: self._entity.set_level(value) + + +class EventRelayListener(ClusterListener): + """Event relay that can be attached to zigbee clusters.""" + + name = 'event_relay' + + @callback + def attribute_updated(self, attrid, value): + """Handle an attribute updated on this cluster.""" + self.zha_send_event( + self._cluster, + SIGNAL_ATTR_UPDATED, + { + 'attribute_id': attrid, + 'attribute_name': self._cluster.attributes.get( + attrid, + ['Unknown'])[0], + 'value': value + } + ) + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle a cluster command received on this cluster.""" + if self._cluster.server_commands is not None and \ + self._cluster.server_commands.get(command_id) is not None: + self.zha_send_event( + self._cluster, + self._cluster.server_commands.get(command_id)[0], + args + ) diff --git a/homeassistant/components/zha/entities/device_entity.py b/homeassistant/components/zha/device_entity.py similarity index 100% rename from homeassistant/components/zha/entities/device_entity.py rename to homeassistant/components/zha/device_entity.py diff --git a/homeassistant/components/zha/entities/entity.py b/homeassistant/components/zha/entity.py similarity index 99% rename from homeassistant/components/zha/entities/entity.py rename to homeassistant/components/zha/entity.py index 8f8c8e58e05..e112e32d592 100644 --- a/homeassistant/components/zha/entities/entity.py +++ b/homeassistant/components/zha/entity.py @@ -13,11 +13,11 @@ from homeassistant.core import callback from homeassistant.helpers import entity from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.util import slugify -from ..const import ( +from .core.const import ( DATA_ZHA, DATA_ZHA_BRIDGE_ID, DOMAIN, ATTR_CLUSTER_ID, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_MANUFACTURER, ATTR_COMMAND, SERVER, ATTR_COMMAND_TYPE, ATTR_ARGS, IN, OUT, CLIENT_COMMANDS, SERVER_COMMANDS) -from ..helpers import bind_configure_reporting +from .core.helpers import bind_configure_reporting _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 630ab3f7bb9..f6dbef50923 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -10,10 +10,10 @@ from homeassistant.components.fan import ( DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, FanEntity) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import helpers -from .const import ( +from .core import helpers +from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_OP, ZHA_DISCOVERY_NEW) -from .entities import ZhaEntity +from .entity import ZhaEntity DEPENDENCIES = ['zha'] diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 766608b35b1..49a09112b31 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -9,12 +9,12 @@ import logging from homeassistant.components import light from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util -from . import helpers -from .const import ( +from .core import helpers +from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) -from .entities import ZhaEntity -from .entities.listeners import ( +from .entity import ZhaEntity +from .core.listeners import ( OnOffListener, LevelListener ) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index dabbcb79815..ae45fad0826 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -10,11 +10,11 @@ from homeassistant.components.sensor import DOMAIN from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.temperature import convert as convert_temperature -from . import helpers -from .const import ( +from .core import helpers +from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, REPORT_CONFIG_RPT_CHANGE, ZHA_DISCOVERY_NEW) -from .entities import ZhaEntity +from .entity import ZhaEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 793da4e1e3a..09c20acd088 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -8,10 +8,10 @@ import logging from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import helpers -from .const import ( +from .core import helpers +from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) -from .entities import ZhaEntity +from .entity import ZhaEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zone/zone.py b/homeassistant/components/zone/zone.py index b7c2e9ee858..ee8b53d6ee4 100644 --- a/homeassistant/components/zone/zone.py +++ b/homeassistant/components/zone/zone.py @@ -56,7 +56,7 @@ def async_active_zone(hass, latitude, longitude, radius=0): return closest -def in_zone(zone, latitude, longitude, radius=0): +def in_zone(zone, latitude, longitude, radius=0) -> bool: """Test if given latitude, longitude is in given zone. Async friendly. diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index 258841e20d0..4591e14a006 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -12,10 +12,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PATH, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, ATTR_NAME, ATTR_ID) +from homeassistant.helpers.discovery import async_load_platform _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['zm-py==0.3.0'] +REQUIREMENTS = ['zm-py==0.3.1'] CONF_PATH_ZMS = 'path_zms' @@ -93,4 +94,8 @@ def setup(hass, config): schema=SET_RUN_STATE_SCHEMA ) + hass.async_create_task( + async_load_platform(hass, 'binary_sensor', DOMAIN, {}, config) + ) + return success diff --git a/homeassistant/components/zoneminder/binary_sensor.py b/homeassistant/components/zoneminder/binary_sensor.py new file mode 100644 index 00000000000..e206ffa80f1 --- /dev/null +++ b/homeassistant/components/zoneminder/binary_sensor.py @@ -0,0 +1,50 @@ +""" +Support for ZoneMinder Binary Sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.zoneminder/ +""" +from homeassistant.components.binary_sensor import BinarySensorDevice + +from homeassistant.components.zoneminder import DOMAIN as ZONEMINDER_DOMAIN + +DEPENDENCIES = ['zoneminder'] + + +async def async_setup_platform(hass, config, add_entities, + discovery_info=None): + """Set up the ZoneMinder binary sensor platform.""" + sensors = [] + for host_name, zm_client in hass.data[ZONEMINDER_DOMAIN].items(): + sensors.append(ZMAvailabilitySensor(host_name, zm_client)) + add_entities(sensors) + return True + + +class ZMAvailabilitySensor(BinarySensorDevice): + """Representation of the availability of ZoneMinder as a binary sensor.""" + + def __init__(self, host_name, client): + """Initialize availability sensor.""" + self._state = None + self._name = host_name + self._client = client + + @property + def name(self): + """Return the name of this binary sensor.""" + return self._name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'connectivity' + + def update(self): + """Update the state of this sensor (availability of ZoneMinder).""" + self._state = self._client.is_available diff --git a/homeassistant/components/camera/zoneminder.py b/homeassistant/components/zoneminder/camera.py similarity index 100% rename from homeassistant/components/camera/zoneminder.py rename to homeassistant/components/zoneminder/camera.py diff --git a/homeassistant/components/sensor/zoneminder.py b/homeassistant/components/zoneminder/sensor.py similarity index 100% rename from homeassistant/components/sensor/zoneminder.py rename to homeassistant/components/zoneminder/sensor.py diff --git a/homeassistant/components/switch/zoneminder.py b/homeassistant/components/zoneminder/switch.py similarity index 100% rename from homeassistant/components/switch/zoneminder.py rename to homeassistant/components/zoneminder/switch.py diff --git a/homeassistant/config.py b/homeassistant/config.py index 0edadf6a78d..5dbf226ca25 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -105,6 +105,9 @@ map: # Track the sun sun: +# Allow diagnosing system problems +system_health: + # Sensors sensor: # Weather prediction @@ -443,7 +446,11 @@ def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str: else: message += '{}.'.format(humanize_error(config, ex)) - domain_config = config.get(domain, config) + try: + domain_config = config.get(domain, config) + except AttributeError: + domain_config = config + message += " (See {}, line {}). ".format( getattr(domain_config, '__config_file__', '?'), getattr(domain_config, '__line__', '?')) @@ -742,15 +749,21 @@ def async_process_component_config( async_log_exception(ex, domain, config, hass) return None - elif hasattr(component, 'PLATFORM_SCHEMA'): + elif (hasattr(component, 'PLATFORM_SCHEMA') or + hasattr(component, 'PLATFORM_SCHEMA_BASE')): platforms = [] for p_name, p_config in config_per_platform(config, domain): # Validate component specific platform schema try: - p_validated = component.PLATFORM_SCHEMA( # type: ignore - p_config) + if hasattr(component, 'PLATFORM_SCHEMA_BASE'): + p_validated = \ + component.PLATFORM_SCHEMA_BASE( # type: ignore + p_config) + else: + p_validated = component.PLATFORM_SCHEMA( # type: ignore + p_config) except vol.Invalid as ex: - async_log_exception(ex, domain, config, hass) + async_log_exception(ex, domain, p_config, hass) continue # Not all platform components follow same pattern for platforms @@ -770,10 +783,10 @@ def async_process_component_config( # pylint: disable=no-member try: p_validated = platform.PLATFORM_SCHEMA( # type: ignore - p_validated) + p_config) except vol.Invalid as ex: async_log_exception(ex, '{}.{}'.format(domain, p_name), - p_validated, hass) + p_config, hass) continue platforms.append(p_validated) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 00b5d797682..9c4c127f52e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -135,6 +135,7 @@ SOURCE_IMPORT = 'import' HANDLERS = Registry() # Components that have config flows. In future we will auto-generate this list. FLOWS = [ + 'ambient_station', 'cast', 'daikin', 'deconz', @@ -159,6 +160,7 @@ FLOWS = [ 'point', 'rainmachine', 'simplisafe', + 'smartthings', 'smhi', 'sonos', 'tellduslive', diff --git a/homeassistant/const.py b/homeassistant/const.py index 28e94d0a666..3a260501e32 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 86 -PATCH_VERSION = '4' +MINOR_VERSION = 87 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) @@ -147,6 +147,7 @@ CONF_TTL = 'ttl' CONF_TYPE = 'type' CONF_UNIT_OF_MEASUREMENT = 'unit_of_measurement' CONF_UNIT_SYSTEM = 'unit_system' +CONF_UPDATE_INTERVAL = 'update_interval' CONF_URL = 'url' CONF_USERNAME = 'username' CONF_VALUE_TEMPLATE = 'value_template' diff --git a/homeassistant/core.py b/homeassistant/core.py index aea37f74898..6ddefd2022d 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -259,9 +259,10 @@ class HomeAssistant: """ task = None + # Check for partials to properly determine if coroutine function check_target = target - if isinstance(target, functools.partial): - check_target = target.func + while isinstance(check_target, functools.partial): + check_target = check_target.func if asyncio.iscoroutine(check_target): task = self.loop.create_task(target) # type: ignore @@ -681,7 +682,7 @@ class State: "State max length is 255 characters.").format(entity_id)) self.entity_id = entity_id.lower() - self.state = state + self.state = state # type: str self.attributes = MappingProxyType(attributes or {}) self.last_updated = last_updated or dt_util.utcnow() self.last_changed = last_changed or self.last_updated diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py new file mode 100644 index 00000000000..19ad52534cb --- /dev/null +++ b/homeassistant/helpers/area_registry.py @@ -0,0 +1,139 @@ +"""Provide a way to connect devices to one physical location.""" +import logging +import uuid +from collections import OrderedDict +from typing import List, Optional + +import attr + +from homeassistant.core import callback +from homeassistant.loader import bind_hass + +_LOGGER = logging.getLogger(__name__) + +DATA_REGISTRY = 'area_registry' + +STORAGE_KEY = 'core.area_registry' +STORAGE_VERSION = 1 +SAVE_DELAY = 10 + + +@attr.s(slots=True, frozen=True) +class AreaEntry: + """Area Registry Entry.""" + + name = attr.ib(type=str, default=None) + id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + + +class AreaRegistry: + """Class to hold a registry of areas.""" + + def __init__(self, hass) -> None: + """Initialize the area registry.""" + self.hass = hass + self.areas = None + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + + @callback + def async_list_areas(self) -> List[AreaEntry]: + """Get all areas.""" + return self.areas.values() + + @callback + def async_create(self, name: str) -> AreaEntry: + """Create a new area.""" + if self._async_is_registered(name): + raise ValueError('Name is already in use') + + area = AreaEntry() + self.areas[area.id] = area + + return self.async_update(area.id, name=name) + + async def async_delete(self, area_id: str) -> None: + """Delete area.""" + device_registry = await \ + self.hass.helpers.device_registry.async_get_registry() + device_registry.async_clear_area_id(area_id) + + del self.areas[area_id] + + self.async_schedule_save() + + @callback + def async_update(self, area_id: str, name: str) -> AreaEntry: + """Update name of area.""" + old = self.areas[area_id] + + changes = {} + + if name == old.name: + return old + + if self._async_is_registered(name): + raise ValueError('Name is already in use') + else: + changes['name'] = name + + new = self.areas[area_id] = attr.evolve(old, **changes) + self.async_schedule_save() + return new + + @callback + def _async_is_registered(self, name) -> Optional[AreaEntry]: + """Check if a name is currently registered.""" + for area in self.areas.values(): + if name == area.name: + return area + return False + + async def async_load(self) -> None: + """Load the area registry.""" + data = await self._store.async_load() + + areas = OrderedDict() + + if data is not None: + for area in data['areas']: + areas[area['id']] = AreaEntry( + name=area['name'], + id=area['id'] + ) + + self.areas = areas + + @callback + def async_schedule_save(self) -> None: + """Schedule saving the area registry.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict: + """Return data of area registry to store in a file.""" + data = {} + + data['areas'] = [ + { + 'name': entry.name, + 'id': entry.id, + } for entry in self.areas.values() + ] + + return data + + +@bind_hass +async def async_get_registry(hass) -> AreaRegistry: + """Return area registry instance.""" + task = hass.data.get(DATA_REGISTRY) + + if task is None: + async def _load_reg(): + registry = AreaRegistry(hass) + await registry.async_load() + return registry + + task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg()) + + return await task diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 86112e2aea2..4b71b770973 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1,12 +1,14 @@ """Offer reusable conditions.""" -from datetime import timedelta +from datetime import datetime, timedelta import functools as ft import logging import sys +from typing import Callable, Container, Optional, Union, cast -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.components import zone as zone_cmp from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, @@ -29,25 +31,30 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=invalid-name -def _threaded_factory(async_factory): +def _threaded_factory(async_factory: + Callable[[ConfigType, bool], Callable[..., bool]]) \ + -> Callable[[ConfigType, bool], Callable[..., bool]]: """Create threaded versions of async factories.""" @ft.wraps(async_factory) - def factory(config, config_validation=True): + def factory(config: ConfigType, + config_validation: bool = True) -> Callable[..., bool]: """Threaded factory.""" async_check = async_factory(config, config_validation) - def condition_if(hass, variables=None): + def condition_if(hass: HomeAssistant, + variables: TemplateVarsType = None) -> bool: """Validate condition.""" - return run_callback_threadsafe( + return cast(bool, run_callback_threadsafe( hass.loop, async_check, hass, variables, - ).result() + ).result()) return condition_if return factory -def async_from_config(config: ConfigType, config_validation: bool = True): +def async_from_config(config: ConfigType, + config_validation: bool = True) -> Callable[..., bool]: """Turn a condition configuration into a method. Should be run on the event loop. @@ -64,20 +71,22 @@ def async_from_config(config: ConfigType, config_validation: bool = True): raise HomeAssistantError('Invalid condition "{}" specified {}'.format( config.get(CONF_CONDITION), config)) - return factory(config, config_validation) + return cast(Callable[..., bool], factory(config, config_validation)) from_config = _threaded_factory(async_from_config) -def async_and_from_config(config: ConfigType, config_validation: bool = True): +def async_and_from_config(config: ConfigType, + config_validation: bool = True) \ + -> Callable[..., bool]: """Create multi condition matcher using 'AND'.""" if config_validation: config = cv.AND_CONDITION_SCHEMA(config) checks = None def if_and_condition(hass: HomeAssistant, - variables=None) -> bool: + variables: TemplateVarsType = None) -> bool: """Test and condition.""" nonlocal checks @@ -101,14 +110,16 @@ def async_and_from_config(config: ConfigType, config_validation: bool = True): and_from_config = _threaded_factory(async_and_from_config) -def async_or_from_config(config: ConfigType, config_validation: bool = True): +def async_or_from_config(config: ConfigType, + config_validation: bool = True) \ + -> Callable[..., bool]: """Create multi condition matcher using 'OR'.""" if config_validation: config = cv.OR_CONDITION_SCHEMA(config) checks = None def if_or_condition(hass: HomeAssistant, - variables=None) -> bool: + variables: TemplateVarsType = None) -> bool: """Test and condition.""" nonlocal checks @@ -131,17 +142,22 @@ def async_or_from_config(config: ConfigType, config_validation: bool = True): or_from_config = _threaded_factory(async_or_from_config) -def numeric_state(hass: HomeAssistant, entity, below=None, above=None, - value_template=None, variables=None): +def numeric_state(hass: HomeAssistant, entity: Union[None, str, State], + below: Optional[float] = None, above: Optional[float] = None, + value_template: Optional[Template] = None, + variables: TemplateVarsType = None) -> bool: """Test a numeric state condition.""" - return run_callback_threadsafe( + return cast(bool, run_callback_threadsafe( hass.loop, async_numeric_state, hass, entity, below, above, value_template, variables, - ).result() + ).result()) -def async_numeric_state(hass: HomeAssistant, entity, below=None, above=None, - value_template=None, variables=None): +def async_numeric_state(hass: HomeAssistant, entity: Union[None, str, State], + below: Optional[float] = None, + above: Optional[float] = None, + value_template: Optional[Template] = None, + variables: TemplateVarsType = None) -> bool: """Test a numeric state condition.""" if isinstance(entity, str): entity = hass.states.get(entity) @@ -164,22 +180,24 @@ def async_numeric_state(hass: HomeAssistant, entity, below=None, above=None, return False try: - value = float(value) + fvalue = float(value) except ValueError: _LOGGER.warning("Value cannot be processed as a number: %s " "(Offending entity: %s)", entity, value) return False - if below is not None and value >= below: + if below is not None and fvalue >= below: return False - if above is not None and value <= above: + if above is not None and fvalue <= above: return False return True -def async_numeric_state_from_config(config, config_validation=True): +def async_numeric_state_from_config(config: ConfigType, + config_validation: bool = True) \ + -> Callable[..., bool]: """Wrap action method with state based condition.""" if config_validation: config = cv.NUMERIC_STATE_CONDITION_SCHEMA(config) @@ -188,7 +206,8 @@ def async_numeric_state_from_config(config, config_validation=True): above = config.get(CONF_ABOVE) value_template = config.get(CONF_VALUE_TEMPLATE) - def if_numeric_state(hass, variables=None): + def if_numeric_state(hass: HomeAssistant, + variables: TemplateVarsType = None) -> bool: """Test numeric state condition.""" if value_template is not None: value_template.hass = hass @@ -202,7 +221,8 @@ def async_numeric_state_from_config(config, config_validation=True): numeric_state_from_config = _threaded_factory(async_numeric_state_from_config) -def state(hass, entity, req_state, for_period=None): +def state(hass: HomeAssistant, entity: Union[None, str, State], req_state: str, + for_period: Optional[timedelta] = None) -> bool: """Test if state matches requirements. Async friendly. @@ -212,6 +232,7 @@ def state(hass, entity, req_state, for_period=None): if entity is None: return False + assert isinstance(entity, State) is_state = entity.state == req_state @@ -221,22 +242,26 @@ def state(hass, entity, req_state, for_period=None): return dt_util.utcnow() - for_period > entity.last_changed -def state_from_config(config, config_validation=True): +def state_from_config(config: ConfigType, + config_validation: bool = True) -> Callable[..., bool]: """Wrap action method with state based condition.""" if config_validation: config = cv.STATE_CONDITION_SCHEMA(config) entity_id = config.get(CONF_ENTITY_ID) - req_state = config.get(CONF_STATE) + req_state = cast(str, config.get(CONF_STATE)) for_period = config.get('for') - def if_state(hass, variables=None): + def if_state(hass: HomeAssistant, + variables: TemplateVarsType = None) -> bool: """Test if condition.""" return state(hass, entity_id, req_state, for_period) return if_state -def sun(hass, before=None, after=None, before_offset=None, after_offset=None): +def sun(hass: HomeAssistant, before: Optional[str] = None, + after: Optional[str] = None, before_offset: Optional[timedelta] = None, + after_offset: Optional[timedelta] = None) -> bool: """Test if current time matches sun requirements.""" utcnow = dt_util.utcnow() today = dt_util.as_local(utcnow).date() @@ -254,22 +279,27 @@ def sun(hass, before=None, after=None, before_offset=None, after_offset=None): # There is no sunset today return False - if before == SUN_EVENT_SUNRISE and utcnow > sunrise + before_offset: + if before == SUN_EVENT_SUNRISE and \ + utcnow > cast(datetime, sunrise) + before_offset: return False - if before == SUN_EVENT_SUNSET and utcnow > sunset + before_offset: + if before == SUN_EVENT_SUNSET and \ + utcnow > cast(datetime, sunset) + before_offset: return False - if after == SUN_EVENT_SUNRISE and utcnow < sunrise + after_offset: + if after == SUN_EVENT_SUNRISE and \ + utcnow < cast(datetime, sunrise) + after_offset: return False - if after == SUN_EVENT_SUNSET and utcnow < sunset + after_offset: + if after == SUN_EVENT_SUNSET and \ + utcnow < cast(datetime, sunset) + after_offset: return False return True -def sun_from_config(config, config_validation=True): +def sun_from_config(config: ConfigType, + config_validation: bool = True) -> Callable[..., bool]: """Wrap action method with sun based condition.""" if config_validation: config = cv.SUN_CONDITION_SCHEMA(config) @@ -278,21 +308,24 @@ def sun_from_config(config, config_validation=True): before_offset = config.get('before_offset') after_offset = config.get('after_offset') - def time_if(hass, variables=None): + def time_if(hass: HomeAssistant, + variables: TemplateVarsType = None) -> bool: """Validate time based if-condition.""" return sun(hass, before, after, before_offset, after_offset) return time_if -def template(hass, value_template, variables=None): +def template(hass: HomeAssistant, value_template: Template, + variables: TemplateVarsType = None) -> bool: """Test if template condition matches.""" - return run_callback_threadsafe( + return cast(bool, run_callback_threadsafe( hass.loop, async_template, hass, value_template, variables, - ).result() + ).result()) -def async_template(hass, value_template, variables=None): +def async_template(hass: HomeAssistant, value_template: Template, + variables: TemplateVarsType = None) -> bool: """Test if template condition matches.""" try: value = value_template.async_render(variables) @@ -303,13 +336,16 @@ def async_template(hass, value_template, variables=None): return value.lower() == 'true' -def async_template_from_config(config, config_validation=True): +def async_template_from_config(config: ConfigType, + config_validation: bool = True) \ + -> Callable[..., bool]: """Wrap action method with state based condition.""" if config_validation: config = cv.TEMPLATE_CONDITION_SCHEMA(config) - value_template = config.get(CONF_VALUE_TEMPLATE) + value_template = cast(Template, config.get(CONF_VALUE_TEMPLATE)) - def template_if(hass, variables=None): + def template_if(hass: HomeAssistant, + variables: TemplateVarsType = None) -> bool: """Validate template based if-condition.""" value_template.hass = hass @@ -321,7 +357,9 @@ def async_template_from_config(config, config_validation=True): template_from_config = _threaded_factory(async_template_from_config) -def time(before=None, after=None, weekday=None): +def time(before: Optional[dt_util.dt.time] = None, + after: Optional[dt_util.dt.time] = None, + weekday: Union[None, str, Container[str]] = None) -> bool: """Test if local time condition matches. Handle the fact that time is continuous and we may be testing for @@ -354,7 +392,8 @@ def time(before=None, after=None, weekday=None): return True -def time_from_config(config, config_validation=True): +def time_from_config(config: ConfigType, + config_validation: bool = True) -> Callable[..., bool]: """Wrap action method with time based condition.""" if config_validation: config = cv.TIME_CONDITION_SCHEMA(config) @@ -362,14 +401,16 @@ def time_from_config(config, config_validation=True): after = config.get(CONF_AFTER) weekday = config.get(CONF_WEEKDAY) - def time_if(hass, variables=None): + def time_if(hass: HomeAssistant, + variables: TemplateVarsType = None) -> bool: """Validate time based if-condition.""" return time(before, after, weekday) return time_if -def zone(hass, zone_ent, entity): +def zone(hass: HomeAssistant, zone_ent: Union[None, str, State], + entity: Union[None, str, State]) -> bool: """Test if zone-condition matches. Async friendly. @@ -396,14 +437,16 @@ def zone(hass, zone_ent, entity): entity.attributes.get(ATTR_GPS_ACCURACY, 0)) -def zone_from_config(config, config_validation=True): +def zone_from_config(config: ConfigType, + config_validation: bool = True) -> Callable[..., bool]: """Wrap action method with zone based condition.""" if config_validation: config = cv.ZONE_CONDITION_SCHEMA(config) entity_id = config.get(CONF_ENTITY_ID) zone_entity_id = config.get(CONF_ZONE) - def if_in_zone(hass, variables=None): + def if_in_zone(hass: HomeAssistant, + variables: TemplateVarsType = None) -> bool: """Test if condition.""" return zone(hass, zone_entity_id, entity_id) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 56d64cd8fd9..b148a875398 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_ALIAS, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, WEEKDAYS, CONF_CONDITION, CONF_BELOW, CONF_ABOVE, CONF_TIMEOUT, SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, - ENTITY_MATCH_ALL) + ENTITY_MATCH_ALL, CONF_ENTITY_NAMESPACE) from homeassistant.core import valid_entity_id, split_entity_id from homeassistant.exceptions import TemplateError import homeassistant.util.dt as dt_util @@ -554,9 +554,20 @@ def key_dependency(key, dependency): PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): string, + vol.Optional(CONF_ENTITY_NAMESPACE): string, vol.Optional(CONF_SCAN_INTERVAL): time_period }, extra=vol.ALLOW_EXTRA) +# This will replace PLATFORM_SCHEMA once all base components are updated +PLATFORM_SCHEMA_2 = vol.Schema({ + vol.Required(CONF_PLATFORM): string, + vol.Optional(CONF_ENTITY_NAMESPACE): string, + vol.Optional(CONF_SCAN_INTERVAL): time_period +}) + +PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA_2.extend({ +}, extra=vol.ALLOW_EXTRA) + EVENT_SCHEMA = vol.Schema({ vol.Optional(CONF_ALIAS): string, vol.Required('event'): string, diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index ce3700ea174..83827cca235 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -36,6 +36,7 @@ class DeviceEntry: name = attr.ib(type=str, default=None) sw_version = attr.ib(type=str, default=None) hub_device_id = attr.ib(type=str, default=None) + area_id = attr.ib(type=str, default=None) id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) @@ -119,9 +120,14 @@ class DeviceRegistry: manufacturer=manufacturer, model=model, name=name, - sw_version=sw_version, + sw_version=sw_version ) + @callback + def async_update_device(self, device_id, *, area_id=_UNDEF): + """Update properties of a device.""" + return self._async_update_device(device_id, area_id=area_id) + @callback def _async_update_device(self, device_id, *, add_config_entry_id=_UNDEF, remove_config_entry_id=_UNDEF, @@ -131,7 +137,8 @@ class DeviceRegistry: model=_UNDEF, name=_UNDEF, sw_version=_UNDEF, - hub_device_id=_UNDEF): + hub_device_id=_UNDEF, + area_id=_UNDEF): """Update device attributes.""" old = self.devices[device_id] @@ -169,6 +176,9 @@ class DeviceRegistry: if value is not _UNDEF and value != getattr(old, attr_name): changes[attr_name] = value + if (area_id is not _UNDEF and area_id != old.area_id): + changes['area_id'] = area_id + if not changes: return old @@ -197,6 +207,8 @@ class DeviceRegistry: id=device['id'], # Introduced in 0.79 hub_device_id=device.get('hub_device_id'), + # Introduced in 0.87 + area_id=device.get('area_id') ) self.devices = devices @@ -222,6 +234,7 @@ class DeviceRegistry: 'sw_version': entry.sw_version, 'id': entry.id, 'hub_device_id': entry.hub_device_id, + 'area_id': entry.area_id } for entry in self.devices.values() ] @@ -235,6 +248,13 @@ class DeviceRegistry: self._async_update_device( dev_id, remove_config_entry_id=config_entry_id) + @callback + def async_clear_area_id(self, area_id: str) -> None: + """Clear area id from registry entries.""" + for dev_id, device in self.devices.items(): + if area_id == device.area_id: + self._async_update_device(dev_id, area_id=None) + @bind_hass async def async_get_registry(hass) -> DeviceRegistry: diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index a28cd3d6392..ec07984f901 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -5,6 +5,7 @@ from typing import Any, Callable from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.util.async_ import run_callback_threadsafe +from homeassistant.util.logging import catch_log_exception from .typing import HomeAssistantType @@ -40,13 +41,18 @@ def async_dispatcher_connect(hass: HomeAssistantType, signal: str, if signal not in hass.data[DATA_DISPATCHER]: hass.data[DATA_DISPATCHER][signal] = [] - hass.data[DATA_DISPATCHER][signal].append(target) + wrapped_target = catch_log_exception( + target, lambda *args: + "Exception in {} when dispatching '{}': {}".format( + target.__name__, signal, args)) + + hass.data[DATA_DISPATCHER][signal].append(wrapped_target) @callback def async_remove_dispatcher() -> None: """Remove signal listener.""" try: - hass.data[DATA_DISPATCHER][signal].remove(target) + hass.data[DATA_DISPATCHER][signal].remove(wrapped_target) except (KeyError, ValueError): # KeyError is key target listener did not exist # ValueError if listener did not exist within signal diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 21634121cd2..44213e6d7c8 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -169,7 +169,8 @@ class EntityComponent: self.logger.warning( 'Not passing an entity ID to a service to target all ' 'entities is deprecated. Update your call to %s.%s to be ' - 'instead: entity_id: "*"', service.domain, service.service) + 'instead: entity_id: %s', service.domain, service.service, + ENTITY_MATCH_ALL) return [entity for entity in self.entities if entity.available] @@ -182,8 +183,9 @@ class EntityComponent: """Register an entity service.""" async def handle_service(call): """Handle the service.""" + service_name = "{}.{}".format(self.domain, name) await self.hass.helpers.service.entity_service_call( - self._platforms.values(), func, call + self._platforms.values(), func, call, service_name ) self.hass.services.async_register( diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 82530708838..6ee32f642bc 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -127,8 +127,8 @@ class EntityRegistry: device_id=device_id, # When we changed our slugify algorithm, we invalidated some # stored entity IDs with either a __ or ending in _. - # Fix introduced in 0.86 (Jan 23, 2018). Next line can be - # removed when we release 1.0 or in 2019. + # Fix introduced in 0.86 (Jan 23, 2019). Next line can be + # removed when we release 1.0 or in 2020. new_entity_id='.'.join(slugify(part) for part in entity_id.split('.', 1))) @@ -149,6 +149,12 @@ class EntityRegistry: self.async_schedule_save() return entity + @callback + def async_remove(self, entity_id): + """Remove an entity from registry.""" + self.entities.pop(entity_id) + self.async_schedule_save() + @callback def async_update_entity(self, entity_id, *, name=_UNDEF, new_entity_id=_UNDEF): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 2b7638b55ee..d2211d031f5 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -187,7 +187,7 @@ async def async_get_all_descriptions(hass): @bind_hass -async def entity_service_call(hass, platforms, func, call): +async def entity_service_call(hass, platforms, func, call, service_name=''): """Handle an entity service call. Calls all platforms simultaneously. @@ -204,9 +204,11 @@ async def entity_service_call(hass, platforms, func, call): if ATTR_ENTITY_ID in call.data: target_all_entities = call.data[ATTR_ENTITY_ID] == ENTITY_MATCH_ALL else: + # Remove the service_name parameter along with this warning _LOGGER.warning( - 'Not passing an entity ID to a service to target all entities is ' - 'deprecated. Use instead: entity_id: "%s"', ENTITY_MATCH_ALL) + 'Not passing an entity ID to a service to target all ' + 'entities is deprecated. Update your call to %s to be ' + 'instead: entity_id: %s', service_name, ENTITY_MATCH_ALL) target_all_entities = True if not target_all_entities: diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py new file mode 100644 index 00000000000..14cf1ff230c --- /dev/null +++ b/homeassistant/helpers/system_info.py @@ -0,0 +1,36 @@ +"""Helper to gather system info.""" +import os +import platform +from typing import Dict + +from homeassistant.const import __version__ as current_version +from homeassistant.loader import bind_hass +from homeassistant.util.package import is_virtual_env +from .typing import HomeAssistantType + + +@bind_hass +async def async_get_system_info(hass: HomeAssistantType) -> Dict: + """Return info about the system.""" + info_object = { + 'version': current_version, + 'dev': 'dev' in current_version, + 'hassio': hass.components.hassio.is_hassio(), + 'virtualenv': is_virtual_env(), + 'python_version': platform.python_version(), + 'docker': False, + 'arch': platform.machine(), + 'timezone': str(hass.config.time_zone), + 'os_name': platform.system(), + } + + if platform.system() == 'Windows': + info_object['os_version'] = platform.win32_ver()[0] + elif platform.system() == 'Darwin': + info_object['os_version'] = platform.mac_ver()[0] + elif platform.system() == 'FreeBSD': + info_object['os_version'] = platform.release() + elif platform.system() == 'Linux': + info_object['docker'] = os.path.isfile('/.dockerenv') + + return info_object diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index d8bbcbc6e12..03ae37843d8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -18,6 +18,7 @@ from homeassistant.const import ( from homeassistant.core import State, valid_entity_id from homeassistant.exceptions import TemplateError from homeassistant.helpers import location as loc_helper +from homeassistant.helpers.typing import TemplateVarsType from homeassistant.loader import bind_hass from homeassistant.util import convert from homeassistant.util import dt as dt_util @@ -115,7 +116,7 @@ class Template: """Extract all entities for state_changed listener.""" return extract_entities(self.template, variables) - def render(self, variables=None, **kwargs): + def render(self, variables: TemplateVarsType = None, **kwargs): """Render given template.""" if variables is not None: kwargs.update(variables) @@ -123,7 +124,8 @@ class Template: return run_callback_threadsafe( self.hass.loop, self.async_render, kwargs).result() - def async_render(self, variables=None, **kwargs): + def async_render(self, variables: TemplateVarsType = None, + **kwargs) -> str: """Render given template. This method must be run in the event loop. diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index 3919d896fd1..91b49283be8 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -1,5 +1,5 @@ """Typing Helpers for Home Assistant.""" -from typing import Dict, Any, Tuple +from typing import Dict, Any, Tuple, Optional import homeassistant.core @@ -9,6 +9,7 @@ GPSType = Tuple[float, float] ConfigType = Dict[str, Any] HomeAssistantType = homeassistant.core.HomeAssistant ServiceDataType = Dict[str, Any] +TemplateVarsType = Optional[Dict[str, Any]] # Custom type for recorder Queries QueryType = Any diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 06577be4763..b9443c287a1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,5 +1,5 @@ aiohttp==3.5.4 -astral==1.7.1 +astral==1.8 async_timeout==3.0.1 attrs==18.2.0 bcrypt==3.1.5 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 50463c28bd1..67bc97da992 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -345,14 +345,21 @@ def check_ha_config_file(hass): _comp_error(ex, domain, config) continue - if not hasattr(component, 'PLATFORM_SCHEMA'): + if (not hasattr(component, 'PLATFORM_SCHEMA') and + not hasattr(component, 'PLATFORM_SCHEMA_BASE')): continue platforms = [] for p_name, p_config in config_per_platform(config, domain): # Validate component specific platform schema try: - p_validated = component.PLATFORM_SCHEMA(p_config) + if hasattr(component, 'PLATFORM_SCHEMA_BASE'): + p_validated = \ + component.PLATFORM_SCHEMA_BASE( # type: ignore + p_config) + else: + p_validated = component.PLATFORM_SCHEMA( # type: ignore + p_config) except vol.Invalid as ex: _comp_error(ex, domain, config) continue diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index d648ed43110..16fea129573 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -3,7 +3,6 @@ import json from urllib.parse import parse_qsl from typing import Any, Dict, Optional -from aiohttp import web from multidict import CIMultiDict, MultiDict @@ -42,12 +41,3 @@ class MockRequest: async def text(self) -> str: """Return the body as text.""" return self._text - - -def serialize_response(response: web.Response) -> Dict[str, Any]: - """Serialize an aiohttp response to a dictionary.""" - return { - 'status': response.status, - 'body': response.body, - 'headers': dict(response.headers), - } diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index ae32566c73c..214d9417e2a 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -1,7 +1,7 @@ """Logging utilities.""" import asyncio from asyncio.events import AbstractEventLoop -from functools import wraps +from functools import partial, wraps import inspect import logging import threading @@ -139,8 +139,13 @@ def catch_log_exception( friendly_msg = format_err(*args) logging.getLogger(module_name).error('%s\n%s', friendly_msg, exc_msg) + # Check for partials to properly determine if coroutine function + check_func = func + while isinstance(check_func, partial): + check_func = check_func.func + wrapper_func = None - if asyncio.iscoroutinefunction(func): + if asyncio.iscoroutinefunction(check_func): @wraps(func) async def async_wrapper(*args: Any) -> None: """Catch and log exception.""" diff --git a/requirements_all.txt b/requirements_all.txt index 441d0490fb3..39015bfb972 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,6 +1,6 @@ # Home Assistant core aiohttp==3.5.4 -astral==1.7.1 +astral==1.8 async_timeout==3.0.1 attrs==18.2.0 bcrypt==3.1.5 @@ -39,7 +39,7 @@ HAP-python==2.4.2 Mastodon.py==1.3.1 # homeassistant.components.isy994 -PyISY==1.1.0 +PyISY==1.1.1 # homeassistant.components.sensor.mvglive PyMVGLive==1.1.4 @@ -72,7 +72,7 @@ RtmAPI==0.7.0 TravisPy==0.3.5 # homeassistant.components.notify.twitter -TwitterAPI==2.5.8 +TwitterAPI==2.5.9 # homeassistant.components.sensor.waze_travel_time WazeRouteCalculator==0.6 @@ -86,8 +86,11 @@ abodepy==0.15.0 # homeassistant.components.media_player.frontier_silicon afsapi==0.0.4 +# homeassistant.components.ambient_station +aioambient==0.1.0 + # homeassistant.components.asuswrt -aioasuswrt==1.1.18 +aioasuswrt==1.1.20 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 @@ -112,7 +115,10 @@ aioharmony==0.1.5 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==1.8.0 +aiohue==1.9.0 + +# homeassistant.components.sensor.iliad_italy +aioiliad==0.1.1 # homeassistant.components.sensor.imap aioimaplib==0.7.13 @@ -138,9 +144,6 @@ alarmdecoder==1.13.2 # homeassistant.components.sensor.alpha_vantage alpha_vantage==2.1.0 -# homeassistant.components.sensor.ambient_station -ambient_api==1.5.2 - # homeassistant.components.amcrest amcrest==1.2.3 @@ -164,7 +167,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.upnp # homeassistant.components.media_player.dlna_dmr -async-upnp-client==0.13.8 +async-upnp-client==0.14.4 # homeassistant.components.light.avion # avion==0.10 @@ -196,7 +199,7 @@ bellows==0.7.0 bimmer_connected==0.5.3 # homeassistant.components.blink -blinkpy==0.11.2 +blinkpy==0.12.1 # homeassistant.components.light.blinksticklight blinkstick==1.1.8 @@ -255,6 +258,9 @@ caldav==0.5.0 # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 +# homeassistant.components.sensor.co2signal +co2signal==0.4.1 + # homeassistant.components.coinbase coinbase==2.1.0 @@ -329,7 +335,7 @@ dlipower==0.7.165 # homeassistant.components.doorbird doorbirdpy==2.0.6 -# homeassistant.components.sensor.dovado +# homeassistant.components.dovado dovado==0.4.1 # homeassistant.components.sensor.dsmr @@ -339,9 +345,15 @@ dsmr_parser==0.12 # homeassistant.components.sensor.dweet dweepy==0.3.0 +# homeassistant.components.ecoal_boiler +ecoaliface==0.4.0 + # homeassistant.components.edp_redy edp_redy==0.0.3 +# homeassistant.components.device_tracker.ee_brightbox +eebrightbox==0.0.4 + # homeassistant.components.media_player.horizon einder==0.3.1 @@ -352,7 +364,7 @@ eliqonline==1.2.2 elkm1-lib==0.7.13 # homeassistant.components.emulated_roku -emulated_roku==0.1.7 +emulated_roku==0.1.8 # homeassistant.components.enocean enocean==0.40 @@ -475,7 +487,7 @@ greenwavereality==0.5.1 gstreamer-player==1.1.2 # homeassistant.components.ffmpeg -ha-ffmpeg==1.9 +ha-ffmpeg==1.11 # homeassistant.components.media_player.philips_js ha-philipsjs==0.0.5 @@ -514,16 +526,16 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190121.1 +home-assistant-frontend==20190203.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 # homeassistant.components.homekit_controller -# homekit==0.12.0 +homekit==0.12.2 # homeassistant.components.homematicip_cloud -homematicip==0.10.3 +homematicip==0.10.4 # homeassistant.components.google # homeassistant.components.remember_the_milk @@ -671,7 +683,7 @@ mficlient==0.3.0 miflora==0.4.0 # homeassistant.components.climate.mill -millheater==0.3.3 +millheater==0.3.4 # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 @@ -715,6 +727,9 @@ neurio==0.3.1 # homeassistant.components.light.niko_home_control niko-home-control==0.1.8 +# homeassistant.components.air_quality.nilu +niluclient==0.1.2 + # homeassistant.components.sensor.nederlandse_spoorwegen nsapi==2.7.4 @@ -728,7 +743,7 @@ nuheat==0.3.0 # homeassistant.components.image_processing.opencv # homeassistant.components.image_processing.tensorflow # homeassistant.components.sensor.pollen -numpy==1.15.4 +numpy==1.16.0 # homeassistant.components.google oauth2client==4.0.0 @@ -789,6 +804,7 @@ piglow==1.2.4 pilight==0.1.1 # homeassistant.components.camera.proxy +# homeassistant.components.image_processing.qrcode # homeassistant.components.image_processing.tensorflow pillow==5.4.1 @@ -828,7 +844,7 @@ prometheus_client==0.2.0 protobuf==3.6.1 # homeassistant.components.sensor.systemmonitor -psutil==5.4.8 +psutil==5.5.0 # homeassistant.components.wink pubnubsub-handler==1.0.3 @@ -878,7 +894,7 @@ pyRFXtrx==0.23 pySwitchmate==0.4.4 # homeassistant.components.tibber -pyTibber==0.9.0 +pyTibber==0.9.4 # homeassistant.components.switch.dlink pyW215==0.6.0 @@ -947,6 +963,9 @@ pycsspeechtts==1.0.2 # homeassistant.components.daikin pydaikin==0.9 +# homeassistant.components.danfoss_air +pydanfossair==0.0.6 + # homeassistant.components.deconz pydeconz==47 @@ -980,6 +999,9 @@ pyenvisalink==3.8 # homeassistant.components.climate.ephember pyephember==0.2.0 +# homeassistant.components.light.everlights +pyeverlights==0.1.0 + # homeassistant.components.sensor.fido pyfido==2.1.1 @@ -1024,7 +1046,7 @@ pyhik==0.1.9 pyhiveapi==0.2.17 # homeassistant.components.homematic -pyhomematic==0.1.54 +pyhomematic==0.1.55 # homeassistant.components.homeworks pyhomeworks==0.0.6 @@ -1100,7 +1122,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==1.3.1 +pymodbus==1.5.2 # homeassistant.components.media_player.monoprice pymonoprice==0.3 @@ -1109,7 +1131,7 @@ pymonoprice==0.3 pymusiccast==0.1.6 # homeassistant.components.cover.myq -pymyq==1.0.0 +pymyq==1.1.0 # homeassistant.components.mysensors pymysensors==0.18.0 @@ -1202,6 +1224,12 @@ pysher==1.0.1 # homeassistant.components.sensor.sma pysma==0.3.1 +# homeassistant.components.smartthings +pysmartapp==0.3.0 + +# homeassistant.components.smartthings +pysmartthings==0.4.2 + # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp # homeassistant.components.switch.snmp @@ -1290,7 +1318,7 @@ python-mpd2==1.0.0 python-mystrom==0.4.4 # homeassistant.components.nest -python-nest==4.0.5 +python-nest==4.1.0 # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.1 @@ -1332,7 +1360,7 @@ python-velbus==2.0.21 python-vlc==1.1.2 # homeassistant.components.wink -python-wink==1.10.1 +python-wink==1.10.3 # homeassistant.components.sensor.awair python_awair==0.0.3 @@ -1397,6 +1425,9 @@ pyxeoma==1.4.0 # homeassistant.components.zabbix pyzabbix==0.7.4 +# homeassistant.components.image_processing.qrcode +pyzbar==0.1.7 + # homeassistant.components.sensor.qnap qnapstats==0.2.7 @@ -1418,6 +1449,9 @@ raincloudy==0.0.5 # homeassistant.components.switch.raspyrfm raspyrfm-client==1.2.8 +# homeassistant.components.sensor.recollect_waste +recollect-waste==1.0.1 + # homeassistant.components.rainmachine regenmaschine==1.1.0 @@ -1442,6 +1476,9 @@ rocketchat-API==0.6.1 # homeassistant.components.vacuum.roomba roombapy==1.3.1 +# homeassistant.components.sensor.rova +rova==0.0.2 + # homeassistant.components.switch.rpi_rf # rpi-rf==0.9.7 @@ -1474,7 +1511,7 @@ sendgrid==5.6.0 sense-hat==2.2.0 # homeassistant.components.sense -sense_energy==0.5.1 +sense_energy==0.6.0 # homeassistant.components.media_player.aquostv sharp_aquos_rc==0.3.2 @@ -1543,7 +1580,7 @@ spotipy-homeassistant==2.4.4.dev1 # homeassistant.components.recorder # homeassistant.components.sensor.sql -sqlalchemy==1.2.15 +sqlalchemy==1.2.16 # homeassistant.components.sensor.srp_energy srpenergy==1.0.5 @@ -1572,6 +1609,9 @@ suds-py3==1.3.3.0 # homeassistant.components.sensor.swiss_hydrological_data swisshydrodata==0.0.3 +# homeassistant.components.device_tracker.synology_srm +synology-srm==0.0.3 + # homeassistant.components.tahoma tahoma-api==0.0.14 @@ -1588,7 +1628,7 @@ tellcore-net==0.4 tellcore-py==1.1.2 # homeassistant.components.tellduslive -tellduslive==0.10.8 +tellduslive==0.10.10 # homeassistant.components.media_player.lg_soundbar temescal==0.1 @@ -1623,8 +1663,7 @@ tp-connected==0.0.4 # homeassistant.components.device_tracker.tplink tplink==0.2.1 -# homeassistant.components.sensor.transmission -# homeassistant.components.switch.transmission +# homeassistant.components.transmission transmissionrpc==0.11 # homeassistant.components.tuya @@ -1726,7 +1765,7 @@ yeelight==0.4.3 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2019.01.10 +youtube_dl==2019.01.24 # homeassistant.components.light.zengge zengge==0.2 @@ -1753,4 +1792,4 @@ zigpy-xbee==0.1.1 zigpy==0.2.0 # homeassistant.components.zoneminder -zm-py==0.3.0 +zm-py==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edcd1d101aa..ec1ece765f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -30,6 +30,9 @@ PyTransportNSW==0.1.1 # homeassistant.components.notify.yessssms YesssSMS==0.2.3 +# homeassistant.components.ambient_station +aioambient==0.1.0 + # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 @@ -38,7 +41,7 @@ aioautomatic==0.6.5 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==1.8.0 +aiohue==1.9.0 # homeassistant.components.unifi aiounifi==4 @@ -61,8 +64,11 @@ defusedxml==0.5.0 # homeassistant.components.sensor.dsmr dsmr_parser==0.12 +# homeassistant.components.device_tracker.ee_brightbox +eebrightbox==0.0.4 + # homeassistant.components.emulated_roku -emulated_roku==0.1.7 +emulated_roku==0.1.8 # homeassistant.components.sensor.entur_public_transport enturclient==0.1.3 @@ -92,7 +98,7 @@ geojson_client==0.3 georss_client==0.5 # homeassistant.components.ffmpeg -ha-ffmpeg==1.9 +ha-ffmpeg==1.11 # homeassistant.components.hangouts hangups==0.4.6 @@ -107,10 +113,13 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190121.1 +home-assistant-frontend==20190203.0 + +# homeassistant.components.homekit_controller +homekit==0.12.2 # homeassistant.components.homematicip_cloud -homematicip==0.10.3 +homematicip==0.10.4 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -139,7 +148,7 @@ mficlient==0.3.0 # homeassistant.components.image_processing.opencv # homeassistant.components.image_processing.tensorflow # homeassistant.components.sensor.pollen -numpy==1.15.4 +numpy==1.16.0 # homeassistant.components.mqtt # homeassistant.components.shiftr @@ -178,7 +187,7 @@ pydeconz==47 pydispatcher==2.0.5 # homeassistant.components.homematic -pyhomematic==0.1.54 +pyhomematic==0.1.55 # homeassistant.components.litejet pylitejet==0.1 @@ -201,6 +210,12 @@ pyotp==2.2.6 # homeassistant.components.qwikswitch pyqwikswitch==0.8 +# homeassistant.components.smartthings +pysmartapp==0.3.0 + +# homeassistant.components.smartthings +pysmartthings==0.4.2 + # homeassistant.components.sonos pysonos==0.0.6 @@ -212,7 +227,7 @@ pyspcwebgw==0.4.0 python-forecastio==1.4.0 # homeassistant.components.nest -python-nest==4.0.5 +python-nest==4.1.0 # homeassistant.components.sensor.awair python_awair==0.0.3 @@ -258,7 +273,7 @@ somecomfort==0.5.2 # homeassistant.components.recorder # homeassistant.components.sensor.sql -sqlalchemy==1.2.15 +sqlalchemy==1.2.16 # homeassistant.components.sensor.srp_energy srpenergy==1.0.5 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e351c7b022b..398b2791848 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -32,11 +32,11 @@ COMMENT_REQUIREMENTS = ( 'i2csense', 'credstash', 'bme680', - 'homekit', 'py_noaa', ) TEST_REQUIREMENTS = ( + 'aioambient', 'aioautomatic', 'aiohttp_cors', 'aiohue', @@ -46,6 +46,7 @@ TEST_REQUIREMENTS = ( 'coinmarketcap', 'defusedxml', 'dsmr_parser', + 'eebrightbox', 'emulated_roku', 'enturclient', 'ephem', @@ -63,6 +64,7 @@ TEST_REQUIREMENTS = ( 'hdate', 'holidays', 'home-assistant-frontend', + 'homekit', 'homematicip', 'influxdb', 'jsonpath', @@ -88,6 +90,8 @@ TEST_REQUIREMENTS = ( 'pynx584', 'pyopenuv', 'pyotp', + 'pysmartapp', + 'pysmartthings', 'pysonos', 'pyqwikswitch', 'PyRMVtransport', diff --git a/setup.py b/setup.py index d8c2c57b3d3..0ceaa7d55b3 100755 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ PACKAGES = find_packages(exclude=['tests', 'tests.*']) REQUIRES = [ 'aiohttp==3.5.4', - 'astral==1.7.1', + 'astral==1.8', 'async_timeout==3.0.1', 'attrs==18.2.0', 'bcrypt==3.1.5', diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index b654b42fb35..ffc4d67f21d 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -73,7 +73,6 @@ async def test_changing_password_raises_invalid_user(data, hass): async def test_adding_user(data, hass): """Test adding a user.""" data.add_auth('test-user', 'test-pass') - data.validate_login('test-user', 'test-pass') data.validate_login(' test-user ', 'test-pass') @@ -81,7 +80,7 @@ async def test_adding_user_duplicate_username(data, hass): """Test adding a user with duplicate username.""" data.add_auth('test-user', 'test-pass') with pytest.raises(hass_auth.InvalidUser): - data.add_auth('test-user ', 'other-pass') + data.add_auth('TEST-user ', 'other-pass') async def test_validating_password_invalid_password(data, hass): @@ -91,16 +90,22 @@ async def test_validating_password_invalid_password(data, hass): with pytest.raises(hass_auth.InvalidAuth): data.validate_login(' test-user ', 'invalid-pass') + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login('test-user', 'test-pass ') + + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login('test-user', 'Test-pass') + async def test_changing_password(data, hass): """Test adding a user.""" data.add_auth('test-user', 'test-pass') - data.change_password('test-user ', 'new-pass') + data.change_password('TEST-USER ', 'new-pass') with pytest.raises(hass_auth.InvalidAuth): data.validate_login('test-user', 'test-pass') - data.validate_login('test-user', 'new-pass') + data.validate_login('test-UsEr', 'new-pass') async def test_login_flow_validates(data, hass): @@ -122,18 +127,18 @@ async def test_login_flow_validates(data, hass): assert result['errors']['base'] == 'invalid_auth' result = await flow.async_step_init({ - 'username': 'test-user ', + 'username': 'TEST-user ', 'password': 'incorrect-pass', }) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['errors']['base'] == 'invalid_auth' result = await flow.async_step_init({ - 'username': 'test-user', + 'username': 'test-USER', 'password': 'test-pass', }) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result['data']['username'] == 'test-user' + assert result['data']['username'] == 'test-USER' async def test_saving_loading(data, hass): @@ -179,6 +184,9 @@ async def test_legacy_adding_user_duplicate_username(legacy_data, hass): legacy_data.add_auth('test-user', 'test-pass') with pytest.raises(hass_auth.InvalidUser): legacy_data.add_auth('test-user', 'other-pass') + # Not considered duplicate + legacy_data.add_auth('test-user ', 'test-pass') + legacy_data.add_auth('Test-user', 'test-pass') async def test_legacy_validating_password_invalid_password(legacy_data, hass): diff --git a/tests/common.py b/tests/common.py index d7b28b3039a..3642c5da6ec 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,35 +1,37 @@ """Test the helper method for writing tests.""" import asyncio -from collections import OrderedDict -from datetime import timedelta import functools as ft import json +import logging import os import sys -from unittest.mock import patch, MagicMock, Mock -from io import StringIO -import logging import threading -from contextlib import contextmanager -from homeassistant import auth, core as ha, config_entries +from collections import OrderedDict +from contextlib import contextmanager +from datetime import timedelta +from io import StringIO +from unittest.mock import MagicMock, Mock, patch + +import homeassistant.util.dt as date_util +import homeassistant.util.yaml as yaml + +from homeassistant import auth, config_entries, core as ha from homeassistant.auth import ( models as auth_models, auth_store, providers as auth_providers, permissions as auth_permissions) from homeassistant.auth.permissions import system_policies -from homeassistant.setup import setup_component, async_setup_component -from homeassistant.config import async_process_component_config -from homeassistant.helpers import ( - intent, entity, restore_state, entity_registry, - entity_platform, storage, device_registry) -from homeassistant.util.unit_system import METRIC_SYSTEM -import homeassistant.util.dt as date_util -import homeassistant.util.yaml as yaml -from homeassistant.const import ( - STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, EVENT_TIME_CHANGED, - EVENT_STATE_CHANGED, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, - ATTR_DISCOVERED, SERVER_PORT, EVENT_HOMEASSISTANT_CLOSE) from homeassistant.components import mqtt, recorder +from homeassistant.config import async_process_component_config +from homeassistant.const import ( + ATTR_DISCOVERED, ATTR_SERVICE, DEVICE_DEFAULT_NAME, + EVENT_HOMEASSISTANT_CLOSE, EVENT_PLATFORM_DISCOVERED, EVENT_STATE_CHANGED, + EVENT_TIME_CHANGED, SERVER_PORT, STATE_ON, STATE_OFF) +from homeassistant.helpers import ( + area_registry, device_registry, entity, entity_platform, entity_registry, + intent, restore_state, storage) +from homeassistant.setup import async_setup_component, setup_component +from homeassistant.util.unit_system import METRIC_SYSTEM from homeassistant.util.async_ import ( run_callback_threadsafe, run_coroutine_threadsafe) @@ -333,6 +335,19 @@ def mock_registry(hass, mock_entries=None): return registry +def mock_area_registry(hass, mock_entries=None): + """Mock the Area Registry.""" + registry = area_registry.AreaRegistry(hass) + registry.areas = mock_entries or OrderedDict() + + async def _get_reg(): + return registry + + hass.data[area_registry.DATA_REGISTRY] = \ + hass.loop.create_task(_get_reg()) + return registry + + def mock_device_registry(hass, mock_entries=None): """Mock the Device Registry.""" registry = device_registry.DeviceRegistry(hass) @@ -435,8 +450,8 @@ class MockModule: # pylint: disable=invalid-name def __init__(self, domain=None, dependencies=None, setup=None, requirements=None, config_schema=None, platform_schema=None, - async_setup=None, async_setup_entry=None, - async_unload_entry=None): + platform_schema_base=None, async_setup=None, + async_setup_entry=None, async_unload_entry=None): """Initialize the mock module.""" self.DOMAIN = domain self.DEPENDENCIES = dependencies or [] @@ -448,6 +463,9 @@ class MockModule: if platform_schema is not None: self.PLATFORM_SCHEMA = platform_schema + if platform_schema_base is not None: + self.PLATFORM_SCHEMA_BASE = platform_schema_base + if setup is not None: # We run this in executor, wrap it in function self.setup = lambda *args: setup(*args) @@ -872,3 +890,8 @@ async def flush_store(store): return await store._async_handle_write_data() + + +async def get_system_health_info(hass, domain): + """Get system health info.""" + return await hass.data['system_health']['info'][domain](hass) diff --git a/tests/components/ambient_station/__init__.py b/tests/components/ambient_station/__init__.py new file mode 100644 index 00000000000..1de98ab57bb --- /dev/null +++ b/tests/components/ambient_station/__init__.py @@ -0,0 +1 @@ +"""Define tests for the Ambient PWS component.""" diff --git a/tests/components/ambient_station/test_config_flow.py b/tests/components/ambient_station/test_config_flow.py new file mode 100644 index 00000000000..a988208e4a0 --- /dev/null +++ b/tests/components/ambient_station/test_config_flow.py @@ -0,0 +1,130 @@ +"""Define tests for the Ambient PWS config flow.""" +import json + +import aioambient +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.ambient_station import ( + CONF_APP_KEY, DOMAIN, config_flow) +from homeassistant.const import CONF_API_KEY + +from tests.common import ( + load_fixture, MockConfigEntry, MockDependency, mock_coro) + + +@pytest.fixture +def get_devices_response(): + """Define a fixture for a successful /devices response.""" + return mock_coro() + + +@pytest.fixture +def mock_aioambient(get_devices_response): + """Mock the aioambient library.""" + with MockDependency('aioambient') as mock_aioambient_: + mock_aioambient_.Client( + ).api.get_devices.return_value = get_devices_response + yield mock_aioambient_ + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_APP_KEY: 'identifier_exists'} + + +@pytest.mark.parametrize( + 'get_devices_response', + [mock_coro(exception=aioambient.errors.AmbientError)]) +async def test_invalid_api_key(hass, mock_aioambient): + """Test that an invalid API/App Key throws an error.""" + conf = { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {'base': 'invalid_key'} + + +@pytest.mark.parametrize('get_devices_response', [mock_coro(return_value=[])]) +async def test_no_devices(hass, mock_aioambient): + """Test that an account with no associated devices throws an error.""" + conf = { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {'base': 'no_devices'} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +@pytest.mark.parametrize( + 'get_devices_response', + [mock_coro(return_value=json.loads(load_fixture('ambient_devices.json')))]) +async def test_step_import(hass, mock_aioambient): + """Test that the import step works.""" + conf = { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_import(import_config=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '67890fghij67' + assert result['data'] == { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + +@pytest.mark.parametrize( + 'get_devices_response', + [mock_coro(return_value=json.loads(load_fixture('ambient_devices.json')))]) +async def test_step_user(hass, mock_aioambient): + """Test that the user step works.""" + conf = { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '67890fghij67' + assert result['data'] == { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } diff --git a/tests/components/automation/test_mqtt.py b/tests/components/automation/test_mqtt.py index 196fdaa9a6f..7d2fe5fa439 100644 --- a/tests/components/automation/test_mqtt.py +++ b/tests/components/automation/test_mqtt.py @@ -1,5 +1,6 @@ """The tests for the MQTT automation.""" import pytest +from unittest import mock from homeassistant.setup import async_setup_component import homeassistant.components.automation as automation @@ -92,3 +93,44 @@ async def test_if_not_fires_on_topic_but_no_payload_match(hass, calls): async_fire_mqtt_message(hass, 'test-topic', 'no-hello') await hass.async_block_till_done() assert 0 == len(calls) + + +async def test_encoding_default(hass, calls): + """Test default encoding.""" + mock_mqtt = await async_mock_mqtt_component(hass) + + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'mqtt', + 'topic': 'test-topic' + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + mock_mqtt.async_subscribe.assert_called_once_with( + 'test-topic', mock.ANY, 0, 'utf-8') + + +async def test_encoding_custom(hass, calls): + """Test default encoding.""" + mock_mqtt = await async_mock_mqtt_component(hass) + + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'mqtt', + 'topic': 'test-topic', + 'encoding': '' + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + mock_mqtt.async_subscribe.assert_called_once_with( + 'test-topic', mock.ANY, 0, None) diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py index a50a4d796aa..bdf9939cb2b 100644 --- a/tests/components/cloud/test_auth_api.py +++ b/tests/components/cloud/test_auth_api.py @@ -1,4 +1,5 @@ """Tests for the tools to communicate with the cloud.""" +import asyncio from unittest.mock import MagicMock, patch from botocore.exceptions import ClientError @@ -165,3 +166,31 @@ def test_check_token_raises(mock_cognito): assert cloud.id_token != mock_cognito.id_token assert cloud.access_token != mock_cognito.access_token assert len(cloud.write_user_info.mock_calls) == 0 + + +async def test_async_setup(hass): + """Test async setup.""" + cloud = MagicMock() + await auth_api.async_setup(hass, cloud) + assert len(cloud.iot.mock_calls) == 2 + on_connect = cloud.iot.mock_calls[0][1][0] + on_disconnect = cloud.iot.mock_calls[1][1][0] + + with patch('random.randint', return_value=0), patch( + 'homeassistant.components.cloud.auth_api.renew_access_token' + ) as mock_renew: + await on_connect() + # Let handle token sleep once + await asyncio.sleep(0) + # Let handle token refresh token + await asyncio.sleep(0) + + assert len(mock_renew.mock_calls) == 1 + assert mock_renew.mock_calls[0][1][0] is cloud + + await on_disconnect() + + # Make sure task is no longer being called + await asyncio.sleep(0) + await asyncio.sleep(0) + assert len(mock_renew.mock_calls) == 1 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 84d35f4bdd8..06de6bf0b59 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -111,6 +111,18 @@ def test_login_view(hass, cloud_client, mock_cognito): assert result_pass == 'my_password' +async def test_login_view_random_exception(cloud_client): + """Try logging in with invalid JSON.""" + with patch('async_timeout.timeout', side_effect=ValueError('Boom')): + req = await cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) + assert req.status == 502 + resp = await req.json() + assert resp == {'code': 'valueerror', 'message': 'Unexpected error: Boom'} + + @asyncio.coroutine def test_login_view_invalid_json(cloud_client): """Try logging in with invalid JSON.""" diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 1a528f8cedf..10a94f46833 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -10,9 +10,8 @@ from homeassistant.components.cloud import ( Cloud, iot, auth_api, MODE_DEV) from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) -from homeassistant.util import dt as dt_util from tests.components.alexa import test_smart_home as test_alexa -from tests.common import mock_coro, async_fire_time_changed +from tests.common import mock_coro from . import mock_cloud_prefs @@ -158,26 +157,6 @@ async def test_handling_core_messages_logout(hass, mock_cloud): assert len(mock_cloud.logout.mock_calls) == 1 -async def test_handling_core_messages_refresh_auth(hass, mock_cloud): - """Test handling core messages.""" - mock_cloud.hass = hass - with patch('random.randint', return_value=0) as mock_rand, patch( - 'homeassistant.components.cloud.auth_api.check_token' - ) as mock_check: - await iot.async_handle_cloud(hass, mock_cloud, { - 'action': 'refresh_auth', - 'seconds': 230, - }) - async_fire_time_changed(hass, dt_util.utcnow()) - await hass.async_block_till_done() - - assert len(mock_rand.mock_calls) == 1 - assert mock_rand.mock_calls[0][1] == (0, 230) - - assert len(mock_check.mock_calls) == 1 - assert mock_check.mock_calls[0][1][0] is mock_cloud - - @asyncio.coroutine def test_cloud_getting_disconnected_by_server(mock_client, caplog, mock_cloud): """Test server disconnecting instance.""" diff --git a/tests/components/cloud/test_utils.py b/tests/components/cloud/test_utils.py new file mode 100644 index 00000000000..24de4ce6214 --- /dev/null +++ b/tests/components/cloud/test_utils.py @@ -0,0 +1,24 @@ +"""Test aiohttp request helper.""" +from aiohttp import web + +from homeassistant.components.cloud import utils + + +def test_serialize_text(): + """Test serializing a text response.""" + response = web.Response(status=201, text='Hello') + assert utils.aiohttp_serialize_response(response) == { + 'status': 201, + 'body': 'Hello', + 'headers': {'Content-Type': 'text/plain; charset=utf-8'}, + } + + +def test_serialize_json(): + """Test serializing a JSON response.""" + response = web.json_response({"how": "what"}) + assert utils.aiohttp_serialize_response(response) == { + 'status': 200, + 'body': '{"how": "what"}', + 'headers': {'Content-Type': 'application/json; charset=utf-8'}, + } diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py new file mode 100644 index 00000000000..875cd1a2e3c --- /dev/null +++ b/tests/components/config/test_area_registry.py @@ -0,0 +1,155 @@ +"""Test area_registry API.""" +import pytest + +from homeassistant.components.config import area_registry +from tests.common import mock_area_registry + + +@pytest.fixture +def client(hass, hass_ws_client): + """Fixture that can interact with the config manager API.""" + hass.loop.run_until_complete(area_registry.async_setup(hass)) + yield hass.loop.run_until_complete(hass_ws_client(hass)) + + +@pytest.fixture +def registry(hass): + """Return an empty, loaded, registry.""" + return mock_area_registry(hass) + + +async def test_list_areas(hass, client, registry): + """Test list entries.""" + registry.async_create('mock 1') + registry.async_create('mock 2') + + await client.send_json({ + 'id': 1, + 'type': 'config/area_registry/list', + }) + + msg = await client.receive_json() + + assert len(msg['result']) == len(registry.areas) + + +async def test_create_area(hass, client, registry): + """Test create entry.""" + await client.send_json({ + 'id': 1, + 'name': "mock", + 'type': 'config/area_registry/create', + }) + + msg = await client.receive_json() + + assert 'mock' in msg['result']['name'] + assert len(registry.areas) == 1 + + +async def test_create_area_with_name_already_in_use(hass, client, registry): + """Test create entry that should fail.""" + registry.async_create('mock') + + await client.send_json({ + 'id': 1, + 'name': "mock", + 'type': 'config/area_registry/create', + }) + + msg = await client.receive_json() + + assert not msg['success'] + assert msg['error']['code'] == 'invalid_info' + assert msg['error']['message'] == "Name is already in use" + assert len(registry.areas) == 1 + + +async def test_delete_area(hass, client, registry): + """Test delete entry.""" + area = registry.async_create('mock') + + await client.send_json({ + 'id': 1, + 'area_id': area.id, + 'type': 'config/area_registry/delete', + }) + + msg = await client.receive_json() + + assert msg['success'] + assert not registry.areas + + +async def test_delete_non_existing_area(hass, client, registry): + """Test delete entry that should fail.""" + registry.async_create('mock') + + await client.send_json({ + 'id': 1, + 'area_id': '', + 'type': 'config/area_registry/delete', + }) + + msg = await client.receive_json() + + assert not msg['success'] + assert msg['error']['code'] == 'invalid_info' + assert msg['error']['message'] == "Area ID doesn't exist" + assert len(registry.areas) == 1 + + +async def test_update_area(hass, client, registry): + """Test update entry.""" + area = registry.async_create('mock 1') + + await client.send_json({ + 'id': 1, + 'area_id': area.id, + 'name': "mock 2", + 'type': 'config/area_registry/update', + }) + + msg = await client.receive_json() + + assert msg['result']['area_id'] == area.id + assert msg['result']['name'] == 'mock 2' + assert len(registry.areas) == 1 + + +async def test_update_area_with_same_name(hass, client, registry): + """Test update entry.""" + area = registry.async_create('mock 1') + + await client.send_json({ + 'id': 1, + 'area_id': area.id, + 'name': "mock 1", + 'type': 'config/area_registry/update', + }) + + msg = await client.receive_json() + + assert msg['result']['area_id'] == area.id + assert msg['result']['name'] == 'mock 1' + assert len(registry.areas) == 1 + + +async def test_update_area_with_name_already_in_use(hass, client, registry): + """Test update entry.""" + area = registry.async_create('mock 1') + registry.async_create('mock 2') + + await client.send_json({ + 'id': 1, + 'area_id': area.id, + 'name': "mock 2", + 'type': 'config/area_registry/update', + }) + + msg = await client.receive_json() + + assert not msg['success'] + assert msg['error']['code'] == 'invalid_info' + assert msg['error']['message'] == "Name is already in use" + assert len(registry.areas) == 2 diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 87eb0fb2d6f..aa1b9e4e2d4 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -1,4 +1,4 @@ -"""Test entity_registry API.""" +"""Test device_registry API.""" import pytest from homeassistant.components.config import device_registry @@ -48,6 +48,7 @@ async def test_list_devices(hass, client, registry): 'name': None, 'sw_version': None, 'hub_device_id': None, + 'area_id': None, }, { 'config_entries': ['1234'], @@ -57,5 +58,30 @@ async def test_list_devices(hass, client, registry): 'name': None, 'sw_version': None, 'hub_device_id': dev1, + 'area_id': None, } ] + + +async def test_update_device(hass, client, registry): + """Test update entry.""" + device = registry.async_get_or_create( + config_entry_id='1234', + connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('bridgeid', '0123')}, + manufacturer='manufacturer', model='model') + + assert not device.area_id + + await client.send_json({ + 'id': 1, + 'device_id': device.id, + 'area_id': '12345A', + 'type': 'config/device_registry/update', + }) + + msg = await client.receive_json() + + assert msg['result']['id'] == device.id + assert msg['result']['area_id'] == '12345A' + assert len(registry.devices) == 1 diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index cd74faf1843..26903bb256b 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -82,6 +82,10 @@ async def test_get_entity(hass, client): msg = await client.receive_json() assert msg['result'] == { + 'config_entry_id': None, + 'device_id': None, + 'disabled_by': None, + 'platform': 'test_platform', 'entity_id': 'test_domain.name', 'name': 'Hello World' } @@ -94,6 +98,10 @@ async def test_get_entity(hass, client): msg = await client.receive_json() assert msg['result'] == { + 'config_entry_id': None, + 'device_id': None, + 'disabled_by': None, + 'platform': 'test_platform', 'entity_id': 'test_domain.no_name', 'name': None } @@ -128,6 +136,10 @@ async def test_update_entity_name(hass, client): msg = await client.receive_json() assert msg['result'] == { + 'config_entry_id': None, + 'device_id': None, + 'disabled_by': None, + 'platform': 'test_platform', 'entity_id': 'test_domain.world', 'name': 'after update' } @@ -165,6 +177,10 @@ async def test_update_entity_no_changes(hass, client): msg = await client.receive_json() assert msg['result'] == { + 'config_entry_id': None, + 'device_id': None, + 'disabled_by': None, + 'platform': 'test_platform', 'entity_id': 'test_domain.world', 'name': 'name of entity' } @@ -224,9 +240,37 @@ async def test_update_entity_id(hass, client): msg = await client.receive_json() assert msg['result'] == { + 'config_entry_id': None, + 'device_id': None, + 'disabled_by': None, + 'platform': 'test_platform', 'entity_id': 'test_domain.planet', 'name': None } assert hass.states.get('test_domain.world') is None assert hass.states.get('test_domain.planet') is not None + + +async def test_remove_entity(hass, client): + """Test removing entity.""" + registry = mock_registry(hass, { + 'test_domain.world': RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + name='before update' + ) + }) + + await client.send_json({ + 'id': 6, + 'type': 'config/entity_registry/remove', + 'entity_id': 'test_domain.world', + }) + + msg = await client.receive_json() + + assert msg['success'] + assert len(registry.entities) == 0 diff --git a/tests/components/device_tracker/test_ee_brightbox.py b/tests/components/device_tracker/test_ee_brightbox.py new file mode 100644 index 00000000000..75609571e6c --- /dev/null +++ b/tests/components/device_tracker/test_ee_brightbox.py @@ -0,0 +1,122 @@ +"""Tests for the EE BrightBox device scanner.""" +from datetime import datetime + +from asynctest import patch +import pytest + +from homeassistant.components.device_tracker import DOMAIN +from homeassistant.const import ( + CONF_PASSWORD, CONF_PLATFORM) +from homeassistant.setup import async_setup_component + + +def _configure_mock_get_devices(eebrightbox_mock): + eebrightbox_instance = eebrightbox_mock.return_value + eebrightbox_instance.__enter__.return_value = eebrightbox_instance + eebrightbox_instance.get_devices.return_value = [ + { + 'mac': 'AA:BB:CC:DD:EE:FF', + 'ip': '192.168.1.10', + 'hostname': 'hostnameAA', + 'activity_ip': True, + 'port': 'eth0', + 'time_last_active': datetime(2019, 1, 20, 16, 4, 0), + }, + { + 'mac': '11:22:33:44:55:66', + 'hostname': 'hostname11', + 'ip': '192.168.1.11', + 'activity_ip': True, + 'port': 'wl0', + 'time_last_active': datetime(2019, 1, 20, 11, 9, 0), + }, + { + 'mac': 'FF:FF:FF:FF:FF:FF', + 'hostname': 'hostnameFF', + 'ip': '192.168.1.12', + 'activity_ip': False, + 'port': 'wl1', + 'time_last_active': datetime(2019, 1, 15, 16, 9, 0), + } + ] + + +def _configure_mock_failed_config_check(eebrightbox_mock): + from eebrightbox import EEBrightBoxException + eebrightbox_instance = eebrightbox_mock.return_value + eebrightbox_instance.__enter__.side_effect = EEBrightBoxException( + "Failed to connect to the router") + + +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf): + """Mock device tracker config loading.""" + pass + + +@patch('eebrightbox.EEBrightBox') +async def test_missing_credentials(eebrightbox_mock, hass): + """Test missing credentials.""" + _configure_mock_get_devices(eebrightbox_mock) + + result = await async_setup_component(hass, DOMAIN, { + DOMAIN: { + CONF_PLATFORM: 'ee_brightbox', + } + }) + + assert result + + await hass.async_block_till_done() + + assert hass.states.get('device_tracker.hostnameaa') is None + assert hass.states.get('device_tracker.hostname11') is None + assert hass.states.get('device_tracker.hostnameff') is None + + +@patch('eebrightbox.EEBrightBox') +async def test_invalid_credentials(eebrightbox_mock, hass): + """Test invalid credentials.""" + _configure_mock_failed_config_check(eebrightbox_mock) + + result = await async_setup_component(hass, DOMAIN, { + DOMAIN: { + CONF_PLATFORM: 'ee_brightbox', + CONF_PASSWORD: 'test_password', + } + }) + + assert result + + await hass.async_block_till_done() + + assert hass.states.get('device_tracker.hostnameaa') is None + assert hass.states.get('device_tracker.hostname11') is None + assert hass.states.get('device_tracker.hostnameff') is None + + +@patch('eebrightbox.EEBrightBox') +async def test_get_devices(eebrightbox_mock, hass): + """Test valid configuration.""" + _configure_mock_get_devices(eebrightbox_mock) + + result = await async_setup_component(hass, DOMAIN, { + DOMAIN: { + CONF_PLATFORM: 'ee_brightbox', + CONF_PASSWORD: 'test_password', + } + }) + + assert result + + await hass.async_block_till_done() + + assert hass.states.get('device_tracker.hostnameaa') is not None + assert hass.states.get('device_tracker.hostname11') is not None + assert hass.states.get('device_tracker.hostnameff') is None + + state = hass.states.get('device_tracker.hostnameaa') + assert state.attributes['mac'] == 'AA:BB:CC:DD:EE:FF' + assert state.attributes['ip'] == '192.168.1.10' + assert state.attributes['port'] == 'eth0' + assert state.attributes['last_active'] == datetime(2019, 1, 20, 16, 4, 0) diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 8582f5b38cf..70fe894debf 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -15,7 +15,7 @@ from homeassistant.components import ( from homeassistant.components.emulated_hue import Config from homeassistant.components.emulated_hue.hue_api import ( HUE_API_STATE_ON, HUE_API_STATE_BRI, HueUsernameView, HueOneLightStateView, - HueAllLightsStateView, HueOneLightChangeView) + HueAllLightsStateView, HueOneLightChangeView, HueAllGroupsStateView) from homeassistant.const import STATE_ON, STATE_OFF HTTP_SERVER_PORT = get_test_instance_port() @@ -135,6 +135,7 @@ def hue_client(loop, hass_hue, aiohttp_client): HueAllLightsStateView(config).register(web_app, web_app.router) HueOneLightStateView(config).register(web_app, web_app.router) HueOneLightChangeView(config).register(web_app, web_app.router) + HueAllGroupsStateView(config).register(web_app, web_app.router) return loop.run_until_complete(aiohttp_client(web_app)) @@ -420,6 +421,20 @@ def test_proper_put_state_request(hue_client): assert result.status == 400 +@asyncio.coroutine +def test_get_empty_groups_state(hue_client): + """Test the request to get groups endpoint.""" + # Test proper on value parsing + result = yield from hue_client.get( + '/api/username/groups') + + assert result.status == 200 + + result_json = yield from result.json() + + assert result_json == {} + + # pylint: disable=invalid-name async def perform_put_test_on_ceiling_lights(hass_hue, hue_client, content_type='application/json'): diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 920a2b81016..1361abf10de 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -27,7 +27,7 @@ class TestFanEntity(unittest.TestCase): def test_fanentity(self): """Test fan entity methods.""" - assert 'on' == self.fan.state + assert 'off' == self.fan.state assert 0 == len(self.fan.speed_list) assert 0 == self.fan.supported_features assert {'speed_list': []} == self.fan.state_attributes diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 9f386ceb904..b1b9a70d594 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -249,7 +249,7 @@ async def test_get_panels(hass, hass_ws_client): """Test get_panels command.""" await async_setup_component(hass, 'frontend') await hass.components.frontend.async_register_built_in_panel( - 'map', 'Map', 'mdi:account-location') + 'map', 'Map', 'mdi:tooltip-account') client = await hass_ws_client(hass) await client.send_json({ @@ -264,7 +264,7 @@ async def test_get_panels(hass, hass_ws_client): assert msg['success'] assert msg['result']['map']['component_name'] == 'map' assert msg['result']['map']['url_path'] == 'map' - assert msg['result']['map']['icon'] == 'mdi:account-location' + assert msg['result']['map']['icon'] == 'mdi:tooltip-account' assert msg['result']['map']['title'] == 'Map' diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index dbad7ba668b..41c232a51c3 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -5,14 +5,16 @@ from unittest.mock import patch, Mock import pytest from homeassistant import data_entry_flow -from homeassistant.components import zone +from homeassistant.components import zone, geofency from homeassistant.components.geofency import ( - CONF_MOBILE_BEACONS, DOMAIN) + CONF_MOBILE_BEACONS, DOMAIN, TRACKER_UPDATE) from homeassistant.const import ( HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, - STATE_NOT_HOME) + STATE_NOT_HOME, CONF_WEBHOOK_ID) +from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant.setup import async_setup_component from homeassistant.util import slugify +from tests.common import MockConfigEntry HOME_LATITUDE = 37.239622 HOME_LONGITUDE = -115.815811 @@ -281,3 +283,21 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id): state_name = hass.states.get('{}.{}'.format( 'device_tracker', device_name)).state assert STATE_HOME == state_name + + +@pytest.mark.xfail( + reason='The device_tracker component does not support unloading yet.' +) +async def test_load_unload_entry(hass, geofency_client): + """Test that the appropriate dispatch signals are added and removed.""" + entry = MockConfigEntry(domain=DOMAIN, data={ + CONF_WEBHOOK_ID: 'geofency_test' + }) + + await geofency.async_setup_entry(hass, entry) + await hass.async_block_till_done() + assert 1 == len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) + + await geofency.async_unload_entry(hass, entry) + await hass.async_block_till_done() + assert 0 == len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index cf818e54911..db1ae655c25 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -2,15 +2,17 @@ from unittest.mock import patch, Mock import pytest +from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant import data_entry_flow -from homeassistant.components import zone +from homeassistant.components import zone, gpslogger from homeassistant.components.device_tracker import \ DOMAIN as DEVICE_TRACKER_DOMAIN -from homeassistant.components.gpslogger import DOMAIN +from homeassistant.components.gpslogger import DOMAIN, TRACKER_UPDATE from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, \ - STATE_HOME, STATE_NOT_HOME + STATE_HOME, STATE_NOT_HOME, CONF_WEBHOOK_ID from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry HOME_LATITUDE = 37.239622 HOME_LONGITUDE = -115.815811 @@ -164,3 +166,21 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): assert 102.0 == state.attributes['altitude'] assert 'gps' == state.attributes['provider'] assert 'running' == state.attributes['activity'] + + +@pytest.mark.xfail( + reason='The device_tracker component does not support unloading yet.' +) +async def test_load_unload_entry(hass): + """Test that the appropriate dispatch signals are added and removed.""" + entry = MockConfigEntry(domain=DOMAIN, data={ + CONF_WEBHOOK_ID: 'gpslogger_test' + }) + + await gpslogger.async_setup_entry(hass, entry) + await hass.async_block_till_done() + assert 1 == len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) + + await gpslogger.async_unload_entry(hass, entry) + await hass.async_block_till_done() + assert 0 == len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py new file mode 100644 index 00000000000..d3c1f9ab07b --- /dev/null +++ b/tests/components/homekit_controller/common.py @@ -0,0 +1,174 @@ +"""Code to support homekit_controller tests.""" +from datetime import timedelta +from unittest import mock + +from homekit.model.services import AbstractService, ServicesTypes +from homekit.model.characteristics import ( + AbstractCharacteristic, CharacteristicPermissions, CharacteristicsTypes) +from homekit.model import Accessory, get_id + +from homeassistant.components.homekit_controller import ( + DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, SERVICE_HOMEKIT) +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util +from tests.common import async_fire_time_changed, fire_service_discovered + + +class FakePairing: + """ + A test fake that pretends to be a paired HomeKit accessory. + + This only contains methods and values that exist on the upstream Pairing + class. + """ + + def __init__(self, accessory): + """Create a fake pairing from an accessory model.""" + self.accessory = accessory + self.pairing_data = { + 'accessories': self.list_accessories_and_characteristics() + } + + def list_accessories_and_characteristics(self): + """Fake implementation of list_accessories_and_characteristics.""" + return [self.accessory.to_accessory_and_service_list()] + + def get_characteristics(self, characteristics): + """Fake implementation of get_characteristics.""" + results = {} + for aid, cid in characteristics: + for service in self.accessory.services: + for char in service.characteristics: + if char.iid != cid: + continue + results[(aid, cid)] = { + 'value': char.get_value() + } + return results + + def put_characteristics(self, characteristics): + """Fake implementation of put_characteristics.""" + for _, cid, new_val in characteristics: + for service in self.accessory.services: + for char in service.characteristics: + if char.iid != cid: + continue + char.set_value(new_val) + + +class FakeController: + """ + A test fake that pretends to be a paired HomeKit accessory. + + This only contains methods and values that exist on the upstream Controller + class. + """ + + def __init__(self): + """Create a Fake controller with no pairings.""" + self.pairings = {} + + def add(self, accessory): + """Create and register a fake pairing for a simulated accessory.""" + pairing = FakePairing(accessory) + self.pairings['00:00:00:00:00:00'] = pairing + return pairing + + +class Helper: + """Helper methods for interacting with HomeKit fakes.""" + + def __init__(self, hass, entity_id, pairing, accessory): + """Create a helper for a given accessory/entity.""" + self.hass = hass + self.entity_id = entity_id + self.pairing = pairing + self.accessory = accessory + + self.characteristics = {} + for service in self.accessory.services: + service_name = ServicesTypes.get_short(service.type) + for char in service.characteristics: + char_name = CharacteristicsTypes.get_short(char.type) + self.characteristics[(service_name, char_name)] = char + + async def poll_and_get_state(self): + """Trigger a time based poll and return the current entity state.""" + next_update = dt_util.utcnow() + timedelta(seconds=60) + async_fire_time_changed(self.hass, next_update) + await self.hass.async_block_till_done() + + state = self.hass.states.get(self.entity_id) + assert state is not None + return state + + +class FakeCharacteristic(AbstractCharacteristic): + """ + A model of a generic HomeKit characteristic. + + Base is abstract and can't be instanced directly so this subclass is + needed even though it doesn't add any methods. + """ + + pass + + +class FakeService(AbstractService): + """A model of a generic HomeKit service.""" + + def __init__(self, service_name): + """Create a fake service by its short form HAP spec name.""" + char_type = ServicesTypes.get_uuid(service_name) + super().__init__(char_type, get_id()) + + def add_characteristic(self, name): + """Add a characteristic to this service by name.""" + full_name = 'public.hap.characteristic.' + name + char = FakeCharacteristic(get_id(), full_name, None) + char.perms = [ + CharacteristicPermissions.paired_read, + CharacteristicPermissions.paired_write + ] + self.characteristics.append(char) + return char + + +async def setup_test_component(hass, services): + """Load a fake homekit accessory based on a homekit accessory model.""" + domain = None + for service in services: + service_name = ServicesTypes.get_short(service.type) + if service_name in HOMEKIT_ACCESSORY_DISPATCH: + domain = HOMEKIT_ACCESSORY_DISPATCH[service_name] + break + + assert domain, 'Cannot map test homekit services to homeassistant domain' + + config = { + 'discovery': { + } + } + + with mock.patch('homekit.Controller') as controller: + fake_controller = controller.return_value = FakeController() + await async_setup_component(hass, DOMAIN, config) + + accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') + accessory.services.extend(services) + pairing = fake_controller.add(accessory) + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + } + } + + fire_service_discovered(hass, SERVICE_HOMEKIT, discovery_info) + await hass.async_block_till_done() + + return Helper(hass, '.'.join((domain, 'testdevice')), pairing, accessory) diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py new file mode 100644 index 00000000000..fe6cffdc09f --- /dev/null +++ b/tests/components/homekit_controller/conftest.py @@ -0,0 +1,14 @@ +"""HomeKit controller session fixtures.""" +import datetime +from unittest import mock + +import pytest + + +@pytest.fixture +def utcnow(request): + """Freeze time at a known point.""" + start_dt = datetime.datetime(2019, 1, 1, 0, 0, 0) + with mock.patch('homeassistant.util.dt.utcnow') as dt_utcnow: + dt_utcnow.return_value = start_dt + yield dt_utcnow diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py new file mode 100644 index 00000000000..0164da5200f --- /dev/null +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -0,0 +1,79 @@ +"""Basic checks for HomeKitalarm_control_panel.""" +from tests.components.homekit_controller.common import ( + FakeService, setup_test_component) + +CURRENT_STATE = ('security-system', 'security-system-state.current') +TARGET_STATE = ('security-system', 'security-system-state.target') + + +def create_security_system_service(): + """Define a security-system characteristics as per page 219 of HAP spec.""" + service = FakeService('public.hap.service.security-system') + + cur_state = service.add_characteristic('security-system-state.current') + cur_state.value = 0 + + targ_state = service.add_characteristic('security-system-state.target') + targ_state.value = 0 + + # According to the spec, a battery-level characteristic is normally + # part of a seperate service. However as the code was written (which + # predates this test) the battery level would have to be part of the lock + # service as it is here. + targ_state = service.add_characteristic('battery-level') + targ_state.value = 50 + + return service + + +async def test_switch_change_alarm_state(hass, utcnow): + """Test that we can turn a HomeKit alarm on and off again.""" + alarm_control_panel = create_security_system_service() + helper = await setup_test_component(hass, [alarm_control_panel]) + + await hass.services.async_call('alarm_control_panel', 'alarm_arm_home', { + 'entity_id': 'alarm_control_panel.testdevice', + }, blocking=True) + assert helper.characteristics[TARGET_STATE].value == 0 + + await hass.services.async_call('alarm_control_panel', 'alarm_arm_away', { + 'entity_id': 'alarm_control_panel.testdevice', + }, blocking=True) + assert helper.characteristics[TARGET_STATE].value == 1 + + await hass.services.async_call('alarm_control_panel', 'alarm_arm_night', { + 'entity_id': 'alarm_control_panel.testdevice', + }, blocking=True) + assert helper.characteristics[TARGET_STATE].value == 2 + + await hass.services.async_call('alarm_control_panel', 'alarm_disarm', { + 'entity_id': 'alarm_control_panel.testdevice', + }, blocking=True) + assert helper.characteristics[TARGET_STATE].value == 3 + + +async def test_switch_read_alarm_state(hass, utcnow): + """Test that we can read the state of a HomeKit alarm accessory.""" + alarm_control_panel = create_security_system_service() + helper = await setup_test_component(hass, [alarm_control_panel]) + + helper.characteristics[CURRENT_STATE].value = 0 + state = await helper.poll_and_get_state() + assert state.state == 'armed_home' + assert state.attributes['battery_level'] == 50 + + helper.characteristics[CURRENT_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.state == 'armed_away' + + helper.characteristics[CURRENT_STATE].value = 2 + state = await helper.poll_and_get_state() + assert state.state == 'armed_night' + + helper.characteristics[CURRENT_STATE].value = 3 + state = await helper.poll_and_get_state() + assert state.state == 'disarmed' + + helper.characteristics[CURRENT_STATE].value = 4 + state = await helper.poll_and_get_state() + assert state.state == 'triggered' diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py new file mode 100644 index 00000000000..bfcd51b55fb --- /dev/null +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -0,0 +1,29 @@ +"""Basic checks for HomeKitLock.""" +from tests.components.homekit_controller.common import ( + FakeService, setup_test_component) + +MOTION_DETECTED = ('motion', 'motion-detected') + + +def create_sensor_motion_service(): + """Define motion characteristics as per page 225 of HAP spec.""" + service = FakeService('public.hap.service.sensor.motion') + + cur_state = service.add_characteristic('motion-detected') + cur_state.value = 0 + + return service + + +async def test_sensor_read_state(hass, utcnow): + """Test that we can read the state of a HomeKit motion sensor accessory.""" + sensor = create_sensor_motion_service() + helper = await setup_test_component(hass, [sensor]) + + helper.characteristics[MOTION_DETECTED].value = False + state = await helper.poll_and_get_state() + assert state.state == 'off' + + helper.characteristics[MOTION_DETECTED].value = True + state = await helper.poll_and_get_state() + assert state.state == 'on' diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py new file mode 100644 index 00000000000..9f5cc9d8764 --- /dev/null +++ b/tests/components/homekit_controller/test_climate.py @@ -0,0 +1,77 @@ +"""Basic checks for HomeKitclimate.""" +from homeassistant.components.climate import ( + DOMAIN, SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE) +from tests.components.homekit_controller.common import ( + setup_test_component) + + +HEATING_COOLING_TARGET = ('thermostat', 'heating-cooling.target') +HEATING_COOLING_CURRENT = ('thermostat', 'heating-cooling.current') +TEMPERATURE_TARGET = ('thermostat', 'temperature.target') +TEMPERATURE_CURRENT = ('thermostat', 'temperature.current') + + +async def test_climate_change_thermostat_state(hass, utcnow): + """Test that we can turn a HomeKit thermostat on and off again.""" + from homekit.model.services import ThermostatService + + helper = await setup_test_component(hass, [ThermostatService()]) + + await hass.services.async_call(DOMAIN, SERVICE_SET_OPERATION_MODE, { + 'entity_id': 'climate.testdevice', + 'operation_mode': 'heat', + }, blocking=True) + + assert helper.characteristics[HEATING_COOLING_TARGET].value == 1 + + await hass.services.async_call(DOMAIN, SERVICE_SET_OPERATION_MODE, { + 'entity_id': 'climate.testdevice', + 'operation_mode': 'cool', + }, blocking=True) + assert helper.characteristics[HEATING_COOLING_TARGET].value == 2 + + +async def test_climate_change_thermostat_temperature(hass, utcnow): + """Test that we can turn a HomeKit thermostat on and off again.""" + from homekit.model.services import ThermostatService + + helper = await setup_test_component(hass, [ThermostatService()]) + + await hass.services.async_call(DOMAIN, SERVICE_SET_TEMPERATURE, { + 'entity_id': 'climate.testdevice', + 'temperature': 21, + }, blocking=True) + assert helper.characteristics[TEMPERATURE_TARGET].value == 21 + + await hass.services.async_call(DOMAIN, SERVICE_SET_TEMPERATURE, { + 'entity_id': 'climate.testdevice', + 'temperature': 25, + }, blocking=True) + assert helper.characteristics[TEMPERATURE_TARGET].value == 25 + + +async def test_climate_read_thermostat_state(hass, utcnow): + """Test that we can read the state of a HomeKit thermostat accessory.""" + from homekit.model.services import ThermostatService + + helper = await setup_test_component(hass, [ThermostatService()]) + + # Simulate that heating is on + helper.characteristics[TEMPERATURE_CURRENT].value = 19 + helper.characteristics[TEMPERATURE_TARGET].value = 21 + helper.characteristics[HEATING_COOLING_CURRENT].value = 1 + helper.characteristics[HEATING_COOLING_TARGET].value = 1 + + state = await helper.poll_and_get_state() + assert state.state == 'heat' + assert state.attributes['current_temperature'] == 19 + + # Simulate that cooling is on + helper.characteristics[TEMPERATURE_CURRENT].value = 21 + helper.characteristics[TEMPERATURE_TARGET].value = 19 + helper.characteristics[HEATING_COOLING_CURRENT].value = 2 + helper.characteristics[HEATING_COOLING_TARGET].value = 2 + + state = await helper.poll_and_get_state() + assert state.state == 'cool' + assert state.attributes['current_temperature'] == 21 diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py new file mode 100644 index 00000000000..062ecc54041 --- /dev/null +++ b/tests/components/homekit_controller/test_cover.py @@ -0,0 +1,213 @@ +"""Basic checks for HomeKitalarm_control_panel.""" +from tests.components.homekit_controller.common import ( + FakeService, setup_test_component) + +POSITION_STATE = ('window-covering', 'position.state') +POSITION_CURRENT = ('window-covering', 'position.current') +POSITION_TARGET = ('window-covering', 'position.target') + +H_TILT_CURRENT = ('window-covering', 'horizontal-tilt.current') +H_TILT_TARGET = ('window-covering', 'horizontal-tilt.target') + +V_TILT_CURRENT = ('window-covering', 'vertical-tilt.current') +V_TILT_TARGET = ('window-covering', 'vertical-tilt.target') + +WINDOW_OBSTRUCTION = ('window-covering', 'obstruction-detected') + +DOOR_CURRENT = ('garage-door-opener', 'door-state.current') +DOOR_TARGET = ('garage-door-opener', 'door-state.target') +DOOR_OBSTRUCTION = ('garage-door-opener', 'obstruction-detected') + + +def create_window_covering_service(): + """Define a window-covering characteristics as per page 219 of HAP spec.""" + service = FakeService('public.hap.service.window-covering') + + cur_state = service.add_characteristic('position.current') + cur_state.value = 0 + + targ_state = service.add_characteristic('position.target') + targ_state.value = 0 + + position_state = service.add_characteristic('position.state') + position_state.value = 0 + + position_hold = service.add_characteristic('position.hold') + position_hold.value = 0 + + obstruction = service.add_characteristic('obstruction-detected') + obstruction.value = False + + name = service.add_characteristic('name') + name.value = "testdevice" + + return service + + +def create_window_covering_service_with_h_tilt(): + """Define a window-covering characteristics as per page 219 of HAP spec.""" + service = create_window_covering_service() + + tilt_current = service.add_characteristic('horizontal-tilt.current') + tilt_current.value = 0 + + tilt_target = service.add_characteristic('horizontal-tilt.target') + tilt_target.value = 0 + + return service + + +def create_window_covering_service_with_v_tilt(): + """Define a window-covering characteristics as per page 219 of HAP spec.""" + service = create_window_covering_service() + + tilt_current = service.add_characteristic('vertical-tilt.current') + tilt_current.value = 0 + + tilt_target = service.add_characteristic('vertical-tilt.target') + tilt_target.value = 0 + + return service + + +async def test_change_window_cover_state(hass, utcnow): + """Test that we can turn a HomeKit alarm on and off again.""" + window_cover = create_window_covering_service() + helper = await setup_test_component(hass, [window_cover]) + + await hass.services.async_call('cover', 'open_cover', { + 'entity_id': helper.entity_id, + }, blocking=True) + assert helper.characteristics[POSITION_TARGET].value == 100 + + await hass.services.async_call('cover', 'close_cover', { + 'entity_id': helper.entity_id, + }, blocking=True) + assert helper.characteristics[POSITION_TARGET].value == 0 + + +async def test_read_window_cover_state(hass, utcnow): + """Test that we can read the state of a HomeKit alarm accessory.""" + window_cover = create_window_covering_service() + helper = await setup_test_component(hass, [window_cover]) + + helper.characteristics[POSITION_STATE].value = 0 + state = await helper.poll_and_get_state() + assert state.state == 'opening' + + helper.characteristics[POSITION_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.state == 'closing' + + helper.characteristics[POSITION_STATE].value = 2 + state = await helper.poll_and_get_state() + assert state.state == 'closed' + + helper.characteristics[WINDOW_OBSTRUCTION].value = True + state = await helper.poll_and_get_state() + assert state.attributes['obstruction-detected'] is True + + +async def test_read_window_cover_tilt_horizontal(hass, utcnow): + """Test that horizontal tilt is handled correctly.""" + window_cover = create_window_covering_service_with_h_tilt() + helper = await setup_test_component(hass, [window_cover]) + + helper.characteristics[H_TILT_CURRENT].value = 75 + state = await helper.poll_and_get_state() + assert state.attributes['current_tilt_position'] == 75 + + +async def test_read_window_cover_tilt_vertical(hass, utcnow): + """Test that vertical tilt is handled correctly.""" + window_cover = create_window_covering_service_with_v_tilt() + helper = await setup_test_component(hass, [window_cover]) + + helper.characteristics[V_TILT_CURRENT].value = 75 + state = await helper.poll_and_get_state() + assert state.attributes['current_tilt_position'] == 75 + + +async def test_write_window_cover_tilt_horizontal(hass, utcnow): + """Test that horizontal tilt is written correctly.""" + window_cover = create_window_covering_service_with_h_tilt() + helper = await setup_test_component(hass, [window_cover]) + + await hass.services.async_call('cover', 'set_cover_tilt_position', { + 'entity_id': helper.entity_id, + 'tilt_position': 90 + }, blocking=True) + assert helper.characteristics[H_TILT_TARGET].value == 90 + + +async def test_write_window_cover_tilt_vertical(hass, utcnow): + """Test that vertical tilt is written correctly.""" + window_cover = create_window_covering_service_with_v_tilt() + helper = await setup_test_component(hass, [window_cover]) + + await hass.services.async_call('cover', 'set_cover_tilt_position', { + 'entity_id': helper.entity_id, + 'tilt_position': 90 + }, blocking=True) + assert helper.characteristics[V_TILT_TARGET].value == 90 + + +def create_garage_door_opener_service(): + """Define a garage-door-opener chars as per page 217 of HAP spec.""" + service = FakeService('public.hap.service.garage-door-opener') + + cur_state = service.add_characteristic('door-state.current') + cur_state.value = 0 + + targ_state = service.add_characteristic('door-state.target') + targ_state.value = 0 + + obstruction = service.add_characteristic('obstruction-detected') + obstruction.value = False + + name = service.add_characteristic('name') + name.value = "testdevice" + + return service + + +async def test_change_door_state(hass, utcnow): + """Test that we can turn open and close a HomeKit garage door.""" + door = create_garage_door_opener_service() + helper = await setup_test_component(hass, [door]) + + await hass.services.async_call('cover', 'open_cover', { + 'entity_id': helper.entity_id, + }, blocking=True) + assert helper.characteristics[DOOR_TARGET].value == 0 + + await hass.services.async_call('cover', 'close_cover', { + 'entity_id': helper.entity_id, + }, blocking=True) + assert helper.characteristics[DOOR_TARGET].value == 1 + + +async def test_read_door_state(hass, utcnow): + """Test that we can read the state of a HomeKit garage door.""" + door = create_garage_door_opener_service() + helper = await setup_test_component(hass, [door]) + + helper.characteristics[DOOR_CURRENT].value = 0 + state = await helper.poll_and_get_state() + assert state.state == 'open' + + helper.characteristics[DOOR_CURRENT].value = 1 + state = await helper.poll_and_get_state() + assert state.state == 'closed' + + helper.characteristics[DOOR_CURRENT].value = 2 + state = await helper.poll_and_get_state() + assert state.state == 'opening' + + helper.characteristics[DOOR_CURRENT].value = 3 + state = await helper.poll_and_get_state() + assert state.state == 'closing' + + helper.characteristics[DOOR_OBSTRUCTION].value = True + state = await helper.poll_and_get_state() + assert state.attributes['obstruction-detected'] is True diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py new file mode 100644 index 00000000000..0509d70c0b9 --- /dev/null +++ b/tests/components/homekit_controller/test_light.py @@ -0,0 +1,128 @@ +"""Basic checks for HomeKitSwitch.""" +from tests.components.homekit_controller.common import ( + FakeService, setup_test_component) + + +LIGHT_ON = ('lightbulb', 'on') +LIGHT_BRIGHTNESS = ('lightbulb', 'brightness') +LIGHT_HUE = ('lightbulb', 'hue') +LIGHT_SATURATION = ('lightbulb', 'saturation') +LIGHT_COLOR_TEMP = ('lightbulb', 'color-temperature') + + +def create_lightbulb_service(): + """Define lightbulb characteristics.""" + service = FakeService('public.hap.service.lightbulb') + + on_char = service.add_characteristic('on') + on_char.value = 0 + + brightness = service.add_characteristic('brightness') + brightness.value = 0 + + return service + + +def create_lightbulb_service_with_hs(): + """Define a lightbulb service with hue + saturation.""" + service = create_lightbulb_service() + + hue = service.add_characteristic('hue') + hue.value = 0 + + saturation = service.add_characteristic('saturation') + saturation.value = 0 + + return service + + +def create_lightbulb_service_with_color_temp(): + """Define a lightbulb service with color temp.""" + service = create_lightbulb_service() + + color_temp = service.add_characteristic('color-temperature') + color_temp.value = 0 + + return service + + +async def test_switch_change_light_state(hass, utcnow): + """Test that we can turn a HomeKit light on and off again.""" + bulb = create_lightbulb_service_with_hs() + helper = await setup_test_component(hass, [bulb]) + + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.testdevice', + 'brightness': 255, + 'hs_color': [4, 5], + }, blocking=True) + + assert helper.characteristics[LIGHT_ON].value == 1 + assert helper.characteristics[LIGHT_BRIGHTNESS].value == 100 + assert helper.characteristics[LIGHT_HUE].value == 4 + assert helper.characteristics[LIGHT_SATURATION].value == 5 + + await hass.services.async_call('light', 'turn_off', { + 'entity_id': 'light.testdevice', + }, blocking=True) + assert helper.characteristics[LIGHT_ON].value == 0 + + +async def test_switch_change_light_state_color_temp(hass, utcnow): + """Test that we can turn change color_temp.""" + bulb = create_lightbulb_service_with_color_temp() + helper = await setup_test_component(hass, [bulb]) + + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.testdevice', + 'brightness': 255, + 'color_temp': 400, + }, blocking=True) + assert helper.characteristics[LIGHT_ON].value == 1 + assert helper.characteristics[LIGHT_BRIGHTNESS].value == 100 + assert helper.characteristics[LIGHT_COLOR_TEMP].value == 400 + + +async def test_switch_read_light_state(hass, utcnow): + """Test that we can read the state of a HomeKit light accessory.""" + bulb = create_lightbulb_service_with_hs() + helper = await setup_test_component(hass, [bulb]) + + # Initial state is that the light is off + state = await helper.poll_and_get_state() + assert state.state == 'off' + + # Simulate that someone switched on the device in the real world not via HA + helper.characteristics[LIGHT_ON].set_value(True) + helper.characteristics[LIGHT_BRIGHTNESS].value = 100 + helper.characteristics[LIGHT_HUE].value = 4 + helper.characteristics[LIGHT_SATURATION].value = 5 + state = await helper.poll_and_get_state() + assert state.state == 'on' + assert state.attributes['brightness'] == 255 + assert state.attributes['hs_color'] == (4, 5) + + # Simulate that device switched off in the real world not via HA + helper.characteristics[LIGHT_ON].set_value(False) + state = await helper.poll_and_get_state() + assert state.state == 'off' + + +async def test_switch_read_light_state_color_temp(hass, utcnow): + """Test that we can read the color_temp of a light accessory.""" + bulb = create_lightbulb_service_with_color_temp() + helper = await setup_test_component(hass, [bulb]) + + # Initial state is that the light is off + state = await helper.poll_and_get_state() + assert state.state == 'off' + + # Simulate that someone switched on the device in the real world not via HA + helper.characteristics[LIGHT_ON].set_value(True) + helper.characteristics[LIGHT_BRIGHTNESS].value = 100 + helper.characteristics[LIGHT_COLOR_TEMP].value = 400 + + state = await helper.poll_and_get_state() + assert state.state == 'on' + assert state.attributes['brightness'] == 255 + assert state.attributes['color_temp'] == 400 diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py new file mode 100644 index 00000000000..3347e51c888 --- /dev/null +++ b/tests/components/homekit_controller/test_lock.py @@ -0,0 +1,59 @@ +"""Basic checks for HomeKitLock.""" +from tests.components.homekit_controller.common import ( + FakeService, setup_test_component) + +LOCK_CURRENT_STATE = ('lock-mechanism', 'lock-mechanism.current-state') +LOCK_TARGET_STATE = ('lock-mechanism', 'lock-mechanism.target-state') + + +def create_lock_service(): + """Define a lock characteristics as per page 219 of HAP spec.""" + service = FakeService('public.hap.service.lock-mechanism') + + cur_state = service.add_characteristic('lock-mechanism.current-state') + cur_state.value = 0 + + targ_state = service.add_characteristic('lock-mechanism.target-state') + targ_state.value = 0 + + # According to the spec, a battery-level characteristic is normally + # part of a seperate service. However as the code was written (which + # predates this test) the battery level would have to be part of the lock + # service as it is here. + targ_state = service.add_characteristic('battery-level') + targ_state.value = 50 + + return service + + +async def test_switch_change_lock_state(hass, utcnow): + """Test that we can turn a HomeKit lock on and off again.""" + lock = create_lock_service() + helper = await setup_test_component(hass, [lock]) + + await hass.services.async_call('lock', 'lock', { + 'entity_id': 'lock.testdevice', + }, blocking=True) + assert helper.characteristics[LOCK_TARGET_STATE].value == 1 + + await hass.services.async_call('lock', 'unlock', { + 'entity_id': 'lock.testdevice', + }, blocking=True) + assert helper.characteristics[LOCK_TARGET_STATE].value == 0 + + +async def test_switch_read_lock_state(hass, utcnow): + """Test that we can read the state of a HomeKit lock accessory.""" + lock = create_lock_service() + helper = await setup_test_component(hass, [lock]) + + helper.characteristics[LOCK_CURRENT_STATE].value = 0 + helper.characteristics[LOCK_TARGET_STATE].value = 0 + state = await helper.poll_and_get_state() + assert state.state == 'unlocked' + assert state.attributes['battery_level'] == 50 + + helper.characteristics[LOCK_CURRENT_STATE].value = 1 + helper.characteristics[LOCK_TARGET_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.state == 'locked' diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py new file mode 100644 index 00000000000..8915f5858cf --- /dev/null +++ b/tests/components/homekit_controller/test_switch.py @@ -0,0 +1,49 @@ +"""Basic checks for HomeKitSwitch.""" +from tests.components.homekit_controller.common import ( + setup_test_component) + + +async def test_switch_change_outlet_state(hass, utcnow): + """Test that we can turn a HomeKit outlet on and off again.""" + from homekit.model.services import OutletService + + helper = await setup_test_component(hass, [OutletService()]) + + await hass.services.async_call('switch', 'turn_on', { + 'entity_id': 'switch.testdevice', + }, blocking=True) + assert helper.characteristics[('outlet', 'on')].value == 1 + + await hass.services.async_call('switch', 'turn_off', { + 'entity_id': 'switch.testdevice', + }, blocking=True) + assert helper.characteristics[('outlet', 'on')].value == 0 + + +async def test_switch_read_outlet_state(hass, utcnow): + """Test that we can read the state of a HomeKit outlet accessory.""" + from homekit.model.services import OutletService + + helper = await setup_test_component(hass, [OutletService()]) + + # Initial state is that the switch is off and the outlet isn't in use + switch_1 = await helper.poll_and_get_state() + assert switch_1.state == 'off' + assert switch_1.attributes['outlet_in_use'] is False + + # Simulate that someone switched on the device in the real world not via HA + helper.characteristics[('outlet', 'on')].set_value(True) + switch_1 = await helper.poll_and_get_state() + assert switch_1.state == 'on' + assert switch_1.attributes['outlet_in_use'] is False + + # Simulate that device switched off in the real world not via HA + helper.characteristics[('outlet', 'on')].set_value(False) + switch_1 = await helper.poll_and_get_state() + assert switch_1.state == 'off' + + # Simulate that someone plugged something into the device + helper.characteristics[('outlet', 'outlet-in-use')].value = True + switch_1 = await helper.poll_and_get_state() + assert switch_1.state == 'off' + assert switch_1.attributes['outlet_in_use'] is True diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 92f58b37662..521920b9281 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -65,7 +65,7 @@ async def test_hap_setup_works(aioclient_mock): assert await hap.async_setup() is True assert hap.home is home - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 6 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 7 assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ (entry, 'alarm_control_panel') assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \ @@ -107,10 +107,10 @@ async def test_hap_reset_unloads_entry_if_setup(): assert hap.home is home assert len(hass.services.async_register.mock_calls) == 0 - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 6 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 7 hass.config_entries.async_forward_entry_unload.return_value = \ mock_coro(True) await hap.async_reset() - assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 6 + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 7 diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 6624937da8d..954337bb413 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -71,7 +71,7 @@ async def test_ip_bans_file_creation(hass, aiohttp_client): raise HTTPUnauthorized app.router.add_get('/', unauth_handler) - setup_bans(hass, app, 1) + setup_bans(hass, app, 2) mock_real_ip(app)("200.201.202.204") with patch('homeassistant.components.http.ban.async_load_ip_bans_config', diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 1c1afe711c6..fadb91a3e03 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -80,6 +80,11 @@ class TestApiConfig(unittest.TestCase): api_config = http.ApiConfig('http://example.com', use_ssl=True) assert api_config.base_url == 'http://example.com:8123' + def test_api_base_url_removes_trailing_slash(hass): + """Test a trialing slash is removed when setting the API URL.""" + api_config = http.ApiConfig('http://example.com/') + assert api_config.base_url == 'http://example.com:8123' + async def test_api_base_url_with_domain(hass): """Test setting API URL.""" @@ -124,6 +129,17 @@ async def test_api_no_base_url(hass): assert hass.config.api.base_url == 'http://127.0.0.1:8123' +async def test_api_base_url_removes_trailing_slash(hass): + """Test setting api url.""" + result = await async_setup_component(hass, 'http', { + 'http': { + 'base_url': 'https://example.com/' + } + }) + assert result + assert hass.config.api.base_url == 'https://example.com' + + async def test_not_log_password(hass, aiohttp_client, caplog, legacy_auth): """Test access with password doesn't get logged.""" assert await async_setup_component(hass, 'api', { diff --git a/tests/components/light/test_everlights.py b/tests/components/light/test_everlights.py new file mode 100644 index 00000000000..026e7927c8d --- /dev/null +++ b/tests/components/light/test_everlights.py @@ -0,0 +1,16 @@ +"""The tests for the everlights component.""" +from homeassistant.components.light import everlights + + +def test_color_rgb_to_int(): + """Test RGB to integer conversion.""" + assert everlights.color_rgb_to_int(0x00, 0x00, 0x00) == 0x000000 + assert everlights.color_rgb_to_int(0xff, 0xff, 0xff) == 0xffffff + assert everlights.color_rgb_to_int(0x12, 0x34, 0x56) == 0x123456 + + +def test_int_to_rgb(): + """Test integer to RGB conversion.""" + assert everlights.color_int_to_rgb(0x000000) == (0x00, 0x00, 0x00) + assert everlights.color_int_to_rgb(0xffffff) == (0xff, 0xff, 0xff) + assert everlights.color_int_to_rgb(0x123456) == (0x12, 0x34, 0x56) diff --git a/tests/components/locative/test_init.py b/tests/components/locative/test_init.py index 5f18d47eb22..877d25d04bd 100644 --- a/tests/components/locative/test_init.py +++ b/tests/components/locative/test_init.py @@ -4,11 +4,15 @@ from unittest.mock import patch, Mock import pytest from homeassistant import data_entry_flow +from homeassistant.components import locative from homeassistant.components.device_tracker import \ DOMAIN as DEVICE_TRACKER_DOMAIN -from homeassistant.components.locative import DOMAIN -from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY +from homeassistant.components.locative import DOMAIN, TRACKER_UPDATE +from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, \ + CONF_WEBHOOK_ID +from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry @pytest.fixture(autouse=True) @@ -234,3 +238,21 @@ async def test_exit_first(hass, locative_client, webhook_id): state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, data['device'])) assert state.state == 'not_home' + + +@pytest.mark.xfail( + reason='The device_tracker component does not support unloading yet.' +) +async def test_load_unload_entry(hass): + """Test that the appropriate dispatch signals are added and removed.""" + entry = MockConfigEntry(domain=DOMAIN, data={ + CONF_WEBHOOK_ID: 'locative_test' + }) + + await locative.async_setup_entry(hass, entry) + await hass.async_block_till_done() + assert 1 == len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) + + await locative.async_unload_entry(hass, entry) + await hass.async_block_till_done() + assert 0 == len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 20490f8c0cd..7aa4ef0f5b3 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -4,6 +4,8 @@ from unittest.mock import patch from homeassistant.setup import async_setup_component from homeassistant.components import frontend, lovelace +from tests.common import get_system_health_info + async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage): """Test we load lovelace config from storage.""" @@ -117,3 +119,67 @@ async def test_lovelace_from_yaml(hass, hass_ws_client): assert response['success'] assert response['result'] == {'hello': 'yo'} + + +async def test_system_health_info_autogen(hass): + """Test system health info endpoint.""" + assert await async_setup_component(hass, 'lovelace', {}) + info = await get_system_health_info(hass, 'lovelace') + assert info == {'mode': 'auto-gen'} + + +async def test_system_health_info_storage(hass, hass_storage): + """Test system health info endpoint.""" + hass_storage[lovelace.STORAGE_KEY] = { + 'key': 'lovelace', + 'version': 1, + 'data': { + 'config': { + 'resources': [], + 'views': [] + } + } + } + assert await async_setup_component(hass, 'lovelace', {}) + info = await get_system_health_info(hass, 'lovelace') + assert info == { + 'mode': 'storage', + 'resources': 0, + 'views': 0, + } + + +async def test_system_health_info_yaml(hass): + """Test system health info endpoint.""" + assert await async_setup_component(hass, 'lovelace', { + 'lovelace': { + 'mode': 'YAML' + } + }) + with patch('homeassistant.components.lovelace.load_yaml', return_value={ + 'views': [ + { + 'cards': [] + } + ] + }): + info = await get_system_health_info(hass, 'lovelace') + assert info == { + 'mode': 'yaml', + 'resources': 0, + 'views': 1, + } + + +async def test_system_health_info_yaml_not_found(hass): + """Test system health info endpoint.""" + assert await async_setup_component(hass, 'lovelace', { + 'lovelace': { + 'mode': 'YAML' + } + }) + info = await get_system_health_info(hass, 'lovelace') + assert info == { + 'mode': 'yaml', + 'error': "{} not found".format(hass.config.path('ui-lovelace.yaml')) + } diff --git a/tests/components/media_player/test_universal.py b/tests/components/media_player/test_universal.py index 9f0f6d0728b..58911776836 100644 --- a/tests/components/media_player/test_universal.py +++ b/tests/components/media_player/test_universal.py @@ -5,7 +5,7 @@ import unittest from voluptuous.error import MultipleInvalid from homeassistant.const import ( - STATE_OFF, STATE_ON, STATE_UNKNOWN, STATE_PLAYING, STATE_PAUSED) + STATE_OFF, STATE_ON, STATE_PLAYING, STATE_PAUSED) import homeassistant.components.switch as switch import homeassistant.components.input_number as input_number import homeassistant.components.input_select as input_select @@ -119,7 +119,7 @@ class MockMediaPlayer(media_player.MediaPlayerDevice): def turn_on(self): """Mock turn_on function.""" - self._state = STATE_UNKNOWN + self._state = None def turn_off(self): """Mock turn_off function.""" diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 1a89e2382e3..572cbdb0e10 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -3,18 +3,18 @@ import json import unittest from unittest.mock import ANY -from homeassistant.setup import setup_component -from homeassistant.const import ( - STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNAVAILABLE, - STATE_UNKNOWN) from homeassistant.components import alarm_control_panel, mqtt from homeassistant.components.mqtt.discovery import async_start +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNAVAILABLE, + STATE_UNKNOWN) +from homeassistant.setup import setup_component from tests.common import ( - assert_setup_component, async_fire_mqtt_message, async_mock_mqtt_component, - async_setup_component, fire_mqtt_message, get_test_home_assistant, - mock_mqtt_component, MockConfigEntry, mock_registry) + MockConfigEntry, assert_setup_component, async_fire_mqtt_message, + async_mock_mqtt_component, async_setup_component, fire_mqtt_message, + get_test_home_assistant, mock_mqtt_component, mock_registry) from tests.components.alarm_control_panel import common CODE = 'HELLO_CODE' @@ -246,6 +246,109 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): fire_mqtt_message(self.hass, 'availability-topic', 'good') +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('alarm_control_panel.test') + + assert '100' == state.attributes.get('val') + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('alarm_control_panel.test') + + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('alarm_control_panel.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message( + hass, 'homeassistant/alarm_control_panel/bla/config', data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('alarm_control_panel.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message( + hass, 'homeassistant/alarm_control_panel/bla/config', data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('alarm_control_panel.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('alarm_control_panel.beer') + assert '75' == state.attributes.get('val') + + async def test_unique_id(hass): """Test unique id option only creates one alarm per unique_id.""" await async_mock_mqtt_component(hass) @@ -276,7 +379,7 @@ async def test_discovery_removal_alarm(hass, mqtt_mock, caplog): data = ( '{ "name": "Beer",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) @@ -306,12 +409,12 @@ async def test_discovery_update_alarm(hass, mqtt_mock, caplog): data1 = ( '{ "name": "Beer",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) data2 = ( '{ "name": "Milk",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) @@ -348,7 +451,7 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ) data2 = ( '{ "name": "Milk",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) @@ -412,6 +515,53 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == '0.1-beta' +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message( + hass, 'homeassistant/alarm_control_panel/bla/config', data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message( + hass, 'homeassistant/alarm_control_panel/bla/config', data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' + + async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" registry = mock_registry(hass, {}) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 5a1c80beae2..3e6e36cd050 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -1,23 +1,21 @@ """The tests for the MQTT binary sensor platform.""" +from datetime import timedelta import json import unittest -from unittest.mock import ANY, Mock, patch -from datetime import timedelta +from unittest.mock import ANY, Mock -import homeassistant.core as ha -from homeassistant.setup import setup_component, async_setup_component from homeassistant.components import binary_sensor, mqtt from homeassistant.components.mqtt.discovery import async_start - -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE - +from homeassistant.const import ( + EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE) +import homeassistant.core as ha +from homeassistant.setup import async_setup_component, setup_component import homeassistant.util.dt as dt_util from tests.common import ( - get_test_home_assistant, fire_mqtt_message, async_fire_mqtt_message, - fire_time_changed, mock_component, mock_mqtt_component, mock_registry, - async_mock_mqtt_component, MockConfigEntry) + MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component, + fire_mqtt_message, fire_time_changed, get_test_home_assistant, + mock_component, mock_mqtt_component, mock_registry) class TestSensorMQTT(unittest.TestCase): @@ -283,66 +281,105 @@ class TestSensorMQTT(unittest.TestCase): assert STATE_OFF == state.state assert 3 == len(events) - def test_setting_sensor_attribute_via_mqtt_json_message(self): - """Test the setting of attribute via MQTT with JSON payload.""" - mock_component(self.hass, 'mqtt') - assert setup_component(self.hass, binary_sensor.DOMAIN, { - binary_sensor.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'test-topic', - 'json_attributes_topic': 'attr-topic' - } - }) - fire_mqtt_message(self.hass, 'attr-topic', '{ "val": "100" }') - self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test') +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) - assert '100' == \ - state.attributes.get('val') + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.test') - @patch('homeassistant.components.mqtt._LOGGER') - def test_update_with_json_attrs_not_dict(self, mock_logger): - """Test attributes get extracted from a JSON result.""" - mock_component(self.hass, 'mqtt') - assert setup_component(self.hass, binary_sensor.DOMAIN, { - binary_sensor.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'test-topic', - 'json_attributes_topic': 'attr-topic' - } - }) + assert '100' == state.attributes.get('val') - fire_mqtt_message(self.hass, 'attr-topic', '[ "list", "of", "things"]') - self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test') - assert state.attributes.get('val') is None - mock_logger.warning.assert_called_with( - 'JSON result was not a dictionary') +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) - @patch('homeassistant.components.mqtt._LOGGER') - def test_update_with_json_attrs_bad_JSON(self, mock_logger): - """Test attributes get extracted from a JSON result.""" - mock_component(self.hass, 'mqtt') - assert setup_component(self.hass, binary_sensor.DOMAIN, { - binary_sensor.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'test-topic', - 'json_attributes_topic': 'attr-topic' - } - }) + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.test') - fire_mqtt_message(self.hass, 'attr-topic', 'This is not JSON') - self.hass.block_till_done() + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text - state = self.hass.states.get('binary_sensor.test') - assert state.attributes.get('val') is None - mock_logger.warning.assert_called_with( - 'Erroneous JSON: %s', 'This is not JSON') + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.beer') + assert '75' == state.attributes.get('val') async def test_unique_id(hass): @@ -492,6 +529,52 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == '0.1-beta' +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' + + async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" registry = mock_registry(hass, {}) diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 8665f26aba9..15b4ed22378 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -1,10 +1,14 @@ """The tests for mqtt camera component.""" import asyncio +from unittest.mock import ANY +from homeassistant.components import camera, mqtt +from homeassistant.components.mqtt.discovery import async_start from homeassistant.setup import async_setup_component from tests.common import ( - async_mock_mqtt_component, async_fire_mqtt_message) + MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component, + mock_registry) @asyncio.coroutine @@ -52,3 +56,129 @@ def test_unique_id(hass): async_fire_mqtt_message(hass, 'test-topic', 'payload') yield from hass.async_block_till_done() assert len(hass.states.async_all()) == 1 + + +async def test_discovery_removal_camera(hass, mqtt_mock, caplog): + """Test removal of discovered camera.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data = ( + '{ "name": "Beer",' + ' "topic": "test_topic"}' + ) + + async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('camera.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config', + '') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('camera.beer') + assert state is None + + +async def test_discovery_update_camera(hass, mqtt_mock, caplog): + """Test update of discovered camera.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "topic": "test_topic"}' + ) + data2 = ( + '{ "name": "Milk",' + ' "topic": "test_topic"}' + ) + + async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('camera.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('camera.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('camera.milk') + assert state is None + + +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "topic": "test_topic"}' + ) + + async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('camera.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('camera.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('camera.beer') + assert state is None + + +async def test_entity_id_update(hass, mqtt_mock): + """Test MQTT subscriptions are managed when entity_id is updated.""" + registry = mock_registry(hass, {}) + mock_mqtt = await async_mock_mqtt_component(hass) + assert await async_setup_component(hass, camera.DOMAIN, { + camera.DOMAIN: [{ + 'platform': 'mqtt', + 'name': 'beer', + 'topic': 'test-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }] + }) + + state = hass.states.get('camera.beer') + assert state is not None + assert mock_mqtt.async_subscribe.call_count == 1 + mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8') + mock_mqtt.async_subscribe.reset_mock() + + registry.async_update_entity('camera.beer', new_entity_id='camera.milk') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('camera.beer') + assert state is None + + state = hass.states.get('camera.milk') + assert state is not None + assert mock_mqtt.async_subscribe.call_count == 1 + mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8') diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index a2aa424eeee..c9b7c748ea5 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -7,21 +7,20 @@ from unittest.mock import ANY import pytest import voluptuous as vol -from homeassistant.util.unit_system import ( - METRIC_SYSTEM -) -from homeassistant.setup import setup_component from homeassistant.components import climate, mqtt -from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.components.climate import ( - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, - SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, SUPPORT_HOLD_MODE, - SUPPORT_AWAY_MODE, SUPPORT_AUX_HEAT, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP) + DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, SUPPORT_AUX_HEAT, SUPPORT_AWAY_MODE, + SUPPORT_FAN_MODE, SUPPORT_HOLD_MODE, SUPPORT_OPERATION_MODE, + SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE) from homeassistant.components.mqtt.discovery import async_start +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.setup import setup_component +from homeassistant.util.unit_system import METRIC_SYSTEM + from tests.common import ( - async_fire_mqtt_message, async_mock_mqtt_component, async_setup_component, - fire_mqtt_message, get_test_home_assistant, mock_component, - mock_mqtt_component, MockConfigEntry, mock_registry) + MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component, + async_setup_component, fire_mqtt_message, get_test_home_assistant, + mock_component, mock_mqtt_component, mock_registry) from tests.components.climate import common ENTITY_CLIMATE = 'climate.test' @@ -674,6 +673,106 @@ class TestMQTTClimate(unittest.TestCase): assert 0.01 == temp_step +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, climate.DOMAIN, { + climate.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('climate.test') + + assert '100' == state.attributes.get('val') + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, climate.DOMAIN, { + climate.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('climate.test') + + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, climate.DOMAIN, { + climate.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('climate.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', + data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('climate.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('climate.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('climate.beer') + assert '75' == state.attributes.get('val') + + async def test_unique_id(hass): """Test unique id option only creates one climate per unique_id.""" await async_mock_mqtt_component(hass) @@ -821,6 +920,53 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == '0.1-beta' +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' + + async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" registry = mock_registry(hass, {}) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 66bf9b97807..9d822ba854b 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -5,7 +5,7 @@ import pytest from homeassistant.setup import async_setup_component -from tests.common import mock_coro, MockConfigEntry +from tests.common import MockConfigEntry, mock_coro @pytest.fixture(autouse=True) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 36f566d0c19..343bb3643c6 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -4,20 +4,20 @@ import unittest from unittest.mock import ANY from homeassistant.components import cover, mqtt -from homeassistant.components.cover import (ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION from homeassistant.components.mqtt.cover import MqttCover from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, - SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, - SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, - SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, - STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN) -from homeassistant.setup import setup_component, async_setup_component + ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, + STATE_UNKNOWN) +from homeassistant.setup import async_setup_component, setup_component from tests.common import ( - get_test_home_assistant, mock_mqtt_component, async_fire_mqtt_message, - fire_mqtt_message, MockConfigEntry, async_mock_mqtt_component, + MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component, + fire_mqtt_message, get_test_home_assistant, mock_mqtt_component, mock_registry) @@ -758,6 +758,7 @@ class TestCoverMQTT(unittest.TestCase): 'set_position_topic': None, 'set_position_template': None, 'unique_id': None, 'device_config': None, }, + None, None) assert 44 == mqtt_cover.find_percentage_in_range(44) @@ -788,6 +789,7 @@ class TestCoverMQTT(unittest.TestCase): 'set_position_topic': None, 'set_position_template': None, 'unique_id': None, 'device_config': None, }, + None, None) assert 40 == mqtt_cover.find_percentage_in_range(120) @@ -818,6 +820,7 @@ class TestCoverMQTT(unittest.TestCase): 'set_position_topic': None, 'set_position_template': None, 'unique_id': None, 'device_config': None, }, + None, None) assert 56 == mqtt_cover.find_percentage_in_range(44) @@ -848,6 +851,7 @@ class TestCoverMQTT(unittest.TestCase): 'set_position_topic': None, 'set_position_template': None, 'unique_id': None, 'device_config': None, }, + None, None) assert 60 == mqtt_cover.find_percentage_in_range(120) @@ -878,6 +882,7 @@ class TestCoverMQTT(unittest.TestCase): 'set_position_topic': None, 'set_position_template': None, 'unique_id': None, 'device_config': None, }, + None, None) assert 44 == mqtt_cover.find_in_range_from_percent(44) @@ -908,6 +913,7 @@ class TestCoverMQTT(unittest.TestCase): 'set_position_topic': None, 'set_position_template': None, 'unique_id': None, 'device_config': None, }, + None, None) assert 120 == mqtt_cover.find_in_range_from_percent(40) @@ -938,6 +944,7 @@ class TestCoverMQTT(unittest.TestCase): 'set_position_topic': None, 'set_position_template': None, 'unique_id': None, 'device_config': None, }, + None, None) assert 44 == mqtt_cover.find_in_range_from_percent(56) @@ -968,6 +975,7 @@ class TestCoverMQTT(unittest.TestCase): 'set_position_topic': None, 'set_position_template': None, 'unique_id': None, 'device_config': None, }, + None, None) assert 120 == mqtt_cover.find_in_range_from_percent(60) @@ -1044,6 +1052,106 @@ class TestCoverMQTT(unittest.TestCase): assert STATE_UNAVAILABLE == state.state +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('cover.test') + + assert '100' == state.attributes.get('val') + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('cover.test') + + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('cover.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config', + data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('cover.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('cover.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('cover.beer') + assert '75' == state.attributes.get('val') + + async def test_discovery_removal_cover(hass, mqtt_mock, caplog): """Test removal of discovered cover.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) @@ -1193,6 +1301,53 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == '0.1-beta' +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' + + async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" registry = mock_registry(hass, {}) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 083227e27c0..47bd912fbc8 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -3,11 +3,11 @@ import asyncio from unittest.mock import patch from homeassistant.components import mqtt -from homeassistant.components.mqtt.discovery import async_start, \ - ALREADY_DISCOVERED -from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.mqtt.discovery import ( + ALREADY_DISCOVERED, async_start) +from homeassistant.const import STATE_OFF, STATE_ON -from tests.common import async_fire_mqtt_message, mock_coro, MockConfigEntry +from tests.common import MockConfigEntry, async_fire_mqtt_message, mock_coro @asyncio.coroutine diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index fea6f6dda74..38b38ff7648 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -2,13 +2,14 @@ import json from unittest.mock import ANY -from homeassistant.setup import async_setup_component from homeassistant.components import fan, mqtt from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE +from homeassistant.setup import async_setup_component -from tests.common import async_fire_mqtt_message, MockConfigEntry, \ - async_mock_mqtt_component, mock_registry +from tests.common import ( + MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component, + mock_registry) async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): @@ -195,6 +196,106 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): assert state is None +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, fan.DOMAIN, { + fan.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('fan.test') + + assert '100' == state.attributes.get('val') + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, fan.DOMAIN, { + fan.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('fan.test') + + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, fan.DOMAIN, { + fan.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('fan.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('fan.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('fan.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('fan.beer') + assert '75' == state.attributes.get('val') + + async def test_unique_id(hass): """Test unique_id option only creates one fan per id.""" await async_mock_mqtt_component(hass) @@ -222,7 +323,7 @@ async def test_unique_id(hass): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT fan device registry integration.""" - entry = MockConfigEntry(domain='mqtt') + entry = MockConfigEntry(domain=mqtt.DOMAIN) entry.add_to_hass(hass) await async_start(hass, 'homeassistant', {}, entry) registry = await hass.helpers.device_registry.async_get_registry() @@ -259,6 +360,53 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == '0.1-beta' +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' + + async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" registry = mock_registry(hass, {}) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 540cfe0369d..94506efa909 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1,22 +1,22 @@ """The tests for the MQTT component.""" import asyncio +import ssl import unittest from unittest import mock -import ssl import pytest import voluptuous as vol +from homeassistant.components import mqtt +from homeassistant.const import ( + ATTR_DOMAIN, ATTR_SERVICE, EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback from homeassistant.setup import async_setup_component -from homeassistant.components import mqtt -from homeassistant.const import (EVENT_CALL_SERVICE, ATTR_DOMAIN, ATTR_SERVICE, - EVENT_HOMEASSISTANT_STOP) -from tests.common import (get_test_home_assistant, mock_coro, - mock_mqtt_component, - threadsafe_coroutine_factory, fire_mqtt_message, - async_fire_mqtt_message, MockConfigEntry) +from tests.common import ( + MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component, + fire_mqtt_message, get_test_home_assistant, mock_coro, mock_mqtt_component, + threadsafe_coroutine_factory) @pytest.fixture @@ -297,23 +297,6 @@ class TestMQTTCallbacks(unittest.TestCase): "b'\\x9a' on test-topic with encoding utf-8" in \ test_handle.output[0] - def test_message_callback_exception_gets_logged(self): - """Test exception raised by message handler.""" - @callback - def bad_handler(*args): - """Record calls.""" - raise Exception('This is a bad message callback') - mqtt.subscribe(self.hass, 'test-topic', bad_handler) - - with self.assertLogs(level='WARNING') as test_handle: - fire_mqtt_message(self.hass, 'test-topic', 'test') - - self.hass.block_till_done() - assert \ - "Exception in bad_handler when handling msg on 'test-topic':" \ - " 'test'" in \ - test_handle.output[0] - def test_all_subscriptions_run_when_decode_fails(self): """Test all other subscriptions still run when decode fails for one.""" mqtt.subscribe(self.hass, 'test-topic', self.record_calls, @@ -766,3 +749,21 @@ def test_mqtt_subscribes_topics_on_connect(hass): async def test_setup_fails_without_config(hass): """Test if the MQTT component fails to load with no config.""" assert not await async_setup_component(hass, mqtt.DOMAIN, {}) + + +async def test_message_callback_exception_gets_logged(hass, caplog): + """Test exception raised by message handler.""" + await async_mock_mqtt_component(hass) + + @callback + def bad_handler(*args): + """Record calls.""" + raise Exception('This is a bad message callback') + + await mqtt.async_subscribe(hass, 'test-topic', bad_handler) + async_fire_mqtt_message(hass, 'test-topic', 'test') + await hass.async_block_till_done() + + assert \ + "Exception in bad_handler when handling msg on 'test-topic':" \ + " 'test'" in caplog.text diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 951a9f04be9..cfb0d75d1c7 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -157,16 +157,16 @@ import json from unittest import mock from unittest.mock import ANY, patch -from homeassistant.setup import async_setup_component -from homeassistant.const import ( - STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) from homeassistant.components import light, mqtt from homeassistant.components.mqtt.discovery import async_start +from homeassistant.const import ( + ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE) import homeassistant.core as ha +from homeassistant.setup import async_setup_component from tests.common import ( - assert_setup_component, async_fire_mqtt_message, async_mock_mqtt_component, - mock_coro, MockConfigEntry, mock_registry) + MockConfigEntry, assert_setup_component, async_fire_mqtt_message, + async_mock_mqtt_component, mock_coro, mock_registry) from tests.components.light import common @@ -1070,6 +1070,106 @@ async def test_custom_availability_payload(hass, mqtt_mock): assert STATE_UNAVAILABLE == state.state +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('light.test') + + assert '100' == state.attributes.get('val') + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('light.test') + + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('light.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert '75' == state.attributes.get('val') + + async def test_unique_id(hass): """Test unique id option only creates one light per unique_id.""" await async_mock_mqtt_component(hass) @@ -1077,13 +1177,13 @@ async def test_unique_id(hass): light.DOMAIN: [{ 'platform': 'mqtt', 'name': 'Test 1', - 'status_topic': 'test-topic', + 'state_topic': 'test-topic', 'command_topic': 'test_topic', 'unique_id': 'TOTALLY_UNIQUE' }, { 'platform': 'mqtt', 'name': 'Test 2', - 'status_topic': 'test-topic', + 'state_topic': 'test-topic', 'command_topic': 'test_topic', 'unique_id': 'TOTALLY_UNIQUE' }] @@ -1100,7 +1200,7 @@ async def test_discovery_removal_light(hass, mqtt_mock, caplog): data = ( '{ "name": "Beer",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) @@ -1139,18 +1239,18 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog): async def test_discovery_update_light(hass, mqtt_mock, caplog): - """Test removal of discovered light.""" + """Test update of discovered light.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) data1 = ( '{ "name": "Beer",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) data2 = ( '{ "name": "Milk",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) @@ -1174,6 +1274,39 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('light.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('light.beer') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT light device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) @@ -1213,6 +1346,53 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == '0.1-beta' +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' + + async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" registry = mock_registry(hass, {}) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 691e34104e1..a0ae0ddb2fb 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -90,17 +90,17 @@ light: import json from unittest.mock import ANY, patch -from homeassistant.setup import async_setup_component -from homeassistant.const import ( - STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE, - ATTR_SUPPORTED_FEATURES) from homeassistant.components import light, mqtt from homeassistant.components.mqtt.discovery import async_start +from homeassistant.const import ( + ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE) import homeassistant.core as ha +from homeassistant.setup import async_setup_component from tests.common import ( - mock_coro, async_fire_mqtt_message, async_mock_mqtt_component, - MockConfigEntry, mock_registry) + MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component, + mock_coro, mock_registry) async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): @@ -536,6 +536,111 @@ async def test_custom_availability_payload(hass, mqtt_mock): assert STATE_UNAVAILABLE == state.state +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'json', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('light.test') + + assert '100' == state.attributes.get('val') + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'json', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('light.test') + + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'json', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('light.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "schema": "json",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "schema": "json",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert '75' == state.attributes.get('val') + + async def test_unique_id(hass): """Test unique id option only creates one light per unique_id.""" await async_mock_mqtt_component(hass) @@ -544,14 +649,14 @@ async def test_unique_id(hass): 'platform': 'mqtt', 'name': 'Test 1', 'schema': 'json', - 'status_topic': 'test-topic', + 'state_topic': 'test-topic', 'command_topic': 'test_topic', 'unique_id': 'TOTALLY_UNIQUE' }, { 'platform': 'mqtt', 'name': 'Test 2', 'schema': 'json', - 'status_topic': 'test-topic', + 'state_topic': 'test-topic', 'command_topic': 'test_topic', 'unique_id': 'TOTALLY_UNIQUE' }] @@ -602,20 +707,20 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog): async def test_discovery_update_light(hass, mqtt_mock, caplog): - """Test removal of discovered light.""" + """Test update of discovered light.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) data1 = ( '{ "name": "Beer",' ' "schema": "json",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) data2 = ( '{ "name": "Milk",' ' "schema": "json",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) @@ -639,6 +744,40 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "schema": "json",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('light.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('light.beer') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT light device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) @@ -679,6 +818,54 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == '0.1-beta' +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'schema': 'json', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' + + async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" registry = mock_registry(hass, {}) diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index f9946fc5b88..2db2bd06aa2 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -29,16 +29,16 @@ If your light doesn't support RGB feature, omit `(red|green|blue)_template`. import json from unittest.mock import ANY, patch -from homeassistant.setup import async_setup_component -from homeassistant.const import ( - STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) from homeassistant.components import light, mqtt from homeassistant.components.mqtt.discovery import async_start +from homeassistant.const import ( + ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE) import homeassistant.core as ha +from homeassistant.setup import async_setup_component from tests.common import ( - async_fire_mqtt_message, assert_setup_component, mock_coro, - async_mock_mqtt_component, MockConfigEntry, mock_registry) + MockConfigEntry, assert_setup_component, async_fire_mqtt_message, + async_mock_mqtt_component, mock_coro, mock_registry) async def test_setup_fails(hass, mqtt_mock): @@ -265,7 +265,6 @@ async def test_optimistic(hass, mqtt_mock): '{{ blue|d }}', 'command_off_template': 'off', 'effect_list': ['colorloop', 'random'], - 'effect_command_topic': 'test_light_rgb/effect/set', 'qos': 2 } }) @@ -485,6 +484,121 @@ async def test_custom_availability_payload(hass, mqtt_mock): assert STATE_UNAVAILABLE == state.state +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'template', + 'name': 'test', + 'command_topic': 'test-topic', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('light.test') + + assert '100' == state.attributes.get('val') + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'template', + 'name': 'test', + 'command_topic': 'test-topic', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('light.test') + + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'template', + 'name': 'test', + 'command_topic': 'test-topic', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('light.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "schema": "template",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "schema": "template",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert '75' == state.attributes.get('val') + + async def test_unique_id(hass): """Test unique id option only creates one light per unique_id.""" await async_mock_mqtt_component(hass) @@ -493,7 +607,7 @@ async def test_unique_id(hass): 'platform': 'mqtt', 'name': 'Test 1', 'schema': 'template', - 'status_topic': 'test-topic', + 'state_topic': 'test-topic', 'command_topic': 'test_topic', 'command_on_template': 'on,{{ transition }}', 'command_off_template': 'off,{{ transition|d }}', @@ -502,7 +616,7 @@ async def test_unique_id(hass): 'platform': 'mqtt', 'name': 'Test 2', 'schema': 'template', - 'status_topic': 'test-topic', + 'state_topic': 'test-topic', 'command_topic': 'test_topic', 'unique_id': 'TOTALLY_UNIQUE' }] @@ -512,7 +626,7 @@ async def test_unique_id(hass): assert len(hass.states.async_entity_ids(light.DOMAIN)) == 1 -async def test_discovery(hass, mqtt_mock, caplog): +async def test_discovery_removal(hass, mqtt_mock, caplog): """Test removal of discovered mqtt_json lights.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) @@ -529,6 +643,12 @@ async def test_discovery(hass, mqtt_mock, caplog): state = hass.states.get('light.beer') assert state is not None assert state.name == 'Beer' + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + '') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert state is None async def test_discovery_deprecated(hass, mqtt_mock, caplog): @@ -551,14 +671,14 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog): async def test_discovery_update_light(hass, mqtt_mock, caplog): - """Test removal of discovered light.""" + """Test update of discovered light.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {}, entry) data1 = ( '{ "name": "Beer",' ' "schema": "template",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic",' ' "command_on_template": "on",' ' "command_off_template": "off"}' @@ -566,7 +686,7 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): data2 = ( '{ "name": "Milk",' ' "schema": "template",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic",' ' "command_on_template": "on",' ' "command_off_template": "off"}' @@ -592,6 +712,42 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "schema": "template",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off"}' + ) + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('light.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('light.beer') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT light device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) @@ -634,6 +790,56 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == '0.1-beta' +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'schema': 'template', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' + + async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" registry = mock_registry(hass, {}) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 83ae806d295..52dd3ecfbdb 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -2,14 +2,14 @@ import json from unittest.mock import ANY -from homeassistant.setup import async_setup_component -from homeassistant.const import ( - STATE_LOCKED, STATE_UNLOCKED, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) from homeassistant.components import lock, mqtt from homeassistant.components.mqtt.discovery import async_start +from homeassistant.const import ( + ATTR_ASSUMED_STATE, STATE_LOCKED, STATE_UNAVAILABLE, STATE_UNLOCKED) +from homeassistant.setup import async_setup_component from tests.common import ( - async_fire_mqtt_message, async_mock_mqtt_component, MockConfigEntry, + MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component, mock_registry) @@ -139,6 +139,106 @@ async def test_custom_availability_payload(hass, mqtt_mock): assert state.state is STATE_UNAVAILABLE +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, lock.DOMAIN, { + lock.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('lock.test') + + assert '100' == state.attributes.get('val') + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, lock.DOMAIN, { + lock.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('lock.test') + + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, lock.DOMAIN, { + lock.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('lock.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config', + data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('lock.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('lock.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('lock.beer') + assert '75' == state.attributes.get('val') + + async def test_unique_id(hass): """Test unique id option only creates one light per unique_id.""" await async_mock_mqtt_component(hass) @@ -146,13 +246,13 @@ async def test_unique_id(hass): lock.DOMAIN: [{ 'platform': 'mqtt', 'name': 'Test 1', - 'status_topic': 'test-topic', + 'state_topic': 'test-topic', 'command_topic': 'test_topic', 'unique_id': 'TOTALLY_UNIQUE' }, { 'platform': 'mqtt', 'name': 'Test 2', - 'status_topic': 'test-topic', + 'state_topic': 'test-topic', 'command_topic': 'test_topic', 'unique_id': 'TOTALLY_UNIQUE' }] @@ -289,6 +389,53 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == '0.1-beta' +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' + + async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" registry = mock_registry(hass, {}) diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 9de76ff64f4..027135e8a7a 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -1,22 +1,21 @@ """The tests for the MQTT sensor platform.""" +from datetime import datetime, timedelta import json import unittest - -from datetime import timedelta, datetime from unittest.mock import ANY, patch -import homeassistant.core as ha -from homeassistant.setup import setup_component, async_setup_component from homeassistant.components import mqtt from homeassistant.components.mqtt.discovery import async_start import homeassistant.components.sensor as sensor from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE +import homeassistant.core as ha +from homeassistant.setup import async_setup_component, setup_component import homeassistant.util.dt as dt_util -from tests.common import mock_mqtt_component, fire_mqtt_message, \ - assert_setup_component, async_fire_mqtt_message, \ - async_mock_mqtt_component, MockConfigEntry, mock_registry -from tests.common import get_test_home_assistant, mock_component +from tests.common import ( + MockConfigEntry, assert_setup_component, async_fire_mqtt_message, + async_mock_mqtt_component, fire_mqtt_message, get_test_home_assistant, + mock_component, mock_mqtt_component, mock_registry) class TestSensorMQTT(unittest.TestCase): @@ -333,67 +332,6 @@ class TestSensorMQTT(unittest.TestCase): state.attributes.get('val') assert '100' == state.state - def test_setting_sensor_attribute_via_mqtt_json_topic(self): - """Test the setting of attribute via MQTT with JSON payload.""" - mock_component(self.hass, 'mqtt') - assert setup_component(self.hass, sensor.DOMAIN, { - sensor.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'test-topic', - 'json_attributes_topic': 'attr-topic' - } - }) - - fire_mqtt_message(self.hass, 'attr-topic', '{ "val": "100" }') - self.hass.block_till_done() - state = self.hass.states.get('sensor.test') - - assert '100' == \ - state.attributes.get('val') - - @patch('homeassistant.components.mqtt._LOGGER') - def test_update_with_json_attrs_topic_not_dict(self, mock_logger): - """Test attributes get extracted from a JSON result.""" - mock_component(self.hass, 'mqtt') - assert setup_component(self.hass, sensor.DOMAIN, { - sensor.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'test-topic', - 'json_attributes_topic': 'attr-topic' - } - }) - - fire_mqtt_message(self.hass, 'attr-topic', '[ "list", "of", "things"]') - self.hass.block_till_done() - state = self.hass.states.get('sensor.test') - - assert state.attributes.get('val') is None - mock_logger.warning.assert_called_with( - 'JSON result was not a dictionary') - - @patch('homeassistant.components.mqtt._LOGGER') - def test_update_with_json_attrs_topic_bad_JSON(self, mock_logger): - """Test attributes get extracted from a JSON result.""" - mock_component(self.hass, 'mqtt') - assert setup_component(self.hass, sensor.DOMAIN, { - sensor.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'test-topic', - 'json_attributes_topic': 'attr-topic' - } - }) - - fire_mqtt_message(self.hass, 'attr-topic', 'This is not JSON') - self.hass.block_till_done() - - state = self.hass.states.get('sensor.test') - assert state.attributes.get('val') is None - mock_logger.warning.assert_called_with( - 'Erroneous JSON: %s', 'This is not JSON') - def test_invalid_device_class(self): """Test device_class option with invalid value.""" with assert_setup_component(0): @@ -428,6 +366,106 @@ class TestSensorMQTT(unittest.TestCase): assert 'device_class' not in state.attributes +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, sensor.DOMAIN, { + sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('sensor.test') + + assert '100' == state.attributes.get('val') + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, sensor.DOMAIN, { + sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('sensor.test') + + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, sensor.DOMAIN, { + sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('sensor.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', + data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('sensor.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('sensor.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('sensor.beer') + assert '75' == state.attributes.get('val') + + async def test_unique_id(hass): """Test unique id option only creates one sensor per unique_id.""" await async_mock_mqtt_component(hass) @@ -457,7 +495,7 @@ async def test_discovery_removal_sensor(hass, mqtt_mock, caplog): await async_start(hass, 'homeassistant', {}, entry) data = ( '{ "name": "Beer",' - ' "status_topic": "test_topic" }' + ' "state_topic": "test_topic" }' ) async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', data) @@ -479,11 +517,11 @@ async def test_discovery_update_sensor(hass, mqtt_mock, caplog): await async_start(hass, 'homeassistant', {}, entry) data1 = ( '{ "name": "Beer",' - ' "status_topic": "test_topic" }' + ' "state_topic": "test_topic" }' ) data2 = ( '{ "name": "Milk",' - ' "status_topic": "test_topic" }' + ' "state_topic": "test_topic" }' ) async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', data1) @@ -577,6 +615,52 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == '0.1-beta' +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' + + async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" registry = mock_registry(hass, {}) diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 9f80f753690..2589adf2f9c 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -1,9 +1,9 @@ """The tests for the MQTT component embedded server.""" -from unittest.mock import Mock, MagicMock, patch +from unittest.mock import MagicMock, Mock, patch +import homeassistant.components.mqtt as mqtt from homeassistant.const import CONF_PASSWORD from homeassistant.setup import setup_component -import homeassistant.components.mqtt as mqtt from tests.common import get_test_home_assistant, mock_coro diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py index 69386e2bad4..b4b005d0d1e 100644 --- a/tests/components/mqtt/test_subscription.py +++ b/tests/components/mqtt/test_subscription.py @@ -1,9 +1,9 @@ """The tests for the MQTT subscription component.""" from unittest import mock -from homeassistant.core import callback from homeassistant.components.mqtt.subscription import ( async_subscribe_topics, async_unsubscribe_topics) +from homeassistant.core import callback from tests.common import async_fire_mqtt_message, async_mock_mqtt_component diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index f5adb4062c6..7917803aa07 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -1,19 +1,20 @@ """The tests for the MQTT switch platform.""" import json -from asynctest import patch -import pytest from unittest.mock import ANY -from homeassistant.setup import async_setup_component -from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNAVAILABLE,\ - ATTR_ASSUMED_STATE -import homeassistant.core as ha -from homeassistant.components import switch, mqtt +from asynctest import patch +import pytest + +from homeassistant.components import mqtt, switch from homeassistant.components.mqtt.discovery import async_start +from homeassistant.const import ( + ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE) +import homeassistant.core as ha +from homeassistant.setup import async_setup_component from tests.common import ( - mock_coro, async_mock_mqtt_component, async_fire_mqtt_message, - MockConfigEntry, mock_registry) + MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component, + mock_coro, mock_registry) from tests.components.switch import common @@ -257,6 +258,106 @@ async def test_custom_state_payload(hass, mock_publish): assert STATE_OFF == state.state +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('switch.test') + + assert '100' == state.attributes.get('val') + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('switch.test') + + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('switch.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('switch.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('switch.beer') + assert '75' == state.attributes.get('val') + + async def test_unique_id(hass): """Test unique id option only creates one switch per unique_id.""" await async_mock_mqtt_component(hass) @@ -291,7 +392,7 @@ async def test_discovery_removal_switch(hass, mqtt_mock, caplog): data = ( '{ "name": "Beer",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) @@ -320,12 +421,12 @@ async def test_discovery_update_switch(hass, mqtt_mock, caplog): data1 = ( '{ "name": "Beer",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) data2 = ( '{ "name": "Milk",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) @@ -359,7 +460,7 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ) data2 = ( '{ "name": "Milk",' - ' "status_topic": "test_topic",' + ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) @@ -421,6 +522,53 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == '0.1-beta' +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' + + async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" registry = mock_registry(hass, {}) diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index 356ce44c6cb..6a61495c143 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -1,20 +1,20 @@ """The tests for the Mqtt vacuum platform.""" import json + import pytest -from homeassistant.setup import async_setup_component -from homeassistant.const import ( - CONF_PLATFORM, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, CONF_NAME) -from homeassistant.components import vacuum, mqtt -from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON, ATTR_STATUS, - ATTR_FAN_SPEED) +from homeassistant.components import mqtt, vacuum from homeassistant.components.mqtt import ( CONF_COMMAND_TOPIC, vacuum as mqttvacuum) from homeassistant.components.mqtt.discovery import async_start +from homeassistant.components.vacuum import ( + ATTR_BATTERY_ICON, ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, ATTR_STATUS) +from homeassistant.const import ( + CONF_NAME, CONF_PLATFORM, STATE_OFF, STATE_ON, STATE_UNAVAILABLE) +from homeassistant.setup import async_setup_component + from tests.common import ( - async_mock_mqtt_component, - async_fire_mqtt_message, MockConfigEntry) + MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component) from tests.components.vacuum import common default_config = { @@ -299,6 +299,39 @@ async def test_discovery_removal_vacuum(hass, mock_publish): assert state is None +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic#" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('vacuum.beer') + assert state is None + + async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('vacuum.milk') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('vacuum.beer') + assert state is None + + async def test_discovery_update_vacuum(hass, mock_publish): """Test update of discovered vacuum.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) @@ -333,6 +366,106 @@ async def test_discovery_update_vacuum(hass, mock_publish): assert state is None +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, vacuum.DOMAIN, { + vacuum.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }') + await hass.async_block_till_done() + state = hass.states.get('vacuum.test') + + assert '100' == state.attributes.get('val') + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, vacuum.DOMAIN, { + vacuum.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]') + await hass.async_block_till_done() + state = hass.states.get('vacuum.test') + + assert state.attributes.get('val') is None + assert 'JSON result was not a dictionary' in caplog.text + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, vacuum.DOMAIN, { + vacuum.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON') + await hass.async_block_till_done() + + state = hass.states.get('vacuum.test') + assert state.attributes.get('val') is None + assert 'Erroneous JSON: This is not JSON' in caplog.text + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic1" }' + ) + data2 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic",' + ' "json_attributes_topic": "attr-topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', + data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('vacuum.beer') + assert '100' == state.attributes.get('val') + + # Change json_attributes_topic + async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('vacuum.beer') + assert '100' == state.attributes.get('val') + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }') + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('vacuum.beer') + assert '75' == state.attributes.get('val') + + async def test_unique_id(hass, mock_publish): """Test unique id option only creates one vacuum per unique_id.""" await async_mock_mqtt_component(hass) @@ -394,3 +527,50 @@ async def test_entity_device_info_with_identifier(hass, mock_publish): assert device.name == 'Beer' assert device.model == 'Glass' assert device.sw_version == '0.1-beta' + + +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Beer' + + config['device']['name'] = 'Milk' + data = json.dumps(config) + async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.name == 'Milk' diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 7b03c7c3d8a..d5cd692b68b 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -170,5 +170,5 @@ class TestRecorderPurge(unittest.TestCase): service_data=service_data) self.hass.block_till_done() self.hass.data[DATA_INSTANCE].block_till_done() - assert mock_logger.debug.mock_calls[4][1][0] == \ + assert mock_logger.debug.mock_calls[3][1][0] == \ "Vacuuming SQLite to free space" diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py index 9ab8d61f739..673cadd6208 100644 --- a/tests/components/sensor/test_dsmr.py +++ b/tests/components/sensor/test_dsmr.py @@ -12,7 +12,6 @@ from unittest.mock import Mock import asynctest from homeassistant.bootstrap import async_setup_component from homeassistant.components.sensor.dsmr import DerivativeDSMREntity -from homeassistant.const import STATE_UNKNOWN import pytest from tests.common import assert_setup_component @@ -99,7 +98,7 @@ def test_derivative(): entity = DerivativeDSMREntity('test', '1.0.0') yield from entity.async_update() - assert entity.state == STATE_UNKNOWN, 'initial state not unknown' + assert entity.state is None, 'initial state not unknown' entity.telegram = { '1.0.0': MBusObject([ @@ -109,7 +108,7 @@ def test_derivative(): } yield from entity.async_update() - assert entity.state == STATE_UNKNOWN, \ + assert entity.state is None, \ 'state after first update should still be unknown' entity.telegram = { diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 3d44b7d131d..29718314ef4 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -5,7 +5,7 @@ from unittest.mock import patch from homeassistant.components.sensor.filter import ( LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter, - RangeFilter) + RangeFilter, TimeThrottleFilter) import homeassistant.util.dt as dt_util from homeassistant.setup import setup_component import homeassistant.core as ha @@ -178,6 +178,18 @@ class TestFilterSensor(unittest.TestCase): filtered.append(new_state) assert [20, 21] == [f.state for f in filtered] + def test_time_throttle(self): + """Test if lowpass filter works.""" + filt = TimeThrottleFilter(window_size=timedelta(minutes=2), + precision=2, + entity=None) + filtered = [] + for state in self.values: + new_state = filt.filter_state(state) + if not filt.skip_processing: + filtered.append(new_state) + assert [20, 18, 22] == [f.state for f in filtered] + def test_time_sma(self): """Test if time_sma filter works.""" filt = TimeSMAFilter(window_size=timedelta(minutes=2), diff --git a/tests/components/sensor/test_google_wifi.py b/tests/components/sensor/test_google_wifi.py index a4b18d0ed4a..989cd13c5d6 100644 --- a/tests/components/sensor/test_google_wifi.py +++ b/tests/components/sensor/test_google_wifi.py @@ -169,7 +169,7 @@ class TestGoogleWifiSensor(unittest.TestCase): sensor = self.sensor_dict[name]['sensor'] self.fake_delay(2) sensor.update() - assert STATE_UNKNOWN == sensor.state + assert sensor.state is None @requests_mock.Mocker() def test_update_when_value_changed(self, mock_req): @@ -213,7 +213,7 @@ class TestGoogleWifiSensor(unittest.TestCase): for name in self.sensor_dict: sensor = self.sensor_dict[name]['sensor'] sensor.update() - assert STATE_UNKNOWN == sensor.state + assert sensor.state is None def update_side_effect(self): """Mock representation of update function.""" diff --git a/tests/components/sensor/test_history_stats.py b/tests/components/sensor/test_history_stats.py index 67cacb29880..28d01de4b34 100644 --- a/tests/components/sensor/test_history_stats.py +++ b/tests/components/sensor/test_history_stats.py @@ -1,8 +1,11 @@ """The test for the History Statistics sensor platform.""" # pylint: disable=protected-access -from datetime import timedelta +from datetime import datetime, timedelta import unittest from unittest.mock import patch +import pytest +import pytz +from homeassistant.helpers import template from homeassistant.const import STATE_UNKNOWN from homeassistant.setup import setup_component @@ -12,7 +15,6 @@ from homeassistant.helpers.template import Template import homeassistant.util.dt as dt_util from tests.common import init_recorder_component, get_test_home_assistant -import pytest class TestHistoryStatsSensor(unittest.TestCase): @@ -50,19 +52,22 @@ class TestHistoryStatsSensor(unittest.TestCase): def test_period_parsing(self): """Test the conversion from templates to period.""" - today = Template('{{ now().replace(hour=0).replace(minute=0)' - '.replace(second=0) }}', self.hass) - duration = timedelta(hours=2, minutes=1) + now = datetime(2019, 1, 1, 23, 30, 0, tzinfo=pytz.utc) + with patch.dict(template.ENV.globals, {'now': lambda: now}): + print(dt_util.now()) + today = Template('{{ now().replace(hour=0).replace(minute=0)' + '.replace(second=0) }}', self.hass) + duration = timedelta(hours=2, minutes=1) - sensor1 = HistoryStatsSensor( - self.hass, 'test', 'on', today, None, duration, 'time', 'test') - sensor2 = HistoryStatsSensor( - self.hass, 'test', 'on', None, today, duration, 'time', 'test') + sensor1 = HistoryStatsSensor( + self.hass, 'test', 'on', today, None, duration, 'time', 'test') + sensor2 = HistoryStatsSensor( + self.hass, 'test', 'on', None, today, duration, 'time', 'test') - sensor1.update_period() - sensor1_start, sensor1_end = sensor1._period - sensor2.update_period() - sensor2_start, sensor2_end = sensor2._period + sensor1.update_period() + sensor1_start, sensor1_end = sensor1._period + sensor2.update_period() + sensor2_start, sensor2_end = sensor2._period # Start = 00:00:00 assert sensor1_start.hour == 0 diff --git a/tests/components/sensor/test_integration.py b/tests/components/sensor/test_integration.py new file mode 100644 index 00000000000..bb4a02c042b --- /dev/null +++ b/tests/components/sensor/test_integration.py @@ -0,0 +1,104 @@ +"""The tests for the integration sensor platform.""" +from datetime import timedelta +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + + +async def test_state(hass): + """Test integration sensor state.""" + config = { + 'sensor': { + 'platform': 'integration', + 'name': 'integration', + 'source': 'sensor.power', + 'unit': 'kWh', + 'round': 2, + } + } + + assert await async_setup_component(hass, 'sensor', config) + + entity_id = config['sensor']['source'] + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + now = dt_util.utcnow() + timedelta(seconds=3600) + with patch('homeassistant.util.dt.utcnow', + return_value=now): + hass.states.async_set(entity_id, 1, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get('sensor.integration') + assert state is not None + + # Testing a power sensor at 1 KiloWatts for 1hour = 1kWh + assert round(float(state.state), config['sensor']['round']) == 1.0 + + assert state.attributes.get('unit_of_measurement') == 'kWh' + + +async def test_prefix(hass): + """Test integration sensor state using a power source.""" + config = { + 'sensor': { + 'platform': 'integration', + 'name': 'integration', + 'source': 'sensor.power', + 'round': 2, + 'unit_prefix': 'k' + } + } + + assert await async_setup_component(hass, 'sensor', config) + + entity_id = config['sensor']['source'] + hass.states.async_set(entity_id, 1000, {'unit_of_measurement': 'W'}) + await hass.async_block_till_done() + + now = dt_util.utcnow() + timedelta(seconds=3600) + with patch('homeassistant.util.dt.utcnow', + return_value=now): + hass.states.async_set(entity_id, 1000, {'unit_of_measurement': 'W'}, + force_update=True) + await hass.async_block_till_done() + + state = hass.states.get('sensor.integration') + assert state is not None + + # Testing a power sensor at 1000 Watts for 1hour = 1kWh + assert round(float(state.state), config['sensor']['round']) == 1.0 + assert state.attributes.get('unit_of_measurement') == 'kWh' + + +async def test_suffix(hass): + """Test integration sensor state using a network counter source.""" + config = { + 'sensor': { + 'platform': 'integration', + 'name': 'integration', + 'source': 'sensor.bytes_per_second', + 'round': 2, + 'unit_prefix': 'k', + 'unit_time': 's' + } + } + + assert await async_setup_component(hass, 'sensor', config) + + entity_id = config['sensor']['source'] + hass.states.async_set(entity_id, 1000, {}) + await hass.async_block_till_done() + + now = dt_util.utcnow() + timedelta(seconds=10) + with patch('homeassistant.util.dt.utcnow', + return_value=now): + hass.states.async_set(entity_id, 1000, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get('sensor.integration') + assert state is not None + + # Testing a network speed sensor at 1000 bytes/s over 10s = 10kbytes + assert round(float(state.state), config['sensor']['round']) == 10.0 diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index 05ab628b1a8..3e71be8a6f6 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -11,7 +11,6 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.setup import setup_component import homeassistant.components.sensor as sensor import homeassistant.components.sensor.rest as rest -from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers.config_validation import template from tests.common import get_test_home_assistant, assert_setup_component @@ -175,7 +174,7 @@ class TestRestSensor(unittest.TestCase): self.rest.update = Mock( 'rest.RestData.update', side_effect=self.update_side_effect(None)) self.sensor.update() - assert STATE_UNKNOWN == self.sensor.state + assert self.sensor.state is None assert not self.sensor.available def test_update_when_value_changed(self): diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py new file mode 100644 index 00000000000..5a3e9135963 --- /dev/null +++ b/tests/components/smartthings/__init__.py @@ -0,0 +1 @@ +"""Tests for the SmartThings component.""" diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py new file mode 100644 index 00000000000..7358e05f346 --- /dev/null +++ b/tests/components/smartthings/conftest.py @@ -0,0 +1,285 @@ +"""Test configuration and mocks for the SmartThings component.""" +from collections import defaultdict +from unittest.mock import Mock, patch +from uuid import uuid4 + +from pysmartthings import ( + CLASSIFICATION_AUTOMATION, AppEntity, AppSettings, DeviceEntity, + InstalledApp, Location) +from pysmartthings.api import Api +import pytest + +from homeassistant.components import webhook +from homeassistant.components.smartthings.const import ( + APP_NAME_PREFIX, CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_INSTANCE_ID, + CONF_LOCATION_ID, DOMAIN, SETTINGS_INSTANCE_ID, STORAGE_KEY, + STORAGE_VERSION) +from homeassistant.config_entries import ( + CONN_CLASS_CLOUD_PUSH, SOURCE_USER, ConfigEntry) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_WEBHOOK_ID +from homeassistant.setup import async_setup_component + +from tests.common import mock_coro + + +@pytest.fixture(autouse=True) +async def setup_component(hass, config_file, hass_storage): + """Load the SmartThing component.""" + hass_storage[STORAGE_KEY] = {'data': config_file, + "version": STORAGE_VERSION} + await async_setup_component(hass, 'smartthings', {}) + hass.config.api.base_url = 'https://test.local' + + +def _create_location(): + loc = Location() + loc.apply_data({ + 'name': 'Test Location', + 'locationId': str(uuid4()) + }) + return loc + + +@pytest.fixture(name='location') +def location_fixture(): + """Fixture for a single location.""" + return _create_location() + + +@pytest.fixture(name='locations') +def locations_fixture(location): + """Fixture for 2 locations.""" + return [location, _create_location()] + + +@pytest.fixture(name="app") +def app_fixture(hass, config_file): + """Fixture for a single app.""" + app = AppEntity(Mock()) + app.apply_data({ + 'appName': APP_NAME_PREFIX + str(uuid4()), + 'appId': str(uuid4()), + 'appType': 'WEBHOOK_SMART_APP', + 'classifications': [CLASSIFICATION_AUTOMATION], + 'displayName': 'Home Assistant', + 'description': "Home Assistant at " + hass.config.api.base_url, + 'singleInstance': True, + 'webhookSmartApp': { + 'targetUrl': webhook.async_generate_url( + hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]), + 'publicKey': ''} + }) + app.refresh = Mock() + app.refresh.return_value = mock_coro() + app.save = Mock() + app.save.return_value = mock_coro() + settings = AppSettings(app.app_id) + settings.settings[SETTINGS_INSTANCE_ID] = config_file[CONF_INSTANCE_ID] + app.settings = Mock() + app.settings.return_value = mock_coro(return_value=settings) + return app + + +@pytest.fixture(name='app_settings') +def app_settings_fixture(app, config_file): + """Fixture for an app settings.""" + settings = AppSettings(app.app_id) + settings.settings[SETTINGS_INSTANCE_ID] = config_file[CONF_INSTANCE_ID] + return settings + + +def _create_installed_app(location_id, app_id): + item = InstalledApp() + item.apply_data(defaultdict(str, { + 'installedAppId': str(uuid4()), + 'installedAppStatus': 'AUTHORIZED', + 'installedAppType': 'UNKNOWN', + 'appId': app_id, + 'locationId': location_id + })) + return item + + +@pytest.fixture(name='installed_app') +def installed_app_fixture(location, app): + """Fixture for a single installed app.""" + return _create_installed_app(location.location_id, app.app_id) + + +@pytest.fixture(name='installed_apps') +def installed_apps_fixture(installed_app, locations, app): + """Fixture for 2 installed apps.""" + return [installed_app, + _create_installed_app(locations[1].location_id, app.app_id)] + + +@pytest.fixture(name='config_file') +def config_file_fixture(): + """Fixture representing the local config file contents.""" + return { + CONF_INSTANCE_ID: str(uuid4()), + CONF_WEBHOOK_ID: webhook.generate_secret() + } + + +@pytest.fixture(name='smartthings_mock') +def smartthings_mock_fixture(locations): + """Fixture to mock smartthings API calls.""" + def _location(location_id): + return mock_coro( + return_value=next(location for location in locations + if location.location_id == location_id)) + + with patch("pysmartthings.SmartThings", autospec=True) as mock: + mock.return_value.location.side_effect = _location + yield mock + + +@pytest.fixture(name='device') +def device_fixture(location): + """Fixture representing devices loaded.""" + item = DeviceEntity(None) + item.status.refresh = Mock() + item.status.refresh.return_value = mock_coro() + item.apply_data({ + "deviceId": "743de49f-036f-4e9c-839a-2f89d57607db", + "name": "GE In-Wall Smart Dimmer", + "label": "Front Porch Lights", + "deviceManufacturerCode": "0063-4944-3038", + "locationId": location.location_id, + "deviceTypeId": "8a9d4b1e3b9b1fe3013b9b206a7f000d", + "deviceTypeName": "Dimmer Switch", + "deviceNetworkType": "ZWAVE", + "components": [ + { + "id": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "indicator", + "version": 1 + }, + { + "id": "sensor", + "version": 1 + }, + { + "id": "actuator", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "light", + "version": 1 + } + ] + } + ], + "dth": { + "deviceTypeId": "8a9d4b1e3b9b1fe3013b9b206a7f000d", + "deviceTypeName": "Dimmer Switch", + "deviceNetworkType": "ZWAVE", + "completedSetup": False + }, + "type": "DTH" + }) + return item + + +@pytest.fixture(name='config_entry') +def config_entry_fixture(hass, installed_app, location): + """Fixture representing a config entry.""" + data = { + CONF_ACCESS_TOKEN: str(uuid4()), + CONF_INSTALLED_APP_ID: installed_app.installed_app_id, + CONF_APP_ID: installed_app.app_id, + CONF_LOCATION_ID: location.location_id + } + return ConfigEntry("1", DOMAIN, location.name, data, SOURCE_USER, + CONN_CLASS_CLOUD_PUSH) + + +@pytest.fixture(name="device_factory") +def device_factory_fixture(): + """Fixture for creating mock devices.""" + api = Mock(spec=Api) + api.post_device_command.return_value = mock_coro(return_value={}) + + def _factory(label, capabilities, status: dict = None): + device_data = { + "deviceId": str(uuid4()), + "name": "Device Type Handler Name", + "label": label, + "deviceManufacturerCode": "9135fc86-0929-4436-bf73-5d75f523d9db", + "locationId": "fcd829e9-82f4-45b9-acfd-62fda029af80", + "components": [ + { + "id": "main", + "capabilities": [ + {"id": capability, "version": 1} + for capability in capabilities + ] + } + ], + "dth": { + "deviceTypeId": "b678b29d-2726-4e4f-9c3f-7aa05bd08964", + "deviceTypeName": "Switch", + "deviceNetworkType": "ZWAVE" + }, + "type": "DTH" + } + device = DeviceEntity(api, data=device_data) + if status: + for attribute, value in status.items(): + device.status.apply_attribute_update( + 'main', '', attribute, value) + return device + return _factory + + +@pytest.fixture(name="event_factory") +def event_factory_fixture(): + """Fixture for creating mock devices.""" + def _factory(device_id, event_type="DEVICE_EVENT", capability='', + attribute='Updated', value='Value'): + event = Mock() + event.event_type = event_type + event.device_id = device_id + event.component_id = 'main' + event.capability = capability + event.attribute = attribute + event.value = value + event.location_id = str(uuid4()) + return event + return _factory + + +@pytest.fixture(name="event_request_factory") +def event_request_factory_fixture(event_factory): + """Fixture for creating mock smartapp event requests.""" + def _factory(device_ids=None, events=None): + request = Mock() + request.installed_app_id = uuid4() + if events is None: + events = [] + if device_ids: + events.extend([event_factory(id) for id in device_ids]) + events.append(event_factory(uuid4())) + events.append(event_factory(device_ids[0], event_type="OTHER")) + request.events = events + return request + return _factory diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py new file mode 100644 index 00000000000..92d891c06d6 --- /dev/null +++ b/tests/components/smartthings/test_binary_sensor.py @@ -0,0 +1,113 @@ +""" +Test for the SmartThings binary_sensor platform. + +The only mocking required is of the underlying SmartThings API object so +real HTTP calls are not initiated during testing. +""" +from pysmartthings import Attribute, Capability + +from homeassistant.components.binary_sensor import DEVICE_CLASSES +from homeassistant.components.smartthings import DeviceBroker, binary_sensor +from homeassistant.components.smartthings.const import ( + DATA_BROKERS, DOMAIN, SIGNAL_SMARTTHINGS_UPDATE, SUPPORTED_CAPABILITIES) +from homeassistant.config_entries import ( + CONN_CLASS_CLOUD_PUSH, SOURCE_USER, ConfigEntry) +from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.helpers.dispatcher import async_dispatcher_send + + +async def _setup_platform(hass, *devices): + """Set up the SmartThings binary_sensor platform and prerequisites.""" + hass.config.components.add(DOMAIN) + broker = DeviceBroker(hass, devices, '') + config_entry = ConfigEntry("1", DOMAIN, "Test", {}, + SOURCE_USER, CONN_CLASS_CLOUD_PUSH) + hass.data[DOMAIN] = { + DATA_BROKERS: { + config_entry.entry_id: broker + } + } + await hass.config_entries.async_forward_entry_setup( + config_entry, 'binary_sensor') + await hass.async_block_till_done() + return config_entry + + +async def test_mapping_integrity(): + """Test ensures the map dicts have proper integrity.""" + # Ensure every CAPABILITY_TO_ATTRIB key is in SUPPORTED_CAPABILITIES + # Ensure every CAPABILITY_TO_ATTRIB value is in ATTRIB_TO_CLASS keys + for capability, attrib in binary_sensor.CAPABILITY_TO_ATTRIB.items(): + assert capability in SUPPORTED_CAPABILITIES, capability + assert attrib in binary_sensor.ATTRIB_TO_CLASS.keys(), attrib + # Ensure every ATTRIB_TO_CLASS value is in DEVICE_CLASSES + for device_class in binary_sensor.ATTRIB_TO_CLASS.values(): + assert device_class in DEVICE_CLASSES + + +async def test_async_setup_platform(): + """Test setup platform does nothing (it uses config entries).""" + await binary_sensor.async_setup_platform(None, None, None) + + +async def test_entity_state(hass, device_factory): + """Tests the state attributes properly match the light types.""" + device = device_factory('Motion Sensor 1', [Capability.motion_sensor], + {Attribute.motion: 'inactive'}) + await _setup_platform(hass, device) + state = hass.states.get('binary_sensor.motion_sensor_1_motion') + assert state.state == 'off' + assert state.attributes[ATTR_FRIENDLY_NAME] ==\ + device.label + ' ' + Attribute.motion + + +async def test_entity_and_device_attributes(hass, device_factory): + """Test the attributes of the entity are correct.""" + # Arrange + device = device_factory('Motion Sensor 1', [Capability.motion_sensor], + {Attribute.motion: 'inactive'}) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + device_registry = await hass.helpers.device_registry.async_get_registry() + # Act + await _setup_platform(hass, device) + # Assert + entry = entity_registry.async_get('binary_sensor.motion_sensor_1_motion') + assert entry + assert entry.unique_id == device.device_id + '.' + Attribute.motion + entry = device_registry.async_get_device( + {(DOMAIN, device.device_id)}, []) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == 'Unavailable' + + +async def test_update_from_signal(hass, device_factory): + """Test the binary_sensor updates when receiving a signal.""" + # Arrange + device = device_factory('Motion Sensor 1', [Capability.motion_sensor], + {Attribute.motion: 'inactive'}) + await _setup_platform(hass, device) + device.status.apply_attribute_update( + 'main', Capability.motion_sensor, Attribute.motion, 'active') + # Act + async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, + [device.device_id]) + # Assert + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.motion_sensor_1_motion') + assert state is not None + assert state.state == 'on' + + +async def test_unload_config_entry(hass, device_factory): + """Test the binary_sensor is removed when the config entry is unloaded.""" + # Arrange + device = device_factory('Motion Sensor 1', [Capability.motion_sensor], + {Attribute.motion: 'inactive'}) + config_entry = await _setup_platform(hass, device) + # Act + await hass.config_entries.async_forward_entry_unload( + config_entry, 'binary_sensor') + # Assert + assert not hass.states.get('binary_sensor.motion_sensor_1_motion') diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py new file mode 100644 index 00000000000..4d2a43a52c7 --- /dev/null +++ b/tests/components/smartthings/test_config_flow.py @@ -0,0 +1,245 @@ +"""Tests for the SmartThings config flow module.""" +from unittest.mock import patch +from uuid import uuid4 + +from aiohttp.client_exceptions import ClientResponseError + +from homeassistant import data_entry_flow +from homeassistant.components.smartthings.config_flow import ( + SmartThingsFlowHandler) +from homeassistant.config_entries import ConfigEntry + +from tests.common import mock_coro + + +async def test_step_user(hass): + """Test the access token form is shown for a user initiated flow.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + result = await flow.async_step_user() + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_init(hass): + """Test the access token form is shown for an init flow.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + result = await flow.async_step_import() + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_base_url_not_https(hass): + """Test the base_url parameter starts with https://.""" + hass.config.api.base_url = 'http://0.0.0.0' + flow = SmartThingsFlowHandler() + flow.hass = hass + result = await flow.async_step_import() + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'base': 'base_url_not_https'} + + +async def test_invalid_token_format(hass): + """Test an error is shown for invalid token formats.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + result = await flow.async_step_user({'access_token': '123456789'}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'access_token': 'token_invalid_format'} + + +async def test_token_already_setup(hass): + """Test an error is shown when the token is already setup.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + token = str(uuid4()) + entries = [ConfigEntry( + version='', domain='', title='', data={'access_token': token}, + source='', connection_class='')] + + with patch.object(hass.config_entries, 'async_entries', + return_value=entries): + result = await flow.async_step_user({'access_token': token}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'access_token': 'token_already_setup'} + + +async def test_token_unauthorized(hass, smartthings_mock): + """Test an error is shown when the token is not authorized.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + smartthings_mock.return_value.apps.return_value = mock_coro( + exception=ClientResponseError(None, None, status=401)) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'access_token': 'token_unauthorized'} + + +async def test_token_forbidden(hass, smartthings_mock): + """Test an error is shown when the token is forbidden.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + smartthings_mock.return_value.apps.return_value = mock_coro( + exception=ClientResponseError(None, None, status=403)) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'access_token': 'token_forbidden'} + + +async def test_unknown_api_error(hass, smartthings_mock): + """Test an error is shown when there is an unknown API error.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + smartthings_mock.return_value.apps.return_value = mock_coro( + exception=ClientResponseError(None, None, status=500)) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'base': 'app_setup_error'} + + +async def test_unknown_error(hass, smartthings_mock): + """Test an error is shown when there is an unknown API error.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + smartthings_mock.return_value.apps.return_value = mock_coro( + exception=Exception('Unknown error')) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'base': 'app_setup_error'} + + +async def test_app_created_then_show_wait_form(hass, app, smartthings_mock): + """Test SmartApp is created when one does not exist and shows wait form.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + smartthings = smartthings_mock.return_value + smartthings.apps.return_value = mock_coro(return_value=[]) + smartthings.create_app.return_value = mock_coro(return_value=(app, None)) + smartthings.update_app_settings.return_value = mock_coro() + smartthings.update_app_oauth.return_value = mock_coro() + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'wait_install' + + +async def test_app_updated_then_show_wait_form( + hass, app, smartthings_mock): + """Test SmartApp is updated when an existing is already created.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + api = smartthings_mock.return_value + api.apps.return_value = mock_coro(return_value=[app]) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'wait_install' + + +async def test_wait_form_displayed(hass): + """Test the wait for installation form is displayed.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + result = await flow.async_step_wait_install(None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'wait_install' + + +async def test_wait_form_displayed_after_checking(hass, smartthings_mock): + """Test error is shown when the user has not installed the app.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + flow.access_token = str(uuid4()) + flow.api = smartthings_mock.return_value + flow.api.installed_apps.return_value = mock_coro(return_value=[]) + + result = await flow.async_step_wait_install({}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'wait_install' + assert result['errors'] == {'base': 'app_not_installed'} + + +async def test_config_entry_created_when_installed( + hass, location, installed_app, smartthings_mock): + """Test a config entry is created once the app is installed.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + flow.access_token = str(uuid4()) + flow.api = smartthings_mock.return_value + flow.app_id = installed_app.app_id + flow.api.installed_apps.return_value = \ + mock_coro(return_value=[installed_app]) + + result = await flow.async_step_wait_install({}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['app_id'] == installed_app.app_id + assert result['data']['installed_app_id'] == \ + installed_app.installed_app_id + assert result['data']['location_id'] == installed_app.location_id + assert result['data']['access_token'] == flow.access_token + assert result['title'] == location.name + + +async def test_multiple_config_entry_created_when_installed( + hass, app, locations, installed_apps, smartthings_mock): + """Test a config entries are created for multiple installs.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + flow.access_token = str(uuid4()) + flow.app_id = app.app_id + flow.api = smartthings_mock.return_value + flow.api.installed_apps.return_value = \ + mock_coro(return_value=installed_apps) + + result = await flow.async_step_wait_install({}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['app_id'] == installed_apps[0].app_id + assert result['data']['installed_app_id'] == \ + installed_apps[0].installed_app_id + assert result['data']['location_id'] == installed_apps[0].location_id + assert result['data']['access_token'] == flow.access_token + assert result['title'] == locations[0].name + + await hass.async_block_till_done() + entries = hass.config_entries.async_entries('smartthings') + assert len(entries) == 1 + assert entries[0].data['app_id'] == installed_apps[1].app_id + assert entries[0].data['installed_app_id'] == \ + installed_apps[1].installed_app_id + assert entries[0].data['location_id'] == installed_apps[1].location_id + assert entries[0].data['access_token'] == flow.access_token + assert entries[0].title == locations[1].name diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py new file mode 100644 index 00000000000..99627e866d9 --- /dev/null +++ b/tests/components/smartthings/test_fan.py @@ -0,0 +1,213 @@ +""" +Test for the SmartThings fan platform. + +The only mocking required is of the underlying SmartThings API object so +real HTTP calls are not initiated during testing. +""" +from pysmartthings import Attribute, Capability + +from homeassistant.components.fan import ( + ATTR_SPEED, ATTR_SPEED_LIST, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, + SPEED_OFF, SUPPORT_SET_SPEED) +from homeassistant.components.smartthings import DeviceBroker, fan +from homeassistant.components.smartthings.const import ( + DATA_BROKERS, DOMAIN, SIGNAL_SMARTTHINGS_UPDATE) +from homeassistant.config_entries import ( + CONN_CLASS_CLOUD_PUSH, SOURCE_USER, ConfigEntry) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES +from homeassistant.helpers.dispatcher import async_dispatcher_send + + +async def _setup_platform(hass, *devices): + """Set up the SmartThings fan platform and prerequisites.""" + hass.config.components.add(DOMAIN) + broker = DeviceBroker(hass, devices, '') + config_entry = ConfigEntry("1", DOMAIN, "Test", {}, + SOURCE_USER, CONN_CLASS_CLOUD_PUSH) + hass.data[DOMAIN] = { + DATA_BROKERS: { + config_entry.entry_id: broker + } + } + await hass.config_entries.async_forward_entry_setup(config_entry, 'fan') + await hass.async_block_till_done() + return config_entry + + +async def test_async_setup_platform(): + """Test setup platform does nothing (it uses config entries).""" + await fan.async_setup_platform(None, None, None) + + +def test_is_fan(device_factory): + """Test fans are correctly identified.""" + non_fans = [ + device_factory('Unknown', ['Unknown']), + device_factory("Switch 1", [Capability.switch]), + device_factory("Non-Switchable Fan", [Capability.fan_speed]), + device_factory("Color Light", + [Capability.switch, Capability.switch_level, + Capability.color_control, + Capability.color_temperature]) + ] + fan_device = device_factory( + "Fan 1", [Capability.switch, Capability.switch_level, + Capability.fan_speed]) + + assert fan.is_fan(fan_device), fan_device.name + for device in non_fans: + assert not fan.is_fan(device), device.name + + +async def test_entity_state(hass, device_factory): + """Tests the state attributes properly match the fan types.""" + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={Attribute.switch: 'on', Attribute.fan_speed: 2}) + await _setup_platform(hass, device) + + # Dimmer 1 + state = hass.states.get('fan.fan_1') + assert state.state == 'on' + assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_SET_SPEED + assert state.attributes[ATTR_SPEED] == SPEED_MEDIUM + assert state.attributes[ATTR_SPEED_LIST] == \ + [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + +async def test_entity_and_device_attributes(hass, device_factory): + """Test the attributes of the entity are correct.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={Attribute.switch: 'on', Attribute.fan_speed: 2}) + await _setup_platform(hass, device) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + device_registry = await hass.helpers.device_registry.async_get_registry() + # Act + await _setup_platform(hass, device) + # Assert + entry = entity_registry.async_get("fan.fan_1") + assert entry + assert entry.unique_id == device.device_id + + entry = device_registry.async_get_device( + {(DOMAIN, device.device_id)}, []) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == 'Unavailable' + + +async def test_turn_off(hass, device_factory): + """Test the fan turns of successfully.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={Attribute.switch: 'on', Attribute.fan_speed: 2}) + await _setup_platform(hass, device) + # Act + await hass.services.async_call( + 'fan', 'turn_off', {'entity_id': 'fan.fan_1'}, + blocking=True) + # Assert + state = hass.states.get('fan.fan_1') + assert state is not None + assert state.state == 'off' + + +async def test_turn_on(hass, device_factory): + """Test the fan turns of successfully.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={Attribute.switch: 'off', Attribute.fan_speed: 0}) + await _setup_platform(hass, device) + # Act + await hass.services.async_call( + 'fan', 'turn_on', {ATTR_ENTITY_ID: "fan.fan_1"}, + blocking=True) + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.state == 'on' + + +async def test_turn_on_with_speed(hass, device_factory): + """Test the fan turns on to the specified speed.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={Attribute.switch: 'off', Attribute.fan_speed: 0}) + await _setup_platform(hass, device) + # Act + await hass.services.async_call( + 'fan', 'turn_on', + {ATTR_ENTITY_ID: "fan.fan_1", + ATTR_SPEED: SPEED_HIGH}, + blocking=True) + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.state == 'on' + assert state.attributes[ATTR_SPEED] == SPEED_HIGH + + +async def test_set_speed(hass, device_factory): + """Test setting to specific fan speed.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={Attribute.switch: 'off', Attribute.fan_speed: 0}) + await _setup_platform(hass, device) + # Act + await hass.services.async_call( + 'fan', 'set_speed', + {ATTR_ENTITY_ID: "fan.fan_1", + ATTR_SPEED: SPEED_HIGH}, + blocking=True) + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.state == 'on' + assert state.attributes[ATTR_SPEED] == SPEED_HIGH + + +async def test_update_from_signal(hass, device_factory): + """Test the fan updates when receiving a signal.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={Attribute.switch: 'off', Attribute.fan_speed: 0}) + await _setup_platform(hass, device) + await device.switch_on(True) + # Act + async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, + [device.device_id]) + # Assert + await hass.async_block_till_done() + state = hass.states.get('fan.fan_1') + assert state is not None + assert state.state == 'on' + + +async def test_unload_config_entry(hass, device_factory): + """Test the fan is removed when the config entry is unloaded.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={Attribute.switch: 'off', Attribute.fan_speed: 0}) + config_entry = await _setup_platform(hass, device) + # Act + await hass.config_entries.async_forward_entry_unload( + config_entry, 'fan') + # Assert + assert not hass.states.get('fan.fan_1') diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py new file mode 100644 index 00000000000..4aef42c1b6f --- /dev/null +++ b/tests/components/smartthings/test_init.py @@ -0,0 +1,212 @@ +"""Tests for the SmartThings component init module.""" +from unittest.mock import Mock, patch +from uuid import uuid4 + +from aiohttp import ClientConnectionError, ClientResponseError +from pysmartthings import InstalledAppStatus +import pytest + +from homeassistant.components import smartthings +from homeassistant.components.smartthings.const import ( + DATA_BROKERS, DOMAIN, EVENT_BUTTON, SIGNAL_SMARTTHINGS_UPDATE, + SUPPORTED_PLATFORMS) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from tests.common import mock_coro + + +async def test_unrecoverable_api_errors_create_new_flow( + hass, config_entry, smartthings_mock): + """ + Test a new config flow is initiated when there are API errors. + + 401 (unauthorized): Occurs when the access token is no longer valid. + 403 (forbidden/not found): Occurs when the app or installed app could + not be retrieved/found (likely deleted?) + """ + api = smartthings_mock.return_value + for error_status in (401, 403): + setattr(hass.config_entries, '_entries', [config_entry]) + api.app.return_value = mock_coro( + exception=ClientResponseError(None, None, + status=error_status)) + + # Assert setup returns false + result = await smartthings.async_setup_entry(hass, config_entry) + assert not result + + # Assert entry was removed and new flow created + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]['handler'] == 'smartthings' + assert flows[0]['context'] == {'source': 'import'} + hass.config_entries.flow.async_abort(flows[0]['flow_id']) + + +async def test_recoverable_api_errors_raise_not_ready( + hass, config_entry, smartthings_mock): + """Test config entry not ready raised for recoverable API errors.""" + setattr(hass.config_entries, '_entries', [config_entry]) + api = smartthings_mock.return_value + api.app.return_value = mock_coro( + exception=ClientResponseError(None, None, status=500)) + + with pytest.raises(ConfigEntryNotReady): + await smartthings.async_setup_entry(hass, config_entry) + + +async def test_connection_errors_raise_not_ready( + hass, config_entry, smartthings_mock): + """Test config entry not ready raised for connection errors.""" + setattr(hass.config_entries, '_entries', [config_entry]) + api = smartthings_mock.return_value + api.app.return_value = mock_coro( + exception=ClientConnectionError()) + + with pytest.raises(ConfigEntryNotReady): + await smartthings.async_setup_entry(hass, config_entry) + + +async def test_base_url_no_longer_https_does_not_load( + hass, config_entry, app, smartthings_mock): + """Test base_url no longer valid creates a new flow.""" + hass.config.api.base_url = 'http://0.0.0.0' + setattr(hass.config_entries, '_entries', [config_entry]) + api = smartthings_mock.return_value + api.app.return_value = mock_coro(return_value=app) + + # Assert setup returns false + result = await smartthings.async_setup_entry(hass, config_entry) + assert not result + + +async def test_unauthorized_installed_app_raises_not_ready( + hass, config_entry, app, installed_app, + smartthings_mock): + """Test config entry not ready raised when the app isn't authorized.""" + setattr(hass.config_entries, '_entries', [config_entry]) + setattr(installed_app, '_installed_app_status', + InstalledAppStatus.PENDING) + + api = smartthings_mock.return_value + api.app.return_value = mock_coro(return_value=app) + api.installed_app.return_value = mock_coro(return_value=installed_app) + + with pytest.raises(ConfigEntryNotReady): + await smartthings.async_setup_entry(hass, config_entry) + + +async def test_config_entry_loads_platforms( + hass, config_entry, app, installed_app, + device, smartthings_mock): + """Test config entry loads properly and proxies to platforms.""" + setattr(hass.config_entries, '_entries', [config_entry]) + + api = smartthings_mock.return_value + api.app.return_value = mock_coro(return_value=app) + api.installed_app.return_value = mock_coro(return_value=installed_app) + api.devices.return_value = mock_coro(return_value=[device]) + + with patch.object(hass.config_entries, 'async_forward_entry_setup', + return_value=mock_coro()) as forward_mock: + assert await smartthings.async_setup_entry(hass, config_entry) + # Assert platforms loaded + await hass.async_block_till_done() + assert forward_mock.call_count == len(SUPPORTED_PLATFORMS) + + +async def test_unload_entry(hass, config_entry): + """Test entries are unloaded correctly.""" + broker = Mock() + broker.event_handler_disconnect = Mock() + hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] = broker + + with patch.object(hass.config_entries, 'async_forward_entry_unload', + return_value=mock_coro( + return_value=True + )) as forward_mock: + assert await smartthings.async_unload_entry(hass, config_entry) + assert broker.event_handler_disconnect.call_count == 1 + assert config_entry.entry_id not in hass.data[DOMAIN][DATA_BROKERS] + # Assert platforms unloaded + await hass.async_block_till_done() + assert forward_mock.call_count == len(SUPPORTED_PLATFORMS) + + +async def test_event_handler_dispatches_updated_devices( + hass, device_factory, event_request_factory): + """Test the event handler dispatches updated devices.""" + devices = [ + device_factory('Bedroom 1 Switch', ['switch']), + device_factory('Bathroom 1', ['switch']), + device_factory('Sensor', ['motionSensor']), + ] + device_ids = [devices[0].device_id, devices[1].device_id, + devices[2].device_id] + request = event_request_factory(device_ids) + called = False + + def signal(ids): + nonlocal called + called = True + assert device_ids == ids + async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) + broker = smartthings.DeviceBroker( + hass, devices, request.installed_app_id) + + await broker.event_handler(request, None, None) + await hass.async_block_till_done() + + assert called + for device in devices: + assert device.status.attributes['Updated'] == 'Value' + + +async def test_event_handler_ignores_other_installed_app( + hass, device_factory, event_request_factory): + """Test the event handler dispatches updated devices.""" + device = device_factory('Bedroom 1 Switch', ['switch']) + request = event_request_factory([device.device_id]) + called = False + + def signal(ids): + nonlocal called + called = True + async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) + broker = smartthings.DeviceBroker(hass, [device], str(uuid4())) + + await broker.event_handler(request, None, None) + await hass.async_block_till_done() + + assert not called + + +async def test_event_handler_fires_button_events( + hass, device_factory, event_factory, event_request_factory): + """Test the event handler fires button events.""" + device = device_factory('Button 1', ['button']) + event = event_factory(device.device_id, capability='button', + attribute='button', value='pushed') + request = event_request_factory(events=[event]) + called = False + + def handler(evt): + nonlocal called + called = True + assert evt.data == { + 'component_id': 'main', + 'device_id': device.device_id, + 'location_id': event.location_id, + 'value': 'pushed', + 'name': device.label + } + hass.bus.async_listen(EVENT_BUTTON, handler) + broker = smartthings.DeviceBroker( + hass, [device], request.installed_app_id) + await broker.event_handler(request, None, None) + await hass.async_block_till_done() + + assert called diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py new file mode 100644 index 00000000000..a4f1103f270 --- /dev/null +++ b/tests/components/smartthings/test_light.py @@ -0,0 +1,293 @@ +""" +Test for the SmartThings light platform. + +The only mocking required is of the underlying SmartThings API object so +real HTTP calls are not initiated during testing. +""" +from pysmartthings import Attribute, Capability +import pytest + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION) +from homeassistant.components.smartthings import DeviceBroker, light +from homeassistant.components.smartthings.const import ( + DATA_BROKERS, DOMAIN, SIGNAL_SMARTTHINGS_UPDATE) +from homeassistant.config_entries import ( + CONN_CLASS_CLOUD_PUSH, SOURCE_USER, ConfigEntry) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES +from homeassistant.helpers.dispatcher import async_dispatcher_send + + +@pytest.fixture(name="light_devices") +def light_devices_fixture(device_factory): + """Fixture returns a set of mock light devices.""" + return [ + device_factory( + "Dimmer 1", + capabilities=[Capability.switch, Capability.switch_level], + status={Attribute.switch: 'on', Attribute.level: 100}), + device_factory( + "Color Dimmer 1", + capabilities=[Capability.switch, Capability.switch_level, + Capability.color_control], + status={Attribute.switch: 'off', Attribute.level: 0, + Attribute.hue: 76.0, Attribute.saturation: 55.0}), + device_factory( + "Color Dimmer 2", + capabilities=[Capability.switch, Capability.switch_level, + Capability.color_control, + Capability.color_temperature], + status={Attribute.switch: 'on', Attribute.level: 100, + Attribute.hue: 76.0, Attribute.saturation: 55.0, + Attribute.color_temperature: 4500}) + ] + + +async def _setup_platform(hass, *devices): + """Set up the SmartThings light platform and prerequisites.""" + hass.config.components.add(DOMAIN) + broker = DeviceBroker(hass, devices, '') + config_entry = ConfigEntry("1", DOMAIN, "Test", {}, + SOURCE_USER, CONN_CLASS_CLOUD_PUSH) + hass.data[DOMAIN] = { + DATA_BROKERS: { + config_entry.entry_id: broker + } + } + await hass.config_entries.async_forward_entry_setup(config_entry, 'light') + await hass.async_block_till_done() + return config_entry + + +async def test_async_setup_platform(): + """Test setup platform does nothing (it uses config entries).""" + await light.async_setup_platform(None, None, None) + + +def test_is_light(device_factory, light_devices): + """Test lights are correctly identified.""" + non_lights = [ + device_factory('Unknown', ['Unknown']), + device_factory("Fan 1", + [Capability.switch, Capability.switch_level, + Capability.fan_speed]), + device_factory("Switch 1", [Capability.switch]), + device_factory("Can't be turned off", + [Capability.switch_level, Capability.color_control, + Capability.color_temperature]) + ] + + for device in light_devices: + assert light.is_light(device), device.name + for device in non_lights: + assert not light.is_light(device), device.name + + +async def test_entity_state(hass, light_devices): + """Tests the state attributes properly match the light types.""" + await _setup_platform(hass, *light_devices) + + # Dimmer 1 + state = hass.states.get('light.dimmer_1') + assert state.state == 'on' + assert state.attributes[ATTR_SUPPORTED_FEATURES] == \ + SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION + assert state.attributes[ATTR_BRIGHTNESS] == 255 + + # Color Dimmer 1 + state = hass.states.get('light.color_dimmer_1') + assert state.state == 'off' + assert state.attributes[ATTR_SUPPORTED_FEATURES] == \ + SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_COLOR + + # Color Dimmer 2 + state = hass.states.get('light.color_dimmer_2') + assert state.state == 'on' + assert state.attributes[ATTR_SUPPORTED_FEATURES] == \ + SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_COLOR | \ + SUPPORT_COLOR_TEMP + assert state.attributes[ATTR_BRIGHTNESS] == 255 + assert state.attributes[ATTR_HS_COLOR] == (273.6, 55.0) + assert state.attributes[ATTR_COLOR_TEMP] == 222 + + +async def test_entity_and_device_attributes(hass, device_factory): + """Test the attributes of the entity are correct.""" + # Arrange + device = device_factory( + "Light 1", [Capability.switch, Capability.switch_level]) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + device_registry = await hass.helpers.device_registry.async_get_registry() + # Act + await _setup_platform(hass, device) + # Assert + entry = entity_registry.async_get("light.light_1") + assert entry + assert entry.unique_id == device.device_id + + entry = device_registry.async_get_device( + {(DOMAIN, device.device_id)}, []) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == 'Unavailable' + + +async def test_turn_off(hass, light_devices): + """Test the light turns of successfully.""" + # Arrange + await _setup_platform(hass, *light_devices) + # Act + await hass.services.async_call( + 'light', 'turn_off', {'entity_id': 'light.color_dimmer_2'}, + blocking=True) + # Assert + state = hass.states.get('light.color_dimmer_2') + assert state is not None + assert state.state == 'off' + + +async def test_turn_off_with_transition(hass, light_devices): + """Test the light turns of successfully with transition.""" + # Arrange + await _setup_platform(hass, *light_devices) + # Act + await hass.services.async_call( + 'light', 'turn_off', + {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_TRANSITION: 2}, + blocking=True) + # Assert + state = hass.states.get("light.color_dimmer_2") + assert state is not None + assert state.state == 'off' + + +async def test_turn_on(hass, light_devices): + """Test the light turns of successfully.""" + # Arrange + await _setup_platform(hass, *light_devices) + # Act + await hass.services.async_call( + 'light', 'turn_on', {ATTR_ENTITY_ID: "light.color_dimmer_1"}, + blocking=True) + # Assert + state = hass.states.get("light.color_dimmer_1") + assert state is not None + assert state.state == 'on' + + +async def test_turn_on_with_brightness(hass, light_devices): + """Test the light turns on to the specified brightness.""" + # Arrange + await _setup_platform(hass, *light_devices) + # Act + await hass.services.async_call( + 'light', 'turn_on', + {ATTR_ENTITY_ID: "light.color_dimmer_1", + ATTR_BRIGHTNESS: 75, ATTR_TRANSITION: 2}, + blocking=True) + # Assert + state = hass.states.get("light.color_dimmer_1") + assert state is not None + assert state.state == 'on' + # round-trip rounding error (expected) + assert state.attributes[ATTR_BRIGHTNESS] == 73.95 + + +async def test_turn_on_with_minimal_brightness(hass, light_devices): + """ + Test lights set to lowest brightness when converted scale would be zero. + + SmartThings light brightness is a percentage (0-100), but HASS uses a + 0-255 scale. This tests if a really low value (1-2) is passed, we don't + set the level to zero, which turns off the lights in SmartThings. + """ + # Arrange + await _setup_platform(hass, *light_devices) + # Act + await hass.services.async_call( + 'light', 'turn_on', + {ATTR_ENTITY_ID: "light.color_dimmer_1", + ATTR_BRIGHTNESS: 2}, + blocking=True) + # Assert + state = hass.states.get("light.color_dimmer_1") + assert state is not None + assert state.state == 'on' + # round-trip rounding error (expected) + assert state.attributes[ATTR_BRIGHTNESS] == 2.55 + + +async def test_turn_on_with_color(hass, light_devices): + """Test the light turns on with color.""" + # Arrange + await _setup_platform(hass, *light_devices) + # Act + await hass.services.async_call( + 'light', 'turn_on', + {ATTR_ENTITY_ID: "light.color_dimmer_2", + ATTR_HS_COLOR: (180, 50)}, + blocking=True) + # Assert + state = hass.states.get("light.color_dimmer_2") + assert state is not None + assert state.state == 'on' + assert state.attributes[ATTR_HS_COLOR] == (180, 50) + + +async def test_turn_on_with_color_temp(hass, light_devices): + """Test the light turns on with color temp.""" + # Arrange + await _setup_platform(hass, *light_devices) + # Act + await hass.services.async_call( + 'light', 'turn_on', + {ATTR_ENTITY_ID: "light.color_dimmer_2", + ATTR_COLOR_TEMP: 300}, + blocking=True) + # Assert + state = hass.states.get("light.color_dimmer_2") + assert state is not None + assert state.state == 'on' + assert state.attributes[ATTR_COLOR_TEMP] == 300 + + +async def test_update_from_signal(hass, device_factory): + """Test the light updates when receiving a signal.""" + # Arrange + device = device_factory( + "Color Dimmer 2", + capabilities=[Capability.switch, Capability.switch_level, + Capability.color_control, Capability.color_temperature], + status={Attribute.switch: 'off', Attribute.level: 100, + Attribute.hue: 76.0, Attribute.saturation: 55.0, + Attribute.color_temperature: 4500}) + await _setup_platform(hass, device) + await device.switch_on(True) + # Act + async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, + [device.device_id]) + # Assert + await hass.async_block_till_done() + state = hass.states.get('light.color_dimmer_2') + assert state is not None + assert state.state == 'on' + + +async def test_unload_config_entry(hass, device_factory): + """Test the light is removed when the config entry is unloaded.""" + # Arrange + device = device_factory( + "Color Dimmer 2", + capabilities=[Capability.switch, Capability.switch_level, + Capability.color_control, Capability.color_temperature], + status={Attribute.switch: 'off', Attribute.level: 100, + Attribute.hue: 76.0, Attribute.saturation: 55.0, + Attribute.color_temperature: 4500}) + config_entry = await _setup_platform(hass, device) + # Act + await hass.config_entries.async_forward_entry_unload( + config_entry, 'light') + # Assert + assert not hass.states.get('light.color_dimmer_2') diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py new file mode 100644 index 00000000000..0f517222c4a --- /dev/null +++ b/tests/components/smartthings/test_smartapp.py @@ -0,0 +1,112 @@ +"""Tests for the smartapp module.""" +from unittest.mock import Mock, patch +from uuid import uuid4 + +from pysmartthings import AppEntity + +from homeassistant.components.smartthings import smartapp +from homeassistant.components.smartthings.const import ( + DATA_MANAGER, DOMAIN, SUPPORTED_CAPABILITIES) + +from tests.common import mock_coro + + +async def test_update_app(hass, app): + """Test update_app does not save if app is current.""" + await smartapp.update_app(hass, app) + assert app.save.call_count == 0 + + +async def test_update_app_updated_needed(hass, app): + """Test update_app updates when an app is needed.""" + mock_app = Mock(spec=AppEntity) + mock_app.app_name = 'Test' + mock_app.refresh.return_value = mock_coro() + mock_app.save.return_value = mock_coro() + + await smartapp.update_app(hass, mock_app) + + assert mock_app.save.call_count == 1 + assert mock_app.app_name == 'Test' + assert mock_app.display_name == app.display_name + assert mock_app.description == app.description + assert mock_app.webhook_target_url == app.webhook_target_url + assert mock_app.app_type == app.app_type + assert mock_app.single_instance == app.single_instance + assert mock_app.classifications == app.classifications + + +async def test_smartapp_install_abort_if_no_other(hass, smartthings_mock): + """Test aborts if no other app was configured already.""" + api = smartthings_mock.return_value + api.create_subscription.return_value = mock_coro() + app = Mock() + app.app_id = uuid4() + request = Mock() + request.installed_app_id = uuid4() + request.auth_token = uuid4() + request.location_id = uuid4() + + await smartapp.smartapp_install(hass, request, None, app) + + entries = hass.config_entries.async_entries('smartthings') + assert not entries + assert api.create_subscription.call_count == \ + len(SUPPORTED_CAPABILITIES) + + +async def test_smartapp_install_creates_flow( + hass, smartthings_mock, config_entry, location): + """Test installation creates flow.""" + # Arrange + setattr(hass.config_entries, '_entries', [config_entry]) + api = smartthings_mock.return_value + api.create_subscription.return_value = mock_coro() + app = Mock() + app.app_id = config_entry.data['app_id'] + request = Mock() + request.installed_app_id = str(uuid4()) + request.auth_token = str(uuid4()) + request.location_id = location.location_id + # Act + await smartapp.smartapp_install(hass, request, None, app) + # Assert + await hass.async_block_till_done() + entries = hass.config_entries.async_entries('smartthings') + assert len(entries) == 2 + assert api.create_subscription.call_count == \ + len(SUPPORTED_CAPABILITIES) + assert entries[1].data['app_id'] == app.app_id + assert entries[1].data['installed_app_id'] == request.installed_app_id + assert entries[1].data['location_id'] == request.location_id + assert entries[1].data['access_token'] == \ + config_entry.data['access_token'] + assert entries[1].title == location.name + + +async def test_smartapp_uninstall(hass, config_entry): + """Test the config entry is unloaded when the app is uninstalled.""" + setattr(hass.config_entries, '_entries', [config_entry]) + app = Mock() + app.app_id = config_entry.data['app_id'] + request = Mock() + request.installed_app_id = config_entry.data['installed_app_id'] + + with patch.object(hass.config_entries, 'async_remove', + return_value=mock_coro()) as remove: + await smartapp.smartapp_uninstall(hass, request, None, app) + assert remove.call_count == 1 + + +async def test_smartapp_webhook(hass): + """Test the smartapp webhook calls the manager.""" + manager = Mock() + manager.handle_request = Mock() + manager.handle_request.return_value = mock_coro(return_value={}) + hass.data[DOMAIN][DATA_MANAGER] = manager + request = Mock() + request.headers = [] + request.json.return_value = mock_coro(return_value={}) + result = await smartapp.smartapp_webhook(hass, '', request) + + assert result.body == b'{}' diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py new file mode 100644 index 00000000000..a8013105291 --- /dev/null +++ b/tests/components/smartthings/test_switch.py @@ -0,0 +1,136 @@ +""" +Test for the SmartThings switch platform. + +The only mocking required is of the underlying SmartThings API object so +real HTTP calls are not initiated during testing. +""" +from pysmartthings import Attribute, Capability + +from homeassistant.components.smartthings import DeviceBroker, switch +from homeassistant.components.smartthings.const import ( + DATA_BROKERS, DOMAIN, SIGNAL_SMARTTHINGS_UPDATE) +from homeassistant.config_entries import ( + CONN_CLASS_CLOUD_PUSH, SOURCE_USER, ConfigEntry) +from homeassistant.helpers.dispatcher import async_dispatcher_send + + +async def _setup_platform(hass, *devices): + """Set up the SmartThings switch platform and prerequisites.""" + hass.config.components.add(DOMAIN) + broker = DeviceBroker(hass, devices, '') + config_entry = ConfigEntry("1", DOMAIN, "Test", {}, + SOURCE_USER, CONN_CLASS_CLOUD_PUSH) + hass.data[DOMAIN] = { + DATA_BROKERS: { + config_entry.entry_id: broker + } + } + await hass.config_entries.async_forward_entry_setup(config_entry, 'switch') + await hass.async_block_till_done() + return config_entry + + +async def test_async_setup_platform(): + """Test setup platform does nothing (it uses config entries).""" + await switch.async_setup_platform(None, None, None) + + +def test_is_switch(device_factory): + """Test switches are correctly identified.""" + switch_device = device_factory('Switch', [Capability.switch]) + non_switch_devices = [ + device_factory('Light', [Capability.switch, Capability.switch_level]), + device_factory('Fan', [Capability.switch, Capability.fan_speed]), + device_factory('Color Light', [Capability.switch, + Capability.color_control]), + device_factory('Temp Light', [Capability.switch, + Capability.color_temperature]), + device_factory('Unknown', ['Unknown']), + ] + assert switch.is_switch(switch_device) + for non_switch_device in non_switch_devices: + assert not switch.is_switch(non_switch_device) + + +async def test_entity_and_device_attributes(hass, device_factory): + """Test the attributes of the entity are correct.""" + # Arrange + device = device_factory('Switch_1', [Capability.switch], + {Attribute.switch: 'on'}) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + device_registry = await hass.helpers.device_registry.async_get_registry() + # Act + await _setup_platform(hass, device) + # Assert + entry = entity_registry.async_get('switch.switch_1') + assert entry + assert entry.unique_id == device.device_id + + entry = device_registry.async_get_device( + {(DOMAIN, device.device_id)}, []) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == 'Unavailable' + + +async def test_turn_off(hass, device_factory): + """Test the switch turns of successfully.""" + # Arrange + device = device_factory('Switch_1', [Capability.switch], + {Attribute.switch: 'on'}) + await _setup_platform(hass, device) + # Act + await hass.services.async_call( + 'switch', 'turn_off', {'entity_id': 'switch.switch_1'}, + blocking=True) + # Assert + state = hass.states.get('switch.switch_1') + assert state is not None + assert state.state == 'off' + + +async def test_turn_on(hass, device_factory): + """Test the switch turns of successfully.""" + # Arrange + device = device_factory('Switch_1', [Capability.switch], + {Attribute.switch: 'off'}) + await _setup_platform(hass, device) + # Act + await hass.services.async_call( + 'switch', 'turn_on', {'entity_id': 'switch.switch_1'}, + blocking=True) + # Assert + state = hass.states.get('switch.switch_1') + assert state is not None + assert state.state == 'on' + + +async def test_update_from_signal(hass, device_factory): + """Test the switch updates when receiving a signal.""" + # Arrange + device = device_factory('Switch_1', [Capability.switch], + {Attribute.switch: 'off'}) + await _setup_platform(hass, device) + await device.switch_on(True) + # Act + async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, + [device.device_id]) + # Assert + await hass.async_block_till_done() + state = hass.states.get('switch.switch_1') + assert state is not None + assert state.state == 'on' + + +async def test_unload_config_entry(hass, device_factory): + """Test the switch is removed when the config entry is unloaded.""" + # Arrange + device = device_factory('Switch', [Capability.switch], + {Attribute.switch: 'on'}) + config_entry = await _setup_platform(hass, device) + # Act + await hass.config_entries.async_forward_entry_unload( + config_entry, 'switch') + # Assert + assert not hass.states.get('switch.switch_1') diff --git a/tests/components/switch/test_rest.py b/tests/components/switch/test_rest.py index cb27ab40855..56f3f0eebc5 100644 --- a/tests/components/switch/test_rest.py +++ b/tests/components/switch/test_rest.py @@ -106,7 +106,7 @@ class TestRestSwitch: self.body_off = Template('off', self.hass) self.switch = rest.RestSwitch( self.name, self.resource, self.method, self.headers, self.auth, - self.body_on, self.body_off, None, 10) + self.body_on, self.body_off, None, 10, True) self.switch.hass = self.hass def teardown_method(self): diff --git a/tests/components/system_health/__init__.py b/tests/components/system_health/__init__.py new file mode 100644 index 00000000000..d59c20d4da6 --- /dev/null +++ b/tests/components/system_health/__init__.py @@ -0,0 +1 @@ +"""Tests for the system health component.""" diff --git a/tests/components/system_health/test_init.py b/tests/components/system_health/test_init.py new file mode 100644 index 00000000000..e090b11877e --- /dev/null +++ b/tests/components/system_health/test_init.py @@ -0,0 +1,105 @@ +"""Tests for the system health component init.""" +import asyncio +from unittest.mock import Mock + +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import mock_coro + + +@pytest.fixture +def mock_system_info(hass): + """Mock system info.""" + hass.helpers.system_info.async_get_system_info = Mock( + return_value=mock_coro({'hello': True}) + ) + + +async def test_info_endpoint_return_info(hass, hass_ws_client, + mock_system_info): + """Test that the info endpoint works.""" + assert await async_setup_component(hass, 'system_health', {}) + client = await hass_ws_client(hass) + + resp = await client.send_json({ + 'id': 6, + 'type': 'system_health/info', + }) + resp = await client.receive_json() + assert resp['success'] + data = resp['result'] + + assert len(data) == 1 + data = data['homeassistant'] + assert data == {'hello': True} + + +async def test_info_endpoint_register_callback(hass, hass_ws_client, + mock_system_info): + """Test that the info endpoint allows registering callbacks.""" + async def mock_info(hass): + return {'storage': 'YAML'} + + hass.components.system_health.async_register_info('lovelace', mock_info) + assert await async_setup_component(hass, 'system_health', {}) + client = await hass_ws_client(hass) + + resp = await client.send_json({ + 'id': 6, + 'type': 'system_health/info', + }) + resp = await client.receive_json() + assert resp['success'] + data = resp['result'] + + assert len(data) == 2 + data = data['lovelace'] + assert data == {'storage': 'YAML'} + + +async def test_info_endpoint_register_callback_timeout(hass, hass_ws_client, + mock_system_info): + """Test that the info endpoint timing out.""" + async def mock_info(hass): + raise asyncio.TimeoutError + + hass.components.system_health.async_register_info('lovelace', mock_info) + assert await async_setup_component(hass, 'system_health', {}) + client = await hass_ws_client(hass) + + resp = await client.send_json({ + 'id': 6, + 'type': 'system_health/info', + }) + resp = await client.receive_json() + assert resp['success'] + data = resp['result'] + + assert len(data) == 2 + data = data['lovelace'] + assert data == {'error': 'Fetching info timed out'} + + +async def test_info_endpoint_register_callback_exc(hass, hass_ws_client, + mock_system_info): + """Test that the info endpoint requires auth.""" + async def mock_info(hass): + raise Exception("TEST ERROR") + + hass.components.system_health.async_register_info('lovelace', mock_info) + assert await async_setup_component(hass, 'system_health', {}) + client = await hass_ws_client(hass) + + resp = await client.send_json({ + 'id': 6, + 'type': 'system_health/info', + }) + resp = await client.receive_json() + assert resp['success'] + data = resp['result'] + + assert len(data) == 2 + data = data['lovelace'] + assert data == {'error': 'TEST ERROR'} diff --git a/tests/components/test_alert.py b/tests/components/test_alert.py index 9fda58c37a3..57da830203e 100644 --- a/tests/components/test_alert.py +++ b/tests/components/test_alert.py @@ -1,17 +1,16 @@ """The tests for the Alert component.""" +import unittest # pylint: disable=protected-access from copy import deepcopy -import unittest -from homeassistant.setup import setup_component -from homeassistant.core import callback -from homeassistant.components.alert import DOMAIN import homeassistant.components.alert as alert import homeassistant.components.notify as notify +from homeassistant.components.alert import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ENTITY_ID, STATE_IDLE, CONF_NAME, CONF_STATE, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, STATE_OFF) - +from homeassistant.core import callback +from homeassistant.setup import setup_component from tests.common import get_test_home_assistant NAME = "alert_test" @@ -19,6 +18,13 @@ DONE_MESSAGE = "alert_gone" NOTIFIER = 'test' TEMPLATE = "{{ states.sensor.test.entity_id }}" TEST_ENTITY = "sensor.test" +TITLE = "{{ states.sensor.test.entity_id }}" +TEST_TITLE = "sensor.test" +TEST_DATA = { + 'data': { + 'inline_keyboard': ['Close garage:/close_garage'] + } +} TEST_CONFIG = \ {alert.DOMAIN: { NAME: { @@ -28,10 +34,13 @@ TEST_CONFIG = \ CONF_STATE: STATE_ON, alert.CONF_REPEAT: 30, alert.CONF_SKIP_FIRST: False, - alert.CONF_NOTIFIERS: [NOTIFIER]} - }} + alert.CONF_NOTIFIERS: [NOTIFIER], + alert.CONF_TITLE: TITLE, + alert.CONF_DATA: {} + } + }} TEST_NOACK = [NAME, NAME, "sensor.test", - STATE_ON, [30], False, None, None, NOTIFIER, False] + STATE_ON, [30], False, None, None, NOTIFIER, False, None, None] ENTITY_ID = alert.ENTITY_ID_FORMAT.format(NAME) @@ -200,7 +209,7 @@ class TestAlert(unittest.TestCase): """Test notifications.""" events = [] config = deepcopy(TEST_CONFIG) - del(config[alert.DOMAIN][NAME][alert.CONF_DONE_MESSAGE]) + del (config[alert.DOMAIN][NAME][alert.CONF_DONE_MESSAGE]) @callback def record_event(event): @@ -286,6 +295,34 @@ class TestAlert(unittest.TestCase): last_event = events[-1] self.assertEqual(last_event.data[notify.ATTR_MESSAGE], TEST_ENTITY) + def test_sending_titled_notification(self): + """Test notifications.""" + events = self._setup_notify() + + config = deepcopy(TEST_CONFIG) + config[alert.DOMAIN][NAME][alert.CONF_TITLE] = TITLE + assert setup_component(self.hass, alert.DOMAIN, config) + + self.hass.states.set(TEST_ENTITY, STATE_ON) + self.hass.block_till_done() + self.assertEqual(1, len(events)) + last_event = events[-1] + self.assertEqual(last_event.data[notify.ATTR_TITLE], TEST_TITLE) + + def test_sending_data_notification(self): + """Test notifications.""" + events = self._setup_notify() + + config = deepcopy(TEST_CONFIG) + config[alert.DOMAIN][NAME][alert.CONF_DATA] = TEST_DATA + assert setup_component(self.hass, alert.DOMAIN, config) + + self.hass.states.set(TEST_ENTITY, STATE_ON) + self.hass.block_till_done() + self.assertEqual(1, len(events)) + last_event = events[-1] + self.assertEqual(last_event.data[notify.ATTR_DATA], TEST_DATA) + def test_skipfirst(self): """Test skipping first notification.""" config = deepcopy(TEST_CONFIG) diff --git a/tests/components/test_updater.py b/tests/components/test_updater.py index 23b669928f4..bde6c3b0c61 100644 --- a/tests/components/test_updater.py +++ b/tests/components/test_updater.py @@ -8,7 +8,8 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components import updater import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, mock_coro, mock_component +from tests.common import ( + async_fire_time_changed, mock_coro, mock_component, MockDependency) NEW_VERSION = '10000.0' MOCK_VERSION = '10.0' @@ -23,6 +24,13 @@ MOCK_CONFIG = {updater.DOMAIN: { }} +@pytest.fixture(autouse=True) +def mock_distro(): + """Mock distro dep.""" + with MockDependency('distro'): + yield + + @pytest.fixture def mock_get_newest_version(): """Fixture to mock get_newest_version.""" @@ -99,30 +107,12 @@ def test_disable_reporting(hass, mock_get_uuid, mock_get_newest_version): assert call[1] is None -@asyncio.coroutine -def test_enabled_component_info(hass, mock_get_uuid): - """Test if new entity is created if new version is available.""" - with patch('homeassistant.components.updater.platform.system', - Mock(return_value="junk")): - res = yield from updater.get_system_info(hass, True) - assert 'components' in res, 'Updater failed to generate component list' - - -@asyncio.coroutine -def test_disable_component_info(hass, mock_get_uuid): - """Test if new entity is created if new version is available.""" - with patch('homeassistant.components.updater.platform.system', - Mock(return_value="junk")): - res = yield from updater.get_system_info(hass, False) - assert 'components' not in res, 'Updater failed, components generate' - - @asyncio.coroutine def test_get_newest_version_no_analytics_when_no_huuid(hass, aioclient_mock): """Test we do not gather analytics when no huuid is passed in.""" aioclient_mock.post(updater.UPDATER_URL, json=MOCK_RESPONSE) - with patch('homeassistant.components.updater.get_system_info', + with patch('homeassistant.helpers.system_info.async_get_system_info', side_effect=Exception): res = yield from updater.get_newest_version(hass, None, False) assert res == (MOCK_RESPONSE['version'], @@ -134,7 +124,7 @@ def test_get_newest_version_analytics_when_huuid(hass, aioclient_mock): """Test we do not gather analytics when no huuid is passed in.""" aioclient_mock.post(updater.UPDATER_URL, json=MOCK_RESPONSE) - with patch('homeassistant.components.updater.get_system_info', + with patch('homeassistant.helpers.system_info.async_get_system_info', Mock(return_value=mock_coro({'fake': 'bla'}))): res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) assert res == (MOCK_RESPONSE['version'], @@ -144,7 +134,7 @@ def test_get_newest_version_analytics_when_huuid(hass, aioclient_mock): @asyncio.coroutine def test_error_fetching_new_version_timeout(hass): """Test we do not gather analytics when no huuid is passed in.""" - with patch('homeassistant.components.updater.get_system_info', + with patch('homeassistant.helpers.system_info.async_get_system_info', Mock(return_value=mock_coro({'fake': 'bla'}))), \ patch('async_timeout.timeout', side_effect=asyncio.TimeoutError): res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) @@ -156,7 +146,7 @@ def test_error_fetching_new_version_bad_json(hass, aioclient_mock): """Test we do not gather analytics when no huuid is passed in.""" aioclient_mock.post(updater.UPDATER_URL, text='not json') - with patch('homeassistant.components.updater.get_system_info', + with patch('homeassistant.helpers.system_info.async_get_system_info', Mock(return_value=mock_coro({'fake': 'bla'}))): res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) assert res is None @@ -170,7 +160,7 @@ def test_error_fetching_new_version_invalid_response(hass, aioclient_mock): # 'release-notes' is missing }) - with patch('homeassistant.components.updater.get_system_info', + with patch('homeassistant.helpers.system_info.async_get_system_info', Mock(return_value=mock_coro({'fake': 'bla'}))): res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) assert res is None diff --git a/tests/components/test_webhook.py b/tests/components/test_webhook.py index e67cf7481cc..9c6c9e6a799 100644 --- a/tests/components/test_webhook.py +++ b/tests/components/test_webhook.py @@ -44,6 +44,12 @@ async def test_generate_webhook_url(hass): assert url == 'https://example.com/api/webhook/some_id' +async def test_async_generate_path(hass): + """Test generating just the path component of the url correctly.""" + path = hass.components.webhook.async_generate_path('some_id') + assert path == '/api/webhook/some_id' + + async def test_posting_webhook_nonexisting(hass, mock_client): """Test posting to a nonexisting webhook.""" resp = await mock_client.post('/api/webhook/non-existing') diff --git a/tests/components/utility_meter/__init__.py b/tests/components/utility_meter/__init__.py new file mode 100644 index 00000000000..bcb65403918 --- /dev/null +++ b/tests/components/utility_meter/__init__.py @@ -0,0 +1 @@ +"""Tests for Utility Meter component.""" diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py new file mode 100644 index 00000000000..51a458506fb --- /dev/null +++ b/tests/components/utility_meter/test_init.py @@ -0,0 +1,102 @@ +"""The tests for the utility_meter component.""" +import logging + +from datetime import timedelta +from unittest.mock import patch + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, ATTR_ENTITY_ID) +from homeassistant.components.utility_meter.const import ( + SERVICE_RESET, SERVICE_SELECT_TARIFF, SERVICE_SELECT_NEXT_TARIFF, + ATTR_TARIFF) +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util +from homeassistant.components.utility_meter.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def test_services(hass): + """Test energy sensor reset service.""" + config = { + 'utility_meter': { + 'energy_bill': { + 'source': 'sensor.energy', + 'cycle': 'hourly', + 'tariffs': ['peak', 'offpeak'], + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + entity_id = config[DOMAIN]['energy_bill']['source'] + hass.states.async_set(entity_id, 1, {"unit_of_measurement": "kWh"}) + await hass.async_block_till_done() + + now = dt_util.utcnow() + timedelta(seconds=10) + with patch('homeassistant.util.dt.utcnow', + return_value=now): + hass.states.async_set(entity_id, 3, {"unit_of_measurement": "kWh"}, + force_update=True) + await hass.async_block_till_done() + + state = hass.states.get('sensor.energy_bill_peak') + assert state.state == '2' + + state = hass.states.get('sensor.energy_bill_offpeak') + assert state.state == '0' + + # Next tariff + data = {ATTR_ENTITY_ID: 'utility_meter.energy_bill'} + await hass.services.async_call(DOMAIN, + SERVICE_SELECT_NEXT_TARIFF, data) + await hass.async_block_till_done() + + now += timedelta(seconds=10) + with patch('homeassistant.util.dt.utcnow', + return_value=now): + hass.states.async_set(entity_id, 4, {"unit_of_measurement": "kWh"}, + force_update=True) + await hass.async_block_till_done() + + state = hass.states.get('sensor.energy_bill_peak') + assert state.state == '2' + + state = hass.states.get('sensor.energy_bill_offpeak') + assert state.state == '1' + + # Change tariff + data = {ATTR_ENTITY_ID: 'utility_meter.energy_bill', + ATTR_TARIFF: 'peak'} + await hass.services.async_call(DOMAIN, + SERVICE_SELECT_TARIFF, data) + await hass.async_block_till_done() + + now += timedelta(seconds=10) + with patch('homeassistant.util.dt.utcnow', + return_value=now): + hass.states.async_set(entity_id, 5, {"unit_of_measurement": "kWh"}, + force_update=True) + await hass.async_block_till_done() + + state = hass.states.get('sensor.energy_bill_peak') + assert state.state == '3' + + state = hass.states.get('sensor.energy_bill_offpeak') + assert state.state == '1' + + # Reset meters + data = {ATTR_ENTITY_ID: 'utility_meter.energy_bill'} + await hass.services.async_call(DOMAIN, SERVICE_RESET, data) + await hass.async_block_till_done() + + state = hass.states.get('sensor.energy_bill_peak') + assert state.state == '0' + + state = hass.states.get('sensor.energy_bill_offpeak') + assert state.state == '0' diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py new file mode 100644 index 00000000000..23fc8872570 --- /dev/null +++ b/tests/components/utility_meter/test_sensor.py @@ -0,0 +1,136 @@ +"""The tests for the utility_meter sensor platform.""" +import logging + +from datetime import timedelta +from unittest.mock import patch +from contextlib import contextmanager + +from tests.common import async_fire_time_changed +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util +from homeassistant.components.utility_meter.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@contextmanager +def alter_time(retval): + """Manage multiple time mocks.""" + patch1 = patch("homeassistant.util.dt.utcnow", return_value=retval) + patch2 = patch("homeassistant.util.dt.now", return_value=retval) + + with patch1, patch2: + yield + + +async def test_state(hass): + """Test utility sensor state.""" + config = { + 'utility_meter': { + 'energy_bill': { + 'source': 'sensor.energy', + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + entity_id = config[DOMAIN]['energy_bill']['source'] + hass.states.async_set(entity_id, 2, {"unit_of_measurement": "kWh"}) + await hass.async_block_till_done() + + now = dt_util.utcnow() + timedelta(seconds=10) + with patch('homeassistant.util.dt.utcnow', + return_value=now): + hass.states.async_set(entity_id, 3, {"unit_of_measurement": "kWh"}, + force_update=True) + await hass.async_block_till_done() + + state = hass.states.get('sensor.energy_bill') + assert state is not None + + assert state.state == '1' + + +async def _test_self_reset(hass, cycle, start_time, expect_reset=True): + """Test energy sensor self reset.""" + config = { + 'utility_meter': { + 'energy_bill': { + 'source': 'sensor.energy', + 'cycle': cycle + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + entity_id = config[DOMAIN]['energy_bill']['source'] + + now = dt_util.parse_datetime(start_time) + with alter_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set(entity_id, 1, {"unit_of_measurement": "kWh"}) + await hass.async_block_till_done() + + now += timedelta(seconds=30) + with alter_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set(entity_id, 3, {"unit_of_measurement": "kWh"}, + force_update=True) + await hass.async_block_till_done() + + now += timedelta(seconds=30) + with alter_time(now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + hass.states.async_set(entity_id, 6, {"unit_of_measurement": "kWh"}, + force_update=True) + await hass.async_block_till_done() + + state = hass.states.get('sensor.energy_bill') + if expect_reset: + assert state.attributes.get('last_period') == '2' + assert state.state == '3' + else: + assert state.attributes.get('last_period') == 0 + assert state.state == '5' + + +async def test_self_reset_hourly(hass): + """Test hourly reset of meter.""" + await _test_self_reset(hass, 'hourly', "2017-12-31T23:59:00.000000+00:00") + + +async def test_self_reset_daily(hass): + """Test daily reset of meter.""" + await _test_self_reset(hass, 'daily', "2017-12-31T23:59:00.000000+00:00") + + +async def test_self_reset_weekly(hass): + """Test weekly reset of meter.""" + await _test_self_reset(hass, 'weekly', "2017-12-31T23:59:00.000000+00:00") + + +async def test_self_reset_monthly(hass): + """Test monthly reset of meter.""" + await _test_self_reset(hass, 'monthly', "2017-12-31T23:59:00.000000+00:00") + + +async def test_self_reset_yearly(hass): + """Test yearly reset of meter.""" + await _test_self_reset(hass, 'yearly', "2017-12-31T23:59:00.000000+00:00") + + +async def test_self_no_reset_yearly(hass): + """Test yearly reset of meter does not occur after 1st January.""" + await _test_self_reset(hass, 'yearly', "2018-01-01T23:59:00.000000+00:00", + expect_reset=False) diff --git a/tests/fixtures/ambient_devices.json b/tests/fixtures/ambient_devices.json new file mode 100644 index 00000000000..cd5edc21cb0 --- /dev/null +++ b/tests/fixtures/ambient_devices.json @@ -0,0 +1,15 @@ +[{ + "macAddress": "12:34:56:78:90:AB", + "lastData": { + "dateutc": 1546889640000, + "baromrelin": 30.09, + "baromabsin": 24.61, + "tempinf": 68.9, + "humidityin": 30, + "date": "2019-01-07T19:34:00.000Z" + }, + "info": { + "name": "Home", + "location": "Home" + } +}] diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py new file mode 100644 index 00000000000..9f2801fe334 --- /dev/null +++ b/tests/helpers/test_area_registry.py @@ -0,0 +1,127 @@ +"""Tests for the Area Registry.""" +import pytest + +from homeassistant.helpers import area_registry +from tests.common import mock_area_registry, flush_store + + +@pytest.fixture +def registry(hass): + """Return an empty, loaded, registry.""" + return mock_area_registry(hass) + + +async def test_list_areas(registry): + """Make sure that we can read areas.""" + registry.async_create('mock') + + areas = registry.async_list_areas() + + assert len(areas) == len(registry.areas) + + +async def test_create_area(registry): + """Make sure that we can create an area.""" + area = registry.async_create('mock') + + assert area.name == 'mock' + assert len(registry.areas) == 1 + + +async def test_create_area_with_name_already_in_use(registry): + """Make sure that we can't create an area with a name already in use.""" + area1 = registry.async_create('mock') + + with pytest.raises(ValueError) as e_info: + area2 = registry.async_create('mock') + assert area1 != area2 + assert e_info == "Name is already in use" + + assert len(registry.areas) == 1 + + +async def test_delete_area(registry): + """Make sure that we can delete an area.""" + area = registry.async_create('mock') + + await registry.async_delete(area.id) + + assert not registry.areas + + +async def test_delete_non_existing_area(registry): + """Make sure that we can't delete an area that doesn't exist.""" + registry.async_create('mock') + + with pytest.raises(KeyError): + await registry.async_delete('') + + assert len(registry.areas) == 1 + + +async def test_update_area(registry): + """Make sure that we can read areas.""" + area = registry.async_create('mock') + + updated_area = registry.async_update(area.id, name='mock1') + + assert updated_area != area + assert updated_area.name == 'mock1' + assert len(registry.areas) == 1 + + +async def test_update_area_with_same_name(registry): + """Make sure that we can reapply the same name to the area.""" + area = registry.async_create('mock') + + updated_area = registry.async_update(area.id, name='mock') + + assert updated_area == area + assert len(registry.areas) == 1 + + +async def test_update_area_with_name_already_in_use(registry): + """Make sure that we can't update an area with a name already in use.""" + area1 = registry.async_create('mock1') + area2 = registry.async_create('mock2') + + with pytest.raises(ValueError) as e_info: + registry.async_update(area1.id, name='mock2') + assert e_info == "Name is already in use" + + assert area1.name == 'mock1' + assert area2.name == 'mock2' + assert len(registry.areas) == 2 + + +async def test_load_area(hass, registry): + """Make sure that we can load/save data correctly.""" + registry.async_create('mock1') + registry.async_create('mock2') + + assert len(registry.areas) == 2 + + registry2 = area_registry.AreaRegistry(hass) + await flush_store(registry._store) + await registry2.async_load() + + assert list(registry.areas) == list(registry2.areas) + + +async def test_loading_area_from_storage(hass, hass_storage): + """Test loading stored areas on start.""" + hass_storage[area_registry.STORAGE_KEY] = { + 'version': area_registry.STORAGE_VERSION, + 'data': { + 'areas': [ + { + 'id': '12345A', + 'name': 'mock' + } + ] + } + } + + registry = await area_registry.async_get_registry(hass) + + assert len(registry.areas) == 1 diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 59bcab92b1e..93fffaa4ecc 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -133,6 +133,7 @@ async def test_loading_from_storage(hass, hass_storage): 'model': 'model', 'name': 'name', 'sw_version': 'version', + 'area_id': '12345A' } ] } @@ -146,6 +147,7 @@ async def test_loading_from_storage(hass, hass_storage): identifiers={('serial', '12:34:56:AB:CD:EF')}, manufacturer='manufacturer', model='model') assert entry.id == 'abcdefghijklm' + assert entry.area_id == '12345A' assert isinstance(entry.config_entries, set) @@ -186,6 +188,25 @@ async def test_removing_config_entries(registry): assert entry3.config_entries == set() +async def test_removing_area_id(registry): + """Make sure we can clear area id.""" + entry = registry.async_get_or_create( + config_entry_id='123', + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, + identifiers={('bridgeid', '0123')}, + manufacturer='manufacturer', model='model') + + entry_w_area = registry.async_update_device(entry.id, area_id='12345A') + + registry.async_clear_area_id('12345A') + entry_wo_area = registry.async_get_device({('bridgeid', '0123')}, set()) + + assert not entry_wo_area.area_id + assert entry_w_area != entry_wo_area + + async def test_specifying_hub_device_create(registry): """Test specifying a hub and updating.""" hub = registry.async_get_or_create( @@ -328,3 +349,19 @@ async def test_format_mac(registry): }, ) assert list(invalid_mac_entry.connections)[0][1] == invalid + + +async def test_update(registry): + """Verify that we can update area_id of a device.""" + entry = registry.async_get_or_create( + config_entry_id='1234', + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }) + + assert not entry.area_id + + updated_entry = registry.async_update_device(entry.id, area_id='12345A') + + assert updated_entry != entry + assert updated_entry.area_id == '12345A' diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index ef1ad2336eb..2812bc6353b 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -3,7 +3,7 @@ import asyncio from homeassistant.core import callback from homeassistant.helpers.dispatcher import ( - dispatcher_send, dispatcher_connect) + async_dispatcher_connect, dispatcher_send, dispatcher_connect) from tests.common import get_test_home_assistant @@ -134,3 +134,20 @@ class TestHelpersDispatcher: self.hass.block_till_done() assert calls == [3, 2, 'bla'] + + +async def test_callback_exception_gets_logged(hass, caplog): + """Test exception raised by signal handler.""" + @callback + def bad_handler(*args): + """Record calls.""" + raise Exception('This is a bad message callback') + + async_dispatcher_connect(hass, 'test', bad_handler) + dispatcher_send(hass, 'test', 'bad') + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert \ + "Exception in bad_handler when dispatching 'test': ('bad',)" \ + in caplog.text diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index ef7b4a60ee2..b1c13a36c6d 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -240,6 +240,10 @@ async def test_loading_invalid_entity_id(hass, hass_storage): 'entity_id': 'test.invalid_end_', 'platform': 'super_platform', 'unique_id': 'id-invalid-end', + }, { + 'entity_id': 'test._invalid_start', + 'platform': 'super_platform', + 'unique_id': 'id-invalid-start', } ] } @@ -256,3 +260,8 @@ async def test_loading_invalid_entity_id(hass, hass_storage): 'test', 'super_platform', 'id-invalid-end') assert valid_entity_id(entity_invalid_end.entity_id) + + entity_invalid_start = registry.async_get_or_create( + 'test', 'super_platform', 'id-invalid-start') + + assert valid_entity_id(entity_invalid_start.entity_id) diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py new file mode 100644 index 00000000000..7f23447e1f4 --- /dev/null +++ b/tests/helpers/test_system_info.py @@ -0,0 +1,12 @@ +"""Tests for the system info helper.""" +import json + +from homeassistant.const import __version__ as current_version + + +async def test_get_system_info(hass): + """Test the get system info.""" + info = await hass.helpers.system_info.async_get_system_info() + assert isinstance(info, dict) + assert info['version'] == current_version + assert json.dumps(info) is not None diff --git a/tests/test_setup.py b/tests/test_setup.py index 2e44ee539d7..6d0d2a35847 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -14,7 +14,8 @@ from homeassistant.const import ( import homeassistant.config as config_util from homeassistant import setup, loader import homeassistant.util.dt as dt_util -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA +from homeassistant.helpers.config_validation import ( + PLATFORM_SCHEMA_2 as PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE) from homeassistant.helpers import discovery from tests.common import \ @@ -94,18 +95,24 @@ class TestSetup: platform_schema = PLATFORM_SCHEMA.extend({ 'hello': str, }) + platform_schema_base = PLATFORM_SCHEMA_BASE.extend({ + }) loader.set_component( self.hass, 'platform_conf', - MockModule('platform_conf', platform_schema=platform_schema)) + MockModule('platform_conf', + platform_schema_base=platform_schema_base)) loader.set_component( self.hass, - 'platform_conf.whatever', MockPlatform('whatever')) + 'platform_conf.whatever', + MockPlatform('whatever', + platform_schema=platform_schema)) with assert_setup_component(0): assert setup.setup_component(self.hass, 'platform_conf', { 'platform_conf': { + 'platform': 'whatever', 'hello': 'world', 'invalid': 'extra', } @@ -121,6 +128,7 @@ class TestSetup: 'hello': 'world', }, 'platform_conf 2': { + 'platform': 'whatever', 'invalid': True } }) @@ -175,6 +183,136 @@ class TestSetup: assert 'platform_conf' in self.hass.config.components assert not config['platform_conf'] # empty + def test_validate_platform_config_2(self): + """Test component PLATFORM_SCHEMA_BASE prio over PLATFORM_SCHEMA.""" + platform_schema = PLATFORM_SCHEMA.extend({ + 'hello': str, + }) + platform_schema_base = PLATFORM_SCHEMA_BASE.extend({ + 'hello': 'world', + }) + loader.set_component( + self.hass, + 'platform_conf', + MockModule('platform_conf', + platform_schema=platform_schema, + platform_schema_base=platform_schema_base)) + + loader.set_component( + self.hass, + 'platform_conf.whatever', + MockPlatform('whatever', + platform_schema=platform_schema)) + + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'platform_conf', { + # fail: no extra keys allowed in platform schema + 'platform_conf': { + 'platform': 'whatever', + 'hello': 'world', + 'invalid': 'extra', + } + }) + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('platform_conf') + + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'platform_conf', { + # pass + 'platform_conf': { + 'platform': 'whatever', + 'hello': 'world', + }, + # fail: key hello violates component platform_schema_base + 'platform_conf 2': { + 'platform': 'whatever', + 'hello': 'there' + } + }) + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('platform_conf') + + def test_validate_platform_config_3(self): + """Test fallback to component PLATFORM_SCHEMA.""" + component_schema = PLATFORM_SCHEMA_BASE.extend({ + 'hello': str, + }) + platform_schema = PLATFORM_SCHEMA.extend({ + 'cheers': str, + 'hello': 'world', + }) + loader.set_component( + self.hass, + 'platform_conf', + MockModule('platform_conf', + platform_schema=component_schema)) + + loader.set_component( + self.hass, + 'platform_conf.whatever', + MockPlatform('whatever', + platform_schema=platform_schema)) + + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'platform_conf', { + 'platform_conf': { + # fail: no extra keys allowed + 'platform': 'whatever', + 'hello': 'world', + 'invalid': 'extra', + } + }) + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('platform_conf') + + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'platform_conf', { + # pass + 'platform_conf': { + 'platform': 'whatever', + 'hello': 'world', + }, + # fail: key hello violates component platform_schema + 'platform_conf 2': { + 'platform': 'whatever', + 'hello': 'there' + } + }) + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('platform_conf') + + def test_validate_platform_config_4(self): + """Test entity_namespace in PLATFORM_SCHEMA.""" + component_schema = PLATFORM_SCHEMA_BASE + platform_schema = PLATFORM_SCHEMA + loader.set_component( + self.hass, + 'platform_conf', + MockModule('platform_conf', + platform_schema_base=component_schema)) + + loader.set_component( + self.hass, + 'platform_conf.whatever', + MockPlatform('whatever', + platform_schema=platform_schema)) + + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'platform_conf', { + 'platform_conf': { + # pass: entity_namespace accepted by PLATFORM_SCHEMA + 'platform': 'whatever', + 'entity_namespace': 'yummy', + } + }) + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('platform_conf') + def test_component_not_found(self): """setup_component should not crash if component doesn't exist.""" assert not setup.setup_component(self.hass, 'non_existing') diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index 8f528376cce..5df1582da32 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -1,5 +1,4 @@ """Test aiohttp request helper.""" -from aiohttp import web from homeassistant.util import aiohttp @@ -32,23 +31,3 @@ async def test_request_post_query(): assert request.query == { 'get': 'true' } - - -def test_serialize_text(): - """Test serializing a text response.""" - response = web.Response(status=201, text='Hello') - assert aiohttp.serialize_response(response) == { - 'status': 201, - 'body': b'Hello', - 'headers': {'Content-Type': 'text/plain; charset=utf-8'}, - } - - -def test_serialize_json(): - """Test serializing a JSON response.""" - response = web.json_response({"how": "what"}) - assert aiohttp.serialize_response(response) == { - 'status': 200, - 'body': b'{"how": "what"}', - 'headers': {'Content-Type': 'application/json; charset=utf-8'}, - } diff --git a/tox.ini b/tox.ini index 1ab771ff24b..d240149cff8 100644 --- a/tox.ini +++ b/tox.ini @@ -60,4 +60,4 @@ whitelist_externals=/bin/bash deps = -r{toxinidir}/requirements_test.txt commands = - /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{__init__,deprecation,dispatcher,entity_values,entityfilter,icon,intent,json,location,signal,state,sun,temperature,translation,typing}.py' + /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{__init__,condition,deprecation,dispatcher,entity_values,entityfilter,icon,intent,json,location,signal,state,sun,temperature,translation,typing}.py' diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 41f447dff12..c01706782a0 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -33,6 +33,11 @@ RUN pip3 install --no-cache-dir -r requirements_all.txt && \ # BEGIN: Development additions +# Install git +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + # Install nodejs RUN curl -sL https://deb.nodesource.com/setup_7.x | bash - && \ apt-get install -y nodejs