diff --git a/.coveragerc b/.coveragerc index b47616973f6..5134f79297c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -71,6 +71,9 @@ omit = homeassistant/components/envisalink.py homeassistant/components/*/envisalink.py + homeassistant/components/gc100.py + homeassistant/components/*/gc100.py + homeassistant/components/google.py homeassistant/components/*/google.py @@ -107,6 +110,9 @@ omit = homeassistant/components/lametric.py homeassistant/components/*/lametric.py + homeassistant/components/linode.py + homeassistant/components/*/linode.py + homeassistant/components/lutron.py homeassistant/components/*/lutron.py @@ -272,8 +278,10 @@ omit = homeassistant/components/camera/mjpeg.py homeassistant/components/camera/rpi_camera.py homeassistant/components/camera/onvif.py + homeassistant/components/camera/ring.py homeassistant/components/camera/synology.py homeassistant/components/camera/yi.py + homeassistant/components/climate/ephember.py homeassistant/components/climate/eq3btsmart.py homeassistant/components/climate/flexit.py homeassistant/components/climate/heatmiser.py @@ -324,6 +332,7 @@ omit = homeassistant/components/emoncms_history.py homeassistant/components/emulated_hue/upnp.py homeassistant/components/fan/mqtt.py + homeassistant/components/fan/xiaomi_miio.py homeassistant/components/feedreader.py homeassistant/components/foursquare.py homeassistant/components/ifttt.py @@ -411,6 +420,7 @@ omit = homeassistant/components/notify/aws_sns.py homeassistant/components/notify/aws_sqs.py homeassistant/components/notify/ciscospark.py + homeassistant/components/notify/clickatell.py homeassistant/components/notify/clicksend.py homeassistant/components/notify/clicksend_tts.py homeassistant/components/notify/discord.py @@ -444,8 +454,10 @@ omit = homeassistant/components/notify/telstra.py homeassistant/components/notify/twitter.py homeassistant/components/notify/xmpp.py + homeassistant/components/notify/yessssms.py homeassistant/components/nuimo_controller.py homeassistant/components/prometheus.py + homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote/harmony.py homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py @@ -462,7 +474,6 @@ omit = homeassistant/components/sensor/broadlink.py homeassistant/components/sensor/buienradar.py homeassistant/components/sensor/citybikes.py - homeassistant/components/sensor/coinmarketcap.py homeassistant/components/sensor/cert_expiry.py homeassistant/components/sensor/comed_hourly_pricing.py homeassistant/components/sensor/cpuspeed.py @@ -470,6 +481,7 @@ omit = homeassistant/components/sensor/cups.py homeassistant/components/sensor/currencylayer.py homeassistant/components/sensor/darksky.py + homeassistant/components/sensor/deluge.py homeassistant/components/sensor/deutsche_bahn.py homeassistant/components/sensor/dht.py homeassistant/components/sensor/dnsip.py @@ -497,17 +509,18 @@ omit = homeassistant/components/sensor/gpsd.py homeassistant/components/sensor/gtfs.py homeassistant/components/sensor/haveibeenpwned.py - homeassistant/components/sensor/hddtemp.py homeassistant/components/sensor/hp_ilo.py homeassistant/components/sensor/htu21d.py homeassistant/components/sensor/hydroquebec.py homeassistant/components/sensor/imap.py homeassistant/components/sensor/imap_email_content.py homeassistant/components/sensor/influxdb.py + homeassistant/components/sensor/irish_rail_transport.py homeassistant/components/sensor/kwb.py homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py + homeassistant/components/sensor/luftdaten.py homeassistant/components/sensor/lyft.py homeassistant/components/sensor/metoffice.py homeassistant/components/sensor/miflora.py @@ -515,6 +528,7 @@ omit = homeassistant/components/sensor/mopar.py homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/mvglive.py + homeassistant/components/sensor/nederlandse_spoorwegen.py homeassistant/components/sensor/netdata.py homeassistant/components/sensor/neurio_energy.py homeassistant/components/sensor/nut.py @@ -551,6 +565,7 @@ omit = homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/synologydsm.py homeassistant/components/sensor/systemmonitor.py + homeassistant/components/sensor/sytadin.py homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py @@ -565,6 +580,7 @@ omit = homeassistant/components/sensor/ups.py homeassistant/components/sensor/vasttrafik.py homeassistant/components/sensor/waqi.py + homeassistant/components/sensor/whois.py homeassistant/components/sensor/worldtidesinfo.py homeassistant/components/sensor/worxlandroid.py homeassistant/components/sensor/xbox_live.py @@ -576,6 +592,7 @@ omit = homeassistant/components/switch/anel_pwrctrl.py homeassistant/components/switch/arest.py homeassistant/components/switch/broadlink.py + homeassistant/components/switch/deluge.py homeassistant/components/switch/digitalloggers.py homeassistant/components/switch/dlink.py homeassistant/components/switch/edimax.py @@ -588,17 +605,19 @@ omit = homeassistant/components/switch/orvibo.py homeassistant/components/switch/pilight.py homeassistant/components/switch/pulseaudio_loopback.py + homeassistant/components/switch/rainbird.py homeassistant/components/switch/rainmachine.py homeassistant/components/switch/rest.py homeassistant/components/switch/rpi_rf.py + homeassistant/components/switch/snmp.py homeassistant/components/switch/tplink.py homeassistant/components/switch/telnet.py homeassistant/components/switch/transmission.py - homeassistant/components/switch/wake_on_lan.py homeassistant/components/switch/xiaomi_miio.py homeassistant/components/telegram_bot/* homeassistant/components/thingspeak.py homeassistant/components/tts/amazon_polly.py + homeassistant/components/tts/microsoft.py homeassistant/components/tts/picotts.py homeassistant/components/vacuum/roomba.py homeassistant/components/weather/bom.py diff --git a/.gitmodules b/.gitmodules index 49d8dace9a4..e69de29bb2d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "homeassistant/components/frontend/www_static/home-assistant-polymer"] - path = homeassistant/components/frontend/www_static/home-assistant-polymer - url = https://github.com/home-assistant/home-assistant-polymer.git diff --git a/CODEOWNERS b/CODEOWNERS index 0560f5d5310..8fd5d0826c1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -41,6 +41,7 @@ homeassistant/components/*/zwave.py @home-assistant/z-wave # Indiviudal components homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt homeassistant/components/camera/yi.py @bachya +homeassistant/components/climate/ephember.py @ttroy50 homeassistant/components/climate/eq3btsmart.py @rytilahti homeassistant/components/climate/sensibo.py @andrey-git homeassistant/components/cover/template.py @PhracturedBlue @@ -50,17 +51,21 @@ homeassistant/components/light/tplink.py @rytilahti homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/monoprice.py @etsinko +homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth homeassistant/components/sensor/airvisual.py @bachya +homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/miflora.py @danielhiversen +homeassistant/components/sensor/sytadin.py @gautric homeassistant/components/sensor/tibber.py @danielhiversen homeassistant/components/sensor/waqi.py @andrey-git homeassistant/components/switch/rainmachine.py @bachya homeassistant/components/switch/tplink.py @rytilahti +homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi -homeassistant/components/*/axis.py @Kane610 homeassistant/components/*/broadlink.py @danielhiversen homeassistant/components/*/rfxtrx.py @danielhiversen homeassistant/components/tesla.py @zabuldon homeassistant/components/*/tesla.py @zabuldon -homeassistant/components/*/xiaomi_aqara.py @danielhiversen -homeassistant/components/*/xiaomi_miio.py @rytilahti +homeassistant/components/*/tradfri.py @ggravlingen +homeassistant/components/*/xiaomi_aqara.py @danielhiversen @syssi +homeassistant/components/*/xiaomi_miio.py @rytilahti @syssi diff --git a/Dockerfile b/Dockerfile index 3eadc8e7b03..5081b4ba721 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # This way, the development image and the production image are kept in sync. FROM python:3.6 -MAINTAINER Paulus Schoutsen +LABEL maintainer="Paulus Schoutsen " # Uncomment any of the following lines to disable the installation. #ENV INSTALL_TELLSTICK no diff --git a/MANIFEST.in b/MANIFEST.in index 6f8652fe270..490b550e705 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,4 @@ include README.rst include LICENSE.md graft homeassistant -prune homeassistant/components/frontend/www_static/home-assistant-polymer recursive-exclude * *.py[co] diff --git a/docs/screenshot-components.png b/docs/screenshot-components.png old mode 100755 new mode 100644 index 247f3073a5e..11b7980d6ca Binary files a/docs/screenshot-components.png and b/docs/screenshot-components.png differ diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4978177a658..4de464be88a 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -88,7 +88,7 @@ def async_from_config_dict(config: Dict[str, Any], if sys.version_info[:2] < (3, 5): _LOGGER.warning( 'Python 3.4 support has been deprecated and will be removed in ' - 'the begining of 2018. Please upgrade Python or your operating ' + 'the beginning of 2018. Please upgrade Python or your operating ' 'system. More info: https://home-assistant.io/blog/2017/10/06/' 'deprecating-python-3.4-support/' ) diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index 19c3ca0233d..21378876d9b 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -1,65 +1,61 @@ -alarm_disarm: - description: Send the alarm the command for disarm +# Describes the format for available alarm control panel services +alarm_disarm: + description: Send the alarm the command for disarm. fields: entity_id: - description: Name of alarm control panel to disarm + description: Name of alarm control panel to disarm. example: 'alarm_control_panel.downstairs' code: - description: An optional code to disarm the alarm control panel with + description: An optional code to disarm the alarm control panel with. example: 1234 alarm_arm_home: - description: Send the alarm the command for arm home - + description: Send the alarm the command for arm home. fields: entity_id: - description: Name of alarm control panel to arm home + description: Name of alarm control panel to arm home. example: 'alarm_control_panel.downstairs' code: - description: An optional code to arm home the alarm control panel with + description: An optional code to arm home the alarm control panel with. example: 1234 alarm_arm_away: - description: Send the alarm the command for arm away - + description: Send the alarm the command for arm away. fields: entity_id: - description: Name of alarm control panel to arm away + description: Name of alarm control panel to arm away. example: 'alarm_control_panel.downstairs' code: - description: An optional code to arm away the alarm control panel with + description: An optional code to arm away the alarm control panel with. example: 1234 alarm_arm_night: - description: Send the alarm the command for arm night - + description: Send the alarm the command for arm night. fields: entity_id: - description: Name of alarm control panel to arm night + description: Name of alarm control panel to arm night. example: 'alarm_control_panel.downstairs' code: - description: An optional code to arm night the alarm control panel with + description: An optional code to arm night the alarm control panel with. example: 1234 alarm_trigger: - description: Send the alarm the command for trigger - + description: Send the alarm the command for trigger. fields: entity_id: - description: Name of alarm control panel to trigger + description: Name of alarm control panel to trigger. example: 'alarm_control_panel.downstairs' code: - description: An optional code to trigger the alarm control panel with + description: An optional code to trigger the alarm control panel with. example: 1234 envisalink_alarm_keypress: - description: Send custom keypresses to the alarm - + description: Send custom keypresses to the alarm. fields: entity_id: - description: Name of the alarm control panel to trigger + description: Name of the alarm control panel to trigger. example: 'alarm_control_panel.downstairs' keypress: - description: 'String to send to the alarm panel (1-6 characters)' + description: 'String to send to the alarm panel (1-6 characters).' example: '*71' diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 05dc8aeef20..7abdf5efcab 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME) -REQUIREMENTS = ['total_connect_client==0.11'] +REQUIREMENTS = ['total_connect_client==0.12'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 61db142ac42..e65345cabca 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,11 +1,13 @@ """Support for alexa Smart Home Skill API.""" import asyncio import logging +import math from uuid import uuid4 from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) from homeassistant.components import switch, light +import homeassistant.util.color as color_util from homeassistant.util.decorator import Registry HANDLERS = Registry() @@ -22,7 +24,10 @@ MAPPING_COMPONENT = { switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], light.DOMAIN: [ 'LIGHT', ('Alexa.PowerController',), { - light.SUPPORT_BRIGHTNESS: 'Alexa.BrightnessController' + light.SUPPORT_BRIGHTNESS: 'Alexa.BrightnessController', + light.SUPPORT_RGB_COLOR: 'Alexa.ColorController', + light.SUPPORT_XY_COLOR: 'Alexa.ColorController', + light.SUPPORT_COLOR_TEMP: 'Alexa.ColorTemperatureController', } ], } @@ -193,11 +198,114 @@ def async_api_turn_off(hass, request, entity): @asyncio.coroutine def async_api_set_brightness(hass, request, entity): """Process a set brightness request.""" - brightness = request[API_PAYLOAD]['brightness'] + brightness = int(request[API_PAYLOAD]['brightness']) yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_BRIGHTNESS: brightness, + light.ATTR_BRIGHTNESS_PCT: brightness, + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) +@extract_entity +@asyncio.coroutine +def async_api_adjust_brightness(hass, request, entity): + """Process a adjust brightness request.""" + brightness_delta = int(request[API_PAYLOAD]['brightnessDelta']) + + # read current state + try: + current = math.floor( + int(entity.attributes.get(light.ATTR_BRIGHTNESS)) / 255 * 100) + except ZeroDivisionError: + current = 0 + + # set brightness + brightness = max(0, brightness_delta + current) + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS_PCT: brightness, + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.ColorController', 'SetColor')) +@extract_entity +@asyncio.coroutine +def async_api_set_color(hass, request, entity): + """Process a set color request.""" + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES) + rgb = color_util.color_hsb_to_RGB( + float(request[API_PAYLOAD]['color']['hue']), + float(request[API_PAYLOAD]['color']['saturation']), + float(request[API_PAYLOAD]['color']['brightness']) + ) + + if supported & light.SUPPORT_RGB_COLOR > 0: + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_RGB_COLOR: rgb, + }, blocking=True) + else: + xyz = color_util.color_RGB_to_xy(*rgb) + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_XY_COLOR: (xyz[0], xyz[1]), + light.ATTR_BRIGHTNESS: xyz[2], + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) +@extract_entity +@asyncio.coroutine +def async_api_set_color_temperature(hass, request, entity): + """Process a set color temperature request.""" + kelvin = int(request[API_PAYLOAD]['colorTemperatureInKelvin']) + + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_KELVIN: kelvin, + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register( + ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) +@extract_entity +@asyncio.coroutine +def async_api_decrease_color_temp(hass, request, entity): + """Process a decrease color temperature request.""" + current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) + max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS)) + + value = min(max_mireds, current + 50) + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_COLOR_TEMP: value, + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register( + ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) +@extract_entity +@asyncio.coroutine +def async_api_increase_color_temp(hass, request, entity): + """Process a increase color temperature request.""" + current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) + min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS)) + + value = max(min_mireds, current - 50) + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_COLOR_TEMP: value, }, blocking=True) return api_message(request) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index 3b905ab0420..ecdc31c8bd7 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -262,7 +262,11 @@ class APIEventView(HomeAssistantView): def post(self, request, event_type): """Fire events.""" body = yield from request.text() - event_data = json.loads(body) if body else None + try: + event_data = json.loads(body) if body else None + except ValueError: + return self.json_message('Event data should be valid JSON', + HTTP_BAD_REQUEST) if event_data is not None and not isinstance(event_data, dict): return self.json_message('Event data should be a JSON object', @@ -309,7 +313,11 @@ class APIDomainServicesView(HomeAssistantView): """ hass = request.app['hass'] body = yield from request.text() - data = json.loads(body) if body else None + try: + data = json.loads(body) if body else None + except ValueError: + return self.json_message('Data should be valid JSON', + HTTP_BAD_REQUEST) with AsyncTrackStates(hass) as changed_states: yield from hass.services.async_call(domain, service, data, True) diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 6f750245df9..7c035d7d1a5 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) TRIGGER_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'event', vol.Required(CONF_EVENT_TYPE): cv.string, - vol.Optional(CONF_EVENT_DATA, default={}): dict, + vol.Optional(CONF_EVENT_DATA): dict, }) @@ -37,6 +37,8 @@ def async_trigger(hass, config, action): def handle_event(event): """Listen for events and calls the action when data matches.""" if event_data_schema: + # Check that the event data matches the configured + # schema if one was provided try: event_data_schema(event.data) except vol.Invalid: diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 571888038a6..d5cdc9ffd83 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -38,13 +38,14 @@ def async_trigger(hass, config, action): time_delta = config.get(CONF_FOR) value_template = config.get(CONF_VALUE_TEMPLATE) async_remove_track_same = None + already_triggered = False if value_template is not None: value_template.hass = hass @callback def check_numeric_state(entity, from_s, to_s): - """Return True if they should trigger.""" + """Return True if criteria are now met.""" if to_s is None: return False @@ -56,51 +57,39 @@ def async_trigger(hass, config, action): 'above': above, } } - - # If new one doesn't match, nothing to do - if not condition.async_numeric_state( - hass, to_s, below, above, value_template, variables): - return False - - return True + return condition.async_numeric_state( + hass, to_s, below, above, value_template, variables) @callback def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - nonlocal async_remove_track_same - - if not check_numeric_state(entity, from_s, to_s): - return - - variables = { - 'trigger': { - 'platform': 'numeric_state', - 'entity_id': entity, - 'below': below, - 'above': above, - 'from_state': from_s, - 'to_state': to_s, - } - } - - # Only match if old didn't exist or existed but didn't match - # Written as: skip if old one did exist and matched - if from_s is not None and condition.async_numeric_state( - hass, from_s, below, above, value_template, variables): - return + nonlocal already_triggered, async_remove_track_same @callback def call_action(): """Call action with right context.""" - hass.async_run_job(action, variables) + hass.async_run_job(action, { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': entity, + 'below': below, + 'above': above, + 'from_state': from_s, + 'to_state': to_s, + } + }) - if not time_delta: - call_action() - return + matching = check_numeric_state(entity, from_s, to_s) - async_remove_track_same = async_track_same_state( - hass, time_delta, call_action, entity_ids=entity_id, - async_check_same_func=check_numeric_state) + if matching and not already_triggered: + if time_delta: + async_remove_track_same = async_track_same_state( + hass, time_delta, call_action, entity_ids=entity_id, + async_check_same_func=check_numeric_state) + else: + call_action() + + already_triggered = matching unsub = async_track_state_change( hass, entity_id, state_automation_listener) diff --git a/homeassistant/components/automation/services.yaml b/homeassistant/components/automation/services.yaml index ee22b671eca..90f66036706 100644 --- a/homeassistant/components/automation/services.yaml +++ b/homeassistant/components/automation/services.yaml @@ -1,6 +1,7 @@ +# Describes the format for available automation services + turn_on: description: Enable an automation. - fields: entity_id: description: Name of the automation to turn on. @@ -8,7 +9,6 @@ turn_on: turn_off: description: Disable an automation. - fields: entity_id: description: Name of the automation to turn off. @@ -16,7 +16,6 @@ turn_off: toggle: description: Toggle an automation. - fields: entity_id: description: Name of the automation to toggle on/off. @@ -24,7 +23,6 @@ toggle: trigger: description: Trigger the action of an automation. - fields: entity_id: description: Name of the automation to trigger. diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index aee8dbc415b..18f2c054b0c 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -11,19 +11,20 @@ import os import voluptuous as vol +from homeassistant.components.discovery import SERVICE_AXIS from homeassistant.config import load_yaml_config_file from homeassistant.const import (ATTR_LOCATION, ATTR_TRIPPED, - CONF_HOST, CONF_INCLUDE, CONF_NAME, - CONF_PASSWORD, CONF_PORT, CONF_TRIGGER_TIME, - CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) -from homeassistant.components.discovery import SERVICE_AXIS + CONF_EVENT, CONF_HOST, CONF_INCLUDE, + CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_TRIGGER_TIME, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['axis==12'] +REQUIREMENTS = ['axis==14'] _LOGGER = logging.getLogger(__name__) @@ -87,10 +88,13 @@ def request_configuration(hass, config, name, host, serialnumber): configurator.notify_errors(request_id, "Functionality mandatory.") return False + callback_data[CONF_INCLUDE] = callback_data[CONF_INCLUDE].split() callback_data[CONF_HOST] = host + if CONF_NAME not in callback_data: callback_data[CONF_NAME] = name + try: device_config = DEVICE_SCHEMA(callback_data) except vol.Invalid: @@ -101,7 +105,6 @@ def request_configuration(hass, config, name, host, serialnumber): if setup_device(hass, config, device_config): config_file = _read_config(hass) config_file[serialnumber] = dict(device_config) - del config_file[serialnumber]['hass'] _write_config(hass, config_file) configurator.request_done(request_id) else: @@ -146,10 +149,10 @@ def request_configuration(hass, config, name, host, serialnumber): def setup(hass, config): """Common setup for Axis devices.""" def _shutdown(call): # pylint: disable=unused-argument - """Stop the metadatastream on shutdown.""" + """Stop the event stream on shutdown.""" for serialnumber, device in AXIS_DEVICES.items(): - _LOGGER.info("Stopping metadatastream for %s.", serialnumber) - device.stop_metadatastream() + _LOGGER.info("Stopping event stream for %s.", serialnumber) + device.stop() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) @@ -162,7 +165,7 @@ def setup(hass, config): if serialnumber not in AXIS_DEVICES: config_file = _read_config(hass) if serialnumber in config_file: - # Device config saved to file + # Device config previously saved to file try: device_config = DEVICE_SCHEMA(config_file[serialnumber]) device_config[CONF_HOST] = host @@ -178,10 +181,8 @@ def setup(hass, config): else: # Device already registered, but on a different IP device = AXIS_DEVICES[serialnumber] - device.url = host - async_dispatcher_send(hass, - DOMAIN + '_' + device.name + '_new_ip', - host) + device.config.host = host + dispatcher_send(hass, DOMAIN + '_' + device.name + '_new_ip', host) # Register discovery service discovery.listen(hass, SERVICE_AXIS, axis_device_discovered) @@ -202,10 +203,11 @@ def setup(hass, config): """Service to send a message.""" for _, device in AXIS_DEVICES.items(): if device.name == call.data[CONF_NAME]: - response = device.do_request(call.data[SERVICE_CGI], - call.data[SERVICE_ACTION], - call.data[SERVICE_PARAM]) - hass.bus.async_fire(SERVICE_VAPIX_CALL_RESPONSE, response) + response = device.vapix.do_request( + call.data[SERVICE_CGI], + call.data[SERVICE_ACTION], + call.data[SERVICE_PARAM]) + hass.bus.fire(SERVICE_VAPIX_CALL_RESPONSE, response) return True _LOGGER.info("Couldn\'t find device %s", call.data[CONF_NAME]) return False @@ -216,7 +218,6 @@ def setup(hass, config): vapix_service, descriptions[DOMAIN][SERVICE_VAPIX_CALL], schema=SERVICE_SCHEMA) - return True @@ -224,9 +225,28 @@ def setup_device(hass, config, device_config): """Set up device.""" from axis import AxisDevice - device_config['hass'] = hass - device = AxisDevice(device_config) # Initialize device - enable_metadatastream = False + def signal_callback(action, event): + """Callback to configure events when initialized on event stream.""" + if action == 'add': + event_config = { + CONF_EVENT: event, + CONF_NAME: device_config[CONF_NAME], + ATTR_LOCATION: device_config[ATTR_LOCATION], + CONF_TRIGGER_TIME: device_config[CONF_TRIGGER_TIME] + } + component = event.event_platform + discovery.load_platform(hass, + component, + DOMAIN, + event_config, + config) + + event_types = list(filter(lambda x: x in device_config[CONF_INCLUDE], + EVENT_TYPES)) + device_config['events'] = event_types + device_config['signal'] = signal_callback + device = AxisDevice(hass.loop, **device_config) + device.name = device_config[CONF_NAME] if device.serial_number is None: # If there is no serial number a connection could not be made @@ -234,16 +254,10 @@ def setup_device(hass, config, device_config): return False for component in device_config[CONF_INCLUDE]: - if component in EVENT_TYPES: - # Sensors are created by device calling event_initialized - # when receiving initialize messages on metadatastream - device.add_event_topic(convert(component, 'type', 'subscribe')) - if not enable_metadatastream: - enable_metadatastream = True - else: + if component == 'camera': camera_config = { - CONF_HOST: device_config[CONF_HOST], CONF_NAME: device_config[CONF_NAME], + CONF_HOST: device_config[CONF_HOST], CONF_PORT: device_config[CONF_PORT], CONF_USERNAME: device_config[CONF_USERNAME], CONF_PASSWORD: device_config[CONF_PASSWORD] @@ -254,17 +268,8 @@ def setup_device(hass, config, device_config): camera_config, config) - if enable_metadatastream: - device.initialize_new_event = event_initialized - if not device.initiate_metadatastream(): - hass.components.persistent_notification.create( - 'Dependency missing for sensors, ' - 'please check documentation', - title=DOMAIN, - notification_id='axis_notification') - AXIS_DEVICES[device.serial_number] = device - + hass.add_job(device.start) return True @@ -287,25 +292,16 @@ def _write_config(hass, config): outfile.write(data) -def event_initialized(event): - """Register event initialized on metadatastream here.""" - hass = event.device_config('hass') - discovery.load_platform(hass, - convert(event.topic, 'topic', 'platform'), - DOMAIN, {'axis_event': event}) - - class AxisDeviceEvent(Entity): """Representation of a Axis device event.""" - def __init__(self, axis_event): + def __init__(self, event_config): """Initialize the event.""" - self.axis_event = axis_event - self._event_class = convert(self.axis_event.topic, 'topic', 'class') - self._name = '{}_{}_{}'.format(self.axis_event.device_name, - convert(self.axis_event.topic, - 'topic', 'type'), + self.axis_event = event_config[CONF_EVENT] + self._name = '{}_{}_{}'.format(event_config[CONF_NAME], + self.axis_event.event_type, self.axis_event.id) + self.location = event_config[ATTR_LOCATION] self.axis_event.callback = self._update_callback def _update_callback(self): @@ -321,7 +317,7 @@ class AxisDeviceEvent(Entity): @property def device_class(self): """Return the class of the event.""" - return self._event_class + return self.axis_event.event_class @property def should_poll(self): @@ -336,52 +332,6 @@ class AxisDeviceEvent(Entity): tripped = self.axis_event.is_tripped attr[ATTR_TRIPPED] = 'True' if tripped else 'False' - location = self.axis_event.device_config(ATTR_LOCATION) - if location: - attr[ATTR_LOCATION] = location + attr[ATTR_LOCATION] = self.location return attr - - -def convert(item, from_key, to_key): - """Translate between Axis and HASS syntax.""" - for entry in REMAP: - if entry[from_key] == item: - return entry[to_key] - - -REMAP = [{'type': 'motion', - 'class': 'motion', - 'topic': 'tns1:VideoAnalytics/tnsaxis:MotionDetection', - 'subscribe': 'onvif:VideoAnalytics/axis:MotionDetection', - 'platform': 'binary_sensor'}, - {'type': 'vmd3', - 'class': 'motion', - 'topic': 'tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1', - 'subscribe': 'onvif:RuleEngine/axis:VMD3/vmd3_video_1', - 'platform': 'binary_sensor'}, - {'type': 'pir', - 'class': 'motion', - 'topic': 'tns1:Device/tnsaxis:Sensor/PIR', - 'subscribe': 'onvif:Device/axis:Sensor/axis:PIR', - 'platform': 'binary_sensor'}, - {'type': 'sound', - 'class': 'sound', - 'topic': 'tns1:AudioSource/tnsaxis:TriggerLevel', - 'subscribe': 'onvif:AudioSource/axis:TriggerLevel', - 'platform': 'binary_sensor'}, - {'type': 'daynight', - 'class': 'light', - 'topic': 'tns1:VideoSource/tnsaxis:DayNightVision', - 'subscribe': 'onvif:VideoSource/axis:DayNightVision', - 'platform': 'binary_sensor'}, - {'type': 'tampering', - 'class': 'safety', - 'topic': 'tns1:VideoSource/tnsaxis:Tampering', - 'subscribe': 'onvif:VideoSource/axis:Tampering', - 'platform': 'binary_sensor'}, - {'type': 'input', - 'class': 'input', - 'topic': 'tns1:Device/tnsaxis:IO/Port', - 'subscribe': 'onvif:Device/axis:IO/Port', - 'platform': 'binary_sensor'}, ] diff --git a/homeassistant/components/binary_sensor/axis.py b/homeassistant/components/binary_sensor/axis.py index 125e9b33bd7..a6e80dbf97f 100644 --- a/homeassistant/components/binary_sensor/axis.py +++ b/homeassistant/components/binary_sensor/axis.py @@ -21,19 +21,19 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Axis device event.""" - add_devices([AxisBinarySensor(discovery_info['axis_event'], hass)], True) + add_devices([AxisBinarySensor(hass, discovery_info)], True) class AxisBinarySensor(AxisDeviceEvent, BinarySensorDevice): """Representation of a binary Axis event.""" - def __init__(self, axis_event, hass): + def __init__(self, hass, event_config): """Initialize the binary sensor.""" self.hass = hass self._state = False - self._delay = axis_event.device_config(CONF_TRIGGER_TIME) + self._delay = event_config[CONF_TRIGGER_TIME] self._timer = None - AxisDeviceEvent.__init__(self, axis_event) + AxisDeviceEvent.__init__(self, event_config) @property def is_on(self): diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py index 13908fb5472..f3dbc912ade 100644 --- a/homeassistant/components/binary_sensor/bayesian.py +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -22,6 +22,10 @@ from homeassistant.helpers.event import async_track_state_change _LOGGER = logging.getLogger(__name__) +ATTR_OBSERVATIONS = 'observations' +ATTR_PROBABILITY = 'probability' +ATTR_PROBABILITY_THRESHOLD = 'probability_threshold' + CONF_OBSERVATIONS = 'observations' CONF_PRIOR = 'prior' CONF_PROBABILITY_THRESHOLD = 'probability_threshold' @@ -29,7 +33,8 @@ CONF_P_GIVEN_F = 'prob_given_false' CONF_P_GIVEN_T = 'prob_given_true' CONF_TO_STATE = 'to_state' -DEFAULT_NAME = 'BayesianBinary' +DEFAULT_NAME = "Bayesian Binary Sensor" +DEFAULT_PROBABILITY_THRESHOLD = 0.5 NUMERIC_STATE_SCHEMA = vol.Schema({ CONF_PLATFORM: 'numeric_state', @@ -49,16 +54,14 @@ STATE_SCHEMA = vol.Schema({ }, required=True) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): - cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): cv.string, - vol.Required(CONF_OBSERVATIONS): vol.Schema( - vol.All(cv.ensure_list, [vol.Any(NUMERIC_STATE_SCHEMA, - STATE_SCHEMA)]) - ), + vol.Required(CONF_OBSERVATIONS): + vol.Schema(vol.All(cv.ensure_list, + [vol.Any(NUMERIC_STATE_SCHEMA, STATE_SCHEMA)])), vol.Required(CONF_PRIOR): vol.Coerce(float), - vol.Optional(CONF_PROBABILITY_THRESHOLD): - vol.Coerce(float), + vol.Optional(CONF_PROBABILITY_THRESHOLD, + default=DEFAULT_PROBABILITY_THRESHOLD): vol.Coerce(float), }) @@ -73,16 +76,16 @@ def update_probability(prior, prob_true, prob_false): @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the Threshold sensor.""" + """Set up the Bayesian Binary sensor.""" name = config.get(CONF_NAME) observations = config.get(CONF_OBSERVATIONS) prior = config.get(CONF_PRIOR) - probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD, 0.5) + probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD) device_class = config.get(CONF_DEVICE_CLASS) async_add_devices([ - BayesianBinarySensor(name, prior, observations, probability_threshold, - device_class) + BayesianBinarySensor( + name, prior, observations, probability_threshold, device_class) ], True) @@ -107,7 +110,7 @@ class BayesianBinarySensor(BinarySensorDevice): self.entity_obs = dict.fromkeys(to_observe, []) for ind, obs in enumerate(self._observations): - obs["id"] = ind + obs['id'] = ind self.entity_obs[obs['entity_id']].append(obs) self.watchers = { @@ -117,7 +120,7 @@ class BayesianBinarySensor(BinarySensorDevice): @asyncio.coroutine def async_added_to_hass(self): - """Call when entity about to be added to hass.""" + """Call when entity about to be added.""" @callback # pylint: disable=invalid-name def async_threshold_sensor_state_listener(entity, old_state, @@ -135,8 +138,8 @@ class BayesianBinarySensor(BinarySensorDevice): prior = self.prior for obs in self.current_obs.values(): - prior = update_probability(prior, obs['prob_true'], - obs['prob_false']) + prior = update_probability( + prior, obs['prob_true'], obs['prob_false']) self.probability = prior self.hass.async_add_job(self.async_update_ha_state, True) @@ -206,9 +209,9 @@ class BayesianBinarySensor(BinarySensorDevice): def device_state_attributes(self): """Return the state attributes of the sensor.""" return { - 'observations': [val for val in self.current_obs.values()], - 'probability': round(self.probability, 2), - 'probability_threshold': self._probability_threshold + ATTR_OBSERVATIONS: [val for val in self.current_obs.values()], + ATTR_PROBABILITY: round(self.probability, 2), + ATTR_PROBABILITY_THRESHOLD: self._probability_threshold, } @asyncio.coroutine diff --git a/homeassistant/components/binary_sensor/gc100.py b/homeassistant/components/binary_sensor/gc100.py new file mode 100644 index 00000000000..c17e6b50911 --- /dev/null +++ b/homeassistant/components/binary_sensor/gc100.py @@ -0,0 +1,69 @@ +""" +Support for binary sensor using GC100. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.gc100/ +""" +import voluptuous as vol + +from homeassistant.components.gc100 import DATA_GC100, CONF_PORTS +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import DEVICE_DEFAULT_NAME +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['gc100'] + +_SENSORS_SCHEMA = vol.Schema({ + cv.string: cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PORTS): vol.All(cv.ensure_list, [_SENSORS_SCHEMA]) +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the GC100 devices.""" + binary_sensors = [] + ports = config.get(CONF_PORTS) + for port in ports: + for port_addr, port_name in port.items(): + binary_sensors.append(GC100BinarySensor( + port_name, port_addr, hass.data[DATA_GC100])) + add_devices(binary_sensors, True) + + +class GC100BinarySensor(BinarySensorDevice): + """Representation of a binary sensor from GC100.""" + + def __init__(self, name, port_addr, gc100): + """Initialize the GC100 binary sensor.""" + # pylint: disable=no-member + self._name = name or DEVICE_DEFAULT_NAME + self._port_addr = port_addr + self._gc100 = gc100 + self._state = None + + # Subscribe to be notified about state changes (PUSH) + self._gc100.subscribe(self._port_addr, self.set_state) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the entity.""" + return self._state + + def update(self): + """Update the sensor state.""" + self._gc100.read_sensor(self._port_addr, self.set_state) + + def set_state(self, state): + """Set the current state.""" + self._state = state == 1 + self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/linode.py b/homeassistant/components/binary_sensor/linode.py new file mode 100644 index 00000000000..8af0318373d --- /dev/null +++ b/homeassistant/components/binary_sensor/linode.py @@ -0,0 +1,96 @@ +""" +Support for monitoring the state of Linode Nodes. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.linode/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.linode import ( + CONF_NODES, ATTR_CREATED, ATTR_NODE_ID, ATTR_NODE_NAME, + ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, + ATTR_REGION, ATTR_VCPUS, DATA_LINODE) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Node' +DEFAULT_DEVICE_CLASS = 'moving' +DEPENDENCIES = ['linode'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NODES): vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Linode droplet sensor.""" + linode = hass.data.get(DATA_LINODE) + nodes = config.get(CONF_NODES) + + dev = [] + for node in nodes: + node_id = linode.get_node_id(node) + if node_id is None: + _LOGGER.error("Node %s is not available", node) + return + dev.append(LinodeBinarySensor(linode, node_id)) + + add_devices(dev, True) + + +class LinodeBinarySensor(BinarySensorDevice): + """Representation of a Linode droplet sensor.""" + + def __init__(self, li, node_id): + """Initialize a new Linode sensor.""" + self._linode = li + self._node_id = node_id + self._state = None + self.data = None + + @property + def name(self): + """Return the name of the sensor.""" + if self.data is not None: + return self.data.label + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + if self.data is not None: + return self.data.status == 'running' + return False + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEFAULT_DEVICE_CLASS + + @property + def device_state_attributes(self): + """Return the state attributes of the Linode Node.""" + if self.data: + return { + ATTR_CREATED: self.data.created, + ATTR_NODE_ID: self.data.id, + ATTR_NODE_NAME: self.data.label, + ATTR_IPV4_ADDRESS: self.data.ipv4, + ATTR_IPV6_ADDRESS: self.data.ipv6, + ATTR_MEMORY: self.data.specs.memory, + ATTR_REGION: self.data.region.country, + ATTR_VCPUS: self.data.specs.vcpus, + } + return {} + + def update(self): + """Update state of sensor.""" + self._linode.update() + if self._linode.data is not None: + for node in self._linode.data: + if node.id == self._node_id: + self.data = node diff --git a/homeassistant/components/binary_sensor/random.py b/homeassistant/components/binary_sensor/random.py new file mode 100644 index 00000000000..162d0480389 --- /dev/null +++ b/homeassistant/components/binary_sensor/random.py @@ -0,0 +1,64 @@ +""" +Support for showing random states. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.random/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) +from homeassistant.const import CONF_NAME, CONF_DEVICE_CLASS + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Random Binary Sensor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Random binary sensor.""" + name = config.get(CONF_NAME) + device_class = config.get(CONF_DEVICE_CLASS) + + async_add_devices([RandomSensor(name, device_class)], True) + + +class RandomSensor(BinarySensorDevice): + """Representation of a Random binary sensor.""" + + def __init__(self, name, device_class): + """Initialize the Random binary sensor.""" + self._name = name + self._device_class = device_class + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + @asyncio.coroutine + def async_update(self): + """Get new state and update the sensor's state.""" + from random import getrandbits + self._state = bool(getrandbits(1)) diff --git a/homeassistant/components/binary_sensor/rfxtrx.py b/homeassistant/components/binary_sensor/rfxtrx.py index e86c948e191..edaee574232 100644 --- a/homeassistant/components/binary_sensor/rfxtrx.py +++ b/homeassistant/components/binary_sensor/rfxtrx.py @@ -62,7 +62,6 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): entity[CONF_COMMAND_ON], entity[CONF_COMMAND_OFF]) device.hass = hass - device.is_lighting4 = (packet_id[2:4] == '13') sensors.append(device) rfxtrx.RFX_DEVICES[device_id] = device @@ -86,17 +85,16 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): if not config[ATTR_AUTOMATIC_ADD]: return - poss_dev = rfxtrx.find_possible_pt2262_device(device_id) - - if poss_dev is not None: - poss_id = slugify(poss_dev.event.device.id_string.lower()) - _LOGGER.info("Found possible matching deviceid %s.", - poss_id) + if event.device.packettype == 0x13: + poss_dev = rfxtrx.find_possible_pt2262_device(device_id) + if poss_dev is not None: + poss_id = slugify(poss_dev.event.device.id_string.lower()) + _LOGGER.info("Found possible matching deviceid %s.", + poss_id) pkt_id = "".join("{0:02x}".format(x) for x in event.data) sensor = RfxtrxBinarySensor(event, pkt_id) sensor.hass = hass - sensor.is_lighting4 = (pkt_id[2:4] == '13') rfxtrx.RFX_DEVICES[device_id] = sensor add_devices_callback([sensor]) _LOGGER.info("Added binary sensor %s " @@ -114,6 +112,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): slugify(event.device.id_string.lower()), event.device.__class__.__name__, event.device.subtype) + if sensor.is_lighting4: if sensor.data_bits is not None: cmd = rfxtrx.get_pt2262_cmd(device_id, sensor.data_bits) @@ -154,7 +153,7 @@ class RfxtrxBinarySensor(BinarySensorDevice): self._device_class = device_class self._off_delay = off_delay self._state = False - self.is_lighting4 = False + self.is_lighting4 = (event.device.packettype == 0x13) self.delay_listener = None self._data_bits = data_bits self._cmd_on = cmd_on diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index 5c9a644f6b7..1e926f00a2f 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -11,7 +11,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.ring import ( - CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE) + CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE, DATA_RING) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS) @@ -27,21 +27,21 @@ SCAN_INTERVAL = timedelta(seconds=5) # Sensor types: Name, category, device_class SENSOR_TYPES = { - 'ding': ['Ding', ['doorbell'], 'occupancy'], - 'motion': ['Motion', ['doorbell'], 'motion'], + 'ding': ['Ding', ['doorbell', 'stickup_cams'], 'occupancy'], + 'motion': ['Motion', ['doorbell', 'stickup_cams'], 'motion'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for a Ring device.""" - ring = hass.data.get('ring') + ring = hass.data[DATA_RING] sensors = [] for sensor_type in config.get(CONF_MONITORED_CONDITIONS): @@ -50,6 +50,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensors.append(RingBinarySensor(hass, device, sensor_type)) + + for device in ring.stickup_cams: + if 'stickup_cams' in SENSOR_TYPES[sensor_type][1]: + sensors.append(RingBinarySensor(hass, + device, + sensor_type)) add_devices(sensors, True) return True diff --git a/homeassistant/components/binary_sensor/tellduslive.py b/homeassistant/components/binary_sensor/tellduslive.py new file mode 100644 index 00000000000..e5d2d83fe47 --- /dev/null +++ b/homeassistant/components/binary_sensor/tellduslive.py @@ -0,0 +1,34 @@ +""" +Support for binary sensors using Tellstick Net. + +This platform uses the Telldus Live online service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tellduslive/ + +""" +import logging + +from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components.binary_sensor import BinarySensorDevice + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tellstick sensors.""" + if discovery_info is None: + return + add_devices( + TelldusLiveSensor(hass, binary_sensor) + for binary_sensor in discovery_info + ) + + +class TelldusLiveSensor(TelldusLiveEntity, BinarySensorDevice): + """Representation of a Tellstick sensor.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self.device.is_on diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 88fdb448330..8b5660f54c5 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -1,11 +1,13 @@ """ -A sensor that monitors trands in other components. +A sensor that monitors trends in other components. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.trend/ """ import asyncio +from collections import deque import logging +import math import voluptuous as vol @@ -16,21 +18,40 @@ from homeassistant.components.binary_sensor import ( BinarySensorDevice, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) from homeassistant.const import ( - ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_DEVICE_CLASS, STATE_UNKNOWN) + ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, + CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, + STATE_UNKNOWN) from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.event import async_track_state_change +from homeassistant.util import utcnow + +REQUIREMENTS = ['numpy==1.13.3'] _LOGGER = logging.getLogger(__name__) + +ATTR_ATTRIBUTE = 'attribute' +ATTR_GRADIENT = 'gradient' +ATTR_MIN_GRADIENT = 'min_gradient' +ATTR_INVERT = 'invert' +ATTR_SAMPLE_DURATION = 'sample_duration' +ATTR_SAMPLE_COUNT = 'sample_count' + CONF_SENSORS = 'sensors' CONF_ATTRIBUTE = 'attribute' +CONF_MAX_SAMPLES = 'max_samples' +CONF_MIN_GRADIENT = 'min_gradient' CONF_INVERT = 'invert' +CONF_SAMPLE_DURATION = 'sample_duration' SENSOR_SCHEMA = vol.Schema({ - vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(ATTR_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_INVERT, default=False): cv.boolean, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, + vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), + vol.Optional(CONF_INVERT, default=False): cv.boolean, + vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -43,17 +64,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the trend sensors.""" sensors = [] - for device, device_config in config[CONF_SENSORS].items(): + for device_id, device_config in config[CONF_SENSORS].items(): entity_id = device_config[ATTR_ENTITY_ID] attribute = device_config.get(CONF_ATTRIBUTE) - friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) device_class = device_config.get(CONF_DEVICE_CLASS) + friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device_id) invert = device_config[CONF_INVERT] + max_samples = device_config[CONF_MAX_SAMPLES] + min_gradient = device_config[CONF_MIN_GRADIENT] + sample_duration = device_config[CONF_SAMPLE_DURATION] sensors.append( SensorTrend( - hass, device, friendly_name, entity_id, attribute, - device_class, invert) + hass, device_id, friendly_name, entity_id, attribute, + device_class, invert, max_samples, min_gradient, + sample_duration) ) if not sensors: _LOGGER.error("No sensors added") @@ -65,30 +90,23 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class SensorTrend(BinarySensorDevice): """Representation of a trend Sensor.""" - def __init__(self, hass, device_id, friendly_name, - target_entity, attribute, device_class, invert): + def __init__(self, hass, device_id, friendly_name, entity_id, + attribute, device_class, invert, max_samples, + min_gradient, sample_duration): """Initialize the sensor.""" self._hass = hass self.entity_id = generate_entity_id( ENTITY_ID_FORMAT, device_id, hass=hass) self._name = friendly_name - self._target_entity = target_entity + self._entity_id = entity_id self._attribute = attribute self._device_class = device_class self._invert = invert + self._sample_duration = sample_duration + self._min_gradient = min_gradient + self._gradient = None self._state = None - self.from_state = None - self.to_state = None - - @callback - def trend_sensor_state_listener(entity, old_state, new_state): - """Handle the target device state changes.""" - self.from_state = old_state - self.to_state = new_state - hass.async_add_job(self.async_update_ha_state(True)) - - track_state_change(hass, target_entity, - trend_sensor_state_listener) + self.samples = deque(maxlen=max_samples) @property def name(self): @@ -105,33 +123,77 @@ class SensorTrend(BinarySensorDevice): """Return the sensor class of the sensor.""" return self._device_class + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ENTITY_ID: self._entity_id, + ATTR_FRIENDLY_NAME: self._name, + ATTR_INVERT: self._invert, + ATTR_GRADIENT: self._gradient, + ATTR_MIN_GRADIENT: self._min_gradient, + ATTR_SAMPLE_DURATION: self._sample_duration, + ATTR_SAMPLE_COUNT: len(self.samples), + } + @property def should_poll(self): """No polling needed.""" return False + @asyncio.coroutine + def async_added_to_hass(self): + """Complete device setup after being added to hass.""" + @callback + def trend_sensor_state_listener(entity, old_state, new_state): + """Handle state changes on the observed device.""" + try: + if self._attribute: + state = new_state.attributes.get(self._attribute) + else: + state = new_state.state + if state != STATE_UNKNOWN: + sample = (utcnow().timestamp(), float(state)) + self.samples.append(sample) + self.async_schedule_update_ha_state(True) + except (ValueError, TypeError) as ex: + _LOGGER.error(ex) + + async_track_state_change( + self.hass, self._entity_id, + trend_sensor_state_listener) + @asyncio.coroutine def async_update(self): """Get the latest data and update the states.""" - if self.from_state is None or self.to_state is None: - return - if (self.from_state.state == STATE_UNKNOWN or - self.to_state.state == STATE_UNKNOWN): - return - try: - if self._attribute: - from_value = float( - self.from_state.attributes.get(self._attribute)) - to_value = float( - self.to_state.attributes.get(self._attribute)) - else: - from_value = float(self.from_state.state) - to_value = float(self.to_state.state) + # Remove outdated samples + if self._sample_duration > 0: + cutoff = utcnow().timestamp() - self._sample_duration + while self.samples and self.samples[0][0] < cutoff: + self.samples.popleft() - self._state = to_value > from_value - if self._invert: - self._state = not self._state + if len(self.samples) < 2: + return - except (ValueError, TypeError) as ex: - self._state = None - _LOGGER.error(ex) + # Calculate gradient of linear trend + yield from self.hass.async_add_job(self._calculate_gradient) + + # Update state + self._state = ( + abs(self._gradient) > abs(self._min_gradient) and + math.copysign(self._gradient, self._min_gradient) == self._gradient + ) + + if self._invert: + self._state = not self._state + + def _calculate_gradient(self): + """Compute the linear trend gradient of the current samples. + + This need run inside executor. + """ + import numpy as np + timestamps = np.array([t for t, _ in self.samples]) + values = np.array([s for _, s in self.samples]) + coeffs = np.polyfit(timestamps, values, 1) + self._gradient = coeffs[0] diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index a610269cedf..eee24b8ad1c 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -25,13 +25,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): for device in gateway.devices['binary_sensor']: model = device['model'] - if model == 'motion': + if model in ['motion', 'sensor_motion.aq2']: devices.append(XiaomiMotionSensor(device, hass, gateway)) - elif model == 'sensor_motion.aq2': - devices.append(XiaomiMotionSensor(device, hass, gateway)) - elif model == 'magnet': - devices.append(XiaomiDoorSensor(device, gateway)) - elif model == 'sensor_magnet.aq2': + elif model in ['magnet', 'sensor_magnet.aq2']: devices.append(XiaomiDoorSensor(device, gateway)) elif model == 'sensor_wleak.aq1': devices.append(XiaomiWaterLeakSensor(device, gateway)) @@ -39,10 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices.append(XiaomiSmokeSensor(device, gateway)) elif model == 'natgas': devices.append(XiaomiNatgasSensor(device, gateway)) - elif model == 'switch': - devices.append(XiaomiButton(device, 'Switch', 'status', - hass, gateway)) - elif model == 'sensor_switch.aq2': + elif model in ['switch', 'sensor_switch.aq2', 'sensor_switch.aq3']: devices.append(XiaomiButton(device, 'Switch', 'status', hass, gateway)) elif model == '86sw1': @@ -289,9 +282,17 @@ class XiaomiButton(XiaomiBinarySensor): def __init__(self, device, name, data_key, hass, xiaomi_hub): """Initialize the XiaomiButton.""" self._hass = hass + self._last_action = None XiaomiBinarySensor.__init__(self, device, name, xiaomi_hub, data_key, None) + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_LAST_ACTION: self._last_action} + attrs.update(super().device_state_attributes) + return attrs + def parse_data(self, data): """Parse data sent by gateway.""" value = data.get(self._data_key) @@ -317,6 +318,8 @@ class XiaomiButton(XiaomiBinarySensor): 'entity_id': self.entity_id, 'click_type': click_type }) + self._last_action = click_type + if value in ['long_click_press', 'long_click_release']: return True return False diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index 952e2302091..61ff4345fbe 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -1,19 +1,21 @@ +# Describes the format for available calendar services + todoist: new_task: description: Create a new task and add it to a project. fields: content: - description: The name of the task. [Required] + description: The name of the task (Required). example: Pick up the mail project: - description: The name of the project this task should belong to. Defaults to Inbox. [Optional] + description: The name of the project this task should belong to. Defaults to Inbox (Optional). example: Errands labels: - description: Any labels that you want to apply to this task, separated by a comma. [Optional] + description: Any labels that you want to apply to this task, separated by a comma (Optional). example: Chores,Deliveries priority: - description: The priority of this task, from 1 (normal) to 4 (urgent). [Optional] + description: The priority of this task, from 1 (normal) to 4 (urgent) (Optional). example: 2 due_date: - description: The day this task is due, in format YYYY-MM-DD. [Optional] + description: The day this task is due, in format YYYY-MM-DD (Optional). example: "2018-04-01" diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index c509d582e11..110f9a11852 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -29,18 +29,22 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED -from homeassistant.helpers.event import async_track_time_interval import homeassistant.helpers.config_validation as cv +DOMAIN = 'camera' +DEPENDENCIES = ['http'] + _LOGGER = logging.getLogger(__name__) -SERVICE_EN_MOTION = 'enable_motion_detection' -SERVICE_DISEN_MOTION = 'disable_motion_detection' -DOMAIN = 'camera' -DEPENDENCIES = ['http'] +SERVICE_ENABLE_MOTION = 'enable_motion_detection' +SERVICE_DISABLE_MOTION = 'disable_motion_detection' +SERVICE_SNAPSHOT = 'snapshot' + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + '.{}' +ATTR_FILENAME = 'filename' + STATE_RECORDING = 'recording' STATE_STREAMING = 'streaming' STATE_IDLE = 'idle' @@ -55,13 +59,17 @@ CAMERA_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) +CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_FILENAME): cv.template +}) + @bind_hass def enable_motion_detection(hass, entity_id=None): """Enable Motion Detection.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.async_add_job(hass.services.async_call( - DOMAIN, SERVICE_EN_MOTION, data)) + DOMAIN, SERVICE_ENABLE_MOTION, data)) @bind_hass @@ -69,9 +77,20 @@ def disable_motion_detection(hass, entity_id=None): """Disable Motion Detection.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.async_add_job(hass.services.async_call( - DOMAIN, SERVICE_DISEN_MOTION, data)) + DOMAIN, SERVICE_DISABLE_MOTION, data)) +@bind_hass +def async_snapshot(hass, filename, entity_id=None): + """Make a snapshot from a camera.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + data[ATTR_FILENAME] = filename + + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_SNAPSHOT, data)) + + +@bind_hass @asyncio.coroutine def async_get_image(hass, entity_id, timeout=10): """Fetch a image from a camera entity.""" @@ -119,7 +138,8 @@ def async_setup(hass, config): entity.async_update_token() hass.async_add_job(entity.async_update_ha_state()) - async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) + hass.helpers.event.async_track_time_interval( + update_tokens, TOKEN_CHANGE_INTERVAL) @asyncio.coroutine def async_handle_camera_service(service): @@ -128,9 +148,9 @@ def async_setup(hass, config): update_tasks = [] for camera in target_cameras: - if service.service == SERVICE_EN_MOTION: + if service.service == SERVICE_ENABLE_MOTION: yield from camera.async_enable_motion_detection() - elif service.service == SERVICE_DISEN_MOTION: + elif service.service == SERVICE_DISABLE_MOTION: yield from camera.async_disable_motion_detection() if not camera.should_poll: @@ -140,16 +160,50 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) + @asyncio.coroutine + def async_handle_snapshot_service(service): + """Handle snapshot services calls.""" + target_cameras = component.async_extract_from_service(service) + filename = service.data[ATTR_FILENAME] + filename.hass = hass + + for camera in target_cameras: + snapshot_file = filename.async_render( + variables={ATTR_ENTITY_ID: camera}) + + # check if we allow to access to that file + if not hass.config.is_allowed_path(snapshot_file): + _LOGGER.error( + "Can't write %s, no access to path!", snapshot_file) + continue + + image = yield from camera.async_camera_image() + + def _write_image(to_file, image_data): + """Executor helper to write image.""" + with open(to_file, 'wb') as img_file: + img_file.write(image_data) + + try: + yield from hass.async_add_job( + _write_image, snapshot_file, image) + except OSError as err: + _LOGGER.error("Can't write image to file: %s", err) + descriptions = yield from hass.async_add_job( load_yaml_config_file, os.path.join( os.path.dirname(__file__), 'services.yaml')) hass.services.async_register( - DOMAIN, SERVICE_EN_MOTION, async_handle_camera_service, - descriptions.get(SERVICE_EN_MOTION), schema=CAMERA_SERVICE_SCHEMA) + DOMAIN, SERVICE_ENABLE_MOTION, async_handle_camera_service, + descriptions.get(SERVICE_ENABLE_MOTION), schema=CAMERA_SERVICE_SCHEMA) hass.services.async_register( - DOMAIN, SERVICE_DISEN_MOTION, async_handle_camera_service, - descriptions.get(SERVICE_DISEN_MOTION), schema=CAMERA_SERVICE_SCHEMA) + DOMAIN, SERVICE_DISABLE_MOTION, async_handle_camera_service, + descriptions.get(SERVICE_DISABLE_MOTION), schema=CAMERA_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_SNAPSHOT, async_handle_snapshot_service, + descriptions.get(SERVICE_SNAPSHOT), + schema=CAMERA_SERVICE_SNAPSHOT) return True diff --git a/homeassistant/components/camera/axis.py b/homeassistant/components/camera/axis.py index ee8ccce1a9c..492c2a47729 100644 --- a/homeassistant/components/camera/axis.py +++ b/homeassistant/components/camera/axis.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) from homeassistant.components.camera.mjpeg import ( CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import dispatcher_connect _LOGGER = logging.getLogger(__name__) @@ -52,9 +52,9 @@ class AxisCamera(MjpegCamera): """Initialize Axis Communications camera component.""" super().__init__(hass, config) self.port = port - async_dispatcher_connect(hass, - DOMAIN + '_' + config[CONF_NAME] + '_new_ip', - self._new_ip) + dispatcher_connect(hass, + DOMAIN + '_' + config[CONF_NAME] + '_new_ip', + self._new_ip) def _new_ip(self, host): """Set new IP for video stream.""" diff --git a/homeassistant/components/camera/ring.py b/homeassistant/components/camera/ring.py new file mode 100644 index 00000000000..70569825764 --- /dev/null +++ b/homeassistant/components/camera/ring.py @@ -0,0 +1,141 @@ +""" +This component provides support to the Ring Door Bell camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.ring/ +""" +import asyncio +import logging + +from datetime import datetime, timedelta + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.components.ring import DATA_RING, CONF_ATTRIBUTION +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import ATTR_ATTRIBUTION, CONF_SCAN_INTERVAL +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.util import dt as dt_util + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +DEPENDENCIES = ['ring', 'ffmpeg'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=90) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up a Ring Door Bell and StickUp Camera.""" + ring = hass.data[DATA_RING] + + cams = [] + for camera in ring.doorbells: + cams.append(RingCam(hass, camera, config)) + + for camera in ring.stickup_cams: + cams.append(RingCam(hass, camera, config)) + + async_add_devices(cams, True) + return True + + +class RingCam(Camera): + """An implementation of a Ring Door Bell camera.""" + + def __init__(self, hass, camera, device_info): + """Initialize a Ring Door Bell camera.""" + super(RingCam, self).__init__() + self._camera = camera + self._hass = hass + self._name = self._camera.name + self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) + self._last_video_id = self._camera.last_recording_id + self._video_url = self._camera.recording_url(self._last_video_id) + self._expires_at = None + self._utcnow = None + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._camera.id, + 'firmware': self._camera.firmware, + 'kind': self._camera.kind, + 'timezone': self._camera.timezone, + 'type': self._camera.family, + 'video_url': self._video_url, + 'video_id': self._last_video_id + } + + @asyncio.coroutine + def async_camera_image(self): + """Return a still image response from the camera.""" + from haffmpeg import ImageFrame, IMAGE_JPEG + ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) + + if self._video_url is None: + return + + image = yield from asyncio.shield(ffmpeg.get_image( + self._video_url, output_format=IMAGE_JPEG, + extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) + return image + + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + + if self._video_url is None: + return + + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + yield from stream.open_camera( + self._video_url, extra_cmd=self._ffmpeg_arguments) + + yield from async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + yield from stream.close() + + @property + def should_poll(self): + """Update the image periodically.""" + return True + + def update(self): + """Update camera entity and refresh attributes.""" + # extract the video expiration from URL + x_amz_expires = int(self._video_url.split('&')[0].split('=')[-1]) + x_amz_date = self._video_url.split('&')[1].split('=')[-1] + + self._utcnow = dt_util.utcnow() + self._expires_at = \ + timedelta(seconds=x_amz_expires) + \ + dt_util.as_utc(datetime.strptime(x_amz_date, "%Y%m%dT%H%M%SZ")) + + if self._last_video_id != self._camera.last_recording_id: + _LOGGER.debug("Updated Ring DoorBell last_video_id") + self._last_video_id = self._camera.last_recording_id + + if self._utcnow >= self._expires_at: + _LOGGER.debug("Updated Ring DoorBell video_url") + self._video_url = self._camera.recording_url(self._last_video_id) diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index b6ed22f708a..926af582cc7 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -1,17 +1,25 @@ # Describes the format for available camera services enable_motion_detection: - description: Enable the motion detection in a camera - + description: Enable the motion detection in a camera. fields: entity_id: - description: Name(s) of entities to enable motion detection + description: Name(s) of entities to enable motion detection. example: 'camera.living_room_camera' disable_motion_detection: - description: Disable the motion detection in a camera - + description: Disable the motion detection in a camera. fields: entity_id: - description: Name(s) of entities to disable motion detection + description: Name(s) of entities to disable motion detection. example: 'camera.living_room_camera' + +snapshot: + description: Take a snapshot from a camera. + fields: + entity_id: + description: Name(s) of entities to create snapshots from. + example: 'camera.living_room_camera' + filename: + description: Template of a Filename. Variable is entity_id. + example: '/tmp/snapshot_{{ entity_id }}' diff --git a/homeassistant/components/climate/ephember.py b/homeassistant/components/climate/ephember.py new file mode 100644 index 00000000000..79ff767c82b --- /dev/null +++ b/homeassistant/components/climate/ephember.py @@ -0,0 +1,117 @@ +""" +Support for the EPH Controls Ember themostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.ephember/ +""" +import logging +from datetime import timedelta +import voluptuous as vol + +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, STATE_HEAT, STATE_IDLE) +from homeassistant.const import ( + TEMP_CELSIUS, CONF_USERNAME, CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyephember==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +# Return cached results if last scan was less then this time ago +SCAN_INTERVAL = timedelta(seconds=120) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the ephember thermostat.""" + from pyephember.pyephember import EphEmber + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + try: + ember = EphEmber(username, password) + zones = ember.get_zones() + for zone in zones: + add_devices([EphEmberThermostat(ember, zone)]) + except RuntimeError: + _LOGGER.error("Cannot connect to EphEmber") + return + + return + + +class EphEmberThermostat(ClimateDevice): + """Representation of a HeatmiserV3 thermostat.""" + + def __init__(self, ember, zone): + """Initialize the thermostat.""" + self._ember = ember + self._zone_name = zone['name'] + self._zone = zone + self._hot_water = zone['isHotWater'] + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._zone_name + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._zone['currentTemperature'] + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._zone['targetTemperature'] + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + if self._zone['isCurrentlyActive']: + return STATE_HEAT + else: + return STATE_IDLE + + @property + def is_aux_heat_on(self): + """Return true if aux heater.""" + return self._zone['isBoostActive'] + + def turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + self._ember.activate_boost_by_name( + self._zone_name, self._zone['targetTemperature']) + + def turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + self._ember.deactivate_boost_by_name(self._zone_name) + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + return + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._zone['targetTemperature'] + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._zone['targetTemperature'] + + def update(self): + """Get the latest data.""" + self._zone = self._ember.get_zone(self._zone_name) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 6af06323fd0..191960d2848 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -36,7 +36,8 @@ CONF_MAX_TEMP = 'max_temp' CONF_TARGET_TEMP = 'target_temp' CONF_AC_MODE = 'ac_mode' CONF_MIN_DUR = 'min_cycle_duration' -CONF_TOLERANCE = 'tolerance' +CONF_COLD_TOLERANCE = 'cold_tolerance' +CONF_HOT_TOLERANCE = 'hot_tolerance' CONF_KEEP_ALIVE = 'keep_alive' @@ -48,7 +49,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), + vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce( + float), + vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce( + float), vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), vol.Optional(CONF_KEEP_ALIVE): vol.All( cv.time_period, cv.positive_timedelta), @@ -66,12 +70,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): target_temp = config.get(CONF_TARGET_TEMP) ac_mode = config.get(CONF_AC_MODE) min_cycle_duration = config.get(CONF_MIN_DUR) - tolerance = config.get(CONF_TOLERANCE) + cold_tolerance = config.get(CONF_COLD_TOLERANCE) + hot_tolerance = config.get(CONF_HOT_TOLERANCE) keep_alive = config.get(CONF_KEEP_ALIVE) async_add_devices([GenericThermostat( hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, - target_temp, ac_mode, min_cycle_duration, tolerance, keep_alive)]) + target_temp, ac_mode, min_cycle_duration, cold_tolerance, + hot_tolerance, keep_alive)]) class GenericThermostat(ClimateDevice): @@ -79,14 +85,15 @@ class GenericThermostat(ClimateDevice): def __init__(self, hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, - tolerance, keep_alive): + cold_tolerance, hot_tolerance, keep_alive): """Initialize the thermostat.""" self.hass = hass self._name = name self.heater_entity_id = heater_entity_id self.ac_mode = ac_mode self.min_cycle_duration = min_cycle_duration - self._tolerance = tolerance + self._cold_tolerance = cold_tolerance + self._hot_tolerance = hot_tolerance self._keep_alive = keep_alive self._enabled = True @@ -261,25 +268,29 @@ class GenericThermostat(ClimateDevice): if self.ac_mode: is_cooling = self._is_device_active if is_cooling: - too_cold = self._target_temp - self._cur_temp > self._tolerance + too_cold = self._target_temp - self._cur_temp >= \ + self._cold_tolerance if too_cold: _LOGGER.info('Turning off AC %s', self.heater_entity_id) switch.async_turn_off(self.hass, self.heater_entity_id) else: - too_hot = self._cur_temp - self._target_temp > self._tolerance + too_hot = self._cur_temp - self._target_temp >= \ + self._hot_tolerance if too_hot: _LOGGER.info('Turning on AC %s', self.heater_entity_id) switch.async_turn_on(self.hass, self.heater_entity_id) else: is_heating = self._is_device_active if is_heating: - too_hot = self._cur_temp - self._target_temp > self._tolerance + too_hot = self._cur_temp - self._target_temp >= \ + self._hot_tolerance if too_hot: _LOGGER.info('Turning off heater %s', self.heater_entity_id) switch.async_turn_off(self.hass, self.heater_entity_id) else: - too_cold = self._target_temp - self._cur_temp > self._tolerance + too_cold = self._target_temp - self._cur_temp >= \ + self._cold_tolerance if too_cold: _LOGGER.info('Turning on heater %s', self.heater_entity_id) switch.async_turn_on(self.hass, self.heater_entity_id) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 0b2df903e17..253a5625ef3 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -11,16 +11,15 @@ import datetime import requests import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.climate import ( ClimateDevice, PLATFORM_SCHEMA, ATTR_FAN_MODE, ATTR_FAN_LIST, ATTR_OPERATION_MODE, ATTR_OPERATION_LIST) from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, - ATTR_TEMPERATURE) -import homeassistant.helpers.config_validation as cv + ATTR_TEMPERATURE, CONF_REGION) -REQUIREMENTS = ['evohomeclient==0.2.5', - 'somecomfort==0.4.1'] +REQUIREMENTS = ['evohomeclient==0.2.5', 'somecomfort==0.4.1'] _LOGGER = logging.getLogger(__name__) @@ -31,7 +30,6 @@ ATTR_CURRENT_OPERATION = 'equipment_output_status' CONF_AWAY_TEMPERATURE = 'away_temperature' CONF_COOL_AWAY_TEMPERATURE = 'away_cool_temperature' CONF_HEAT_AWAY_TEMPERATURE = 'away_heat_temperature' -CONF_REGION = 'region' DEFAULT_AWAY_TEMPERATURE = 16 DEFAULT_COOL_AWAY_TEMPERATURE = 30 diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index ecc5667f927..193c5107575 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -1,132 +1,102 @@ +# Describes the format for available climate services + set_aux_heat: - description: Turn auxiliary heater on/off for climate device - + description: Turn auxiliary heater on/off for climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - aux_heat: - description: New value of axillary heater + description: New value of axillary heater. example: true - set_away_mode: - description: Turn away mode on/off for climate device - + description: Turn away mode on/off for climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - away_mode: - description: New value of away mode + description: New value of away mode. example: true - set_hold_mode: - description: Turn hold mode for climate device - + description: Turn hold mode for climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - hold_mode: description: New value of hold mode example: 'away' - set_temperature: - description: Set target temperature of climate device - + description: Set target temperature of climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - temperature: - description: New target temperature for hvac + description: New target temperature for HVAC. example: 25 - target_temp_high: - description: New target high tempereature for hvac + description: New target high tempereature for HVAC. example: 26 - target_temp_low: - description: New target low temperature for hvac + description: New target low temperature for HVAC. example: 20 - operation_mode: description: Operation mode to set temperature to. This defaults to current_operation mode if not set, or set incorrectly. example: 'Heat' - set_humidity: - description: Set target humidity of climate device - + description: Set target humidity of climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - humidity: - description: New target humidity for climate device + description: New target humidity for climate device. example: 60 - set_fan_mode: - description: Set fan operation for climate device - + description: Set fan operation for climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.nest' - fan_mode: - description: New value of fan mode + description: New value of fan mode. example: On Low - set_operation_mode: - description: Set operation mode for climate device - + description: Set operation mode for climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.nest' - operation_mode: - description: New value of operation mode + description: New value of operation mode. example: Heat - - set_swing_mode: - description: Set swing operation for climate device - + description: Set swing operation for climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.nest' - swing_mode: - description: New value of swing mode + description: New value of swing mode. example: 1 - ecobee_set_fan_min_on_time: - description: Set the minimum fan on time - + description: Set the minimum fan on time. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - fan_min_on_time: - description: New value of fan min on time + description: New value of fan min on time. example: 5 ecobee_resume_program: - description: Resume the programmed schedule - + description: Resume the programmed schedule. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - resume_all: description: Resume all events and return to the scheduled program. This default to false which removes only the top event. example: true diff --git a/homeassistant/components/climate/toon.py b/homeassistant/components/climate/toon.py index c4021a97c91..72e6ecb1fdb 100644 --- a/homeassistant/components/climate/toon.py +++ b/homeassistant/components/climate/toon.py @@ -3,23 +3,20 @@ Toon van Eneco Thermostat Support. This provides a component for the rebranded Quby thermostat as provided by Eneco. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.toon/ """ - -from homeassistant.components.climate import (ClimateDevice, - ATTR_TEMPERATURE, - STATE_PERFORMANCE, - STATE_HEAT, - STATE_ECO, - STATE_COOL) -from homeassistant.const import TEMP_CELSIUS - import homeassistant.components.toon as toon_main +from homeassistant.components.climate import ( + ClimateDevice, ATTR_TEMPERATURE, STATE_PERFORMANCE, STATE_HEAT, STATE_ECO, + STATE_COOL) +from homeassistant.const import TEMP_CELSIUS def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup thermostat.""" - # Add toon - add_devices((ThermostatDevice(hass), ), True) + """Set up the Toon thermostat.""" + add_devices([ThermostatDevice(hass)], True) class ThermostatDevice(ClimateDevice): @@ -31,25 +28,21 @@ class ThermostatDevice(ClimateDevice): self.hass = hass self.thermos = hass.data[toon_main.TOON_HANDLE] - # set up internal state vars self._state = None self._temperature = None self._setpoint = None - self._operation_list = [STATE_PERFORMANCE, - STATE_HEAT, - STATE_ECO, - STATE_COOL] + self._operation_list = [ + STATE_PERFORMANCE, + STATE_HEAT, + STATE_ECO, + STATE_COOL, + ] @property def name(self): """Name of this Thermostat.""" return self._name - @property - def should_poll(self): - """Polling is required.""" - return True - @property def temperature_unit(self): """The unit of measurement used by the platform.""" @@ -83,10 +76,12 @@ class ThermostatDevice(ClimateDevice): def set_operation_mode(self, operation_mode): """Set new operation mode as toonlib requires it.""" - toonlib_values = {STATE_PERFORMANCE: 'Comfort', - STATE_HEAT: 'Home', - STATE_ECO: 'Away', - STATE_COOL: 'Sleep'} + toonlib_values = { + STATE_PERFORMANCE: 'Comfort', + STATE_HEAT: 'Home', + STATE_ECO: 'Away', + STATE_COOL: 'Sleep', + } self.thermos.set_state(toonlib_values[operation_mode]) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index c711b00fdd2..c5d709d60c3 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -6,22 +6,23 @@ import os import voluptuous as vol -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE) from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS - REQUIREMENTS = ['warrant==0.5.0'] -DEPENDENCIES = ['http'] -CONF_MODE = 'mode' + +_LOGGER = logging.getLogger(__name__) + CONF_COGNITO_CLIENT_ID = 'cognito_client_id' -CONF_USER_POOL_ID = 'user_pool_id' -CONF_REGION = 'region' CONF_RELAYER = 'relayer' +CONF_USER_POOL_ID = 'user_pool_id' + MODE_DEV = 'development' DEFAULT_MODE = MODE_DEV -_LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['http'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 92b517b570c..1bb6668e0cc 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -62,7 +62,7 @@ class CloudIoT: self.client = client = yield from session.ws_connect( self.cloud.relayer, headers={ hdrs.AUTHORIZATION: - 'Bearer {}'.format(self.cloud.access_token) + 'Bearer {}'.format(self.cloud.id_token) }) self.tries = 0 diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 9ce7f30529b..c45e3561c47 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -8,7 +8,6 @@ from homeassistant.core import callback from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID from homeassistant.setup import ( async_prepare_setup_platform, ATTR_COMPONENT) -from homeassistant.components.frontend import register_built_in_panel from homeassistant.components.http import HomeAssistantView from homeassistant.util.yaml import load_yaml, dump @@ -21,7 +20,8 @@ ON_DEMAND = ('zwave') @asyncio.coroutine def async_setup(hass, config): """Set up the config component.""" - register_built_in_panel(hass, 'config', 'Configuration', 'mdi:settings') + yield from hass.components.frontend.async_register_built_in_panel( + 'config', 'config', 'mdi:settings') @asyncio.coroutine def setup_panel(panel_name): diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml index 02765ca9ab8..41be271fff0 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -1,71 +1,63 @@ -open_cover: - description: Open all or specified cover +# Describes the format for available cover services +open_cover: + description: Open all or specified cover. fields: entity_id: - description: Name(s) of cover(s) to open + description: Name(s) of cover(s) to open. example: 'cover.living_room' close_cover: - description: Close all or specified cover - + description: Close all or specified cover. fields: entity_id: - description: Name(s) of cover(s) to close + description: Name(s) of cover(s) to close. example: 'cover.living_room' set_cover_position: - description: Move to specific position all or specified cover - + description: Move to specific position all or specified cover. fields: entity_id: - description: Name(s) of cover(s) to set cover position + description: Name(s) of cover(s) to set cover position. example: 'cover.living_room' - position: - description: Position of the cover (0 to 100) + description: Position of the cover (0 to 100). example: 30 stop_cover: - description: Stop all or specified cover - + description: Stop all or specified cover. fields: entity_id: - description: Name(s) of cover(s) to stop + description: Name(s) of cover(s) to stop. example: 'cover.living_room' open_cover_tilt: - description: Open all or specified cover tilt - + description: Open all or specified cover tilt. fields: entity_id: - description: Name(s) of cover(s) tilt to open + description: Name(s) of cover(s) tilt to open. example: 'cover.living_room' close_cover_tilt: - description: Close all or specified cover tilt - + description: Close all or specified cover tilt. fields: entity_id: - description: Name(s) of cover(s) to close tilt + description: Name(s) of cover(s) to close tilt. example: 'cover.living_room' set_cover_tilt_position: - description: Move to specific position all or specified cover tilt - + description: Move to specific position all or specified cover tilt. fields: entity_id: - description: Name(s) of cover(s) to set cover tilt position + description: Name(s) of cover(s) to set cover tilt position. example: 'cover.living_room' - position: - description: Position of the cover (0 to 100) + description: Position of the cover (0 to 100). example: 30 stop_cover_tilt: - description: Stop all or specified cover - + description: Stop all or specified cover. fields: entity_id: - description: Name(s) of cover(s) to stop + description: Name(s) of cover(s) to stop. example: 'cover.living_room' diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index 4c79d19d38d..34aa636185e 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -19,7 +19,8 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_ENTITY_ID, EVENT_HOMEASSISTANT_START, MATCH_ALL, CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, - CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED) + CONF_ENTITY_PICTURE_TEMPLATE, CONF_OPTIMISTIC, + STATE_OPEN, STATE_CLOSED) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id @@ -57,6 +58,7 @@ COVER_SCHEMA = vol.Schema({ vol.Optional(CONF_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_TEMPLATE): cv.template, vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, vol.Optional(CONF_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, @@ -81,6 +83,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): position_template = device_config.get(CONF_POSITION_TEMPLATE) tilt_template = device_config.get(CONF_TILT_TEMPLATE) icon_template = device_config.get(CONF_ICON_TEMPLATE) + entity_picture_template = device_config.get( + CONF_ENTITY_PICTURE_TEMPLATE) open_action = device_config.get(OPEN_ACTION) close_action = device_config.get(CLOSE_ACTION) stop_action = device_config.get(STOP_ACTION) @@ -114,6 +118,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if str(temp_ids) != MATCH_ALL: template_entity_ids |= set(temp_ids) + if entity_picture_template is not None: + temp_ids = entity_picture_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + if not template_entity_ids: template_entity_ids = MATCH_ALL @@ -124,8 +133,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hass, device, friendly_name, state_template, position_template, tilt_template, icon_template, - open_action, close_action, stop_action, - position_action, tilt_action, + entity_picture_template, open_action, close_action, + stop_action, position_action, tilt_action, optimistic, tilt_optimistic, entity_ids ) ) @@ -142,8 +151,8 @@ class CoverTemplate(CoverDevice): def __init__(self, hass, device_id, friendly_name, state_template, position_template, tilt_template, icon_template, - open_action, close_action, stop_action, - position_action, tilt_action, + entity_picture_template, open_action, close_action, + stop_action, position_action, tilt_action, optimistic, tilt_optimistic, entity_ids): """Initialize the Template cover.""" self.hass = hass @@ -154,6 +163,7 @@ class CoverTemplate(CoverDevice): self._position_template = position_template self._tilt_template = tilt_template self._icon_template = icon_template + self._entity_picture_template = entity_picture_template self._open_script = None if open_action is not None: self._open_script = Script(hass, open_action) @@ -173,6 +183,7 @@ class CoverTemplate(CoverDevice): (not state_template and not position_template)) self._tilt_optimistic = tilt_optimistic or not tilt_template self._icon = None + self._entity_picture = None self._position = None self._tilt_value = None self._entities = entity_ids @@ -185,6 +196,8 @@ class CoverTemplate(CoverDevice): self._tilt_template.hass = self.hass if self._icon_template is not None: self._icon_template.hass = self.hass + if self._entity_picture_template is not None: + self._entity_picture_template.hass = self.hass @asyncio.coroutine def async_added_to_hass(self): @@ -236,6 +249,11 @@ class CoverTemplate(CoverDevice): """Return the icon to use in the frontend, if any.""" return self._icon + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + return self._entity_picture + @property def supported_features(self): """Flag supported features.""" @@ -283,7 +301,7 @@ class CoverTemplate(CoverDevice): def async_stop_cover(self, **kwargs): """Fire the stop action.""" if self._stop_script: - self.hass.async_add_job(self._stop_script.async_run()) + yield from self._stop_script.async_run() @asyncio.coroutine def async_set_cover_position(self, **kwargs): @@ -369,16 +387,28 @@ class CoverTemplate(CoverDevice): except ValueError as ex: _LOGGER.error(ex) self._tilt_value = None - if self._icon_template is not None: + + for property_name, template in ( + ('_icon', self._icon_template), + ('_entity_picture', self._entity_picture_template)): + if template is None: + continue + try: - self._icon = self._icon_template.async_render() + setattr(self, property_name, template.async_render()) except TemplateError as ex: + friendly_property_name = property_name[1:].replace('_', ' ') if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): # Common during HA startup - so just a warning - _LOGGER.warning('Could not render icon template %s,' - ' the state is unknown.', self._name) + _LOGGER.warning('Could not render %s template %s,' + ' the state is unknown.', + friendly_property_name, self._name) return - self._icon = super().icon - _LOGGER.error('Could not render icon template %s: %s', - self._name, ex) + + try: + setattr(self, property_name, + getattr(super(), property_name)) + except AttributeError: + _LOGGER.error('Could not render %s template %s: %s', + friendly_property_name, self._name, ex) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 9a6dffc6101..05131a039cd 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -21,7 +21,7 @@ from homeassistant.components import group, zone from homeassistant.config import load_yaml_config_file, async_log_exception from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers import config_per_platform +from homeassistant.helpers import config_per_platform, discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.restore_state import async_get_last_state @@ -175,6 +175,13 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): tracker.async_setup_group() + @asyncio.coroutine + def async_platform_discovered(platform, info): + """Load a platform.""" + yield from async_setup_platform(platform, {}, disc_info=info) + + discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) + # Clean up stale devices async_track_utc_time_change( hass, tracker.async_update_stale, second=range(0, 60, 5)) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 9b214441ac9..f2d2a4c74b5 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -12,18 +12,17 @@ from collections import namedtuple 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_PASSWORD, CONF_USERNAME, CONF_PORT) -import homeassistant.helpers.config_validation as cv + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, + CONF_PROTOCOL) REQUIREMENTS = ['pexpect==4.0.1'] _LOGGER = logging.getLogger(__name__) -CONF_MODE = 'mode' -CONF_PROTOCOL = 'protocol' CONF_PUB_KEY = 'pub_key' CONF_SSH_KEY = 'ssh_key' @@ -36,10 +35,8 @@ PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_PROTOCOL, default='ssh'): - vol.In(['ssh', 'telnet']), - vol.Optional(CONF_MODE, default='router'): - vol.In(['router', 'ap']), + vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']), + vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']), vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port, vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, @@ -102,21 +99,18 @@ class AsusWrtDeviceScanner(DeviceScanner): self.success_init = False return - self.connection = SshConnection(self.host, self.port, - self.username, - self.password, - self.ssh_key, - self.mode == "ap") + self.connection = SshConnection( + self.host, self.port, self.username, self.password, + self.ssh_key, self.mode == 'ap') else: if not self.password: _LOGGER.error("No password specified") self.success_init = False return - self.connection = TelnetConnection(self.host, self.port, - self.username, - self.password, - self.mode == "ap") + self.connection = TelnetConnection( + self.host, self.port, self.username, self.password, + self.mode == 'ap') self.last_results = {} diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 05fe0b6997d..ef747657cb4 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.6.3'] +REQUIREMENTS = ['aioautomatic==0.6.4'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -35,7 +35,7 @@ CONF_CURRENT_LOCATION = 'current_location' DEFAULT_TIMEOUT = 5 -DEFAULT_SCOPE = ['location', 'vehicle:profile', 'trip'] +DEFAULT_SCOPE = ['location', 'trip', 'vehicle:events', 'vehicle:profile'] FULL_SCOPE = DEFAULT_SCOPE + ['current_location'] ATTR_FUEL_LEVEL = 'fuel_level' diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py index d4e576bad74..58d69f39a1d 100755 --- a/homeassistant/components/device_tracker/geofency.py +++ b/homeassistant/components/device_tracker/geofency.py @@ -21,6 +21,9 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['http'] +ATTR_CURRENT_LATITUDE = 'currentLatitude' +ATTR_CURRENT_LONGITUDE = 'currentLongitude' + BEACON_DEV_PREFIX = 'beacon' CONF_MOBILE_BEACONS = 'mobile_beacons' @@ -72,6 +75,9 @@ class GeofencyView(HomeAssistantView): location_name = data['name'] else: location_name = STATE_NOT_HOME + if ATTR_CURRENT_LATITUDE in data: + data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] + data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] return (yield from self._set_location(hass, data, location_name)) @@ -96,8 +102,12 @@ class GeofencyView(HomeAssistantView): data['device'] = slugify(data['device']) data['name'] = slugify(data['name']) - data[ATTR_LATITUDE] = float(data[ATTR_LATITUDE]) - data[ATTR_LONGITUDE] = float(data[ATTR_LONGITUDE]) + gps_attributes = [ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_CURRENT_LATITUDE, ATTR_CURRENT_LONGITUDE] + + for attribute in gps_attributes: + if attribute in data: + data[attribute] = float(data[attribute]) return data diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index b445de116b9..7ac84125863 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -76,25 +76,47 @@ class MikrotikScanner(DeviceScanner): port=int(self.port) ) - routerboard_info = self.client(cmd='/system/routerboard/getall') + try: + routerboard_info = self.client( + cmd='/system/routerboard/getall') + except (librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionError): + routerboard_info = None + raise if routerboard_info: _LOGGER.info("Connected to Mikrotik %s with IP %s", routerboard_info[0].get('model', 'Router'), self.host) + self.connected = True - self.capsman_exist = self.client( - cmd='/capsman/interface/getall' - ) + + try: + self.capsman_exist = self.client( + cmd='/caps-man/interface/getall' + ) + except (librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionError): + self.capsman_exist = False + if not self.capsman_exist: _LOGGER.info( 'Mikrotik %s: Not a CAPSman controller. Trying ' 'local interfaces ', self.host ) - self.wireless_exist = self.client( - cmd='/interface/wireless/getall' - ) + + try: + self.wireless_exist = self.client( + cmd='/interface/wireless/getall' + ) + except (librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionError): + self.wireless_exist = False + if not self.wireless_exist: _LOGGER.info( 'Mikrotik %s: Wireless adapters not found. Try to ' @@ -104,6 +126,7 @@ class MikrotikScanner(DeviceScanner): ) except (librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, librouteros.exceptions.ConnectionError) as api_error: _LOGGER.error("Connection error: %s", api_error) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index ace6a251747..77241e1a8ab 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -20,7 +20,7 @@ from homeassistant.const import STATE_HOME from homeassistant.core import callback from homeassistant.util import slugify, decorator -REQUIREMENTS = ['libnacl==1.6.0'] +REQUIREMENTS = ['libnacl==1.6.1'] _LOGGER = logging.getLogger(__name__) @@ -199,7 +199,7 @@ class OwnTracksContext: self.async_see = async_see self.secret = secret self.max_gps_accuracy = max_gps_accuracy - self.mobile_beacons_active = defaultdict(list) + self.mobile_beacons_active = defaultdict(set) self.regions_entered = defaultdict(list) self.import_waypoints = import_waypoints self.waypoint_whitelist = waypoint_whitelist @@ -234,10 +234,25 @@ class OwnTracksContext: return True @asyncio.coroutine - def async_see_beacons(self, dev_id, kwargs_param): + def async_see_beacons(self, hass, dev_id, kwargs_param): """Set active beacons to the current location.""" kwargs = kwargs_param.copy() + + # Mobile beacons should always be set to the location of the + # tracking device. I get the device state and make the necessary + # changes to kwargs. + device_tracker_state = hass.states.get( + "device_tracker.{}".format(dev_id)) + + if device_tracker_state is not None: + acc = device_tracker_state.attributes.get("gps_accuracy") + lat = device_tracker_state.attributes.get("latitude") + lon = device_tracker_state.attributes.get("longitude") + kwargs['gps_accuracy'] = acc + kwargs['gps'] = (lat, lon) + # the battery state applies to the tracking device, not the beacon + # kwargs location is the beacon's configured lat/lon kwargs.pop('battery', None) for beacon in self.mobile_beacons_active[dev_id]: kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) @@ -261,7 +276,7 @@ def async_handle_location_message(hass, context, message): return yield from context.async_see(**kwargs) - yield from context.async_see_beacons(dev_id, kwargs) + yield from context.async_see_beacons(hass, dev_id, kwargs) @asyncio.coroutine @@ -271,11 +286,15 @@ def _async_transition_message_enter(hass, context, message, location): dev_id, kwargs = _parse_see_args(message) if zone is None and message.get('t') == 'b': - # Not a HA zone, and a beacon so assume mobile + # Not a HA zone, and a beacon so mobile beacon. + # kwargs will contain the lat/lon of the beacon + # which is not where the beacon actually is + # and is probably set to 0/0 beacons = context.mobile_beacons_active[dev_id] if location not in beacons: - beacons.append(location) + beacons.add(location) _LOGGER.info("Added beacon %s", location) + yield from context.async_see_beacons(hass, dev_id, kwargs) else: # Normal region regions = context.regions_entered[dev_id] @@ -283,9 +302,8 @@ def _async_transition_message_enter(hass, context, message, location): regions.append(location) _LOGGER.info("Enter region %s", location) _set_gps_from_zone(kwargs, location, zone) - - yield from context.async_see(**kwargs) - yield from context.async_see_beacons(dev_id, kwargs) + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(hass, dev_id, kwargs) @asyncio.coroutine @@ -297,30 +315,29 @@ def _async_transition_message_leave(hass, context, message, location): if location in regions: regions.remove(location) - new_region = regions[-1] if regions else None - - if new_region: - # Exit to previous region - zone = hass.states.get( - "zone.{}".format(slugify(new_region))) - _set_gps_from_zone(kwargs, new_region, zone) - _LOGGER.info("Exit to %s", new_region) - yield from context.async_see(**kwargs) - yield from context.async_see_beacons(dev_id, kwargs) - return - + beacons = context.mobile_beacons_active[dev_id] + if location in beacons: + beacons.remove(location) + _LOGGER.info("Remove beacon %s", location) + yield from context.async_see_beacons(hass, dev_id, kwargs) else: + new_region = regions[-1] if regions else None + if new_region: + # Exit to previous region + zone = hass.states.get( + "zone.{}".format(slugify(new_region))) + _set_gps_from_zone(kwargs, new_region, zone) + _LOGGER.info("Exit to %s", new_region) + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(hass, dev_id, kwargs) + return + _LOGGER.info("Exit to GPS") # Check for GPS accuracy if context.async_valid_accuracy(message): yield from context.async_see(**kwargs) - yield from context.async_see_beacons(dev_id, kwargs) - - beacons = context.mobile_beacons_active[dev_id] - if location in beacons: - beacons.remove(location) - _LOGGER.info("Remove beacon %s", location) + yield from context.async_see_beacons(hass, dev_id, kwargs) @HANDLERS.register('transition') diff --git a/homeassistant/components/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml index 2d3315b319a..7436bbd6ea4 100644 --- a/homeassistant/components/device_tracker/services.yaml +++ b/homeassistant/components/device_tracker/services.yaml @@ -1,41 +1,33 @@ # Describes the format for available device tracker services see: - description: Control tracked device - + description: Control tracked device. fields: mac: description: MAC address of device example: 'FF:FF:FF:FF:FF:FF' - dev_id: - description: Id of device (find id in known_devices.yaml) + description: Id of device (find id in known_devices.yaml). example: 'phonedave' - host_name: description: Hostname of device example: 'Dave' - location_name: - description: Name of location where device is located (not_home is away) + description: Name of location where device is located (not_home is away). example: 'home' - gps: - description: GPS coordinates where device is located (latitude, longitude) + description: GPS coordinates where device is located (latitude, longitude). example: '[51.509802, -0.086692]' - gps_accuracy: - description: Accuracy of GPS coordinates + description: Accuracy of GPS coordinates. example: '80' - battery: - description: Battery level of device + description: Battery level of device. example: '100' icloud: icloud_lost_iphone: - description: Service to play the lost iphone sound on an iDevice - + description: Service to play the lost iphone sound on an iDevice. fields: account_name: description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. @@ -43,10 +35,8 @@ icloud: device_name: description: Name of the device that will play the sound. This is optional, if it isn't given it will play on all devices for the given account. example: 'iphonebart' - icloud_set_interval: - description: Service to set the interval of an iDevice - + description: Service to set the interval of an iDevice. fields: account_name: description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. @@ -57,10 +47,8 @@ icloud: interval: description: The interval (in minutes) that the iDevice will have until the according device_tracker entity changes from zone or until this service is used again. This is optional, if it isn't given the interval of the device will revert back to the original interval based on the current state. example: 1 - icloud_update: description: Service to ask for an update of an iDevice. - fields: account_name: description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. @@ -68,10 +56,8 @@ icloud: device_name: description: Name of the device that will be updated. This is optional, if it isn't given it will update all devices for the given account. example: 'iphonebart' - icloud_reset_account: description: Service to restart an iCloud account. Helpful when not all devices are found after initializing or when you add a new device. - fields: account_name: description: Name of the account in the config that will be restarted. This is optional, if it isn't given it will restart all accounts. diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index d0cfcff20ef..8c1bf6dc67b 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_HOST _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pysnmp==4.3.10'] +REQUIREMENTS = ['pysnmp==4.4.1'] CONF_COMMUNITY = 'community' CONF_AUTHKEY = 'authkey' @@ -26,11 +26,11 @@ CONF_BASEOID = 'baseoid' DEFAULT_COMMUNITY = 'public' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_BASEOID): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string, vol.Inclusive(CONF_AUTHKEY, 'keys'): cv.string, vol.Inclusive(CONF_PRIVKEY, 'keys'): cv.string, - vol.Required(CONF_BASEOID): cv.string }) diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index 9ccc61dffc9..99f20d4385e 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -19,16 +19,29 @@ from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) +CONF_DHCP_SOFTWARE = 'dhcp_software' +DEFAULT_DHCP_SOFTWARE = 'dnsmasq' +DHCP_SOFTWARES = [ + 'dnsmasq', + 'odhcpd' +] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_DHCP_SOFTWARE, + default=DEFAULT_DHCP_SOFTWARE): vol.In(DHCP_SOFTWARES) }) def get_scanner(hass, config): """Validate the configuration and return an ubus scanner.""" - scanner = UbusDeviceScanner(config[DOMAIN]) + dhcp_sw = config[DOMAIN][CONF_DHCP_SOFTWARE] + if dhcp_sw == 'dnsmasq': + scanner = DnsmasqUbusDeviceScanner(config[DOMAIN]) + else: + scanner = OdhcpdUbusDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -70,7 +83,6 @@ class UbusDeviceScanner(DeviceScanner): self.session_id = _get_session_id(self.url, self.username, self.password) self.hostapd = [] - self.leasefile = None self.mac2name = None self.success_init = self.session_id is not None @@ -79,44 +91,29 @@ class UbusDeviceScanner(DeviceScanner): self._update_info() return self.last_results + def _generate_mac2name(self): + """Must be implemented depending on the software.""" + raise NotImplementedError + @_refresh_on_acccess_denied def get_device_name(self, mac): """Return the name of the given device or None if we don't know.""" - if self.leasefile is None: - result = _req_json_rpc( - self.url, self.session_id, 'call', 'uci', 'get', - config="dhcp", type="dnsmasq") - if result: - values = result["values"].values() - self.leasefile = next(iter(values))["leasefile"] - else: - return - if self.mac2name is None: - result = _req_json_rpc( - self.url, self.session_id, 'call', 'file', 'read', - path=self.leasefile) - if result: - self.mac2name = dict() - for line in result["data"].splitlines(): - hosts = line.split(" ") - self.mac2name[hosts[1].upper()] = hosts[3] - else: - # Error, handled in the _req_json_rpc - return - - return self.mac2name.get(mac.upper(), None) + self._generate_mac2name() + name = self.mac2name.get(mac.upper(), None) + self.mac2name = None + return name @_refresh_on_acccess_denied def _update_info(self): - """Ensure the information from the Luci router is up to date. + """Ensure the information from the router is up to date. Returns boolean if scanning successful. """ if not self.success_init: return False - _LOGGER.info("Checking ARP") + _LOGGER.info("Checking hostapd") if not self.hostapd: hostapd = _req_json_rpc( @@ -136,6 +133,57 @@ class UbusDeviceScanner(DeviceScanner): return bool(results) +class DnsmasqUbusDeviceScanner(UbusDeviceScanner): + """Implement the Ubus device scanning for the dnsmasq DHCP server.""" + + def __init__(self, config): + """Initialize the scanner.""" + super(DnsmasqUbusDeviceScanner, self).__init__(config) + self.leasefile = None + + def _generate_mac2name(self): + if self.leasefile is None: + result = _req_json_rpc( + self.url, self.session_id, 'call', 'uci', 'get', + config="dhcp", type="dnsmasq") + if result: + values = result["values"].values() + self.leasefile = next(iter(values))["leasefile"] + else: + return + + result = _req_json_rpc( + self.url, self.session_id, 'call', 'file', 'read', + path=self.leasefile) + if result: + self.mac2name = dict() + for line in result["data"].splitlines(): + hosts = line.split(" ") + self.mac2name[hosts[1].upper()] = hosts[3] + else: + # Error, handled in the _req_json_rpc + return + + +class OdhcpdUbusDeviceScanner(UbusDeviceScanner): + """Implement the Ubus device scanning for the odhcp DHCP server.""" + + def _generate_mac2name(self): + result = _req_json_rpc( + self.url, self.session_id, 'call', 'dhcp', 'ipv4leases') + if result: + self.mac2name = dict() + for device in result["device"].values(): + for lease in device['leases']: + mac = lease['mac'] # mac = aabbccddeeff + # Convert it to expected format with colon + mac = ":".join(mac[i:i+2] for i in range(0, len(mac), 2)) + self.mac2name[mac.upper()] = lease['hostname'] + else: + # Error, handled in the _req_json_rpc + return + + def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params): """Perform one JSON RPC operation.""" data = json.dumps({"jsonrpc": "2.0", diff --git a/homeassistant/components/apiai.py b/homeassistant/components/dialogflow.py similarity index 67% rename from homeassistant/components/apiai.py rename to homeassistant/components/dialogflow.py index eb6cd0027f7..3f2cae112f5 100644 --- a/homeassistant/components/apiai.py +++ b/homeassistant/components/dialogflow.py @@ -1,8 +1,8 @@ """ -Support for API.AI webhook. +Support for Dialogflow webhook. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/apiai/ +https://home-assistant.io/components/dialogflow/ """ import asyncio import logging @@ -15,17 +15,16 @@ from homeassistant.components.http import HomeAssistantView _LOGGER = logging.getLogger(__name__) -INTENTS_API_ENDPOINT = '/api/apiai' - CONF_INTENTS = 'intents' CONF_SPEECH = 'speech' CONF_ACTION = 'action' CONF_ASYNC_ACTION = 'async_action' DEFAULT_CONF_ASYNC_ACTION = False - -DOMAIN = 'apiai' DEPENDENCIES = ['http'] +DOMAIN = 'dialogflow' + +INTENTS_API_ENDPOINT = '/api/dialogflow' CONFIG_SCHEMA = vol.Schema({ DOMAIN: {} @@ -34,30 +33,30 @@ CONFIG_SCHEMA = vol.Schema({ @asyncio.coroutine def async_setup(hass, config): - """Activate API.AI component.""" - hass.http.register_view(ApiaiIntentsView) + """Set up Dialogflow component.""" + hass.http.register_view(DialogflowIntentsView) return True -class ApiaiIntentsView(HomeAssistantView): - """Handle API.AI requests.""" +class DialogflowIntentsView(HomeAssistantView): + """Handle Dialogflow requests.""" url = INTENTS_API_ENDPOINT - name = 'api:apiai' + name = 'api:dialogflow' @asyncio.coroutine def post(self, request): - """Handle API.AI.""" + """Handle Dialogflow.""" hass = request.app['hass'] data = yield from request.json() - _LOGGER.debug("Received api.ai request: %s", data) + _LOGGER.debug("Received Dialogflow request: %s", data) req = data.get('result') if req is None: - _LOGGER.error("Received invalid data from api.ai: %s", data) + _LOGGER.error("Received invalid data from Dialogflow: %s", data) return self.json_message( "Expected result value not received", HTTP_BAD_REQUEST) @@ -68,13 +67,13 @@ class ApiaiIntentsView(HomeAssistantView): action = req.get('action') parameters = req.get('parameters') - apiai_response = ApiaiResponse(parameters) + dialogflow_response = DialogflowResponse(parameters) if action == "": _LOGGER.warning("Received intent with empty action") - apiai_response.add_speech( - "You have not defined an action in your api.ai intent.") - return self.json(apiai_response) + dialogflow_response.add_speech( + "You have not defined an action in your Dialogflow intent.") + return self.json(dialogflow_response) try: intent_response = yield from intent.async_handle( @@ -83,31 +82,31 @@ class ApiaiIntentsView(HomeAssistantView): in parameters.items()}) except intent.UnknownIntent as err: - _LOGGER.warning('Received unknown intent %s', action) - apiai_response.add_speech( + _LOGGER.warning("Received unknown intent %s", action) + dialogflow_response.add_speech( "This intent is not yet configured within Home Assistant.") - return self.json(apiai_response) + return self.json(dialogflow_response) except intent.InvalidSlotInfo as err: - _LOGGER.error('Received invalid slot data: %s', err) + _LOGGER.error("Received invalid slot data: %s", err) return self.json_message('Invalid slot data received', HTTP_BAD_REQUEST) except intent.IntentError: - _LOGGER.exception('Error handling request for %s', action) + _LOGGER.exception("Error handling request for %s", action) return self.json_message('Error handling intent', HTTP_BAD_REQUEST) if 'plain' in intent_response.speech: - apiai_response.add_speech( + dialogflow_response.add_speech( intent_response.speech['plain']['speech']) - return self.json(apiai_response) + return self.json(dialogflow_response) -class ApiaiResponse(object): - """Help generating the response for API.AI.""" +class DialogflowResponse(object): + """Help generating the response for Dialogflow.""" def __init__(self, parameters): - """Initialize the response.""" + """Initialize the Dialogflow response.""" self.speech = None self.parameters = {} # Parameter names replace '.' and '-' for '_' @@ -125,7 +124,7 @@ class ApiaiResponse(object): self.speech = text def as_dict(self): - """Return response in an API.AI valid dict.""" + """Return response in a Dialogflow valid dictionary.""" return { 'speech': self.speech, 'displayText': self.speech, diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 50cc771ffd3..6861c5bdc70 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.2.2'] +REQUIREMENTS = ['netdisco==1.2.3'] DOMAIN = 'discovery' diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index 0450ba175ee..07f432a5218 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -17,6 +17,7 @@ from homeassistant.util import sanitize_filename _LOGGER = logging.getLogger(__name__) +ATTR_FILENAME = 'filename' ATTR_SUBDIR = 'subdir' ATTR_URL = 'url' @@ -29,6 +30,7 @@ SERVICE_DOWNLOAD_FILE = 'download_file' SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({ vol.Required(ATTR_URL): cv.url, vol.Optional(ATTR_SUBDIR): cv.string, + vol.Optional(ATTR_FILENAME): cv.string, }) CONFIG_SCHEMA = vol.Schema({ @@ -62,6 +64,8 @@ def setup(hass, config): subdir = service.data.get(ATTR_SUBDIR) + filename = service.data.get(ATTR_FILENAME) + if subdir: subdir = sanitize_filename(subdir) @@ -70,9 +74,9 @@ def setup(hass, config): req = requests.get(url, stream=True, timeout=10) if req.status_code == 200: - filename = None - if 'content-disposition' in req.headers: + if filename is None and \ + 'content-disposition' in req.headers: match = re.findall(r"filename=(\S+)", req.headers['content-disposition']) @@ -80,8 +84,7 @@ def setup(hass, config): filename = match[0].strip("'\" ") if not filename: - filename = os.path.basename( - url).strip() + filename = os.path.basename(url).strip() if not filename: filename = 'ha_download' diff --git a/homeassistant/components/duckdns.py b/homeassistant/components/duckdns.py index 0045b9421a2..178e1579538 100644 --- a/homeassistant/components/duckdns.py +++ b/homeassistant/components/duckdns.py @@ -1,4 +1,9 @@ -"""Integrate with DuckDNS.""" +""" +Integrate with DuckDNS. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/duckdns/ +""" import asyncio from datetime import timedelta import logging @@ -11,13 +16,18 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.aiohttp_client import async_get_clientsession -DOMAIN = 'duckdns' -UPDATE_URL = 'https://www.duckdns.org/update' -INTERVAL = timedelta(minutes=5) _LOGGER = logging.getLogger(__name__) -SERVICE_SET_TXT = 'set_txt' + ATTR_TXT = 'txt' +DOMAIN = 'duckdns' + +INTERVAL = timedelta(minutes=5) + +SERVICE_SET_TXT = 'set_txt' + +UPDATE_URL = 'https://www.duckdns.org/update' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_DOMAIN): cv.string, @@ -59,8 +69,8 @@ def async_setup(hass, config): @asyncio.coroutine def update_domain_service(call): """Update the DuckDNS entry.""" - yield from _update_duckdns(session, domain, token, - txt=call.data[ATTR_TXT]) + yield from _update_duckdns( + session, domain, token, txt=call.data[ATTR_TXT]) async_track_time_interval(hass, update_domain_interval, INTERVAL) hass.services.async_register( @@ -96,7 +106,7 @@ def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False): body = yield from resp.text() if body != 'OK': - _LOGGER.warning('Updating DuckDNS domain %s failed', domain) + _LOGGER.warning("Updating DuckDNS domain failed: %s", domain) return False return True diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index a83f5337cae..b2399d748c9 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -76,7 +76,6 @@ def setup(hass, yaml_config): server = HomeAssistantWSGI( hass, - development=False, server_host=config.host_ip_addr, server_port=config.listen_port, api_password=None, diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 7ff174b32b6..7b98ca7deaa 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -287,6 +287,11 @@ def parse_hue_api_put_light_body(request_json, entity): report_brightness = True result = (brightness > 0) + elif entity.domain == "scene": + brightness = None + report_brightness = False + result = True + elif (entity.domain == "script" or entity.domain == "media_player" or entity.domain == "fan"): diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index e76e11d4786..eed6cf898c1 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -186,7 +186,7 @@ class MqttFan(FanEntity): yield from mqtt.async_subscribe( self.hass, self._topic[CONF_SPEED_STATE_TOPIC], speed_received, self._qos) - self._speed = SPEED_OFF + self._speed = SPEED_OFF @callback def oscillation_received(topic, payload, qos): @@ -202,7 +202,7 @@ class MqttFan(FanEntity): yield from mqtt.async_subscribe( self.hass, self._topic[CONF_OSCILLATION_STATE_TOPIC], oscillation_received, self._qos) - self._oscillation = False + self._oscillation = False @property def should_poll(self): diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index 4a91f49e382..2a8ad453ec8 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -1,60 +1,51 @@ # Describes the format for available fan services set_speed: - description: Sets fan speed - + description: Sets fan speed. fields: entity_id: description: Name(s) of the entities to set example: 'fan.living_room' - speed: description: Speed setting example: 'low' turn_on: - description: Turns fan on - + description: Turns fan on. fields: entity_id: description: Names(s) of the entities to turn on example: 'fan.living_room' - speed: description: Speed setting example: 'high' turn_off: - description: Turns fan off - + description: Turns fan off. fields: entity_id: description: Names(s) of the entities to turn off example: 'fan.living_room' oscillate: - description: Oscillates the fan - + description: Oscillates the fan. fields: entity_id: description: Name(s) of the entities to oscillate example: 'fan.desk_fan' - oscillating: description: Flag to turn on/off oscillation example: True toggle: - description: Toggle the fan on/off - + description: Toggle the fan on/off. fields: entity_id: description: Name(s) of the entities to toggle exampl: 'fan.living_room' set_direction: - description: Set the fan rotation direction - + description: Set the fan rotation. fields: entity_id: description: Name(s) of the entities to toggle @@ -64,8 +55,7 @@ set_direction: example: 'left' dyson_set_night_mode: - description: Set the fan in night mode - + description: Set the fan in night mode. fields: entity_id: description: Name(s) of the entities to enable/disable night mode diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py new file mode 100644 index 00000000000..3b0e0385f13 --- /dev/null +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -0,0 +1,332 @@ +""" +Support for Xiaomi Mi Air Purifier 2. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/fan.xiaomi_miio/ +""" +import asyncio +from functools import partial +import logging +import os + +import voluptuous as vol + +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.components.fan import (FanEntity, PLATFORM_SCHEMA, + SUPPORT_SET_SPEED, DOMAIN) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, + ATTR_ENTITY_ID, ) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Xiaomi Air Purifier' +PLATFORM = 'xiaomi_miio' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +REQUIREMENTS = ['python-miio==0.3.0'] + +ATTR_TEMPERATURE = 'temperature' +ATTR_HUMIDITY = 'humidity' +ATTR_AIR_QUALITY_INDEX = 'aqi' +ATTR_MODE = 'mode' +ATTR_FILTER_HOURS_USED = 'filter_hours_used' +ATTR_FILTER_LIFE = 'filter_life_remaining' +ATTR_FAVORITE_LEVEL = 'favorite_level' +ATTR_BUZZER = 'buzzer' +ATTR_CHILD_LOCK = 'child_lock' +ATTR_LED = 'led' +ATTR_LED_BRIGHTNESS = 'led_brightness' +ATTR_MOTOR_SPEED = 'motor_speed' + +ATTR_BRIGHTNESS = 'brightness' +ATTR_LEVEL = 'level' + +SUCCESS = ['ok'] + +SERVICE_SET_BUZZER_ON = 'xiaomi_miio_set_buzzer_on' +SERVICE_SET_BUZZER_OFF = 'xiaomi_miio_set_buzzer_off' +SERVICE_SET_LED_ON = 'xiaomi_miio_set_led_on' +SERVICE_SET_LED_OFF = 'xiaomi_miio_set_led_off' +SERVICE_SET_FAVORITE_LEVEL = 'xiaomi_miio_set_favorite_level' +SERVICE_SET_LED_BRIGHTNESS = 'xiaomi_miio_set_led_brightness' + +AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +SERVICE_SCHEMA_LED_BRIGHTNESS = AIRPURIFIER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_BRIGHTNESS): + vol.All(vol.Coerce(int), vol.Clamp(min=0, max=2)) +}) + +SERVICE_SCHEMA_FAVORITE_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_LEVEL): + vol.All(vol.Coerce(int), vol.Clamp(min=0, max=16)) +}) + +SERVICE_TO_METHOD = { + SERVICE_SET_BUZZER_ON: {'method': 'async_set_buzzer_on'}, + SERVICE_SET_BUZZER_OFF: {'method': 'async_set_buzzer_off'}, + SERVICE_SET_LED_ON: {'method': 'async_set_led_on'}, + SERVICE_SET_LED_OFF: {'method': 'async_set_led_off'}, + SERVICE_SET_FAVORITE_LEVEL: { + 'method': 'async_set_favorite_level', + 'schema': SERVICE_SCHEMA_FAVORITE_LEVEL}, + SERVICE_SET_LED_BRIGHTNESS: { + 'method': 'async_set_led_brightness', + 'schema': SERVICE_SCHEMA_LED_BRIGHTNESS}, +} + + +# pylint: disable=unused-argument +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the air purifier from config.""" + from miio import AirPurifier, DeviceException + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {} + + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + + try: + air_purifier = AirPurifier(host, token) + + xiaomi_air_purifier = XiaomiAirPurifier(name, air_purifier) + hass.data[PLATFORM][host] = xiaomi_air_purifier + except DeviceException: + raise PlatformNotReady + + async_add_devices([xiaomi_air_purifier], update_before_add=True) + + @asyncio.coroutine + def async_service_handler(service): + """Map services to methods on XiaomiAirPurifier.""" + method = SERVICE_TO_METHOD.get(service.service) + 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_air_purifiers = [air for air in hass.data[PLATFORM].values() + if air.entity_id in entity_ids] + else: + target_air_purifiers = hass.data[PLATFORM].values() + + update_tasks = [] + for air_purifier in target_air_purifiers: + yield from getattr(air_purifier, method['method'])(**params) + update_tasks.append(air_purifier.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'xiaomi_miio_services.yaml')) + + for air_purifier_service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[air_purifier_service].get( + 'schema', AIRPURIFIER_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, air_purifier_service, async_service_handler, + description=descriptions.get(air_purifier_service), schema=schema) + + +class XiaomiAirPurifier(FanEntity): + """Representation of a Xiaomi Air Purifier.""" + + def __init__(self, name, air_purifier): + """Initialize the air purifier.""" + self._name = name + + self._air_purifier = air_purifier + self._state = None + self._state_attrs = { + ATTR_AIR_QUALITY_INDEX: None, + ATTR_TEMPERATURE: None, + ATTR_HUMIDITY: None, + ATTR_MODE: None, + ATTR_FILTER_HOURS_USED: None, + ATTR_FILTER_LIFE: None, + ATTR_FAVORITE_LEVEL: None, + ATTR_BUZZER: None, + ATTR_CHILD_LOCK: None, + ATTR_LED: None, + ATTR_LED_BRIGHTNESS: None, + ATTR_MOTOR_SPEED: None + } + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_SET_SPEED + + @property + def should_poll(self): + """Poll the fan.""" + return True + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def available(self): + """Return true when state is known.""" + return self._state is not None + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + + @property + def is_on(self): + """Return true if fan is on.""" + return self._state + + @asyncio.coroutine + def _try_command(self, mask_error, func, *args, **kwargs): + """Call a air purifier command handling error messages.""" + from miio import DeviceException + try: + result = yield from self.hass.async_add_job( + partial(func, *args, **kwargs)) + + _LOGGER.debug("Response received from air purifier: %s", result) + + return result == SUCCESS + except DeviceException as exc: + _LOGGER.error(mask_error, exc) + return False + + @asyncio.coroutine + def async_turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + """Turn the fan on.""" + if speed: + # If operation mode was set the device must not be turned on. + yield from self.async_set_speed(speed) + return + + yield from self._try_command( + "Turning the air purifier on failed.", self._air_purifier.on) + + @asyncio.coroutine + def async_turn_off(self: ToggleEntity, **kwargs) -> None: + """Turn the fan off.""" + yield from self._try_command( + "Turning the air purifier off failed.", self._air_purifier.off) + + @asyncio.coroutine + def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + + try: + state = yield from self.hass.async_add_job( + self._air_purifier.status) + _LOGGER.debug("Got new state: %s", state) + + self._state = state.is_on + self._state_attrs = { + ATTR_TEMPERATURE: state.temperature, + ATTR_HUMIDITY: state.humidity, + ATTR_AIR_QUALITY_INDEX: state.aqi, + ATTR_MODE: state.mode.value, + ATTR_FILTER_HOURS_USED: state.filter_hours_used, + ATTR_FILTER_LIFE: state.filter_life_remaining, + ATTR_FAVORITE_LEVEL: state.favorite_level, + ATTR_BUZZER: state.buzzer, + ATTR_CHILD_LOCK: state.child_lock, + ATTR_LED: state.led, + ATTR_MOTOR_SPEED: state.motor_speed + } + + if state.led_brightness: + self._state_attrs[ + ATTR_LED_BRIGHTNESS] = state.led_brightness.value + + except DeviceException as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) + + @property + def speed_list(self: ToggleEntity) -> list: + """Get the list of available speeds.""" + from miio.airpurifier import OperationMode + return [mode.name for mode in OperationMode] + + @property + def speed(self): + """Return the current speed.""" + if self._state: + from miio.airpurifier import OperationMode + + return OperationMode(self._state_attrs[ATTR_MODE]).name + + return None + + @asyncio.coroutine + def async_set_speed(self: ToggleEntity, speed: str) -> None: + """Set the speed of the fan.""" + _LOGGER.debug("Setting the operation mode to: " + speed) + from miio.airpurifier import OperationMode + + yield from self._try_command( + "Setting operation mode of the air purifier failed.", + self._air_purifier.set_mode, OperationMode[speed]) + + @asyncio.coroutine + def async_set_buzzer_on(self): + """Turn the buzzer on.""" + yield from self._try_command( + "Turning the buzzer of air purifier on failed.", + self._air_purifier.set_buzzer, True) + + @asyncio.coroutine + def async_set_buzzer_off(self): + """Turn the buzzer on.""" + yield from self._try_command( + "Turning the buzzer of air purifier off failed.", + self._air_purifier.set_buzzer, False) + + @asyncio.coroutine + def async_set_led_on(self): + """Turn the led on.""" + yield from self._try_command( + "Turning the led of air purifier off failed.", + self._air_purifier.set_led, True) + + @asyncio.coroutine + def async_set_led_off(self): + """Turn the led off.""" + yield from self._try_command( + "Turning the led of air purifier off failed.", + self._air_purifier.set_led, False) + + @asyncio.coroutine + def async_set_led_brightness(self, brightness: int=2): + """Set the led brightness.""" + from miio.airpurifier import LedBrightness + + yield from self._try_command( + "Setting the led brightness of the air purifier failed.", + self._air_purifier.set_led_brightness, LedBrightness(brightness)) + + @asyncio.coroutine + def async_set_favorite_level(self, level: int=1): + """Set the favorite level.""" + yield from self._try_command( + "Setting the favorite level of the air purifier failed.", + self._air_purifier.set_favorite_level, level) diff --git a/homeassistant/components/fan/xiaomi_miio_services.yaml b/homeassistant/components/fan/xiaomi_miio_services.yaml new file mode 100644 index 00000000000..93f6318e60b --- /dev/null +++ b/homeassistant/components/fan/xiaomi_miio_services.yaml @@ -0,0 +1,56 @@ + +xiaomi_miio_set_buzzer_on: + description: Turn the buzzer on. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + +xiaomi_miio_set_buzzer_off: + description: Turn the buzzer off. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + +xiaomi_miio_set_led_on: + description: Turn the led on. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + +xiaomi_miio_set_led_off: + description: Turn the led off. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + +xiaomi_miio_set_favorite_level: + description: Set the favorite level. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + + level: + description: Level, between 0 and 16. + example: '1' + +xiaomi_miio_set_led_brightness: + description: Set the led brightness. + + fields: + entity_id: + description: Name of the air purifier entity. + example: 'fan.xiaomi_air_purifier' + + brightness: + description: Brightness (0 = Bright, 1 = Dim, 2 = Off) + example: '1' diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 941de4574cf..d354557fa0f 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,4 +1,9 @@ -"""Handle the frontend for Home Assistant.""" +""" +Handle the frontend for Home Assistant. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/frontend/ +""" import asyncio import hashlib import json @@ -7,17 +12,16 @@ import os from aiohttp import web import voluptuous as vol -import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.config_validation as cv +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.auth import is_trusted_ip from homeassistant.config import find_config_file, load_yaml_config_file from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -from homeassistant.components import api -from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.auth import is_trusted_ip -from homeassistant.components.http.const import KEY_DEVELOPMENT -from .version import FINGERPRINTS + +REQUIREMENTS = ['home-assistant-frontend==20171103.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api'] @@ -25,11 +29,16 @@ DEPENDENCIES = ['api', 'websocket_api'] URL_PANEL_COMPONENT = '/frontend/panels/{}.html' URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html' -STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static/') +POLYMER_PATH = os.path.join(os.path.dirname(__file__), + 'home-assistant-polymer/') +FINAL_PATH = os.path.join(POLYMER_PATH, 'final') + +CONF_THEMES = 'themes' +CONF_EXTRA_HTML_URL = 'extra_html_url' +CONF_FRONTEND_REPO = 'development_repo' -ATTR_THEMES = 'themes' -ATTR_EXTRA_HTML_URL = 'extra_html_url' DEFAULT_THEME_COLOR = '#03A9F4' + MANIFEST_JSON = { 'background_color': '#FFFFFF', 'description': 'Open-source home automation platform running on Python 3.', @@ -50,9 +59,9 @@ for size in (192, 384, 512, 1024): 'type': 'image/png' }) +DATA_FINALIZE_PANEL = 'frontend_finalize_panel' DATA_PANELS = 'frontend_panels' DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' -DATA_INDEX_VIEW = 'frontend_index_view' DATA_THEMES = 'frontend_themes' DATA_DEFAULT_THEME = 'frontend_default_theme' DEFAULT_THEME = 'default' @@ -60,15 +69,16 @@ DEFAULT_THEME = 'default' PRIMARY_COLOR = 'primary-color' # To keep track we don't register a component twice (gives a warning) -_REGISTERED_COMPONENTS = set() +# _REGISTERED_COMPONENTS = set() _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(ATTR_THEMES): vol.Schema({ + vol.Optional(CONF_FRONTEND_REPO): cv.isdir, + vol.Optional(CONF_THEMES): vol.Schema({ cv.string: {cv.string: cv.string} }), - vol.Optional(ATTR_EXTRA_HTML_URL): + vol.Optional(CONF_EXTRA_HTML_URL): vol.All(cv.ensure_list, [cv.string]), }), }, extra=vol.ALLOW_EXTRA) @@ -80,101 +90,175 @@ SERVICE_SET_THEME_SCHEMA = vol.Schema({ }) +class AbstractPanel: + """Abstract class for panels.""" + + # Name of the webcomponent + component_name = None + + # Icon to show in the sidebar (optional) + sidebar_icon = None + + # Title to show in the sidebar (optional) + sidebar_title = None + + # Url to the webcomponent + webcomponent_url = None + + # Url to show the panel in the frontend + frontend_url_path = None + + # Config to pass to the webcomponent + config = None + + @asyncio.coroutine + def async_register(self, hass): + """Register panel with HASS.""" + panels = hass.data.get(DATA_PANELS) + if panels is None: + panels = hass.data[DATA_PANELS] = {} + + if self.frontend_url_path in panels: + _LOGGER.warning("Overwriting component %s", self.frontend_url_path) + + if DATA_FINALIZE_PANEL in hass.data: + yield from hass.data[DATA_FINALIZE_PANEL](self) + + panels[self.frontend_url_path] = self + + @callback + def async_register_index_routes(self, router, index_view): + """Register routes for panel to be served by index view.""" + router.add_route( + 'get', '/{}'.format(self.frontend_url_path), index_view.get) + router.add_route( + 'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path), + index_view.get) + + def as_dict(self): + """Panel as dictionary.""" + return { + 'component_name': self.component_name, + 'icon': self.sidebar_icon, + 'title': self.sidebar_title, + 'url': self.webcomponent_url, + 'url_path': self.frontend_url_path, + 'config': self.config, + } + + +class BuiltInPanel(AbstractPanel): + """Panel that is part of hass_frontend.""" + + def __init__(self, component_name, sidebar_title, sidebar_icon, + frontend_url_path, config): + """Initialize a built-in panel.""" + self.component_name = component_name + self.sidebar_title = sidebar_title + self.sidebar_icon = sidebar_icon + self.frontend_url_path = frontend_url_path or component_name + self.config = config + + @asyncio.coroutine + def async_finalize(self, hass, frontend_repository_path): + """Finalize this panel for usage. + + If frontend_repository_path is set, will be prepended to path of + built-in components. + """ + panel_path = 'panels/ha-panel-{}.html'.format(self.component_name) + + if frontend_repository_path is None: + import hass_frontend + + self.webcomponent_url = \ + '/static/panels/ha-panel-{}-{}.html'.format( + self.component_name, + hass_frontend.FINGERPRINTS[panel_path]) + + else: + # Dev mode + self.webcomponent_url = \ + '/home-assistant-polymer/panels/{}/ha-panel-{}.html'.format( + self.component_name, self.component_name) + + +class ExternalPanel(AbstractPanel): + """Panel that is added by a custom component.""" + + REGISTERED_COMPONENTS = set() + + def __init__(self, component_name, path, md5, sidebar_title, sidebar_icon, + frontend_url_path, config): + """Initialize an external panel.""" + self.component_name = component_name + self.path = path + self.md5 = md5 + self.sidebar_title = sidebar_title + self.sidebar_icon = sidebar_icon + self.frontend_url_path = frontend_url_path or component_name + self.config = config + + @asyncio.coroutine + def async_finalize(self, hass, frontend_repository_path): + """Finalize this panel for usage. + + frontend_repository_path is set, will be prepended to path of built-in + components. + """ + try: + if self.md5 is None: + yield from hass.async_add_job(_fingerprint, self.path) + except OSError: + _LOGGER.error('Cannot find or access %s at %s', + self.component_name, self.path) + hass.data[DATA_PANELS].pop(self.frontend_url_path) + + self.webcomponent_url = \ + URL_PANEL_COMPONENT_FP.format(self.component_name, self.md5) + + if self.component_name not in self.REGISTERED_COMPONENTS: + hass.http.register_static_path( + self.webcomponent_url, self.path, + # if path is None, we're in prod mode, so cache static assets + frontend_repository_path is None) + self.REGISTERED_COMPONENTS.add(self.component_name) + + @bind_hass -def register_built_in_panel(hass, component_name, sidebar_title=None, - sidebar_icon=None, url_path=None, config=None): +@asyncio.coroutine +def async_register_built_in_panel(hass, component_name, sidebar_title=None, + sidebar_icon=None, frontend_url_path=None, + config=None): """Register a built-in panel.""" - nondev_path = 'panels/ha-panel-{}.html'.format(component_name) - - if hass.http.development: - url = ('/static/home-assistant-polymer/panels/' - '{0}/ha-panel-{0}.html'.format(component_name)) - path = os.path.join( - STATIC_PATH, 'home-assistant-polymer/panels/', - '{0}/ha-panel-{0}.html'.format(component_name)) - else: - url = None # use default url generate mechanism - path = os.path.join(STATIC_PATH, nondev_path) - - # Fingerprint doesn't exist when adding new built-in panel - register_panel(hass, component_name, path, - FINGERPRINTS.get(nondev_path, 'dev'), sidebar_title, - sidebar_icon, url_path, url, config) + panel = BuiltInPanel(component_name, sidebar_title, sidebar_icon, + frontend_url_path, config) + yield from panel.async_register(hass) @bind_hass -def register_panel(hass, component_name, path, md5=None, sidebar_title=None, - sidebar_icon=None, url_path=None, url=None, config=None): +@asyncio.coroutine +def async_register_panel(hass, component_name, path, md5=None, + sidebar_title=None, sidebar_icon=None, + frontend_url_path=None, config=None): """Register a panel for the frontend. component_name: name of the web component path: path to the HTML of the web component (required unless url is provided) - md5: the md5 hash of the web component (for versioning, optional) + md5: the md5 hash of the web component (for versioning in url, optional) sidebar_title: title to show in the sidebar (optional) sidebar_icon: icon to show next to title in sidebar (optional) url_path: name to use in the url (defaults to component_name) - url: for the web component (optional) config: config to be passed into the web component """ - panels = hass.data.get(DATA_PANELS) - if panels is None: - panels = hass.data[DATA_PANELS] = {} - - if url_path is None: - url_path = component_name - - if url_path in panels: - _LOGGER.warning("Overwriting component %s", url_path) - - if url is None: - if not os.path.isfile(path): - _LOGGER.error( - "Panel %s component does not exist: %s", component_name, path) - return - - if md5 is None: - with open(path) as fil: - md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() - - data = { - 'url_path': url_path, - 'component_name': component_name, - } - - if sidebar_title: - data['title'] = sidebar_title - if sidebar_icon: - data['icon'] = sidebar_icon - if config is not None: - data['config'] = config - - if url is not None: - data['url'] = url - else: - url = URL_PANEL_COMPONENT.format(component_name) - - if url not in _REGISTERED_COMPONENTS: - hass.http.register_static_path(url, path) - _REGISTERED_COMPONENTS.add(url) - - fprinted_url = URL_PANEL_COMPONENT_FP.format(component_name, md5) - data['url'] = fprinted_url - - panels[url_path] = data - - # Register index view for this route if IndexView already loaded - # Otherwise it will be done during setup. - index_view = hass.data.get(DATA_INDEX_VIEW) - - if index_view: - hass.http.app.router.add_route( - 'get', '/{}'.format(url_path), index_view.get) - hass.http.app.router.add_route( - 'get', '/{}/{{extra:.+}}'.format(url_path), index_view.get) + panel = ExternalPanel(component_name, path, md5, sidebar_title, + sidebar_icon, frontend_url_path, config) + yield from panel.async_register(hass) @bind_hass +@callback def add_extra_html_url(hass, url): """Register extra html url to load.""" url_set = hass.data.get(DATA_EXTRA_HTML_URL) @@ -188,57 +272,74 @@ def add_manifest_json_key(key, val): MANIFEST_JSON[key] = val -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Set up the serving of the frontend.""" - hass.http.register_view(BootstrapView) hass.http.register_view(ManifestJSONView) - if hass.http.development: - sw_path = "home-assistant-polymer/build/service_worker.js" - else: - sw_path = "service_worker.js" + conf = config.get(DOMAIN, {}) - hass.http.register_static_path("/service_worker.js", - os.path.join(STATIC_PATH, sw_path), False) - hass.http.register_static_path("/robots.txt", - os.path.join(STATIC_PATH, "robots.txt")) - hass.http.register_static_path("/static", STATIC_PATH) + repo_path = conf.get(CONF_FRONTEND_REPO) + is_dev = repo_path is not None + + if is_dev: + hass.http.register_static_path( + "/home-assistant-polymer", repo_path, False) + hass.http.register_static_path( + "/static/translations", + os.path.join(repo_path, "build/translations"), False) + sw_path = os.path.join(repo_path, "build/service_worker.js") + static_path = os.path.join(repo_path, 'hass_frontend') + else: + import hass_frontend + frontend_path = hass_frontend.where() + sw_path = os.path.join(frontend_path, "service_worker.js") + static_path = frontend_path + + hass.http.register_static_path("/service_worker.js", sw_path, False) + hass.http.register_static_path( + "/robots.txt", os.path.join(static_path, "robots.txt"), not is_dev) + hass.http.register_static_path("/static", static_path, not is_dev) local = hass.config.path('www') if os.path.isdir(local): - hass.http.register_static_path("/local", local) + hass.http.register_static_path("/local", local, not is_dev) - index_view = hass.data[DATA_INDEX_VIEW] = IndexView() + index_view = IndexView(is_dev) hass.http.register_view(index_view) - # Components have registered panels before frontend got setup. - # Now register their urls. - if DATA_PANELS in hass.data: - for url_path in hass.data[DATA_PANELS]: - hass.http.app.router.add_route( - 'get', '/{}'.format(url_path), index_view.get) - hass.http.app.router.add_route( - 'get', '/{}/{{extra:.+}}'.format(url_path), index_view.get) - else: - hass.data[DATA_PANELS] = {} + @asyncio.coroutine + def finalize_panel(panel): + """Finalize setup of a panel.""" + yield from panel.async_finalize(hass, repo_path) + panel.async_register_index_routes(hass.http.app.router, index_view) + + yield from asyncio.wait([ + async_register_built_in_panel(hass, panel) + for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', + 'dev-template', 'dev-mqtt', 'kiosk')], loop=hass.loop) + + hass.data[DATA_FINALIZE_PANEL] = finalize_panel + + # Finalize registration of panels that registered before frontend was setup + # This includes the built-in panels from line above. + yield from asyncio.wait( + [finalize_panel(panel) for panel in hass.data[DATA_PANELS].values()], + loop=hass.loop) if DATA_EXTRA_HTML_URL not in hass.data: hass.data[DATA_EXTRA_HTML_URL] = set() - for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt', 'kiosk'): - register_built_in_panel(hass, panel) - - themes = config.get(DOMAIN, {}).get(ATTR_THEMES) - setup_themes(hass, themes) - - for url in config.get(DOMAIN, {}).get(ATTR_EXTRA_HTML_URL, []): + for url in conf.get(CONF_EXTRA_HTML_URL, []): add_extra_html_url(hass, url) + yield from async_setup_themes(hass, conf.get(CONF_THEMES)) + return True -def setup_themes(hass, themes): +@asyncio.coroutine +def async_setup_themes(hass, themes): """Set up themes data and services.""" hass.http.register_view(ThemesView) hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME @@ -278,40 +379,22 @@ def setup_themes(hass, themes): def reload_themes(_): """Reload themes.""" path = find_config_file(hass.config.config_dir) - new_themes = load_yaml_config_file(path)[DOMAIN].get(ATTR_THEMES, {}) + new_themes = load_yaml_config_file(path)[DOMAIN].get(CONF_THEMES, {}) hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME update_theme_and_fire_event() - descriptions = load_yaml_config_file( + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) - hass.services.register(DOMAIN, SERVICE_SET_THEME, - set_theme, - descriptions[SERVICE_SET_THEME], - SERVICE_SET_THEME_SCHEMA) - hass.services.register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes, - descriptions[SERVICE_RELOAD_THEMES]) - -class BootstrapView(HomeAssistantView): - """View to bootstrap frontend with all needed data.""" - - url = '/api/bootstrap' - name = 'api:bootstrap' - - @callback - def get(self, request): - """Return all data needed to bootstrap Home Assistant.""" - hass = request.app['hass'] - - return self.json({ - 'config': hass.config.as_dict(), - 'states': hass.states.async_all(), - 'events': api.async_events_json(hass), - 'services': api.async_services_json(hass), - 'panels': hass.data[DATA_PANELS], - }) + hass.services.async_register(DOMAIN, SERVICE_SET_THEME, + set_theme, + descriptions[SERVICE_SET_THEME], + SERVICE_SET_THEME_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes, + descriptions[SERVICE_RELOAD_THEMES]) class IndexView(HomeAssistantView): @@ -322,10 +405,11 @@ class IndexView(HomeAssistantView): requires_auth = False extra_urls = ['/states', '/states/{extra}'] - def __init__(self): + def __init__(self, use_repo): """Initialize the frontend view.""" from jinja2 import FileSystemLoader, Environment + self.use_repo = use_repo self.templates = Environment( autoescape=True, loader=FileSystemLoader( @@ -338,18 +422,23 @@ class IndexView(HomeAssistantView): """Serve the index view.""" hass = request.app['hass'] - if request.app[KEY_DEVELOPMENT]: - core_url = '/static/home-assistant-polymer/build/core.js' + if self.use_repo: + core_url = '/home-assistant-polymer/build/core.js' compatibility_url = \ - '/static/home-assistant-polymer/build/compatibility.js' - ui_url = '/static/home-assistant-polymer/src/home-assistant.html' + '/home-assistant-polymer/build/compatibility.js' + ui_url = '/home-assistant-polymer/src/home-assistant.html' + icons_fp = '' + icons_url = '/static/mdi.html' else: + import hass_frontend core_url = '/static/core-{}.js'.format( - FINGERPRINTS['core.js']) + hass_frontend.FINGERPRINTS['core.js']) compatibility_url = '/static/compatibility-{}.js'.format( - FINGERPRINTS['compatibility.js']) + hass_frontend.FINGERPRINTS['compatibility.js']) ui_url = '/static/frontend-{}.html'.format( - FINGERPRINTS['frontend.html']) + hass_frontend.FINGERPRINTS['frontend.html']) + icons_fp = '-{}'.format(hass_frontend.FINGERPRINTS['mdi.html']) + icons_url = '/static/mdi{}.html'.format(icons_fp) if request.path == '/': panel = 'states' @@ -359,17 +448,13 @@ class IndexView(HomeAssistantView): if panel == 'states': panel_url = '' else: - panel_url = hass.data[DATA_PANELS][panel]['url'] + panel_url = hass.data[DATA_PANELS][panel].webcomponent_url no_auth = 'true' - if hass.config.api.api_password: - # require password if set + if hass.config.api.api_password and not is_trusted_ip(request): + # do not try to auto connect on load no_auth = 'false' - if is_trusted_ip(request): - # bypass for trusted networks - no_auth = 'true' - icons_url = '/static/mdi-{}.html'.format(FINGERPRINTS['mdi.html']) template = yield from hass.async_add_job( self.templates.get_template, 'index.html') @@ -379,9 +464,9 @@ class IndexView(HomeAssistantView): resp = template.render( core_url=core_url, ui_url=ui_url, compatibility_url=compatibility_url, no_auth=no_auth, - icons_url=icons_url, icons=FINGERPRINTS['mdi.html'], + icons_url=icons_url, icons=icons_fp, panel_url=panel_url, panels=hass.data[DATA_PANELS], - dev_mode=request.app[KEY_DEVELOPMENT], + dev_mode=self.use_repo, theme_color=MANIFEST_JSON['theme_color'], extra_urls=hass.data[DATA_EXTRA_HTML_URL]) @@ -418,3 +503,9 @@ class ThemesView(HomeAssistantView): 'themes': hass.data[DATA_THEMES], 'default_theme': hass.data[DATA_DEFAULT_THEME], }) + + +def _fingerprint(path): + """Fingerprint a file.""" + with open(path) as fil: + return hashlib.md5(fil.read().encode('utf-8')).hexdigest() diff --git a/homeassistant/components/frontend/services.yaml b/homeassistant/components/frontend/services.yaml index 7d56cbb7693..dc1fb40be48 100644 --- a/homeassistant/components/frontend/services.yaml +++ b/homeassistant/components/frontend/services.yaml @@ -8,4 +8,4 @@ set_theme: example: 'light' reload_themes: - description: Reload themes from yaml config. + description: Reload themes from yaml configuration. diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 70e7e777510..c941fbc15ae 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -8,11 +8,13 @@ - - - {% for panel in panels.values() -%} - - {% endfor -%} + + {% if not dev_mode %} + + {% for panel in panels.values() -%} + + {% endfor -%} + {% endif %} @@ -36,7 +38,7 @@ display: block; content: ""; height: 48px; - background-color: #03A9F4; + background-color: {{ theme_color }}; } #ha-init-skeleton .message { @@ -50,7 +52,7 @@ } #ha-init-skeleton a { - color: #03A9F4; + color: {{ theme_color }}; text-decoration: none; font-weight: bold; } diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py deleted file mode 100644 index 052bd7e86fe..00000000000 --- a/homeassistant/components/frontend/version.py +++ /dev/null @@ -1,24 +0,0 @@ -"""DO NOT MODIFY. Auto-generated by script/fingerprint_frontend.""" - -FINGERPRINTS = { - "compatibility.js": "1686167ff210e001f063f5c606b2e74b", - "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "2de1bde3b4a6c6c47dd95504fc098906", - "mdi.html": "2e848b4da029bf73d426d5ba058a088d", - "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "52e2e1d477bfd6dc3708d65b8337f0af", - "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", - "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", - "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", - "panels/ha-panel-dev-service.html": "422b2c181ee0713fa31d45a64e605baf", - "panels/ha-panel-dev-state.html": "7948d3dba058f31517d880df8ed0e857", - "panels/ha-panel-dev-template.html": "928e7b81b9c113b70edc9f4a1d051827", - "panels/ha-panel-hassio.html": "b46e7619f3c355f872d5370741d89f6a", - "panels/ha-panel-history.html": "fe2daac10a14f51fa3eb7d23978df1f7", - "panels/ha-panel-iframe.html": "56930204d6e067a3d600cf030f4b34c8", - "panels/ha-panel-kiosk.html": "b40aa5cb52dd7675bea744afcf9eebf8", - "panels/ha-panel-logbook.html": "771afdcf48dc7e308b0282417d2e02d8", - "panels/ha-panel-mailbox.html": "a8cca44ca36553e91565e3c894ea6323", - "panels/ha-panel-map.html": "565db019147162080c21af962afc097f", - "panels/ha-panel-shopping-list.html": "d8cfd0ecdb3aa6214c0f6908c34c7141" -} diff --git a/homeassistant/components/frontend/www_static/compatibility.js b/homeassistant/components/frontend/www_static/compatibility.js deleted file mode 100644 index 566f3310d9a..00000000000 --- a/homeassistant/components/frontend/www_static/compatibility.js +++ /dev/null @@ -1 +0,0 @@ -!function(){"use strict";function e(e,t){if(void 0===e||null===e)throw new TypeError("Cannot convert first argument to object");for(var r=Object(e),n=1;n{'use strict';if(!window.customElements)return;const a=window.HTMLElement,b=window.customElements.define,c=window.customElements.get,d=new Map,e=new Map;let f=!1,g=!1;window.HTMLElement=function(){if(!f){const a=d.get(this.constructor),b=c.call(window.customElements,a);g=!0;const e=new b;return e}f=!1;},window.HTMLElement.prototype=a.prototype;Object.defineProperty(window,'customElements',{value:window.customElements,configurable:!0,writable:!0}),Object.defineProperty(window.customElements,'define',{value:(c,h)=>{const i=h.prototype,j=class extends a{constructor(){super(),Object.setPrototypeOf(this,i),g||(f=!0,h.call(this)),g=!1;}},k=j.prototype;j.observedAttributes=h.observedAttributes,k.connectedCallback=i.connectedCallback,k.disconnectedCallback=i.disconnectedCallback,k.attributeChangedCallback=i.attributeChangedCallback,k.adoptedCallback=i.adoptedCallback,d.set(h,c),e.set(c,h),b.call(window.customElements,c,j);},configurable:!0,writable:!0}),Object.defineProperty(window.customElements,'get',{value:(a)=>e.get(a),configurable:!0,writable:!0});})(); - -/** -@license -Copyright (c) 2017 The Polymer Project Authors. All rights reserved. -This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt -The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt -The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt -Code distributed by Google as part of the polymer project is also -subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt -*/ - -}()); diff --git a/homeassistant/components/frontend/www_static/custom-elements-es5-adapter.js.gz b/homeassistant/components/frontend/www_static/custom-elements-es5-adapter.js.gz deleted file mode 100644 index 42759b325ad..00000000000 Binary files a/homeassistant/components/frontend/www_static/custom-elements-es5-adapter.js.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/COPYRIGHT.txt b/homeassistant/components/frontend/www_static/fonts/roboto/COPYRIGHT.txt deleted file mode 100644 index a7ef69930cb..00000000000 --- a/homeassistant/components/frontend/www_static/fonts/roboto/COPYRIGHT.txt +++ /dev/null @@ -1 +0,0 @@ -Copyright 2011 Google Inc. All Rights Reserved. \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/DESCRIPTION.en_us.html b/homeassistant/components/frontend/www_static/fonts/roboto/DESCRIPTION.en_us.html deleted file mode 100644 index 3a6834fd4c4..00000000000 --- a/homeassistant/components/frontend/www_static/fonts/roboto/DESCRIPTION.en_us.html +++ /dev/null @@ -1,17 +0,0 @@ -

Roboto has a dual nature. It has a mechanical skeleton and the forms are -largely geometric. At the same time, the font features friendly and open -curves. While some grotesks distort their letterforms to force a rigid rhythm, -Roboto doesn’t compromise, allowing letters to be settled into their natural -width. This makes for a more natural reading rhythm more commonly found in -humanist and serif types.

- -

This is the normal family, which can be used alongside the -Roboto Condensed family and the -Roboto Slab family.

- -

-Updated January 14 2015: -Christian Robertson and the Material Design team unveiled the latest version of Roboto at Google I/O last year, and it is now available from Google Fonts. -Existing websites using Roboto via Google Fonts will start using the latest version automatically. -If you have installed the fonts on your computer, please download them again and re-install. -

\ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/LICENSE.txt b/homeassistant/components/frontend/www_static/fonts/roboto/LICENSE.txt deleted file mode 100644 index d6456956733..00000000000 --- a/homeassistant/components/frontend/www_static/fonts/roboto/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/METADATA.json b/homeassistant/components/frontend/www_static/fonts/roboto/METADATA.json deleted file mode 100644 index 061bc67688b..00000000000 --- a/homeassistant/components/frontend/www_static/fonts/roboto/METADATA.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "name": "Roboto", - "designer": "Christian Robertson", - "license": "Apache2", - "visibility": "External", - "category": "Sans Serif", - "size": 86523, - "fonts": [ - { - "name": "Roboto", - "style": "normal", - "weight": 100, - "filename": "Roboto-Thin.ttf", - "postScriptName": "Roboto-Thin", - "fullName": "Roboto Thin", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 100, - "filename": "Roboto-ThinItalic.ttf", - "postScriptName": "Roboto-ThinItalic", - "fullName": "Roboto Thin Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "normal", - "weight": 300, - "filename": "Roboto-Light.ttf", - "postScriptName": "Roboto-Light", - "fullName": "Roboto Light", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 300, - "filename": "Roboto-LightItalic.ttf", - "postScriptName": "Roboto-LightItalic", - "fullName": "Roboto Light Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "normal", - "weight": 400, - "filename": "Roboto-Regular.ttf", - "postScriptName": "Roboto-Regular", - "fullName": "Roboto", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 400, - "filename": "Roboto-Italic.ttf", - "postScriptName": "Roboto-Italic", - "fullName": "Roboto Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "normal", - "weight": 500, - "filename": "Roboto-Medium.ttf", - "postScriptName": "Roboto-Medium", - "fullName": "Roboto Medium", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 500, - "filename": "Roboto-MediumItalic.ttf", - "postScriptName": "Roboto-MediumItalic", - "fullName": "Roboto Medium Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "normal", - "weight": 700, - "filename": "Roboto-Bold.ttf", - "postScriptName": "Roboto-Bold", - "fullName": "Roboto Bold", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 700, - "filename": "Roboto-BoldItalic.ttf", - "postScriptName": "Roboto-BoldItalic", - "fullName": "Roboto Bold Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "normal", - "weight": 900, - "filename": "Roboto-Black.ttf", - "postScriptName": "Roboto-Black", - "fullName": "Roboto Black", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 900, - "filename": "Roboto-BlackItalic.ttf", - "postScriptName": "Roboto-BlackItalic", - "fullName": "Roboto Black Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - } - ], - "subsets": [ - "cyrillic", - "cyrillic-ext", - "greek", - "greek-ext", - "latin", - "latin-ext", - "menu", - "vietnamese" - ], - "dateAdded": "2013-01-09" -} diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf deleted file mode 100644 index fbde625d403..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf.gz deleted file mode 100644 index ffbf4a965e3..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf deleted file mode 100644 index 60f7782a2e4..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf.gz deleted file mode 100644 index 38c32845ad9..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf deleted file mode 100644 index a355c27cde0..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf.gz deleted file mode 100644 index 9d9d303b98d..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf deleted file mode 100644 index 3c9a7a37361..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf.gz deleted file mode 100644 index 681577fb32b..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf deleted file mode 100644 index ff6046d5bfa..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf.gz deleted file mode 100644 index 5b29473a7d2..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf deleted file mode 100644 index 94c6bcc67e0..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf.gz deleted file mode 100644 index 22d96d0f3f5..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf deleted file mode 100644 index 04cc0023020..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf.gz deleted file mode 100644 index 03952b19923..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf deleted file mode 100644 index 39c63d74617..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf.gz deleted file mode 100644 index 2c62e686f6a..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf deleted file mode 100644 index dc743f0a66c..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf.gz deleted file mode 100644 index 0d0131bf8ac..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf deleted file mode 100644 index 8c082c8de09..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf.gz deleted file mode 100644 index ff39470ca87..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf deleted file mode 100644 index d69555029c3..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf.gz deleted file mode 100644 index 80cca9828ed..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf deleted file mode 100644 index 07172ff666a..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf.gz deleted file mode 100644 index 3935ec50be8..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/DESCRIPTION.en_us.html b/homeassistant/components/frontend/www_static/fonts/robotomono/DESCRIPTION.en_us.html deleted file mode 100644 index eb6ba3a2e3c..00000000000 --- a/homeassistant/components/frontend/www_static/fonts/robotomono/DESCRIPTION.en_us.html +++ /dev/null @@ -1,17 +0,0 @@ -

-Roboto Mono is a monospaced addition to the Roboto type family. -Like the other members of the Roboto family, the fonts are optimized for readability on screens across a wide variety of devices and reading environments. -While the monospaced version is related to its variable width cousin, it doesn’t hesitate to change forms to better fit the constraints of a monospaced environment. -For example, narrow glyphs like ‘I’, ‘l’ and ‘i’ have added serifs for more even texture while wider glyphs are adjusted for weight. -Curved caps like ‘C’ and ‘O’ take on the straighter sides from Roboto Condensed. -

- -

-Special consideration is given to glyphs important for reading and writing software source code. -Letters with similar shapes are easy to tell apart. -Digit ‘1’, lowercase ‘l’ and capital ‘I’ are easily differentiated as are zero and the letter ‘O’. -Punctuation important for code has also been considered. -For example, the curly braces ‘{ }’ have exaggerated points to clearly differentiate them from parenthesis ‘( )’ and braces ‘[ ]’. -Periods and commas are also exaggerated to identify them more quickly. -The scale and weight of symbols commonly used as operators have also been optimized. -

diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/LICENSE.txt b/homeassistant/components/frontend/www_static/fonts/robotomono/LICENSE.txt deleted file mode 100644 index d6456956733..00000000000 --- a/homeassistant/components/frontend/www_static/fonts/robotomono/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/METADATA.json b/homeassistant/components/frontend/www_static/fonts/robotomono/METADATA.json deleted file mode 100644 index a2a212bfa8f..00000000000 --- a/homeassistant/components/frontend/www_static/fonts/robotomono/METADATA.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "name": "Roboto Mono", - "designer": "Christian Robertson", - "license": "Apache2", - "visibility": "External", - "category": "Monospace", - "size": 51290, - "fonts": [ - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Thin", - "fullName": "Roboto Mono Thin", - "style": "normal", - "weight": 100, - "filename": "RobotoMono-Thin.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-ThinItalic", - "fullName": "Roboto Mono Thin Italic", - "style": "italic", - "weight": 100, - "filename": "RobotoMono-ThinItalic.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Light", - "fullName": "Roboto Mono Light", - "style": "normal", - "weight": 300, - "filename": "RobotoMono-Light.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-LightItalic", - "fullName": "Roboto Mono Light Italic", - "style": "italic", - "weight": 300, - "filename": "RobotoMono-LightItalic.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Regular", - "fullName": "Roboto Mono", - "style": "normal", - "weight": 400, - "filename": "RobotoMono-Regular.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Italic", - "fullName": "Roboto Mono Italic", - "style": "italic", - "weight": 400, - "filename": "RobotoMono-Italic.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Medium", - "fullName": "Roboto Mono Medium", - "style": "normal", - "weight": 500, - "filename": "RobotoMono-Medium.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-MediumItalic", - "fullName": "Roboto Mono Medium Italic", - "style": "italic", - "weight": 500, - "filename": "RobotoMono-MediumItalic.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Bold", - "fullName": "Roboto Mono Bold", - "style": "normal", - "weight": 700, - "filename": "RobotoMono-Bold.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-BoldItalic", - "fullName": "Roboto Mono Bold Italic", - "style": "italic", - "weight": 700, - "filename": "RobotoMono-BoldItalic.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - } - ], - "subsets": [ - "cyrillic", - "cyrillic-ext", - "greek", - "greek-ext", - "latin", - "latin-ext", - "menu", - "vietnamese" - ], - "dateAdded": "2015-05-13" -} diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf deleted file mode 100644 index c6a81a570c2..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf.gz deleted file mode 100644 index 11e5df42284..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf deleted file mode 100644 index b2261d6649a..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf.gz deleted file mode 100644 index 7ce6b8d8f5f..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf deleted file mode 100644 index 6e4001e1967..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf.gz deleted file mode 100644 index 42e30d27831..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf deleted file mode 100644 index 5ca4889ebac..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf.gz deleted file mode 100644 index dd6ed496c7d..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf deleted file mode 100644 index db7c368471c..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf.gz deleted file mode 100644 index 452274f2a89..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf deleted file mode 100644 index 0bcdc740c66..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf.gz deleted file mode 100644 index d7cccfe5dda..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf deleted file mode 100644 index b4f5e20e3d9..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf.gz deleted file mode 100644 index 934c7252d33..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf deleted file mode 100644 index 495a82ce92e..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf.gz deleted file mode 100644 index cb043e8fef6..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf deleted file mode 100644 index 1b5085eed8c..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf.gz deleted file mode 100644 index 398aac15837..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf deleted file mode 100644 index dfa1d139ba8..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf.gz deleted file mode 100644 index 1b60ee9dcbb..00000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html deleted file mode 100644 index c873d66777e..00000000000 --- a/homeassistant/components/frontend/www_static/frontend.html +++ /dev/null @@ -1,168 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz deleted file mode 100644 index 6da11b7083d..00000000000 Binary files a/homeassistant/components/frontend/www_static/frontend.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer deleted file mode 160000 index b0791abb9a6..00000000000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b0791abb9a61216476cb3a637c410cdddef7e91c diff --git a/homeassistant/components/frontend/www_static/icons/favicon-1024x1024.png b/homeassistant/components/frontend/www_static/icons/favicon-1024x1024.png deleted file mode 100644 index 4bcc7924726..00000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon-1024x1024.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/favicon-192x192.png b/homeassistant/components/frontend/www_static/icons/favicon-192x192.png deleted file mode 100644 index 2959efdf89d..00000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon-192x192.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/favicon-384x384.png b/homeassistant/components/frontend/www_static/icons/favicon-384x384.png deleted file mode 100644 index 51f67770790..00000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon-384x384.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/favicon-512x512.png b/homeassistant/components/frontend/www_static/icons/favicon-512x512.png deleted file mode 100644 index 28239a05ad5..00000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon-512x512.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/favicon-apple-180x180.png b/homeassistant/components/frontend/www_static/icons/favicon-apple-180x180.png deleted file mode 100644 index 20117d00f22..00000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon-apple-180x180.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/favicon.ico b/homeassistant/components/frontend/www_static/icons/favicon.ico deleted file mode 100644 index 6d12158c18b..00000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon.ico and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/home-assistant-icon.svg b/homeassistant/components/frontend/www_static/icons/home-assistant-icon.svg deleted file mode 100644 index 1ff4c190f59..00000000000 --- a/homeassistant/components/frontend/www_static/icons/home-assistant-icon.svg +++ /dev/null @@ -1,2814 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-150x150.png b/homeassistant/components/frontend/www_static/icons/tile-win-150x150.png deleted file mode 100644 index 20039166df6..00000000000 Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-150x150.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-310x150.png b/homeassistant/components/frontend/www_static/icons/tile-win-310x150.png deleted file mode 100644 index 6320cb6b210..00000000000 Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-310x150.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-310x310.png b/homeassistant/components/frontend/www_static/icons/tile-win-310x310.png deleted file mode 100644 index 33bb1223c75..00000000000 Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-310x310.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-70x70.png b/homeassistant/components/frontend/www_static/icons/tile-win-70x70.png deleted file mode 100644 index 9adf95d56d5..00000000000 Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-70x70.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/card_media_player_bg.png b/homeassistant/components/frontend/www_static/images/card_media_player_bg.png deleted file mode 100644 index 6c97dd2f511..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/card_media_player_bg.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png b/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png deleted file mode 100644 index e62a4165c9b..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_fitbit_app.png b/homeassistant/components/frontend/www_static/images/config_fitbit_app.png deleted file mode 100644 index 271a0c6dd47..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_fitbit_app.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_icloud.png b/homeassistant/components/frontend/www_static/images/config_icloud.png deleted file mode 100644 index 2058986018b..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_icloud.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_insteon.png b/homeassistant/components/frontend/www_static/images/config_insteon.png deleted file mode 100644 index 0039cf3d160..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_insteon.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg b/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg deleted file mode 100644 index f10d258bf34..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_webos.png b/homeassistant/components/frontend/www_static/images/config_webos.png deleted file mode 100644 index 757aec76270..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_webos.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_wink.png b/homeassistant/components/frontend/www_static/images/config_wink.png deleted file mode 100644 index 6b91f8cb58e..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_wink.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-cloudy.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-cloudy.svg deleted file mode 100644 index a0c80c53611..00000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-cloudy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-fog.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-fog.svg deleted file mode 100644 index 42571dfb738..00000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-fog.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-hail.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-hail.svg deleted file mode 100644 index 7934e54f7ae..00000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-hail.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-night.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-night.svg deleted file mode 100644 index d880912be93..00000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-night.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-partlycloudy.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-partlycloudy.svg deleted file mode 100644 index af93dfa0b2a..00000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-partlycloudy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-pouring.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-pouring.svg deleted file mode 100644 index bf20e9bc0c9..00000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-pouring.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-rainy.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-rainy.svg deleted file mode 100644 index 27ae4d033ff..00000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-rainy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-snowy.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-snowy.svg deleted file mode 100644 index 9c56c2bb469..00000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-snowy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-sunny.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-sunny.svg deleted file mode 100644 index 8f9733041a1..00000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-sunny.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-windy.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-windy.svg deleted file mode 100644 index de0b444fd01..00000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-windy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png b/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png deleted file mode 100644 index a2cf7f9efef..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/layers.png b/homeassistant/components/frontend/www_static/images/leaflet/layers.png deleted file mode 100644 index bca0a0e4296..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/leaflet/layers.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/leaflet.css b/homeassistant/components/frontend/www_static/images/leaflet/leaflet.css deleted file mode 100644 index a3ea996ead2..00000000000 --- a/homeassistant/components/frontend/www_static/images/leaflet/leaflet.css +++ /dev/null @@ -1,631 +0,0 @@ -/* required styles */ - -.leaflet-pane, -.leaflet-tile, -.leaflet-marker-icon, -.leaflet-marker-shadow, -.leaflet-tile-container, -.leaflet-pane > svg, -.leaflet-pane > canvas, -.leaflet-zoom-box, -.leaflet-image-layer, -.leaflet-layer { - position: absolute; - left: 0; - top: 0; - } -.leaflet-container { - overflow: hidden; - } -.leaflet-tile, -.leaflet-marker-icon, -.leaflet-marker-shadow { - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - -webkit-user-drag: none; - } -/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ -.leaflet-safari .leaflet-tile { - image-rendering: -webkit-optimize-contrast; - } -/* hack that prevents hw layers "stretching" when loading new tiles */ -.leaflet-safari .leaflet-tile-container { - width: 1600px; - height: 1600px; - -webkit-transform-origin: 0 0; - } -.leaflet-marker-icon, -.leaflet-marker-shadow { - display: block; - } -/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ -/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ -.leaflet-container .leaflet-overlay-pane svg, -.leaflet-container .leaflet-marker-pane img, -.leaflet-container .leaflet-shadow-pane img, -.leaflet-container .leaflet-tile-pane img, -.leaflet-container img.leaflet-image-layer { - max-width: none !important; - } - -.leaflet-container.leaflet-touch-zoom { - -ms-touch-action: pan-x pan-y; - touch-action: pan-x pan-y; - } -.leaflet-container.leaflet-touch-drag { - -ms-touch-action: pinch-zoom; - } -.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { - -ms-touch-action: none; - touch-action: none; -} -.leaflet-container { - -webkit-tap-highlight-color: transparent; -} -.leaflet-container a { - -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); -} -.leaflet-tile { - filter: inherit; - visibility: hidden; - } -.leaflet-tile-loaded { - visibility: inherit; - } -.leaflet-zoom-box { - width: 0; - height: 0; - -moz-box-sizing: border-box; - box-sizing: border-box; - z-index: 800; - } -/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ -.leaflet-overlay-pane svg { - -moz-user-select: none; - } - -.leaflet-pane { z-index: 400; } - -.leaflet-tile-pane { z-index: 200; } -.leaflet-overlay-pane { z-index: 400; } -.leaflet-shadow-pane { z-index: 500; } -.leaflet-marker-pane { z-index: 600; } -.leaflet-tooltip-pane { z-index: 650; } -.leaflet-popup-pane { z-index: 700; } - -.leaflet-map-pane canvas { z-index: 100; } -.leaflet-map-pane svg { z-index: 200; } - -.leaflet-vml-shape { - width: 1px; - height: 1px; - } -.lvml { - behavior: url(#default#VML); - display: inline-block; - position: absolute; - } - - -/* control positioning */ - -.leaflet-control { - position: relative; - z-index: 800; - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; - } -.leaflet-top, -.leaflet-bottom { - position: absolute; - z-index: 1000; - pointer-events: none; - } -.leaflet-top { - top: 0; - } -.leaflet-right { - right: 0; - } -.leaflet-bottom { - bottom: 0; - } -.leaflet-left { - left: 0; - } -.leaflet-control { - float: left; - clear: both; - } -.leaflet-right .leaflet-control { - float: right; - } -.leaflet-top .leaflet-control { - margin-top: 10px; - } -.leaflet-bottom .leaflet-control { - margin-bottom: 10px; - } -.leaflet-left .leaflet-control { - margin-left: 10px; - } -.leaflet-right .leaflet-control { - margin-right: 10px; - } - - -/* zoom and fade animations */ - -.leaflet-fade-anim .leaflet-tile { - will-change: opacity; - } -.leaflet-fade-anim .leaflet-popup { - opacity: 0; - -webkit-transition: opacity 0.2s linear; - -moz-transition: opacity 0.2s linear; - -o-transition: opacity 0.2s linear; - transition: opacity 0.2s linear; - } -.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { - opacity: 1; - } -.leaflet-zoom-animated { - -webkit-transform-origin: 0 0; - -ms-transform-origin: 0 0; - transform-origin: 0 0; - } -.leaflet-zoom-anim .leaflet-zoom-animated { - will-change: transform; - } -.leaflet-zoom-anim .leaflet-zoom-animated { - -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); - -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); - -o-transition: -o-transform 0.25s cubic-bezier(0,0,0.25,1); - transition: transform 0.25s cubic-bezier(0,0,0.25,1); - } -.leaflet-zoom-anim .leaflet-tile, -.leaflet-pan-anim .leaflet-tile { - -webkit-transition: none; - -moz-transition: none; - -o-transition: none; - transition: none; - } - -.leaflet-zoom-anim .leaflet-zoom-hide { - visibility: hidden; - } - - -/* cursors */ - -.leaflet-interactive { - cursor: pointer; - } -.leaflet-grab { - cursor: -webkit-grab; - cursor: -moz-grab; - } -.leaflet-crosshair, -.leaflet-crosshair .leaflet-interactive { - cursor: crosshair; - } -.leaflet-popup-pane, -.leaflet-control { - cursor: auto; - } -.leaflet-dragging .leaflet-grab, -.leaflet-dragging .leaflet-grab .leaflet-interactive, -.leaflet-dragging .leaflet-marker-draggable { - cursor: move; - cursor: -webkit-grabbing; - cursor: -moz-grabbing; - } - -/* marker & overlays interactivity */ -.leaflet-marker-icon, -.leaflet-marker-shadow, -.leaflet-image-layer, -.leaflet-pane > svg path, -.leaflet-tile-container { - pointer-events: none; - } - -.leaflet-marker-icon.leaflet-interactive, -.leaflet-image-layer.leaflet-interactive, -.leaflet-pane > svg path.leaflet-interactive { - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; - } - -/* visual tweaks */ - -.leaflet-container { - background: #ddd; - outline: 0; - } -.leaflet-container a { - color: #0078A8; - } -.leaflet-container a.leaflet-active { - outline: 2px solid orange; - } -.leaflet-zoom-box { - border: 2px dotted #38f; - background: rgba(255,255,255,0.5); - } - - -/* general typography */ -.leaflet-container { - font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; - } - - -/* general toolbar styles */ - -.leaflet-bar { - box-shadow: 0 1px 5px rgba(0,0,0,0.65); - border-radius: 4px; - } -.leaflet-bar a, -.leaflet-bar a:hover { - background-color: #fff; - border-bottom: 1px solid #ccc; - width: 26px; - height: 26px; - line-height: 26px; - display: block; - text-align: center; - text-decoration: none; - color: black; - } -.leaflet-bar a, -.leaflet-control-layers-toggle { - background-position: 50% 50%; - background-repeat: no-repeat; - display: block; - } -.leaflet-bar a:hover { - background-color: #f4f4f4; - } -.leaflet-bar a:first-child { - border-top-left-radius: 4px; - border-top-right-radius: 4px; - } -.leaflet-bar a:last-child { - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - border-bottom: none; - } -.leaflet-bar a.leaflet-disabled { - cursor: default; - background-color: #f4f4f4; - color: #bbb; - } - -.leaflet-touch .leaflet-bar a { - width: 30px; - height: 30px; - line-height: 30px; - } -.leaflet-touch .leaflet-bar a:first-child { - border-top-left-radius: 2px; - border-top-right-radius: 2px; - } -.leaflet-touch .leaflet-bar a:last-child { - border-bottom-left-radius: 2px; - border-bottom-right-radius: 2px; - } - -/* zoom control */ - -.leaflet-control-zoom-in, -.leaflet-control-zoom-out { - font: bold 18px 'Lucida Console', Monaco, monospace; - text-indent: 1px; - } - -.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { - font-size: 22px; - } - - -/* layers control */ - -.leaflet-control-layers { - box-shadow: 0 1px 5px rgba(0,0,0,0.4); - background: #fff; - border-radius: 5px; - } -.leaflet-control-layers-toggle { - background-image: url(images/layers.png); - width: 36px; - height: 36px; - } -.leaflet-retina .leaflet-control-layers-toggle { - background-image: url(images/layers-2x.png); - background-size: 26px 26px; - } -.leaflet-touch .leaflet-control-layers-toggle { - width: 44px; - height: 44px; - } -.leaflet-control-layers .leaflet-control-layers-list, -.leaflet-control-layers-expanded .leaflet-control-layers-toggle { - display: none; - } -.leaflet-control-layers-expanded .leaflet-control-layers-list { - display: block; - position: relative; - } -.leaflet-control-layers-expanded { - padding: 6px 10px 6px 6px; - color: #333; - background: #fff; - } -.leaflet-control-layers-scrollbar { - overflow-y: scroll; - padding-right: 5px; - } -.leaflet-control-layers-selector { - margin-top: 2px; - position: relative; - top: 1px; - } -.leaflet-control-layers label { - display: block; - } -.leaflet-control-layers-separator { - height: 0; - border-top: 1px solid #ddd; - margin: 5px -10px 5px -6px; - } - -/* Default icon URLs */ -.leaflet-default-icon-path { - background-image: url(images/marker-icon.png); - } - - -/* attribution and scale controls */ - -.leaflet-container .leaflet-control-attribution { - background: #fff; - background: rgba(255, 255, 255, 0.7); - margin: 0; - } -.leaflet-control-attribution, -.leaflet-control-scale-line { - padding: 0 5px; - color: #333; - } -.leaflet-control-attribution a { - text-decoration: none; - } -.leaflet-control-attribution a:hover { - text-decoration: underline; - } -.leaflet-container .leaflet-control-attribution, -.leaflet-container .leaflet-control-scale { - font-size: 11px; - } -.leaflet-left .leaflet-control-scale { - margin-left: 5px; - } -.leaflet-bottom .leaflet-control-scale { - margin-bottom: 5px; - } -.leaflet-control-scale-line { - border: 2px solid #777; - border-top: none; - line-height: 1.1; - padding: 2px 5px 1px; - font-size: 11px; - white-space: nowrap; - overflow: hidden; - -moz-box-sizing: border-box; - box-sizing: border-box; - - background: #fff; - background: rgba(255, 255, 255, 0.5); - } -.leaflet-control-scale-line:not(:first-child) { - border-top: 2px solid #777; - border-bottom: none; - margin-top: -2px; - } -.leaflet-control-scale-line:not(:first-child):not(:last-child) { - border-bottom: 2px solid #777; - } - -.leaflet-touch .leaflet-control-attribution, -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - box-shadow: none; - } -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - border: 2px solid rgba(0,0,0,0.2); - background-clip: padding-box; - } - - -/* popup */ - -.leaflet-popup { - position: absolute; - text-align: center; - margin-bottom: 20px; - } -.leaflet-popup-content-wrapper { - padding: 1px; - text-align: left; - border-radius: 12px; - } -.leaflet-popup-content { - margin: 13px 19px; - line-height: 1.4; - } -.leaflet-popup-content p { - margin: 18px 0; - } -.leaflet-popup-tip-container { - width: 40px; - height: 20px; - position: absolute; - left: 50%; - margin-left: -20px; - overflow: hidden; - pointer-events: none; - } -.leaflet-popup-tip { - width: 17px; - height: 17px; - padding: 1px; - - margin: -10px auto 0; - - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -ms-transform: rotate(45deg); - -o-transform: rotate(45deg); - transform: rotate(45deg); - } -.leaflet-popup-content-wrapper, -.leaflet-popup-tip { - background: white; - color: #333; - box-shadow: 0 3px 14px rgba(0,0,0,0.4); - } -.leaflet-container a.leaflet-popup-close-button { - position: absolute; - top: 0; - right: 0; - padding: 4px 4px 0 0; - border: none; - text-align: center; - width: 18px; - height: 14px; - font: 16px/14px Tahoma, Verdana, sans-serif; - color: #c3c3c3; - text-decoration: none; - font-weight: bold; - background: transparent; - } -.leaflet-container a.leaflet-popup-close-button:hover { - color: #999; - } -.leaflet-popup-scrolled { - overflow: auto; - border-bottom: 1px solid #ddd; - border-top: 1px solid #ddd; - } - -.leaflet-oldie .leaflet-popup-content-wrapper { - zoom: 1; - } -.leaflet-oldie .leaflet-popup-tip { - width: 24px; - margin: 0 auto; - - -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; - filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); - } -.leaflet-oldie .leaflet-popup-tip-container { - margin-top: -1px; - } - -.leaflet-oldie .leaflet-control-zoom, -.leaflet-oldie .leaflet-control-layers, -.leaflet-oldie .leaflet-popup-content-wrapper, -.leaflet-oldie .leaflet-popup-tip { - border: 1px solid #999; - } - - -/* div icon */ - -.leaflet-div-icon { - background: #fff; - border: 1px solid #666; - } - - -/* Tooltip */ -/* Base styles for the element that has a tooltip */ -.leaflet-tooltip { - position: absolute; - padding: 6px; - background-color: #fff; - border: 1px solid #fff; - border-radius: 3px; - color: #222; - white-space: nowrap; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - pointer-events: none; - box-shadow: 0 1px 3px rgba(0,0,0,0.4); - } -.leaflet-tooltip.leaflet-clickable { - cursor: pointer; - pointer-events: auto; - } -.leaflet-tooltip-top:before, -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - position: absolute; - pointer-events: none; - border: 6px solid transparent; - background: transparent; - content: ""; - } - -/* Directions */ - -.leaflet-tooltip-bottom { - margin-top: 6px; -} -.leaflet-tooltip-top { - margin-top: -6px; -} -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-top:before { - left: 50%; - margin-left: -6px; - } -.leaflet-tooltip-top:before { - bottom: 0; - margin-bottom: -12px; - border-top-color: #fff; - } -.leaflet-tooltip-bottom:before { - top: 0; - margin-top: -12px; - margin-left: -6px; - border-bottom-color: #fff; - } -.leaflet-tooltip-left { - margin-left: -6px; -} -.leaflet-tooltip-right { - margin-left: 6px; -} -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - top: 50%; - margin-top: -6px; - } -.leaflet-tooltip-left:before { - right: 0; - margin-right: -12px; - border-left-color: #fff; - } -.leaflet-tooltip-right:before { - left: 0; - margin-left: -12px; - border-right-color: #fff; - } diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png deleted file mode 100644 index 0015b6495fa..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png deleted file mode 100644 index e2e9f757f51..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png deleted file mode 100644 index d1e773c715a..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/logo_automatic.png b/homeassistant/components/frontend/www_static/images/logo_automatic.png deleted file mode 100644 index ab03fa93b4c..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/logo_automatic.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/logo_axis.png b/homeassistant/components/frontend/www_static/images/logo_axis.png deleted file mode 100644 index 5eeb9b7b2a7..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/logo_axis.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/logo_philips_hue.png b/homeassistant/components/frontend/www_static/images/logo_philips_hue.png deleted file mode 100644 index ae4df811fa8..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/logo_philips_hue.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/logo_plex_mediaserver.png b/homeassistant/components/frontend/www_static/images/logo_plex_mediaserver.png deleted file mode 100644 index 97a1b4b352c..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/logo_plex_mediaserver.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/notification-badge.png b/homeassistant/components/frontend/www_static/images/notification-badge.png deleted file mode 100644 index 2d254444915..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/notification-badge.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/smart-tv.png b/homeassistant/components/frontend/www_static/images/smart-tv.png deleted file mode 100644 index 5ecda68b402..00000000000 Binary files a/homeassistant/components/frontend/www_static/images/smart-tv.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/mdi.html b/homeassistant/components/frontend/www_static/mdi.html deleted file mode 100644 index 962626edadf..00000000000 --- a/homeassistant/components/frontend/www_static/mdi.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/mdi.html.gz b/homeassistant/components/frontend/www_static/mdi.html.gz deleted file mode 100644 index 1befb4958ad..00000000000 Binary files a/homeassistant/components/frontend/www_static/mdi.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/micromarkdown-js.html b/homeassistant/components/frontend/www_static/micromarkdown-js.html deleted file mode 100644 index a80c564cb7b..00000000000 --- a/homeassistant/components/frontend/www_static/micromarkdown-js.html +++ /dev/null @@ -1,10 +0,0 @@ - diff --git a/homeassistant/components/frontend/www_static/micromarkdown-js.html.gz b/homeassistant/components/frontend/www_static/micromarkdown-js.html.gz deleted file mode 100644 index 341f96c260e..00000000000 Binary files a/homeassistant/components/frontend/www_static/micromarkdown-js.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html deleted file mode 100644 index 3fd7ef594a2..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html +++ /dev/null @@ -1,7 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz deleted file mode 100644 index 7618031d1a3..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html deleted file mode 100644 index e32d8306061..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz deleted file mode 100644 index 18406f9cad6..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html deleted file mode 100644 index 47b283e0a51..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html +++ /dev/null @@ -1,2 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz deleted file mode 100644 index d5348147181..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html deleted file mode 100644 index 80201efa386..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html.gz deleted file mode 100644 index 28a28a9647d..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html deleted file mode 100644 index c65c92d1b4f..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz deleted file mode 100644 index 9a5e3896a82..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html deleted file mode 100644 index 5f4337b0171..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz deleted file mode 100644 index 686dd7b7cc2..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html deleted file mode 100644 index 53638dd582b..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html +++ /dev/null @@ -1,2 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz deleted file mode 100644 index 24fd95f17a7..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html deleted file mode 100644 index 68bcffbb13d..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz deleted file mode 100644 index 4c6d52d6448..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html deleted file mode 100644 index 3b5f128b763..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz deleted file mode 100644 index f4e4ce09f41..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html deleted file mode 100644 index 68bd07459e7..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz deleted file mode 100644 index 949d08e0674..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html b/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html deleted file mode 100644 index 803c3696726..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html.gz deleted file mode 100644 index 63279686866..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html deleted file mode 100644 index fc9aa01d0b1..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz deleted file mode 100644 index 904aecb6acb..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html b/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html deleted file mode 100644 index 62948d65f07..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html.gz deleted file mode 100644 index d96d49785ac..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html deleted file mode 100644 index 5f34f7bc28a..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz deleted file mode 100644 index d9dd4c687fb..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html b/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html deleted file mode 100644 index 35954d7dc5f..00000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html.gz deleted file mode 100644 index 4af82c43063..00000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/robots.txt b/homeassistant/components/frontend/www_static/robots.txt deleted file mode 100644 index 77470cb39f0..00000000000 --- a/homeassistant/components/frontend/www_static/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: / \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js deleted file mode 100644 index 3c5f09f3daf..00000000000 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ /dev/null @@ -1,346 +0,0 @@ -/** - * Copyright 2016 Google Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. -*/ - -// DO NOT EDIT THIS GENERATED OUTPUT DIRECTLY! -// This file should be overwritten as part of your build process. -// If you need to extend the behavior of the generated service worker, the best approach is to write -// additional code and include it using the importScripts option: -// https://github.com/GoogleChrome/sw-precache#importscripts-arraystring -// -// Alternatively, it's possible to make changes to the underlying template file and then use that as the -// new base for generating output, via the templateFilePath option: -// https://github.com/GoogleChrome/sw-precache#templatefilepath-string -// -// If you go that route, make sure that whenever you update your sw-precache dependency, you reconcile any -// changes made to this original template file with your modified copy. - -// This generated service worker JavaScript will precache your site's resources. -// The code needs to be saved in a .js file at the top-level of your site, and registered -// from your pages in order to be used. See -// https://github.com/googlechrome/sw-precache/blob/master/demo/app/js/service-worker-registration.js -// for an example of how you can register this script and handle various service worker events. - -/* eslint-env worker, serviceworker */ -/* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ -'use strict'; - -var precacheConfig = [["/","517bbe13d945f188a0896870cd3055d0"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-2de1bde3b4a6c6c47dd95504fc098906.html","51faf83c8a2d86529bc76a67fe6b5234"],["/static/mdi-89074face5529f5fe6fbae49ecb3e88b.html","97754e463f9e56a95c813d4d8e792347"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; -var cacheName = 'sw-precache-v3--' + (self.registration ? self.registration.scope : ''); - - -var ignoreUrlParametersMatching = [/^utm_/]; - - - -var addDirectoryIndex = function (originalUrl, index) { - var url = new URL(originalUrl); - if (url.pathname.slice(-1) === '/') { - url.pathname += index; - } - return url.toString(); - }; - -var cleanResponse = function (originalResponse) { - // If this is not a redirected response, then we don't have to do anything. - if (!originalResponse.redirected) { - return Promise.resolve(originalResponse); - } - - // Firefox 50 and below doesn't support the Response.body stream, so we may - // need to read the entire body to memory as a Blob. - var bodyPromise = 'body' in originalResponse ? - Promise.resolve(originalResponse.body) : - originalResponse.blob(); - - return bodyPromise.then(function(body) { - // new Response() is happy when passed either a stream or a Blob. - return new Response(body, { - headers: originalResponse.headers, - status: originalResponse.status, - statusText: originalResponse.statusText - }); - }); - }; - -var createCacheKey = function (originalUrl, paramName, paramValue, - dontCacheBustUrlsMatching) { - // Create a new URL object to avoid modifying originalUrl. - var url = new URL(originalUrl); - - // If dontCacheBustUrlsMatching is not set, or if we don't have a match, - // then add in the extra cache-busting URL parameter. - if (!dontCacheBustUrlsMatching || - !(url.pathname.match(dontCacheBustUrlsMatching))) { - url.search += (url.search ? '&' : '') + - encodeURIComponent(paramName) + '=' + encodeURIComponent(paramValue); - } - - return url.toString(); - }; - -var isPathWhitelisted = function (whitelist, absoluteUrlString) { - // If the whitelist is empty, then consider all URLs to be whitelisted. - if (whitelist.length === 0) { - return true; - } - - // Otherwise compare each path regex to the path of the URL passed in. - var path = (new URL(absoluteUrlString)).pathname; - return whitelist.some(function(whitelistedPathRegex) { - return path.match(whitelistedPathRegex); - }); - }; - -var stripIgnoredUrlParameters = function (originalUrl, - ignoreUrlParametersMatching) { - var url = new URL(originalUrl); - // Remove the hash; see https://github.com/GoogleChrome/sw-precache/issues/290 - url.hash = ''; - - url.search = url.search.slice(1) // Exclude initial '?' - .split('&') // Split into an array of 'key=value' strings - .map(function(kv) { - return kv.split('='); // Split each 'key=value' string into a [key, value] array - }) - .filter(function(kv) { - return ignoreUrlParametersMatching.every(function(ignoredRegex) { - return !ignoredRegex.test(kv[0]); // Return true iff the key doesn't match any of the regexes. - }); - }) - .map(function(kv) { - return kv.join('='); // Join each [key, value] array into a 'key=value' string - }) - .join('&'); // Join the array of 'key=value' strings into a string with '&' in between each - - return url.toString(); - }; - - -var hashParamName = '_sw-precache'; -var urlsToCacheKeys = new Map( - precacheConfig.map(function(item) { - var relativeUrl = item[0]; - var hash = item[1]; - var absoluteUrl = new URL(relativeUrl, self.location); - var cacheKey = createCacheKey(absoluteUrl, hashParamName, hash, false); - return [absoluteUrl.toString(), cacheKey]; - }) -); - -function setOfCachedUrls(cache) { - return cache.keys().then(function(requests) { - return requests.map(function(request) { - return request.url; - }); - }).then(function(urls) { - return new Set(urls); - }); -} - -self.addEventListener('install', function(event) { - event.waitUntil( - caches.open(cacheName).then(function(cache) { - return setOfCachedUrls(cache).then(function(cachedUrls) { - return Promise.all( - Array.from(urlsToCacheKeys.values()).map(function(cacheKey) { - // If we don't have a key matching url in the cache already, add it. - if (!cachedUrls.has(cacheKey)) { - var request = new Request(cacheKey, {credentials: 'same-origin'}); - return fetch(request).then(function(response) { - // Bail out of installation unless we get back a 200 OK for - // every request. - if (!response.ok) { - throw new Error('Request for ' + cacheKey + ' returned a ' + - 'response with status ' + response.status); - } - - return cleanResponse(response).then(function(responseToCache) { - return cache.put(cacheKey, responseToCache); - }); - }); - } - }) - ); - }); - }).then(function() { - - // Force the SW to transition from installing -> active state - return self.skipWaiting(); - - }) - ); -}); - -self.addEventListener('activate', function(event) { - var setOfExpectedUrls = new Set(urlsToCacheKeys.values()); - - event.waitUntil( - caches.open(cacheName).then(function(cache) { - return cache.keys().then(function(existingRequests) { - return Promise.all( - existingRequests.map(function(existingRequest) { - if (!setOfExpectedUrls.has(existingRequest.url)) { - return cache.delete(existingRequest); - } - }) - ); - }); - }).then(function() { - - return self.clients.claim(); - - }) - ); -}); - - -self.addEventListener('fetch', function(event) { - if (event.request.method === 'GET') { - // Should we call event.respondWith() inside this fetch event handler? - // This needs to be determined synchronously, which will give other fetch - // handlers a chance to handle the request if need be. - var shouldRespond; - - // First, remove all the ignored parameters and hash fragment, and see if we - // have that URL in our cache. If so, great! shouldRespond will be true. - var url = stripIgnoredUrlParameters(event.request.url, ignoreUrlParametersMatching); - shouldRespond = urlsToCacheKeys.has(url); - - // If shouldRespond is false, check again, this time with 'index.html' - // (or whatever the directoryIndex option is set to) at the end. - var directoryIndex = 'index.html'; - if (!shouldRespond && directoryIndex) { - url = addDirectoryIndex(url, directoryIndex); - shouldRespond = urlsToCacheKeys.has(url); - } - - // If shouldRespond is still false, check to see if this is a navigation - // request, and if so, whether the URL matches navigateFallbackWhitelist. - var navigateFallback = '/'; - if (!shouldRespond && - navigateFallback && - (event.request.mode === 'navigate') && - isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"], event.request.url)) { - url = new URL(navigateFallback, self.location).toString(); - shouldRespond = urlsToCacheKeys.has(url); - } - - // If shouldRespond was set to true at any point, then call - // event.respondWith(), using the appropriate cache key. - if (shouldRespond) { - event.respondWith( - caches.open(cacheName).then(function(cache) { - return cache.match(urlsToCacheKeys.get(url)).then(function(response) { - if (response) { - return response; - } - throw Error('The cached response that was expected is missing.'); - }); - }).catch(function(e) { - // Fall back to just fetch()ing the request if some unexpected error - // prevented the cached response from being valid. - console.warn('Couldn\'t serve response for "%s" from cache: %O', event.request.url, e); - return fetch(event.request); - }) - ); - } - } -}); - - - - - - - - -self.addEventListener("push", function(event) { - var data; - if (event.data) { - data = event.data.json(); - event.waitUntil( - self.registration.showNotification(data.title, data) - .then(function(notification){ - firePushCallback({ - type: "received", - tag: data.tag, - data: data.data - }, data.data.jwt); - }) - ); - } -}); -self.addEventListener('notificationclick', function(event) { - var url; - - notificationEventCallback('clicked', event); - - event.notification.close(); - - if (!event.notification.data || !event.notification.data.url) { - return; - } - - url = event.notification.data.url; - - if (!url) return; - - event.waitUntil( - clients.matchAll({ - type: 'window', - }) - .then(function (windowClients) { - var i; - var client; - for (i = 0; i < windowClients.length; i++) { - client = windowClients[i]; - if (client.url === url && 'focus' in client) { - return client.focus(); - } - } - if (clients.openWindow) { - return clients.openWindow(url); - } - return undefined; - }) - ); -}); -self.addEventListener('notificationclose', function(event) { - notificationEventCallback('closed', event); -}); - -function notificationEventCallback(event_type, event){ - firePushCallback({ - action: event.action, - data: event.notification.data, - tag: event.notification.tag, - type: event_type - }, event.notification.data.jwt); -} -function firePushCallback(payload, jwt){ - // Don't send the JWT in the payload.data - delete payload.data.jwt; - // If payload.data is empty then just remove the entire payload.data object. - if (Object.keys(payload.data).length === 0 && payload.data.constructor === Object) { - delete payload.data; - } - fetch('/api/notify.html5/callback', { - method: 'POST', - headers: new Headers({'Content-Type': 'application/json', - 'Authorization': 'Bearer '+jwt}), - body: JSON.stringify(payload) - }); -} diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz deleted file mode 100644 index f0ad1b7c426..00000000000 Binary files a/homeassistant/components/frontend/www_static/service_worker.js.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/webcomponents-lite.js b/homeassistant/components/frontend/www_static/webcomponents-lite.js deleted file mode 100644 index 1cce53a0b30..00000000000 --- a/homeassistant/components/frontend/www_static/webcomponents-lite.js +++ /dev/null @@ -1,196 +0,0 @@ -(function(){/* - - Copyright (c) 2016 The Polymer Project Authors. All rights reserved. - This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt - The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt - The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt - Code distributed by Google as part of the polymer project is also - subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt - -Copyright (c) 2017 The Polymer Project Authors. All rights reserved. -This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt -The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt -The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt -Code distributed by Google as part of the polymer project is also -subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt - - Copyright (c) 2014 The Polymer Project Authors. All rights reserved. - This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt - The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt - The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt - Code distributed by Google as part of the polymer project is also - subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt - -Copyright (c) 2016 The Polymer Project Authors. All rights reserved. -This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt -The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt -The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt -Code distributed by Google as part of the polymer project is also -subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt -*/ -'use strict';var N="undefined"!=typeof window&&window===this?this:"undefined"!=typeof global&&null!=global?global:this,Oa="function"==typeof Object.defineProperties?Object.defineProperty:function(g,t,R){g!=Array.prototype&&g!=Object.prototype&&(g[t]=R.value)};function Pb(){Pb=function(){};N.Symbol||(N.Symbol=Qb)}var Qb=function(){var g=0;return function(t){return"jscomp_symbol_"+(t||"")+g++}}(); -function Od(){Pb();var g=N.Symbol.iterator;g||(g=N.Symbol.iterator=N.Symbol("iterator"));"function"!=typeof Array.prototype[g]&&Oa(Array.prototype,g,{configurable:!0,writable:!0,value:function(){return Pd(this)}});Od=function(){}}function Pd(g){var t=0;return Qd(function(){return t":return">";case '"':return""";case "\u00a0":return" "}}function tc(a){for(var b={},c=0;c";break a;case Node.TEXT_NODE:k=k.data;k=m&&ze[m.localName]?k:k.replace(Ae,sc);break a;case Node.COMMENT_NODE:k="\x3c!--"+k.data+"--\x3e";break a;default:throw window.console.error(k),Error("not implemented");}}c+=k}return c}function da(a){E.currentNode=a;return E.parentNode()}function Ya(a){E.currentNode=a;return E.firstChild()}function Za(a){E.currentNode=a;return E.lastChild()}function uc(a){E.currentNode= -a;return E.previousSibling()}function vc(a){E.currentNode=a;return E.nextSibling()}function X(a){var b=[];E.currentNode=a;for(a=E.firstChild();a;)b.push(a),a=E.nextSibling();return b}function wc(a){F.currentNode=a;return F.parentNode()}function xc(a){F.currentNode=a;return F.firstChild()}function yc(a){F.currentNode=a;return F.lastChild()}function zc(a){F.currentNode=a;return F.previousSibling()}function Ac(a){F.currentNode=a;return F.nextSibling()}function Bc(a){var b=[];F.currentNode=a;for(a=F.firstChild();a;)b.push(a), -a=F.nextSibling();return b}function Cc(a){return mb(a,function(a){return X(a)})}function Dc(a){switch(a.nodeType){case Node.ELEMENT_NODE:case Node.DOCUMENT_FRAGMENT_NODE:a=document.createTreeWalker(a,NodeFilter.SHOW_TEXT,null,!1);for(var b="",c;c=a.nextNode();)b+=c.nodeValue;return b;default:return a.nodeValue}}function O(a,b,c){for(var d in b){var e=Object.getOwnPropertyDescriptor(a,d);e&&e.configurable||!e&&c?Object.defineProperty(a,d,b[d]):c&&console.warn("Could not define",d,"on",a)}}function U(a){O(a, -Ec);O(a,nb);O(a,ob)}function Fc(a,b,c){fc(a);c=c||null;a.__shady=a.__shady||{};b.__shady=b.__shady||{};c&&(c.__shady=c.__shady||{});a.__shady.previousSibling=c?c.__shady.previousSibling:b.lastChild;var d=a.__shady.previousSibling;d&&d.__shady&&(d.__shady.nextSibling=a);(d=a.__shady.nextSibling=c)&&d.__shady&&(d.__shady.previousSibling=a);a.__shady.parentNode=b;c?c===b.__shady.firstChild&&(b.__shady.firstChild=a):(b.__shady.lastChild=a,b.__shady.firstChild||(b.__shady.firstChild=a));b.__shady.childNodes= -null}function pb(a,b,c){if(b===a)throw Error("Failed to execute 'appendChild' on 'Node': The new child element contains the parent.");if(c){var d=c.__shady&&c.__shady.parentNode;if(void 0!==d&&d!==a||void 0===d&&da(c)!==a)throw Error("Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.");}if(c===b)return b;b.parentNode&&qb(b.parentNode,b);d=ma(a);var e;if(e=d)a:{if(!b.__noInsertionPoint){var f;"slot"===b.localName?f=[b]:b.querySelectorAll&& -(f=b.querySelectorAll("slot"));if(f&&f.length){e=f;break a}}e=void 0}(f=e)&&d.$a(f);d&&("slot"===a.localName||f)&&d.O();if(ca(a)){d=c;ec(a);a.__shady=a.__shady||{};void 0!==a.__shady.firstChild&&(a.__shady.childNodes=null);if(b.nodeType===Node.DOCUMENT_FRAGMENT_NODE){f=b.childNodes;for(e=0;e":return">";case "\u00a0":return" "}},k=function(b){Object.defineProperty(b,"innerHTML",{get:function(){for(var a="",b=this.content.firstChild;b;b=b.nextSibling)a+=b.outerHTML||b.data.replace(u,h);return a},set:function(b){g.body.innerHTML=b;for(a.b(g);this.content.firstChild;)this.content.removeChild(this.content.firstChild);for(;g.body.firstChild;)this.content.appendChild(g.body.firstChild)}, -configurable:!0})},g=document.implementation.createHTMLDocument("template"),q=!0,l=document.createElement("style");l.textContent="template{display:none;}";var p=document.head;p.insertBefore(l,p.firstElementChild);a.prototype=Object.create(HTMLElement.prototype);var t=!document.createElement("div").hasOwnProperty("innerHTML");a.R=function(b){if(!b.content){b.content=g.createDocumentFragment();for(var c;c=b.firstChild;)b.content.appendChild(c);if(t)b.__proto__=a.prototype;else if(b.cloneNode=function(b){return a.a(this, -b)},q)try{k(b)}catch(rf){q=!1}a.b(b.content)}};k(a.prototype);a.b=function(b){b=b.querySelectorAll("template");for(var c=0,d=b.length,e;c]/g}if(b||f)a.a=function(a,b){var d=c.call(a,!1);this.R&&this.R(d);b&&(d.content.appendChild(c.call(a.content,!0)),this.ta(d.content, -a.content));return d},a.prototype.cloneNode=function(b){return a.a(this,b)},a.ta=function(a,b){if(b.querySelectorAll){b=b.querySelectorAll("template");a=a.querySelectorAll("template");for(var c=0,d=a.length,e,f;c]*)(rel=['|"]?stylesheet['|"]?[^>]*>)/g,r={mb:function(a,b){a.href&&a.setAttribute("href",r.fa(a.getAttribute("href"),b));a.src&&a.setAttribute("src",r.fa(a.getAttribute("src"),b));if("style"===a.localName){var c= -r.Ma(a.textContent,b,t);a.textContent=r.Ma(c,b,u)}},Ma:function(a,b,c){return a.replace(c,function(a,c,d,e){a=d.replace(/["']/g,"");b&&(a=r.fa(a,b));return c+"'"+a+"'"+e})},fa:function(a,b){if(void 0===r.na){r.na=!1;try{var c=new URL("b","http://a");c.pathname="c%20d";r.na="http://a/c%20d"===c.href}catch(sf){}}if(r.na)return(new URL(a,b)).href;c=r.Za;c||(c=document.implementation.createHTMLDocument("temp"),r.Za=c,c.ya=c.createElement("base"),c.head.appendChild(c.ya),c.xa=c.createElement("a"));c.ya.href= -b;c.xa.href=a;return c.xa.href||a}},w={async:!0,load:function(a,b,c){if(a)if(a.match(/^data:/)){a=a.split(",");var d=a[1];d=-1e.status?b(d,a):c(d)}; -e.send()}else c("error: href must be specified")}},z=/Trident/.test(navigator.userAgent)||/Edge\/\d./i.test(navigator.userAgent);m.prototype.c=function(a){var b=this;a=a.querySelectorAll("link[rel=import]");l(a,function(a){return b.h(a)})};m.prototype.h=function(a){var b=this,c=a.href;if(void 0!==this.a[c]){var d=this.a[c];d&&d.__loaded&&(a.import=d,this.g(a))}else this.b++,this.a[c]="pending",w.load(c,function(a,d){a=b.s(a,d||c);b.a[c]=a;b.b--;b.c(a);b.i()},function(){b.a[c]=null;b.b--;b.i()})}; -m.prototype.s=function(a,b){if(!a)return document.createDocumentFragment();z&&(a=a.replace(v,function(a,b,c){return-1===a.indexOf("type=")?b+" type=import-disable "+c:a}));var c=document.createElement("template");c.innerHTML=a;if(c.content)a=c.content;else for(a=document.createDocumentFragment();c.firstChild;)a.appendChild(c.firstChild);if(c=a.querySelector("base"))b=r.fa(c.getAttribute("href"),b),c.removeAttribute("href");c=a.querySelectorAll('link[rel=import], link[rel=stylesheet][href][type=import-disable],\n style:not([type]), link[rel=stylesheet][href]:not([type]),\n script:not([type]), script[type="application/javascript"],\n script[type="text/javascript"]'); -var d=0;l(c,function(a){h(a);r.mb(a,b);a.setAttribute("import-dependency","");"script"===a.localName&&!a.src&&a.textContent&&(a.setAttribute("src","data:text/javascript;charset=utf-8,"+encodeURIComponent(a.textContent+("\n//# sourceURL="+b+(d?"-"+d:"")+".js\n"))),a.textContent="",d++)});return a};m.prototype.i=function(){var a=this;if(!this.b){this.f.disconnect();this.flatten(document);var b=!1,c=!1,d=function(){c&&b&&(a.c(document),a.b||(a.f.observe(document.head,{childList:!0,subtree:!0}),a.j()))}; -this.v(function(){c=!0;d()});this.u(function(){b=!0;d()})}};m.prototype.flatten=function(a){var b=this;a=a.querySelectorAll("link[rel=import]");l(a,function(a){var c=b.a[a.href];(a.import=c)&&c.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&(b.a[a.href]=a,a.readyState="loading",a.import=a,b.flatten(c),a.appendChild(c))})};m.prototype.u=function(a){function b(e){if(e]/g,ye=tc("area base br col command embed hr img input keygen link meta param source track wbr".split(" ")),ze=tc("style script xmp iframe noembed noframes plaintext noscript".split(" ")),E=document.createTreeWalker(document,NodeFilter.SHOW_ALL,null,!1),F=document.createTreeWalker(document,NodeFilter.SHOW_ELEMENT,null,!1),Ve=Object.freeze({parentNode:da,firstChild:Ya,lastChild:Za,previousSibling:uc,nextSibling:vc,childNodes:X,parentElement:wc,firstElementChild:xc,lastElementChild:yc,previousElementSibling:zc, -nextElementSibling:Ac,children:Bc,innerHTML:Cc,textContent:Dc}),Hb=Object.getOwnPropertyDescriptor(Element.prototype,"innerHTML")||Object.getOwnPropertyDescriptor(HTMLElement.prototype,"innerHTML"),Ga=document.implementation.createHTMLDocument("inert").createElement("div"),Ib=Object.getOwnPropertyDescriptor(Document.prototype,"activeElement"),Ec={parentElement:{get:function(){var a=this.__shady&&this.__shady.parentNode;a&&a.nodeType!==Node.ELEMENT_NODE&&(a=null);return void 0!==a?a:wc(this)},configurable:!0}, -parentNode:{get:function(){var a=this.__shady&&this.__shady.parentNode;return void 0!==a?a:da(this)},configurable:!0},nextSibling:{get:function(){var a=this.__shady&&this.__shady.nextSibling;return void 0!==a?a:vc(this)},configurable:!0},previousSibling:{get:function(){var a=this.__shady&&this.__shady.previousSibling;return void 0!==a?a:uc(this)},configurable:!0},className:{get:function(){return this.getAttribute("class")||""},set:function(a){this.setAttribute("class",a)},configurable:!0},nextElementSibling:{get:function(){if(this.__shady&& -void 0!==this.__shady.nextSibling){for(var a=this.nextSibling;a&&a.nodeType!==Node.ELEMENT_NODE;)a=a.nextSibling;return a}return Ac(this)},configurable:!0},previousElementSibling:{get:function(){if(this.__shady&&void 0!==this.__shady.previousSibling){for(var a=this.previousSibling;a&&a.nodeType!==Node.ELEMENT_NODE;)a=a.previousSibling;return a}return zc(this)},configurable:!0}},nb={childNodes:{get:function(){if(ca(this)){if(!this.__shady.childNodes){this.__shady.childNodes=[];for(var a=this.firstChild;a;a= -a.nextSibling)this.__shady.childNodes.push(a)}var b=this.__shady.childNodes}else b=X(this);b.item=function(a){return b[a]};return b},configurable:!0},childElementCount:{get:function(){return this.children.length},configurable:!0},firstChild:{get:function(){var a=this.__shady&&this.__shady.firstChild;return void 0!==a?a:Ya(this)},configurable:!0},lastChild:{get:function(){var a=this.__shady&&this.__shady.lastChild;return void 0!==a?a:Za(this)},configurable:!0},textContent:{get:function(){if(ca(this)){for(var a= -[],b=0,c=this.childNodes,d;d=c[b];b++)d.nodeType!==Node.COMMENT_NODE&&a.push(d.textContent);return a.join("")}return Dc(this)},set:function(a){switch(this.nodeType){case Node.ELEMENT_NODE:case Node.DOCUMENT_FRAGMENT_NODE:for(;this.firstChild;)this.removeChild(this.firstChild);(0b.__shady.assignedNodes.length&&(b.__shady.sa=!0)}b.__shady.sa&&(b.__shady.sa=!1,this.i(b))}};l.prototype.h=function(a,b){a.__shady=a.__shady||{};var c=a.__shady.oa;a.__shady.oa=null;b||(b=(b=this.a[a.slot||"__catchall"])&&b[0]);b?(b.__shady.assignedNodes.push(a),a.__shady.assignedSlot=b):a.__shady.assignedSlot=void 0;c!==a.__shady.assignedSlot&&a.__shady.assignedSlot&&(a.__shady.assignedSlot.__shady.sa=!0)};l.prototype.u=function(a){var b=a.__shady.assignedNodes;a.__shady.assignedNodes= -[];a.__shady.W=[];if(a.__shady.Ea=b)for(var c=0;cb.indexOf(d))||b.push(d)}for(a=0;a "+b}))}a=a.replace(mf,function(a,b,c){return'[dir="'+c+'"] '+b+", "+b+'[dir="'+ -c+'"]'});return{value:a,kb:b,stop:f}};v.prototype.u=function(a,b){a=a.split(Ad);a[0]+=b;return a.join(Ad)};v.prototype.H=function(a,b){var c=a.match(Bd);return(c=c&&c[2].trim()||"")?c[0].match(Cd)?a.replace(Bd,function(a,c,f){return b+f}):c.split(Cd)[0]===b?c:nf:a.replace(Kb,b)};v.prototype.J=function(a){a.selector=a.parsedSelector;this.B(a);this.l(a,this.N)};v.prototype.B=function(a){a.selector===of&&(a.selector="html")};v.prototype.N=function(a){return a.match(Lb)?this.g(a,Dd):this.u(a.trim(),Dd)}; -N.Object.defineProperties(v.prototype,{a:{configurable:!0,enumerable:!0,get:function(){return"style-scope"}}});var Jb=/:(nth[-\w]+)\(([^)]+)\)/,Dd=":not(.style-scope)",zd=",",kf=/(^|[\s>+~]+)((?:\[.+?\]|[^\s>+~=[])+)/g,Cd=/[[.:#*]/,Kb=":host",of=":root",Lb="::slotted",jf=new RegExp("^("+Lb+")"),Bd=/(:host)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/,lf=/(?:::slotted)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/,mf=/(.*):dir\((?:(ltr|rtl))\)/,hf=".",Ad=":",gf="class",nf="should_not_match",r=new v;w.get=function(a){return a? -a.__styleInfo:null};w.set=function(a,b){return a.__styleInfo=b};w.prototype.c=function(){return this.G};w.prototype._getStyleRules=w.prototype.c;var Ed=function(a){return a.matches||a.matchesSelector||a.mozMatchesSelector||a.msMatchesSelector||a.oMatchesSelector||a.webkitMatchesSelector}(window.Element.prototype),pf=navigator.userAgent.match("Trident");p.prototype.J=function(a){var b=this,c={},d=[],e=0;fa(a,function(a){b.c(a);a.index=e++;b.I(a.w.cssText,c)},function(a){d.push(a)});a.b=d;a=[];for(var f in c)a.push(f); -return a};p.prototype.c=function(a){if(!a.w){var b={},c={};this.b(a,c)&&(b.F=c,a.rules=null);b.cssText=this.H(a);a.w=b}};p.prototype.b=function(a,b){var c=a.w;if(c){if(c.F)return Object.assign(b,c.F),!0}else{c=a.parsedCssText;for(var d;a=Ia.exec(c);){d=(a[2]||a[3]).trim();if("inherit"!==d||"unset"!==d)b[a[1].trim()]=d;d=!0}return d}};p.prototype.H=function(a){return this.N(a.parsedCssText)};p.prototype.N=function(a){return a.replace(ff,"").replace(Ia,"")};p.prototype.I=function(a,b){for(var c;c=df.exec(a);){var d= -c[1];":"!==c[2]&&(b[d]=!0)}};p.prototype.ga=function(a){for(var b=Object.getOwnPropertyNames(a),c=0,d;c *"===f||"html"===f,k=0===f.indexOf(":host")&&!g;"shady"===c&&(g=f===e+" > *."+e||-1!== -f.indexOf("html"),k=!g&&0===f.indexOf(e));"shadow"===c&&(g=":host > *"===f||"html"===f,k=k&&!g);if(g||k)c=e,k&&(z&&!b.A&&(b.A=r.s(b,r.g,r.i(a),e)),c=b.A||e),d({vb:c,pb:k,Gb:g})}};p.prototype.K=function(a,b){var c={},d={},e=this,f=b&&b.__cssBuild;fa(b,function(b){e.ia(a,b,f,function(f){Ed.call(a.Db||a,f.vb)&&(f.pb?e.b(b,c):e.b(b,d))})},null,!0);return{tb:d,ob:c}};p.prototype.ha=function(a,b,c){var d=this,e=W(a),f=r.f(e.is,e.$),g=new RegExp("(?:^|[^.#[:])"+(a.extends?"\\"+f.slice(0,-1)+"\\]":f)+"($|[.:[\\s>+~])"); -e=w.get(a).G;var k=this.h(e,c);return r.c(a,e,function(a){d.D(a,b);z||kd(a)||!a.cssText||(d.B(a,k),d.l(a,g,f,c))})};p.prototype.h=function(a,b){a=a.b;var c={};if(!z&&a)for(var d=0,e=a[d];d=f._useCount&&f.parentNode&&f.parentNode.removeChild(f)); -z?e.a?(e.a.textContent=b,d=e.a):b&&(d=Ab(b,c,a.shadowRoot,e.b)):d?d.parentNode||(pf&&-1this.c&&e.shift();this.cache[a]= -e};ta.prototype.fetch=function(a,b,c){if(a=this.cache[a])for(var d=a.length-1;0<=d;d--){var e=a[d];if(this.a(e,b,c))return e}};if(!z){var Fd=new MutationObserver(nd),Gd=function(a){Fd.observe(a,{childList:!0,subtree:!0})};if(window.customElements&&!window.customElements.polyfillWrapFlushCallback)Gd(document);else{var Nb=function(){Gd(document.body)};window.HTMLImports?window.HTMLImports.whenReady(Nb):requestAnimationFrame(function(){if("loading"===document.readyState){var a=function(){Nb();document.removeEventListener("readystatechange", -a)};document.addEventListener("readystatechange",a)}else Nb()})}R=function(){nd(Fd.takeRecords())}}var Ea={},Pe=Promise.resolve(),Bb=null,pd=window.HTMLImports&&window.HTMLImports.whenReady||null,Cb,La=null,sa=null;t.prototype.Ha=function(){!this.enqueued&&sa&&(this.enqueued=!0,Rb(sa))};t.prototype.b=function(a){a.__seenByShadyCSS||(a.__seenByShadyCSS=!0,this.customStyles.push(a),this.Ha())};t.prototype.a=function(a){return a.__shadyCSSCachedStyle?a.__shadyCSSCachedStyle:a.getStyle?a.getStyle():a}; -t.prototype.c=function(){for(var a=this.customStyles,b=0;b dict: - """Helper to simplify format for response.""" + """Make response message.""" return {'requestId': request_id, 'payload': payload} def entity_to_device(entity: Entity): """Convert a hass entity into an google actions device.""" - class_data = MAPPING_COMPONENT.get(entity.domain) + class_data = MAPPING_COMPONENT.get( + entity.attributes.get(ATTR_GOOGLE_ASSISTANT_TYPE) or entity.domain) if class_data is None: return None @@ -94,6 +99,14 @@ def entity_to_device(entity: Entity): for feature, trait in class_data[2].items(): if feature & supported > 0: device['traits'].append(trait) + if entity.domain == climate.DOMAIN: + modes = ','.join( + m for m in entity.attributes.get(climate.ATTR_OPERATION_LIST, []) + if m in CLIMATE_SUPPORTED_MODES) + device['attributes'] = { + 'availableThermostatModes': modes, + 'thermostatTemperatureUnit': 'C', + } return device @@ -150,6 +163,23 @@ def determine_service(entity_id: str, command: str, return (cover.SERVICE_OPEN_COVER, service_data) return (cover.SERVICE_CLOSE_COVER, service_data) + # special climate handling + if domain == climate.DOMAIN: + if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: + service_data['temperature'] = params.get( + 'thermostatTemperatureSetpoint', 25) + return (climate.SERVICE_SET_TEMPERATURE, service_data) + if command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: + service_data['target_temp_high'] = params.get( + 'thermostatTemperatureSetpointHigh', 25) + service_data['target_temp_low'] = params.get( + 'thermostatTemperatureSetpointLow', 18) + return (climate.SERVICE_SET_TEMPERATURE, service_data) + if command == COMMAND_THERMOSTAT_SET_MODE: + service_data['operation_mode'] = params.get( + 'thermostatMode', 'off') + return (climate.SERVICE_SET_OPERATION_MODE, service_data) + if command == COMMAND_BRIGHTNESS: brightness = params.get('brightness') service_data['brightness'] = int(brightness / 100 * 255) diff --git a/homeassistant/components/google_domains.py b/homeassistant/components/google_domains.py new file mode 100644 index 00000000000..3b414306be5 --- /dev/null +++ b/homeassistant/components/google_domains.py @@ -0,0 +1,94 @@ +""" +Integrate with Google Domains. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/google_domains/ +""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME) + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'google_domains' + +INTERVAL = timedelta(minutes=5) + +DEFAULT_TIMEOUT = 10 + +UPDATE_URL = 'https://{}:{}@domains.google.com/nic/update' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the Google Domains component.""" + domain = config[DOMAIN].get(CONF_DOMAIN) + user = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + timeout = config[DOMAIN].get(CONF_TIMEOUT) + + session = hass.helpers.aiohttp_client.async_get_clientsession() + + result = yield from _update_google_domains( + hass, session, domain, user, password, timeout) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the Google Domains entry.""" + yield from _update_google_domains( + hass, session, domain, user, password, timeout) + + hass.helpers.event.async_track_time_interval( + update_domain_interval, INTERVAL) + + return True + + +@asyncio.coroutine +def _update_google_domains(hass, session, domain, user, password, timeout): + """Update Google Domains.""" + url = UPDATE_URL.format(user, password) + + params = { + 'hostname': domain + } + + try: + with async_timeout.timeout(timeout, loop=hass.loop): + resp = yield from session.get(url, params=params) + body = yield from resp.text() + + if body.startswith('good') or body.startswith('nochg'): + return True + + _LOGGER.warning('Updating Google Domains failed: %s => %s', + domain, body) + + except aiohttp.ClientError: + _LOGGER.warning("Can't connect to Google Domains API") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from Google Domains API for domain: %s", + domain) + + return False diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml index 2447392c3b7..f51f8b909d4 100644 --- a/homeassistant/components/group/services.yaml +++ b/homeassistant/components/group/services.yaml @@ -1,59 +1,50 @@ +# Describes the format for available group services + reload: - description: "Reload group configuration." + description: Reload group configuration. set_visibility: - description: Hide or show a group - + description: Hide or show a group. fields: entity_id: - description: Name(s) of entities to set value + description: Name(s) of entities to set value. example: 'group.travel' - visible: description: True if group should be shown or False if it should be hidden. example: True set: - description: Create/Update a user group - + description: Create/Update a user group. fields: object_id: - description: Group id and part of entity id + description: Group id and part of entity id. example: 'test_group' - name: description: Name of group example: 'My test group' - view: - description: Boolean for if the group is a view + description: Boolean for if the group is a view. example: True - icon: - description: Name of icon for the group + description: Name of icon for the group. example: 'mdi:camera' - control: - description: Value for control the group control + description: Value for control the group control. example: 'hidden' - visible: - description: If the group is visible on UI + description: If the group is visible on UI. example: True - entities: - description: List of all members in the group. Not compatible with 'delta' + description: List of all members in the group. Not compatible with 'delta'. example: domain.entity_id1, domain.entity_id2 - add_entities: description: List of members they will change on group listening. example: domain.entity_id1, domain.entity_id2 remove: - description: Remove a user group - + description: Remove a user group. fields: object_id: - description: Group id and part of entity id + description: Group id and part of entity id. example: 'test_group' diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index 0527bdbf2be..940de2ba12f 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -23,7 +23,6 @@ from homeassistant.components.http import ( HomeAssistantView, KEY_AUTHENTICATED, CONF_API_PASSWORD, CONF_SERVER_PORT, CONF_SSL_CERTIFICATE) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.components.frontend import register_built_in_panel _LOGGER = logging.getLogger(__name__) @@ -90,8 +89,8 @@ def async_setup(hass, config): hass.http.register_view(HassIOView(hassio)) if 'frontend' in hass.config.components: - register_built_in_panel(hass, 'hassio', 'Hass.io', - 'mdi:access-point-network') + yield from hass.components.frontend.async_register_built_in_panel( + 'hassio', 'Hass.io', 'mdi:access-point-network') if 'http' in config: yield from hassio.update_hass_api(config['http']) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 4f51abf8973..55858dbe765 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -17,7 +17,6 @@ from homeassistant.const import ( HTTP_BAD_REQUEST, CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE) import homeassistant.util.dt as dt_util from homeassistant.components import recorder, script -from homeassistant.components.frontend import register_built_in_panel from homeassistant.components.http import HomeAssistantView from homeassistant.const import ATTR_HIDDEN from homeassistant.components.recorder.util import session_scope, execute @@ -231,8 +230,8 @@ def get_state(hass, utc_point_in_time, entity_id, run=None): return states[0] if states else None -# pylint: disable=unused-argument -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Set up the history hooks.""" filters = Filters() exclude = config[DOMAIN].get(CONF_EXCLUDE) @@ -245,7 +244,8 @@ def setup(hass, config): filters.included_domains = include[CONF_DOMAINS] hass.http.register_view(HistoryPeriodView(filters)) - register_built_in_panel(hass, 'history', 'History', 'mdi:poll-box') + yield from hass.components.frontend.async_register_built_in_panel( + 'history', 'history', 'mdi:poll-box') return True diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index c9de284067f..f402a9d6892 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -28,9 +28,8 @@ from homeassistant.util.logging import HideSensitiveDataFilter from .auth import auth_middleware from .ban import ban_middleware from .const import ( - KEY_USE_X_FORWARDED_FOR, KEY_TRUSTED_NETWORKS, - KEY_BANS_ENABLED, KEY_LOGIN_THRESHOLD, - KEY_DEVELOPMENT, KEY_AUTHENTICATED) + KEY_USE_X_FORWARDED_FOR, KEY_TRUSTED_NETWORKS, KEY_BANS_ENABLED, + KEY_LOGIN_THRESHOLD, KEY_AUTHENTICATED) from .static import ( staticresource_middleware, CachingFileResponse, CachingStaticResource) from .util import get_real_ip @@ -43,7 +42,6 @@ CONF_API_PASSWORD = 'api_password' CONF_SERVER_HOST = 'server_host' CONF_SERVER_PORT = 'server_port' CONF_BASE_URL = 'base_url' -CONF_DEVELOPMENT = 'development' CONF_SSL_CERTIFICATE = 'ssl_certificate' CONF_SSL_KEY = 'ssl_key' CONF_CORS_ORIGINS = 'cors_allowed_origins' @@ -84,7 +82,6 @@ HTTP_SCHEMA = vol.Schema({ vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string, vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, vol.Optional(CONF_BASE_URL): cv.string, - vol.Optional(CONF_DEVELOPMENT, default=DEFAULT_DEVELOPMENT): cv.string, vol.Optional(CONF_SSL_CERTIFICATE, default=None): cv.isfile, vol.Optional(CONF_SSL_KEY, default=None): cv.isfile, vol.Optional(CONF_CORS_ORIGINS, default=[]): @@ -113,7 +110,6 @@ def async_setup(hass, config): api_password = conf[CONF_API_PASSWORD] server_host = conf[CONF_SERVER_HOST] server_port = conf[CONF_SERVER_PORT] - development = conf[CONF_DEVELOPMENT] == '1' ssl_certificate = conf[CONF_SSL_CERTIFICATE] ssl_key = conf[CONF_SSL_KEY] cors_origins = conf[CONF_CORS_ORIGINS] @@ -128,7 +124,6 @@ def async_setup(hass, config): server = HomeAssistantWSGI( hass, - development=development, server_host=server_host, server_port=server_port, api_password=api_password, @@ -176,7 +171,7 @@ def async_setup(hass, config): class HomeAssistantWSGI(object): """WSGI server for Home Assistant.""" - def __init__(self, hass, development, api_password, ssl_certificate, + def __init__(self, hass, api_password, ssl_certificate, ssl_key, server_host, server_port, cors_origins, use_x_forwarded_for, trusted_networks, login_threshold, is_ban_enabled): @@ -194,10 +189,8 @@ class HomeAssistantWSGI(object): self.app[KEY_TRUSTED_NETWORKS] = trusted_networks self.app[KEY_BANS_ENABLED] = is_ban_enabled self.app[KEY_LOGIN_THRESHOLD] = login_threshold - self.app[KEY_DEVELOPMENT] = development self.hass = hass - self.development = development self.api_password = api_password self.ssl_certificate = ssl_certificate self.ssl_key = ssl_key diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index 5922042e4fb..4250dd32514 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -7,6 +7,5 @@ KEY_BANS_ENABLED = 'ha_bans_enabled' KEY_BANNED_IPS = 'ha_banned_ips' KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts' KEY_LOGIN_THRESHOLD = 'ha_login_threshold' -KEY_DEVELOPMENT = 'ha_development' HTTP_HEADER_X_FORWARDED_FOR = 'X-Forwarded-For' diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 21e955fc968..f991a4ee0fc 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -8,8 +8,6 @@ from aiohttp.web_exceptions import HTTPNotFound from aiohttp.web_urldispatcher import StaticResource from yarl import unquote -from .const import KEY_DEVELOPMENT - _FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) @@ -53,10 +51,9 @@ class CachingFileResponse(FileResponse): @asyncio.coroutine def sendfile(request, fobj, count): """Sendfile that includes a cache header.""" - if not request.app[KEY_DEVELOPMENT]: - cache_time = 31 * 86400 # = 1 month - self.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format( - cache_time) + cache_time = 31 * 86400 # = 1 month + self.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format( + cache_time) yield from orig_sendfile(request, fobj, count) diff --git a/homeassistant/components/image_processing/openalpr_local.py b/homeassistant/components/image_processing/openalpr_local.py index b0ef93611ea..ce06d98bf13 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 +from homeassistant.const import STATE_UNKNOWN, 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) @@ -46,7 +46,6 @@ OPENALPR_REGIONS = [ ] CONF_ALPR_BIN = 'alp_bin' -CONF_REGION = 'region' DEFAULT_BINARY = 'alpr' diff --git a/homeassistant/components/image_processing/services.yaml b/homeassistant/components/image_processing/services.yaml index 2c6369f9804..1f1fa347dc9 100644 --- a/homeassistant/components/image_processing/services.yaml +++ b/homeassistant/components/image_processing/services.yaml @@ -1,9 +1,8 @@ -# Describes the format for available image_processing services +# Describes the format for available image processing services scan: - description: Process an image immediately - + description: Process an image immediately. fields: entity_id: - description: Name(s) of entities to scan immediately + description: Name(s) of entities to scan immediately. example: 'image_processing.alpr_garage' diff --git a/homeassistant/components/input_number.py b/homeassistant/components/input_number.py index 598fb573904..856cdac1e4b 100644 --- a/homeassistant/components/input_number.py +++ b/homeassistant/components/input_number.py @@ -4,18 +4,20 @@ Component to offer a way to set a numeric value from a slider or text box. For more details about this component, please refer to the documentation at https://home-assistant.io/components/input_number/ """ +import os import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME) -from homeassistant.loader import bind_hass + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) @@ -25,7 +27,6 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' CONF_INITIAL = 'initial' CONF_MIN = 'min' CONF_MAX = 'max' -CONF_MODE = 'mode' CONF_STEP = 'step' MODE_SLIDER = 'slider' @@ -38,6 +39,12 @@ ATTR_STEP = 'step' ATTR_MODE = 'mode' SERVICE_SET_VALUE = 'set_value' +SERVICE_INCREMENT = 'increment' +SERVICE_DECREMENT = 'decrement' + +SERVICE_DEFAULT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids +}) SERVICE_SET_VALUE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, @@ -77,6 +84,19 @@ CONFIG_SCHEMA = vol.Schema({ }, required=True, extra=vol.ALLOW_EXTRA) +SERVICE_TO_METHOD = { + SERVICE_SET_VALUE: { + 'method': 'async_set_value', + 'schema': SERVICE_SET_VALUE_SCHEMA}, + SERVICE_INCREMENT: { + 'method': 'async_increment', + 'schema': SERVICE_DEFAULT_SCHEMA}, + SERVICE_DECREMENT: { + 'method': 'async_decrement', + 'schema': SERVICE_DEFAULT_SCHEMA}, +} + + @bind_hass def set_value(hass, entity_id, value): """Set input_number to value.""" @@ -86,6 +106,22 @@ def set_value(hass, entity_id, value): }) +@bind_hass +def increment(hass, entity_id): + """Increment value of entity.""" + hass.services.call(DOMAIN, SERVICE_INCREMENT, { + ATTR_ENTITY_ID: entity_id + }) + + +@bind_hass +def decrement(hass, entity_id): + """Decrement value of entity.""" + hass.services.call(DOMAIN, SERVICE_DECREMENT, { + ATTR_ENTITY_ID: entity_id + }) + + @asyncio.coroutine def async_setup(hass, config): """Set up an input slider.""" @@ -111,25 +147,39 @@ def async_setup(hass, config): return False @asyncio.coroutine - def async_set_value_service(call): - """Handle a calls to the input slider services.""" - target_inputs = component.async_extract_from_service(call) + def async_handle_service(service): + """Handle calls to input_number services.""" + target_inputs = component.async_extract_from_service(service) + method = SERVICE_TO_METHOD.get(service.service) + params = service.data.copy() + params.pop(ATTR_ENTITY_ID, None) - tasks = [input_number.async_set_value(call.data[ATTR_VALUE]) - for input_number in target_inputs] - if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) + # call method + update_tasks = [] + for target_input in target_inputs: + yield from getattr(target_input, method['method'])(**params) + if not target_input.should_poll: + continue + update_tasks.append(target_input.async_update_ha_state(True)) - hass.services.async_register( - DOMAIN, SERVICE_SET_VALUE, async_set_value_service, - schema=SERVICE_SET_VALUE_SCHEMA) + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml')) + + for service, data in SERVICE_TO_METHOD.items(): + hass.services.async_register( + DOMAIN, service, async_handle_service, + description=descriptions[DOMAIN][service], schema=data['schema']) yield from component.async_add_entities(entities) return True class InputNumber(Entity): - """Represent an slider.""" + """Representation of a slider.""" def __init__(self, object_id, name, initial, minimum, maximum, step, icon, unit, mode): @@ -204,3 +254,25 @@ class InputNumber(Entity): return self._current_value = num_value yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_increment(self): + """Increment value.""" + new_value = self._current_value + self._step + if new_value > self._maximum: + _LOGGER.warning("Invalid value: %s (range %s - %s)", + new_value, self._minimum, self._maximum) + return + self._current_value = new_value + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_decrement(self): + """Decrement value.""" + new_value = self._current_value - self._step + if new_value < self._minimum: + _LOGGER.warning("Invalid value: %s (range %s - %s)", + new_value, self._minimum, self._maximum) + return + self._current_value = new_value + yield from self.async_update_ha_state() diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py index 583181fe453..a9df7c15ea3 100755 --- a/homeassistant/components/input_text.py +++ b/homeassistant/components/input_text.py @@ -4,12 +4,14 @@ Component to offer a way to enter a value into a text box. For more details about this component, please refer to the documentation at https://home-assistant.io/components/input_text/ """ +import os import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME) from homeassistant.loader import bind_hass @@ -110,8 +112,13 @@ def async_setup(hass, config): if tasks: yield from asyncio.wait(tasks, loop=hass.loop) + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml')) + hass.services.async_register( DOMAIN, SERVICE_SET_VALUE, async_set_value_service, + description=descriptions[DOMAIN][SERVICE_SET_VALUE], schema=SERVICE_SET_VALUE_SCHEMA) yield from component.async_add_entities(entities) diff --git a/homeassistant/components/introduction.py b/homeassistant/components/introduction.py index 367eeb1a6c3..cc3e00c4475 100644 --- a/homeassistant/components/introduction.py +++ b/homeassistant/components/introduction.py @@ -4,6 +4,7 @@ Component that will help guide the user taking its first steps. For more details about this component, please refer to the documentation at https://home-assistant.io/components/introduction/ """ +import asyncio import logging import voluptuous as vol @@ -15,7 +16,8 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -def setup(hass, config=None): +@asyncio.coroutine +def async_setup(hass, config=None): """Set up the introduction component.""" log = logging.getLogger(__name__) log.info(""" @@ -46,4 +48,16 @@ def setup(hass, config=None): ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ """) + hass.components.persistent_notification.async_create(""" +Here are some resources to get started: + + - [Configuring Home Assistant](https://home-assistant.io/getting-started/configuration/) + - [Available components](https://home-assistant.io/components/) + - [Troubleshooting your configuration](https://home-assistant.io/docs/configuration/troubleshooting/) + - [Getting help](https://home-assistant.io/help/) + +To not see this card popup in the future, edit your config in +`configuration.yaml` and disable the `introduction` component. +""", 'Welcome Home!') # noqa + return True diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 84917ead37a..feacf34bfe8 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -23,7 +23,7 @@ from homeassistant.components.light import ( SUPPORT_XY_COLOR, Light, PLATFORM_SCHEMA) from homeassistant.config import load_yaml_config_file from homeassistant.const import (CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME) -from homeassistant.components.emulated_hue import ATTR_EMULATED_HUE +from homeassistant.components.emulated_hue import ATTR_EMULATED_HUE_HIDDEN import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['phue==1.0'] @@ -471,7 +471,8 @@ class HueLight(Light): """Return the device state attributes.""" attributes = {} if not self.allow_in_emulated_hue: - attributes[ATTR_EMULATED_HUE] = self.allow_in_emulated_hue + attributes[ATTR_EMULATED_HUE_HIDDEN] = \ + not self.allow_in_emulated_hue if self.is_group: attributes[ATTR_IS_HUE_GROUP] = self.is_group return attributes diff --git a/homeassistant/components/light/hyperion.py b/homeassistant/components/light/hyperion.py index ec91ba582fb..88bdc1a4c95 100644 --- a/homeassistant/components/light/hyperion.py +++ b/homeassistant/components/light/hyperion.py @@ -18,10 +18,12 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) CONF_DEFAULT_COLOR = 'default_color' +CONF_PRIORITY = 'priority' DEFAULT_COLOR = [255, 255, 255] DEFAULT_NAME = 'Hyperion' DEFAULT_PORT = 19444 +DEFAULT_PRIORITY = 128 SUPPORT_HYPERION = SUPPORT_RGB_COLOR @@ -32,6 +34,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(list, vol.Length(min=3, max=3), [vol.All(vol.Coerce(int), vol.Range(min=0, max=255))]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PRIORITY, default=DEFAULT_PRIORITY): cv.positive_int, }) @@ -39,9 +42,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a Hyperion server remote.""" host = config.get(CONF_HOST) port = config.get(CONF_PORT) + priority = config.get(CONF_PRIORITY) default_color = config.get(CONF_DEFAULT_COLOR) - device = Hyperion(config.get(CONF_NAME), host, port, default_color) + device = Hyperion(config.get(CONF_NAME), host, port, priority, + default_color) if device.setup(): add_devices([device]) @@ -52,11 +57,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class Hyperion(Light): """Representation of a Hyperion remote.""" - def __init__(self, name, host, port, default_color): + def __init__(self, name, host, port, priority, default_color): """Initialize the light.""" self._host = host self._port = port self._name = name + self._priority = priority self._default_color = default_color self._rgb_color = [0, 0, 0] @@ -87,8 +93,11 @@ class Hyperion(Light): else: self._rgb_color = self._default_color - self.json_request( - {'command': 'color', 'priority': 128, 'color': self._rgb_color}) + self.json_request({ + 'command': 'color', + 'priority': self._priority, + 'color': self._rgb_color + }) def turn_off(self, **kwargs): """Disconnect all remotes.""" diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index a66cecd3ef8..1e5c0f743bb 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -51,6 +51,7 @@ CONF_WHITE_VALUE_COMMAND_TOPIC = 'white_value_command_topic' 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' DEFAULT_BRIGHTNESS_SCALE = 255 DEFAULT_NAME = 'MQTT Light' @@ -58,6 +59,9 @@ DEFAULT_OPTIMISTIC = False DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_WHITE_VALUE_SCALE = 255 +DEFAULT_ON_COMMAND_TYPE = 'last' + +VALUES_ON_COMMAND_TYPE = ['first', 'last', 'brightness'] PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BRIGHTNESS_COMMAND_TOPIC): mqtt.valid_publish_topic, @@ -89,6 +93,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_XY_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_XY_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_XY_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE): + vol.In(VALUES_ON_COMMAND_TYPE), }) @@ -141,6 +147,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_OPTIMISTIC), config.get(CONF_BRIGHTNESS_SCALE), config.get(CONF_WHITE_VALUE_SCALE), + config.get(CONF_ON_COMMAND_TYPE), )]) @@ -149,7 +156,7 @@ class MqttLight(Light): def __init__(self, name, effect_list, topic, templates, qos, retain, payload, optimistic, brightness_scale, - white_value_scale): + white_value_scale, on_command_type): """Initialize MQTT light.""" self._name = name self._effect_list = effect_list @@ -173,6 +180,7 @@ class MqttLight(Light): optimistic or topic[CONF_XY_STATE_TOPIC] is None self._brightness_scale = brightness_scale self._white_value_scale = white_value_scale + self._on_command_type = on_command_type self._state = False self._brightness = None self._rgb = None @@ -397,6 +405,20 @@ class MqttLight(Light): """ should_update = False + if self._on_command_type == 'first': + mqtt.async_publish( + self.hass, self._topic[CONF_COMMAND_TOPIC], + self._payload['on'], self._qos, self._retain) + should_update = True + + # If brightness is being used instead of an on command, make sure + # there is a brightness input. Either set the brightness to our + # saved value or the maximum value if this is the first call + elif self._on_command_type == 'brightness': + if ATTR_BRIGHTNESS not in kwargs: + kwargs[ATTR_BRIGHTNESS] = self._brightness if \ + self._brightness else 255 + if ATTR_RGB_COLOR in kwargs and \ self._topic[CONF_RGB_COMMAND_TOPIC] is not None: @@ -408,6 +430,7 @@ class MqttLight(Light): rgb_color_str = tpl.async_render(variables) else: rgb_color_str = '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]) + mqtt.async_publish( self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], rgb_color_str, self._qos, self._retain) @@ -434,6 +457,7 @@ class MqttLight(Light): mqtt.async_publish( self.hass, self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC], color_temp, self._qos, self._retain) + if self._optimistic_color_temp: self._color_temp = kwargs[ATTR_COLOR_TEMP] should_update = True @@ -445,6 +469,7 @@ class MqttLight(Light): mqtt.async_publish( self.hass, self._topic[CONF_EFFECT_COMMAND_TOPIC], effect, self._qos, self._retain) + if self._optimistic_effect: self._effect = kwargs[ATTR_EFFECT] should_update = True @@ -473,9 +498,10 @@ class MqttLight(Light): self._xy = kwargs[ATTR_XY_COLOR] should_update = True - mqtt.async_publish( - self.hass, self._topic[CONF_COMMAND_TOPIC], self._payload['on'], - self._qos, self._retain) + if self._on_command_type == 'last': + mqtt.async_publish(self.hass, self._topic[CONF_COMMAND_TOPIC], + self._payload['on'], self._qos, self._retain) + should_update = True if self._optimistic: # Optimistically assume that switch has changed state. diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 782d4496442..23814f16598 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -1,201 +1,160 @@ # Describes the format for available light services turn_on: - description: Turn a light on - + description: Turn a light on. fields: entity_id: description: Name(s) of entities to turn on example: 'light.kitchen' - transition: description: Duration in seconds it takes to get to next state example: 60 - rgb_color: - description: Color for the light in RGB-format + description: Color for the light in RGB-format. example: '[255, 100, 100]' - color_name: - description: A human readable color name + description: A human readable color name. example: 'red' - xy_color: - description: Color for the light in XY-format + description: Color for the light in XY-format. example: '[0.52, 0.43]' - color_temp: - description: Color temperature for the light in mireds + description: Color temperature for the light in mireds. example: 250 - kelvin: - description: Color temperature for the light in Kelvin + description: Color temperature for the light in Kelvin. example: 4000 - white_value: - description: Number between 0..255 indicating level of white + description: Number between 0..255 indicating level of white. example: '250' - brightness: - description: Number between 0..255 indicating brightness + description: Number between 0..255 indicating brightness. example: 120 - brightness_pct: - description: Number between 0..100 indicating percentage of full brightness + description: Number between 0..100 indicating percentage of full brightness. example: 47 - profile: - description: Name of a light profile to use + description: Name of a light profile to use. example: relax - flash: - description: If the light should flash + description: If the light should flash. values: - short - long - effect: - description: Light effect + description: Light effect. values: - colorloop - random turn_off: - description: Turn a light off - + description: Turn a light off. fields: entity_id: - description: Name(s) of entities to turn off + description: Name(s) of entities to turn off. example: 'light.kitchen' - transition: - description: Duration in seconds it takes to get to next state + description: Duration in seconds it takes to get to next state. example: 60 - flash: - description: If the light should flash + description: If the light should flash. values: - short - long toggle: - description: Toggles a light - + description: Toggles a light. fields: entity_id: - description: Name(s) of entities to toggle + description: Name(s) of entities to toggle. example: 'light.kitchen' - transition: - description: Duration in seconds it takes to get to next state + description: Duration in seconds it takes to get to next state. example: 60 hue_activate_scene: - description: Activate a hue scene stored in the hue hub - + description: Activate a hue scene stored in the hue hub. fields: group_name: - description: Name of hue group/room from the hue app + description: Name of hue group/room from the hue app. example: "Living Room" - scene_name: - description: Name of hue scene from the hue app + description: Name of hue scene from the hue app. example: "Energize" lifx_set_state: - description: Set a color/brightness and possibliy turn the light on/off - + description: Set a color/brightness and possibliy turn the light on/off. fields: entity_id: - description: Name(s) of entities to set a state on + description: Name(s) of entities to set a state on. example: 'light.garage' - '...': - description: All turn_on parameters can be used to specify a color - + description: All turn_on parameters can be used to specify a color. infrared: - description: Automatic infrared level (0..255) when light brightness is low + description: Automatic infrared level (0..255) when light brightness is low. example: 255 - zones: - description: List of zone numbers to affect (8 per LIFX Z, starts at 0) + description: List of zone numbers to affect (8 per LIFX Z, starts at 0). example: '[0,5]' - transition: - description: Duration in seconds it takes to get to the final state + description: Duration in seconds it takes to get to the final state. example: 10 - power: description: Turn the light on (True) or off (False). Leave out to keep the power as it is. example: True lifx_effect_pulse: description: Run a flash effect by changing to a color and back. - fields: entity_id: - description: Name(s) of entities to run the effect on + description: Name(s) of entities to run the effect on. example: 'light.kitchen' - mode: - description: 'Decides how colors are changed. Possible values: blink, breathe, ping, strobe, solid' + description: 'Decides how colors are changed. Possible values: blink, breathe, ping, strobe, solid.' example: strobe - brightness: - description: Number between 0..255 indicating brightness of the temporary color + description: Number between 0..255 indicating brightness of the temporary color. example: 120 - color_name: - description: A human readable color name + description: A human readable color name. example: 'red' - rgb_color: - description: The temporary color in RGB-format + description: The temporary color in RGB-format. example: '[255, 100, 100]' - period: - description: Duration of the effect in seconds (default 1.0) + description: Duration of the effect in seconds (default 1.0). example: 3 - cycles: - description: Number of times the effect should run (default 1.0) + description: Number of times the effect should run (default 1.0). example: 2 - power_on: - description: Powered off lights are temporarily turned on during the effect (default True) + description: Powered off lights are temporarily turned on during the effect (default True). example: False lifx_effect_colorloop: description: Run an effect with looping colors. - fields: entity_id: - description: Name(s) of entities to run the effect on + description: Name(s) of entities to run the effect on. example: 'light.disco1, light.disco2, light.disco3' - brightness: - description: Number between 0 and 255 indicating brightness of the effect. Leave this out to maintain the current brightness of each participating light + description: Number between 0 and 255 indicating brightness of the effect. Leave this out to maintain the current brightness of each participating light. example: 120 - period: - description: Duration (in seconds) between color changes (default 60) + description: Duration (in seconds) between color changes (default 60). example: 180 - change: - description: Hue movement per period, in degrees on a color wheel (ranges from 0 to 360, default 20) + description: Hue movement per period, in degrees on a color wheel (ranges from 0 to 360, default 20). example: 45 - spread: - description: Maximum hue difference between participating lights, in degrees on a color wheel (ranges from 0 to 360, default 30) + description: Maximum hue difference between participating lights, in degrees on a color wheel (ranges from 0 to 360, default 30). example: 0 - power_on: - description: Powered off lights are temporarily turned on during the effect (default True) + description: Powered off lights are temporarily turned on during the effect (default True). example: False lifx_effect_stop: description: Stop a running effect. - fields: entity_id: description: Name(s) of entities to stop effects on. Leave out to stop effects everywhere. diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index b2a9e97f11e..465e84fae90 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -13,8 +13,10 @@ from homeassistant.core import callback from homeassistant.components.light import ( ATTR_BRIGHTNESS, ENTITY_ID_FORMAT, Light, SUPPORT_BRIGHTNESS) from homeassistant.const import ( - CONF_VALUE_TEMPLATE, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, STATE_ON, - STATE_OFF, EVENT_HOMEASSISTANT_START, MATCH_ALL, CONF_LIGHTS) + CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_ENTITY_PICTURE_TEMPLATE, + CONF_ENTITY_ID, CONF_FRIENDLY_NAME, STATE_ON, STATE_OFF, + EVENT_HOMEASSISTANT_START, MATCH_ALL, CONF_LIGHTS +) from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv @@ -34,6 +36,8 @@ LIGHT_SCHEMA = vol.Schema({ vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_VALUE_TEMPLATE, default=None): cv.template, + vol.Optional(CONF_ICON_TEMPLATE, default=None): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE, default=None): cv.template, vol.Optional(CONF_LEVEL_ACTION, default=None): cv.SCRIPT_SCHEMA, vol.Optional(CONF_LEVEL_TEMPLATE, default=None): cv.template, vol.Optional(CONF_FRIENDLY_NAME, default=None): cv.string, @@ -53,6 +57,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for device, device_config in config[CONF_LIGHTS].items(): friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) state_template = device_config[CONF_VALUE_TEMPLATE] + icon_template = device_config.get(CONF_ICON_TEMPLATE) + entity_picture_template = device_config.get( + CONF_ENTITY_PICTURE_TEMPLATE) on_action = device_config[CONF_ON_ACTION] off_action = device_config[CONF_OFF_ACTION] level_action = device_config.get(CONF_LEVEL_ACTION) @@ -70,6 +77,16 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if str(temp_ids) != MATCH_ALL: template_entity_ids |= set(temp_ids) + if icon_template is not None: + temp_ids = icon_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if entity_picture_template is not None: + temp_ids = entity_picture_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + if not template_entity_ids: template_entity_ids = MATCH_ALL @@ -78,8 +95,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): lights.append( LightTemplate( hass, device, friendly_name, state_template, - on_action, off_action, level_action, level_template, - entity_ids) + icon_template, entity_picture_template, on_action, + off_action, level_action, level_template, entity_ids) ) if not lights: @@ -94,14 +111,16 @@ class LightTemplate(Light): """Representation of a templated Light, including dimmable.""" def __init__(self, hass, device_id, friendly_name, state_template, - on_action, off_action, level_action, level_template, - entity_ids): + icon_template, entity_picture_template, on_action, + off_action, level_action, level_template, entity_ids): """Initialize the light.""" self.hass = hass self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, device_id, hass=hass) self._name = friendly_name self._template = state_template + self._icon_template = icon_template + self._entity_picture_template = entity_picture_template self._on_script = Script(hass, on_action) self._off_script = Script(hass, off_action) self._level_script = None @@ -110,6 +129,8 @@ class LightTemplate(Light): self._level_template = level_template self._state = False + self._icon = None + self._entity_picture = None self._brightness = None self._entities = entity_ids @@ -117,6 +138,10 @@ class LightTemplate(Light): self._template.hass = self.hass if self._level_template is not None: self._level_template.hass = self.hass + if self._icon_template is not None: + self._icon_template.hass = self.hass + if self._entity_picture_template is not None: + self._entity_picture_template.hass = self.hass @property def brightness(self): @@ -146,6 +171,16 @@ class LightTemplate(Light): """Return the polling state.""" return False + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + return self._entity_picture + @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" @@ -186,7 +221,7 @@ class LightTemplate(Light): self.hass.async_add_job(self._level_script.async_run( {"brightness": kwargs[ATTR_BRIGHTNESS]})) else: - self.hass.async_add_job(self._on_script.async_run()) + yield from self._on_script.async_run() if optimistic_set: self.async_schedule_update_ha_state() @@ -194,7 +229,7 @@ class LightTemplate(Light): @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn the light off.""" - self.hass.async_add_job(self._off_script.async_run()) + yield from self._off_script.async_run() if self._template is None: self._state = False self.async_schedule_update_ha_state() @@ -234,3 +269,28 @@ class LightTemplate(Light): 'Expected: 0-255', brightness) self._brightness = None + + for property_name, template in ( + ('_icon', self._icon_template), + ('_entity_picture', self._entity_picture_template)): + if template is None: + continue + + try: + setattr(self, property_name, template.async_render()) + except TemplateError as ex: + friendly_property_name = property_name[1:].replace('_', ' ') + if ex.args and ex.args[0].startswith( + "UndefinedError: 'None' has no attribute"): + # Common during HA startup - so just a warning + _LOGGER.warning('Could not render %s template %s,' + ' the state is unknown.', + friendly_property_name, self._name) + return + + try: + setattr(self, property_name, + getattr(super(), property_name)) + except AttributeError: + _LOGGER.error('Could not render %s template %s: %s', + friendly_property_name, self._name, ex) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index ff9201d49b9..c71ca60ee03 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -161,14 +161,18 @@ class TradfriLight(Light): @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" - from pytradfri.color import MAX_KELVIN_WS - return color_util.color_temperature_kelvin_to_mired(MAX_KELVIN_WS) + if self._light_control.max_kelvin is not None: + return color_util.color_temperature_kelvin_to_mired( + self._light_control.max_kelvin + ) @property def max_mireds(self): """Return the warmest color_temp that this light supports.""" - from pytradfri.color import MIN_KELVIN_WS - return color_util.color_temperature_kelvin_to_mired(MIN_KELVIN_WS) + if self._light_control.min_kelvin is not None: + return color_util.color_temperature_kelvin_to_mired( + self._light_control.min_kelvin + ) @property def device_state_attributes(self): @@ -217,13 +221,11 @@ class TradfriLight(Light): @property def color_temp(self): """Return the CT color value in mireds.""" - if (self._light_data.kelvin_color is None or - self.supported_features & SUPPORT_COLOR_TEMP == 0 or - not self._temp_supported): - return None - return color_util.color_temperature_kelvin_to_mired( - self._light_data.kelvin_color - ) + kelvin_color = self._light_data.kelvin_color_inferred + if kelvin_color is not None: + return color_util.color_temperature_kelvin_to_mired( + kelvin_color + ) @property def rgb_color(self): @@ -297,10 +299,13 @@ class TradfriLight(Light): self._rgb_color = None self._features = SUPPORTED_FEATURES - if self._light_data.hex_color is not None: - if self._light.device_info.manufacturer == IKEA: + if self._light.device_info.manufacturer == IKEA: + if self._light_control.can_set_kelvin: self._features |= SUPPORT_COLOR_TEMP - else: + if self._light_control.can_set_color: + self._features |= SUPPORT_RGB_COLOR + else: + if self._light_data.hex_color is not None: self._features |= SUPPORT_RGB_COLOR self._temp_supported = self._light.device_info.manufacturer \ @@ -309,11 +314,7 @@ class TradfriLight(Light): def _observe_update(self, tradfri_device): """Receive new state data for this light.""" self._refresh(tradfri_device) - - # Handle Hue lights paired with the gateway - # hex_color is 0 when bulb is unreachable - if self._light_data.hex_color not in (None, '0'): - self._rgb_color = color_util.rgb_hex_to_rgb_list( - self._light_data.hex_color) - + self._rgb_color = color_util.rgb_hex_to_rgb_list( + self._light_data.hex_color_inferred + ) self.hass.async_add_job(self.async_update_ha_state()) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index cebd1670c4a..b25f2745365 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -1,5 +1,5 @@ """ -Support for Xiaomi Philips Lights (LED Ball & Ceil). +Support for Xiaomi Philips Lights (LED Ball & Ceiling Lamp, Eyecare Lamp 2). For more details about this platform, please refer to the documentation https://home-assistant.io/components/light.xiaomi_philipslight/ @@ -21,14 +21,14 @@ from homeassistant.exceptions import PlatformNotReady _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Philips Light' -PLATFORM = 'xiaomi_miio' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-mirobo==0.2.0'] +REQUIREMENTS = ['python-miio==0.3.0'] # The light does not accept cct values < 1 CCT_MIN = 1 @@ -42,9 +42,7 @@ ATTR_MODEL = 'model' @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the light from config.""" - from mirobo import Ceil, DeviceException - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} + from miio import Device, DeviceException host = config.get(CONF_HOST) name = config.get(CONF_NAME) @@ -52,23 +50,43 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + devices = [] try: - light = Ceil(host, token) + light = Device(host, token) device_info = light.info() _LOGGER.info("%s %s %s initialized", - device_info.raw['model'], - device_info.raw['fw_ver'], - device_info.raw['hw_ver']) + device_info.model, + device_info.firmware_version, + device_info.hardware_version) + + if device_info.model == 'philips.light.sread1': + from miio import PhilipsEyecare + light = PhilipsEyecare(host, token) + device = XiaomiPhilipsEyecareLamp(name, light, device_info) + devices.append(device) + elif device_info.model == 'philips.light.ceil': + from miio import Ceil + light = Ceil(host, token) + device = XiaomiPhilipsCeilingLamp(name, light, device_info) + devices.append(device) + elif device_info.model == 'philips.light.bulb': + from miio import Ceil + light = Ceil(host, token) + device = XiaomiPhilipsLightBall(name, light, device_info) + devices.append(device) + else: + _LOGGER.error( + 'Unsupported device found! Please create an issue at ' + 'https://github.com/rytilahti/python-miio/issues ' + 'and provide the following data: %s', device_info.model) - philips_light = XiaomiPhilipsLight(name, light, device_info) - hass.data[PLATFORM][host] = philips_light except DeviceException: raise PlatformNotReady - async_add_devices([philips_light], update_before_add=True) + async_add_devices(devices, update_before_add=True) -class XiaomiPhilipsLight(Light): +class XiaomiPhilipsGenericLight(Light): """Representation of a Xiaomi Philips Light.""" def __init__(self, name, light, device_info): @@ -82,7 +100,7 @@ class XiaomiPhilipsLight(Light): self._light = light self._state = None self._state_attrs = { - ATTR_MODEL: self._device_info.raw['model'], + ATTR_MODEL: self._device_info.model, } @property @@ -115,30 +133,15 @@ class XiaomiPhilipsLight(Light): """Return the brightness of this light between 0..255.""" return self._brightness - @property - def color_temp(self): - """Return the color temperature.""" - return self._color_temp - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return 175 - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return 333 - @property def supported_features(self): """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + return SUPPORT_BRIGHTNESS @asyncio.coroutine def _try_command(self, mask_error, func, *args, **kwargs): """Call a light command handling error messages.""" - from mirobo import DeviceException + from miio import DeviceException try: result = yield from self.hass.async_add_job( partial(func, *args, **kwargs)) @@ -168,6 +171,68 @@ class XiaomiPhilipsLight(Light): if result: self._brightness = brightness + self._state = yield from self._try_command( + "Turning the light on failed.", self._light.on) + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + self._state = yield from self._try_command( + "Turning the light off failed.", self._light.off) + + @asyncio.coroutine + def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + try: + state = yield from self.hass.async_add_job(self._light.status) + _LOGGER.debug("Got new state: %s", state) + + self._state = state.is_on + self._brightness = int(255 * 0.01 * state.brightness) + + except DeviceException as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) + + @staticmethod + def translate(value, left_min, left_max, right_min, right_max): + """Map a value from left span to right span.""" + left_span = left_max - left_min + right_span = right_max - right_min + value_scaled = float(value - left_min) / float(left_span) + return int(right_min + (value_scaled * right_span)) + + +class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): + """Representation of a Xiaomi Philips Light Ball.""" + + def __init__(self, name, light, device_info): + """Initialize the light device.""" + super().__init__(name, light, device_info) + + @property + def color_temp(self): + """Return the color temperature.""" + return self._color_temp + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 175 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 333 + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" if ATTR_COLOR_TEMP in kwargs: color_temp = kwargs[ATTR_COLOR_TEMP] percent_color_temp = self.translate( @@ -186,42 +251,64 @@ class XiaomiPhilipsLight(Light): if result: self._color_temp = color_temp - result = yield from self._try_command( + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = int(100 * brightness / 255) + + _LOGGER.debug( + "Setting brightness: %s %s%%", + self.brightness, percent_brightness) + + result = yield from self._try_command( + "Setting brightness failed: %s", + self._light.set_brightness, percent_brightness) + + if result: + self._brightness = brightness + + self._state = yield from self._try_command( "Turning the light on failed.", self._light.on) - if result: - self._state = True - - @asyncio.coroutine - def async_turn_off(self, **kwargs): - """Turn the light off.""" - result = yield from self._try_command( - "Turning the light off failed.", self._light.off) - - if result: - self._state = True - @asyncio.coroutine def async_update(self): """Fetch state from the device.""" - from mirobo import DeviceException + from miio import DeviceException try: state = yield from self.hass.async_add_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._state = state.is_on self._brightness = int(255 * 0.01 * state.brightness) - self._color_temp = self.translate(state.color_temperature, - CCT_MIN, CCT_MAX, - self.max_mireds, self.min_mireds) + self._color_temp = self.translate( + state.color_temperature, + CCT_MIN, CCT_MAX, + self.max_mireds, self.min_mireds) except DeviceException as ex: _LOGGER.error("Got exception while fetching the state: %s", ex) - @staticmethod - def translate(value, left_min, left_max, right_min, right_max): - """Map a value from left span to right span.""" - left_span = left_max - left_min - right_span = right_max - right_min - value_scaled = float(value - left_min) / float(left_span) - return int(right_min + (value_scaled * right_span)) + +class XiaomiPhilipsCeilingLamp(XiaomiPhilipsLightBall, Light): + """Representation of a Xiaomi Philips Ceiling Lamp.""" + + def __init__(self, name, light, device_info): + """Initialize the light device.""" + super().__init__(name, light, device_info) + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 175 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 370 + + +class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight, Light): + """Representation of a Xiaomi Philips Eyecare Lamp 2.""" + + def __init__(self, name, light, device_info): + """Initialize the light device.""" + super().__init__(name, light, device_info) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 4c472a0a78f..126318f187f 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -13,12 +13,15 @@ import voluptuous as vol from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_to_rgb) + color_temperature_to_rgb, + color_RGB_to_xy, + color_xy_brightness_to_RGB) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_COLOR_TEMP, - ATTR_FLASH, FLASH_SHORT, FLASH_LONG, ATTR_EFFECT, - SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, + ATTR_FLASH, ATTR_XY_COLOR, FLASH_SHORT, FLASH_LONG, ATTR_EFFECT, + SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_XY_COLOR, + SUPPORT_TRANSITION, SUPPORT_COLOR_TEMP, SUPPORT_FLASH, SUPPORT_EFFECT, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv @@ -51,6 +54,7 @@ SUPPORT_YEELIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_YEELIGHT_RGB = (SUPPORT_YEELIGHT | SUPPORT_RGB_COLOR | + SUPPORT_XY_COLOR | SUPPORT_EFFECT | SUPPORT_COLOR_TEMP) @@ -154,6 +158,7 @@ class YeelightLight(Light): self._color_temp = None self._is_on = None self._rgb = None + self._xy = None @property def available(self) -> bool: @@ -236,6 +241,11 @@ class YeelightLight(Light): """Return the color property.""" return self._rgb + @property + def xy_color(self) -> tuple: + """Return the XY color value.""" + return self._xy + @property def _properties(self) -> dict: return self._bulb.last_properties @@ -284,6 +294,12 @@ class YeelightLight(Light): self._rgb = self._get_rgb_from_properties() + if self._rgb: + xyb = color_RGB_to_xy(*self._rgb) + self._xy = (xyb[0], xyb[1]) + else: + self._xy = None + self._available = True except yeelight.BulbException as ex: if self._available: # just inform once @@ -410,6 +426,7 @@ class YeelightLight(Light): rgb = kwargs.get(ATTR_RGB_COLOR) flash = kwargs.get(ATTR_FLASH) effect = kwargs.get(ATTR_EFFECT) + xy_color = kwargs.get(ATTR_XY_COLOR) duration = int(self.config[CONF_TRANSITION]) # in ms if ATTR_TRANSITION in kwargs: # passed kwarg overrides config @@ -427,6 +444,9 @@ class YeelightLight(Light): except yeelight.BulbException as ex: _LOGGER.error("Unable to turn on music mode," "consider disabling it: %s", ex) + if xy_color and brightness: + rgb = color_xy_brightness_to_RGB(xy_color[0], xy_color[1], + brightness) try: # values checked for none in methods diff --git a/homeassistant/components/linode.py b/homeassistant/components/linode.py new file mode 100644 index 00000000000..9e87c002482 --- /dev/null +++ b/homeassistant/components/linode.py @@ -0,0 +1,98 @@ +""" +Support for Linode. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/linode/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['linode-api==4.1.4b2'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_CREATED = 'created' +ATTR_NODE_ID = 'node_id' +ATTR_NODE_NAME = 'node_name' +ATTR_IPV4_ADDRESS = 'ipv4_address' +ATTR_IPV6_ADDRESS = 'ipv6_address' +ATTR_MEMORY = 'memory' +ATTR_REGION = 'region' +ATTR_VCPUS = 'vcpus' + +CONF_NODES = 'nodes' + +DATA_LINODE = 'data_li' +LINODE_PLATFORMS = ['binary_sensor', 'switch'] +DOMAIN = 'linode' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Linode component.""" + import linode + + conf = config[DOMAIN] + access_token = conf.get(CONF_ACCESS_TOKEN) + + _linode = Linode(access_token) + + try: + _LOGGER.info("Linode Profile %s", + _linode.manager.get_profile().username) + except linode.errors.ApiError as _ex: + _LOGGER.error(_ex) + return False + + hass.data[DATA_LINODE] = _linode + + return True + + +class Linode(object): + """Handle all communication with the Linode API.""" + + def __init__(self, access_token): + """Initialize the Linode connection.""" + import linode + + self._access_token = access_token + self.data = None + self.manager = linode.LinodeClient(token=self._access_token) + + def get_node_id(self, node_name): + """Get the status of a Linode Instance.""" + import linode + node_id = None + + try: + all_nodes = self.manager.linode.get_instances() + for node in all_nodes: + if node_name == node.label: + node_id = node.id + except linode.errors.ApiError as _ex: + _LOGGER.error(_ex) + + return node_id + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Use the data from Linode API.""" + import linode + try: + self.data = self.manager.linode.get_instances() + except linode.errors.ApiError as _ex: + _LOGGER.error(_ex) diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index 810ef5a2e5b..0b4688c02a2 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -1,86 +1,80 @@ -clear_usercode: - description: Clear a usercode from lock +# Describes the format for available lock services +clear_usercode: + description: Clear a usercode from lock. fields: node_id: - description: Node id of the lock + description: Node id of the lock. example: 18 code_slot: - description: Code slot to clear code from + description: Code slot to clear code from. example: 1 get_usercode: - description: Retrieve a usercode from lock - + description: Retrieve a usercode from lock. fields: node_id: - description: Node id of the lock + description: Node id of the lock. example: 18 code_slot: - description: Code slot to retrieve a code from + description: Code slot to retrieve a code from. example: 1 nuki_lock_n_go: - description: "Lock 'n' Go" - + description: "Nuki Lock 'n' Go" fields: entity_id: - description: Entity id of the Nuki lock + description: Entity id of the Nuki lock. example: 'lock.front_door' unlatch: - description: Whether to unlatch the lock + description: Whether to unlatch the lock. example: false nuki_unlatch: - description: "Unlatch" - + description: Nuki unlatch. fields: entity_id: - description: Entity id of the Nuki lock + description: Entity id of the Nuki lock. example: 'lock.front_door' lock: - description: Lock all or specified locks - + description: Lock all or specified locks. fields: entity_id: - description: Name of lock to lock + description: Name of lock to lock. example: 'lock.front_door' code: - description: An optional code to lock the lock with + description: An optional code to lock the lock with. example: 1234 set_usercode: - description: Set a usercode to lock - + description: Set a usercode to lock. fields: node_id: - description: Node id of the lock + description: Node id of the lock. example: 18 code_slot: - description: Code slot to set the code + description: Code slot to set the code. example: 1 usercode: - description: Code to set + description: Code to set. example: 1234 unlock: - description: Unlock all or specified locks - + description: Unlock all or specified locks. fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' code: - description: An optional code to unlock the lock with + description: An optional code to unlock the lock with. example: 1234 wink_set_lock_vacation_mode: description: Set vacation mode for all or specified locks. Disables all user codes. - fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' enabled: description: enable or disable. true or false. @@ -88,32 +82,29 @@ wink_set_lock_vacation_mode: wink_set_lock_alarm_mode: description: Set alarm mode for all or specified locks. - fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' mode: - description: One of tamper, activity, or forced_entry + description: One of tamper, activity, or forced_entry. example: tamper wink_set_lock_alarm_sensitivity: description: Set alarm sensitivity for all or specified locks. - fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' sensitivity: - description: One of low, medium_low, medium, medium_high, high + description: One of low, medium_low, medium, medium_high, high. example: medium wink_set_lock_alarm_state: description: Set alarm state. - fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' enabled: description: enable or disable. true or false. @@ -121,10 +112,9 @@ wink_set_lock_alarm_state: wink_set_lock_beeper_state: description: Set beeper state. - fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' enabled: description: enable or disable. true or false. @@ -132,10 +122,9 @@ wink_set_lock_beeper_state: wink_add_new_lock_key_code: description: Add a new user key code. - fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' name: description: name of the new key code. diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 4facf1334c6..63a271acdd5 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -15,7 +15,6 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util from homeassistant.components import sun -from homeassistant.components.frontend import register_built_in_panel from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, @@ -84,6 +83,7 @@ def async_log_entry(hass, name, message, domain=None, entity_id=None): hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, data) +@asyncio.coroutine def setup(hass, config): """Listen for download events to download files.""" @callback @@ -100,10 +100,10 @@ def setup(hass, config): hass.http.register_view(LogbookView(config.get(DOMAIN, {}))) - register_built_in_panel( - hass, 'logbook', 'Logbook', 'mdi:format-list-bulleted-type') + yield from hass.components.frontend.async_register_built_in_panel( + 'logbook', 'logbook', 'mdi:format-list-bulleted-type') - hass.services.register( + hass.services.async_register( DOMAIN, 'log', log_message, schema=LOG_MESSAGE_SCHEMA) return True diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 21b2dc7279f..4f999649a44 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -35,8 +35,8 @@ _LOGGER = logging.getLogger(__name__) def async_setup(hass, config): """Track states and offer events for mailboxes.""" mailboxes = [] - hass.components.frontend.register_built_in_panel( - 'mailbox', 'Mailbox', 'mdi:mailbox') + yield from hass.components.frontend.async_register_built_in_panel( + 'mailbox', 'mailbox', 'mdi:mailbox') hass.http.register_view(MailboxPlatformsView(mailboxes)) hass.http.register_view(MailboxMessageView(mailboxes)) hass.http.register_view(MailboxMediaView(mailboxes)) diff --git a/homeassistant/components/map.py b/homeassistant/components/map.py index a1b8f4cfdf3..2b204e584c3 100644 --- a/homeassistant/components/map.py +++ b/homeassistant/components/map.py @@ -6,13 +6,12 @@ https://home-assistant.io/components/map/ """ import asyncio -from homeassistant.components.frontend import register_built_in_panel - DOMAIN = 'map' @asyncio.coroutine def async_setup(hass, config): """Register the built-in map panel.""" - register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location') + yield from hass.components.frontend.async_register_built_in_panel( + 'map', 'map', 'mdi:account-location') return True diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 20b754f7560..9cee62c39f7 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/media_extractor/ """ import logging import os + import voluptuous as vol from homeassistant.components.media_player import ( @@ -15,16 +16,16 @@ from homeassistant.components.media_player import ( from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.10.12'] +REQUIREMENTS = ['youtube_dl==2017.10.29'] _LOGGER = logging.getLogger(__name__) -DOMAIN = 'media_extractor' -DEPENDENCIES = ['media_player'] - CONF_CUSTOMIZE_ENTITIES = 'customize' CONF_DEFAULT_STREAM_QUERY = 'default_query' + DEFAULT_STREAM_QUERY = 'best' +DEPENDENCIES = ['media_player'] +DOMAIN = 'media_extractor' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index d12c634884f..f037dfb708e 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -12,7 +12,7 @@ import logging import os from random import SystemRandom -from aiohttp import web +from aiohttp import web, hdrs import async_timeout import voluptuous as vol @@ -490,9 +490,8 @@ class MediaPlayerDevice(Entity): def media_image_hash(self): """Hash value for media image.""" url = self.media_image_url - if url is not None: - return hashlib.md5(url.encode('utf-8')).hexdigest()[:5] + return hashlib.sha256(url.encode('utf-8')).hexdigest()[:16] return None @@ -966,4 +965,8 @@ class MediaPlayerImageView(HomeAssistantView): if data is None: return web.Response(status=500) - return web.Response(body=data, content_type=content_type) + headers = {hdrs.CACHE_CONTROL: 'max-age=3600'} + return web.Response( + body=data, + content_type=content_type, + headers=headers) diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py index 8260fb94509..572405baa6e 100755 --- a/homeassistant/components/media_player/denon.py +++ b/homeassistant/components/media_player/denon.py @@ -78,7 +78,9 @@ class DenonDevice(MediaPlayerDevice): def _setup_sources(self, telnet): # NSFRN - Network name - self._name = self.telnet_request(telnet, 'NSFRN ?')[len('NSFRN '):] + nsfrn = self.telnet_request(telnet, 'NSFRN ?')[len('NSFRN '):] + if nsfrn: + self._name = nsfrn # SSFUN - Configured sources with names self._source_list = {} @@ -110,7 +112,7 @@ class DenonDevice(MediaPlayerDevice): if all_lines: return lines - return lines[0] + return lines[0] if lines else '' def telnet_command(self, command): """Establish a telnet connection and sends `command`.""" diff --git a/homeassistant/components/media_player/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 594e9b20432..15698ec5022 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -4,13 +4,13 @@ Support for interface with an Orange Livebox Play TV appliance. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.liveboxplaytv/ """ +import asyncio import logging from datetime import timedelta import requests import voluptuous as vol -import homeassistant.util as util from homeassistant.components.media_player import ( SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PLAY, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_PAUSED, CONF_NAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['liveboxplaytv==1.5.0'] +REQUIREMENTS = ['liveboxplaytv==2.0.0'] _LOGGER = logging.getLogger(__name__) @@ -43,8 +43,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Orange Livebox Play TV platform.""" host = config.get(CONF_HOST) port = config.get(CONF_PORT) @@ -58,7 +58,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): except IOError: _LOGGER.error("Failed to connect to Livebox Play TV at %s:%s. " "Please check your configuration", host, port) - add_devices(livebox_devices, True) + async_add_devices(livebox_devices, True) class LiveboxPlayTvDevice(MediaPlayerDevice): @@ -78,18 +78,28 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): self._current_program = None self._media_image_url = None - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update(self): + @asyncio.coroutine + def async_update(self): """Retrieve the latest data.""" try: self._state = self.refresh_state() # Update current channel - channel = self._client.get_current_channel() + channel = self._client.channel if channel is not None: - self._current_program = self._client.program - self._current_channel = channel.get('name', None) - self._media_image_url = \ - self._client.get_current_channel_image(img_size=300) + self._current_program = yield from \ + self._client.async_get_current_program_name() + self._current_channel = channel + # Set media image to current program if a thumbnail is + # available. Otherwise we'll use the channel's image. + img_size = 800 + prg_img_url = yield from \ + self._client.async_get_current_program_image(img_size) + if prg_img_url: + self._media_image_url = prg_img_url + else: + chan_img_url = \ + self._client.get_current_channel_image(img_size) + self._media_image_url = chan_img_url self.refresh_channel_list() except requests.ConnectionError: self._state = None @@ -136,8 +146,11 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): def media_title(self): """Title of current playing media.""" if self._current_channel: - return '{}: {}'.format(self._current_channel, - self._current_program) + if self._current_program: + return '{}: {}'.format(self._current_channel, + self._current_program) + else: + return self._current_channel @property def supported_features(self): diff --git a/homeassistant/components/media_player/monoprice.py b/homeassistant/components/media_player/monoprice.py index b9a25367660..10b4b8414d8 100644 --- a/homeassistant/components/media_player/monoprice.py +++ b/homeassistant/components/media_player/monoprice.py @@ -137,6 +137,11 @@ class MonopriceZone(MediaPlayerDevice): """Return flag of media commands that are supported.""" return SUPPORT_MONOPRICE + @property + def media_title(self): + """Return the current source as medial title.""" + return self._source + @property def source(self): """"Return the current input source of the device.""" diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 44dd9a7ea29..c661e2a3b58 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.mpd/ """ import logging +import os from datetime import timedelta import voluptuous as vol @@ -176,9 +177,13 @@ class MpdDevice(MediaPlayerDevice): """Return the title of current playing media.""" name = self._currentsong.get('name', None) title = self._currentsong.get('title', None) + file_name = self._currentsong.get('file', None) if name is None and title is None: - return "None" + if file_name is None: + return "None" + else: + return os.path.basename(file_name) elif name is None: return title elif title is None: diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 2bf35666873..4722a538fa9 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -6,9 +6,7 @@ https://home-assistant.io/components/media_player.plex/ """ import json import logging -import os from datetime import timedelta -from urllib.parse import urlparse import requests import voluptuous as vol @@ -23,8 +21,9 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['plexapi==2.0.2'] +REQUIREMENTS = ['plexapi==3.0.3'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -49,35 +48,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def config_from_file(filename, config=None): - """Small configuration file management function.""" - if config: - # We're writing configuration - try: - with open(filename, 'w') as fdesc: - fdesc.write(json.dumps(config)) - except IOError as error: - _LOGGER.error("Saving config file failed: %s", error) - return False - return True - else: - # We're reading config - if os.path.isfile(filename): - try: - with open(filename, 'r') as fdesc: - return json.loads(fdesc.read()) - except IOError as error: - _LOGGER.error("Reading config file failed: %s", error) - # This won't work yet - return False - else: - return {} - - def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the Plex platform.""" # get config from plex.conf - file_config = config_from_file(hass.config.path(PLEX_CONFIG_FILE)) + file_config = load_json(hass.config.path(PLEX_CONFIG_FILE)) if file_config: # Setup a configured PlexServer @@ -147,7 +121,7 @@ def setup_plexserver( _LOGGER.info("Discovery configuration done") # Save config - if not config_from_file( + if not save_json( hass.config.path(PLEX_CONFIG_FILE), {host: { 'token': token, 'ssl': has_ssl, @@ -183,7 +157,7 @@ def setup_plexserver( if device.machineIdentifier not in plex_clients: new_client = PlexClient(config, device, None, plex_sessions, update_devices, - update_sessions) + update_sessions, plexserver) plex_clients[device.machineIdentifier] = new_client new_plex_clients.append(new_client) else: @@ -196,7 +170,7 @@ def setup_plexserver( and machine_identifier is not None): new_client = PlexClient(config, None, session, plex_sessions, update_devices, - update_sessions) + update_sessions, plexserver) plex_clients[machine_identifier] = new_client new_plex_clients.append(new_client) else: @@ -225,9 +199,8 @@ def setup_plexserver( plex_sessions.clear() for session in sessions: - if (session.player is not None and - session.player.machineIdentifier is not None): - plex_sessions[session.player.machineIdentifier] = session + for player in session.players: + plex_sessions[player.machineIdentifier] = session update_sessions() update_devices() @@ -277,14 +250,15 @@ class PlexClient(MediaPlayerDevice): """Representation of a Plex device.""" def __init__(self, config, device, session, plex_sessions, - update_devices, update_sessions): + update_devices, update_sessions, plex_server): """Initialize the Plex device.""" - from plexapi.utils import NA self._app_name = '' + self._server = plex_server self._device = None self._device_protocol_capabilities = None self._is_player_active = False self._is_player_available = False + self._player = None self._machine_identifier = None self._make = '' self._name = None @@ -296,7 +270,6 @@ class PlexClient(MediaPlayerDevice): self._state = STATE_IDLE self._volume_level = 1 # since we can't retrieve remotely self._volume_muted = False # since we can't retrieve remotely - self.na_type = NA self.config = config self.plex_sessions = plex_sessions self.update_devices = update_devices @@ -353,42 +326,35 @@ class PlexClient(MediaPlayerDevice): self._session = session if device: self._device = device + if "127.0.0.1" in self._device.url("/"): + self._device.proxyThroughServer() self._session = None - - if self._device: - self._machine_identifier = self._convert_na_to_none( - self._device.machineIdentifier) - self._name = self._convert_na_to_none( - self._device.title) or DEVICE_DEFAULT_NAME + self._machine_identifier = self._device.machineIdentifier + self._name = self._device.title or DEVICE_DEFAULT_NAME self._device_protocol_capabilities = ( self._device.protocolCapabilities) - # set valid session, preferring device session - if self._device and self.plex_sessions.get( - self._device.machineIdentifier, None): - self._session = self._convert_na_to_none(self.plex_sessions.get( - self._device.machineIdentifier, None)) + # set valid session, preferring device session + if self.plex_sessions.get(self._device.machineIdentifier, None): + self._session = self.plex_sessions.get( + self._device.machineIdentifier, None) if self._session: - self._media_position = self._convert_na_to_none( - self._session.viewOffset) - self._media_content_id = self._convert_na_to_none( - self._session.ratingKey) - self._media_content_rating = self._convert_na_to_none( - self._session.contentRating) - - # player dependent data - if self._session and self._session.player: - self._is_player_available = True - self._machine_identifier = self._convert_na_to_none( - self._session.player.machineIdentifier) - self._name = self._convert_na_to_none(self._session.player.title) - self._player_state = self._session.player.state - self._session_username = self._convert_na_to_none( - self._session.username) - self._make = self._convert_na_to_none(self._session.player.device) - else: - self._is_player_available = False + if self._device.machineIdentifier is not None and \ + self._session.players: + self._is_player_available = True + self._player = [p for p in self._session.players + if p.machineIdentifier == + self._device.machineIdentifier][0] + self._name = self._player.title + self._player_state = self._player.state + self._session_username = self._session.usernames[0] + self._make = self._player.device + else: + self._is_player_available = False + self._media_position = self._session.viewOffset + self._media_content_id = self._session.ratingKey + self._media_content_rating = self._session.contentRating if self._player_state == 'playing': self._is_player_active = True @@ -405,8 +371,7 @@ class PlexClient(MediaPlayerDevice): if self._is_player_active and self._session is not None: self._session_type = self._session.type - self._media_duration = self._convert_na_to_none( - self._session.duration) + self._media_duration = self._session.duration else: self._session_type = None @@ -424,40 +389,34 @@ class PlexClient(MediaPlayerDevice): # title (movie name, tv episode name, music song name) if self._session and self._is_player_active: - self._media_title = self._convert_na_to_none(self._session.title) + self._media_title = self._session.title # Movies if (self.media_content_type == MEDIA_TYPE_VIDEO and - self._convert_na_to_none(self._session.year) is not None): + self._session.year is not None): self._media_title += ' (' + str(self._session.year) + ')' # TV Show if self._media_content_type is MEDIA_TYPE_TVSHOW: # season number (00) - if callable(self._convert_na_to_none(self._session.seasons)): - self._media_season = self._convert_na_to_none( - self._session.seasons()[0].index).zfill(2) - elif self._convert_na_to_none( - self._session.parentIndex) is not None: + if callable(self._session.seasons): + self._media_season = self._session.seasons()[0].index.zfill(2) + elif self._session.parentIndex is not None: self._media_season = self._session.parentIndex.zfill(2) else: self._media_season = None # show name - self._media_series_title = self._convert_na_to_none( - self._session.grandparentTitle) + self._media_series_title = self._session.grandparentTitle # episode number (00) - if self._convert_na_to_none(self._session.index) is not None: + if self._session.index is not None: self._media_episode = str(self._session.index).zfill(2) # Music if self._media_content_type == MEDIA_TYPE_MUSIC: - self._media_album_name = self._convert_na_to_none( - self._session.parentTitle) - self._media_album_artist = self._convert_na_to_none( - self._session.grandparentTitle) - self._media_track = self._convert_na_to_none(self._session.index) - self._media_artist = self._convert_na_to_none( - self._session.originalTitle) + self._media_album_name = self._session.parentTitle + self._media_album_artist = self._session.grandparentTitle + self._media_track = self._session.index + self._media_artist = self._session.originalTitle # use album artist if track artist is missing if self._media_artist is None: _LOGGER.debug("Using album artist because track artist " @@ -466,41 +425,26 @@ class PlexClient(MediaPlayerDevice): # set app name to library name if (self._session is not None - and self._session.librarySectionID is not None): - self._app_name = self._convert_na_to_none( - self._session.server.library.sectionByID( - self._session.librarySectionID).title) + and self._session.section() is not None): + self._app_name = self._session.section().title else: self._app_name = '' # media image url if self._session is not None: - thumb_url = self._get_thumbnail_url(self._session.thumb) + thumb_url = self._session.thumbUrl if (self.media_content_type is MEDIA_TYPE_TVSHOW and not self.config.get(CONF_USE_EPISODE_ART)): - thumb_url = self._get_thumbnail_url( + thumb_url = self._server.url( self._session.grandparentThumb) if thumb_url is None: _LOGGER.debug("Using media art because media thumb " "was not found: %s", self.entity_id) - thumb_url = self._get_thumbnail_url(self._session.art) + thumb_url = self._server.url(self._session.art) self._media_image_url = thumb_url - def _get_thumbnail_url(self, property_value): - """Return full URL (if exists) for a thumbnail property.""" - if self._convert_na_to_none(property_value) is None: - return None - - if self._session is None or self._session.server is None: - return None - - url = self._session.server.url(property_value) - response = requests.get(url, verify=False) - if response and response.status_code == 200: - return url - def force_idle(self): """Force client to idle.""" self._state = STATE_IDLE @@ -548,17 +492,6 @@ class PlexClient(MediaPlayerDevice): self.update_devices(no_throttle=True) self.update_sessions(no_throttle=True) - # pylint: disable=no-self-use, singleton-comparison - def _convert_na_to_none(self, value): - """Convert PlexAPI _NA() instances to None.""" - # PlexAPI will return a "__NA__" object which can be compared to - # None, but isn't actually None - this converts it to a real None - # type so that lower layers don't think it's a URL and choke on it - if value is self.na_type: - return None - - return value - @property def _active_media_plexapi_type(self): """Get the active media type required by PlexAPI commands.""" @@ -685,32 +618,9 @@ class PlexClient(MediaPlayerDevice): return None - def _local_client_control_fix(self): - """Detect if local client and adjust url to allow control.""" - if self.device is None: - return - - # if this device's machineIdentifier matches an active client - # with a loopback address, the device must be local or casting - for client in self.device.server.clients(): - if ("127.0.0.1" in client.baseurl and - client.machineIdentifier == self.device.machineIdentifier): - # point controls to server since that's where the - # playback is occurring - _LOGGER.debug( - "Local client detected, redirecting controls to " - "Plex server: %s", self.entity_id) - server_url = self.device.server.baseurl - client_url = self.device.baseurl - self.device.baseurl = "{}://{}:{}".format( - urlparse(client_url).scheme, - urlparse(server_url).hostname, - str(urlparse(client_url).port)) - def set_volume_level(self, volume): """Set volume level, range 0..1.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.setVolume( int(volume * 100), self._active_media_plexapi_type) self._volume_level = volume # store since we can't retrieve @@ -749,19 +659,16 @@ class PlexClient(MediaPlayerDevice): def media_play(self): """Send play command.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.play(self._active_media_plexapi_type) def media_pause(self): """Send pause command.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.pause(self._active_media_plexapi_type) def media_stop(self): """Send stop command.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.stop(self._active_media_plexapi_type) def turn_off(self): @@ -772,13 +679,11 @@ class PlexClient(MediaPlayerDevice): def media_next_track(self): """Send next track command.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.skipNext(self._active_media_plexapi_type) def media_previous_track(self): """Send previous track command.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.skipPrevious(self._active_media_plexapi_type) # pylint: disable=W0613 @@ -874,8 +779,6 @@ class PlexClient(MediaPlayerDevice): if delete: media.delete() - self._local_client_control_fix() - server_url = self.device.server.baseurl.split(':') self.device.sendCommand('playback/playMedia', **dict({ 'machineIdentifier': self.device.server.machineIdentifier, diff --git a/homeassistant/components/media_player/russound_rnet.py b/homeassistant/components/media_player/russound_rnet.py index 77a9939c36c..932872467bd 100644 --- a/homeassistant/components/media_player/russound_rnet.py +++ b/homeassistant/components/media_player/russound_rnet.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, STATE_OFF, STATE_ON, CONF_NAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['russound==0.1.7'] +REQUIREMENTS = ['russound==0.1.9'] _LOGGER = logging.getLogger(__name__) @@ -85,23 +85,30 @@ class RussoundRNETDevice(MediaPlayerDevice): def update(self): """Retrieve latest state.""" - if self._russ.get_power('1', self._zone_id) == 0: - self._state = STATE_OFF - else: - self._state = STATE_ON - - self._volume = self._russ.get_volume('1', self._zone_id) / 100.0 - + # Updated this function to make a single call to get_zone_info, so that + # with a single call we can get On/Off, Volume and Source, reducing the + # amount of traffic and speeding up the update process. + ret = self._russ.get_zone_info('1', self._zone_id, 4) + _LOGGER.debug("ret= %s", ret) + if ret is not None: + _LOGGER.debug("Updating status for zone %s", self._zone_id) + if ret[0] == 0: + self._state = STATE_OFF + else: + self._state = STATE_ON + self._volume = ret[2] * 2 / 100.0 # Returns 0 based index for source. - index = self._russ.get_source('1', self._zone_id) + index = ret[1] # Possibility exists that user has defined list of all sources. # If a source is set externally that is beyond the defined list then # an exception will be thrown. # In this case return and unknown source (None) - try: - self._source = self._sources[index] - except IndexError: - self._source = None + try: + self._source = self._sources[index] + except IndexError: + self._source = None + else: + _LOGGER.error("Could not update status for zone %s", self._zone_id) @property def name(self): diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 993863ea725..f2d7b8e07dd 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -1,121 +1,107 @@ -# Describes the format for available media_player services +# Describes the format for available media player services turn_on: - description: Turn a media player power on - + description: Turn a media player power on. fields: entity_id: - description: Name(s) of entities to turn on + description: Name(s) of entities to turn on. example: 'media_player.living_room_chromecast' turn_off: - description: Turn a media player power off - + description: Turn a media player power off. fields: entity_id: - description: Name(s) of entities to turn off + description: Name(s) of entities to turn off. example: 'media_player.living_room_chromecast' toggle: - description: Toggles a media player power state - + description: Toggles a media player power state. fields: entity_id: - description: Name(s) of entities to toggle + description: Name(s) of entities to toggle. example: 'media_player.living_room_chromecast' volume_up: - description: Turn a media player volume up - + description: Turn a media player volume up. fields: entity_id: - description: Name(s) of entities to turn volume up on + description: Name(s) of entities to turn volume up on. example: 'media_player.living_room_sonos' volume_down: - description: Turn a media player volume down - + description: Turn a media player volume down. fields: entity_id: - description: Name(s) of entities to turn volume down on + description: Name(s) of entities to turn volume down on. example: 'media_player.living_room_sonos' volume_mute: - description: Mute a media player's volume - + description: Mute a media player's volume. fields: entity_id: - description: Name(s) of entities to mute + description: Name(s) of entities to mute. example: 'media_player.living_room_sonos' is_volume_muted: - description: True/false for mute/unmute + description: True/false for mute/unmute. example: true volume_set: - description: Set a media player's volume level - + description: Set a media player's volume level. fields: entity_id: - description: Name(s) of entities to set volume level on + description: Name(s) of entities to set volume level on. example: 'media_player.living_room_sonos' volume_level: - description: Volume level to set as float + description: Volume level to set as float. example: 0.6 media_play_pause: - description: Toggle media player play/pause state - + description: Toggle media player play/pause state. fields: entity_id: - description: Name(s) of entities to toggle play/pause state on + description: Name(s) of entities to toggle play/pause state on. example: 'media_player.living_room_sonos' media_play: description: Send the media player the command for play. - fields: entity_id: - description: Name(s) of entities to play on + description: Name(s) of entities to play on. example: 'media_player.living_room_sonos' media_pause: description: Send the media player the command for pause. - fields: entity_id: - description: Name(s) of entities to pause on + description: Name(s) of entities to pause on. example: 'media_player.living_room_sonos' media_stop: description: Send the media player the stop command. - fields: entity_id: - description: Name(s) of entities to stop on + description: Name(s) of entities to stop on. example: 'media_player.living_room_sonos' media_next_track: description: Send the media player the command for next track. - fields: entity_id: - description: Name(s) of entities to send next track command to + description: Name(s) of entities to send next track command to. example: 'media_player.living_room_sonos' media_previous_track: description: Send the media player the command for previous track. - fields: entity_id: - description: Name(s) of entities to send previous track command to + description: Name(s) of entities to send previous track command to. example: 'media_player.living_room_sonos' media_seek: description: Send the media player the command to seek in current playing media. - fields: entity_id: - description: Name(s) of entities to seek media on + description: Name(s) of entities to seek media on. example: 'media_player.living_room_chromecast' seek_position: description: Position to seek to. The format is platform dependent. @@ -123,7 +109,6 @@ media_seek: play_media: description: Send the media player the command for playing media. - fields: entity_id: description: Name(s) of entities to seek media on @@ -137,10 +122,9 @@ play_media: select_source: description: Send the media player the command to change input source. - fields: entity_id: - description: Name(s) of entities to change source on + description: Name(s) of entities to change source on. example: 'media_player.media_player.txnr535_0009b0d81f82' source: description: Name of the source to switch to. Platform dependent. @@ -148,26 +132,23 @@ select_source: clear_playlist: description: Send the media player the command to clear players playlist. - fields: entity_id: - description: Name(s) of entities to change source on + description: Name(s) of entities to change source on. example: 'media_player.living_room_chromecast' shuffle_set: - description: Set shuffling state - + description: Set shuffling state. fields: entity_id: - description: Name(s) of entities to set + description: Name(s) of entities to set. example: 'media_player.spotify' shuffle: - description: True/false for enabling/disabling shuffle + description: True/false for enabling/disabling shuffle. example: true snapcast_snapshot: description: Take a snapshot of the media player. - fields: entity_id: description: Name(s) of entities that will be snapshotted. Platform dependent. @@ -175,7 +156,6 @@ snapcast_snapshot: snapcast_restore: description: Restore a snapshot of the media player. - fields: entity_id: description: Name(s) of entities that will be restored. Platform dependent. @@ -183,19 +163,16 @@ snapcast_restore: sonos_join: description: Group player together. - fields: master: description: Entity ID of the player that should become the coordinator of the group. example: 'media_player.living_room_sonos' - entity_id: description: Name(s) of entities that will coordinate the grouping. Platform dependent. example: 'media_player.living_room_sonos' sonos_unjoin: description: Unjoin the player from a group. - fields: entity_id: description: Name(s) of entities that will be unjoined from their group. Platform dependent. @@ -203,42 +180,36 @@ sonos_unjoin: sonos_snapshot: description: Take a snapshot of the media player. - fields: entity_id: description: Name(s) of entities that will be snapshot. Platform dependent. example: 'media_player.living_room_sonos' - with_group: description: True (default) or False. Snapshot with all group attributes. example: 'true' sonos_restore: description: Restore a snapshot of the media player. - fields: entity_id: description: Name(s) of entities that will be restored. Platform dependent. example: 'media_player.living_room_sonos' - with_group: description: True (default) or False. Restore with all group attributes. example: 'true' sonos_set_sleep_timer: - description: Set a Sonos timer - + description: Set a Sonos timer. fields: entity_id: description: Name(s) of entities that will have a timer set. example: 'media_player.living_room_sonos' sleep_time: - description: Number of seconds to set the timer + description: Number of seconds to set the timer. example: '900' sonos_clear_sleep_timer: - description: Clear a Sonos timer - + description: Clear a Sonos timer. fields: entity_id: description: Name(s) of entities that will have the timer cleared. @@ -246,49 +217,44 @@ sonos_clear_sleep_timer: soundtouch_play_everywhere: - description: Play on all Bose Soundtouch devices - + description: Play on all Bose Soundtouch devices. fields: master: description: Name of the master entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices example: 'media_player.soundtouch_home' soundtouch_create_zone: - description: Create a multi-room zone - + description: Create a Sountouch multi-room zone. fields: master: description: Name of the master entity that will coordinate the multi-room zone. Platform dependent. example: 'media_player.soundtouch_home' slaves: - description: Name of slaves entities to add to the new zone + description: Name of slaves entities to add to the new zone. example: 'media_player.soundtouch_bedroom' soundtouch_add_zone_slave: - description: Add a slave to a multi-room zone - + description: Add a slave to a Sountouch multi-room zone. fields: master: description: Name of the master entity that is coordinating the multi-room zone. Platform dependent. example: 'media_player.soundtouch_home' slaves: - description: Name of slaves entities to add to the existing zone + description: Name of slaves entities to add to the existing zone. example: 'media_player.soundtouch_bedroom' soundtouch_remove_zone_slave: - description: Remove a slave from the multi-room zone - + description: Remove a slave from the Sounttouch multi-room zone. fields: master: description: Name of the master entity that is coordinating the multi-room zone. Platform dependent. example: 'media_player.soundtouch_home' slaves: - description: Name of slaves entities to remove from the existing zone + description: Name of slaves entities to remove from the existing zone. example: 'media_player.soundtouch_bedroom' kodi_add_to_playlist: description: Add music to the default playlist (i.e. playlistid=0). - fields: entity_id: description: Name(s) of the Kodi entities where to add the media. @@ -308,7 +274,6 @@ kodi_add_to_playlist: kodi_call_method: description: 'Call a Kodi JSONRPC API method with optional parameters. Results of the Kodi API call will be redirected in a Home Assistant event: `kodi_call_method_result`.' - fields: entity_id: description: Name(s) of the Kodi entities where to run the API method. diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 410728dafaa..47786e793ca 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -150,8 +150,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.warning("No Sonos speakers found") return - hass.data[DATA_SONOS] = [SonosDevice(p) for p in players] - add_devices(hass.data[DATA_SONOS], True) + # Add coordinators first so they can be queried by slaves + coordinators = [SonosDevice(p) for p in players if p.is_coordinator] + slaves = [SonosDevice(p) for p in players if not p.is_coordinator] + hass.data[DATA_SONOS] = coordinators + slaves + if coordinators: + add_devices(coordinators, True) + if slaves: + add_devices(slaves, True) _LOGGER.info("Added %s Sonos speakers", len(players)) descriptions = load_yaml_config_file( @@ -321,7 +327,6 @@ class SonosDevice(MediaPlayerDevice): self._media_album_name = None self._media_title = None self._media_radio_show = None - self._media_next_title = None self._available = True self._support_previous_track = False self._support_next_track = False @@ -440,7 +445,6 @@ class SonosDevice(MediaPlayerDevice): self._media_album_name = None self._media_title = None self._media_radio_show = None - self._media_next_title = None self._current_track_uri = None self._current_track_is_radio_stream = False self._support_previous_track = False @@ -733,17 +737,6 @@ class SonosDevice(MediaPlayerDevice): next_track_uri ) - next_track_metadata = event.variables.get('next_track_meta_data') - if next_track_metadata: - next_track = '{title} - {creator}'.format( - title=next_track_metadata.title, - creator=next_track_metadata.creator - ) - if next_track != self._media_next_title: - self._media_next_title = next_track - else: - self._media_next_title = None - elif event.service == self._player.renderingControl: if 'volume' in event.variables: self._player_volume = int( diff --git a/homeassistant/components/media_player/vizio.py b/homeassistant/components/media_player/vizio.py index 4ae8f037a4f..5d6e6fcf6dd 100644 --- a/homeassistant/components/media_player/vizio.py +++ b/homeassistant/components/media_player/vizio.py @@ -75,11 +75,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return False if config.get(CONF_SUPPRESS_WARNING): - import requests - from requests.packages.urllib3.exceptions import InsecureRequestWarning + from requests.packages import urllib3 _LOGGER.warning('InsecureRequestWarning is disabled ' 'because of Vizio platform configuration.') - requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) add_devices([device], True) diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py index e5aeaa6b9c1..861e75ac144 100644 --- a/homeassistant/components/media_player/yamaha.py +++ b/homeassistant/components/media_player/yamaha.py @@ -252,6 +252,20 @@ class YamahaDevice(MediaPlayerDevice): Yamaha to direct play certain kinds of media. media_type is treated as the input type that we are setting, and media id is specific to it. + + For the NET RADIO mediatype the format for ``media_id`` is a + "path" in your vtuner hierarchy. For instance: + ``Bookmarks>Internet>Radio Paradise``. The separators are + ``>`` and the parts of this are navigated by name behind the + scenes. There is a looping construct built into the yamaha + library to do this with a fallback timeout if the vtuner + service is unresponsive. + + NOTE: this might take a while, because the only API interface + for setting the net radio station emulates button pressing and + navigating through the net radio menu hiearchy. And each sub + menu must be fetched by the receiver from the vtuner service. + """ if media_type == "NET RADIO": self._receiver.net_radio(media_id) diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py index 3e12b3bf7fa..27efc4f3814 100644 --- a/homeassistant/components/media_player/yamaha_musiccast.py +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -2,7 +2,6 @@ media_player: - platform: yamaha_musiccast - name: "Living Room" host: 192.168.xxx.xx port: 5005 @@ -13,7 +12,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, + CONF_HOST, CONF_PORT, STATE_UNKNOWN, STATE_ON ) from homeassistant.components.media_player import ( @@ -34,16 +33,17 @@ SUPPORTED_FEATURES = ( ) KNOWN_HOSTS_KEY = 'data_yamaha_musiccast' +INTERVAL_SECONDS = 'interval_seconds' -REQUIREMENTS = ['pymusiccast==0.1.2'] +REQUIREMENTS = ['pymusiccast==0.1.3'] -DEFAULT_NAME = "Yamaha Receiver" DEFAULT_PORT = 5005 +DEFAULT_INTERVAL = 480 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, + vol.Optional(INTERVAL_SECONDS, default=DEFAULT_INTERVAL): cv.positive_int, }) @@ -57,9 +57,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): known_hosts = hass.data[KNOWN_HOSTS_KEY] = [] _LOGGER.debug("known_hosts: %s", known_hosts) - name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) + interval = config.get(INTERVAL_SECONDS) # Get IP of host to prevent duplicates try: @@ -81,14 +81,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): known_hosts.append(reg_host) try: - receiver = pymusiccast.McDevice(ipaddr, udp_port=port) + receiver = pymusiccast.McDevice( + ipaddr, udp_port=port, mc_interval=interval) except pymusiccast.exceptions.YMCInitError as err: _LOGGER.error(err) receiver = None if receiver: - _LOGGER.debug("receiver: %s / Port: %d", receiver, port) - add_devices([YamahaDevice(receiver, name)], True) + for zone in receiver.zones: + _LOGGER.debug( + "receiver: %s / Port: %d / Zone: %s", + receiver, port, zone) + add_devices( + [YamahaDevice(receiver, receiver.zones[zone])], + True) else: known_hosts.remove(reg_host) @@ -96,24 +102,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class YamahaDevice(MediaPlayerDevice): """Representation of a Yamaha MusicCast device.""" - def __init__(self, receiver, name): + def __init__(self, recv, zone): """Initialize the Yamaha MusicCast device.""" - self._receiver = receiver - self._name = name - self.power = STATE_UNKNOWN - self.volume = 0 - self.volume_max = 0 - self.mute = False + self._recv = recv + self._name = recv.name self._source = None self._source_list = [] - self.status = STATE_UNKNOWN + self._zone = zone + self.mute = False self.media_status = None - self._receiver.set_yamaha_device(self) + self.power = STATE_UNKNOWN + self.status = STATE_UNKNOWN + self.volume = 0 + self.volume_max = 0 + self._recv.set_yamaha_device(self) + self._zone.set_yamaha_device(self) @property def name(self): """Return the name of the device.""" - return self._name + return "{} ({})".format(self._name, self._zone.zone_id) @property def state(self): @@ -197,71 +205,57 @@ class YamahaDevice(MediaPlayerDevice): def update(self): """Get the latest details from the device.""" _LOGGER.debug("update: %s", self.entity_id) - - # call from constructor setup_platform() - if not self.entity_id: - _LOGGER.debug("First run") - self._receiver.update_status(push=False) - # call from regular polling - else: - # update_status_timer was set before - if self._receiver.update_status_timer: - _LOGGER.debug( - "is_alive: %s", - self._receiver.update_status_timer.is_alive()) - # e.g. computer was suspended, while hass was running - if not self._receiver.update_status_timer.is_alive(): - _LOGGER.debug("Reinitializing") - self._receiver.update_status() + self._recv.update_status() + self._zone.update_status() def turn_on(self): """Turn on specified media player or all.""" _LOGGER.debug("Turn device: on") - self._receiver.set_power(True) + self._zone.set_power(True) def turn_off(self): """Turn off specified media player or all.""" _LOGGER.debug("Turn device: off") - self._receiver.set_power(False) + self._zone.set_power(False) def media_play(self): """Send the media player the command for play/pause.""" _LOGGER.debug("Play") - self._receiver.set_playback("play") + self._recv.set_playback("play") def media_pause(self): """Send the media player the command for pause.""" _LOGGER.debug("Pause") - self._receiver.set_playback("pause") + self._recv.set_playback("pause") def media_stop(self): """Send the media player the stop command.""" _LOGGER.debug("Stop") - self._receiver.set_playback("stop") + self._recv.set_playback("stop") def media_previous_track(self): """Send the media player the command for prev track.""" _LOGGER.debug("Previous") - self._receiver.set_playback("previous") + self._recv.set_playback("previous") def media_next_track(self): """Send the media player the command for next track.""" _LOGGER.debug("Next") - self._receiver.set_playback("next") + self._recv.set_playback("next") def mute_volume(self, mute): """Send mute command.""" _LOGGER.debug("Mute volume: %s", mute) - self._receiver.set_mute(mute) + self._zone.set_mute(mute) def set_volume_level(self, volume): """Set volume level, range 0..1.""" _LOGGER.debug("Volume level: %.2f / %d", volume, volume * self.volume_max) - self._receiver.set_volume(volume * self.volume_max) + self._zone.set_volume(volume * self.volume_max) def select_source(self, source): """Send the media player the command to select input source.""" _LOGGER.debug("select_source: %s", source) self.status = STATE_UNKNOWN - self._receiver.set_input(source) + self._zone.set_input(source) diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index 9c713787fac..e338e21802a 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -1,28 +1,25 @@ -publish: - description: Publish a message to an MQTT topic +# Describes the format for available MQTT services +publish: + description: Publish a message to an MQTT topic. fields: topic: - description: Topic to publish payload + description: Topic to publish payload. example: /homeassistant/hello - payload: - description: Payload to publish + description: Payload to publish. example: This is great - payload_template: description: Template to render as payload value. Ignored if payload given. example: "{{ states('sensor.temperature') }}" - qos: - description: Quality of Service + description: Quality of Service to use. example: 2 values: - 0 - 1 - 2 default: 0 - retain: description: If message should have the retain flag set. example: true diff --git a/homeassistant/components/mqtt_statestream.py b/homeassistant/components/mqtt_statestream.py index 8469cb3b334..d24361637e9 100644 --- a/homeassistant/components/mqtt_statestream.py +++ b/homeassistant/components/mqtt_statestream.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/mqtt_statestream/ """ import asyncio +import json import voluptuous as vol @@ -12,6 +13,7 @@ from homeassistant.const import MATCH_ALL from homeassistant.core import callback from homeassistant.components.mqtt import valid_publish_topic from homeassistant.helpers.event import async_track_state_change +from homeassistant.remote import JSONEncoder import homeassistant.helpers.config_validation as cv CONF_BASE_TOPIC = 'base_topic' @@ -65,8 +67,9 @@ def async_setup(hass, config): if publish_attributes: for key, val in new_state.attributes.items(): if val: + encoded_val = json.dumps(val, cls=JSONEncoder) hass.components.mqtt.async_publish(mybase + key, - val, 1, True) + encoded_val, 1, True) async_track_state_change(hass, MATCH_ALL, _state_publisher) return True diff --git a/homeassistant/components/namecheapdns.py b/homeassistant/components/namecheapdns.py index bfad10b4f76..dcca8829535 100644 --- a/homeassistant/components/namecheapdns.py +++ b/homeassistant/components/namecheapdns.py @@ -1,46 +1,55 @@ -"""Integrate with NamecheapDNS.""" +""" +Integrate with namecheap DNS services. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/namecheapdns/ +""" import asyncio -from datetime import timedelta import logging +from datetime import timedelta import voluptuous as vol -from homeassistant.const import CONF_HOST, CONF_ACCESS_TOKEN, CONF_DOMAIN import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_DOMAIN from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.aiohttp_client import async_get_clientsession -DOMAIN = 'namecheapdns' -UPDATE_URL = 'https://dynamicdns.park-your-domain.com/update' -INTERVAL = timedelta(minutes=5) _LOGGER = logging.getLogger(__name__) +DOMAIN = 'namecheapdns' + +INTERVAL = timedelta(minutes=5) + +UPDATE_URL = 'https://dynamicdns.park-your-domain.com/update' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOST): cv.string, vol.Required(CONF_DOMAIN): cv.string, - vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HOST, default='@'): cv.string, }) }, extra=vol.ALLOW_EXTRA) @asyncio.coroutine def async_setup(hass, config): - """Initialize the NamecheapDNS component.""" + """Initialize the namecheap DNS component.""" host = config[DOMAIN][CONF_HOST] domain = config[DOMAIN][CONF_DOMAIN] - token = config[DOMAIN][CONF_ACCESS_TOKEN] + password = config[DOMAIN][CONF_PASSWORD] + session = async_get_clientsession(hass) - result = yield from _update_namecheapdns(session, host, domain, token) + result = yield from _update_namecheapdns(session, host, domain, password) if not result: return False @asyncio.coroutine def update_domain_interval(now): - """Update the NamecheapDNS entry.""" - yield from _update_namecheapdns(session, host, domain, token) + """Update the namecheap DNS entry.""" + yield from _update_namecheapdns(session, host, domain, password) async_track_time_interval(hass, update_domain_interval, INTERVAL) @@ -48,14 +57,14 @@ def async_setup(hass, config): @asyncio.coroutine -def _update_namecheapdns(session, host, domain, token): - """Update NamecheapDNS.""" +def _update_namecheapdns(session, host, domain, password): + """Update namecheap DNS entry.""" import xml.etree.ElementTree as ET params = { 'host': host, 'domain': domain, - 'password': token, + 'password': password, } resp = yield from session.get(UPDATE_URL, params=params) @@ -64,7 +73,7 @@ def _update_namecheapdns(session, host, domain, token): err_count = root.find('ErrCount').text if int(err_count) != 0: - _LOGGER.warning('Updating Namecheap domain %s failed', domain) + _LOGGER.warning("Updating namecheap domain failed: %s", domain) return False return True diff --git a/homeassistant/components/no_ip.py b/homeassistant/components/no_ip.py new file mode 100644 index 00000000000..d92cd752aef --- /dev/null +++ b/homeassistant/components/no_ip.py @@ -0,0 +1,113 @@ +""" +Integrate with NO-IP Dynamic DNS service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/no_ip/ +""" +import asyncio +import base64 +import logging +from datetime import timedelta + +import aiohttp +import async_timeout +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, HTTP_HEADER_AUTH, + HTTP_HEADER_USER_AGENT, PROJECT_EMAIL) +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'no_ip' + +INTERVAL = timedelta(minutes=5) + +DEFAULT_TIMEOUT = 10 + +NO_IP_ERRORS = { + 'nohost': "Hostname supplied does not exist under specified account", + 'badauth': "Invalid username password combination", + 'badagent': "Client disabled", + '!donator': + "An update request was sent with a feature that is not available", + 'abuse': "Username is blocked due to abuse", + '911': "A fatal error on NO-IP's side such as a database outage", +} + +UPDATE_URL = 'https://dynupdate.noip.com/nic/update' +USER_AGENT = "{} {}".format(SERVER_SOFTWARE, PROJECT_EMAIL) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the NO-IP component.""" + domain = config[DOMAIN].get(CONF_DOMAIN) + user = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + timeout = config[DOMAIN].get(CONF_TIMEOUT) + + auth_str = base64.b64encode('{}:{}'.format(user, password).encode('utf-8')) + + session = hass.helpers.aiohttp_client.async_get_clientsession() + + result = yield from _update_no_ip( + hass, session, domain, auth_str, timeout) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the NO-IP entry.""" + yield from _update_no_ip(hass, session, domain, auth_str, timeout) + + hass.helpers.event.async_track_time_interval( + update_domain_interval, INTERVAL) + + return True + + +@asyncio.coroutine +def _update_no_ip(hass, session, domain, auth_str, timeout): + """Update NO-IP.""" + url = UPDATE_URL + + params = { + 'hostname': domain, + } + + headers = { + HTTP_HEADER_AUTH: "Basic {}".format(auth_str.decode('utf-8')), + HTTP_HEADER_USER_AGENT: USER_AGENT, + } + + try: + with async_timeout.timeout(timeout, loop=hass.loop): + resp = yield from session.get(url, params=params, headers=headers) + body = yield from resp.text() + + if body.startswith('good') or body.startswith('nochg'): + return True + + _LOGGER.warning("Updating NO-IP failed: %s => %s", domain, + NO_IP_ERRORS[body.strip()]) + + except aiohttp.ClientError: + _LOGGER.warning("Can't connect to NO-IP API") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from NO-IP API for domain: %s", domain) + + return False diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index 7bdc103523d..b0cc4a0121d 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( import homeassistant.helpers.config_validation as cv from homeassistant.remote import JSONEncoder -REQUIREMENTS = ['boto3==1.4.3'] +REQUIREMENTS = ['boto3==1.4.7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py index 27fa7ac41c2..c94e3abaa96 100644 --- a/homeassistant/components/notify/aws_sns.py +++ b/homeassistant/components/notify/aws_sns.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["boto3==1.4.3"] +REQUIREMENTS = ["boto3==1.4.7"] CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py index 227dba14b43..43c04ed16d0 100644 --- a/homeassistant/components/notify/aws_sqs.py +++ b/homeassistant/components/notify/aws_sqs.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["boto3==1.4.3"] +REQUIREMENTS = ["boto3==1.4.7"] CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' diff --git a/homeassistant/components/notify/clickatell.py b/homeassistant/components/notify/clickatell.py new file mode 100644 index 00000000000..6af2b455129 --- /dev/null +++ b/homeassistant/components/notify/clickatell.py @@ -0,0 +1,52 @@ +""" +Clickatell platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.clickatell/ +""" +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_API_KEY, CONF_RECIPIENT) +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'clickatell' + +BASE_API_URL = 'https://platform.clickatell.com/messages/http/send' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_RECIPIENT): cv.string, +}) + + +def get_service(hass, config, discovery_info=None): + """Get the Clickatell notification service.""" + return ClickatellNotificationService(config) + + +class ClickatellNotificationService(BaseNotificationService): + """Implementation of a notification service for the Clickatell service.""" + + def __init__(self, config): + """Initialize the service.""" + self.api_key = config.get(CONF_API_KEY) + self.recipient = config.get(CONF_RECIPIENT) + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + data = { + 'apiKey': self.api_key, + 'to': self.recipient, + 'content': message, + } + + resp = requests.get(BASE_API_URL, params=data, timeout=5) + if (resp.status_code != 200) or (resp.status_code != 201): + _LOGGER.error("Error %s : %s", resp.status_code, resp.text) diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index 07b13c60d1e..dca47a46dbf 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -12,13 +12,12 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ATTR_TARGET) +from homeassistant.const import CONF_TOKEN _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['discord.py==0.16.12'] -CONF_TOKEN = 'token' - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_TOKEN): cv.string }) diff --git a/homeassistant/components/notify/ecobee.py b/homeassistant/components/notify/ecobee.py index 2d64a2d5b47..c718149b4b5 100644 --- a/homeassistant/components/notify/ecobee.py +++ b/homeassistant/components/notify/ecobee.py @@ -8,14 +8,14 @@ import logging import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components import ecobee from homeassistant.components.notify import ( BaseNotificationService, PLATFORM_SCHEMA) # NOQA -import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ['ecobee'] _LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['ecobee'] CONF_INDEX = 'index' diff --git a/homeassistant/components/notify/hipchat.py b/homeassistant/components/notify/hipchat.py index ee1283b9820..344827c00b4 100644 --- a/homeassistant/components/notify/hipchat.py +++ b/homeassistant/components/notify/hipchat.py @@ -11,14 +11,13 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TARGET, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import CONF_TOKEN, CONF_HOST +from homeassistant.const import CONF_TOKEN, CONF_HOST, CONF_ROOM REQUIREMENTS = ['hipnotify==1.0.8'] _LOGGER = logging.getLogger(__name__) CONF_COLOR = 'color' -CONF_ROOM = 'room' CONF_NOTIFY = 'notify' CONF_FORMAT = 'format' diff --git a/homeassistant/components/notify/rocketchat.py b/homeassistant/components/notify/rocketchat.py index f2898c8b998..e9b481b1cf3 100644 --- a/homeassistant/components/notify/rocketchat.py +++ b/homeassistant/components/notify/rocketchat.py @@ -8,17 +8,14 @@ import logging import voluptuous as vol -from homeassistant.const import ( - CONF_URL, CONF_USERNAME, CONF_PASSWORD) -from homeassistant.components.notify import ( - ATTR_DATA, PLATFORM_SCHEMA, - BaseNotificationService) import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_URL, CONF_USERNAME, CONF_PASSWORD, CONF_ROOM) +from homeassistant.components.notify import ( + ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) REQUIREMENTS = ['rocketchat-API==0.6.1'] -CONF_ROOM = 'room' - _LOGGER = logging.getLogger(__name__) # pylint: disable=no-value-for-parameter @@ -44,11 +41,11 @@ def get_service(hass, config, discovery_info=None): return RocketChatNotificationService(url, username, password, room) except RocketConnectionException: _LOGGER.warning( - "Unable to connect to Rocket.Chat server at %s.", url) + "Unable to connect to Rocket.Chat server at %s", url) except RocketAuthenticationException: _LOGGER.warning( - "Rocket.Chat authentication failed for user %s.", username) - _LOGGER.info("Please check your username/password.") + "Rocket.Chat authentication failed for user %s", username) + _LOGGER.info("Please check your username/password") return None @@ -65,8 +62,8 @@ class RocketChatNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to Rocket.Chat.""" data = kwargs.get(ATTR_DATA) or {} - resp = self._server.chat_post_message(message, channel=self._room, - **data) + resp = self._server.chat_post_message( + message, channel=self._room, **data) if resp.status_code == 200: success = resp.json()["success"] if not success: diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index b7f192ff983..b0185218846 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==5.2.0'] +REQUIREMENTS = ['sendgrid==5.3.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml index 4fe66844aa9..23b1c968c4a 100644 --- a/homeassistant/components/notify/services.yaml +++ b/homeassistant/components/notify/services.yaml @@ -1,31 +1,27 @@ -notify: - description: Send a notification +# Describes the format for available notification services +notify: + description: Send a notification. fields: message: description: Message body of the notification. example: The garage door has been open for 10 minutes. - title: description: Optional title for your notification. example: 'Your Garage Door Friend' - target: description: An array of targets to send the notification to. Optional depending on the platform. example: platform specific - data: description: Extended information for notification. Optional depending on the platform. example: platform specific apns_register: description: Registers a device to receive push notifications. - fields: push_id: description: The device token, a 64 character hex string (256 bits). The device token is provided to you by your client app, which receives the token after registering itself with the remote notification service. example: '72f2a8633655c5ce574fdc9b2b34ff8abdfc3b739b6ceb7a9ff06c1cbbf99f62' - name: description: A friendly name for the device (optional). example: 'Sam''s iPhone' diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 455bab039f6..806acdb6d09 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -11,7 +11,8 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT +from homeassistant.const import ( + CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT, CONF_ROOM) REQUIREMENTS = ['sleekxmpp==1.3.2', 'dnspython3==1.15.0', @@ -22,7 +23,6 @@ _LOGGER = logging.getLogger(__name__) CONF_TLS = 'tls' CONF_VERIFY = 'verify' -CONF_ROOM = 'room' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENDER): cv.string, diff --git a/homeassistant/components/notify/yessssms.py b/homeassistant/components/notify/yessssms.py new file mode 100644 index 00000000000..37a6a90a62e --- /dev/null +++ b/homeassistant/components/notify/yessssms.py @@ -0,0 +1,51 @@ +""" +Support for the YesssSMS platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.yessssms/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_RECIPIENT +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['YesssSMS==0.1.1b3'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_RECIPIENT): cv.string, +}) + + +def get_service(hass, config, discovery_info=None): + """Get the YesssSMS notification service.""" + return YesssSMSNotificationService( + config[CONF_USERNAME], config[CONF_PASSWORD], config[CONF_RECIPIENT]) + + +class YesssSMSNotificationService(BaseNotificationService): + """Implement a notification service for the YesssSMS service.""" + + def __init__(self, username, password, recipient): + """Initialize the service.""" + from YesssSMS import YesssSMS + self.yesss = YesssSMS(username, password) + self._recipient = recipient + + def send_message(self, message="", **kwargs): + """Send a SMS message via Yesss.at's website.""" + try: + self.yesss.send(self._recipient, message) + except ValueError as ex: + if str(ex).startswith("YesssSMS:"): + _LOGGER.error(str(ex)) + except RuntimeError as ex: + if str(ex).startswith("YesssSMS:"): + _LOGGER.error(str(ex)) diff --git a/homeassistant/components/panel_custom.py b/homeassistant/components/panel_custom.py index 7806cc4cac8..473d44f3b55 100644 --- a/homeassistant/components/panel_custom.py +++ b/homeassistant/components/panel_custom.py @@ -4,13 +4,13 @@ Register a custom front end panel. For more details about this component, please refer to the documentation at https://home-assistant.io/components/panel_custom/ """ +import asyncio import logging import os import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components.frontend import register_panel DOMAIN = 'panel_custom' DEPENDENCIES = ['frontend'] @@ -40,7 +40,8 @@ CONFIG_SCHEMA = vol.Schema({ _LOGGER = logging.getLogger(__name__) -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Initialize custom panel.""" success = False @@ -56,11 +57,11 @@ def setup(hass, config): name, panel_path) continue - register_panel( - hass, name, panel_path, + yield from hass.components.frontend.async_register_panel( + name, panel_path, sidebar_title=panel.get(CONF_SIDEBAR_TITLE), sidebar_icon=panel.get(CONF_SIDEBAR_ICON), - url_path=panel.get(CONF_URL_PATH), + frontend_url_path=panel.get(CONF_URL_PATH), config=panel.get(CONF_CONFIG), ) diff --git a/homeassistant/components/panel_iframe.py b/homeassistant/components/panel_iframe.py index 50e764ba1f9..e4be19c53ed 100644 --- a/homeassistant/components/panel_iframe.py +++ b/homeassistant/components/panel_iframe.py @@ -4,10 +4,11 @@ Register an iFrame front end panel. For more details about this component, please refer to the documentation at https://home-assistant.io/components/panel_iframe/ """ +import asyncio + import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components.frontend import register_built_in_panel DOMAIN = 'panel_iframe' DEPENDENCIES = ['frontend'] @@ -26,11 +27,12 @@ CONFIG_SCHEMA = vol.Schema({ }})}, extra=vol.ALLOW_EXTRA) +@asyncio.coroutine def setup(hass, config): """Set up the iFrame frontend panels.""" for url_path, info in config[DOMAIN].items(): - register_built_in_panel( - hass, 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), + yield from hass.components.frontend.async_register_built_in_panel( + 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), url_path, {'url': info[CONF_URL]}) return True diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 0c4674f89cc..aaba6e42de3 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -43,6 +43,8 @@ SCHEMA_SERVICE_DISMISS = vol.Schema({ DEFAULT_OBJECT_ID = 'notification' _LOGGER = logging.getLogger(__name__) +STATE = 'notifying' + @bind_hass def create(hass, message, title=None, notification_id=None): @@ -113,7 +115,9 @@ def async_setup(hass, config): _LOGGER.error('Error rendering message %s: %s', message, ex) message = message.template - hass.states.async_set(entity_id, message, attr) + attr[ATTR_MESSAGE] = message + + hass.states.async_set(entity_id, STATE, attr) @callback def dismiss_service(call): diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml index 2a10f9c8499..ca73c6d56bb 100644 --- a/homeassistant/components/persistent_notification/services.yaml +++ b/homeassistant/components/persistent_notification/services.yaml @@ -1,22 +1,18 @@ create: - description: Show a notification in the frontend - + description: Show a notification in the frontend. fields: message: description: Message body of the notification. [Templates accepted] example: Please check your configuration.yaml. - title: description: Optional title for your notification. [Optional, Templates accepted] example: Test notification - notification_id: description: Target ID of the notification, will replace a notification with the same Id. [Optional] example: 1234 dismiss: - description: Remove a notification from the frontend - + description: Remove a notification from the frontend. fields: notification_id: description: Target ID of the notification, which should be removed. [Required] diff --git a/homeassistant/components/plant.py b/homeassistant/components/plant.py index 3a6876e3e12..523fa2d6859 100644 --- a/homeassistant/components/plant.py +++ b/homeassistant/components/plant.py @@ -171,15 +171,15 @@ class Plant(Entity): reading = self._sensormap[entity_id] if reading == READING_MOISTURE: - self._moisture = int(value) + self._moisture = int(float(value)) elif reading == READING_BATTERY: - self._battery = int(value) + self._battery = int(float(value)) elif reading == READING_TEMPERATURE: self._temperature = float(value) elif reading == READING_CONDUCTIVITY: - self._conductivity = int(value) + self._conductivity = int(float(value)) elif reading == READING_BRIGHTNESS: - self._brightness = int(value) + self._brightness = int(float(value)) else: raise _LOGGER.error("Unknown reading from sensor %s: %s", entity_id, value) diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index 6bf677b9645..75b2a1fed71 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -1,4 +1,9 @@ -"""Component to allow running Python scripts.""" +""" +Component to allow running Python scripts. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/python_script/ +""" import datetime import glob import logging @@ -7,16 +12,19 @@ import time import voluptuous as vol +import homeassistant.util.dt as dt_util from homeassistant.const import SERVICE_RELOAD from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.util import sanitize_filename -import homeassistant.util.dt as dt_util + +REQUIREMENTS = ['restrictedpython==4.0b2'] + +_LOGGER = logging.getLogger(__name__) DOMAIN = 'python_script' -REQUIREMENTS = ['restrictedpython==4.0a3'] + FOLDER = 'python_scripts' -_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema(dict) @@ -43,11 +51,11 @@ class ScriptError(HomeAssistantError): def setup(hass, config): - """Initialize the python_script component.""" + """Initialize the Python script component.""" path = hass.config.path(FOLDER) if not os.path.isdir(path): - _LOGGER.warning('Folder %s not found in config folder', FOLDER) + _LOGGER.warning("Folder %s not found in configuration folder", FOLDER) return False discover_scripts(hass) @@ -65,7 +73,7 @@ def discover_scripts(hass): path = hass.config.path(FOLDER) if not os.path.isdir(path): - _LOGGER.warning('Folder %s not found in config folder', FOLDER) + _LOGGER.warning("Folder %s not found in configuration folder", FOLDER) return False def python_script_service_handler(call): @@ -104,19 +112,19 @@ def execute(hass, filename, source, data=None): compiled = compile_restricted_exec(source, filename=filename) if compiled.errors: - _LOGGER.error('Error loading script %s: %s', filename, - ', '.join(compiled.errors)) + _LOGGER.error("Error loading script %s: %s", filename, + ", ".join(compiled.errors)) return if compiled.warnings: - _LOGGER.warning('Warning loading script %s: %s', filename, - ', '.join(compiled.warnings)) + _LOGGER.warning("Warning loading script %s: %s", filename, + ", ".join(compiled.warnings)) def protected_getattr(obj, name, default=None): """Restricted method to get attributes.""" # pylint: disable=too-many-boolean-expressions if name.startswith('async_'): - raise ScriptError('Not allowed to access async methods') + raise ScriptError("Not allowed to access async methods") elif (obj is hass and name not in ALLOWED_HASS or obj is hass.bus and name not in ALLOWED_EVENTBUS or obj is hass.states and name not in ALLOWED_STATEMACHINE or @@ -124,7 +132,7 @@ def execute(hass, filename, source, data=None): obj is dt_util and name not in ALLOWED_DT_UTIL or obj is datetime and name not in ALLOWED_DATETIME or isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME): - raise ScriptError('Not allowed to access {}.{}'.format( + raise ScriptError("Not allowed to access {}.{}".format( obj.__class__.__name__, name)) return getattr(obj, name, default) @@ -152,13 +160,13 @@ def execute(hass, filename, source, data=None): } try: - _LOGGER.info('Executing %s: %s', filename, data) + _LOGGER.info("Executing %s: %s", filename, data) # pylint: disable=exec-used exec(compiled.code, restricted_globals, local) except ScriptError as err: - logger.error('Error executing script: %s', err) + logger.error("Error executing script: %s", err) except Exception as err: # pylint: disable=broad-except - logger.exception('Error executing script: %s', err) + logger.exception("Error executing script: %s", err) class StubPrinter: @@ -172,7 +180,7 @@ class StubPrinter: """Print text.""" # pylint: disable=no-self-use _LOGGER.warning( - "Don't use print() inside scripts. Use logger.info() instead.") + "Don't use print() inside scripts. Use logger.info() instead") class TimeWrapper: @@ -186,8 +194,8 @@ class TimeWrapper: """Sleep method that warns once.""" if not TimeWrapper.warned: TimeWrapper.warned = True - _LOGGER.warning('Using time.sleep can reduce the performance of ' - 'Home Assistant') + _LOGGER.warning("Using time.sleep can reduce the performance of " + "Home Assistant") time.sleep(*args, **kwargs) diff --git a/homeassistant/components/raincloud.py b/homeassistant/components/raincloud.py index bed23674d32..1668fce0f45 100644 --- a/homeassistant/components/raincloud.py +++ b/homeassistant/components/raincloud.py @@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import ( from requests.exceptions import HTTPError, ConnectTimeout -REQUIREMENTS = ['raincloudy==0.0.3'] +REQUIREMENTS = ['raincloudy==0.0.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index eb92f345a07..e9b08941b83 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -14,19 +14,20 @@ from os import path import queue import threading import time +from collections import namedtuple from datetime import datetime, timedelta from typing import Optional, Dict import voluptuous as vol from homeassistant.core import ( - HomeAssistant, callback, split_entity_id, CoreState) + HomeAssistant, callback, CoreState) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ENTITIES, CONF_EXCLUDE, CONF_DOMAINS, CONF_INCLUDE, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from homeassistant import config as conf_util @@ -120,7 +121,7 @@ def run_information(hass, point_in_time: Optional[datetime]=None): def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the recorder.""" conf = config.get(DOMAIN, {}) - purge_days = conf.get(CONF_PURGE_KEEP_DAYS) + keep_days = conf.get(CONF_PURGE_KEEP_DAYS) purge_interval = conf.get(CONF_PURGE_INTERVAL) db_url = conf.get(CONF_DB_URL, None) @@ -131,28 +132,20 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: include = conf.get(CONF_INCLUDE, {}) exclude = conf.get(CONF_EXCLUDE, {}) instance = hass.data[DATA_INSTANCE] = Recorder( - hass, uri=db_url, include=include, exclude=exclude) + hass=hass, keep_days=keep_days, purge_interval=purge_interval, + uri=db_url, include=include, exclude=exclude) instance.async_initialize() instance.start() - @asyncio.coroutine - def async_handle_purge_interval(now): - """Handle purge interval.""" - instance.do_purge(purge_days) - @asyncio.coroutine def async_handle_purge_service(service): """Handle calls to the purge service.""" - instance.do_purge(service.data[ATTR_KEEP_DAYS]) + instance.do_adhoc_purge(service.data[ATTR_KEEP_DAYS]) descriptions = yield from hass.async_add_job( conf_util.load_yaml_config_file, path.join( path.dirname(__file__), 'services.yaml')) - if purge_interval and purge_days: - async_track_time_interval(hass, async_handle_purge_interval, - timedelta(days=purge_interval)) - hass.services.async_register(DOMAIN, SERVICE_PURGE, async_handle_purge_service, descriptions.get(SERVICE_PURGE), @@ -161,16 +154,21 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return (yield from instance.async_db_ready) +PurgeTask = namedtuple('PurgeTask', ['keep_days']) + + class Recorder(threading.Thread): """A threaded recorder class.""" - def __init__(self, hass: HomeAssistant, uri: str, + def __init__(self, hass: HomeAssistant, keep_days: int, + purge_interval: int, uri: str, include: Dict, exclude: Dict) -> None: """Initialize the recorder.""" threading.Thread.__init__(self, name='Recorder') self.hass = hass - self.purge_days = None + self.keep_days = keep_days + self.purge_interval = purge_interval self.queue = queue.Queue() # type: Any self.recording_start = dt_util.utcnow() self.db_url = uri @@ -178,25 +176,23 @@ class Recorder(threading.Thread): self.engine = None # type: Any self.run_info = None # type: Any - self.include_e = include.get(CONF_ENTITIES, []) - self.include_d = include.get(CONF_DOMAINS, []) - self.exclude = exclude.get(CONF_ENTITIES, []) + \ - exclude.get(CONF_DOMAINS, []) + self.entity_filter = generate_filter(include.get(CONF_DOMAINS, []), + include.get(CONF_ENTITIES, []), + exclude.get(CONF_DOMAINS, []), + exclude.get(CONF_ENTITIES, [])) self.exclude_t = exclude.get(CONF_EVENT_TYPES, []) self.get_session = None - self.purge_task = object() @callback def async_initialize(self): """Initialize the recorder.""" self.hass.bus.async_listen(MATCH_ALL, self.event_listener) - def do_purge(self, purge_days=None): - """Event listener for purging data.""" - if purge_days is not None: - self.purge_days = purge_days - self.queue.put(self.purge_task) + def do_adhoc_purge(self, keep_days): + """Trigger an adhoc purge retaining keep_days worth of data.""" + if keep_days is not None: + self.queue.put(PurgeTask(keep_days)) def run(self): """Start processing events to save.""" @@ -263,6 +259,27 @@ class Recorder(threading.Thread): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, notify_hass_started) + if self.keep_days and self.purge_interval: + @callback + def async_purge(now): + """Trigger the purge and schedule the next run.""" + self.queue.put(PurgeTask(self.keep_days)) + self.hass.helpers.event.async_track_point_in_time( + async_purge, now + timedelta(days=self.purge_interval)) + + earliest = dt_util.utcnow() + timedelta(minutes=30) + run = latest = dt_util.utcnow() + \ + timedelta(days=self.purge_interval) + with session_scope(session=self.get_session()) as session: + event = session.query(Events).first() + if event is not None: + session.expunge(event) + run = dt_util.as_utc(event.time_fired) + \ + timedelta(days=self.keep_days+self.purge_interval) + run = min(latest, max(run, earliest)) + self.hass.helpers.event.async_track_point_in_time( + async_purge, run) + self.hass.add_job(register) result = hass_started.result() @@ -278,8 +295,9 @@ class Recorder(threading.Thread): self._close_connection() self.queue.task_done() return - elif event is self.purge_task: - purge.purge_old_data(self, self.purge_days) + elif isinstance(event, PurgeTask): + purge.purge_old_data(self, event.keep_days) + self.queue.task_done() continue elif event.event_type == EVENT_TIME_CHANGED: self.queue.task_done() @@ -290,21 +308,7 @@ class Recorder(threading.Thread): entity_id = event.data.get(ATTR_ENTITY_ID) if entity_id is not None: - domain = split_entity_id(entity_id)[0] - - # Exclude entities OR - # Exclude domains, but include specific entities - if (entity_id in self.exclude) or \ - (domain in self.exclude and - entity_id not in self.include_e): - self.queue.task_done() - continue - - # Included domains only (excluded entities above) OR - # Include entities only, but only if no excludes - if (self.include_d and domain not in self.include_d) or \ - (self.include_e and entity_id not in self.include_e - and not self.exclude): + if not self.entity_filter(entity_id): self.queue.task_done() continue diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index fa57e8fc07f..a2a8c9eab8d 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -2,8 +2,7 @@ purge: description: Start purge task - delete events and states older than x days, according to keep_days service data. - fields: keep_days: - description: Number of history days to keep in database after purge. Value >= 0 + description: Number of history days to keep in database after purge. Value >= 0. example: 2 diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py new file mode 100644 index 00000000000..4a788297c60 --- /dev/null +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -0,0 +1,251 @@ +"""Component to interact with Remember The Milk. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/remember_the_milk/ + +Minimum viable product, it currently only support creating new tasks in your +Remember The Milk (https://www.rememberthemilk.com/) account. + +This product uses the Remember The Milk API but is not endorsed or certified +by Remember The Milk. +""" +import logging +import os +import json +import voluptuous as vol + +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (CONF_API_KEY, STATE_OK, CONF_TOKEN, CONF_NAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +# httplib2 is a transitive dependency from RtmAPI. If this dependency is not +# set explicitly, the library does not work. +REQUIREMENTS = ['RtmAPI==0.7.0', 'httplib2==0.10.3'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'remember_the_milk' +DEFAULT_NAME = DOMAIN +GROUP_NAME_RTM = 'remember the milk accounts' + +CONF_SHARED_SECRET = 'shared_secret' + +RTM_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_SHARED_SECRET): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA]) +}, extra=vol.ALLOW_EXTRA) + +CONFIG_FILE_NAME = '.remember_the_milk.conf' +SERVICE_CREATE_TASK = 'create_task' + +SERVICE_SCHEMA_CREATE_TASK = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + + +def setup(hass, config): + """Set up the remember_the_milk component.""" + component = EntityComponent(_LOGGER, DOMAIN, hass, + group_name=GROUP_NAME_RTM) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + stored_rtm_config = RememberTheMilkConfiguration(hass) + for rtm_config in config[DOMAIN]: + account_name = rtm_config[CONF_NAME] + _LOGGER.info("Adding Remember the milk account %s", account_name) + api_key = rtm_config[CONF_API_KEY] + shared_secret = rtm_config[CONF_SHARED_SECRET] + token = stored_rtm_config.get_token(account_name) + if token: + _LOGGER.debug("found token for account %s", account_name) + _create_instance( + hass, account_name, api_key, shared_secret, token, + stored_rtm_config, component, descriptions) + else: + _register_new_account( + hass, account_name, api_key, shared_secret, + stored_rtm_config, component, descriptions) + + _LOGGER.debug("Finished adding all Remember the milk accounts") + return True + + +def _create_instance(hass, account_name, api_key, shared_secret, + token, stored_rtm_config, component, descriptions): + entity = RememberTheMilk(account_name, api_key, shared_secret, + token, stored_rtm_config) + component.add_entity(entity) + hass.services.async_register( + DOMAIN, '{}_create_task'.format(account_name), entity.create_task, + description=descriptions.get(SERVICE_CREATE_TASK), + schema=SERVICE_SCHEMA_CREATE_TASK) + + +def _register_new_account(hass, account_name, api_key, shared_secret, + stored_rtm_config, component, descriptions): + from rtmapi import Rtm + + request_id = None + configurator = hass.components.configurator + api = Rtm(api_key, shared_secret, "write", None) + url, frob = api.authenticate_desktop() + _LOGGER.debug('sent authentication request to server') + + def register_account_callback(_): + """Callback for configurator.""" + api.retrieve_token(frob) + token = api.token + if api.token is None: + _LOGGER.error('Failed to register, please try again.') + configurator.notify_errors( + request_id, + 'Failed to register, please try again.') + return + + stored_rtm_config.set_token(account_name, token) + _LOGGER.debug('retrieved new token from server') + + _create_instance( + hass, account_name, api_key, shared_secret, token, + stored_rtm_config, component, descriptions) + + configurator.request_done(request_id) + + request_id = configurator.async_request_config( + '{} - {}'.format(DOMAIN, account_name), + callback=register_account_callback, + description='You need to log in to Remember The Milk to' + + 'connect your account. \n\n' + + 'Step 1: Click on the link "Remember The Milk login"\n\n' + + 'Step 2: Click on "login completed"', + link_name='Remember The Milk login', + link_url=url, + submit_caption="login completed", + ) + + +class RememberTheMilkConfiguration(object): + """Internal configuration data for RememberTheMilk class. + + This class stores the authentication token it get from the backend. + """ + + def __init__(self, hass): + """Create new instance of configuration.""" + self._config_file_path = hass.config.path(CONFIG_FILE_NAME) + if not os.path.isfile(self._config_file_path): + self._config = dict() + return + try: + _LOGGER.debug('loading configuration from file: %s', + self._config_file_path) + with open(self._config_file_path, 'r') as config_file: + self._config = json.load(config_file) + except ValueError: + _LOGGER.error('failed to load configuration file, creating a ' + 'new one: %s', self._config_file_path) + self._config = dict() + + def save_config(self): + """Write the configuration to a file.""" + with open(self._config_file_path, 'w') as config_file: + json.dump(self._config, config_file) + + def get_token(self, profile_name): + """Get the server token for a profile.""" + if profile_name in self._config: + return self._config[profile_name][CONF_TOKEN] + return None + + def set_token(self, profile_name, token): + """Store a new server token for a profile.""" + if profile_name not in self._config: + self._config[profile_name] = dict() + self._config[profile_name][CONF_TOKEN] = token + self.save_config() + + def delete_token(self, profile_name): + """Delete a token for a profile. + + Usually called when the token has expired. + """ + self._config.pop(profile_name, None) + self.save_config() + + +class RememberTheMilk(Entity): + """MVP implementation of an interface to Remember The Milk.""" + + def __init__(self, name, api_key, shared_secret, token, rtm_config): + """Create new instance of Remember The Milk component.""" + import rtmapi + + self._name = name + self._api_key = api_key + self._shared_secret = shared_secret + self._token = token + self._rtm_config = rtm_config + self._rtm_api = rtmapi.Rtm(api_key, shared_secret, "delete", token) + self._token_valid = None + self._check_token() + _LOGGER.debug("instance created for account %s", self._name) + + def _check_token(self): + """Check if the API token is still valid. + + If it is not valid any more, delete it from the configuration. This + will trigger a new authentication process. + """ + valid = self._rtm_api.token_valid() + if not valid: + _LOGGER.error('Token for account %s is invalid. You need to ' + 'register again!', self.name) + self._rtm_config.delete_token(self._name) + self._token_valid = False + else: + self._token_valid = True + return self._token_valid + + def create_task(self, call): + """Create a new task on Remember The Milk. + + You can use the smart syntax to define the attribues of a new task, + e.g. "my task #some_tag ^today" will add tag "some_tag" and set the + due date to today. + """ + import rtmapi + + try: + task_name = call.data.get('name') + result = self._rtm_api.rtm.timelines.create() + timeline = result.timeline.value + self._rtm_api.rtm.tasks.add( + timeline=timeline, name=task_name, parse='1') + _LOGGER.debug('created new task "%s" in account %s', + task_name, self.name) + except rtmapi.RtmRequestFailedException as rtm_exception: + _LOGGER.error('Error creating new Remember The Milk task for ' + 'account %s: %s', self._name, rtm_exception) + return False + return True + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if not self._token_valid: + return 'API token invalid' + return STATE_OK diff --git a/homeassistant/components/remember_the_milk/services.yaml b/homeassistant/components/remember_the_milk/services.yaml new file mode 100644 index 00000000000..ebf242013f1 --- /dev/null +++ b/homeassistant/components/remember_the_milk/services.yaml @@ -0,0 +1,9 @@ +# Describes the format for available Remember The Milk services + +create_task: + description: Create a new task in your Remember The Milk account + + fields: + name: + description: name of the new task, you can use the smart syntax here + example: 'do this ^today #from_hass' \ No newline at end of file diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index e59cd709a71..2a1deebdc7b 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -1,56 +1,51 @@ # Describes the format for available remote services turn_on: - description: Sends the Power On Command - + description: Sends the Power On Command. fields: entity_id: - description: Name(s) of entities to turn on + description: Name(s) of entities to turn on. example: 'remote.family_room' activity: - description: Activity ID or Activity Name to start + description: Activity ID or Activity Name to start. example: 'BedroomTV' toggle: - description: Toggles a device - + description: Toggles a device. fields: entity_id: - description: Name(s) of entities to toggle + description: Name(s) of entities to toggle. example: 'remote.family_room' turn_off: - description: Sends the Power Off Command - + description: Sends the Power Off Command. fields: entity_id: - description: Name(s) of entities to turn off + description: Name(s) of entities to turn off. example: 'remote.family_room' send_command: - description: Sends a single command to a single device - + description: Sends a single command to a single device. fields: entity_id: - description: Name(s) of entities to send command from + description: Name(s) of entities to send command from. example: 'remote.family_room' device: - description: Device ID to send command to + description: Device ID to send command to. example: '32756745' command: description: A single command or a list of commands to send. example: 'Play' num_repeats: - description: An optional value that specifies the number of times you want to repeat the command(s). If not specified, the command(s) will not be repeated + description: An optional value that specifies the number of times you want to repeat the command(s). If not specified, the command(s) will not be repeated. example: '5' delay_secs: - description: An optional value that specifies that number of seconds you want to wait in between repeated commands. If not specified, the default of 0.4 seconds will be used + description: An optional value that specifies that number of seconds you want to wait in between repeated commands. If not specified, the default of 0.4 seconds will be used. example: '0.75' harmony_sync: - description: Syncs the remote's configuration - + description: Syncs the remote's configuration. fields: entity_id: - description: Name(s) of entities to sync + description: Name(s) of entities to sync. example: 'remote.family_room' diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index 0c5acd3f7fa..8b730bf97f2 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/rfxtrx/ """ +import asyncio import logging from collections import OrderedDict import voluptuous as vol @@ -244,15 +245,13 @@ def get_pt2262_cmd(device_id, data_bits): def get_pt2262_device(device_id): """Look for the device which id matches the given device_id parameter.""" for dev_id, device in RFX_DEVICES.items(): - try: - if device.masked_id == get_pt2262_deviceid(device_id, - device.data_bits): - _LOGGER.info("rfxtrx: found matching device %s for %s", - device_id, - get_pt2262_deviceid(device_id, device.data_bits)) - return device - except AttributeError: - continue + if (hasattr(device, 'is_lighting4') and + device.masked_id == get_pt2262_deviceid(device_id, + device.data_bits)): + _LOGGER.info("rfxtrx: found matching device %s for %s", + device_id, + device.masked_id) + return device return None @@ -260,7 +259,7 @@ def get_pt2262_device(device_id): def find_possible_pt2262_device(device_id): """Look for the device which id matches the given device_id parameter.""" for dev_id, device in RFX_DEVICES.items(): - if len(dev_id) == len(device_id): + if hasattr(device, 'is_lighting4') and len(dev_id) == len(device_id): size = None for i in range(0, len(dev_id)): if dev_id[i] != device_id[i]: @@ -395,6 +394,12 @@ class RfxtrxDevice(Entity): self._state = datas[ATTR_STATE] self._should_fire_event = datas[ATTR_FIREEVENT] self._brightness = 0 + self.added_to_hass = False + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe RFXtrx events.""" + self.added_to_hass = True @property def should_poll(self): @@ -429,7 +434,8 @@ class RfxtrxDevice(Entity): """Update det state of the device.""" self._state = state self._brightness = brightness - self.schedule_update_ha_state() + if self.added_to_hass: + self.schedule_update_ha_state() def _send_command(self, command, brightness=0): if not self._event: @@ -469,4 +475,5 @@ class RfxtrxDevice(Entity): self._event.device.send_stop(self.hass.data[RFXOBJECT] .transport) - self.schedule_update_ha_state() + if self.added_to_hass: + self.schedule_update_ha_state() diff --git a/homeassistant/components/ring.py b/homeassistant/components/ring.py index a1529fddbd6..701889d60b5 100644 --- a/homeassistant/components/ring.py +++ b/homeassistant/components/ring.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from requests.exceptions import HTTPError, ConnectTimeout -REQUIREMENTS = ['ring_doorbell==0.1.4'] +REQUIREMENTS = ['ring_doorbell==0.1.6'] _LOGGER = logging.getLogger(__name__) @@ -21,6 +21,7 @@ CONF_ATTRIBUTION = "Data provided by Ring.com" NOTIFICATION_ID = 'ring_notification' NOTIFICATION_TITLE = 'Ring Sensor Setup' +DATA_RING = 'ring' DOMAIN = 'ring' DEFAULT_CACHEDB = '.ring_cache.pickle' DEFAULT_ENTITY_NAMESPACE = 'ring' diff --git a/homeassistant/components/scene/hunterdouglas_powerview.py b/homeassistant/components/scene/hunterdouglas_powerview.py index 0f5ba85c342..4f5ac5725a3 100644 --- a/homeassistant/components/scene/hunterdouglas_powerview.py +++ b/homeassistant/components/scene/hunterdouglas_powerview.py @@ -16,7 +16,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiopvapi==1.4'] +REQUIREMENTS = ['aiopvapi==1.5.4'] ENTITY_ID_FORMAT = DOMAIN + '.{}' HUB_ADDRESS = 'address' @@ -39,46 +39,53 @@ STATE_ATTRIBUTE_ROOM_NAME = 'roomName' @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up home assistant scene entries.""" - from aiopvapi.hub import Hub + # from aiopvapi.hub import Hub + from aiopvapi.scenes import Scenes + from aiopvapi.rooms import Rooms + from aiopvapi.resources.scene import Scene as PvScene hub_address = config.get(HUB_ADDRESS) websession = async_get_clientsession(hass) - _hub = Hub(hub_address, hass.loop, websession) - _scenes = yield from _hub.scenes.get_scenes() - _rooms = yield from _hub.rooms.get_rooms() + _scenes = yield from Scenes( + hub_address, hass.loop, websession).get_resources() + _rooms = yield from Rooms( + hub_address, hass.loop, websession).get_resources() if not _scenes or not _rooms: + _LOGGER.error( + "Unable to initialize PowerView hub: %s", hub_address) return - pvscenes = (PowerViewScene(hass, _scene, _rooms, _hub) - for _scene in _scenes[SCENE_DATA]) + pvscenes = (PowerViewScene(hass, + PvScene(_raw_scene, hub_address, hass.loop, + websession), _rooms) + for _raw_scene in _scenes[SCENE_DATA]) async_add_devices(pvscenes) class PowerViewScene(Scene): """Representation of a Powerview scene.""" - def __init__(self, hass, scene_data, room_data, hub): + def __init__(self, hass, scene, room_data): """Initialize the scene.""" - self.hub = hub + self._scene = scene self.hass = hass - self._sync_room_data(room_data, scene_data) - self._name = scene_data[SCENE_NAME] - self._scene_id = scene_data[SCENE_ID] + self._room_name = None + self._sync_room_data(room_data) self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, str(scene_data[SCENE_ID]), hass=hass) + ENTITY_ID_FORMAT, str(self._scene.id), hass=hass) - def _sync_room_data(self, room_data, scene_data): - """Sync the room data.""" + def _sync_room_data(self, room_data): + """Sync room data.""" room = next((room for room in room_data[ROOM_DATA] - if room[ROOM_ID] == scene_data[ROOM_ID_IN_SCENE]), {}) + if room[ROOM_ID] == self._scene.room_id), {}) self._room_name = room.get(ROOM_NAME, '') @property def name(self): """Return the name of the scene.""" - return self._name + return self._scene.name @property def device_state_attributes(self): @@ -92,4 +99,4 @@ class PowerViewScene(Scene): def async_activate(self): """Activate scene. Try to get entities into requested state.""" - yield from self.hub.scenes.activate_scene(self._scene_id) + yield from self._scene.activate() diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index 82486a63d31..56ddf7adcab 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_STATE, - CONF_SHOW_ON_MAP) + CONF_SHOW_ON_MAP, CONF_RADIUS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -31,7 +31,6 @@ ATTR_TIMESTAMP = 'timestamp' CONF_CITY = 'city' CONF_COUNTRY = 'country' -CONF_RADIUS = 'radius' CONF_ATTRIBUTION = "Data provided by AirVisual" MASS_PARTS_PER_MILLION = 'ppm' diff --git a/homeassistant/components/sensor/citybikes.py b/homeassistant/components/sensor/citybikes.py index a7bf7533e32..a13d2ca8d56 100644 --- a/homeassistant/components/sensor/citybikes.py +++ b/homeassistant/components/sensor/citybikes.py @@ -12,46 +12,50 @@ import aiohttp import async_timeout 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_LATITUDE, CONF_LONGITUDE, + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, ATTR_ATTRIBUTION, ATTR_LOCATION, ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_FRIENDLY_NAME, STATE_UNKNOWN, LENGTH_METERS, LENGTH_FEET) + ATTR_FRIENDLY_NAME, STATE_UNKNOWN, LENGTH_METERS, LENGTH_FEET, + ATTR_ID) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.entity import Entity from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import location, distance -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +ATTR_EMPTY_SLOTS = 'empty_slots' +ATTR_EXTRA = 'extra' +ATTR_FREE_BIKES = 'free_bikes' +ATTR_NAME = 'name' +ATTR_NETWORK = 'network' +ATTR_NETWORKS_LIST = 'networks' +ATTR_STATIONS_LIST = 'stations' +ATTR_TIMESTAMP = 'timestamp' +ATTR_UID = 'uid' + +CONF_NETWORK = 'network' +CONF_STATIONS_LIST = 'stations' + DEFAULT_ENDPOINT = 'https://api.citybik.es/{uri}' +DOMAIN = 'citybikes' + +MONITORED_NETWORKS = 'monitored-networks' + NETWORKS_URI = 'v2/networks' -STATIONS_URI = 'v2/networks/{uid}?fields=network.stations' REQUEST_TIMEOUT = 5 # In seconds; argument to asyncio.timeout + SCAN_INTERVAL = timedelta(minutes=5) # Timely, and doesn't suffocate the API -DOMAIN = 'citybikes' -MONITORED_NETWORKS = 'monitored-networks' -CONF_NETWORK = 'network' -CONF_RADIUS = 'radius' -CONF_STATIONS_LIST = 'stations' -ATTR_NETWORKS_LIST = 'networks' -ATTR_NETWORK = 'network' -ATTR_STATIONS_LIST = 'stations' -ATTR_ID = 'id' -ATTR_UID = 'uid' -ATTR_NAME = 'name' -ATTR_EXTRA = 'extra' -ATTR_TIMESTAMP = 'timestamp' -ATTR_EMPTY_SLOTS = 'empty_slots' -ATTR_FREE_BIKES = 'free_bikes' -ATTR_TIMESTAMP = 'timestamp' + +STATIONS_URI = 'v2/networks/{uid}?fields=network.stations' + CITYBIKES_ATTRIBUTION = "Information provided by the CityBikes Project "\ "(https://citybik.es/#about)" - PLATFORM_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_RADIUS, CONF_STATIONS_LIST), PLATFORM_SCHEMA.extend({ @@ -61,10 +65,7 @@ PLATFORM_SCHEMA = vol.All( vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude, vol.Optional(CONF_RADIUS, 'station_filter'): cv.positive_int, vol.Optional(CONF_STATIONS_LIST, 'station_filter'): - vol.All( - cv.ensure_list, - vol.Length(min=1), - [cv.string]) + vol.All(cv.ensure_list, vol.Length(min=1), [cv.string]) })) NETWORK_SCHEMA = vol.Schema({ @@ -88,9 +89,8 @@ STATION_SCHEMA = vol.Schema({ vol.Required(ATTR_ID): cv.string, vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_TIMESTAMP): cv.string, - vol.Optional(ATTR_EXTRA): vol.Schema({ - vol.Optional(ATTR_UID): cv.string - }, extra=vol.REMOVE_EXTRA) + vol.Optional(ATTR_EXTRA): + vol.Schema({vol.Optional(ATTR_UID): cv.string}, extra=vol.REMOVE_EXTRA) }, extra=vol.REMOVE_EXTRA) STATIONS_RESPONSE_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/sensor/coinmarketcap.py b/homeassistant/components/sensor/coinmarketcap.py index 616b30abf2b..dc5048a8176 100644 --- a/homeassistant/components/sensor/coinmarketcap.py +++ b/homeassistant/components/sensor/coinmarketcap.py @@ -12,26 +12,28 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_CURRENCY +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_CURRENCY, CONF_DISPLAY_CURRENCY) from homeassistant.helpers.entity import Entity REQUIREMENTS = ['coinmarketcap==4.1.1'] _LOGGER = logging.getLogger(__name__) -ATTR_24H_VOLUME_USD = '24h_volume_usd' +ATTR_24H_VOLUME = '24h_volume' ATTR_AVAILABLE_SUPPLY = 'available_supply' -ATTR_MARKET_CAP = 'market_cap_usd' +ATTR_MARKET_CAP = 'market_cap' ATTR_NAME = 'name' ATTR_PERCENT_CHANGE_24H = 'percent_change_24h' ATTR_PERCENT_CHANGE_7D = 'percent_change_7d' -ATTR_PRICE = 'price_usd' +ATTR_PRICE = 'price' ATTR_SYMBOL = 'symbol' ATTR_TOTAL_SUPPLY = 'total_supply' CONF_ATTRIBUTION = "Data provided by CoinMarketCap" DEFAULT_CURRENCY = 'bitcoin' +DEFAULT_DISPLAY_CURRENCY = 'USD' ICON = 'mdi:currency-usd' @@ -39,21 +41,26 @@ SCAN_INTERVAL = timedelta(minutes=15) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string, + vol.Optional(CONF_DISPLAY_CURRENCY, default=DEFAULT_DISPLAY_CURRENCY): + cv.string, }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the CoinMarketCap sensor.""" currency = config.get(CONF_CURRENCY) + display_currency = config.get(CONF_DISPLAY_CURRENCY).lower() try: - CoinMarketCapData(currency).update() + CoinMarketCapData(currency, display_currency).update() except HTTPError: - _LOGGER.warning("Currency %s is not available. Using bitcoin", - currency) + _LOGGER.warning("Currency %s or display currency %s is not available. " + "Using bitcoin and USD.", currency, display_currency) currency = DEFAULT_CURRENCY + display_currency = DEFAULT_DISPLAY_CURRENCY - add_devices([CoinMarketCapSensor(CoinMarketCapData(currency))], True) + add_devices([CoinMarketCapSensor( + CoinMarketCapData(currency, display_currency))], True) class CoinMarketCapSensor(Entity): @@ -63,7 +70,7 @@ class CoinMarketCapSensor(Entity): """Initialize the sensor.""" self.data = data self._ticker = None - self._unit_of_measurement = 'USD' + self._unit_of_measurement = self.data.display_currency.upper() @property def name(self): @@ -73,7 +80,8 @@ class CoinMarketCapSensor(Entity): @property def state(self): """Return the state of the sensor.""" - return round(float(self._ticker.get('price_usd')), 2) + return round(float(self._ticker.get( + 'price_{}'.format(self.data.display_currency))), 2) @property def unit_of_measurement(self): @@ -89,10 +97,12 @@ class CoinMarketCapSensor(Entity): def device_state_attributes(self): """Return the state attributes of the sensor.""" return { - ATTR_24H_VOLUME_USD: self._ticker.get('24h_volume_usd'), + ATTR_24H_VOLUME: self._ticker.get( + '24h_volume_{}'.format(self.data.display_currency)), ATTR_ATTRIBUTION: CONF_ATTRIBUTION, ATTR_AVAILABLE_SUPPLY: self._ticker.get('available_supply'), - ATTR_MARKET_CAP: self._ticker.get('market_cap_usd'), + ATTR_MARKET_CAP: self._ticker.get( + 'market_cap_{}'.format(self.data.display_currency)), ATTR_PERCENT_CHANGE_24H: self._ticker.get('percent_change_24h'), ATTR_PERCENT_CHANGE_7D: self._ticker.get('percent_change_7d'), ATTR_SYMBOL: self._ticker.get('symbol'), @@ -108,12 +118,16 @@ class CoinMarketCapSensor(Entity): class CoinMarketCapData(object): """Get the latest data and update the states.""" - def __init__(self, currency): + def __init__(self, currency, display_currency): """Initialize the data object.""" self.currency = currency + self.display_currency = display_currency self.ticker = None def update(self): """Get the latest data from blockchain.info.""" from coinmarketcap import Market - self.ticker = Market().ticker(self.currency, limit=1) + self.ticker = Market().ticker( + self.currency, + limit=1, + convert=self.display_currency) diff --git a/homeassistant/components/sensor/crimereports.py b/homeassistant/components/sensor/crimereports.py index b6e5ea33216..aecfca60bf1 100644 --- a/homeassistant/components/sensor/crimereports.py +++ b/homeassistant/components/sensor/crimereports.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_INCLUDE, CONF_EXCLUDE, CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, - ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_RADIUS, LENGTH_KILOMETERS, LENGTH_METERS) from homeassistant.helpers.entity import Entity from homeassistant.util import slugify @@ -25,8 +25,6 @@ REQUIREMENTS = ['crimereports==1.0.0'] _LOGGER = logging.getLogger(__name__) -CONF_RADIUS = 'radius' - DOMAIN = 'crimereports' EVENT_INCIDENT = '{}_incident'.format(DOMAIN) @@ -58,11 +56,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class CrimeReportsSensor(Entity): - """Crime Reports Sensor.""" + """Representation of a Crime Reports Sensor.""" def __init__(self, hass, name, latitude, longitude, radius, include, exclude): - """Initialize the sensor.""" + """Initialize the Crime Reports sensor.""" import crimereports self._hass = hass self._name = name diff --git a/homeassistant/components/sensor/deluge.py b/homeassistant/components/sensor/deluge.py new file mode 100644 index 00000000000..f4793867d4c --- /dev/null +++ b/homeassistant/components/sensor/deluge.py @@ -0,0 +1,129 @@ +""" +Support for monitoring the Deluge BitTorrent client API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.deluge/ +""" +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_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, + CONF_MONITORED_VARIABLES, STATE_IDLE) +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['deluge-client==1.0.5'] + +_LOGGER = logging.getLogger(__name__) +_THROTTLED_REFRESH = None + +DEFAULT_NAME = 'Deluge' +DEFAULT_PORT = 58846 +DHT_UPLOAD = 1000 +DHT_DOWNLOAD = 1000 + +SENSOR_TYPES = { + 'current_status': ['Status', None], + 'download_speed': ['Down Speed', 'kB/s'], + 'upload_speed': ['Up Speed', 'kB/s'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Deluge sensors.""" + from deluge_client import DelugeRPCClient + + 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) + + deluge_api = DelugeRPCClient(host, port, username, password) + try: + deluge_api.connect() + except ConnectionRefusedError: + _LOGGER.error("Connection to Deluge Daemon failed") + return + + dev = [] + for variable in config[CONF_MONITORED_VARIABLES]: + dev.append(DelugeSensor(variable, deluge_api, name)) + + add_devices(dev) + + +class DelugeSensor(Entity): + """Representation of a Deluge sensor.""" + + def __init__(self, sensor_type, deluge_client, client_name): + """Initialize the sensor.""" + self._name = SENSOR_TYPES[sensor_type][0] + self.client = deluge_client + self.type = sensor_type + self.client_name = client_name + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.data = None + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self.client_name, self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from Deluge and updates the state.""" + self.data = self.client.call('core.get_session_status', + ['upload_rate', 'download_rate', + 'dht_upload_rate', 'dht_download_rate']) + + upload = self.data[b'upload_rate'] - self.data[b'dht_upload_rate'] + download = self.data[b'download_rate'] - self.data[ + b'dht_download_rate'] + + if self.type == 'current_status': + if self.data: + if upload > 0 and download > 0: + self._state = 'Up/Down' + elif upload > 0 and download == 0: + self._state = 'Seeding' + elif upload == 0 and download > 0: + self._state = 'Downloading' + else: + self._state = STATE_IDLE + else: + self._state = None + + if self.data: + if self.type == 'download_speed': + kb_spd = float(download) + kb_spd = kb_spd / 1024 + self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1) + elif self.type == 'upload_speed': + kb_spd = float(upload) + kb_spd = kb_spd / 1024 + self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1) diff --git a/homeassistant/components/sensor/emoncms.py b/homeassistant/components/sensor/emoncms.py index 8178d0cc46f..fc1daf151c7 100644 --- a/homeassistant/components/sensor/emoncms.py +++ b/homeassistant/components/sensor/emoncms.py @@ -112,8 +112,13 @@ class EmonCmsSensor(Entity): unit_of_measurement, sensorid, elem): """Initialize the sensor.""" if name is None: - self._name = "emoncms{}_feedid_{}".format( - sensorid, elem["id"]) + # Suppress ID in sensor name if it's 1, since most people won't + # have more than one EmonCMS source and it's redundant to show the + # ID if there's only one. + id_for_name = '' if str(sensorid) == '1' else sensorid + # Use the feed name assigned in EmonCMS or fall back to the feed ID + feed_name = elem.get('name') or 'Feed {}'.format(elem['id']) + self._name = "EmonCMS{} {}".format(id_for_name, feed_name) else: self._name = name self._identifier = get_id( diff --git a/homeassistant/components/sensor/fail2ban.py b/homeassistant/components/sensor/fail2ban.py new file mode 100644 index 00000000000..a343a59c314 --- /dev/null +++ b/homeassistant/components/sensor/fail2ban.py @@ -0,0 +1,145 @@ +""" +Support for displaying IPs banned by fail2ban. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fail2ban/ +""" +import os +import asyncio +import logging + +from datetime import timedelta + +import re +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_SCAN_INTERVAL, CONF_FILE_PATH +) +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_JAILS = 'jails' + +DEFAULT_NAME = 'fail2ban' +DEFAULT_LOG = '/var/log/fail2ban.log' +SCAN_INTERVAL = timedelta(seconds=120) + +STATE_CURRENT_BANS = 'current_bans' +STATE_ALL_BANS = 'total_bans' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_JAILS, default=[]): + vol.All(cv.ensure_list, vol.Length(min=1)), + vol.Optional(CONF_FILE_PATH, default=DEFAULT_LOG): cv.isfile, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the fail2ban sensor.""" + name = config.get(CONF_NAME) + jails = config.get(CONF_JAILS) + scan_interval = config.get(CONF_SCAN_INTERVAL) + log_file = config.get(CONF_FILE_PATH) + + device_list = [] + log_parser = BanLogParser(scan_interval, log_file) + for jail in jails: + device_list.append(BanSensor(name, jail, log_parser)) + + async_add_devices(device_list, True) + + +class BanSensor(Entity): + """Implementation of a fail2ban sensor.""" + + def __init__(self, name, jail, log_parser): + """Initialize the sensor.""" + self._name = '{} {}'.format(name, jail) + self.jail = jail + self.ban_dict = {STATE_CURRENT_BANS: [], STATE_ALL_BANS: []} + self.last_ban = None + self.log_parser = log_parser + self.log_parser.ip_regex[self.jail] = re.compile( + r"\[{}\].(Ban|Unban) ([\w+\.]{{3,}})".format(re.escape(self.jail)) + ) + _LOGGER.debug("Setting up jail %s", self.jail) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state_attributes(self): + """Return the state attributes of the fail2ban sensor.""" + return self.ban_dict + + @property + def state(self): + """Return the most recently banned IP Address.""" + return self.last_ban + + def update(self): + """Update the list of banned ips.""" + if self.log_parser.timer(): + self.log_parser.read_log(self.jail) + + if self.log_parser.data: + for entry in self.log_parser.data: + _LOGGER.debug(entry) + current_ip = entry[1] + if entry[0] == 'Ban': + if current_ip not in self.ban_dict[STATE_CURRENT_BANS]: + self.ban_dict[STATE_CURRENT_BANS].append(current_ip) + if current_ip not in self.ban_dict[STATE_ALL_BANS]: + self.ban_dict[STATE_ALL_BANS].append(current_ip) + if len(self.ban_dict[STATE_ALL_BANS]) > 10: + self.ban_dict[STATE_ALL_BANS].pop(0) + + elif entry[0] == 'Unban': + if current_ip in self.ban_dict[STATE_CURRENT_BANS]: + self.ban_dict[STATE_CURRENT_BANS].remove(current_ip) + + if self.ban_dict[STATE_CURRENT_BANS]: + self.last_ban = self.ban_dict[STATE_CURRENT_BANS][-1] + else: + self.last_ban = 'None' + + +class BanLogParser(object): + """Class to parse fail2ban logs.""" + + def __init__(self, interval, log_file): + """Initialize the parser.""" + self.interval = interval + self.log_file = log_file + self.data = list() + self.last_update = dt_util.now() + self.ip_regex = dict() + + def timer(self): + """Check if we are allowed to update.""" + boundary = dt_util.now() - self.interval + if boundary > self.last_update: + self.last_update = dt_util.now() + return True + return False + + def read_log(self, jail): + """Read the fail2ban log and find entries for jail.""" + self.data = list() + try: + with open(self.log_file, 'r', encoding='utf-8') as file_data: + self.data = self.ip_regex[jail].findall(file_data.read()) + + except (IndexError, FileNotFoundError, IsADirectoryError, + UnboundLocalError): + _LOGGER.warning("File not present: %s", + os.path.basename(self.log_file)) diff --git a/homeassistant/components/sensor/geo_rss_events.py b/homeassistant/components/sensor/geo_rss_events.py index 484dd67e0e4..c8c4db17c8d 100644 --- a/homeassistant/components/sensor/geo_rss_events.py +++ b/homeassistant/components/sensor/geo_rss_events.py @@ -8,7 +8,6 @@ and grouped by category. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.geo_rss_events/ """ - import logging from collections import namedtuple from datetime import timedelta @@ -17,8 +16,8 @@ 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) +from homeassistant.const import ( + STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_RADIUS, CONF_URL) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -31,8 +30,6 @@ ATTR_DISTANCE = 'distance' ATTR_TITLE = 'title' CONF_CATEGORIES = 'categories' -CONF_RADIUS = 'radius' -CONF_URL = 'url' DEFAULT_ICON = 'mdi:alert' DEFAULT_NAME = "Event Service" @@ -50,8 +47,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_URL): cv.string, vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_CATEGORIES, default=[]): vol.All(cv.ensure_list, - [cv.string]), + vol.Optional(CONF_CATEGORIES, default=[]): + vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=DEFAULT_UNIT_OF_MEASUREMENT): cv.string, }) @@ -59,7 +56,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the GeoRSS component.""" - # Grab location from config home_latitude = hass.config.latitude home_longitude = hass.config.longitude url = config.get(CONF_URL) @@ -154,7 +150,7 @@ class GeoRssServiceSensor(Entity): class GeoRssServiceData(object): - """Provides access to GeoRSS feed and stores the latest data.""" + """Provide access to GeoRSS feed and stores the latest data.""" def __init__(self, home_latitude, home_longitude, url, radius_in_km): """Initialize the update service.""" diff --git a/homeassistant/components/sensor/gitter.py b/homeassistant/components/sensor/gitter.py index 5a41046a948..58f33635750 100644 --- a/homeassistant/components/sensor/gitter.py +++ b/homeassistant/components/sensor/gitter.py @@ -10,10 +10,10 @@ 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_API_KEY +from homeassistant.const import CONF_NAME, CONF_API_KEY, CONF_ROOM from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['gitterpy==0.1.5'] +REQUIREMENTS = ['gitterpy==0.1.6'] _LOGGER = logging.getLogger(__name__) @@ -21,8 +21,6 @@ ATTR_MENTION = 'mention' ATTR_ROOM = 'room' ATTR_USERNAME = 'username' -CONF_ROOM = 'room' - DEFAULT_NAME = 'Gitter messages' DEFAULT_ROOM = 'home-assistant/home-assistant' @@ -99,5 +97,8 @@ class GitterSensor(Entity): def update(self): """Get the latest data and updates the state.""" data = self._data.user.unread_items(self._room) - self._mention = len(data['mention']) - self._state = len(data['chat']) + if 'error' not in data.keys(): + self._mention = len(data['mention']) + self._state = len(data['chat']) + else: + _LOGGER.error("Not joined: %s", self._room) diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py index 2d1edbd1bb1..b61b7abeae3 100644 --- a/homeassistant/components/sensor/glances.py +++ b/homeassistant/components/sensor/glances.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_HOST, CONF_PORT, STATE_UNKNOWN, CONF_NAME, CONF_RESOURCES, - TEMP_CELSIUS) + CONF_HOST, CONF_PORT, CONF_NAME, CONF_RESOURCES, TEMP_CELSIUS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -28,21 +27,21 @@ DEFAULT_PORT = '61208' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) SENSOR_TYPES = { - 'disk_use_percent': ['Disk used', '%'], - 'disk_use': ['Disk used', 'GiB'], - 'disk_free': ['Disk free', 'GiB'], - 'memory_use_percent': ['RAM used', '%'], - 'memory_use': ['RAM used', 'MiB'], - 'memory_free': ['RAM free', 'MiB'], - 'swap_use_percent': ['Swap used', '%'], - 'swap_use': ['Swap used', 'GiB'], - 'swap_free': ['Swap free', 'GiB'], - 'processor_load': ['CPU load', '15 min'], - 'process_running': ['Running', 'Count'], - 'process_total': ['Total', 'Count'], - 'process_thread': ['Thread', 'Count'], - 'process_sleeping': ['Sleeping', 'Count'], - 'cpu_temp': ['CPU Temp', TEMP_CELSIUS], + 'disk_use_percent': ['Disk used', '%', 'mdi:harddisk'], + 'disk_use': ['Disk used', 'GiB', 'mdi:harddisk'], + 'disk_free': ['Disk free', 'GiB', 'mdi:harddisk'], + 'memory_use_percent': ['RAM used', '%', 'mdi:memory'], + 'memory_use': ['RAM used', 'MiB', 'mdi:memory'], + 'memory_free': ['RAM free', 'MiB', 'mdi:memory'], + 'swap_use_percent': ['Swap used', '%', 'mdi:memory'], + 'swap_use': ['Swap used', 'GiB', 'mdi:memory'], + 'swap_free': ['Swap free', 'GiB', 'mdi:memory'], + 'processor_load': ['CPU load', '15 min', 'mdi:memory'], + 'process_running': ['Running', 'Count', 'mdi:memory'], + 'process_total': ['Total', 'Count', 'mdi:memory'], + 'process_thread': ['Thread', 'Count', 'mdi:memory'], + 'process_sleeping': ['Sleeping', 'Count', 'mdi:memory'], + 'cpu_temp': ['CPU Temp', TEMP_CELSIUS, 'mdi:thermometer'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -81,16 +80,19 @@ class GlancesSensor(Entity): self.rest = rest self._name = name self.type = sensor_type - self._state = STATE_UNKNOWN + self._state = None self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @property def name(self): """Return the name of the sensor.""" - if self._name is None: - return SENSOR_TYPES[self.type][0] return '{} {}'.format(self._name, SENSOR_TYPES[self.type][0]) + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return SENSOR_TYPES[self.type][2] + @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" @@ -161,7 +163,7 @@ class GlancesData(object): def __init__(self, resource): """Initialize the data object.""" self._resource = resource - self.data = dict() + self.data = {} @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index fe0db29eb92..e7d25872701 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.const import ( CONF_API_KEY, CONF_NAME, EVENT_HOMEASSISTANT_START, ATTR_LATITUDE, - ATTR_LONGITUDE) + ATTR_LONGITUDE, CONF_MODE) from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv import homeassistant.helpers.location as location @@ -25,7 +25,6 @@ REQUIREMENTS = ['googlemaps==2.5.1'] _LOGGER = logging.getLogger(__name__) CONF_DESTINATION = 'destination' -CONF_MODE = 'mode' CONF_OPTIONS = 'options' CONF_ORIGIN = 'origin' CONF_TRAVEL_MODE = 'travel_mode' diff --git a/homeassistant/components/sensor/hddtemp.py b/homeassistant/components/sensor/hddtemp.py index cd84bd8f9a8..e025cd2fbcd 100644 --- a/homeassistant/components/sensor/hddtemp.py +++ b/homeassistant/components/sensor/hddtemp.py @@ -11,11 +11,10 @@ from telnetlib import Telnet import voluptuous as vol 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, CONF_HOST, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, - STATE_UNKNOWN) + CONF_NAME, CONF_HOST, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_DISKS) +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -30,6 +29,7 @@ DEFAULT_TIMEOUT = 5 SCAN_INTERVAL = timedelta(minutes=1) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DISKS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -41,25 +41,34 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) + disks = config.get(CONF_DISKS) hddtemp = HddTempData(host, port) hddtemp.update() if hddtemp.data is None: - _LOGGER.error("Unable to fetch the data from %s:%s", host, port) return False - add_devices([HddTempSensor(name, hddtemp)], True) + if not disks: + disks = [next(iter(hddtemp.data)).split('|')[0]] + + dev = [] + for disk in disks: + if disk in hddtemp.data: + dev.append(HddTempSensor(name, disk, hddtemp)) + + add_devices(dev, True) class HddTempSensor(Entity): """Representation of a HDDTemp sensor.""" - def __init__(self, name, hddtemp): + def __init__(self, name, disk, hddtemp): """Initialize a HDDTemp sensor.""" self.hddtemp = hddtemp - self._name = name - self._state = False + self.disk = disk + self._name = '{} {}'.format(name, disk) + self._state = None self._details = None @property @@ -75,7 +84,7 @@ class HddTempSensor(Entity): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - if self._details[4] == 'C': + if self._details[3] == 'C': return TEMP_CELSIUS return TEMP_FAHRENHEIT @@ -83,19 +92,19 @@ class HddTempSensor(Entity): def device_state_attributes(self): """Return the state attributes of the sensor.""" return { - ATTR_DEVICE: self._details[1], - ATTR_MODEL: self._details[2], + ATTR_DEVICE: self._details[0], + ATTR_MODEL: self._details[1], } def update(self): """Get the latest data from HDDTemp daemon and updates the state.""" self.hddtemp.update() - if self.hddtemp.data is not None: - self._details = self.hddtemp.data.split('|') - self._state = self._details[3] + if self.hddtemp.data and self.disk in self.hddtemp.data: + self._details = self.hddtemp.data[self.disk].split('|') + self._state = self._details[2] else: - self._state = STATE_UNKNOWN + self._state = None class HddTempData(object): @@ -112,7 +121,10 @@ class HddTempData(object): try: connection = Telnet( host=self.host, port=self.port, timeout=DEFAULT_TIMEOUT) - self.data = connection.read_all().decode('ascii') + data = connection.read_all().decode( + 'ascii').lstrip('|').rstrip('|').split('||') + self.data = {data[i].split('|')[0]: data[i] + for i in range(0, len(data), 1)} except ConnectionRefusedError: _LOGGER.error( "HDDTemp is not available at %s:%s", self.host, self.port) diff --git a/homeassistant/components/sensor/imap.py b/homeassistant/components/sensor/imap.py index 6f64f479dec..9f12353221d 100644 --- a/homeassistant/components/sensor/imap.py +++ b/homeassistant/components/sensor/imap.py @@ -23,6 +23,7 @@ _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['aioimaplib==0.7.13'] CONF_SERVER = 'server' +CONF_FOLDER = 'folder' DEFAULT_PORT = 993 @@ -34,6 +35,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_SERVER): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_FOLDER, default='INBOX'): cv.string, }) @@ -44,7 +46,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_USERNAME), config.get(CONF_PASSWORD), config.get(CONF_SERVER), - config.get(CONF_PORT)) + config.get(CONF_PORT), + config.get(CONF_FOLDER)) if not (yield from sensor.connection()): raise PlatformNotReady @@ -56,13 +59,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class ImapSensor(Entity): """Representation of an IMAP sensor.""" - def __init__(self, name, user, password, server, port): + def __init__(self, name, user, password, server, port, folder): """Initialize the sensor.""" self._name = name or user self._user = user self._password = password self._server = server self._port = port + self._folder = folder self._unread_count = 0 self._connection = None self._does_push = None @@ -110,7 +114,7 @@ class ImapSensor(Entity): self._server, self._port) yield from self._connection.wait_hello_from_server() yield from self._connection.login(self._user, self._password) - yield from self._connection.select() + yield from self._connection.select(self._folder) self._does_push = self._connection.has_capability('IDLE') except (aioimaplib.AioImapException, asyncio.TimeoutError): self._connection = None diff --git a/homeassistant/components/sensor/irish_rail_transport.py b/homeassistant/components/sensor/irish_rail_transport.py new file mode 100644 index 00000000000..ad2a312ce63 --- /dev/null +++ b/homeassistant/components/sensor/irish_rail_transport.py @@ -0,0 +1,182 @@ +""" +Support for Irish Rail RTPI information. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.irish_rail_transport/ +""" +import logging +from datetime import timedelta + +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 = ['pyirishrail==0.0.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATION = "Station" +ATTR_ORIGIN = "Origin" +ATTR_DESTINATION = "Destination" +ATTR_DIRECTION = "Direction" +ATTR_STOPS_AT = "Stops at" +ATTR_DUE_IN = "Due in" +ATTR_DUE_AT = "Due at" +ATTR_EXPECT_AT = "Expected at" +ATTR_NEXT_UP = "Later Train" +ATTR_TRAIN_TYPE = "Train type" + +CONF_STATION = 'station' +CONF_DESTINATION = 'destination' +CONF_DIRECTION = 'direction' +CONF_STOPS_AT = 'stops_at' + +DEFAULT_NAME = 'Next Train' +ICON = 'mdi:train' + +SCAN_INTERVAL = timedelta(minutes=2) +TIME_STR_FORMAT = '%H:%M' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STATION): cv.string, + vol.Optional(CONF_DIRECTION, default=None): cv.string, + vol.Optional(CONF_DESTINATION, default=None): cv.string, + vol.Optional(CONF_STOPS_AT, default=None): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Irish Rail transport sensor.""" + from pyirishrail.pyirishrail import IrishRailRTPI + station = config.get(CONF_STATION) + direction = config.get(CONF_DIRECTION) + destination = config.get(CONF_DESTINATION) + stops_at = config.get(CONF_STOPS_AT) + name = config.get(CONF_NAME) + + irish_rail = IrishRailRTPI() + data = IrishRailTransportData( + irish_rail, station, direction, destination, stops_at) + add_devices([IrishRailTransportSensor( + data, station, direction, destination, stops_at, name)], True) + + +class IrishRailTransportSensor(Entity): + """Implementation of an irish rail public transport sensor.""" + + def __init__(self, data, station, direction, destination, stops_at, name): + """Initialize the sensor.""" + self.data = data + self._station = station + self._direction = direction + self._direction = direction + self._stops_at = stops_at + self._name = name + self._state = None + self._times = [] + + @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 device_state_attributes(self): + """Return the state attributes.""" + if len(self._times) > 0: + next_up = "None" + if len(self._times) > 1: + next_up = self._times[1][ATTR_ORIGIN] + " to " + next_up += self._times[1][ATTR_DESTINATION] + " in " + next_up += self._times[1][ATTR_DUE_IN] + + return { + ATTR_STATION: self._station, + ATTR_ORIGIN: self._times[0][ATTR_ORIGIN], + ATTR_DESTINATION: self._times[0][ATTR_DESTINATION], + ATTR_DUE_IN: self._times[0][ATTR_DUE_IN], + ATTR_DUE_AT: self._times[0][ATTR_DUE_AT], + ATTR_EXPECT_AT: self._times[0][ATTR_EXPECT_AT], + ATTR_DIRECTION: self._times[0][ATTR_DIRECTION], + ATTR_STOPS_AT: self._times[0][ATTR_STOPS_AT], + ATTR_NEXT_UP: next_up, + ATTR_TRAIN_TYPE: self._times[0][ATTR_TRAIN_TYPE] + } + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return 'min' + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the latest data and update the states.""" + self.data.update() + self._times = self.data.info + if len(self._times) > 0: + self._state = self._times[0][ATTR_DUE_IN] + else: + self._state = None + + +class IrishRailTransportData(object): + """The Class for handling the data retrieval.""" + + def __init__(self, irish_rail, station, direction, destination, stops_at): + """Initialize the data object.""" + self._ir_api = irish_rail + self.station = station + self.direction = direction + self.destination = destination + self.stops_at = stops_at + self.info = self._empty_train_data() + + def update(self): + """Get the latest data from irishrail.""" + trains = self._ir_api.get_station_by_name(self.station, + direction=self.direction, + destination=self.destination) + stops_at = self.stops_at if self.stops_at else '' + self.info = [] + for train in trains: + train_data = {ATTR_STATION: self.station, + ATTR_ORIGIN: train.get('origin'), + ATTR_DESTINATION: train.get('destination'), + ATTR_DUE_IN: train.get('due_in_mins'), + ATTR_DUE_AT: train.get('scheduled_arrival_time'), + ATTR_EXPECT_AT: train.get('expected_departure_time'), + ATTR_DIRECTION: train.get('direction'), + ATTR_STOPS_AT: stops_at, + ATTR_TRAIN_TYPE: train.get('type')} + self.info.append(train_data) + + if not self.info or len(self.info) == 0: + self.info = self._empty_train_data() + + def _empty_train_data(self): + """Generate info for an empty train.""" + dest = self.destination if self.destination else '' + direction = self.direction if self.direction else '' + stops_at = self.stops_at if self.stops_at else '' + return [{ATTR_STATION: self.station, + ATTR_ORIGIN: '', + ATTR_DESTINATION: dest, + ATTR_DUE_IN: 'n/a', + ATTR_DUE_AT: 'n/a', + ATTR_EXPECT_AT: 'n/a', + ATTR_DIRECTION: direction, + ATTR_STOPS_AT: stops_at, + ATTR_TRAIN_TYPE: ''}] diff --git a/homeassistant/components/sensor/lastfm.py b/homeassistant/components/sensor/lastfm.py index 76a40354829..8f3ac8ebb70 100644 --- a/homeassistant/components/sensor/lastfm.py +++ b/homeassistant/components/sensor/lastfm.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pylast==1.9.0'] +REQUIREMENTS = ['pylast==2.0.0'] ATTR_LAST_PLAYED = 'last_played' ATTR_PLAY_COUNT = 'play_count' diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py new file mode 100644 index 00000000000..e317e89030f --- /dev/null +++ b/homeassistant/components/sensor/luftdaten.py @@ -0,0 +1,142 @@ +""" +Support for Luftdaten sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.luftdaten/ +""" +import asyncio +import json +import logging +from datetime import timedelta + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_RESOURCE, CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS, + TEMP_CELSIUS) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' + +SENSOR_TEMPERATURE = 'temperature' +SENSOR_HUMIDITY = 'humidity' +SENSOR_PM10 = 'P1' +SENSOR_PM2_5 = 'P2' + +SENSOR_TYPES = { + SENSOR_TEMPERATURE: ['Temperature', TEMP_CELSIUS], + SENSOR_HUMIDITY: ['Humidity', '%'], + SENSOR_PM10: ['PM10', VOLUME_MICROGRAMS_PER_CUBIC_METER], + SENSOR_PM2_5: ['PM2.5', VOLUME_MICROGRAMS_PER_CUBIC_METER] +} + +DEFAULT_NAME = 'Luftdaten Sensor' +DEFAULT_RESOURCE = 'https://api.luftdaten.info/v1/sensor/' +DEFAULT_VERIFY_SSL = True + +CONF_SENSORID = 'sensorid' + +SCAN_INTERVAL = timedelta(minutes=3) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSORID): cv.positive_int, + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_RESOURCE, default=DEFAULT_RESOURCE): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Luftdaten sensor.""" + name = config.get(CONF_NAME) + sensorid = config.get(CONF_SENSORID) + verify_ssl = config.get(CONF_VERIFY_SSL) + + resource = '{}{}/'.format(config.get(CONF_RESOURCE), sensorid) + + rest_client = LuftdatenData(resource, verify_ssl) + rest_client.update() + + if rest_client.data is None: + _LOGGER.error("Unable to fetch Luftdaten data") + return False + + devices = [] + for variable in config[CONF_MONITORED_CONDITIONS]: + devices.append(LuftdatenSensor(rest_client, name, variable)) + + async_add_devices(devices, True) + + +class LuftdatenSensor(Entity): + """Implementation of a LuftdatenSensor sensor.""" + + def __init__(self, rest_client, name, sensor_type): + """Initialize the LuftdatenSensor sensor.""" + self.rest_client = rest_client + self._name = name + self._state = None + self.sensor_type = sensor_type + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self._name, SENSOR_TYPES[self.sensor_type][0]) + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from REST API and update the state.""" + self.rest_client.update() + value = self.rest_client.data + + if value is None: + self._state = None + else: + parsed_json = json.loads(value) + + log_entries_count = len(parsed_json) - 1 + latest_log_entry = parsed_json[log_entries_count] + sensordata_values = latest_log_entry['sensordatavalues'] + for sensordata_value in sensordata_values: + if sensordata_value['value_type'] == self.sensor_type: + self._state = sensordata_value['value'] + + +class LuftdatenData(object): + """Class for handling the data retrieval.""" + + def __init__(self, resource, verify_ssl): + """Initialize the data object.""" + self._request = requests.Request('GET', resource).prepare() + self._verify_ssl = verify_ssl + self.data = None + + def update(self): + """Get the latest data from Luftdaten service.""" + try: + with requests.Session() as sess: + response = sess.send( + self._request, timeout=10, verify=self._verify_ssl) + + self.data = response.text + except requests.exceptions.RequestException: + _LOGGER.error("Error fetching data: %s", self._request) + self.data = None diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index 21198fa940b..40c6ce7458c 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -11,13 +11,13 @@ from datetime import timedelta import voluptuous as vol -from homeassistant.core import callback import homeassistant.components.mqtt as mqtt +import homeassistant.helpers.config_validation as cv +from homeassistant.components.mqtt import CONF_STATE_TOPIC from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_TIMEOUT, STATE_NOT_HOME) -from homeassistant.components.mqtt import CONF_STATE_TOPIC -import homeassistant.helpers.config_validation as cv + CONF_NAME, CONF_TIMEOUT, STATE_NOT_HOME, ATTR_ID) +from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.util import dt, slugify @@ -27,16 +27,14 @@ DEPENDENCIES = ['mqtt'] ATTR_DEVICE_ID = 'device_id' ATTR_DISTANCE = 'distance' -ATTR_ID = 'id' ATTR_ROOM = 'room' CONF_DEVICE_ID = 'device_id' -CONF_ROOM = 'room' CONF_AWAY_TIMEOUT = 'away_timeout' +DEFAULT_AWAY_TIMEOUT = 0 DEFAULT_NAME = 'Room Sensor' DEFAULT_TIMEOUT = 5 -DEFAULT_AWAY_TIMEOUT = 0 DEFAULT_TOPIC = 'room_presence' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/sensor/nederlandse_spoorwegen.py b/homeassistant/components/sensor/nederlandse_spoorwegen.py new file mode 100644 index 00000000000..e8d3aa41c6c --- /dev/null +++ b/homeassistant/components/sensor/nederlandse_spoorwegen.py @@ -0,0 +1,167 @@ +""" +Support for Nederlandse Spoorwegen public transport. + +For more details on this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.nederlandse_spoorwegen/ +""" +from datetime import datetime +from datetime import timedelta +import logging + +import voluptuous as vol +import requests + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_EMAIL, CONF_NAME, + CONF_PASSWORD, ATTR_ATTRIBUTION) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['nsapi==2.7.4'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Data provided by NS" +CONF_ROUTES = 'routes' +CONF_FROM = 'from' +CONF_TO = 'to' +CONF_VIA = 'via' + +ICON = 'mdi:train' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) + +ROUTE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_FROM): cv.string, + vol.Required(CONF_TO): cv.string, + vol.Optional(CONF_VIA): cv.string}) + +ROUTES_SCHEMA = vol.All( + cv.ensure_list, + [ROUTE_SCHEMA]) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_ROUTES): ROUTES_SCHEMA, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the departure sensor.""" + import ns_api + nsapi = ns_api.NSAPI( + config.get(CONF_EMAIL), config.get(CONF_PASSWORD)) + try: + stations = nsapi.get_stations() + except (requests.exceptions.ConnectionError, + requests.exceptions.HTTPError) as error: + _LOGGER.error("Couldn't fetch stations, API password correct?: %s", + error) + return + + sensors = [] + for departure in config.get(CONF_ROUTES): + if(not valid_stations(stations, [departure.get(CONF_FROM), + departure.get(CONF_VIA), + departure.get(CONF_TO)])): + continue + sensors.append( + NSDepartureSensor( + nsapi, departure.get(CONF_NAME), departure.get(CONF_FROM), + departure.get(CONF_TO), departure.get(CONF_VIA))) + if len(sensors): + add_devices(sensors, True) + + +def valid_stations(stations, given_stations): + """Verify the existance of the given station codes.""" + for station in given_stations: + if station is None: + continue + if not any(s.code == station.upper() for s in stations): + _LOGGER.warning("Station '%s' is not a valid station.", station) + return False + return True + + +class NSDepartureSensor(Entity): + """Implementation of a NS Departure Sensor.""" + + def __init__(self, nsapi, name, departure, heading, via): + """Initialize the sensor.""" + self._nsapi = nsapi + self._name = name + self._departure = departure + self._via = via + self._heading = heading + self._state = None + self._trips = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the icon for the frontend.""" + return ICON + + @property + def state(self): + """Return the next departure time.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if not self._trips: + return + + if self._trips[0].trip_parts: + route = [self._trips[0].departure] + for k in self._trips[0].trip_parts: + route.append(k.destination) + + return { + 'going': self._trips[0].going, + 'departure_time_planned': + self._trips[0].departure_time_planned.strftime('%H:%M'), + 'departure_time_actual': + self._trips[0].departure_time_actual.strftime('%H:%M'), + 'departure_delay': + self._trips[0].departure_time_planned != + self._trips[0].departure_time_actual, + 'arrival_time_planned': + self._trips[0].arrival_time_planned.strftime('%H:%M'), + 'arrival_time_actual': + self._trips[0].arrival_time_actual.strftime('%H:%M'), + 'arrival_delay': + self._trips[0].arrival_time_planned != + self._trips[0].arrival_time_actual, + 'next': + self._trips[1].departure_time_actual.strftime('%H:%M'), + 'status': self._trips[0].status.lower(), + 'transfers': self._trips[0].nr_transfers, + 'route': route, + 'remarks': [r.message for r in self._trips[0].trip_remarks], + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + } + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the trip information.""" + try: + self._trips = self._nsapi.get_trips( + datetime.now().strftime("%d-%m-%Y %H:%M"), + self._departure, self._via, self._heading, + True, 0) + if self._trips: + actual_time = self._trips[0].departure_time_actual + self._state = actual_time.strftime('%H:%M') + except (requests.exceptions.ConnectionError, + requests.exceptions.HTTPError) as error: + _LOGGER.error("Couldn't fetch trip info: %s", error) diff --git a/homeassistant/components/sensor/opensky.py b/homeassistant/components/sensor/opensky.py index 43c9177f960..bd071ace578 100644 --- a/homeassistant/components/sensor/opensky.py +++ b/homeassistant/components/sensor/opensky.py @@ -10,27 +10,29 @@ from datetime import timedelta import requests 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_LATITUDE, CONF_LONGITUDE, + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, LENGTH_KILOMETERS, LENGTH_METERS) from homeassistant.helpers.entity import Entity from homeassistant.util import distance as util_distance from homeassistant.util import location as util_location -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=12) # opensky public limit is 10 seconds -DOMAIN = 'opensky' -EVENT_OPENSKY_ENTRY = '{}_entry'.format(DOMAIN) -EVENT_OPENSKY_EXIT = '{}_exit'.format(DOMAIN) -CONF_RADIUS = 'radius' +ATTR_CALLSIGN = 'callsign' +ATTR_ON_GROUND = 'on_ground' ATTR_SENSOR = 'sensor' ATTR_STATES = 'states' -ATTR_ON_GROUND = 'on_ground' -ATTR_CALLSIGN = 'callsign' + +DOMAIN = 'opensky' + +EVENT_OPENSKY_ENTRY = '{}_entry'.format(DOMAIN) +EVENT_OPENSKY_EXIT = '{}_exit'.format(DOMAIN) +SCAN_INTERVAL = timedelta(seconds=12) # opensky public limit is 10 seconds + OPENSKY_ATTRIBUTION = "Information provided by the OpenSky Network "\ "(https://opensky-network.org)" OPENSKY_API_URL = 'https://opensky-network.org/api/states/all' @@ -88,7 +90,7 @@ class OpenSkySensor(Entity): for callsign in callsigns: data = { ATTR_CALLSIGN: callsign, - ATTR_SENSOR: self._name + ATTR_SENSOR: self._name, } self._hass.bus.fire(event, data) diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py index 1077c7462d2..0a75d0395ec 100644 --- a/homeassistant/components/sensor/plex.py +++ b/homeassistant/components/sensor/plex.py @@ -10,12 +10,12 @@ import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT) + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT, CONF_TOKEN) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['plexapi==2.0.2'] +REQUIREMENTS = ['plexapi==3.0.3'] _LOGGER = logging.getLogger(__name__) @@ -31,6 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TOKEN): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SERVER): cv.string, vol.Optional(CONF_USERNAME): cv.string, @@ -46,28 +47,31 @@ def setup_platform(hass, config, add_devices, discovery_info=None): plex_server = config.get(CONF_SERVER) plex_host = config.get(CONF_HOST) plex_port = config.get(CONF_PORT) + plex_token = config.get(CONF_TOKEN) plex_url = 'http://{}:{}'.format(plex_host, plex_port) add_devices([PlexSensor( - name, plex_url, plex_user, plex_password, plex_server)], True) + name, plex_url, plex_user, plex_password, plex_server, + plex_token)], True) class PlexSensor(Entity): """Representation of a Plex now playing sensor.""" - def __init__(self, name, plex_url, plex_user, plex_password, plex_server): + def __init__(self, name, plex_url, plex_user, plex_password, + plex_server, plex_token): """Initialize the sensor.""" - from plexapi.utils import NA from plexapi.myplex import MyPlexAccount from plexapi.server import PlexServer - self._na_type = NA self._name = name self._state = 0 self._now_playing = [] - if plex_user and plex_password: - user = MyPlexAccount.signin(plex_user, plex_password) + if plex_token: + self._server = PlexServer(plex_url, plex_token) + elif plex_user and plex_password: + user = MyPlexAccount(plex_user, plex_password) server = plex_server if plex_server else user.resources()[0].name self._server = user.resource(server).connect() else: @@ -99,9 +103,9 @@ class PlexSensor(Entity): sessions = self._server.sessions() now_playing = [] for sess in sessions: - user = sess.username if sess.username is not self._na_type else "" - title = sess.title if sess.title is not self._na_type else "" - year = sess.year if sess.year is not self._na_type else "" + user = sess.usernames[0] if sess.usernames is not None else "" + title = sess.title if sess.title is not None else "" + year = sess.year if sess.year is not None else "" now_playing.append((user, "{0} ({1})".format(title, year))) self._state = len(sessions) self._now_playing = now_playing diff --git a/homeassistant/components/sensor/radarr.py b/homeassistant/components/sensor/radarr.py index 33a09a51aef..3b2c818a7b3 100644 --- a/homeassistant/components/sensor/radarr.py +++ b/homeassistant/components/sensor/radarr.py @@ -133,14 +133,12 @@ class RadarrSensor(Entity): attributes[command['name']] = command['state'] elif self.type == 'diskspace': for data in self.data: + free_space = to_unit(data['freeSpace'], self._unit) + total_space = to_unit(data['totalSpace'], self._unit) + percentage_used = (0 if total_space == 0 + else free_space / total_space * 100) attributes[data['path']] = '{:.2f}/{:.2f}{} ({:.2f}%)'.format( - to_unit(data['freeSpace'], self._unit), - to_unit(data['totalSpace'], self._unit), - self._unit, ( - to_unit(data['freeSpace'], self._unit) / - to_unit(data['totalSpace'], self._unit) * 100 - ) - ) + free_space, total_space, self._unit, percentage_used) elif self.type == 'movies': for movie in self.data: attributes[to_key(movie)] = movie['downloaded'] diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index c8d5591f2fa..2ae1c3674ea 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -68,10 +68,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): rest = RestData(method, resource, auth, headers, payload, verify_ssl) rest.update() - if rest.data is None: - _LOGGER.error("Unable to fetch REST data") - return False - add_devices([RestSensor(hass, rest, name, unit, value_template)], True) @@ -97,6 +93,11 @@ class RestSensor(Entity): """Return the unit the value is expressed in.""" return self._unit_of_measurement + @property + def available(self): + """Return if the sensor data are available.""" + return self.rest.data is not None + @property def state(self): """Return the state of the device.""" diff --git a/homeassistant/components/sensor/ring.py b/homeassistant/components/sensor/ring.py index 606b049b7e4..6c8794d096f 100644 --- a/homeassistant/components/sensor/ring.py +++ b/homeassistant/components/sensor/ring.py @@ -11,7 +11,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.ring import ( - CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE) + CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE, DATA_RING) from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS, @@ -27,24 +27,43 @@ SCAN_INTERVAL = timedelta(seconds=30) # Sensor types: Name, category, units, icon, kind SENSOR_TYPES = { - 'battery': ['Battery', ['doorbell'], '%', 'battery-50', None], - 'last_activity': ['Last Activity', ['doorbell'], None, 'history', None], - 'last_ding': ['Last Ding', ['doorbell'], None, 'history', 'ding'], - 'last_motion': ['Last Motion', ['doorbell'], None, 'history', 'motion'], - 'volume': ['Volume', ['chime', 'doorbell'], None, 'bell-ring', None], + 'battery': [ + 'Battery', ['doorbell', 'stickup_cams'], '%', 'battery-50', None], + + 'last_activity': [ + 'Last Activity', ['doorbell', 'stickup_cams'], None, 'history', None], + + 'last_ding': [ + 'Last Ding', ['doorbell', 'stickup_cams'], None, 'history', 'ding'], + + 'last_motion': [ + 'Last Motion', ['doorbell', 'stickup_cams'], None, + 'history', 'motion'], + + 'volume': [ + 'Volume', ['chime', 'doorbell', 'stickup_cams'], None, + 'bell-ring', None], + + 'wifi_signal_category': [ + 'WiFi Signal Category', ['chime', 'doorbell', 'stickup_cams'], None, + 'wifi', None], + + 'wifi_signal_strength': [ + 'WiFi Signal Strength', ['chime', 'doorbell', 'stickup_cams'], 'dBm', + 'wifi', None], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for a Ring device.""" - ring = hass.data.get('ring') + ring = hass.data[DATA_RING] sensors = [] for sensor_type in config.get(CONF_MONITORED_CONDITIONS): @@ -56,6 +75,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if 'doorbell' in SENSOR_TYPES[sensor_type][1]: sensors.append(RingSensor(hass, device, sensor_type)) + for device in ring.stickup_cams: + if 'stickup_cams' in SENSOR_TYPES[sensor_type][1]: + sensors.append(RingSensor(hass, device, sensor_type)) + add_devices(sensors, True) return True @@ -97,6 +120,7 @@ class RingSensor(Entity): attrs['kind'] = self._data.kind attrs['timezone'] = self._data.timezone attrs['type'] = self._data.family + attrs['wifi_name'] = self._data.wifi_name if self._extra and self._sensor_type.startswith('last_'): attrs['created_at'] = self._extra['created_at'] @@ -132,10 +156,18 @@ class RingSensor(Entity): self._state = self._data.battery_life if self._sensor_type.startswith('last_'): - history = self._data.history(timezone=self._tz, - kind=self._kind) + history = self._data.history(limit=5, + timezone=self._tz, + kind=self._kind, + enforce_limit=True) if history: self._extra = history[0] created_at = self._extra['created_at'] self._state = '{0:0>2}:{1:0>2}'.format( created_at.hour, created_at.minute) + + if self._sensor_type == 'wifi_signal_category': + self._state = self._data.wifi_signal_category + + if self._sensor_type == 'wifi_signal_strength': + self._state = self._data.wifi_signal_strength diff --git a/homeassistant/components/sensor/scrape.py b/homeassistant/components/sensor/scrape.py index 60f601fa5ab..0065f3e0927 100644 --- a/homeassistant/components/sensor/scrape.py +++ b/homeassistant/components/sensor/scrape.py @@ -7,12 +7,15 @@ https://home-assistant.io/components/sensor.scrape/ import logging import voluptuous as vol +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_VALUE_TEMPLATE, CONF_VERIFY_SSL) + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, CONF_USERNAME, + CONF_PASSWORD, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -21,6 +24,7 @@ REQUIREMENTS = ['beautifulsoup4==4.6.0'] _LOGGER = logging.getLogger(__name__) CONF_SELECT = 'select' +CONF_ATTR = 'attribute' DEFAULT_NAME = 'Web scrape' DEFAULT_VERIFY_SSL = True @@ -28,8 +32,13 @@ DEFAULT_VERIFY_SSL = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.string, vol.Required(CONF_SELECT): cv.string, + vol.Optional(CONF_ATTR): cv.string, + vol.Optional(CONF_AUTHENTICATION): + vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, }) @@ -40,14 +49,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) resource = config.get(CONF_RESOURCE) method = 'GET' - payload = auth = headers = None + payload = headers = None verify_ssl = config.get(CONF_VERIFY_SSL) select = config.get(CONF_SELECT) + attr = config.get(CONF_ATTR) unit = config.get(CONF_UNIT_OF_MEASUREMENT) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) value_template = config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass + if username and password: + if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: + auth = HTTPDigestAuth(username, password) + else: + auth = HTTPBasicAuth(username, password) + else: + auth = None rest = RestData(method, resource, auth, headers, payload, verify_ssl) rest.update() @@ -56,19 +75,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return False add_devices([ - ScrapeSensor(hass, rest, name, select, value_template, unit) - ], True) + ScrapeSensor(rest, name, select, attr, value_template, unit)], True) class ScrapeSensor(Entity): """Representation of a web scrape sensor.""" - def __init__(self, hass, rest, name, select, value_template, unit): + def __init__(self, rest, name, select, attr, value_template, unit): """Initialize a web scrape sensor.""" self.rest = rest self._name = name self._state = STATE_UNKNOWN self._select = select + self._attr = attr self._value_template = value_template self._unit_of_measurement = unit @@ -95,7 +114,10 @@ class ScrapeSensor(Entity): raw_data = BeautifulSoup(self.rest.data, 'html.parser') _LOGGER.debug(raw_data) - value = raw_data.select(self._select)[0].text + if self._attr is not None: + value = raw_data.select(self._select)[0][self._attr] + else: + value = raw_data.select(self._select)[0].text _LOGGER.debug(value) if self._value_template is not None: diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 370b560a892..841ff107826 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VALUE_TEMPLATE) -REQUIREMENTS = ['pysnmp==4.3.10'] +REQUIREMENTS = ['pysnmp==4.4.1'] _LOGGER = logging.getLogger(__name__) @@ -41,16 +41,15 @@ SCAN_INTERVAL = timedelta(seconds=10) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_BASEOID): cv.string, + vol.Optional(CONF_ACCEPT_ERRORS, default=False): cv.boolean, vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string, + vol.Optional(CONF_DEFAULT_VALUE): cv.string, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): - vol.In(SNMP_VERSIONS), - vol.Optional(CONF_ACCEPT_ERRORS, default=False): cv.boolean, - vol.Optional(CONF_DEFAULT_VALUE): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In(SNMP_VERSIONS), }) diff --git a/homeassistant/components/sensor/sonarr.py b/homeassistant/components/sensor/sonarr.py index 4be5582b8c4..42460a83d6f 100644 --- a/homeassistant/components/sensor/sonarr.py +++ b/homeassistant/components/sensor/sonarr.py @@ -132,10 +132,12 @@ class SonarrSensor(Entity): show['seasonNumber'], show['episodeNumber']) elif self.type == 'queue': for show in self.data: + remaining = (1 if show['size'] == 0 + else show['sizeleft']/show['size']) attributes[show['series']['title'] + ' S{:02d}E{:02d}'.format( show['episode']['seasonNumber'], show['episode']['episodeNumber'] - )] = '{:.2f}%'.format(100*(1-(show['sizeleft']/show['size']))) + )] = '{:.2f}%'.format(100*(1-(remaining))) elif self.type == 'wanted': for show in self.data: attributes[show['series']['title'] + ' S{:02d}E{:02d}'.format( diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index bf3af95d515..c7ba61ef504 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_change from homeassistant.helpers.restore_state import async_get_last_state -REQUIREMENTS = ['speedtest-cli==1.0.6'] +REQUIREMENTS = ['speedtest-cli==1.0.7'] _LOGGER = logging.getLogger(__name__) _SPEEDTEST_REGEX = re.compile(r'Ping:\s(\d+\.\d+)\sms[\r\n]+' diff --git a/homeassistant/components/sensor/swiss_public_transport.py b/homeassistant/components/sensor/swiss_public_transport.py index 973eac0bdde..40b77d278af 100644 --- a/homeassistant/components/sensor/swiss_public_transport.py +++ b/homeassistant/components/sensor/swiss_public_transport.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION from homeassistant.helpers.entity import Entity from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['python_opendata_transport==0.0.2'] +REQUIREMENTS = ['python_opendata_transport==0.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/synologydsm.py b/homeassistant/components/sensor/synologydsm.py index 20d93a4abc0..cfc868de664 100644 --- a/homeassistant/components/sensor/synologydsm.py +++ b/homeassistant/components/sensor/synologydsm.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, TEMP_CELSIUS, - CONF_MONITORED_CONDITIONS, EVENT_HOMEASSISTANT_START) + CONF_MONITORED_CONDITIONS, EVENT_HOMEASSISTANT_START, CONF_DISKS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -21,7 +21,6 @@ REQUIREMENTS = ['python-synology==0.1.0'] _LOGGER = logging.getLogger(__name__) -CONF_DISKS = 'disks' CONF_VOLUMES = 'volumes' DEFAULT_NAME = 'Synology DSM' DEFAULT_PORT = 5000 diff --git a/homeassistant/components/sensor/sytadin.py b/homeassistant/components/sensor/sytadin.py new file mode 100644 index 00000000000..9a85eb25575 --- /dev/null +++ b/homeassistant/components/sensor/sytadin.py @@ -0,0 +1,140 @@ +""" +Support for Sytadin Traffic, French Traffic Supervision. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sytadin/ +""" +import logging +import re +from datetime import timedelta + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + LENGTH_KILOMETERS, CONF_MONITORED_CONDITIONS, CONF_NAME, ATTR_ATTRIBUTION) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['beautifulsoup4==4.6.0'] + +_LOGGER = logging.getLogger(__name__) + +URL = 'http://www.sytadin.fr/sys/barometres_de_la_circulation.jsp.html' + +CONF_ATTRIBUTION = "Data provided by Direction des routes Île-de-France" \ + "(DiRIF)" + +DEFAULT_NAME = 'Sytadin' +REGEX = r'(\d*\.\d+|\d+)' + +OPTION_TRAFFIC_JAM = 'traffic_jam' +OPTION_MEAN_VELOCITY = 'mean_velocity' +OPTION_CONGESTION = 'congestion' + +SENSOR_TYPES = { + OPTION_TRAFFIC_JAM: ['Traffic Jam', LENGTH_KILOMETERS], + OPTION_MEAN_VELOCITY: ['Mean Velocity', LENGTH_KILOMETERS+'/h'], + OPTION_CONGESTION: ['Congestion', ''], +} + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=[OPTION_TRAFFIC_JAM]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up of the Sytadin Traffic sensor platform.""" + name = config.get(CONF_NAME) + + sytadin = SytadinData(URL) + + dev = [] + for option in config.get(CONF_MONITORED_CONDITIONS): + _LOGGER.debug("Sensor device - %s", option) + dev.append(SytadinSensor( + sytadin, name, option, SENSOR_TYPES[option][0], + SENSOR_TYPES[option][1])) + add_devices(dev, True) + + +class SytadinSensor(Entity): + """Representation of a Sytadin Sensor.""" + + def __init__(self, data, name, sensor_type, option, unit): + """Initialize the sensor.""" + self.data = data + self._state = None + self._name = name + self._option = option + self._type = sensor_type + self._unit = unit + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self._name, self._option) + + @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 device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + } + + def update(self): + """Fetch new state data for the sensor.""" + self.data.update() + + if self.data is None: + return + + if self._type == OPTION_TRAFFIC_JAM: + self._state = self.data.traffic_jam + elif self._type == OPTION_MEAN_VELOCITY: + self._state = self.data.mean_velocity + elif self._type == OPTION_CONGESTION: + self._state = self.data.congestion + + +class SytadinData(object): + """The class for handling the data retrieval.""" + + def __init__(self, resource): + """Initialize the data object.""" + self._resource = resource + self.data = None + self.traffic_jam = self.mean_velocity = self.congestion = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the Sytadin.""" + from bs4 import BeautifulSoup + + try: + raw_html = requests.get(self._resource, timeout=10).text + data = BeautifulSoup(raw_html, 'html.parser') + + values = data.select('.barometre_valeur') + self.traffic_jam = re.search(REGEX, values[0].text).group() + self.mean_velocity = re.search(REGEX, values[1].text).group() + self.congestion = re.search(REGEX, values[2].text).group() + except requests.exceptions.ConnectionError: + _LOGGER.error("Connection error") + self.data = None diff --git a/homeassistant/components/sensor/tado.py b/homeassistant/components/sensor/tado.py index 1d40e4ceb50..781f2e006d9 100644 --- a/homeassistant/components/sensor/tado.py +++ b/homeassistant/components/sensor/tado.py @@ -9,12 +9,12 @@ import logging from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.entity import Entity from homeassistant.components.tado import (DATA_TADO) +from homeassistant.const import (ATTR_ID) _LOGGER = logging.getLogger(__name__) ATTR_DATA_ID = 'data_id' ATTR_DEVICE = 'device' -ATTR_ID = 'id' ATTR_NAME = 'name' ATTR_ZONE = 'zone' diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index ff426951d3f..b347439e08d 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -13,8 +13,8 @@ from homeassistant.core import callback from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - CONF_ICON_TEMPLATE, ATTR_ENTITY_ID, CONF_SENSORS, - EVENT_HOMEASSISTANT_START) + CONF_ICON_TEMPLATE, CONF_ENTITY_PICTURE_TEMPLATE, ATTR_ENTITY_ID, + CONF_SENSORS, EVENT_HOMEASSISTANT_START) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity, async_generate_entity_id @@ -25,6 +25,7 @@ _LOGGER = logging.getLogger(__name__) SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids @@ -44,6 +45,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for device, device_config in config[CONF_SENSORS].items(): state_template = device_config[CONF_VALUE_TEMPLATE] icon_template = device_config.get(CONF_ICON_TEMPLATE) + entity_picture_template = device_config.get( + CONF_ENTITY_PICTURE_TEMPLATE) entity_ids = (device_config.get(ATTR_ENTITY_ID) or state_template.extract_entities()) friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) @@ -54,6 +57,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if icon_template is not None: icon_template.hass = hass + if entity_picture_template is not None: + entity_picture_template.hass = hass + sensors.append( SensorTemplate( hass, @@ -62,6 +68,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): unit_of_measurement, state_template, icon_template, + entity_picture_template, entity_ids) ) if not sensors: @@ -75,8 +82,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class SensorTemplate(Entity): """Representation of a Template Sensor.""" - def __init__(self, hass, device_id, friendly_name, unit_of_measurement, - state_template, icon_template, entity_ids): + def __init__(self, hass, device_id, friendly_name, + unit_of_measurement, state_template, icon_template, + entity_picture_template, entity_ids): """Initialize the sensor.""" self.hass = hass self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, @@ -86,7 +94,9 @@ class SensorTemplate(Entity): self._template = state_template self._state = None self._icon_template = icon_template + self._entity_picture_template = entity_picture_template self._icon = None + self._entity_picture = None self._entities = entity_ids @asyncio.coroutine @@ -123,6 +133,11 @@ class SensorTemplate(Entity): """Return the icon to use in the frontend, if any.""" return self._icon + @property + def entity_picture(self): + """Return the entity_picture to use in the frontend, if any.""" + return self._entity_picture + @property def unit_of_measurement(self): """Return the unit_of_measurement of the device.""" @@ -148,16 +163,27 @@ class SensorTemplate(Entity): self._state = None _LOGGER.error('Could not render template %s: %s', self._name, ex) - if self._icon_template is not None: + for property_name, template in ( + ('_icon', self._icon_template), + ('_entity_picture', self._entity_picture_template)): + if template is None: + continue + try: - self._icon = self._icon_template.async_render() + setattr(self, property_name, template.async_render()) except TemplateError as ex: + friendly_property_name = property_name[1:].replace('_', ' ') if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): # Common during HA startup - so just a warning - _LOGGER.warning('Could not render icon template %s,' - ' the state is unknown.', self._name) + _LOGGER.warning('Could not render %s template %s,' + ' the state is unknown.', + friendly_property_name, self._name) return - self._icon = super().icon - _LOGGER.error('Could not render icon template %s: %s', - self._name, ex) + + try: + setattr(self, property_name, + getattr(super(), property_name)) + except AttributeError: + _LOGGER.error('Could not render %s template %s: %s', + friendly_property_name, self._name, ex) diff --git a/homeassistant/components/sensor/toon.py b/homeassistant/components/sensor/toon.py index ee5ae9ca51e..cecce0d270f 100644 --- a/homeassistant/components/sensor/toon.py +++ b/homeassistant/components/sensor/toon.py @@ -1,8 +1,8 @@ """ -Toon van Eneco Utility Gages. +Component for the rebranded Quby thermostat as provided by Eneco. -This provides a component for the rebranded Quby thermostat as provided by -Eneco. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.toon/ """ import logging import datetime as datetime @@ -12,46 +12,33 @@ import homeassistant.components.toon as toon_main _LOGGER = logging.getLogger(__name__) -STATE_ATTR_DEVICE_TYPE = "device_type" -STATE_ATTR_LAST_CONNECTED_CHANGE = "last_connected_change" +STATE_ATTR_DEVICE_TYPE = 'device_type' +STATE_ATTR_LAST_CONNECTED_CHANGE = 'last_connected_change' def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup sensors.""" + """Set up the Toon sensors.""" _toon_main = hass.data[toon_main.TOON_HANDLE] sensor_items = [] - sensor_items.extend([ToonSensor(hass, - 'Power_current', - 'power-plug', - 'Watt'), - ToonSensor(hass, - 'Power_today', - 'power-plug', - 'kWh')]) + sensor_items.extend([ + ToonSensor(hass, 'Power_current', 'power-plug', 'Watt'), + ToonSensor(hass, 'Power_today', 'power-plug', 'kWh'), + ]) if _toon_main.gas: - sensor_items.extend([ToonSensor(hass, - 'Gas_current', - 'gas-cylinder', - 'CM3'), - ToonSensor(hass, - 'Gas_today', - 'gas-cylinder', - 'M3')]) + sensor_items.extend([ + ToonSensor(hass, 'Gas_current', 'gas-cylinder', 'CM3'), + ToonSensor(hass, 'Gas_today', 'gas-cylinder', 'M3'), + ]) for plug in _toon_main.toon.smartplugs: sensor_items.extend([ - FibaroSensor(hass, - '{}_current_power'.format(plug.name), - plug.name, - 'power-socket-eu', - 'Watt'), - FibaroSensor(hass, - '{}_today_energy'.format(plug.name), - plug.name, - 'power-socket-eu', - 'kWh')]) + FibaroSensor(hass, '{}_current_power'.format(plug.name), + plug.name, 'power-socket-eu', 'Watt'), + FibaroSensor(hass, '{}_today_energy'.format(plug.name), + plug.name, 'power-socket-eu', 'kWh'), + ]) if _toon_main.toon.solar.produced or _toon_main.solar: sensor_items.extend([ @@ -61,36 +48,30 @@ def setup_platform(hass, config, add_devices, discovery_info=None): SolarSensor(hass, 'Solar_average_produced', 'kWh'), SolarSensor(hass, 'Solar_meter_reading_low_produced', 'kWh'), SolarSensor(hass, 'Solar_meter_reading_produced', 'kWh'), - SolarSensor(hass, 'Solar_daily_cost_produced', 'Euro') + SolarSensor(hass, 'Solar_daily_cost_produced', 'Euro'), ]) for smokedetector in _toon_main.toon.smokedetectors: sensor_items.append( - FibaroSmokeDetector(hass, - '{}_smoke_detector'.format(smokedetector.name), - smokedetector.device_uuid, - 'alarm-bell', - '%')) + FibaroSmokeDetector( + hass, '{}_smoke_detector'.format(smokedetector.name), + smokedetector.device_uuid, 'alarm-bell', '%') + ) add_devices(sensor_items) class ToonSensor(Entity): - """Representation of a sensor.""" + """Representation of a Toon sensor.""" def __init__(self, hass, name, icon, unit_of_measurement): - """Initialize the sensor.""" + """Initialize the Toon sensor.""" self._name = name self._state = None - self._icon = "mdi:" + icon + self._icon = 'mdi:{}'.format(icon) self._unit_of_measurement = unit_of_measurement self.thermos = hass.data[toon_main.TOON_HANDLE] - @property - def should_poll(self): - """Polling required.""" - return True - @property def name(self): """Return the name of the sensor.""" @@ -117,22 +98,17 @@ class ToonSensor(Entity): class FibaroSensor(Entity): - """Representation of a sensor.""" + """Representation of a Fibaro sensor.""" def __init__(self, hass, name, plug_name, icon, unit_of_measurement): - """Initialize the sensor.""" + """Initialize the Fibaro sensor.""" self._name = name self._plug_name = plug_name self._state = None - self._icon = "mdi:" + icon + self._icon = 'mdi:{}'.format(icon) self._unit_of_measurement = unit_of_measurement self.toon = hass.data[toon_main.TOON_HANDLE] - @property - def should_poll(self): - """Polling required.""" - return True - @property def name(self): """Return the name of the sensor.""" @@ -160,21 +136,16 @@ class FibaroSensor(Entity): class SolarSensor(Entity): - """Representation of a sensor.""" + """Representation of a Solar sensor.""" def __init__(self, hass, name, unit_of_measurement): - """Initialize the sensor.""" + """Initialize the Solar sensor.""" self._name = name self._state = None - self._icon = "mdi:weather-sunny" + self._icon = 'mdi:weather-sunny' self._unit_of_measurement = unit_of_measurement self.toon = hass.data[toon_main.TOON_HANDLE] - @property - def should_poll(self): - """Polling required.""" - return True - @property def name(self): """Return the name of the sensor.""" @@ -201,22 +172,17 @@ class SolarSensor(Entity): class FibaroSmokeDetector(Entity): - """Representation of a smoke detector.""" + """Representation of a Fibaro smoke detector.""" def __init__(self, hass, name, uid, icon, unit_of_measurement): - """Initialize the sensor.""" + """Initialize the Fibaro smoke sensor.""" self._name = name self._uid = uid self._state = None - self._icon = "mdi:" + icon + self._icon = 'mdi:{}'.format(icon) self._unit_of_measurement = unit_of_measurement self.toon = hass.data[toon_main.TOON_HANDLE] - @property - def should_poll(self): - """Polling required.""" - return True - @property def name(self): """Return the name of the sensor.""" @@ -235,9 +201,9 @@ class FibaroSmokeDetector(Entity): ).strftime('%Y-%m-%d %H:%M:%S') return { - STATE_ATTR_DEVICE_TYPE: self.toon.get_data('device_type', - self.name), - STATE_ATTR_LAST_CONNECTED_CHANGE: value + STATE_ATTR_DEVICE_TYPE: + self.toon.get_data('device_type', self.name), + STATE_ATTR_LAST_CONNECTED_CHANGE: value, } @property diff --git a/homeassistant/components/sensor/uk_transport.py b/homeassistant/components/sensor/uk_transport.py index bcac4b47279..9b35afb418c 100644 --- a/homeassistant/components/sensor/uk_transport.py +++ b/homeassistant/components/sensor/uk_transport.py @@ -6,13 +6,15 @@ https://home-assistant.io/components/sensor.uk_transport/ import logging import re from datetime import datetime, timedelta + import requests import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_MODE from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -28,7 +30,6 @@ ATTR_NEXT_TRAINS = 'next_trains' CONF_API_APP_KEY = 'app_key' CONF_API_APP_ID = 'app_id' CONF_QUERIES = 'queries' -CONF_MODE = 'mode' CONF_ORIGIN = 'origin' CONF_DESTINATION = 'destination' diff --git a/homeassistant/components/sensor/uptime.py b/homeassistant/components/sensor/uptime.py index 89c0fbffd8e..91746af71f1 100644 --- a/homeassistant/components/sensor/uptime.py +++ b/homeassistant/components/sensor/uptime.py @@ -23,7 +23,7 @@ DEFAULT_NAME = 'Uptime' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='days'): - vol.All(cv.string, vol.In(['hours', 'days'])) + vol.All(cv.string, vol.In(['minutes', 'hours', 'days'])) }) @@ -73,6 +73,8 @@ class UptimeSensor(Entity): div_factor = 3600 if self.unit_of_measurement == 'days': div_factor *= 24 + elif self.unit_of_measurement == 'minutes': + div_factor /= 60 delta = delta.total_seconds() / div_factor self._state = round(delta, 2) _LOGGER.debug("New value: %s", delta) diff --git a/homeassistant/components/sensor/whois.py b/homeassistant/components/sensor/whois.py new file mode 100644 index 00000000000..9f50a4c13db --- /dev/null +++ b/homeassistant/components/sensor/whois.py @@ -0,0 +1,136 @@ +""" +Get WHOIS information for a given host. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.whois/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import CONF_NAME +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['pythonwhois==2.4.3'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DOMAIN = 'domain' + +DEFAULT_NAME = 'Whois' + +ATTR_NAME_SERVERS = 'name_servers' +ATTR_REGISTRAR = 'registrar' +ATTR_UPDATED = 'updated' +ATTR_EXPIRES = 'expires' + +SCAN_INTERVAL = timedelta(hours=24) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the WHOIS sensor.""" + from pythonwhois import get_whois + from pythonwhois.shared import WhoisException + + domain = config.get(CONF_DOMAIN) + name = config.get(CONF_NAME) + + try: + if 'expiration_date' in get_whois(domain, normalized=True): + add_devices([WhoisSensor(name, domain)], True) + else: + _LOGGER.warning( + "WHOIS lookup for %s didn't contain expiration_date", + domain) + return + except WhoisException as ex: + _LOGGER.error("Exception %s occurred during WHOIS lookup for %s", + ex, + domain) + return + + +class WhoisSensor(Entity): + """Implementation of a WHOIS sensor.""" + + def __init__(self, name, domain): + """Initialize the sensor.""" + from pythonwhois import get_whois + + self.whois = get_whois + + self._name = name + self._domain = domain + + self._state = None + self._data = None + self._updated_date = None + self._expiration_date = None + self._name_servers = [] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """The icon to represent this sensor.""" + return 'mdi:calendar-clock' + + @property + def unit_of_measurement(self): + """The unit of measurement to present the value in.""" + return 'days' + + @property + def state(self): + """Return the expiration days for hostname.""" + return self._state + + @property + def device_state_attributes(self): + """Get the more info attributes.""" + if self._data: + updated_formatted = self._updated_date.isoformat() + expires_formatted = self._expiration_date.isoformat() + + return { + ATTR_NAME_SERVERS: ' '.join(self._name_servers), + ATTR_REGISTRAR: self._data['registrar'][0], + ATTR_UPDATED: updated_formatted, + ATTR_EXPIRES: expires_formatted, + } + + def update(self): + """Get the current WHOIS data for hostname.""" + from pythonwhois.shared import WhoisException + + try: + response = self.whois(self._domain, normalized=True) + except WhoisException as ex: + _LOGGER.error("Exception %s occurred during WHOIS lookup", ex) + return + + if response: + self._data = response + + if self._data['nameservers']: + self._name_servers = self._data['nameservers'] + + if 'expiration_date' in self._data: + self._expiration_date = self._data['expiration_date'][0] + if 'updated_date' in self._data: + self._updated_date = self._data['updated_date'][0] + + time_delta = (self._expiration_date - self._expiration_date.now()) + + self._state = time_delta.days diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 1c7b3123c64..37829142e0c 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -1,179 +1,139 @@ - +# Describes the format for available component services foursquare: checkin: - description: Check a user into a Foursquare venue - + description: Check a user into a Foursquare venue. fields: venueId: description: The Foursquare venue where the user is checking in. [Required] example: IHR8THISVNU - eventId: description: The event the user is checking in to. [Optional] example: UHR8THISVNT - shout: description: A message about your check-in. The maximum length of this field is 140 characters. [Optional] example: There are crayons! Crayons! - mentions: description: Mentions in your check-in. This parameter is a semicolon-delimited list of mentions. A single mention is of the form "start,end,userid", where start is the index of the first character in the shout representing the mention, end is the index of the first character in the shout after the mention, and userid is the userid of the user being mentioned. If userid is prefixed with "fbu-", this indicates a Facebook userid that is being mention. Character indices in shouts are 0-based. [Optional] example: 5,10,HZXXY3Y;15,20,GZYYZ3Z;25,30,fbu-GZXY13Y - broadcast: description: "Who to broadcast this check-in to. Accepts a comma-delimited list of values: private (off the grid) or public (share with friends), facebook share on facebook, twitter share on twitter, followers share with followers (celebrity mode users only), If no valid value is found, the default is public. [Optional]" example: public,twitter - ll: description: Latitude and longitude of the user's location. Only specify this field if you have a GPS or other device reported location for the user at the time of check-in. [Optional] example: 33.7,44.2 - llAcc: description: Accuracy of the user's latitude and longitude, in meters. [Optional] example: 1 - alt: description: Altitude of the user's location, in meters. [Optional] example: 0 - altAcc: description: Vertical accuracy of the user's location, in meters. example: 1 homematic: virtualkey: - description: Press a virtual key from CCU/Homegear or simulate keypress - + description: Press a virtual key from CCU/Homegear or simulate keypress. fields: address: - description: Address of homematic device or BidCoS-RF for virtual remote + description: Address of homematic device or BidCoS-RF for virtual remote. example: BidCoS-RF - channel: - description: Channel for calling a keypress + description: Channel for calling a keypress. example: 1 - param: - description: Event to send i.e. PRESS_LONG, PRESS_SHORT + description: Event to send i.e. PRESS_LONG, PRESS_SHORT. example: PRESS_LONG - proxy: - description: (Optional) for set a hosts value + description: (Optional) for set a hosts value. example: Hosts name from config - set_var_value: description: Set the name of a node. - fields: entity_id: - description: Name(s) of homematic central to set value + description: Name(s) of homematic central to set value. example: 'homematic.ccu2' - name: - description: Name of the variable to set + description: Name of the variable to set. example: 'testvariable' - value: description: New value example: 1 - set_dev_value: description: Set a device property on RPC XML interface. - fields: address: description: Address of homematic device or BidCoS-RF for virtual remote example: BidCoS-RF - channel: description: Channel for calling a keypress example: 1 - param: description: Event to send i.e. PRESS_LONG, PRESS_SHORT example: PRESS_LONG - proxy: description: (Optional) for set a hosts value example: Hosts name from config - value: description: New value example: 1 - reconnect: description: Reconnect to all Homematic Hubs. microsoft_face: create_group: description: Create a new person group. - fields: name: - description: Name of the group + description: Name of the group. example: 'family' - delete_group: description: Delete a new person group. - fields: name: - description: Name of the group + description: Name of the group. example: 'family' - train_group: description: Train a person group. - fields: group: description: Name of the group example: 'family' - create_person: description: Create a new person in the group. - fields: name: description: Name of the person example: 'Hans' - group: description: Name of the group example: 'family' - delete_person: description: Delete a person in the group. - fields: name: - description: Name of the person + description: Name of the person. example: 'Hans' - group: - description: Name of the group + description: Name of the group. example: 'family' - face_person: description: Add a new picture to a person. - fields: person: - description: Name of the person + description: Name of the person. example: 'Hans' - group: - description: Name of the group + description: Name of the group. example: 'family' - camera_entity: - description: Camera to take a picture + description: Camera to take a picture. example: camera.door verisure: capture_smartcam: description: Capture a new image from a smartcam. - fields: device_serial: description: The serial number of the smartcam you want to capture an image from. @@ -182,23 +142,18 @@ verisure: alert: turn_off: description: Silence alert's notifications. - fields: entity_id: description: Name of the alert to silence. example: 'alert.garage_door_open' - turn_on: description: Reset alert's notifications. - fields: entity_id: description: Name of the alert to reset. example: 'alert.garage_door_open' - toggle: description: Toggle alert's notifications. - fields: entity_id: description: Name of the alert to toggle. @@ -207,34 +162,26 @@ alert: hdmi_cec: send_command: description: Sends CEC command into HDMI CEC capable adapter. - fields: raw: description: 'Raw CEC command in format "00:00:00:00" where first two digits are source and destination, second byte is command and optional other bytes are command parameters. If raw command specified, other params are ignored.' example: '"10:36"' - src: desctiption: 'Source of command. Could be decimal number or string with hexadeximal notation: "0x10".' example: '12 or "0xc"' - dst: description: 'Destination for command. Could be decimal number or string with hexadeximal notation: "0x10".' example: '5 or "0x5"' - cmd: description: 'Command itself. Could be decimal number or string with hexadeximal notation: "0x10".' example: '144 or "0x90"' - att: description: Optional parameters. example: [0, 2] - update: description: Update devices state from network. - volume: description: Increase or decrease volume of system. - fields: up: description: Increases volume x levels. @@ -245,17 +192,14 @@ hdmi_cec: mute: description: Mutes audio system. Value should be on, off or toggle. example: "toggle" - select_device: description: Select HDMI device. fields: device: description: Address of device to select. Can be entity_id, physical address or alias from confuguration. example: '"switch.hdmi_1" or "1.1.0.0" or "01:10"' - power_on: description: Power on all devices which supports it. - standby: description: Standby all devices which supports it. @@ -266,14 +210,12 @@ ffmpeg: entity_id: description: Name(s) of entities that will start. Platform dependent. example: 'binary_sensor.ffmpeg_noise' - stop: description: Send a stop command to a ffmpeg based sensor. fields: entity_id: description: Name(s) of entities that will stop. Platform dependent. example: 'binary_sensor.ffmpeg_noise' - restart: description: Send a restart command to a ffmpeg based sensor. fields: @@ -288,34 +230,28 @@ logger: hassio: host_reboot: description: Reboot host computer. - host_shutdown: description: Poweroff host computer. - host_update: description: Update host computer. fields: version: description: Optional or it will be use the latest version. example: '0.3' - supervisor_update: description: Update HassIO supervisor. fields: version: description: Optional or it will be use the latest version. example: '0.3' - supervisor_reload: description: Reload HassIO supervisor addons/updates/configs. - homeassistant_update: description: Update HomeAssistant docker image. fields: version: description: Optional or it will be use the latest version. example: '0.40.1' - addon_install: description: Install a HassIO docker addon. fields: @@ -325,14 +261,12 @@ hassio: version: description: Optional or it will be use the latest version. example: '0.2' - addon_uninstall: description: Uninstall a HassIO docker addon. fields: addon: description: Name of addon. example: 'smb_config' - addon_update: description: Update a HassIO docker addon. fields: @@ -342,14 +276,12 @@ hassio: version: description: Optional or it will be use the latest version. example: '0.2' - addon_start: description: Start a HassIO docker addon. fields: addon: description: Name of addon. example: 'smb_config' - addon_stop: description: Stop a HassIO docker addon. fields: @@ -391,45 +323,42 @@ axis: apple_tv: apple_tv_authenticate: description: Start AirPlay device authentication. - fields: entity_id: description: Name(s) of entities to authenticate with. example: 'media_player.apple_tv' - apple_tv_scan: description: Scan for Apple TV devices. modbus: write_register: - description: Write to a modbus holding register + description: Write to a modbus holding register. fields: unit: - description: Address of the modbus unit + description: Address of the modbus unit. example: 21 address: - description: Address of the holding register to write to + description: Address of the holding register to write to. example: 0 value: - description: Value to write + description: Value to write. example: 0 write_coil: - description: Write to a modbus coil + description: Write to a modbus coil. fields: unit: - description: Address of the modbus unit + description: Address of the modbus unit. example: 21 address: - description: Address of the register to read + description: Address of the register to read. example: 0 state: - description: State to write + description: State to write. example: false wake_on_lan: send_magic_packet: description: Send a 'magic packet' to wake up a device with 'Wake-On-LAN' capabilities. - fields: mac: description: MAC address of the device to wake up. @@ -440,50 +369,42 @@ wake_on_lan: knx: group_write: - description: Turn a light on - + description: Turn a light on. fields: address: - description: Group address(es) to write to + description: Group address(es) to write to. example: '1/1/0' - data: - description: KNX data to send + description: KNX data to send. example: 1 rflink: send_command: - description: Send device command through RFLink - + description: Send device command through RFLink. fields: device_id: - description: RFLink device ID + description: RFLink device ID. example: 'newkaku_0000c6c2_1' command: - description: The command to be sent + description: The command to be sent. example: 'on' counter: decrement: description: Decrement a counter. - fields: entity_id: description: Entity id of the counter to decrement. example: 'counter.count0' - increment: description: Increment a counter. - fields: entity_id: description: Entity id of the counter to increment. example: 'counter.count0' - reset: description: Reset a counter. - fields: entity_id: description: Entity id of the counter to reset. @@ -492,27 +413,21 @@ counter: abode: change_setting: description: Change an Abode system setting. - fields: setting: description: Setting to change. example: 'beeper_mute' - value: description: Value of the setting. example: '1' - capture_image: description: Request a new image capture from a camera device. - fields: entity_id: description: Entity id of the camera to request an image. example: 'camera.downstairs_motion_camera' - trigger_quick_action: description: Trigger an Abode quick action. - fields: entity_id: description: Entity id of the quick action to trigger. @@ -520,29 +435,58 @@ abode: input_boolean: toggle: - description: Toggles an input boolean - + description: Toggles an input boolean. fields: entity_id: - description: Entity id of the input boolean to toggle + description: Entity id of the input boolean to toggle. example: 'input_boolean.notify_alerts' - turn_off: - description: Turns OFF an input boolean - + description: Turns off an input boolean fields: entity_id: - description: Entity id of the input boolean to turn off + description: Entity id of the input boolean to turn off. example: 'input_boolean.notify_alerts' - turn_on: - description: Turns ON an input boolean - + description: Turns on an input boolean. fields: entity_id: - description: Entity id of the input boolean to turn on + description: Entity id of the input boolean to turn on. example: 'input_boolean.notify_alerts' +input_text: + set_value: + description: Set the value of an input text entity. + fields: + entity_id: + description: Entity id of the input text to set the new value. + example: 'input_text.text1' + value: + description: The target value the entity should be set to. + example: This is an example text + +input_number: + set_value: + description: Set the value of an input number entity. + fields: + entity_id: + description: Entity id of the input number to set the new value. + example: 'input_number.threshold' + value: + description: The target value the entity should be set to. + example: 42 + increment: + description: Increment the value of an input number entity by its stepping. + fields: + entity_id: + description: Entity id of the input number the should be incremented. + example: 'input_number.threshold' + decrement: + description: Decrement the value of an input number entity by its stepping. + fields: + entity_id: + description: Entity id of the input number the should be decremented. + example: 'input_number.threshold' + homeassistant: check_config: description: Check the Home Assistant configuration files for errors. Errors will be displayed in the Home Assistant log. @@ -556,17 +500,17 @@ homeassistant: description: Generic service to toggle devices on/off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. fields: entity_id: - description: The entity_id of the device to toggle on/off + description: The entity_id of the device to toggle on/off. example: light.living_room turn_on: description: Generic service to turn devices on under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. fields: entity_id: - description: The entity_id of the device to turn on + description: The entity_id of the device to turn on. example: light.living_room turn_off: description: Generic service to turn devices off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. fields: entity_id: - description: The entity_id of the device to turn off + description: The entity_id of the device to turn off. example: light.living_room diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index ad9fae67bc6..8b318d07946 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -48,8 +48,8 @@ def async_setup(hass, config): 'What is on my shopping list' ]) - hass.components.frontend.register_built_in_panel( - 'shopping-list', 'Shopping List', 'mdi:cart') + yield from hass.components.frontend.async_register_built_in_panel( + 'shopping-list', 'shopping_list', 'mdi:cart') return True diff --git a/homeassistant/components/switch/deluge.py b/homeassistant/components/switch/deluge.py new file mode 100644 index 00000000000..30287a2669e --- /dev/null +++ b/homeassistant/components/switch/deluge.py @@ -0,0 +1,97 @@ +""" +Support for setting the Deluge BitTorrent client in Pause. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.deluge/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, STATE_OFF, + STATE_ON) +from homeassistant.helpers.entity import ToggleEntity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['deluge-client==1.0.5'] + +_LOGGING = logging.getLogger(__name__) + +DEFAULT_NAME = 'Deluge Switch' +DEFAULT_PORT = 58846 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Deluge switch.""" + from deluge_client import DelugeRPCClient + + 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) + + deluge_api = DelugeRPCClient(host, port, username, password) + try: + deluge_api.connect() + except ConnectionRefusedError: + _LOGGING.error("Connection to Deluge Daemon failed") + return + + add_devices([DelugeSwitch(deluge_api, name)]) + + +class DelugeSwitch(ToggleEntity): + """Representation of a Deluge switch.""" + + def __init__(self, deluge_client, name): + """Initialize the Deluge switch.""" + self._name = name + self.deluge_client = deluge_client + self._state = STATE_OFF + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def is_on(self): + """Return true if device is on.""" + return self._state == STATE_ON + + def turn_on(self, **kwargs): + """Turn the device on.""" + self.deluge_client.call('core.resume_all_torrents') + + def turn_off(self, **kwargs): + """Turn the device off.""" + self.deluge_client.call('core.pause_all_torrents') + + def update(self): + """Get the latest data from deluge and updates the state.""" + torrent_list = self.deluge_client.call('core.get_torrents_status', {}, + ['paused']) + for torrent in torrent_list.values(): + item = torrent.popitem() + if not item[1]: + self._state = STATE_ON + return + + self._state = STATE_OFF diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 4df8f792a4b..ff432f2efc8 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -11,9 +11,12 @@ import logging import voluptuous as vol -from homeassistant.components.light import is_on, turn_on +import homeassistant.helpers.config_validation as cv +from homeassistant.components.light import ( + is_on, turn_on, VALID_TRANSITION, ATTR_TRANSITION) from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.const import CONF_NAME, CONF_PLATFORM, CONF_LIGHTS +from homeassistant.const import ( + CONF_NAME, CONF_PLATFORM, CONF_LIGHTS, CONF_MODE) from homeassistant.helpers.event import track_time_change from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util import slugify @@ -21,9 +24,6 @@ from homeassistant.util.color import ( color_temperature_to_rgb, color_RGB_to_xy, color_temperature_kelvin_to_mired) from homeassistant.util.dt import now as dt_now -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['light'] _LOGGER = logging.getLogger(__name__) @@ -34,12 +34,14 @@ CONF_SUNSET_CT = 'sunset_colortemp' CONF_STOP_CT = 'stop_colortemp' CONF_BRIGHTNESS = 'brightness' CONF_DISABLE_BRIGTNESS_ADJUST = 'disable_brightness_adjust' -CONF_MODE = 'mode' +CONF_INTERVAL = 'interval' MODE_XY = 'xy' MODE_MIRED = 'mired' MODE_RGB = 'rgb' DEFAULT_MODE = MODE_XY +DEPENDENCIES = ['light'] + PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'flux', @@ -57,37 +59,39 @@ PLATFORM_SCHEMA = vol.Schema({ vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), vol.Optional(CONF_DISABLE_BRIGTNESS_ADJUST): cv.boolean, vol.Optional(CONF_MODE, default=DEFAULT_MODE): - vol.Any(MODE_XY, MODE_MIRED, MODE_RGB) + vol.Any(MODE_XY, MODE_MIRED, MODE_RGB), + vol.Optional(CONF_INTERVAL, default=30): cv.positive_int, + vol.Optional(ATTR_TRANSITION, default=30): VALID_TRANSITION }) -def set_lights_xy(hass, lights, x_val, y_val, brightness): +def set_lights_xy(hass, lights, x_val, y_val, brightness, transition): """Set color of array of lights.""" for light in lights: if is_on(hass, light): turn_on(hass, light, xy_color=[x_val, y_val], brightness=brightness, - transition=30) + transition=transition) -def set_lights_temp(hass, lights, mired, brightness): +def set_lights_temp(hass, lights, mired, brightness, transition): """Set color of array of lights.""" for light in lights: if is_on(hass, light): turn_on(hass, light, color_temp=int(mired), brightness=brightness, - transition=30) + transition=transition) -def set_lights_rgb(hass, lights, rgb): +def set_lights_rgb(hass, lights, rgb, transition): """Set color of array of lights.""" for light in lights: if is_on(hass, light): turn_on(hass, light, rgb_color=rgb, - transition=30) + transition=transition) # pylint: disable=unused-argument @@ -103,9 +107,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): brightness = config.get(CONF_BRIGHTNESS) disable_brightness_adjust = config.get(CONF_DISABLE_BRIGTNESS_ADJUST) mode = config.get(CONF_MODE) - flux = FluxSwitch(name, hass, False, lights, start_time, stop_time, + interval = config.get(CONF_INTERVAL) + transition = config.get(ATTR_TRANSITION) + flux = FluxSwitch(name, hass, lights, start_time, stop_time, start_colortemp, sunset_colortemp, stop_colortemp, - brightness, disable_brightness_adjust, mode) + brightness, disable_brightness_adjust, mode, interval, + transition) add_devices([flux]) def update(call=None): @@ -119,9 +126,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class FluxSwitch(SwitchDevice): """Representation of a Flux switch.""" - def __init__(self, name, hass, state, lights, start_time, stop_time, + def __init__(self, name, hass, lights, start_time, stop_time, start_colortemp, sunset_colortemp, stop_colortemp, - brightness, disable_brightness_adjust, mode): + brightness, disable_brightness_adjust, mode, interval, + transition): """Initialize the Flux switch.""" self._name = name self.hass = hass @@ -134,6 +142,8 @@ class FluxSwitch(SwitchDevice): self._brightness = brightness self._disable_brightness_adjust = disable_brightness_adjust self._mode = mode + self._interval = interval + self._transition = transition self.unsub_tracker = None @property @@ -155,7 +165,7 @@ class FluxSwitch(SwitchDevice): self.flux_update() self.unsub_tracker = track_time_change( - self.hass, self.flux_update, second=[0, 30]) + self.hass, self.flux_update, second=[0, self._interval]) self.schedule_update_ha_state() @@ -232,20 +242,21 @@ class FluxSwitch(SwitchDevice): brightness = None if self._mode == MODE_XY: set_lights_xy(self.hass, self._lights, x_val, - y_val, brightness) + y_val, brightness, self._transition) _LOGGER.info("Lights updated to x:%s y:%s brightness:%s, %s%% " "of %s cycle complete at %s", x_val, y_val, brightness, round( percentage_complete * 100), time_state, now) elif self._mode == MODE_RGB: - set_lights_rgb(self.hass, self._lights, rgb) + set_lights_rgb(self.hass, self._lights, rgb, self._transition) _LOGGER.info("Lights updated to rgb:%s, %s%% " "of %s cycle complete at %s", rgb, round(percentage_complete * 100), time_state, now) else: # Convert to mired and clamp to allowed values mired = color_temperature_kelvin_to_mired(temp) - set_lights_temp(self.hass, self._lights, mired, brightness) + set_lights_temp(self.hass, self._lights, mired, brightness, + self._transition) _LOGGER.info("Lights updated to mired:%s brightness:%s, %s%% " "of %s cycle complete at %s", mired, brightness, round(percentage_complete * 100), time_state, now) diff --git a/homeassistant/components/switch/gc100.py b/homeassistant/components/switch/gc100.py new file mode 100644 index 00000000000..ed50c3f63f6 --- /dev/null +++ b/homeassistant/components/switch/gc100.py @@ -0,0 +1,74 @@ +""" +Support for switches using GC100. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.gc100/ +""" +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.gc100 import DATA_GC100, CONF_PORTS +from homeassistant.components.switch import (PLATFORM_SCHEMA) +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.const import DEVICE_DEFAULT_NAME + +DEPENDENCIES = ['gc100'] + +_SWITCH_SCHEMA = vol.Schema({ + cv.string: cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PORTS): vol.All(cv.ensure_list, [_SWITCH_SCHEMA]) +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the GC100 devices.""" + switches = [] + ports = config.get(CONF_PORTS) + for port in ports: + for port_addr, port_name in port.items(): + switches.append(GC100Switch( + port_name, port_addr, hass.data[DATA_GC100])) + add_devices(switches, True) + + +class GC100Switch(ToggleEntity): + """Represent a switch/relay from GC100.""" + + def __init__(self, name, port_addr, gc100): + """Initialize the GC100 switch.""" + # pylint: disable=no-member + self._name = name or DEVICE_DEFAULT_NAME + self._port_addr = port_addr + self._gc100 = gc100 + self._state = None + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return the state of the entity.""" + return self._state + + def turn_on(self): + """Turn the device on.""" + self._gc100.write_switch(self._port_addr, 1, self.set_state) + + def turn_off(self): + """Turn the device off.""" + self._gc100.write_switch(self._port_addr, 0, self.set_state) + + def update(self): + """Update the sensor state.""" + self._gc100.read_sensor(self._port_addr, self.set_state) + + def set_state(self, state): + """Set the current state.""" + self._state = state == 1 + self.schedule_update_ha_state() diff --git a/homeassistant/components/switch/linode.py b/homeassistant/components/switch/linode.py new file mode 100644 index 00000000000..91177e32116 --- /dev/null +++ b/homeassistant/components/switch/linode.py @@ -0,0 +1,100 @@ +""" +Support for interacting with Linode nodes. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.linode/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.linode import ( + CONF_NODES, ATTR_CREATED, ATTR_NODE_ID, ATTR_NODE_NAME, + ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, + ATTR_REGION, ATTR_VCPUS, DATA_LINODE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['linode'] + +DEFAULT_NAME = 'Node' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NODES): vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Linode Node switch.""" + linode = hass.data.get(DATA_LINODE) + nodes = config.get(CONF_NODES) + + dev = [] + for node in nodes: + node_id = linode.get_node_id(node) + if node_id is None: + _LOGGER.error("Node %s is not available", node) + return + dev.append(LinodeSwitch(linode, node_id)) + + add_devices(dev, True) + + +class LinodeSwitch(SwitchDevice): + """Representation of a Linode Node switch.""" + + def __init__(self, li, node_id): + """Initialize a new Linode sensor.""" + self._linode = li + self._node_id = node_id + self.data = None + self._state = None + + @property + def name(self): + """Return the name of the switch.""" + if self.data is not None: + return self.data.label + + @property + def is_on(self): + """Return true if switch is on.""" + if self.data is not None: + return self.data.status == 'running' + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the Linode Node.""" + if self.data: + return { + ATTR_CREATED: self.data.created, + ATTR_NODE_ID: self.data.id, + ATTR_NODE_NAME: self.data.label, + ATTR_IPV4_ADDRESS: self.data.ipv4, + ATTR_IPV6_ADDRESS: self.data.ipv6, + ATTR_MEMORY: self.data.specs.memory, + ATTR_REGION: self.data.region.country, + ATTR_VCPUS: self.data.specs.vcpus, + } + return {} + + def turn_on(self, **kwargs): + """Boot-up the Node.""" + if self.data.status != 'running': + self.data.boot() + + def turn_off(self, **kwargs): + """Shutdown the nodes.""" + if self.data.status == 'running': + self.data.shutdown() + + def update(self): + """Get the latest data from the device and update the data.""" + self._linode.update() + if self._linode.data is not None: + for node in self._linode.data: + if node.id == self._node_id: + self.data = node diff --git a/homeassistant/components/switch/rainbird.py b/homeassistant/components/switch/rainbird.py new file mode 100644 index 00000000000..c1dbfbc4e72 --- /dev/null +++ b/homeassistant/components/switch/rainbird.py @@ -0,0 +1,108 @@ +""" +Support for Rain Bird Irrigation system LNK WiFi Module. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/rainbird/ +""" + +import logging + +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_PLATFORM, CONF_SWITCHES, CONF_ZONE, + CONF_FRIENDLY_NAME, CONF_TRIGGER_TIME, + CONF_SCAN_INTERVAL, CONF_HOST, CONF_PASSWORD) +from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import PlatformNotReady + +REQUIREMENTS = ['pyrainbird==0.1.0'] + +DOMAIN = 'rainbird' +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PLATFORM): DOMAIN, + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_SWITCHES, default={}): vol.Schema({ + cv.string: { + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Required(CONF_ZONE): cv.string, + vol.Required(CONF_TRIGGER_TIME): cv.string, + vol.Optional(CONF_SCAN_INTERVAL): cv.string, + }, + }), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Rain Bird switches over a Rain Bird controller.""" + server = config.get(CONF_HOST) + password = config.get(CONF_PASSWORD) + + from pyrainbird import RainbirdController + controller = RainbirdController(_LOGGER) + controller.setConfig(server, password) + + _LOGGER.debug("Rain Bird Controller set to " + str(server)) + + if controller.currentIrrigation() == -1: + _LOGGER.error("Error getting state. Possible configuration issues") + raise PlatformNotReady + else: + _LOGGER.debug("Initialized Rain Bird Controller") + + devices = [] + for dev_id, switch in config.get(CONF_SWITCHES).items(): + devices.append(RainBirdSwitch(controller, switch, dev_id)) + add_devices(devices, True) + + +class RainBirdSwitch(SwitchDevice): + """Representation of a Rain Bird switch.""" + + def __init__(self, rb, dev, dev_id): + """Initialize a Rain Bird Switch Device.""" + self._rainbird = rb + self._devid = dev_id + self._zone = int(dev.get(CONF_ZONE)) + self._name = dev.get(CONF_FRIENDLY_NAME, + "Sprinker {}".format(self._zone)) + self._state = None + self._duration = dev.get(CONF_TRIGGER_TIME) + self._attributes = { + "duration": self._duration, + "zone": self._zone + } + + @property + def device_state_attributes(self): + """Return state attributes.""" + return self._attributes + + @property + def name(self): + """Get the name of the switch.""" + return self._name + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._rainbird.startIrrigation(int(self._zone), int(self._duration)) + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._rainbird.stopIrrigation() + + def get_device_status(self): + """Get the status of the switch from Rain Bird Controller.""" + return self._rainbird.currentIrrigation() == self._zone + + def update(self): + """Update switch status.""" + self._state = self.get_device_status() + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state diff --git a/homeassistant/components/switch/services.yaml b/homeassistant/components/switch/services.yaml index 5fdd8142ffc..f52b197d432 100644 --- a/homeassistant/components/switch/services.yaml +++ b/homeassistant/components/switch/services.yaml @@ -1,37 +1,32 @@ # Describes the format for available switch services turn_on: - description: Turn a switch on - + description: Turn a switch on. fields: entity_id: description: Name(s) of entities to turn on example: 'switch.living_room' turn_off: - description: Turn a switch off - + description: Turn a switch off. fields: entity_id: - description: Name(s) of entities to turn off + description: Name(s) of entities to turn off. example: 'switch.living_room' toggle: - description: Toggles a switch state - + description: Toggles a switch state. fields: entity_id: - description: Name(s) of entities to toggle + description: Name(s) of entities to toggle. example: 'switch.living_room' mysensors_send_ir_code: description: Set an IR code as a state attribute for a MySensors IR device switch and turn the switch on. - fields: entity_id: description: Name(s) of entities that should have the IR code set and be turned on. Platform dependent. example: 'switch.living_room_1_1' - V_IR_SEND: - description: IR code to send + description: IR code to send. example: '0xC284' diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py new file mode 100644 index 00000000000..d372991c3e2 --- /dev/null +++ b/homeassistant/components/switch/snmp.py @@ -0,0 +1,148 @@ +""" +Support for SNMP enabled switch. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.snmp/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PORT, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pysnmp==4.4.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_BASEOID = 'baseoid' +CONF_COMMUNITY = 'community' +CONF_VERSION = 'version' + +DEFAULT_NAME = 'SNMP Switch' +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = '161' +DEFAULT_COMMUNITY = 'private' +DEFAULT_VERSION = '1' +DEFAULT_PAYLOAD_ON = 1 +DEFAULT_PAYLOAD_OFF = 0 + +SNMP_VERSIONS = { + '1': 0, + '2c': 1 +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_BASEOID): cv.string, + vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In(SNMP_VERSIONS), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the SNMP switch.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + community = config.get(CONF_COMMUNITY) + baseoid = config.get(CONF_BASEOID) + version = config.get(CONF_VERSION) + payload_on = config.get(CONF_PAYLOAD_ON) + payload_off = config.get(CONF_PAYLOAD_OFF) + + add_devices( + [SnmpSwitch(name, host, port, community, baseoid, version, payload_on, + payload_off)], True) + + +class SnmpSwitch(SwitchDevice): + """Represents a SNMP switch.""" + + def __init__(self, name, host, port, community, + baseoid, version, payload_on, payload_off): + """Initialize the switch.""" + self._name = name + self._host = host + self._port = port + self._community = community + self._baseoid = baseoid + self._version = SNMP_VERSIONS[version] + self._state = None + self._payload_on = payload_on + self._payload_off = payload_off + + def turn_on(self): + """Turn on the switch.""" + from pyasn1.type.univ import (Integer) + + self._set(Integer(self._payload_on)) + + def turn_off(self): + """Turn off the switch.""" + from pyasn1.type.univ import (Integer) + + self._set(Integer(self._payload_off)) + + def update(self): + """Update the state.""" + from pysnmp.hlapi import ( + getCmd, CommunityData, SnmpEngine, UdpTransportTarget, ContextData, + ObjectType, ObjectIdentity) + + from pyasn1.type.univ import (Integer) + + request = getCmd( + SnmpEngine(), + CommunityData(self._community, mpModel=self._version), + UdpTransportTarget((self._host, self._port)), + ContextData(), + ObjectType(ObjectIdentity(self._baseoid)) + ) + + errindication, errstatus, errindex, restable = next(request) + + if errindication: + _LOGGER.error("SNMP error: %s", errindication) + elif errstatus: + _LOGGER.error("SNMP error: %s at %s", errstatus.prettyPrint(), + errindex and restable[-1][int(errindex) - 1] or '?') + else: + for resrow in restable: + if resrow[-1] == Integer(self._payload_on): + self._state = True + elif resrow[-1] == Integer(self._payload_off): + self._state = False + else: + self._state = None + + @property + def name(self): + """Return the switch's name.""" + return self._name + + @property + def is_on(self): + """Return true if switch is on; False if off. None if unknown.""" + return self._state + + def _set(self, value): + from pysnmp.hlapi import ( + setCmd, CommunityData, SnmpEngine, UdpTransportTarget, ContextData, + ObjectType, ObjectIdentity) + + request = setCmd( + SnmpEngine(), + CommunityData(self._community, mpModel=self._version), + UdpTransportTarget((self._host, self._port)), + ContextData(), + ObjectType(ObjectIdentity(self._baseoid), value) + ) + + next(request) diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index 2d50363bb2b..93ebf98e9ac 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -13,8 +13,9 @@ from homeassistant.core import callback from homeassistant.components.switch import ( ENTITY_ID_FORMAT, SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, STATE_OFF, - STATE_ON, ATTR_ENTITY_ID, CONF_SWITCHES, EVENT_HOMEASSISTANT_START) + ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, + CONF_ENTITY_PICTURE_TEMPLATE, STATE_OFF, STATE_ON, ATTR_ENTITY_ID, + CONF_SWITCHES, EVENT_HOMEASSISTANT_START) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id @@ -30,6 +31,7 @@ OFF_ACTION = 'turn_off' SWITCH_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, vol.Required(ON_ACTION): cv.SCRIPT_SCHEMA, vol.Required(OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, @@ -51,6 +53,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) state_template = device_config[CONF_VALUE_TEMPLATE] icon_template = device_config.get(CONF_ICON_TEMPLATE) + entity_picture_template = device_config.get( + CONF_ENTITY_PICTURE_TEMPLATE) on_action = device_config[ON_ACTION] off_action = device_config[OFF_ACTION] entity_ids = (device_config.get(ATTR_ENTITY_ID) or @@ -61,10 +65,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if icon_template is not None: icon_template.hass = hass + if entity_picture_template is not None: + entity_picture_template.hass = hass + switches.append( SwitchTemplate( - hass, device, friendly_name, state_template, icon_template, - on_action, off_action, entity_ids) + hass, device, friendly_name, state_template, + icon_template, entity_picture_template, on_action, + off_action, entity_ids) ) if not switches: _LOGGER.error("No switches added") @@ -78,7 +86,8 @@ class SwitchTemplate(SwitchDevice): """Representation of a Template switch.""" def __init__(self, hass, device_id, friendly_name, state_template, - icon_template, on_action, off_action, entity_ids): + icon_template, entity_picture_template, on_action, + off_action, entity_ids): """Initialize the Template switch.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -89,7 +98,9 @@ class SwitchTemplate(SwitchDevice): self._off_script = Script(hass, off_action) self._state = False self._icon_template = icon_template + self._entity_picture_template = entity_picture_template self._icon = None + self._entity_picture = None self._entities = entity_ids @asyncio.coroutine @@ -136,13 +147,20 @@ class SwitchTemplate(SwitchDevice): """Return the icon to use in the frontend, if any.""" return self._icon - def turn_on(self, **kwargs): - """Fire the on action.""" - self._on_script.run() + @property + def entity_picture(self): + """Return the entity_picture to use in the frontend, if any.""" + return self._entity_picture - def turn_off(self, **kwargs): + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Fire the on action.""" + yield from self._on_script.async_run() + + @asyncio.coroutine + def async_turn_off(self, **kwargs): """Fire the off action.""" - self._off_script.run() + yield from self._off_script.async_run() @asyncio.coroutine def async_update(self): @@ -162,16 +180,27 @@ class SwitchTemplate(SwitchDevice): _LOGGER.error(ex) self._state = None - if self._icon_template is not None: + for property_name, template in ( + ('_icon', self._icon_template), + ('_entity_picture', self._entity_picture_template)): + if template is None: + continue + try: - self._icon = self._icon_template.async_render() + setattr(self, property_name, template.async_render()) except TemplateError as ex: + friendly_property_name = property_name[1:].replace('_', ' ') if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): # Common during HA startup - so just a warning - _LOGGER.warning('Could not render icon template %s,' - ' the state is unknown.', self._name) + _LOGGER.warning('Could not render %s template %s,' + ' the state is unknown.', + friendly_property_name, self._name) return - self._icon = super().icon - _LOGGER.error('Could not render icon template %s: %s', - self._name, ex) + + try: + setattr(self, property_name, + getattr(super(), property_name)) + except AttributeError: + _LOGGER.error('Could not render %s template %s: %s', + friendly_property_name, self._name, ex) diff --git a/homeassistant/components/switch/toon.py b/homeassistant/components/switch/toon.py index 656d175ff3a..d5f50be0bef 100644 --- a/homeassistant/components/switch/toon.py +++ b/homeassistant/components/switch/toon.py @@ -1,18 +1,19 @@ """ Support for Eneco Slimmer stekkers (Smart Plugs). -This provides controlls for the z-wave smart plugs Toon can control. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.toon/ """ import logging -from homeassistant.components.switch import SwitchDevice import homeassistant.components.toon as toon_main +from homeassistant.components.switch import SwitchDevice _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Setup discovered Smart Plugs.""" + """Set up the discovered Toon Smart Plugs.""" _toon_main = hass.data[toon_main.TOON_HANDLE] switch_items = [] for plug in _toon_main.toon.smartplugs: @@ -22,18 +23,13 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class EnecoSmartPlug(SwitchDevice): - """Representation of a Smart Plug.""" + """Representation of a Toon Smart Plug.""" def __init__(self, hass, plug): """Initialize the Smart Plug.""" self.smartplug = plug self.toon_data_store = hass.data[toon_main.TOON_HANDLE] - @property - def should_poll(self): - """No polling needed with subscriptions.""" - return True - @property def unique_id(self): """Return the ID of this switch.""" diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index a7cb8681791..1191322dce6 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -18,14 +18,14 @@ from homeassistant.exceptions import PlatformNotReady _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Miio Switch' -PLATFORM = 'xiaomi_miio' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-mirobo==0.2.0'] +REQUIREMENTS = ['python-miio==0.3.0'] ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' @@ -38,7 +38,7 @@ SUCCESS = ['ok'] @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the switch from config.""" - from mirobo import Plug, DeviceException + from miio import Device, DeviceException host = config.get(CONF_HOST) name = config.get(CONF_NAME) @@ -46,23 +46,51 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + devices = [] try: - plug = Plug(host, token) + plug = Device(host, token) device_info = plug.info() _LOGGER.info("%s %s %s initialized", - device_info.raw['model'], - device_info.raw['fw_ver'], - device_info.raw['hw_ver']) + device_info.model, + device_info.firmware_version, + device_info.hardware_version) - xiaomi_plug_switch = XiaomiPlugSwitch(name, plug, device_info) + if device_info.model in ['chuangmi.plug.v1']: + from miio import PlugV1 + plug = PlugV1(host, token) + + # The device has two switchable channels (mains and a USB port). + # A switch device per channel will be created. + for channel_usb in [True, False]: + device = ChuangMiPlugV1Switch( + name, plug, device_info, channel_usb) + devices.append(device) + + elif device_info.model in ['qmi.powerstrip.v1', + 'zimi.powerstrip.v2']: + from miio import Strip + plug = Strip(host, token) + device = XiaomiPowerStripSwitch(name, plug, device_info) + devices.append(device) + elif device_info.model in ['chuangmi.plug.m1', + 'chuangmi.plug.v2']: + from miio import Plug + plug = Plug(host, token) + device = XiaomiPlugGenericSwitch(name, plug, device_info) + devices.append(device) + else: + _LOGGER.error( + 'Unsupported device found! Please create an issue at ' + 'https://github.com/rytilahti/python-miio/issues ' + 'and provide the following data: %s', device_info.model) except DeviceException: raise PlatformNotReady - async_add_devices([xiaomi_plug_switch], update_before_add=True) + async_add_devices(devices, update_before_add=True) -class XiaomiPlugSwitch(SwitchDevice): - """Representation of a Xiaomi Plug.""" +class XiaomiPlugGenericSwitch(SwitchDevice): + """Representation of a Xiaomi Plug Generic.""" def __init__(self, name, plug, device_info): """Initialize the plug switch.""" @@ -74,8 +102,7 @@ class XiaomiPlugSwitch(SwitchDevice): self._state = None self._state_attrs = { ATTR_TEMPERATURE: None, - ATTR_LOAD_POWER: None, - ATTR_MODEL: self._device_info.raw['model'], + ATTR_MODEL: self._device_info.model, } self._skip_update = False @@ -112,7 +139,7 @@ class XiaomiPlugSwitch(SwitchDevice): @asyncio.coroutine def _try_command(self, mask_error, func, *args, **kwargs): """Call a plug command handling error messages.""" - from mirobo import DeviceException + from miio import DeviceException try: result = yield from self.hass.async_add_job( partial(func, *args, **kwargs)) @@ -147,7 +174,43 @@ class XiaomiPlugSwitch(SwitchDevice): @asyncio.coroutine def async_update(self): """Fetch state from the device.""" - from mirobo import DeviceException + from miio import DeviceException + + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + + try: + state = yield from self.hass.async_add_job(self._plug.status) + _LOGGER.debug("Got new state: %s", state) + + self._state = state.is_on + self._state_attrs.update({ + ATTR_TEMPERATURE: state.temperature + }) + + except DeviceException as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) + + +class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch, SwitchDevice): + """Representation of a Xiaomi Power Strip.""" + + def __init__(self, name, plug, device_info): + """Initialize the plug switch.""" + XiaomiPlugGenericSwitch.__init__(self, name, plug, device_info) + + self._state_attrs = { + ATTR_TEMPERATURE: None, + ATTR_LOAD_POWER: None, + ATTR_MODEL: self._device_info.model, + } + + @asyncio.coroutine + def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException # On state change the device doesn't provide the new state immediately. if self._skip_update: @@ -161,8 +224,69 @@ class XiaomiPlugSwitch(SwitchDevice): self._state = state.is_on self._state_attrs.update({ ATTR_TEMPERATURE: state.temperature, - ATTR_LOAD_POWER: state.load_power, + ATTR_LOAD_POWER: state.load_power }) except DeviceException as ex: _LOGGER.error("Got exception while fetching the state: %s", ex) + + +class ChuangMiPlugV1Switch(XiaomiPlugGenericSwitch, SwitchDevice): + """Representation of a Chuang Mi Plug V1.""" + + def __init__(self, name, plug, device_info, channel_usb): + """Initialize the plug switch.""" + name = name + ' USB' if channel_usb else name + + XiaomiPlugGenericSwitch.__init__(self, name, plug, device_info) + self._channel_usb = channel_usb + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn a channel on.""" + if self._channel_usb: + result = yield from self._try_command( + "Turning the plug on failed.", self._plug.usb_on) + else: + result = yield from self._try_command( + "Turning the plug on failed.", self._plug.on) + + if result: + self._state = True + self._skip_update = True + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn a channel off.""" + if self._channel_usb: + result = yield from self._try_command( + "Turning the plug on failed.", self._plug.usb_off) + else: + result = yield from self._try_command( + "Turning the plug on failed.", self._plug.off) + + if result: + self._state = False + self._skip_update = True + + @asyncio.coroutine + def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + + try: + state = yield from self.hass.async_add_job(self._plug.status) + _LOGGER.debug("Got new state: %s", state) + + if self._channel_usb: + self._state = state.usb_power + else: + self._state = state.is_on + + except DeviceException as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index d8ad6a6fecb..3b86d97c310 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -1,239 +1,190 @@ -send_message: - description: Send a notification +# Describes the format for available Telegram bot services +send_message: + description: Send a notification. fields: message: description: Message body of the notification. example: The garage door has been open for 10 minutes. - title: description: Optional title for your notification. Will be composed as '%title\n%message' example: 'Your Garage Door Friend' - target: description: An array of pre-authorized chat_ids to send the notification to. If not present, first allowed chat_id is the default. example: '[12345, 67890] or 12345' - parse_mode: description: "Parser for the message text: `html` or `markdown`." example: 'html' - disable_notification: description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. example: true - disable_web_page_preview: description: Disables link previews for links in the message. example: true - keyboard: description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' - inline_keyboard: description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. example: '["/button1, /button2", "/button3"] or ["Text button1:/button1, Text button2:/button2", "Text button3:/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' send_photo: - description: Send a photo - + description: Send a photo. fields: url: description: Remote path to an image. example: 'http://example.org/path/to/the/image.png' - file: description: Local path to an image. example: '/path/to/the/image.png' - caption: description: The title of the image. example: 'My image' - username: description: Username for a URL which require HTTP basic authentication. example: myuser - password: description: Password for a URL which require HTTP basic authentication. example: myuser_pwd - target: description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. example: '[12345, 67890] or 12345' - disable_notification: description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. example: true - keyboard: description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' - inline_keyboard: description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' send_document: - description: Send a document - + description: Send a document. fields: url: description: Remote path to a document. example: 'http://example.org/path/to/the/document.odf' - file: description: Local path to a document. example: '/tmp/whatever.odf' - caption: description: The title of the document. example: Document Title xy - username: description: Username for a URL which require HTTP basic authentication. example: myuser - password: description: Password for a URL which require HTTP basic authentication. example: myuser_pwd - target: description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. example: '[12345, 67890] or 12345' - disable_notification: description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. example: true - keyboard: description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' - inline_keyboard: description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' send_location: - description: Send a location - + description: Send a location. fields: latitude: description: The latitude to send. example: -15.123 - longitude: description: The longitude to send. example: 38.123 - target: description: An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default. example: '[12345, 67890] or 12345' - disable_notification: description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. example: true - keyboard: description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' - inline_keyboard: description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' edit_message: description: Edit a previusly sent message. - fields: message_id: description: id of the message to edit. example: '{{ trigger.event.data.message.message_id }}' - chat_id: description: The chat_id where to edit the message. example: 12345 - message: description: Message body of the notification. example: The garage door has been open for 10 minutes. - title: description: Optional title for your notification. Will be composed as '%title\n%message' example: 'Your Garage Door Friend' - parse_mode: description: "Parser for the message text: `html` or `markdown`." example: 'html' - disable_web_page_preview: description: Disables link previews for links in the message. example: true - inline_keyboard: description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' edit_caption: description: Edit the caption of a previusly sent message. - fields: message_id: description: id of the message to edit. example: '{{ trigger.event.data.message.message_id }}' - chat_id: description: The chat_id where to edit the caption. example: 12345 - caption: description: Message body of the notification. example: The garage door has been open for 10 minutes. - inline_keyboard: description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' edit_replymarkup: description: Edit the inline keyboard of a previusly sent message. - fields: message_id: description: id of the message to edit. example: '{{ trigger.event.data.message.message_id }}' - chat_id: description: The chat_id where to edit the reply_markup. example: 12345 - inline_keyboard: description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' answer_callback_query: description: Respond to a callback query originated by clicking on an online keyboard button. The answer will be displayed to the user as a notification at the top of the chat screen or as an alert. - fields: message: description: Unformatted text message body of the notification. example: "OK, I'm listening" - callback_query_id: description: Unique id of the callback response. example: '{{ trigger.event.data.id }}' - show_alert: description: Show a permanent notification. example: true delete_message: description: Delete a previously sent message.

 - fields: message_id: description: id of the message to delete. example: '{{ trigger.event.data.message.message_id }}'

 - chat_id: description: The chat_id where to delete the message. example: 12345 diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index 1f2b3720062..a0e1efbd75c 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -117,6 +117,8 @@ class TelldusLiveClient(object): return 'cover' elif device.methods & TURNON: return 'switch' + elif device.methods == 0: + return 'binary_sensor' _LOGGER.warning( "Unidentified device type (methods: %d)", device.methods) return 'switch' diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index 6ae96b88da7..85407ff4c7a 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -10,16 +10,18 @@ import threading import voluptuous as vol from homeassistant.helpers import discovery -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT) from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['tellcore-py==1.1.2'] +REQUIREMENTS = ['tellcore-py==1.1.2', 'tellcore-net==0.1'] _LOGGER = logging.getLogger(__name__) ATTR_DISCOVER_CONFIG = 'config' ATTR_DISCOVER_DEVICES = 'devices' -ATTR_SIGNAL_REPETITIONS = 'signal_repetitions' +CONF_SIGNAL_REPETITIONS = 'signal_repetitions' DEFAULT_SIGNAL_REPETITIONS = 1 DOMAIN = 'tellstick' @@ -34,7 +36,9 @@ TELLCORE_REGISTRY = None CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(ATTR_SIGNAL_REPETITIONS, + vol.Inclusive(CONF_HOST, 'tellcore-net'): cv.string, + vol.Inclusive(CONF_PORT, 'tellcore-net'): cv.port, + vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS): vol.Coerce(int), }), }, extra=vol.ALLOW_EXTRA) @@ -48,7 +52,7 @@ def _discover(hass, config, component_name, found_tellcore_devices): _LOGGER.info("Discovered %d new %s devices", len(found_tellcore_devices), component_name) - signal_repetitions = config[DOMAIN].get(ATTR_SIGNAL_REPETITIONS) + signal_repetitions = config[DOMAIN].get(CONF_SIGNAL_REPETITIONS) discovery.load_platform(hass, component_name, DOMAIN, { ATTR_DISCOVER_DEVICES: found_tellcore_devices, @@ -58,12 +62,28 @@ def _discover(hass, config, component_name, found_tellcore_devices): def setup(hass, config): """Set up the Tellstick component.""" from tellcore.constants import TELLSTICK_DIM - from tellcore.telldus import AsyncioCallbackDispatcher + from tellcore.telldus import QueuedCallbackDispatcher from tellcore.telldus import TelldusCore + from tellcorenet import TellCoreClient + + conf = config.get(DOMAIN, {}) + net_host = conf.get(CONF_HOST) + net_port = conf.get(CONF_PORT) + + # Initialize remote tellcore client + if net_host and net_port: + net_client = TellCoreClient(net_host, net_port) + net_client.start() + + def stop_tellcore_net(event): + """Event handler to stop the client.""" + net_client.stop() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_tellcore_net) try: tellcore_lib = TelldusCore( - callback_dispatcher=AsyncioCallbackDispatcher(hass.loop)) + callback_dispatcher=QueuedCallbackDispatcher()) except OSError: _LOGGER.exception("Could not initialize Tellstick") return False diff --git a/homeassistant/components/tesla.py b/homeassistant/components/tesla.py index 915ebb6d4c3..86dc9c86792 100644 --- a/homeassistant/components/tesla.py +++ b/homeassistant/components/tesla.py @@ -6,8 +6,6 @@ https://home-assistant.io/components/tesla/ """ from collections import defaultdict import logging - -from urllib.error import HTTPError import voluptuous as vol from homeassistant.const import ( @@ -17,7 +15,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import slugify -REQUIREMENTS = ['teslajsonpy==0.0.17'] +REQUIREMENTS = ['teslajsonpy==0.0.18'] DOMAIN = 'tesla' @@ -45,7 +43,7 @@ TESLA_COMPONENTS = [ def setup(hass, base_config): """Set up of Tesla platform.""" - from teslajsonpy.controller import Controller as teslaApi + from teslajsonpy import Controller as teslaAPI, TeslaException config = base_config.get(DOMAIN) @@ -55,12 +53,12 @@ def setup(hass, base_config): if hass.data.get(DOMAIN) is None: try: hass.data[DOMAIN] = { - 'controller': teslaApi( + 'controller': teslaAPI( email, password, update_interval), 'devices': defaultdict(list) } _LOGGER.debug("Connected to the Tesla API.") - except HTTPError as ex: + except TeslaException as ex: if ex.code == 401: hass.components.persistent_notification.create( "Error:
Please check username and password." @@ -72,12 +70,11 @@ def setup(hass, base_config): "Error:
Can't communicate with Tesla API.
" "Error code: {} Reason: {}" "You will need to restart Home Assistant after fixing." - "".format(ex.code, ex.reason), + "".format(ex.code, ex.message), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) _LOGGER.error("Unable to communicate with Tesla API: %s", - ex.reason) - + ex.message) return False all_devices = hass.data[DOMAIN]['controller'].list_vehicles() diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py new file mode 100644 index 00000000000..b2f5db88b5f --- /dev/null +++ b/homeassistant/components/timer/__init__.py @@ -0,0 +1,320 @@ +""" +Timer component. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/timer/ +""" +import asyncio +import logging +import os +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.util.dt as dt_util +import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_track_point_in_utc_time + +from homeassistant.loader import bind_hass + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'timer' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +DEFAULT_DURATION = 0 +ATTR_DURATION = 'duration' +CONF_DURATION = 'duration' + +STATUS_IDLE = 'idle' +STATUS_ACTIVE = 'active' +STATUS_PAUSED = 'paused' + +EVENT_TIMER_FINISHED = 'timer.finished' +EVENT_TIMER_CANCELLED = 'timer.cancelled' + +SERVICE_START = 'start' +SERVICE_PAUSE = 'pause' +SERVICE_CANCEL = 'cancel' +SERVICE_FINISH = 'finish' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +SERVICE_SCHEMA_DURATION = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_DURATION, + default=timedelta(DEFAULT_DURATION)): cv.time_period, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.Any({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_DURATION, timedelta(DEFAULT_DURATION)): + cv.time_period, + }, None) + }) +}, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def start(hass, entity_id, duration): + """Start a timer.""" + hass.add_job(async_start, hass, entity_id, {ATTR_ENTITY_ID: entity_id, + ATTR_DURATION: duration}) + + +@callback +@bind_hass +def async_start(hass, entity_id, duration): + """Start a timer.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_START, {ATTR_ENTITY_ID: entity_id, + ATTR_DURATION: duration})) + + +@bind_hass +def pause(hass, entity_id): + """Pause a timer.""" + hass.add_job(async_pause, hass, entity_id) + + +@callback +@bind_hass +def async_pause(hass, entity_id): + """Pause a timer.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_PAUSE, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def cancel(hass, entity_id): + """Cancel a timer.""" + hass.add_job(async_cancel, hass, entity_id) + + +@callback +@bind_hass +def async_cancel(hass, entity_id): + """Cancel a timer.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_CANCEL, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def finish(hass, entity_id): + """Finish a timer.""" + hass.add_job(async_cancel, hass, entity_id) + + +@callback +@bind_hass +def async_finish(hass, entity_id): + """Finish a timer.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_FINISH, {ATTR_ENTITY_ID: entity_id})) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up a timer.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + icon = cfg.get(CONF_ICON) + duration = cfg.get(CONF_DURATION) + + entities.append(Timer(hass, object_id, name, icon, duration)) + + if not entities: + return False + + @asyncio.coroutine + def async_handler_service(service): + """Handle a call to the timer services.""" + target_timers = component.async_extract_from_service(service) + + attr = None + if service.service == SERVICE_PAUSE: + attr = 'async_pause' + elif service.service == SERVICE_CANCEL: + attr = 'async_cancel' + elif service.service == SERVICE_FINISH: + attr = 'async_finish' + + tasks = [getattr(timer, attr)() for timer in target_timers if attr] + if service.service == SERVICE_START: + for timer in target_timers: + tasks.append( + timer.async_start(service.data.get(ATTR_DURATION)) + ) + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, + os.path.join(os.path.dirname(__file__), 'services.yaml') + ) + + hass.services.async_register( + DOMAIN, SERVICE_START, async_handler_service, + descriptions[SERVICE_START], SERVICE_SCHEMA_DURATION) + hass.services.async_register( + DOMAIN, SERVICE_PAUSE, async_handler_service, + descriptions[SERVICE_PAUSE], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_CANCEL, async_handler_service, + descriptions[SERVICE_CANCEL], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_FINISH, async_handler_service, + descriptions[SERVICE_FINISH], SERVICE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class Timer(Entity): + """Representation of a timer.""" + + def __init__(self, hass, object_id, name, icon, duration): + """Initialize a timer.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._state = STATUS_IDLE + self._duration = duration + self._remaining = self._duration + self._icon = icon + self._hass = hass + self._end = None + self._listener = None + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return name of the timer.""" + 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 current value of the timer.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_DURATION: str(self._duration), + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity is about to be added to Home Assistant.""" + # If not None, we got an initial value. + if self._state is not None: + return + + restore_state = self._hass.helpers.restore_state + state = yield from restore_state.async_get_last_state(self.entity_id) + self._state = state and state.state == state + + @asyncio.coroutine + def async_start(self, duration): + """Start a timer.""" + if self._listener: + self._listener() + self._listener = None + newduration = None + if duration: + newduration = duration + + self._state = STATUS_ACTIVE + # pylint: disable=redefined-outer-name + start = dt_util.utcnow() + if self._remaining and newduration is None: + self._end = start + self._remaining + else: + if newduration: + self._duration = newduration + self._remaining = newduration + else: + self._remaining = self._duration + self._end = start + self._duration + self._listener = async_track_point_in_utc_time(self._hass, + self.async_finished, + self._end) + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_pause(self): + """Pause a timer.""" + if self._listener is None: + return + + self._listener() + self._listener = None + self._remaining = self._end - dt_util.utcnow() + self._state = STATUS_PAUSED + self._end = None + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_cancel(self): + """Cancel a timer.""" + if self._listener: + self._listener() + self._listener = None + self._state = STATUS_IDLE + self._end = None + self._remaining = timedelta() + self._hass.bus.async_fire(EVENT_TIMER_CANCELLED, + {"entity_id": self.entity_id}) + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_finish(self): + """Reset and updates the states, fire finished event.""" + if self._state != STATUS_ACTIVE: + return + + self._listener = None + self._state = STATUS_IDLE + self._remaining = timedelta() + self._hass.bus.async_fire(EVENT_TIMER_FINISHED, + {"entity_id": self.entity_id}) + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_finished(self, time): + """Reset and updates the states, fire finished event.""" + if self._state != STATUS_ACTIVE: + return + + self._listener = None + self._state = STATUS_IDLE + self._remaining = timedelta() + self._hass.bus.async_fire(EVENT_TIMER_FINISHED, + {"entity_id": self.entity_id}) + yield from self.async_update_ha_state() diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml new file mode 100644 index 00000000000..f7d2c1a77b5 --- /dev/null +++ b/homeassistant/components/timer/services.yaml @@ -0,0 +1,34 @@ +start: + description: Start a timer. + + fields: + entity_id: + description: Entity id of the timer to start. [optional] + example: 'timer.timer0' + duration: + description: Duration the timer requires to finish. [optional] + example: '00:01:00 or 60' + +pause: + description: Pause a timer. + + fields: + entity_id: + description: Entity id of the timer to pause. [optional] + example: 'timer.timer0' + +cancel: + description: Cancel a timer. + + fields: + entity_id: + description: Entity id of the timer to cancel. [optional] + example: 'timer.timer0' + +finish: + description: Finish a timer. + + fields: + entity_id: + description: Entity id of the timer to finish. [optional] + example: 'timer.timer0' \ No newline at end of file diff --git a/homeassistant/components/toon.py b/homeassistant/components/toon.py index d873c42e815..ffb820e8148 100644 --- a/homeassistant/components/toon.py +++ b/homeassistant/components/toon.py @@ -1,32 +1,33 @@ """ Toon van Eneco Support. -This provides a component for the rebranded Quby thermostat as provided by -Eneco. +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/toon/ """ import logging from datetime import datetime, timedelta + import voluptuous as vol -# Import the device class from the component that you want to support +import homeassistant.helpers.config_validation as cv from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) from homeassistant.helpers.discovery import load_platform -import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -# Home Assistant depends on 3rd party packages for API specific code. REQUIREMENTS = ['toonlib==1.0.2'] _LOGGER = logging.getLogger(__name__) +CONF_GAS = 'gas' +CONF_SOLAR = 'solar' + +DEFAULT_GAS = True +DEFAULT_SOLAR = False +DOMAIN = 'toon' + MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) -DOMAIN = 'toon' TOON_HANDLE = 'toon_handle' -CONF_GAS = 'gas' -DEFAULT_GAS = True -CONF_SOLAR = 'solar' -DEFAULT_SOLAR = False # Validation of the user's configuration CONFIG_SCHEMA = vol.Schema({ @@ -40,37 +41,32 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): - """Setup toon.""" + """Set up the Toon component.""" from toonlib import InvalidCredentials - gas = config['toon']['gas'] - solar = config['toon']['solar'] + gas = config[DOMAIN][CONF_GAS] + solar = config[DOMAIN][CONF_SOLAR] + username = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] try: - hass.data[TOON_HANDLE] = ToonDataStore(config['toon']['username'], - config['toon']['password'], - gas, - solar) + hass.data[TOON_HANDLE] = ToonDataStore(username, password, gas, solar) except InvalidCredentials: return False - # Load all platforms for platform in ('climate', 'sensor', 'switch'): load_platform(hass, platform, DOMAIN, {}, config) - # Initialization successfull return True -class ToonDataStore: - """An object to store the toon data.""" +class ToonDataStore(object): + """An object to store the Toon data.""" def __init__(self, username, password, gas=DEFAULT_GAS, solar=DEFAULT_SOLAR): - """Initialize toon.""" + """Initialize Toon.""" from toonlib import Toon - # Creating the class - toon = Toon(username, password) self.toon = toon @@ -83,7 +79,7 @@ class ToonDataStore: @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): - """Update toon data.""" + """Update Toon data.""" self.last_update = datetime.now() self.data['power_current'] = self.toon.power.value @@ -104,11 +100,12 @@ class ToonDataStore: 1000, 2) for plug in self.toon.smartplugs: - self.data[plug.name] = {'current_power': plug.current_usage, - 'today_energy': round( - float(plug.daily_usage) / 1000, 2), - 'current_state': plug.current_state, - 'is_connected': plug.is_connected} + self.data[plug.name] = { + 'current_power': plug.current_usage, + 'today_energy': round(float(plug.daily_usage) / 1000, 2), + 'current_state': plug.current_state, + 'is_connected': plug.is_connected, + } self.data['solar_maximum'] = self.toon.solar.maximum self.data['solar_produced'] = self.toon.solar.produced @@ -123,11 +120,12 @@ class ToonDataStore: for detector in self.toon.smokedetectors: value = '{}_smoke_detector'.format(detector.name) - self.data[value] = {'smoke_detector': detector.battery_level, - 'device_type': detector.device_type, - 'is_connected': detector.is_connected, - 'last_connected_change': - detector.last_connected_change} + self.data[value] = { + 'smoke_detector': detector.battery_level, + 'device_type': detector.device_type, + 'is_connected': detector.is_connected, + 'last_connected_change': detector.last_connected_change, + } def set_state(self, state): """Push a new state to the Toon unit.""" diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index a24305c7fd4..ead4924d599 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -13,17 +13,18 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery -from homeassistant.const import CONF_HOST, CONF_API_KEY +from homeassistant.const import CONF_HOST from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI -REQUIREMENTS = ['pytradfri==3.0', - 'DTLSSocket==0.1.3', +REQUIREMENTS = ['pytradfri==4.0.1', + 'DTLSSocket==0.1.4', 'https://github.com/chrysn/aiocoap/archive/' '3286f48f0b949901c8b5c04c0719dc54ab63d431.zip' '#aiocoap==0.3'] DOMAIN = 'tradfri' -CONFIG_FILE = 'tradfri.conf' +GATEWAY_IDENTITY = 'homeassistant' +CONFIG_FILE = '.tradfri_psk.conf' KEY_CONFIG = 'tradfri_configuring' KEY_GATEWAY = 'tradfri_gateway' KEY_API = 'tradfri_api' @@ -34,7 +35,6 @@ DEFAULT_ALLOW_TRADFRI_GROUPS = True CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Inclusive(CONF_HOST, 'gateway'): cv.string, - vol.Inclusive(CONF_API_KEY, 'gateway'): cv.string, vol.Optional(CONF_ALLOW_TRADFRI_GROUPS, default=DEFAULT_ALLOW_TRADFRI_GROUPS): cv.boolean, }) @@ -56,9 +56,17 @@ def request_configuration(hass, config, host): @asyncio.coroutine def configuration_callback(callback_data): """Handle the submitted configuration.""" - res = yield from _setup_gateway(hass, config, host, - callback_data.get('key'), + try: + from pytradfri.api.aiocoap_api import APIFactory + except ImportError: + _LOGGER.exception("Looks like something isn't installed!") + return + + api_factory = APIFactory(host, psk_id=GATEWAY_IDENTITY) + psk = yield from api_factory.generate_psk(callback_data.get('key')) + res = yield from _setup_gateway(hass, config, host, psk, DEFAULT_ALLOW_TRADFRI_GROUPS) + if not res: hass.async_add_job(configurator.notify_errors, instance, "Unable to connect.") @@ -67,7 +75,7 @@ def request_configuration(hass, config, host): def success(): """Set up was successful.""" conf = _read_config(hass) - conf[host] = {'key': callback_data.get('key')} + conf[host] = {'key': psk} _write_config(hass, conf) hass.async_add_job(configurator.request_done, instance) @@ -87,13 +95,12 @@ def async_setup(hass, config): """Set up the Tradfri component.""" conf = config.get(DOMAIN, {}) host = conf.get(CONF_HOST) - key = conf.get(CONF_API_KEY) allow_tradfri_groups = conf.get(CONF_ALLOW_TRADFRI_GROUPS) + keys = yield from hass.async_add_job(_read_config, hass) @asyncio.coroutine def gateway_discovered(service, info): """Run when a gateway is discovered.""" - keys = yield from hass.async_add_job(_read_config, hass) host = info['host'] if host in keys: @@ -104,11 +111,16 @@ def async_setup(hass, config): discovery.async_listen(hass, SERVICE_IKEA_TRADFRI, gateway_discovered) - if host is None: + if not host: return True - return (yield from _setup_gateway(hass, config, host, key, - allow_tradfri_groups)) + if host and keys.get(host): + return (yield from _setup_gateway(hass, config, host, + keys[host]['key'], + allow_tradfri_groups)) + else: + hass.async_add_job(request_configuration, hass, config, host) + return True @asyncio.coroutine @@ -116,19 +128,21 @@ def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): """Create a gateway.""" from pytradfri import Gateway, RequestError try: - from pytradfri.api.aiocoap_api import api_factory + from pytradfri.api.aiocoap_api import APIFactory except ImportError: _LOGGER.exception("Looks like something isn't installed!") return False try: - api = yield from api_factory(host, key, loop=hass.loop) + factory = APIFactory(host, psk_id=GATEWAY_IDENTITY, psk=key, + loop=hass.loop) + api = factory.request + gateway = Gateway() + gateway_info_result = yield from api(gateway.get_gateway_info()) except RequestError: _LOGGER.exception("Tradfri setup failed.") return False - gateway = Gateway() - gateway_info_result = yield from api(gateway.get_gateway_info()) gateway_id = gateway_info_result.id hass.data.setdefault(KEY_API, {}) hass.data.setdefault(KEY_GATEWAY, {}) diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index a75f71c3463..d7cf0f1f2d1 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -11,7 +11,7 @@ from homeassistant.components.tts import Provider, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['boto3==1.4.3'] +REQUIREMENTS = ['boto3==1.4.7'] CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' diff --git a/homeassistant/components/tts/microsoft.py b/homeassistant/components/tts/microsoft.py new file mode 100644 index 00000000000..4f4c5eb959d --- /dev/null +++ b/homeassistant/components/tts/microsoft.py @@ -0,0 +1,89 @@ +""" +Support for the Microsoft Cognitive Services text-to-speech service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tts.microsoft/ +""" +import logging +from http.client import HTTPException + +import voluptuous as vol + +from homeassistant.components.tts import Provider, PLATFORM_SCHEMA, CONF_LANG +from homeassistant.const import CONF_TYPE, CONF_API_KEY +import homeassistant.helpers.config_validation as cv + +CONF_GENDER = 'gender' +CONF_OUTPUT = 'output' + +REQUIREMENTS = ["pycsspeechtts==1.0.1"] + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_LANGUAGES = [ + 'ar-eg', 'ar-sa', 'ca-es', 'cs-cz', 'da-dk', 'de-at', 'de-ch', 'de-de', + 'el-gr', 'en-au', 'en-ca', 'en-ga', 'en-ie', 'en-in', 'en-us', 'es-es', + 'en-mx', 'fi-fi', 'fr-ca', 'fr-ch', 'fr-fr', 'he-il', 'hi-in', 'hu-hu', + 'id-id', 'it-it', 'ja-jp', 'ko-kr', 'nb-no', 'nl-nl', 'pl-pl', 'pt-br', + 'pt-pt', 'ro-ro', 'ru-ru', 'sk-sk', 'sv-se', 'th-th', 'tr-tr', 'zh-cn', + 'zh-hk', 'zh-tw', +] + +GENDERS = [ + 'Female', 'Male', +] + +DEFAULT_LANG = 'en-us' +DEFAULT_GENDER = 'Female' +DEFAULT_TYPE = 'ZiraRUS' +DEFAULT_OUTPUT = 'audio-16khz-128kbitrate-mono-mp3' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORTED_LANGUAGES), + vol.Optional(CONF_GENDER, default=DEFAULT_GENDER): vol.In(GENDERS), + vol.Optional(CONF_TYPE, default=DEFAULT_TYPE): cv.string, +}) + + +def get_engine(hass, config): + """Set up Microsoft speech component.""" + return MicrosoftProvider(config[CONF_API_KEY], config[CONF_LANG], + config[CONF_GENDER], config[CONF_TYPE]) + + +class MicrosoftProvider(Provider): + """The Microsoft speech API provider.""" + + def __init__(self, apikey, lang, gender, ttype): + """Init Microsoft TTS service.""" + self._apikey = apikey + self._lang = lang + self._gender = gender + self._type = ttype + self._output = DEFAULT_OUTPUT + self.name = 'Microsoft' + + @property + def default_language(self): + """Return the default language.""" + return self._lang + + @property + def supported_languages(self): + """Return list of supported languages.""" + return SUPPORTED_LANGUAGES + + def get_tts_audio(self, message, language, options=None): + """Load TTS from Microsoft.""" + if language is None: + language = self._lang + from pycsspeechtts import pycsspeechtts + try: + trans = pycsspeechtts.TTSTranslator(self._apikey) + data = trans.speak(language, self._gender, self._type, + self._output, message) + except HTTPException as ex: + _LOGGER.error("Error occurred for Microsoft TTS: %s", ex) + return(None, None) + return ("mp3", data) diff --git a/homeassistant/components/tts/services.yaml b/homeassistant/components/tts/services.yaml index 7e69d4939f0..823eef632f3 100644 --- a/homeassistant/components/tts/services.yaml +++ b/homeassistant/components/tts/services.yaml @@ -1,23 +1,20 @@ +# Describes the format for available TTS services + say: description: Say some things on a media player. - fields: entity_id: description: Name(s) of media player entities. example: 'media_player.floor' - message: description: Text to speak on devices. example: 'My name is hanna' - cache: description: Control file cache of this message. example: 'true' - language: description: Language to use for speech generation. example: 'ru' - options: description: A dictionary containing platform-specific options. Optional depending on the platform. example: platform specific diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index 7f321d055cf..fea365ac7c7 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -1,6 +1,7 @@ +# Describes the format for available vacuum services + turn_on: description: Start a new cleaning task. - fields: entity_id: description: Name of the botvac entity. @@ -8,7 +9,6 @@ turn_on: turn_off: description: Stop the current cleaning task and return to home. - fields: entity_id: description: Name of the botvac entity. @@ -16,7 +16,6 @@ turn_off: stop: description: Stop the current cleaning task. - fields: entity_id: description: Name of the botvac entity. @@ -24,7 +23,6 @@ stop: locate: description: Locate the vacuum cleaner robot. - fields: entity_id: description: Name of the botvac entity. @@ -32,7 +30,6 @@ locate: start_pause: description: Start, pause, or resume the cleaning task. - fields: entity_id: description: Name of the botvac entity. @@ -40,7 +37,6 @@ start_pause: return_to_base: description: Tell the vacuum cleaner to return to its dock. - fields: entity_id: description: Name of the botvac entity. @@ -48,7 +44,6 @@ return_to_base: clean_spot: description: Tell the vacuum cleaner to do a spot clean-up. - fields: entity_id: description: Name of the botvac entity. @@ -56,35 +51,29 @@ clean_spot: send_command: description: Send a raw command to the vacuum cleaner. - fields: entity_id: description: Name of the botvac entity. example: 'vacuum.xiaomi_vacuum_cleaner' - command: description: Command to execute. example: 'set_dnd_timer' - params: description: Parameters for the command. example: '[22,0,6,0]' set_fan_speed: description: Set the fan speed of the vacuum cleaner. - fields: entity_id: description: Name of the botvac entity. example: 'vacuum.xiaomi_vacuum_cleaner' - fan_speed: description: Platform dependent vacuum cleaner fan speed, with speed steps, like 'medium', or by percentage, between 0 and 100. example: 'low' xiaomi_remote_control_start: description: Start remote control of the vacuum cleaner. You can then move it with `remote_control_move`, when done call `remote_control_stop`. - fields: entity_id: description: Name of the botvac entity. @@ -92,7 +81,6 @@ xiaomi_remote_control_start: xiaomi_remote_control_stop: description: Stop remote control mode of the vacuum cleaner. - fields: entity_id: description: Name of the botvac entity. @@ -100,40 +88,32 @@ xiaomi_remote_control_stop: xiaomi_remote_control_move: description: Remote control the vacuum cleaner, make sure you first set it in remote control mode with `remote_control_start`. - fields: entity_id: description: Name of the botvac entity. example: 'vacuum.xiaomi_vacuum_cleaner' - velocity: description: Speed, between -0.29 and 0.29. example: '0.2' - rotation: description: Rotation, between -179 degrees and 179 degrees. example: '90' - duration: - description: Duration of the movement? + description: Duration of the movement. example: '1500' xiaomi_remote_control_move_step: description: Remote control the vacuum cleaner, only makes one move and then stops. - fields: entity_id: description: Name of the botvac entity. example: 'vacuum.xiaomi_vacuum_cleaner' - velocity: description: Speed, between -0.29 and 0.29. example: '0.2' - rotation: description: Rotation, between -179 degrees and 179 degrees. example: '90' - duration: - description: Duration of the movement? + description: Duration of the movement. example: '1500' diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 37d7be38f9d..ed19e220008 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-mirobo==0.2.0'] +REQUIREMENTS = ['python-miio==0.3.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index a26e1efb553..7418ca812a1 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -19,7 +19,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.37'] +REQUIREMENTS = ['pyvera==0.2.38'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index 2defe73a6bf..426893ec306 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -12,7 +12,6 @@ import os from datetime import timedelta import voluptuous as vol -import requests from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView @@ -27,6 +26,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file +from homeassistant.util.json import load_json, save_json REQUIREMENTS = ['python-wink==1.7.0', 'pubnubsub-handler==1.0.2'] @@ -41,9 +41,7 @@ CONF_CLIENT_SECRET = 'client_secret' CONF_USER_AGENT = 'user_agent' CONF_OAUTH = 'oauth' CONF_LOCAL_CONTROL = 'local_control' -CONF_APPSPOT = 'appspot' CONF_MISSING_OAUTH_MSG = 'Missing oauth2 credentials.' -CONF_TOKEN_URL = "https://winkbearertoken.appspot.com/token" ATTR_ACCESS_TOKEN = 'access_token' ATTR_REFRESH_TOKEN = 'refresh_token' @@ -92,9 +90,9 @@ AUTO_SHUTOFF_TIMES = [None, -1, 30, 60, 120] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Inclusive(CONF_EMAIL, CONF_APPSPOT, + vol.Inclusive(CONF_EMAIL, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG): cv.string, - vol.Inclusive(CONF_PASSWORD, CONF_APPSPOT, + vol.Inclusive(CONF_PASSWORD, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG): cv.string, vol.Inclusive(CONF_CLIENT_ID, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG): cv.string, @@ -157,31 +155,11 @@ WINK_COMPONENTS = [ WINK_HUBS = [] -def _write_config_file(file_path, config): - try: - with open(file_path, 'w') as conf_file: - conf_file.write(json.dumps(config, sort_keys=True, indent=4)) - except IOError as error: - _LOGGER.error("Saving config file failed: %s", error) - raise IOError("Saving Wink config file failed") - return config - - -def _read_config_file(file_path): - try: - with open(file_path, 'r') as conf_file: - return json.loads(conf_file.read()) - except IOError as error: - _LOGGER.error("Reading config file failed: %s", error) - raise IOError("Reading Wink config file failed") - - def _request_app_setup(hass, config): """Assist user with configuring the Wink dev application.""" hass.data[DOMAIN]['configurator'] = True configurator = hass.components.configurator - # pylint: disable=unused-argument def wink_configuration_callback(callback_data): """Handle configuration updates.""" _config_path = hass.config.path(WINK_CONFIG_FILE) @@ -189,12 +167,12 @@ def _request_app_setup(hass, config): setup(hass, config) return - client_id = callback_data.get('client_id') - client_secret = callback_data.get('client_secret') + client_id = callback_data.get('client_id').strip() + client_secret = callback_data.get('client_secret').strip() if None not in (client_id, client_secret): - _write_config_file(_config_path, - {ATTR_CLIENT_ID: client_id, - ATTR_CLIENT_SECRET: client_secret}) + save_json(_config_path, + {ATTR_CLIENT_ID: client_id, + ATTR_CLIENT_SECRET: client_secret}) setup(hass, config) return else: @@ -267,19 +245,6 @@ def setup(hass, config): 'configurator': False } - def _get_wink_token_from_web(): - _email = hass.data[DOMAIN]["oauth"]["email"] - _password = hass.data[DOMAIN]["oauth"]["password"] - - payload = {'username': _email, 'password': _password} - token_response = requests.post(CONF_TOKEN_URL, data=payload) - try: - token = token_response.text.split(':')[1].split()[0].rstrip(' right = 1 --> 8' @@ -14,7 +15,6 @@ pair_new_device: rename_wink_device: description: Rename the provided device. - fields: entity_id: description: The entity_id of the device to rename. @@ -25,7 +25,6 @@ rename_wink_device: delete_wink_device: description: Remove/unpair device from Wink. - fields: entity_id: description: The entity_id of the device to delete. @@ -38,21 +37,19 @@ refresh_state_from_wink: set_siren_volume: description: Set the volume of the siren for a Dome siren/chime. - fields: entity_id: - description: Name(s) of the entities to set + description: Name(s) of the entities to set. example: 'switch.dome_siren' volume: - description: Volume level. One of ["low", "medium", "high"] + description: Volume level. One of ["low", "medium", "high"]. example: "high" enable_chime: description: Enable the chime of a Dome siren with the provided sound. - fields: entity_id: - description: Name(s) of the entities to set + description: Name(s) of the entities to set. example: 'switch.dome_siren' tone: description: The tone to use for the chime. One of ["doorbell", "fur_elise", "doorbell_extended", "alert", "william_tell", "rondo_alla_turca", "police_siren", "evacuation", "beep_beep", "beep", "inactive"] @@ -60,10 +57,9 @@ enable_chime: set_siren_tone: description: Set the sound to use when the siren is enabled. (This doesn't enable the siren) - fields: entity_id: - description: Name(s) of the entities to set + description: Name(s) of the entities to set. example: 'switch.dome_siren' tone: description: The tone to use for the chime. One of ["doorbell", "fur_elise", "doorbell_extended", "alert", "william_tell", "rondo_alla_turca", "police_siren", "evacuation", "beep_beep", "beep", "inactive"] @@ -71,10 +67,9 @@ set_siren_tone: siren_set_auto_shutoff: description: How long to sound the siren before turning off. - fields: entity_id: - description: Name(s) of the entities to set + description: Name(s) of the entities to set. example: 'switch.dome_siren' auto_shutoff: description: The time in seconds to sound the siren. One of [None, -1, 30, 60, 120] (None and -1 are forever. Use None for gocontrol, and -1 for Dome) @@ -82,27 +77,24 @@ siren_set_auto_shutoff: set_siren_strobe_enabled: description: Enable or disable the strobe light when the siren is sounding. - fields: entity_id: - description: Name(s) of the entities to set + description: Name(s) of the entities to set. example: 'switch.dome_siren' enabled: description: "True or False" set_chime_strobe_enabled: description: Enable or disable the strobe light when the chime is sounding. - fields: entity_id: - description: Name(s) of the entities to set + description: Name(s) of the entities to set. example: 'switch.dome_siren' enabled: description: "True or False" enable_siren: description: Enable/disable the siren. - fields: entity_id: description: Name(s) of the entities to set @@ -112,10 +104,9 @@ enable_siren: set_chime_volume: description: Set the volume of the chime for a Dome siren/chime. - fields: entity_id: - description: Name(s) of the entities to set + description: Name(s) of the entities to set. example: 'switch.dome_siren' volume: description: Volume level. One of ["low", "medium", "high"] diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 700018ac29c..f875edef310 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -1,4 +1,5 @@ """Support for Xiaomi Gateways.""" +import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -8,61 +9,75 @@ from homeassistant.components.discovery import SERVICE_XIAOMI_GW from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, CONF_MAC, CONF_HOST, CONF_PORT) -REQUIREMENTS = ['PyXiaomiGateway==0.5.2'] +REQUIREMENTS = ['PyXiaomiGateway==0.6.0'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' ATTR_RINGTONE_VOL = 'ringtone_vol' +ATTR_DEVICE_ID = 'device_id' CONF_DISCOVERY_RETRY = 'discovery_retry' CONF_GATEWAYS = 'gateways' CONF_INTERFACE = 'interface' +CONF_KEY = 'key' DOMAIN = 'xiaomi_aqara' PY_XIAOMI_GATEWAY = "xiaomi_gw" +SERVICE_PLAY_RINGTONE = 'play_ringtone' +SERVICE_STOP_RINGTONE = 'stop_ringtone' +SERVICE_ADD_DEVICE = 'add_device' +SERVICE_REMOVE_DEVICE = 'remove_device' -def _validate_conf(config): - """Validate a list of devices definitions.""" - res_config = [] - for gw_conf in config: - for _conf in gw_conf.keys(): - if _conf not in [CONF_MAC, CONF_HOST, CONF_PORT, 'key']: - raise vol.Invalid('{} is not a valid config parameter'. - format(_conf)) - res_gw_conf = {'sid': gw_conf.get(CONF_MAC)} - if res_gw_conf['sid'] is not None: - res_gw_conf['sid'] = res_gw_conf['sid'].replace(":", "").lower() - if len(res_gw_conf['sid']) != 12: - raise vol.Invalid('Invalid mac address', gw_conf.get(CONF_MAC)) - key = gw_conf.get('key') +GW_MAC = vol.All( + cv.string, + lambda value: value.replace(':', '').lower(), + vol.Length(min=12, max=12) +) - if key is None: - _LOGGER.warning( - 'Gateway Key is not provided.' - ' Controlling gateway device will not be possible.') - elif len(key) != 16: - raise vol.Invalid('Invalid key {}.' - ' Key must be 16 characters'.format(key)) - res_gw_conf['key'] = key - host = gw_conf.get(CONF_HOST) - if host is not None: - res_gw_conf[CONF_HOST] = host - res_gw_conf['port'] = gw_conf.get(CONF_PORT, 9898) +SERVICE_SCHEMA_PLAY_RINGTONE = vol.Schema({ + vol.Required(ATTR_RINGTONE_ID): + vol.All(vol.Coerce(int), vol.NotIn([9, 14, 15, 16, 17, 18, 19])), + vol.Optional(ATTR_RINGTONE_VOL): + vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100)) +}) - _LOGGER.warning( - 'Static address (%s:%s) of the gateway provided. ' - 'Discovery of this host will be skipped.', - res_gw_conf[CONF_HOST], res_gw_conf[CONF_PORT]) +SERVICE_SCHEMA_REMOVE_DEVICE = vol.Schema({ + vol.Required(ATTR_DEVICE_ID): + vol.All(cv.string, vol.Length(min=14, max=14)) +}) - res_config.append(res_gw_conf) - return res_config +GATEWAY_CONFIG = vol.Schema({ + vol.Optional(CONF_MAC, default=None): vol.Any(GW_MAC, None), + vol.Optional(CONF_KEY, default=None): + vol.All(cv.string, vol.Length(min=16, max=16)), + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=9898): cv.port, +}) + + +def _fix_conf_defaults(config): + """Update some config defaults.""" + config['sid'] = config.pop(CONF_MAC, None) + + if config.get(CONF_KEY) is None: + _LOGGER.warning( + 'Key is not provided for gateway %s. Controlling the gateway ' + 'will not be possible.', config['sid']) + + if config.get(CONF_HOST) is None: + config.pop(CONF_PORT) + + return config + + +DEFAULT_GATEWAY_CONFIG = [{CONF_MAC: None, CONF_KEY: None}] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_GATEWAYS, default=[{CONF_MAC: None, "key": None}]): - vol.All(cv.ensure_list, _validate_conf), + vol.Optional(CONF_GATEWAYS, default=DEFAULT_GATEWAY_CONFIG): + vol.All(cv.ensure_list, [GATEWAY_CONFIG], [_fix_conf_defaults]), vol.Optional(CONF_INTERFACE, default='any'): cv.string, vol.Optional(CONF_DISCOVERY_RETRY, default=3): cv.positive_int }) @@ -81,30 +96,30 @@ def setup(hass, config): interface = config[DOMAIN][CONF_INTERFACE] discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY] + @asyncio.coroutine def xiaomi_gw_discovered(service, discovery_info): """Called when Xiaomi Gateway device(s) has been found.""" # We don't need to do anything here, the purpose of HA's # discovery service is to just trigger loading of this # component, and then its own discovery process kicks in. - _LOGGER.info("Discovered: %s", discovery_info) discovery.listen(hass, SERVICE_XIAOMI_GW, xiaomi_gw_discovered) from PyXiaomiGateway import PyXiaomiGateway - hass.data[PY_XIAOMI_GATEWAY] = PyXiaomiGateway(hass.add_job, gateways, - interface) + xiaomi = hass.data[PY_XIAOMI_GATEWAY] = PyXiaomiGateway( + hass.add_job, gateways, interface) _LOGGER.debug("Expecting %s gateways", len(gateways)) for k in range(discovery_retry): _LOGGER.info('Discovering Xiaomi Gateways (Try %s)', k + 1) - hass.data[PY_XIAOMI_GATEWAY].discover_gateways() - if len(hass.data[PY_XIAOMI_GATEWAY].gateways) >= len(gateways): + xiaomi.discover_gateways() + if len(xiaomi.gateways) >= len(gateways): break - if not hass.data[PY_XIAOMI_GATEWAY].gateways: + if not xiaomi.gateways: _LOGGER.error("No gateway discovered") return False - hass.data[PY_XIAOMI_GATEWAY].listen() + xiaomi.listen() _LOGGER.debug("Gateways discovered. Listening for broadcasts") for component in ['binary_sensor', 'sensor', 'switch', 'light', 'cover']: @@ -113,61 +128,61 @@ def setup(hass, config): def stop_xiaomi(event): """Stop Xiaomi Socket.""" _LOGGER.info("Shutting down Xiaomi Hub.") - hass.data[PY_XIAOMI_GATEWAY].stop_listen() + xiaomi.stop_listen() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_xiaomi) def play_ringtone_service(call): """Service to play ringtone through Gateway.""" ring_id = call.data.get(ATTR_RINGTONE_ID) - gw_sid = call.data.get(ATTR_GW_MAC) - if ring_id is None or gw_sid is None: - _LOGGER.error("Mandatory parameters is not specified.") - return + gateway = call.data.get(ATTR_GW_MAC) - ring_id = int(ring_id) - if ring_id in [9, 14-19]: - _LOGGER.error('Specified mid: %s is not defined in gateway.', - ring_id) - return + kwargs = {'mid': ring_id} ring_vol = call.data.get(ATTR_RINGTONE_VOL) - if ring_vol is None: - ringtone = {'mid': ring_id} - else: - ringtone = {'mid': ring_id, 'vol': int(ring_vol)} + if ring_vol is not None: + kwargs['vol'] = ring_vol - gw_sid = gw_sid.replace(":", "").lower() - - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - if gateway.sid == gw_sid: - gateway.write_to_hub(gateway.sid, **ringtone) - break - else: - _LOGGER.error('Unknown gateway sid: %s was specified.', gw_sid) + gateway.write_to_hub(gateway.sid, **kwargs) def stop_ringtone_service(call): """Service to stop playing ringtone on Gateway.""" - gw_sid = call.data.get(ATTR_GW_MAC) - if gw_sid is None: - _LOGGER.error("Mandatory parameter (%s) is not specified.", - ATTR_GW_MAC) - return + gateway = call.data.get(ATTR_GW_MAC) + gateway.write_to_hub(gateway.sid, mid=10000) - gw_sid = gw_sid.replace(":", "").lower() - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - if gateway.sid == gw_sid: - ringtone = {'mid': 10000} - gateway.write_to_hub(gateway.sid, **ringtone) - break - else: - _LOGGER.error('Unknown gateway sid: %s was specified.', gw_sid) + def add_device_service(call): + """Service to add a new sub-device within the next 30 seconds.""" + gateway = call.data.get(ATTR_GW_MAC) + gateway.write_to_hub(gateway.sid, join_permission='yes') + hass.components.persistent_notification.async_create( + 'Join permission enabled for 30 seconds! ' + 'Please press the pairing button of the new device once.', + title='Xiaomi Aqara Gateway') + + def remove_device_service(call): + """Service to remove a sub-device from the gateway.""" + device_id = call.data.get(ATTR_DEVICE_ID) + gateway = call.data.get(ATTR_GW_MAC) + gateway.write_to_hub(gateway.sid, remove_device=device_id) + + gateway_only_schema = _add_gateway_to_schema(xiaomi, vol.Schema({})) + + hass.services.async_register( + DOMAIN, SERVICE_PLAY_RINGTONE, play_ringtone_service, + schema=_add_gateway_to_schema(xiaomi, SERVICE_SCHEMA_PLAY_RINGTONE)) + + hass.services.async_register( + DOMAIN, SERVICE_STOP_RINGTONE, stop_ringtone_service, + schema=gateway_only_schema) + + hass.services.async_register( + DOMAIN, SERVICE_ADD_DEVICE, add_device_service, + schema=gateway_only_schema) + + hass.services.async_register( + DOMAIN, SERVICE_REMOVE_DEVICE, remove_device_service, + schema=_add_gateway_to_schema(xiaomi, SERVICE_SCHEMA_REMOVE_DEVICE)) - hass.services.async_register(DOMAIN, 'play_ringtone', - play_ringtone_service, - description=None, schema=None) - hass.services.async_register(DOMAIN, 'stop_ringtone', - stop_ringtone_service, - description=None, schema=None) return True @@ -223,3 +238,27 @@ class XiaomiDevice(Entity): def parse_data(self, data): """Parse data sent by gateway.""" raise NotImplementedError() + + +def _add_gateway_to_schema(xiaomi, schema): + """Extend a voluptuous schema with a gateway validator.""" + def gateway(sid): + """Convert sid to a gateway.""" + sid = str(sid).replace(':', '').lower() + + for gateway in xiaomi.gateways.values(): + if gateway.sid == sid: + return gateway + + raise vol.Invalid('Unknown gateway sid {}'.format(sid)) + + gateways = list(xiaomi.gateways.values()) + kwargs = {} + + # If the user has only 1 gateway, make it the default for services. + if len(gateways) == 1: + kwargs['default'] = gateways[0] + + return schema.extend({ + vol.Required(ATTR_GW_MAC, **kwargs): gateway + }) diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index 712abfb1b6e..9ba503e6666 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -9,15 +9,15 @@ import logging import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, CONF_LATITUDE, - CONF_LONGITUDE, CONF_ICON) + CONF_LONGITUDE, CONF_ICON, CONF_RADIUS) from homeassistant.loader import bind_hass from homeassistant.helpers import config_per_platform from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.util.async import run_callback_threadsafe from homeassistant.util.location import distance -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -25,7 +25,6 @@ ATTR_PASSIVE = 'passive' ATTR_RADIUS = 'radius' CONF_PASSIVE = 'passive' -CONF_RADIUS = 'radius' DEFAULT_NAME = 'Unnamed zone' DEFAULT_PASSIVE = False diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index 911a583afc0..06e317333be 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -1,3 +1,5 @@ +# Describes the format for available Z-Wave services + change_association: description: Change an association in the Z-Wave network. fields: diff --git a/homeassistant/config.py b/homeassistant/config.py index 89289378c76..c4c96804fca 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -677,9 +677,18 @@ def async_notify_setup_error(hass, component, link=False): errors = hass.data[DATA_PERSISTENT_ERRORS] = {} errors[component] = errors.get(component) or link - _lst = [HA_COMPONENT_URL.format(name.replace('_', '-'), name) - if link else name for name, link in errors.items()] - message = ('The following components and platforms could not be set up:\n' - '* ' + '\n* '.join(list(_lst)) + '\nPlease check your config') + + message = 'The following components and platforms could not be set up:\n\n' + + for name, link in errors.items(): + if link: + part = HA_COMPONENT_URL.format(name.replace('_', '-'), name) + else: + part = name + + message += ' - {}\n'.format(part) + + message += '\nPlease check your config.' + persistent_notification.async_create( hass, message, 'Invalid config', 'invalid_config') diff --git a/homeassistant/const.py b/homeassistant/const.py index 3a2ee824dda..1c84c9d57f1 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 = 56 -PATCH_VERSION = '2' +MINOR_VERSION = 57 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) @@ -88,6 +88,8 @@ CONF_DEVICE_CLASS = 'device_class' CONF_DEVICES = 'devices' CONF_DISARM_AFTER_TRIGGER = 'disarm_after_trigger' CONF_DISCOVERY = 'discovery' +CONF_DISKS = 'disks' +CONF_DISPLAY_CURRENCY = 'display_currency' CONF_DISPLAY_OPTIONS = 'display_options' CONF_DOMAIN = 'domain' CONF_DOMAINS = 'domains' @@ -97,6 +99,7 @@ CONF_EMAIL = 'email' CONF_ENTITIES = 'entities' CONF_ENTITY_ID = 'entity_id' CONF_ENTITY_NAMESPACE = 'entity_namespace' +CONF_ENTITY_PICTURE_TEMPLATE = 'entity_picture_template' CONF_EVENT = 'event' CONF_EXCLUDE = 'exclude' CONF_FILE_PATH = 'file_path' @@ -116,8 +119,9 @@ CONF_LONGITUDE = 'longitude' CONF_LIGHTS = 'lights' CONF_MAC = 'mac' CONF_METHOD = 'method' -CONF_MINIMUM = 'minimum' CONF_MAXIMUM = 'maximum' +CONF_MINIMUM = 'minimum' +CONF_MODE = 'mode' CONF_MONITORED_CONDITIONS = 'monitored_conditions' CONF_MONITORED_VARIABLES = 'monitored_variables' CONF_NAME = 'name' @@ -134,13 +138,17 @@ CONF_PIN = 'pin' CONF_PLATFORM = 'platform' CONF_PORT = 'port' CONF_PREFIX = 'prefix' +CONF_PROFILE_NAME = 'profile_name' CONF_PROTOCOL = 'protocol' CONF_PROXY_SSL = 'proxy_ssl' CONF_QUOTE = 'quote' +CONF_RADIUS = 'radius' CONF_RECIPIENT = 'recipient' +CONF_REGION = 'region' CONF_RESOURCE = 'resource' CONF_RESOURCES = 'resources' CONF_RGB = 'rgb' +CONF_ROOM = 'room' CONF_SCAN_INTERVAL = 'scan_interval' CONF_SENDER = 'sender' CONF_SENSOR_TYPE = 'sensor_type' @@ -217,6 +225,9 @@ STATE_PROBLEM = 'problem' # Attribution ATTR_ATTRIBUTION = 'attribution' +# Credentials +ATTR_CREDENTIALS = 'credentials' + # Contains time-related attributes ATTR_NOW = 'now' ATTR_DATE = 'date' @@ -227,6 +238,9 @@ ATTR_DOMAIN = 'domain' ATTR_SERVICE = 'service' ATTR_SERVICE_DATA = 'service_data' +# IDs +ATTR_ID = 'id' + # Data for a SERVICE_EXECUTED event ATTR_SERVICE_CALL_ID = 'service_call_id' @@ -251,38 +265,6 @@ CONF_UNIT_SYSTEM_IMPERIAL = 'imperial' # type: str # Electrical attributes ATTR_VOLTAGE = 'voltage' -# Temperature attribute -ATTR_TEMPERATURE = 'temperature' -TEMP_CELSIUS = '°C' -TEMP_FAHRENHEIT = '°F' - -# Length units -LENGTH_CENTIMETERS = 'cm' # type: str -LENGTH_METERS = 'm' # type: str -LENGTH_KILOMETERS = 'km' # type: str - -LENGTH_INCHES = 'in' # type: str -LENGTH_FEET = 'ft' # type: str -LENGTH_YARD = 'yd' # type: str -LENGTH_MILES = 'mi' # type: str - -# Volume units -VOLUME_LITERS = 'L' # type: str -VOLUME_MILLILITERS = 'mL' # type: str - -VOLUME_GALLONS = 'gal' # type: str -VOLUME_FLUID_OUNCE = 'fl. oz.' # type: str - -# Mass units -MASS_GRAMS = 'g' # type: str -MASS_KILOGRAMS = 'kg' # type: str - -MASS_OUNCES = 'oz' # type: str -MASS_POUNDS = 'lb' # type: str - -# UV Index units -UNIT_UV_INDEX = 'UV index' # type: str - # Contains the information that is discovered ATTR_DISCOVERED = 'discovered' @@ -334,6 +316,41 @@ ATTR_SUPPORTED_FEATURES = 'supported_features' # Class of device within its domain ATTR_DEVICE_CLASS = 'device_class' +# Temperature attribute +ATTR_TEMPERATURE = 'temperature' + +# #### UNITS OF MEASUREMENT #### +# Temperature units +TEMP_CELSIUS = '°C' +TEMP_FAHRENHEIT = '°F' + +# Length units +LENGTH_CENTIMETERS = 'cm' # type: str +LENGTH_METERS = 'm' # type: str +LENGTH_KILOMETERS = 'km' # type: str + +LENGTH_INCHES = 'in' # type: str +LENGTH_FEET = 'ft' # type: str +LENGTH_YARD = 'yd' # type: str +LENGTH_MILES = 'mi' # type: str + +# Volume units +VOLUME_LITERS = 'L' # type: str +VOLUME_MILLILITERS = 'mL' # type: str + +VOLUME_GALLONS = 'gal' # type: str +VOLUME_FLUID_OUNCE = 'fl. oz.' # type: str + +# Mass units +MASS_GRAMS = 'g' # type: str +MASS_KILOGRAMS = 'kg' # type: str + +MASS_OUNCES = 'oz' # type: str +MASS_POUNDS = 'lb' # type: str + +# UV Index units +UNIT_UV_INDEX = 'UV index' # type: str + # #### SERVICES #### SERVICE_HOMEASSISTANT_STOP = 'stop' SERVICE_HOMEASSISTANT_RESTART = 'restart' @@ -413,6 +430,8 @@ HTTP_DIGEST_AUTHENTICATION = 'digest' HTTP_HEADER_HA_AUTH = 'X-HA-access' HTTP_HEADER_ACCEPT_ENCODING = 'Accept-Encoding' +HTTP_HEADER_AUTH = 'Authorization' +HTTP_HEADER_USER_AGENT = 'User-Agent' HTTP_HEADER_CONTENT_TYPE = 'Content-type' HTTP_HEADER_CONTENT_ENCODING = 'Content-Encoding' HTTP_HEADER_VARY = 'Vary' diff --git a/homeassistant/core.py b/homeassistant/core.py index e7f4f8758f8..31bb281aeaa 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -32,7 +32,7 @@ from homeassistant.const import ( EVENT_SERVICE_REMOVED, __version__) from homeassistant import loader from homeassistant.exceptions import ( - HomeAssistantError, InvalidEntityFormatError) + HomeAssistantError, InvalidEntityFormatError, InvalidStateError) from homeassistant.util.async import ( run_coroutine_threadsafe, run_callback_threadsafe, fire_coroutine_threadsafe) @@ -65,6 +65,11 @@ def valid_entity_id(entity_id: str) -> bool: return ENTITY_ID_PATTERN.match(entity_id) is not None +def valid_state(state: str) -> bool: + """Test if an state is valid.""" + return len(state) < 256 + + def callback(func: Callable[..., None]) -> Callable[..., None]: """Annotation to mark method as safe to call from within the event loop.""" # pylint: disable=protected-access @@ -520,13 +525,20 @@ class State(object): def __init__(self, entity_id, state, attributes=None, last_changed=None, last_updated=None): """Initialize a new state.""" + state = str(state) + if not valid_entity_id(entity_id): raise InvalidEntityFormatError(( "Invalid entity id encountered: {}. " "Format should be .").format(entity_id)) + if not valid_state(state): + raise InvalidStateError(( + "Invalid state encountered for entity id: {}. " + "State max length is 255 characters.").format(entity_id)) + self.entity_id = entity_id.lower() - self.state = str(state) + self.state = state 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/exceptions.py b/homeassistant/exceptions.py index 2889d83af5c..cb8a3c87820 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -32,3 +32,9 @@ class PlatformNotReady(HomeAssistantError): """Error to indicate that platform is not ready.""" pass + + +class InvalidStateError(HomeAssistantError): + """When an invalid state is encountered.""" + + pass diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 4c48e685b23..e5512b9140e 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -12,7 +12,8 @@ import voluptuous as vol from homeassistant.loader import get_platform from homeassistant.const import ( - CONF_PLATFORM, CONF_SCAN_INTERVAL, TEMP_CELSIUS, TEMP_FAHRENHEIT, + CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, CONF_PLATFORM, + CONF_SCAN_INTERVAL, TEMP_CELSIUS, TEMP_FAHRENHEIT, 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) @@ -107,6 +108,19 @@ def isfile(value: Any) -> str: return file_in +def isdir(value: Any) -> str: + """Validate that the value is an existing dir.""" + if value is None: + raise vol.Invalid('not a directory') + dir_in = os.path.expanduser(str(value)) + + if not os.path.isdir(dir_in): + raise vol.Invalid('not a directory') + if not os.access(dir_in, os.R_OK): + raise vol.Invalid('directory not readable') + return dir_in + + def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]: """Wrap value in list if it is not one.""" if value is None: @@ -549,3 +563,16 @@ SCRIPT_SCHEMA = vol.All( [vol.Any(SERVICE_SCHEMA, _SCRIPT_DELAY_SCHEMA, _SCRIPT_WAIT_TEMPLATE_SCHEMA, EVENT_SCHEMA, CONDITION_SCHEMA)], ) + +FILTER_SCHEMA = vol.Schema({ + vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(ensure_list, [string]) + }), + vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(ensure_list, [string]) + }) +}) diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py new file mode 100644 index 00000000000..d8d3f1c9325 --- /dev/null +++ b/homeassistant/helpers/entityfilter.py @@ -0,0 +1,78 @@ +"""Helper class to implement include/exclude of entities and domains.""" + +from homeassistant.core import split_entity_id + + +def generate_filter(include_domains, include_entities, + exclude_domains, exclude_entities): + """Return a function that will filter entities based on the args.""" + include_d = set(include_domains) + include_e = set(include_entities) + exclude_d = set(exclude_domains) + exclude_e = set(exclude_entities) + + have_exclude = bool(exclude_e or exclude_d) + have_include = bool(include_e or include_d) + + # Case 1 - no includes or excludes - pass all entities + if not have_include and not have_exclude: + return lambda entity_id: True + + # Case 2 - includes, no excludes - only include specified entities + if have_include and not have_exclude: + def entity_filter_2(entity_id): + """Return filter function for case 2.""" + domain = split_entity_id(entity_id)[0] + return (entity_id in include_e or + domain in include_d) + + return entity_filter_2 + + # Case 3 - excludes, no includes - only exclude specified entities + if not have_include and have_exclude: + def entity_filter_3(entity_id): + """Return filter function for case 3.""" + domain = split_entity_id(entity_id)[0] + return (entity_id not in exclude_e and + domain not in exclude_d) + + return entity_filter_3 + + # Case 4 - both includes and excludes specified + # Case 4a - include domain specified + # - if domain is included, and entity not excluded, pass + # - if domain is not included, and entity not included, fail + # note: if both include and exclude domains specified, + # the exclude domains are ignored + if include_d: + def entity_filter_4a(entity_id): + """Return filter function for case 4a.""" + domain = split_entity_id(entity_id)[0] + if domain in include_d: + return entity_id not in exclude_e + else: + return entity_id in include_e + + return entity_filter_4a + + # Case 4b - exclude domain specified + # - if domain is excluded, and entity not included, fail + # - if domain is not excluded, and entity not excluded, pass + if exclude_d: + def entity_filter_4b(entity_id): + """Return filter function for case 4b.""" + domain = split_entity_id(entity_id)[0] + if domain in exclude_d: + return entity_id in include_e + else: + return entity_id not in exclude_e + + return entity_filter_4b + + # Case 4c - neither include or exclude domain specified + # - Only pass if entity is included. Ignore entity excludes. + def entity_filter_4c(entity_id): + """Return filter function for case 4c.""" + return entity_id in include_e + + return entity_filter_4c diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 783aca0ceac..7da87160684 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,4 @@ -requests==2.14.2 +requests==2.18.4 pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 diff --git a/homeassistant/scripts/credstash.py b/homeassistant/scripts/credstash.py index 4c9273b8299..12516e55c7d 100644 --- a/homeassistant/scripts/credstash.py +++ b/homeassistant/scripts/credstash.py @@ -4,7 +4,7 @@ import getpass from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['credstash==1.13.3', 'botocore==1.4.93'] +REQUIREMENTS = ['credstash==1.14.0', 'botocore==1.7.34'] def run(args): diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 9616774c623..794f6546113 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -257,6 +257,48 @@ def color_xy_brightness_to_RGB(vX: float, vY: float, return (ir, ig, ib) +# pylint: disable=invalid-sequence-index +def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> Tuple[int, int, int]: + """Convert a hsb into its rgb representation.""" + if fS == 0: + fV = fB * 255 + return (fV, fV, fV) + + r = g = b = 0 + h = fH / 60 + f = h - float(math.floor(h)) + p = fB * (1 - fS) + q = fB * (1 - fS * f) + t = fB * (1 - (fS * (1 - f))) + + if int(h) == 0: + r = int(fB * 255) + g = int(t * 255) + b = int(p * 255) + elif int(h) == 1: + r = int(q * 255) + g = int(fB * 255) + b = int(p * 255) + elif int(h) == 2: + r = int(p * 255) + g = int(fB * 255) + b = int(t * 255) + elif int(h) == 3: + r = int(p * 255) + g = int(q * 255) + b = int(fB * 255) + elif int(h) == 4: + r = int(t * 255) + g = int(p * 255) + b = int(fB * 255) + elif int(h) == 5: + r = int(fB * 255) + g = int(p * 255) + b = int(q * 255) + + return (r, g, b) + + # pylint: disable=invalid-sequence-index def color_RGB_to_hsv(iR: int, iG: int, iB: int) -> Tuple[int, int, int]: """Convert an rgb color to its hsv representation.""" @@ -392,9 +434,9 @@ def _get_blue(temperature: float) -> float: def color_temperature_mired_to_kelvin(mired_temperature): """Convert absolute mired shift to degrees kelvin.""" - return 1000000 / mired_temperature + return math.floor(1000000 / mired_temperature) def color_temperature_kelvin_to_mired(kelvin_temperature): """Convert degrees kelvin to mired shift.""" - return 1000000 / kelvin_temperature + return math.floor(1000000 / kelvin_temperature) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py new file mode 100644 index 00000000000..810463260fd --- /dev/null +++ b/homeassistant/util/json.py @@ -0,0 +1,50 @@ +"""JSON utility functions.""" +import logging +from typing import Union, List, Dict + +import json + +from homeassistant.exceptions import HomeAssistantError + +_LOGGER = logging.getLogger(__name__) + + +def load_json(filename: str) -> Union[List, Dict]: + """Load JSON data from a file and return as dict or list. + + Defaults to returning empty dict if file is not found. + """ + try: + with open(filename, encoding='utf-8') as fdesc: + return json.loads(fdesc.read()) + except FileNotFoundError: + # This is not a fatal error + _LOGGER.debug('JSON file not found: %s', filename) + except ValueError as error: + _LOGGER.exception('Could not parse JSON content: %s', filename) + raise HomeAssistantError(error) + except OSError as error: + _LOGGER.exception('JSON file reading failed: %s', filename) + raise HomeAssistantError(error) + return {} # (also evaluates to False) + + +def save_json(filename: str, config: Union[List, Dict]): + """Save JSON data to a file. + + Returns True on success. + """ + try: + data = json.dumps(config, sort_keys=True, indent=4) + with open(filename, 'w', encoding='utf-8') as fdesc: + fdesc.write(data) + return True + except TypeError as error: + _LOGGER.exception('Failed to serialize to JSON: %s', + filename) + raise HomeAssistantError(error) + except OSError as error: + _LOGGER.exception('Saving JSON file failed: %s', + filename) + raise HomeAssistantError(error) + return False diff --git a/requirements_all.txt b/requirements_all.txt index 985d9d539cd..b5e8039240e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,5 +1,5 @@ # Home Assistant core -requests==2.14.2 +requests==2.18.4 pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 @@ -19,7 +19,7 @@ certifi>=2017.4.17 # Adafruit_BBIO==1.0.0 # homeassistant.components.tradfri -# DTLSSocket==0.1.3 +# DTLSSocket==0.1.4 # homeassistant.components.doorbird DoorBirdPy==0.0.4 @@ -37,11 +37,14 @@ PyMVGLive==1.1.4 PyMata==2.14 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.5.2 +PyXiaomiGateway==0.6.0 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 +# homeassistant.components.remember_the_milk +RtmAPI==0.7.0 + # homeassistant.components.media_player.sonos SoCo==0.12 @@ -51,11 +54,14 @@ TravisPy==0.3.5 # homeassistant.components.notify.twitter TwitterAPI==2.4.6 +# homeassistant.components.notify.yessssms +YesssSMS==0.1.1b3 + # homeassistant.components.abode abodepy==0.12.1 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.3 +aioautomatic==0.6.4 # homeassistant.components.sensor.dnsip aiodns==1.1.1 @@ -74,7 +80,7 @@ aiolifx==0.6.0 aiolifx_effects==0.1.2 # homeassistant.components.scene.hunterdouglas_powerview -aiopvapi==1.4 +aiopvapi==1.5.4 # homeassistant.components.alarmdecoder alarmdecoder==0.12.3 @@ -98,7 +104,7 @@ asterisk_mbox==0.4.0 # avion==0.7 # homeassistant.components.axis -axis==12 +axis==14 # homeassistant.components.sensor.modem_callerid basicmodem==0.7 @@ -112,6 +118,7 @@ batinfo==0.4.2 # homeassistant.components.device_tracker.linksys_ap # homeassistant.components.sensor.geizhals # homeassistant.components.sensor.scrape +# homeassistant.components.sensor.sytadin beautifulsoup4==4.6.0 # homeassistant.components.zha @@ -136,10 +143,10 @@ blockchain==1.4.0 # homeassistant.components.notify.aws_sns # homeassistant.components.notify.aws_sqs # homeassistant.components.tts.amazon_polly -boto3==1.4.3 +boto3==1.4.7 # homeassistant.scripts.credstash -botocore==1.4.93 +botocore==1.7.34 # homeassistant.components.sensor.broadlink # homeassistant.components.switch.broadlink @@ -163,7 +170,7 @@ colorlog==3.0.1 concord232==0.14 # homeassistant.scripts.credstash -# credstash==1.13.3 +# credstash==1.14.0 # homeassistant.components.sensor.crimereports crimereports==1.0.0 @@ -184,6 +191,10 @@ datapoint==0.4.3 # homeassistant.components.device_tracker.upc_connect defusedxml==0.5.0 +# homeassistant.components.sensor.deluge +# homeassistant.components.switch.deluge +deluge-client==1.0.5 + # homeassistant.components.media_player.denonavr denonavr==0.5.4 @@ -277,7 +288,7 @@ gTTS-token==1.1.1 # gattlib==0.20150805 # homeassistant.components.sensor.gitter -gitterpy==0.1.5 +gitterpy==0.1.6 # homeassistant.components.notify.gntp gntp==1.0.3 @@ -318,9 +329,15 @@ hipnotify==1.0.8 # homeassistant.components.binary_sensor.workday holidays==0.8.1 +# homeassistant.components.frontend +home-assistant-frontend==20171103.0 + # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a +# homeassistant.components.remember_the_milk +httplib2==0.10.3 + # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 @@ -387,7 +404,7 @@ keyring>=9.3,<10.0 # homeassistant.components.device_tracker.owntracks # homeassistant.components.device_tracker.owntracks_http -libnacl==1.6.0 +libnacl==1.6.1 # homeassistant.components.dyson libpurecoollink==0.4.2 @@ -410,8 +427,11 @@ lightify==1.0.6 # homeassistant.components.light.limitlessled limitlessled==1.0.8 +# homeassistant.components.linode +linode-api==4.1.4b2 + # homeassistant.components.media_player.liveboxplaytv -liveboxplaytv==1.5.0 +liveboxplaytv==2.0.0 # homeassistant.components.lametric # homeassistant.components.notify.lametric @@ -456,11 +476,15 @@ myusps==1.2.2 nad_receiver==0.0.6 # homeassistant.components.discovery -netdisco==1.2.2 +netdisco==1.2.3 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 +# homeassistant.components.sensor.nederlandse_spoorwegen +nsapi==2.7.4 + +# homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv numpy==1.13.3 @@ -518,7 +542,7 @@ pilight==0.1.1 # homeassistant.components.media_player.plex # homeassistant.components.sensor.plex -plexapi==2.0.2 +plexapi==3.0.3 # homeassistant.components.sensor.mhz19 # homeassistant.components.sensor.serial_pm @@ -605,6 +629,9 @@ pycmus==0.1.0 # homeassistant.components.comfoconnect pycomfoconnect==0.3 +# homeassistant.components.tts.microsoft +pycsspeechtts==1.0.1 + # homeassistant.components.sensor.cups # pycups==1.9.73 @@ -626,6 +653,9 @@ pyemby==1.4 # homeassistant.components.envisalink pyenvisalink==2.2 +# homeassistant.components.climate.ephember +pyephember==0.1.1 + # homeassistant.components.sensor.fido pyfido==1.0.1 @@ -650,6 +680,9 @@ pyhydroquebec==1.2.0 # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 +# homeassistant.components.sensor.irish_rail_transport +pyirishrail==0.0.2 + # homeassistant.components.binary_sensor.iss pyiss==1.0.1 @@ -663,7 +696,7 @@ pykira==0.1.1 pykwb==0.0.8 # homeassistant.components.sensor.lastfm -pylast==1.9.0 +pylast==2.0.0 # homeassistant.components.media_player.webostv # homeassistant.components.notify.webostv @@ -694,7 +727,7 @@ pymodbus==1.3.1 pymonoprice==0.2 # homeassistant.components.media_player.yamaha_musiccast -pymusiccast==0.1.2 +pymusiccast==0.1.3 # homeassistant.components.cover.myq pymyq==0.0.8 @@ -731,6 +764,9 @@ pyowm==2.7.1 # homeassistant.components.qwikswitch pyqwikswitch==0.4 +# homeassistant.components.switch.rainbird +pyrainbird==0.1.0 + # homeassistant.components.climate.sensibo pysensibo==1.0.1 @@ -748,7 +784,8 @@ pysma==0.1.3 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp -pysnmp==4.3.10 +# homeassistant.components.switch.snmp +pysnmp==4.4.1 # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner @@ -775,6 +812,9 @@ python-etherscan-api==0.0.1 # homeassistant.components.sensor.darksky python-forecastio==1.3.5 +# homeassistant.components.gc100 +python-gc100==1.0.1a + # homeassistant.components.sensor.hp_ilo python-hpilo==3.9 @@ -788,10 +828,11 @@ python-juicenet==0.0.5 # homeassistant.components.lirc # python-lirc==1.2.3 +# homeassistant.components.fan.xiaomi_miio # homeassistant.components.light.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-mirobo==0.2.0 +python-miio==0.3.0 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 @@ -837,7 +878,7 @@ python-vlc==1.1.2 python-wink==1.7.0 # homeassistant.components.sensor.swiss_public_transport -python_opendata_transport==0.0.2 +python_opendata_transport==0.0.3 # homeassistant.components.zwave python_openzwave==0.4.0.35 @@ -845,11 +886,14 @@ python_openzwave==0.4.0.35 # homeassistant.components.alarm_control_panel.egardia pythonegardia==1.0.22 +# homeassistant.components.sensor.whois +pythonwhois==2.4.3 + # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 # homeassistant.components.tradfri -pytradfri==3.0 +pytradfri==4.0.1 # homeassistant.components.device_tracker.unifi pyunifi==2.13 @@ -858,7 +902,7 @@ pyunifi==2.13 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.37 +pyvera==0.2.38 # homeassistant.components.media_player.vizio pyvizio==0.0.2 @@ -885,7 +929,7 @@ rachiopy==0.1.2 radiotherm==1.3 # homeassistant.components.raincloud -raincloudy==0.0.3 +raincloudy==0.0.4 # homeassistant.components.raspihats # raspihats==2.2.3 @@ -894,13 +938,13 @@ raincloudy==0.0.3 regenmaschine==0.4.1 # homeassistant.components.python_script -restrictedpython==4.0a3 +restrictedpython==4.0b2 # homeassistant.components.rflink rflink==0.0.34 # homeassistant.components.ring -ring_doorbell==0.1.4 +ring_doorbell==0.1.6 # homeassistant.components.notify.rocketchat rocketchat-API==0.6.1 @@ -912,7 +956,7 @@ roombapy==1.3.1 # rpi-rf==0.9.6 # homeassistant.components.media_player.russound_rnet -russound==0.1.7 +russound==0.1.9 # homeassistant.components.media_player.russound_rio russound_rio==0.1.4 @@ -933,7 +977,7 @@ schiene==0.18 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==5.2.0 +sendgrid==5.3.0 # homeassistant.components.light.sensehat # homeassistant.components.sensor.sensehat @@ -977,7 +1021,7 @@ snapcast==2.0.7 somecomfort==0.4.1 # homeassistant.components.sensor.speedtest -speedtest-cli==1.0.6 +speedtest-cli==1.0.7 # homeassistant.components.recorder # homeassistant.scripts.db_migrator @@ -998,6 +1042,9 @@ tank_utility==1.4.0 # homeassistant.components.binary_sensor.tapsaff tapsaff==0.1.3 +# homeassistant.components.tellstick +tellcore-net==0.1 + # homeassistant.components.tellstick # homeassistant.components.sensor.tellstick tellcore-py==1.1.2 @@ -1009,7 +1056,7 @@ tellduslive==0.3.4 temperusb==1.5.3 # homeassistant.components.tesla -teslajsonpy==0.0.17 +teslajsonpy==0.0.18 # homeassistant.components.thingspeak thingspeak==0.4.1 @@ -1024,7 +1071,7 @@ todoist-python==7.0.17 toonlib==1.0.2 # homeassistant.components.alarm_control_panel.totalconnect -total_connect_client==0.11 +total_connect_client==0.12 # homeassistant.components.sensor.transmission # homeassistant.components.switch.transmission @@ -1100,7 +1147,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.10.12 +youtube_dl==2017.10.29 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_docs.txt b/requirements_docs.txt index 0d1f2a95fa2..68fbec8cf97 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.6.4 +Sphinx==1.6.5 sphinx-autodoc-typehints==1.2.3 sphinx-autodoc-annotation==1.0.post1 diff --git a/requirements_test.txt b/requirements_test.txt index 5d22dbe13ba..1aa909bc9bb 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,7 +3,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.521 +mypy==0.540 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cdd6a55bc0c..62aaf4c3b5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.521 +mypy==0.540 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 @@ -27,7 +27,7 @@ PyJWT==1.5.3 SoCo==0.12 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.3 +aioautomatic==0.6.4 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -36,6 +36,9 @@ aiohttp_cors==0.5.3 # homeassistant.components.notify.apns apns2==0.1.1 +# homeassistant.components.sensor.coinmarketcap +coinmarketcap==4.1.1 + # homeassistant.components.device_tracker.upc_connect defusedxml==0.5.0 @@ -70,6 +73,9 @@ hbmqtt==0.8 # homeassistant.components.binary_sensor.workday holidays==0.8.1 +# homeassistant.components.frontend +home-assistant-frontend==20171103.0 + # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb influxdb==4.1.1 @@ -84,6 +90,10 @@ libsoundtouch==0.7.2 # homeassistant.components.switch.mfi mficlient==0.3.0 +# homeassistant.components.binary_sensor.trend +# homeassistant.components.image_processing.opencv +numpy==1.13.3 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.3.1 @@ -117,6 +127,9 @@ pynx584==0.4 # homeassistant.components.sensor.darksky python-forecastio==1.3.5 +# homeassistant.components.sensor.whois +pythonwhois==2.4.3 + # homeassistant.components.device_tracker.unifi pyunifi==2.13 @@ -124,13 +137,13 @@ pyunifi==2.13 pywebpush==1.1.0 # homeassistant.components.python_script -restrictedpython==4.0a3 +restrictedpython==4.0b2 # homeassistant.components.rflink rflink==0.0.34 # homeassistant.components.ring -ring_doorbell==0.1.4 +ring_doorbell==0.1.6 # homeassistant.components.media_player.yamaha rxv==0.5.1 @@ -151,6 +164,13 @@ statsd==3.2.1 # homeassistant.components.camera.uvc uvcclient==0.10.1 +# homeassistant.components.wake_on_lan +# homeassistant.components.media_player.panasonic_viera +# homeassistant.components.media_player.samsungtv +# homeassistant.components.media_player.webostv +# homeassistant.components.switch.wake_on_lan +wakeonlan==0.2.2 + # homeassistant.components.cloud warrant==0.5.0 diff --git a/script/bootstrap b/script/bootstrap index 05e69cc4db7..e7034f1c33c 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -5,10 +5,6 @@ set -e cd "$(dirname "$0")/.." -script/bootstrap_server -if command -v yarn >/dev/null ; then - script/bootstrap_frontend -else - echo "Frontend development not possible without Node/yarn" -fi +echo "Installing test dependencies..." +python3 -m pip install tox colorlog diff --git a/script/bootstrap_frontend b/script/bootstrap_frontend deleted file mode 100755 index d8338161e74..00000000000 --- a/script/bootstrap_frontend +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -# Resolve all frontend dependencies that the application requires to develop. - -# Stop on errors -set -e - -cd "$(dirname "$0")/.." - -echo "Bootstrapping frontend..." - -git submodule update --init -cd homeassistant/components/frontend/www_static/home-assistant-polymer - -# Install node modules -yarn install - -# Install bower web components. Allow to download the components as root since the user in docker is root. -./node_modules/.bin/bower install --allow-root - -# Build files that need to be generated to run development mode -yarn dev - -cd ../../../../.. diff --git a/script/bootstrap_server b/script/bootstrap_server deleted file mode 100755 index 791adc3a0c1..00000000000 --- a/script/bootstrap_server +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -# Resolve all server dependencies that the application requires to develop. - -# Stop on errors -set -e - -cd "$(dirname "$0")/.." - -echo "Installing test dependencies..." -python3 -m pip install tox colorlog diff --git a/script/build_frontend b/script/build_frontend deleted file mode 100755 index 3eee66daf36..00000000000 --- a/script/build_frontend +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh -# Builds the frontend for production - -# Stop on errors -set -e - -cd "$(dirname "$0")/.." - -# Clean up -rm -rf homeassistant/components/frontend/www_static/core.js* \ - homeassistant/components/frontend/www_static/compatibility.js* \ - homeassistant/components/frontend/www_static/frontend.html* \ - homeassistant/components/frontend/www_static/webcomponents-lite.js* \ - homeassistant/components/frontend/www_static/custom-elements-es5-adapter.js* \ - homeassistant/components/frontend/www_static/panels -cd homeassistant/components/frontend/www_static/home-assistant-polymer - -# Build frontend -BUILD_DEV=0 ./node_modules/.bin/gulp -cp bower_components/webcomponentsjs/webcomponents-lite.js .. -cp bower_components/webcomponentsjs/custom-elements-es5-adapter.js .. -cp build/*.js build/*.html .. -mkdir ../panels -cp build/panels/*.html ../panels -BUILD_DEV=0 ./node_modules/.bin/gulp gen-service-worker -cp build/service_worker.js .. -cd .. - -# Pack frontend -gzip -f -n -k -9 *.html *.js ./panels/*.html -cd ../../../.. - -# Generate the MD5 hash of the new frontend -script/fingerprint_frontend.py diff --git a/script/fingerprint_frontend.py b/script/fingerprint_frontend.py deleted file mode 100755 index 591d68690d2..00000000000 --- a/script/fingerprint_frontend.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a file with all md5 hashes of the assets.""" - -from collections import OrderedDict -import glob -import hashlib -import json - -fingerprint_file = 'homeassistant/components/frontend/version.py' -base_dir = 'homeassistant/components/frontend/www_static/' - - -def fingerprint(): - """Fingerprint the frontend files.""" - files = (glob.glob(base_dir + '**/*.html') + - glob.glob(base_dir + '*.html') + - glob.glob(base_dir + 'core.js') + - glob.glob(base_dir + 'compatibility.js')) - - md5s = OrderedDict() - - for fil in sorted(files): - name = fil[len(base_dir):] - with open(fil) as fp: - md5 = hashlib.md5(fp.read().encode('utf-8')).hexdigest() - md5s[name] = md5 - - template = """\"\"\"DO NOT MODIFY. Auto-generated by script/fingerprint_frontend.\"\"\" - -FINGERPRINTS = {} -""" - - result = template.format(json.dumps(md5s, indent=4)) - - with open(fingerprint_file, 'w') as fp: - fp.write(result) - - -if __name__ == '__main__': - fingerprint() diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ddac210bc26..d2ac40c2550 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -39,6 +39,7 @@ TEST_REQUIREMENTS = ( 'aioautomatic', 'aiohttp_cors', 'apns2', + 'coinmarketcap', 'defusedxml', 'dsmr_parser', 'ephem', @@ -50,10 +51,12 @@ TEST_REQUIREMENTS = ( 'haversine', 'hbmqtt', 'holidays', + 'home-assistant-frontend', 'influxdb', 'libpurecoollink', 'libsoundtouch', 'mficlient', + 'numpy', 'paho-mqtt', 'pexpect', 'pilight', @@ -78,6 +81,8 @@ TEST_REQUIREMENTS = ( 'uvcclient', 'warrant', 'yahoo-finance', + 'pythonwhois', + 'wakeonlan' ) IGNORE_PACKAGES = ( diff --git a/script/update_mdi.py b/script/update_mdi.py deleted file mode 100755 index f9a0a8aca9f..00000000000 --- a/script/update_mdi.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -"""Download the latest Polymer v1 iconset for materialdesignicons.com.""" - -import gzip -import os -import re -import requests -import sys - -from fingerprint_frontend import fingerprint - -GETTING_STARTED_URL = ('https://raw.githubusercontent.com/Templarian/' - 'MaterialDesign/master/site/getting-started.savvy') -DOWNLOAD_LINK = re.compile(r'(/api/download/polymer/v1/([A-Z0-9-]{36}))') -START_ICONSET = '=3.11,<4', 'pytz>=2017.02', 'pip>=8.0.3', diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 1c1fcfb7594..4c79e95b324 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -109,13 +109,17 @@ def test_discovery_request(hass): 'light.test_2', 'on', { 'friendly_name': "Test light 2", 'supported_features': 1 }) + hass.states.async_set( + 'light.test_3', 'on', { + 'friendly_name': "Test light 3", 'supported_features': 19 + }) msg = yield from smart_home.async_handle_message(hass, request) assert 'event' in msg msg = msg['event'] - assert len(msg['payload']['endpoints']) == 3 + assert len(msg['payload']['endpoints']) == 4 assert msg['header']['name'] == 'Discover.Response' assert msg['header']['namespace'] == 'Alexa.Discovery' @@ -150,6 +154,22 @@ def test_discovery_request(hass): continue + if appliance['endpointId'] == 'light#test_3': + assert appliance['displayCategories'][0] == "LIGHT" + assert appliance['friendlyName'] == "Test light 3" + assert len(appliance['capabilities']) == 4 + + caps = set() + for feature in appliance['capabilities']: + caps.add(feature['interface']) + + assert 'Alexa.BrightnessController' in caps + assert 'Alexa.PowerController' in caps + assert 'Alexa.ColorController' in caps + assert 'Alexa.ColorTemperatureController' in caps + + continue + raise AssertionError("Unknown appliance!") @@ -257,5 +277,185 @@ def test_api_set_brightness(hass): assert len(call_light) == 1 assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['brightness'] == '50' + assert call_light[0].data['brightness_pct'] == 50 + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize( + "result,adjust", [(25, '-5'), (35, '5'), (0, '-80')]) +def test_api_adjust_brightness(hass, result, adjust): + """Test api adjust brightness process.""" + request = get_new_request( + 'Alexa.BrightnessController', 'AdjustBrightness', 'light#test') + + # add payload + request['directive']['payload']['brightnessDelta'] = adjust + + # settup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'brightness': '77' + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['brightness_pct'] == result + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +def test_api_set_color_rgb(hass): + """Test api set color process.""" + request = get_new_request( + 'Alexa.ColorController', 'SetColor', 'light#test') + + # add payload + request['directive']['payload']['color'] = { + 'hue': '120', + 'saturation': '0.612', + 'brightness': '0.342', + } + + # settup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", + 'supported_features': 16, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['rgb_color'] == (33, 87, 33) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +def test_api_set_color_xy(hass): + """Test api set color process.""" + request = get_new_request( + 'Alexa.ColorController', 'SetColor', 'light#test') + + # add payload + request['directive']['payload']['color'] = { + 'hue': '120', + 'saturation': '0.612', + 'brightness': '0.342', + } + + # settup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", + 'supported_features': 64, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['xy_color'] == (0.23, 0.585) + assert call_light[0].data['brightness'] == 18 + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +def test_api_set_color_temperature(hass): + """Test api set color temperature process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'SetColorTemperature', + 'light#test') + + # add payload + request['directive']['payload']['colorTemperatureInKelvin'] = '7500' + + # settup test devices + hass.states.async_set( + 'light.test', 'off', {'friendly_name': "Test light"}) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['kelvin'] == 7500 + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("result,initial", [(383, '333'), (500, '500')]) +def test_api_decrease_color_temp(hass, result, initial): + """Test api decrease color temp process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'DecreaseColorTemperature', + 'light#test') + + # settup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'color_temp': initial, + 'max_mireds': 500, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['color_temp'] == result + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("result,initial", [(283, '333'), (142, '142')]) +def test_api_increase_color_temp(hass, result, initial): + """Test api increase color temp process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'IncreaseColorTemperature', + 'light#test') + + # settup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'color_temp': initial, + 'min_mireds': 142, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['color_temp'] == result assert msg['header']['name'] == 'Response' diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index bde34f7fb9f..df9ab69e7e8 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -54,6 +54,31 @@ class TestAutomationEvent(unittest.TestCase): self.hass.block_till_done() self.assertEqual(1, len(self.calls)) + def test_if_fires_on_event_extra_data(self): + """Test the firing of events still matches with event data.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.bus.fire('test_event', {'extra_key': 'extra_data'}) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + + automation.turn_off(self.hass) + self.hass.block_till_done() + + self.hass.bus.fire('test_event') + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_event_with_data(self): """Test the firing of events with data.""" assert setup_component(self.hass, automation.DOMAIN, { @@ -74,6 +99,30 @@ class TestAutomationEvent(unittest.TestCase): self.hass.block_till_done() self.assertEqual(1, len(self.calls)) + def test_if_fires_on_event_with_empty_data_config(self): + """Test the firing of events with empty data config. + + The frontend automation editor can produce configurations with an + empty dict for event_data instead of no key. + """ + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + 'event_data': {} + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.bus.fire('test_event', {'some_attr': 'some_value', + 'another': 'value'}) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_event_with_nested_data(self): """Test the firing of events with nested data.""" assert setup_component(self.hass, automation.DOMAIN, { diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index cb36a91dddb..35841baa930 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -86,7 +86,7 @@ class TestAutomationNumericState(unittest.TestCase): def test_if_not_fires_on_entity_change_below_to_below(self): """"Test the firing with changed entity.""" - self.hass.states.set('test.entity', 9) + self.hass.states.set('test.entity', 11) self.hass.block_till_done() assert setup_component(self.hass, automation.DOMAIN, { @@ -102,10 +102,15 @@ class TestAutomationNumericState(unittest.TestCase): } }) - # 9 is below 10 so this should not fire again - self.hass.states.set('test.entity', 8) + # 9 is below 10 so this should fire + self.hass.states.set('test.entity', 9) self.hass.block_till_done() - self.assertEqual(0, len(self.calls)) + self.assertEqual(1, len(self.calls)) + + # already below so should not fire again + self.hass.states.set('test.entity', 5) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) def test_if_not_below_fires_on_entity_change_to_equal(self): """"Test the firing with changed entity.""" @@ -130,6 +135,52 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(0, len(self.calls)) + def test_if_fires_on_initial_entity_below(self): + """"Test the firing when starting with a match.""" + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'below': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + # Fire on first update even if initial state was already below + self.hass.states.set('test.entity', 8) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_initial_entity_above(self): + """"Test the firing when starting with a match.""" + self.hass.states.set('test.entity', 11) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + # Fire on first update even if initial state was already above + self.hass.states.set('test.entity', 12) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_entity_change_above(self): """"Test the firing with changed entity.""" assert setup_component(self.hass, automation.DOMAIN, { @@ -176,7 +227,7 @@ class TestAutomationNumericState(unittest.TestCase): def test_if_not_fires_on_entity_change_above_to_above(self): """"Test the firing with changed entity.""" # set initial state - self.hass.states.set('test.entity', 11) + self.hass.states.set('test.entity', 9) self.hass.block_till_done() assert setup_component(self.hass, automation.DOMAIN, { @@ -192,10 +243,15 @@ class TestAutomationNumericState(unittest.TestCase): } }) - # 11 is above 10 so this should fire again + # 12 is above 10 so this should fire self.hass.states.set('test.entity', 12) self.hass.block_till_done() - self.assertEqual(0, len(self.calls)) + self.assertEqual(1, len(self.calls)) + + # already above, should not fire again + self.hass.states.set('test.entity', 15) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) def test_if_not_above_fires_on_entity_change_to_equal(self): """"Test the firing with changed entity.""" diff --git a/tests/components/binary_sensor/test_random.py b/tests/components/binary_sensor/test_random.py new file mode 100644 index 00000000000..9ec1990158d --- /dev/null +++ b/tests/components/binary_sensor/test_random.py @@ -0,0 +1,51 @@ +"""The test for the Random binary sensor platform.""" +import unittest +from unittest.mock import patch + +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant + + +class TestRandomSensor(unittest.TestCase): + """Test the Random binary sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + @patch('random.getrandbits', return_value=1) + def test_random_binary_sensor_on(self, mocked): + """Test the Random binary sensor.""" + config = { + 'binary_sensor': { + 'platform': 'random', + 'name': 'test', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + state = self.hass.states.get('binary_sensor.test') + + self.assertEqual(state.state, 'on') + + @patch('random.getrandbits', return_value=False) + def test_random_binary_sensor_off(self, mocked): + """Test the Random binary sensor.""" + config = { + 'binary_sensor': { + 'platform': 'random', + 'name': 'test', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + state = self.hass.states.get('binary_sensor.test') + + self.assertEqual(state.state, 'off') diff --git a/tests/components/binary_sensor/test_ring.py b/tests/components/binary_sensor/test_ring.py index 58a357be1b6..889282b56dd 100644 --- a/tests/components/binary_sensor/test_ring.py +++ b/tests/components/binary_sensor/test_ring.py @@ -50,6 +50,8 @@ class TestRingBinarySensorSetup(unittest.TestCase): text=load_fixture('ring_devices.json')) mock.get('https://api.ring.com/clients_api/dings/active', text=load_fixture('ring_ding_active.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987652/health', + text=load_fixture('ring_doorboot_health_attrs.json')) base_ring.setup(self.hass, VALID_CONFIG) ring.setup_platform(self.hass, diff --git a/tests/components/binary_sensor/test_trend.py b/tests/components/binary_sensor/test_trend.py index dd3c0ba9890..c1083cc1857 100644 --- a/tests/components/binary_sensor/test_trend.py +++ b/tests/components/binary_sensor/test_trend.py @@ -10,7 +10,7 @@ class TestTrendBinarySensor: hass = None def setup_method(self, method): - """Setup things to be run when tests are started.""" + """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() def teardown_method(self, method): @@ -38,6 +38,67 @@ class TestTrendBinarySensor: state = self.hass.states.get('binary_sensor.test_trend_sensor') assert state.state == 'on' + def test_up_using_trendline(self): + """Test up trend using multiple samples and trendline calculation.""" + assert setup.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': "sensor.test_state", + 'sample_duration': 300, + 'min_gradient': 1, + 'max_samples': 25, + } + } + } + }) + + for val in [1, 0, 2, 3]: + self.hass.states.set('sensor.test_state', val) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'on' + + for val in [0, 1, 0, 0]: + self.hass.states.set('sensor.test_state', val) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + + def test_down_using_trendline(self): + """Test down trend using multiple samples and trendline calculation.""" + assert setup.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': "sensor.test_state", + 'sample_duration': 300, + 'min_gradient': 1, + 'max_samples': 25, + 'invert': 'Yes' + } + } + } + }) + + for val in [3, 2, 3, 1]: + self.hass.states.set('sensor.test_state', val) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'on' + + for val in [4, 2, 4, 4]: + self.hass.states.set('sensor.test_state', val) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + def test_down(self): """Test down trend.""" assert setup.setup_component(self.hass, 'binary_sensor', { @@ -59,7 +120,7 @@ class TestTrendBinarySensor: state = self.hass.states.get('binary_sensor.test_trend_sensor') assert state.state == 'off' - def test__invert_up(self): + def test_invert_up(self): """Test up trend with custom message.""" assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { @@ -142,11 +203,33 @@ class TestTrendBinarySensor: self.hass.states.set('sensor.test_state', 'State', {'attr': '2'}) self.hass.block_till_done() self.hass.states.set('sensor.test_state', 'State', {'attr': '1'}) - self.hass.block_till_done() state = self.hass.states.get('binary_sensor.test_trend_sensor') assert state.state == 'off' + def test_max_samples(self): + """Test that sample count is limited correctly.""" + assert setup.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': "sensor.test_state", + 'max_samples': 3, + 'min_gradient': -1, + } + } + } + }) + + for val in [0, 1, 2, 3, 2, 1]: + self.hass.states.set('sensor.test_state', val) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'on' + assert state.attributes['sample_count'] == 3 + def test_non_numeric(self): """Test up trend.""" assert setup.setup_component(self.hass, 'binary_sensor', { @@ -186,7 +269,6 @@ class TestTrendBinarySensor: self.hass.states.set('sensor.test_state', 'State', {'attr': '2'}) self.hass.block_till_done() self.hass.states.set('sensor.test_state', 'State', {'attr': '1'}) - self.hass.block_till_done() state = self.hass.states.get('binary_sensor.test_trend_sensor') assert state.state == 'off' diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 97f6c0385df..70e95dd7b93 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -1,10 +1,10 @@ """The tests for the camera component.""" import asyncio -from unittest.mock import patch +from unittest.mock import patch, mock_open import pytest -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component from homeassistant.const import ATTR_ENTITY_PICTURE import homeassistant.components.camera as camera import homeassistant.components.http as http @@ -15,6 +15,20 @@ from tests.common import ( get_test_home_assistant, get_test_instance_port, assert_setup_component) +@pytest.fixture +def mock_camera(hass): + """Initialize a demo camera platform.""" + assert hass.loop.run_until_complete(async_setup_component(hass, 'camera', { + camera.DOMAIN: { + 'platform': 'demo' + } + })) + + with patch('homeassistant.components.camera.demo.DemoCamera.camera_image', + return_value=b'Test'): + yield + + class TestSetupCamera(object): """Test class for setup camera.""" @@ -105,3 +119,20 @@ class TestGetImage(object): self.hass, 'camera.demo_camera'), self.hass.loop).result() assert len(aioclient_mock.mock_calls) == 1 + + +@asyncio.coroutine +def test_snapshot_service(hass, mock_camera): + """Test snapshot service.""" + mopen = mock_open() + + with patch('homeassistant.components.camera.open', mopen, create=True), \ + patch.object(hass.config, 'is_allowed_path', + return_value=True): + hass.components.camera.async_snapshot('/tmp/bla') + yield from hass.async_block_till_done() + + mock_write = mopen().write + + assert len(mock_write.mock_calls) == 1 + assert mock_write.mock_calls[0][1][0] == b'Test' diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 15fc3f6a982..74b2186b8d7 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -28,7 +28,8 @@ ENT_SWITCH = 'switch.test' MIN_TEMP = 3.0 MAX_TEMP = 65.0 TARGET_TEMP = 42.0 -TOLERANCE = 0.5 +COLD_TOLERANCE = 0.5 +HOT_TOLERANCE = 0.5 class TestSetupClimateGenericThermostat(unittest.TestCase): @@ -88,7 +89,8 @@ class TestClimateGenericThermostat(unittest.TestCase): assert setup_component(self.hass, climate.DOMAIN, {'climate': { 'platform': 'generic_thermostat', 'name': 'test', - 'tolerance': 2, + 'cold_tolerance': 2, + 'hot_tolerance': 4, 'heater': ENT_SWITCH, 'target_sensor': ENT_SENSOR }}) @@ -183,11 +185,11 @@ class TestClimateGenericThermostat(unittest.TestCase): self.assertEqual(0, len(self.calls)) def test_temp_change_heater_on_outside_tolerance(self): - """Test if temperature change turn heater on outside tolerance.""" + """Test if temperature change turn heater on outside cold tolerance.""" self._setup_switch(False) climate.set_temperature(self.hass, 30) self.hass.block_till_done() - self._setup_sensor(25) + self._setup_sensor(27) self.hass.block_till_done() self.assertEqual(1, len(self.calls)) call = self.calls[0] @@ -200,12 +202,12 @@ class TestClimateGenericThermostat(unittest.TestCase): self._setup_switch(True) climate.set_temperature(self.hass, 30) self.hass.block_till_done() - self._setup_sensor(31) + self._setup_sensor(33) self.hass.block_till_done() self.assertEqual(0, len(self.calls)) def test_temp_change_heater_off_outside_tolerance(self): - """Test if temperature change turn heater off outside tolerance.""" + """Test if temperature change turn heater off outside hot tolerance.""" self._setup_switch(True) climate.set_temperature(self.hass, 30) self.hass.block_till_done() @@ -271,7 +273,8 @@ class TestClimateGenericThermostatACMode(unittest.TestCase): assert setup_component(self.hass, climate.DOMAIN, {'climate': { 'platform': 'generic_thermostat', 'name': 'test', - 'tolerance': 0.3, + 'cold_tolerance': 2, + 'hot_tolerance': 4, 'heater': ENT_SWITCH, 'target_sensor': ENT_SENSOR, 'ac_mode': True @@ -321,7 +324,7 @@ class TestClimateGenericThermostatACMode(unittest.TestCase): self._setup_switch(True) climate.set_temperature(self.hass, 30) self.hass.block_till_done() - self._setup_sensor(25) + self._setup_sensor(27) self.hass.block_till_done() self.assertEqual(1, len(self.calls)) call = self.calls[0] @@ -405,7 +408,8 @@ class TestClimateGenericThermostatACModeMinCycle(unittest.TestCase): assert setup_component(self.hass, climate.DOMAIN, {'climate': { 'platform': 'generic_thermostat', 'name': 'test', - 'tolerance': 0.3, + 'cold_tolerance': 0.3, + 'hot_tolerance': 0.3, 'heater': ENT_SWITCH, 'target_sensor': ENT_SENSOR, 'ac_mode': True, @@ -498,7 +502,8 @@ class TestClimateGenericThermostatMinCycle(unittest.TestCase): assert setup_component(self.hass, climate.DOMAIN, {'climate': { 'platform': 'generic_thermostat', 'name': 'test', - 'tolerance': 0.3, + 'cold_tolerance': 0.3, + 'hot_tolerance': 0.3, 'heater': ENT_SWITCH, 'target_sensor': ENT_SENSOR, 'min_cycle_duration': datetime.timedelta(minutes=10) @@ -590,7 +595,8 @@ class TestClimateGenericThermostatACKeepAlive(unittest.TestCase): assert setup_component(self.hass, climate.DOMAIN, {'climate': { 'platform': 'generic_thermostat', 'name': 'test', - 'tolerance': 0.3, + 'cold_tolerance': 0.3, + 'hot_tolerance': 0.3, 'heater': ENT_SWITCH, 'target_sensor': ENT_SENSOR, 'ac_mode': True, @@ -681,7 +687,8 @@ class TestClimateGenericThermostatKeepAlive(unittest.TestCase): assert setup_component(self.hass, climate.DOMAIN, {'climate': { 'platform': 'generic_thermostat', 'name': 'test', - 'tolerance': 0.3, + 'cold_tolerance': 0.3, + 'hot_tolerance': 0.3, 'heater': ENT_SWITCH, 'target_sensor': ENT_SENSOR, 'keep_alive': datetime.timedelta(minutes=10) diff --git a/tests/components/cover/test_template.py b/tests/components/cover/test_template.py index 495508203b3..af114135da9 100644 --- a/tests/components/cover/test_template.py +++ b/tests/components/cover/test_template.py @@ -682,3 +682,43 @@ class TestTemplateCover(unittest.TestCase): state = self.hass.states.get('cover.test_template_cover') assert state.attributes['icon'] == 'mdi:check' + + def test_entity_picture_template(self): + """Test icon template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ states.cover.test_state.state }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'entity_picture_template': + "{% if states.cover.test_state.state %}" + "/local/cover.png" + "{% endif %}" + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('entity_picture') == '' + + state = self.hass.states.set('cover.test_state', STATE_OPEN) + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + + assert state.attributes['entity_picture'] == '/local/cover.png' diff --git a/tests/components/device_tracker/test_geofency.py b/tests/components/device_tracker/test_geofency.py index e8aa44cb0e5..5def6a217f4 100644 --- a/tests/components/device_tracker/test_geofency.py +++ b/tests/components/device_tracker/test_geofency.py @@ -170,6 +170,21 @@ def test_gps_enter_and_exit_home(hass, geofency_client): 'device_tracker', device_name)).state assert STATE_NOT_HOME == state_name + # Exit the Home zone with "Send Current Position" enabled + data = GPS_EXIT_HOME.copy() + data['currentLatitude'] = NOT_HOME_LATITUDE + data['currentLongitude'] = NOT_HOME_LONGITUDE + + req = yield from geofency_client.post(URL, data=data) + assert req.status == HTTP_OK + device_name = slugify(GPS_EXIT_HOME['device']) + current_latitude = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).attributes['latitude'] + assert NOT_HOME_LATITUDE == current_latitude + current_longitude = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).attributes['longitude'] + assert NOT_HOME_LONGITUDE == current_longitude + @asyncio.coroutine def test_beacon_enter_and_exit_home(hass, geofency_client): diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index ecdbe0085ee..a8531e2aa69 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -11,6 +11,7 @@ import os from homeassistant.components import zone from homeassistant.core import callback, State from homeassistant.setup import setup_component +from homeassistant.helpers import discovery from homeassistant.loader import get_component from homeassistant.util.async import run_coroutine_threadsafe import homeassistant.util.dt as dt_util @@ -310,6 +311,23 @@ class TestComponentsDeviceTracker(unittest.TestCase): 'No http request for macvendor made!' self.assertEqual(tracker.devices['b827eb000000'].vendor, vendor_string) + @patch( + 'homeassistant.components.device_tracker.DeviceTracker.see') + @patch( + 'homeassistant.components.device_tracker.demo.setup_scanner', + autospec=True) + def test_discover_platform(self, mock_demo_setup_scanner, mock_see): + """Test discovery of device_tracker demo platform.""" + assert device_tracker.DOMAIN not in self.hass.config.components + discovery.load_platform( + self.hass, device_tracker.DOMAIN, 'demo', {'test_key': 'test_val'}, + {}) + self.hass.block_till_done() + assert device_tracker.DOMAIN in self.hass.config.components + assert mock_demo_setup_scanner.called + assert mock_demo_setup_scanner.call_args[0] == ( + self.hass, {}, mock_see, {'test_key': 'test_val'}) + def test_update_stale(self): """Test stalled update.""" scanner = get_component('device_tracker.test').SCANNER diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index eb163fdcbdf..a06adcb286a 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -26,95 +26,174 @@ WAYPOINT_TOPIC_BLOCKED = 'owntracks/{}/{}/waypoints'.format( DEVICE_TRACKER_STATE = 'device_tracker.{}_{}'.format(USER, DEVICE) IBEACON_DEVICE = 'keys' -REGION_TRACKER_STATE = 'device_tracker.beacon_{}'.format(IBEACON_DEVICE) +MOBILE_BEACON_FMT = 'device_tracker.beacon_{}' CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' CONF_WAYPOINT_IMPORT = owntracks.CONF_WAYPOINT_IMPORT CONF_WAYPOINT_WHITELIST = owntracks.CONF_WAYPOINT_WHITELIST CONF_SECRET = owntracks.CONF_SECRET -LOCATION_MESSAGE = { - 'batt': 92, - 'cog': 248, - 'tid': 'user', - 'lon': 1.0, - 't': 'u', - 'alt': 27, +TEST_ZONE_LAT = 45.0 +TEST_ZONE_LON = 90.0 +TEST_ZONE_DEG_PER_M = 0.0000127 +FIVE_M = TEST_ZONE_DEG_PER_M * 5.0 + + +# Home Assistant Zones +INNER_ZONE = { + 'name': 'zone', + 'latitude': TEST_ZONE_LAT+0.1, + 'longitude': TEST_ZONE_LON+0.1, + 'radius': 50 +} + +OUTER_ZONE = { + 'name': 'zone', + 'latitude': TEST_ZONE_LAT, + 'longitude': TEST_ZONE_LON, + 'radius': 100000 +} + + +def build_message(test_params, default_params): + """Build a test message from overrides and another message.""" + new_params = default_params.copy() + new_params.update(test_params) + return new_params + + +# Default message parameters +DEFAULT_LOCATION_MESSAGE = { + '_type': 'location', + 'lon': OUTER_ZONE['longitude'], + 'lat': OUTER_ZONE['latitude'], 'acc': 60, - 'p': 101.3977584838867, - 'vac': 4, - 'lat': 2.0, - '_type': 'location', - 'tst': 1, - 'vel': 0} - -LOCATION_MESSAGE_INACCURATE = { + 'tid': 'user', + 't': 'u', 'batt': 92, 'cog': 248, - 'tid': 'user', - 'lon': 2.0, - 't': 'u', 'alt': 27, - 'acc': 2000, 'p': 101.3977584838867, 'vac': 4, - 'lat': 6.0, - '_type': 'location', 'tst': 1, - 'vel': 0} + 'vel': 0 +} -LOCATION_MESSAGE_ZERO_ACCURACY = { - 'batt': 92, - 'cog': 248, - 'tid': 'user', - 'lon': 2.0, - 't': 'u', - 'alt': 27, - 'acc': 0, - 'p': 101.3977584838867, - 'vac': 4, - 'lat': 6.0, - '_type': 'location', - 'tst': 1, - 'vel': 0} - -REGION_ENTER_MESSAGE = { - 'lon': 1.0, +# Owntracks will publish a transition when crossing +# a circular region boundary. +ZONE_EDGE = TEST_ZONE_DEG_PER_M * INNER_ZONE['radius'] +DEFAULT_TRANSITION_MESSAGE = { + '_type': 'transition', + 't': 'c', + 'lon': INNER_ZONE['longitude'], + 'lat': INNER_ZONE['latitude'] - ZONE_EDGE, + 'acc': 60, 'event': 'enter', 'tid': 'user', 'desc': 'inner', 'wtst': 1, + 'tst': 2 +} + +# iBeacons that are named the same as an HA zone +# are used to trigger enter and leave updates +# for that zone. In this case the "inner" zone. +# +# iBeacons that do not share an HA zone name +# are treated as mobile tracking devices for +# objects which can't track themselves e.g. keys. +# +# iBeacons are typically configured with the +# default lat/lon 0.0/0.0 and have acc 0.0 but +# regardless the reported location is not trusted. +# +# Owntracks will send both a location message +# for the device and an 'event' message for +# the beacon transition. +DEFAULT_BEACON_TRANSITION_MESSAGE = { + '_type': 'transition', 't': 'b', - 'acc': 60, - 'tst': 2, - 'lat': 2.0, - '_type': 'transition'} - - -REGION_LEAVE_MESSAGE = { - 'lon': 1.0, - 'event': 'leave', + 'lon': 0.0, + 'lat': 0.0, + 'acc': 0.0, + 'event': 'enter', 'tid': 'user', 'desc': 'inner', 'wtst': 1, - 't': 'b', - 'acc': 60, - 'tst': 2, - 'lat': 2.0, - '_type': 'transition'} + 'tst': 2 +} -REGION_LEAVE_INACCURATE_MESSAGE = { - 'lon': 10.0, - 'event': 'leave', - 'tid': 'user', - 'desc': 'inner', - 'wtst': 1, - 't': 'b', - 'acc': 2000, - 'tst': 2, - 'lat': 20.0, - '_type': 'transition'} +# Location messages +LOCATION_MESSAGE = DEFAULT_LOCATION_MESSAGE +LOCATION_MESSAGE_INACCURATE = build_message( + {'lat': INNER_ZONE['latitude'] - ZONE_EDGE, + 'lon': INNER_ZONE['longitude'] - ZONE_EDGE, + 'acc': 2000}, + LOCATION_MESSAGE) + +LOCATION_MESSAGE_ZERO_ACCURACY = build_message( + {'lat': INNER_ZONE['latitude'] - ZONE_EDGE, + 'lon': INNER_ZONE['longitude'] - ZONE_EDGE, + 'acc': 0}, + LOCATION_MESSAGE) + +LOCATION_MESSAGE_NOT_HOME = build_message( + {'lat': OUTER_ZONE['latitude'] - 2.0, + 'lon': INNER_ZONE['longitude'] - 2.0, + 'acc': 100}, + LOCATION_MESSAGE) + +# Region GPS messages +REGION_GPS_ENTER_MESSAGE = DEFAULT_TRANSITION_MESSAGE + +REGION_GPS_LEAVE_MESSAGE = build_message( + {'lon': INNER_ZONE['longitude'] - ZONE_EDGE * 10, + 'lat': INNER_ZONE['latitude'] - ZONE_EDGE * 10, + 'event': 'leave'}, + DEFAULT_TRANSITION_MESSAGE) + +REGION_GPS_ENTER_MESSAGE_INACCURATE = build_message( + {'acc': 2000}, + REGION_GPS_ENTER_MESSAGE) + +REGION_GPS_LEAVE_MESSAGE_INACCURATE = build_message( + {'acc': 2000}, + REGION_GPS_LEAVE_MESSAGE) + +REGION_GPS_ENTER_MESSAGE_ZERO = build_message( + {'acc': 0}, + REGION_GPS_ENTER_MESSAGE) + +REGION_GPS_LEAVE_MESSAGE_ZERO = build_message( + {'acc': 0}, + REGION_GPS_LEAVE_MESSAGE) + +REGION_GPS_LEAVE_MESSAGE_OUTER = build_message( + {'lon': OUTER_ZONE['longitude'] - 2.0, + 'lat': OUTER_ZONE['latitude'] - 2.0, + 'desc': 'outer', + 'event': 'leave'}, + DEFAULT_TRANSITION_MESSAGE) + +# Region Beacon messages +REGION_BEACON_ENTER_MESSAGE = DEFAULT_BEACON_TRANSITION_MESSAGE + +REGION_BEACON_LEAVE_MESSAGE = build_message( + {'event': 'leave'}, + DEFAULT_BEACON_TRANSITION_MESSAGE) + +# Mobile Beacon messages +MOBILE_BEACON_ENTER_EVENT_MESSAGE = build_message( + {'desc': IBEACON_DEVICE}, + DEFAULT_BEACON_TRANSITION_MESSAGE) + +MOBILE_BEACON_LEAVE_EVENT_MESSAGE = build_message( + {'desc': IBEACON_DEVICE, + 'event': 'leave'}, + DEFAULT_BEACON_TRANSITION_MESSAGE) + +# Waypoint messages WAYPOINTS_EXPORTED_MESSAGE = { "_type": "waypoints", "_creator": "test", @@ -160,54 +239,9 @@ WAYPOINT_ENTITY_NAMES = [ 'zone.ram_phone__exp_wayp2', ] -REGION_ENTER_ZERO_MESSAGE = { - 'lon': 1.0, - 'event': 'enter', - 'tid': 'user', - 'desc': 'inner', - 'wtst': 1, - 't': 'b', - 'acc': 0, - 'tst': 2, - 'lat': 2.0, - '_type': 'transition'} - -REGION_LEAVE_ZERO_MESSAGE = { - 'lon': 10.0, - 'event': 'leave', - 'tid': 'user', - 'desc': 'inner', - 'wtst': 1, - 't': 'b', - 'acc': 0, - 'tst': 2, - 'lat': 20.0, - '_type': 'transition'} - BAD_JSON_PREFIX = '--$this is bad json#--' BAD_JSON_SUFFIX = '** and it ends here ^^' -TEST_SECRET_KEY = 's3cretkey' -ENCRYPTED_LOCATION_MESSAGE = { - # Encrypted version of LOCATION_MESSAGE using libsodium and TEST_SECRET_KEY - '_type': 'encrypted', - 'data': ('qm1A83I6TVFRmH5343xy+cbex8jBBxDFkHRuJhELVKVRA/DgXcyKtghw' - '9pOw75Lo4gHcyy2wV5CmkjrpKEBR7Qhye4AR0y7hOvlx6U/a3GuY1+W8' - 'I4smrLkwMvGgBOzXSNdVTzbFTHDvG3gRRaNHFkt2+5MsbH2Dd6CXmpzq' - 'DIfSN7QzwOevuvNIElii5MlFxI6ZnYIDYA/ZdnAXHEVsNIbyT2N0CXt3' - 'fTPzgGtFzsufx40EEUkC06J7QTJl7lLG6qaLW1cCWp86Vp0eL3vtZ6xq') -} - -MOCK_ENCRYPTED_LOCATION_MESSAGE = { - # Mock-encrypted version of LOCATION_MESSAGE using pickle - '_type': 'encrypted', - 'data': ('gANDCXMzY3JldGtleXEAQ6p7ImxvbiI6IDEuMCwgInQiOiAidSIsICJi' - 'YXR0IjogOTIsICJhY2MiOiA2MCwgInZlbCI6IDAsICJfdHlwZSI6ICJs' - 'b2NhdGlvbiIsICJ2YWMiOiA0LCAicCI6IDEwMS4zOTc3NTg0ODM4ODY3' - 'LCAidHN0IjogMSwgImxhdCI6IDIuMCwgImFsdCI6IDI3LCAiY29nIjog' - 'MjQ4LCAidGlkIjogInVzZXIifXEBhnECLg==') -} - class BaseMQTT(unittest.TestCase): """Base MQTT assert functions.""" @@ -282,58 +316,46 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): }}) self.hass.states.set( - 'zone.inner', 'zoning', - { - 'name': 'zone', - 'latitude': 2.1, - 'longitude': 1.1, - 'radius': 10 - }) + 'zone.inner', 'zoning', INNER_ZONE) self.hass.states.set( - 'zone.inner_2', 'zoning', - { - 'name': 'zone', - 'latitude': 2.1, - 'longitude': 1.1, - 'radius': 10 - }) + 'zone.inner_2', 'zoning', INNER_ZONE) self.hass.states.set( - 'zone.outer', 'zoning', - { - 'name': 'zone', - 'latitude': 2.0, - 'longitude': 1.0, - 'radius': 100000 - }) + 'zone.outer', 'zoning', OUTER_ZONE) - # Clear state between teste + # Clear state between tests + # NB: state "None" is not a state that is created by Device + # so when we compare state to None in the tests this + # is really checking that it is still in its original + # test case state. See Device.async_update. self.hass.states.set(DEVICE_TRACKER_STATE, None) def teardown_method(self, _): """Stop everything that was started.""" self.hass.stop() - def assert_tracker_state(self, location): - """Test the assertion of a tracker state.""" - state = self.hass.states.get(REGION_TRACKER_STATE) + def assert_mobile_tracker_state(self, location, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker state.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = self.hass.states.get(dev_id) self.assertEqual(state.state, location) - def assert_tracker_latitude(self, latitude): - """Test the assertion of a tracker latitude.""" - state = self.hass.states.get(REGION_TRACKER_STATE) + def assert_mobile_tracker_latitude(self, latitude, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker latitude.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = self.hass.states.get(dev_id) self.assertEqual(state.attributes.get('latitude'), latitude) - def assert_tracker_accuracy(self, accuracy): - """Test the assertion of a tracker accuracy.""" - state = self.hass.states.get(REGION_TRACKER_STATE) + def assert_mobile_tracker_accuracy(self, accuracy, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker accuracy.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = self.hass.states.get(dev_id) self.assertEqual(state.attributes.get('gps_accuracy'), accuracy) def test_location_invalid_devid(self): # pylint: disable=invalid-name """Test the update of a location.""" self.send_message('owntracks/paulus/nexus-5x', LOCATION_MESSAGE) - state = self.hass.states.get('device_tracker.paulus_nexus5x') assert state.state == 'outer' @@ -341,8 +363,8 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): """Test the update of a location.""" self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_latitude(2.0) - self.assert_location_accuracy(60.0) + self.assert_location_latitude(LOCATION_MESSAGE['lat']) + self.assert_location_accuracy(LOCATION_MESSAGE['acc']) self.assert_location_state('outer') def test_location_inaccurate_gps(self): @@ -350,288 +372,686 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE) - self.assert_location_latitude(2.0) - self.assert_location_longitude(1.0) + # Ignored inaccurate GPS. Location remains at previous. + self.assert_location_latitude(LOCATION_MESSAGE['lat']) + self.assert_location_longitude(LOCATION_MESSAGE['lon']) def test_location_zero_accuracy_gps(self): """Ignore the location for zero accuracy GPS information.""" self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) - self.assert_location_latitude(2.0) - self.assert_location_longitude(1.0) + # Ignored inaccurate GPS. Location remains at previous. + self.assert_location_latitude(LOCATION_MESSAGE['lat']) + self.assert_location_longitude(LOCATION_MESSAGE['lon']) - def test_event_entry_exit(self): + # ------------------------------------------------------------------------ + # GPS based event entry / exit testing + + def test_event_gps_entry_exit(self): """Test the entry event.""" - self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE) + # Entering the owntrack circular region named "inner" + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) # Enter uses the zone's gps co-ords - self.assert_location_latitude(2.1) - self.assert_location_accuracy(10.0) + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) self.assert_location_state('inner') self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) # Updates ignored when in a zone - self.assert_location_latitude(2.1) - self.assert_location_accuracy(10.0) + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # asssociated with the inner zone regardless of GPS. + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) self.assert_location_state('inner') - self.send_message(EVENT_TOPIC, REGION_LEAVE_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) # Exit switches back to GPS - self.assert_location_latitude(2.0) - self.assert_location_accuracy(60.0) + self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) + self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) self.assert_location_state('outer') # Left clean zone state self.assertFalse(self.context.regions_entered[USER]) - def test_event_with_spaces(self): + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + + # Now sending a location update moves me again. + self.assert_location_latitude(LOCATION_MESSAGE['lat']) + self.assert_location_accuracy(LOCATION_MESSAGE['acc']) + + def test_event_gps_with_spaces(self): """Test the entry event.""" - message = REGION_ENTER_MESSAGE.copy() - message['desc'] = "inner 2" + message = build_message({'desc': "inner 2"}, + REGION_GPS_ENTER_MESSAGE) self.send_message(EVENT_TOPIC, message) self.assert_location_state('inner 2') - message = REGION_LEAVE_MESSAGE.copy() - message['desc'] = "inner 2" + message = build_message({'desc': "inner 2"}, + REGION_GPS_LEAVE_MESSAGE) self.send_message(EVENT_TOPIC, message) # Left clean zone state self.assertFalse(self.context.regions_entered[USER]) - def test_event_entry_exit_inaccurate(self): - """Test the event for inaccurate exit.""" - self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE) + def test_event_gps_entry_inaccurate(self): + """Test the event for inaccurate entry.""" + # Set location to the outer zone. + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - # Enter uses the zone's gps co-ords - self.assert_location_latitude(2.1) - self.assert_location_accuracy(10.0) + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE) + + # I enter the zone even though the message GPS was inaccurate. + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) self.assert_location_state('inner') - self.send_message(EVENT_TOPIC, REGION_LEAVE_INACCURATE_MESSAGE) + def test_event_gps_entry_exit_inaccurate(self): + """Test the event for inaccurate exit.""" + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) + self.assert_location_state('inner') + + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE) # Exit doesn't use inaccurate gps - self.assert_location_latitude(2.1) - self.assert_location_accuracy(10.0) + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) self.assert_location_state('inner') # But does exit region correctly self.assertFalse(self.context.regions_entered[USER]) - def test_event_entry_exit_zero_accuracy(self): + def test_event_gps_entry_exit_zero_accuracy(self): """Test entry/exit events with accuracy zero.""" - self.send_message(EVENT_TOPIC, REGION_ENTER_ZERO_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO) # Enter uses the zone's gps co-ords - self.assert_location_latitude(2.1) - self.assert_location_accuracy(10.0) + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) self.assert_location_state('inner') - self.send_message(EVENT_TOPIC, REGION_LEAVE_ZERO_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO) # Exit doesn't use zero gps - self.assert_location_latitude(2.1) - self.assert_location_accuracy(10.0) + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) self.assert_location_state('inner') # But does exit region correctly self.assertFalse(self.context.regions_entered[USER]) - def test_event_exit_outside_zone_sets_away(self): + def test_event_gps_exit_outside_zone_sets_away(self): """Test the event for exit zone.""" - self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) self.assert_location_state('inner') # Exit message far away GPS location - message = REGION_LEAVE_MESSAGE.copy() - message['lon'] = 90.1 - message['lat'] = 90.1 + message = build_message( + {'lon': 90.0, + 'lat': 90.0}, + REGION_GPS_LEAVE_MESSAGE) self.send_message(EVENT_TOPIC, message) # Exit forces zone change to away self.assert_location_state(STATE_NOT_HOME) - def test_event_entry_exit_right_order(self): + def test_event_gps_entry_exit_right_order(self): """Test the event for ordering.""" # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE) - + # Set location to the outer zone. + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) self.assert_location_state('inner') - self.assert_location_latitude(2.1) - self.assert_location_accuracy(10.0) # Enter inner2 zone - message = REGION_ENTER_MESSAGE.copy() - message['desc'] = "inner_2" + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) self.send_message(EVENT_TOPIC, message) self.assert_location_state('inner_2') - self.assert_location_latitude(2.1) - self.assert_location_accuracy(10.0) # Exit inner_2 - should be in 'inner' - message = REGION_LEAVE_MESSAGE.copy() - message['desc'] = "inner_2" + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) self.send_message(EVENT_TOPIC, message) self.assert_location_state('inner') - self.assert_location_latitude(2.1) - self.assert_location_accuracy(10.0) # Exit inner - should be in 'outer' - self.send_message(EVENT_TOPIC, REGION_LEAVE_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) + self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) self.assert_location_state('outer') - self.assert_location_latitude(2.0) - self.assert_location_accuracy(60.0) - def test_event_entry_exit_wrong_order(self): + def test_event_gps_entry_exit_wrong_order(self): """Test the event for wrong order.""" # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) self.assert_location_state('inner') # Enter inner2 zone - message = REGION_ENTER_MESSAGE.copy() - message['desc'] = "inner_2" + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) self.send_message(EVENT_TOPIC, message) self.assert_location_state('inner_2') # Exit inner - should still be in 'inner_2' - self.send_message(EVENT_TOPIC, REGION_LEAVE_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) self.assert_location_state('inner_2') # Exit inner_2 - should be in 'outer' - message = REGION_LEAVE_MESSAGE.copy() - message['desc'] = "inner_2" + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) self.send_message(EVENT_TOPIC, message) + self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) + self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) self.assert_location_state('outer') - def test_event_entry_unknown_zone(self): + def test_event_gps_entry_unknown_zone(self): """Test the event for unknown zone.""" # Just treat as location update - message = REGION_ENTER_MESSAGE.copy() - message['desc'] = "unknown" + message = build_message( + {'desc': "unknown"}, + REGION_GPS_ENTER_MESSAGE) self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(2.0) - self.assert_location_state('outer') + self.assert_location_latitude(REGION_GPS_ENTER_MESSAGE['lat']) + self.assert_location_state('inner') - def test_event_exit_unknown_zone(self): + def test_event_gps_exit_unknown_zone(self): """Test the event for unknown zone.""" # Just treat as location update - message = REGION_LEAVE_MESSAGE.copy() - message['desc'] = "unknown" + message = build_message( + {'desc': "unknown"}, + REGION_GPS_LEAVE_MESSAGE) self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(2.0) + self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) self.assert_location_state('outer') def test_event_entry_zone_loading_dash(self): """Test the event for zone landing.""" # Make sure the leading - is ignored # Ownracks uses this to switch on hold - message = REGION_ENTER_MESSAGE.copy() - message['desc'] = "-inner" - self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE) - + message = build_message( + {'desc': "-inner"}, + REGION_GPS_ENTER_MESSAGE) + self.send_message(EVENT_TOPIC, message) self.assert_location_state('inner') + # Region Beacon based event entry / exit testing + + def test_event_region_entry_exit(self): + """Test the entry event.""" + # Seeing a beacon named "inner" + self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) + self.assert_location_state('inner') + + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + + # Updates ignored when in a zone + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # asssociated with the inner zone regardless of GPS. + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) + self.assert_location_state('inner') + + self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # Exit switches back to GPS but the beacon has no coords + # so I am still located at the center of the inner region + # until I receive a location update. + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) + self.assert_location_state('inner') + + # Left clean zone state + self.assertFalse(self.context.regions_entered[USER]) + + # Now sending a location update moves me again. + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + self.assert_location_latitude(LOCATION_MESSAGE['lat']) + self.assert_location_accuracy(LOCATION_MESSAGE['acc']) + + def test_event_region_with_spaces(self): + """Test the entry event.""" + message = build_message({'desc': "inner 2"}, + REGION_BEACON_ENTER_MESSAGE) + self.send_message(EVENT_TOPIC, message) + self.assert_location_state('inner 2') + + message = build_message({'desc': "inner 2"}, + REGION_BEACON_LEAVE_MESSAGE) + self.send_message(EVENT_TOPIC, message) + + # Left clean zone state + self.assertFalse(self.context.regions_entered[USER]) + + def test_event_region_entry_exit_right_order(self): + """Test the event for ordering.""" + # Enter inner zone + # Set location to the outer zone. + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + + # See 'inner' region beacon + self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + self.assert_location_state('inner') + + # See 'inner_2' region beacon + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + self.send_message(EVENT_TOPIC, message) + self.assert_location_state('inner_2') + + # Exit inner_2 - should be in 'inner' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + self.send_message(EVENT_TOPIC, message) + self.assert_location_state('inner') + + # Exit inner - should be in 'outer' + self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner zone. + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) + self.assert_location_state('inner') + + def test_event_region_entry_exit_wrong_order(self): + """Test the event for wrong order.""" + # Enter inner zone + self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + self.assert_location_state('inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + self.send_message(EVENT_TOPIC, message) + self.assert_location_state('inner_2') + + # Exit inner - should still be in 'inner_2' + self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + self.assert_location_state('inner_2') + + # Exit inner_2 - should be in 'outer' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + self.send_message(EVENT_TOPIC, message) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner_2 zone. + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_accuracy(INNER_ZONE['radius']) + self.assert_location_state('inner_2') + + def test_event_beacon_unknown_zone_no_location(self): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. Except + # in this case my Device hasn't had a location message + # yet so it's in an odd state where it has state.state + # None and no GPS coords so set the beacon to. + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + self.send_message(EVENT_TOPIC, message) + + # My current state is None because I haven't seen a + # location message or a GPS or Region # Beacon event + # message. None is the state the test harness set for + # the Device during test case setup. + self.assert_location_state('None') + + # home is the state of a Device constructed through + # the normal code path on it's first observation with + # the conditions I pass along. + self.assert_mobile_tracker_state('home', 'unknown') + + def test_event_beacon_unknown_zone(self): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. First I + # set my location so that my state is 'outer' + + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + self.assert_location_state('outer') + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + self.send_message(EVENT_TOPIC, message) + + # My state is still outer and now the unknown beacon + # has joined me at outer. + self.assert_location_state('outer') + self.assert_mobile_tracker_state('outer', 'unknown') + + def test_event_beacon_entry_zone_loading_dash(self): + """Test the event for beacon zone landing.""" + # Make sure the leading - is ignored + # Ownracks uses this to switch on hold + + message = build_message( + {'desc': "-inner"}, + REGION_BEACON_ENTER_MESSAGE) + self.send_message(EVENT_TOPIC, message) + self.assert_location_state('inner') + + # ------------------------------------------------------------------------ + # Mobile Beacon based event entry / exit testing + def test_mobile_enter_move_beacon(self): """Test the movement of a beacon.""" - # Enter mobile beacon, should set location - message = REGION_ENTER_MESSAGE.copy() - message['desc'] = IBEACON_DEVICE - self.send_message(EVENT_TOPIC, message) + # I am in the outer zone. + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_tracker_latitude(2.0) - self.assert_tracker_state('outer') + # I see the 'keys' beacon. I set the location of the + # beacon_keys tracker to my current device location. + self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - # Move should move beacon - message = LOCATION_MESSAGE.copy() - message['lat'] = "3.0" - self.send_message(LOCATION_TOPIC, message) + self.assert_mobile_tracker_latitude(LOCATION_MESSAGE['lat']) + self.assert_mobile_tracker_state('outer') - self.assert_tracker_latitude(3.0) - self.assert_tracker_state(STATE_NOT_HOME) + # Location update to outside of defined zones. + # I am now 'not home' and neither are my keys. + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + + self.assert_location_state(STATE_NOT_HOME) + self.assert_mobile_tracker_state(STATE_NOT_HOME) + + not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat'] + self.assert_location_latitude(not_home_lat) + self.assert_mobile_tracker_latitude(not_home_lat) def test_mobile_enter_exit_region_beacon(self): - """Test the enter and the exit of a region beacon.""" - # Start tracking beacon - message = REGION_ENTER_MESSAGE.copy() - message['desc'] = IBEACON_DEVICE - self.send_message(EVENT_TOPIC, message) - self.assert_tracker_latitude(2.0) - self.assert_tracker_state('outer') + """Test the enter and the exit of a mobile beacon.""" + # I am in the outer zone. + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - # Enter location should move beacon - message = REGION_ENTER_MESSAGE.copy() - message['desc'] = "inner_2" - self.send_message(EVENT_TOPIC, message) + # I see a new mobile beacon + self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) + self.assert_mobile_tracker_state('outer') - self.assert_tracker_latitude(2.1) - self.assert_tracker_state('inner_2') + # GPS enter message should move beacon + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - # Exit location should switch to gps - message = REGION_LEAVE_MESSAGE.copy() - message['desc'] = "inner_2" - self.send_message(EVENT_TOPIC, message) - self.assert_tracker_latitude(2.0) + self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) + self.assert_mobile_tracker_state(REGION_GPS_ENTER_MESSAGE['desc']) + + # Exit inner zone to outer zone should move beacon to + # center of outer zone + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) + self.assert_mobile_tracker_state('outer') def test_mobile_exit_move_beacon(self): """Test the exit move of a beacon.""" - # Start tracking beacon - message = REGION_ENTER_MESSAGE.copy() - message['desc'] = IBEACON_DEVICE - self.send_message(EVENT_TOPIC, message) - self.assert_tracker_latitude(2.0) - self.assert_tracker_state('outer') + # I am in the outer zone. + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see a new mobile beacon + self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) + self.assert_mobile_tracker_state('outer') # Exit mobile beacon, should set location - message = REGION_LEAVE_MESSAGE.copy() - message['desc'] = IBEACON_DEVICE - message['lat'] = "3.0" - self.send_message(EVENT_TOPIC, message) + self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_tracker_latitude(3.0) + self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) + self.assert_mobile_tracker_state('outer') # Move after exit should do nothing - message = LOCATION_MESSAGE.copy() - message['lat'] = "4.0" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_tracker_latitude(3.0) + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) + self.assert_mobile_tracker_state('outer') def test_mobile_multiple_async_enter_exit(self): """Test the multiple entering.""" # Test race condition - enter_message = REGION_ENTER_MESSAGE.copy() - enter_message['desc'] = IBEACON_DEVICE - exit_message = REGION_LEAVE_MESSAGE.copy() - exit_message['desc'] = IBEACON_DEVICE - for _ in range(0, 20): fire_mqtt_message( - self.hass, EVENT_TOPIC, json.dumps(enter_message)) + self.hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) fire_mqtt_message( - self.hass, EVENT_TOPIC, json.dumps(exit_message)) + self.hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)) fire_mqtt_message( - self.hass, EVENT_TOPIC, json.dumps(enter_message)) + self.hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) self.hass.block_till_done() - self.send_message(EVENT_TOPIC, exit_message) - self.assertEqual(self.context.mobile_beacons_active['greg_phone'], []) + self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + self.assertEqual(len(self.context.mobile_beacons_active['greg_phone']), + 0) def test_mobile_multiple_enter_exit(self): """Test the multiple entering.""" - # Should only happen if the iphone dies - enter_message = REGION_ENTER_MESSAGE.copy() - enter_message['desc'] = IBEACON_DEVICE - exit_message = REGION_LEAVE_MESSAGE.copy() - exit_message['desc'] = IBEACON_DEVICE + self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.send_message(EVENT_TOPIC, enter_message) - self.send_message(EVENT_TOPIC, enter_message) - self.send_message(EVENT_TOPIC, exit_message) + self.assertEqual(len(self.context.mobile_beacons_active['greg_phone']), + 0) - self.assertEqual(self.context.mobile_beacons_active['greg_phone'], []) + def test_complex_movement(self): + """Test a complex sequence representative of real-world use.""" + # I am in the outer zone. + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + self.assert_location_state('outer') + + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_state('inner') + + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_state('inner') + + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) + self.assert_location_state('inner') + self.assert_mobile_tracker_state('inner') + + # Slightly odd, I leave the location by gps before I lose + # sight of the region beacon. This is also a little odd in + # that my GPS coords are now in the 'outer' zone but I did not + # "enter" that zone when I started up so my location is not + # the center of OUTER_ZONE, but rather just my GPS location. + + # gps out of inner event and location + location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) + self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) + self.assert_location_state('outer') + self.assert_mobile_tracker_state('outer') + + # region beacon leave inner + location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.assert_location_latitude(location_message['lat']) + self.assert_mobile_tracker_latitude(location_message['lat']) + self.assert_location_state('outer') + self.assert_mobile_tracker_state('outer') + + # lose keys mobile beacon + lost_keys_location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + self.send_message(LOCATION_TOPIC, lost_keys_location_message) + self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + self.assert_location_latitude(lost_keys_location_message['lat']) + self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) + self.assert_location_state('outer') + self.assert_mobile_tracker_state('outer') + + # gps leave outer + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + self.assert_location_latitude(LOCATION_MESSAGE_NOT_HOME['lat']) + self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) + self.assert_location_state('not_home') + self.assert_mobile_tracker_state('outer') + + # location move not home + location_message = build_message( + {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M, + 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M}, + LOCATION_MESSAGE_NOT_HOME) + self.send_message(LOCATION_TOPIC, location_message) + self.assert_location_latitude(location_message['lat']) + self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) + self.assert_location_state('not_home') + self.assert_mobile_tracker_state('outer') + + def test_complex_movement_sticky_keys_beacon(self): + """Test a complex sequence which was previously broken.""" + # I am not_home + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + self.assert_location_state('outer') + + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_state('inner') + + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) + self.assert_location_state('inner') + self.assert_mobile_tracker_state('inner') + + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_state('inner') + + # This sequence of moves would cause keys to follow + # greg_phone around even after the OwnTracks sent + # a mobile beacon 'leave' event for the keys. + # leave keys + self.send_message(LOCATION_TOPIC, location_message) + self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + self.assert_location_state('inner') + self.assert_mobile_tracker_state('inner') + + # leave inner region beacon + self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.assert_location_state('inner') + self.assert_mobile_tracker_state('inner') + + # enter inner region beacon + self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.assert_location_latitude(INNER_ZONE['latitude']) + self.assert_location_state('inner') + + # enter keys + self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.assert_location_state('inner') + self.assert_mobile_tracker_state('inner') + + # leave keys + self.send_message(LOCATION_TOPIC, location_message) + self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + self.assert_location_state('inner') + self.assert_mobile_tracker_state('inner') + + # leave inner region beacon + self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + self.send_message(LOCATION_TOPIC, location_message) + self.assert_location_state('inner') + self.assert_mobile_tracker_state('inner') + + # GPS leave inner region, I'm in the 'outer' region now + # but on GPS coords + leave_location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + self.send_message(LOCATION_TOPIC, leave_location_message) + self.assert_location_state('outer') + self.assert_mobile_tracker_state('inner') + self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) + self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) def test_waypoint_import_simple(self): """Test a simple import of list of waypoints.""" @@ -698,6 +1118,51 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.assertTrue(wayp == new_wayp) +def generate_ciphers(secret): + """Generate test ciphers for the DEFAULT_LOCATION_MESSAGE.""" + # libnacl ciphertext generation will fail if the module + # cannot be imported. However, the test for decryption + # also relies on this library and won't be run without it. + import json + import pickle + import base64 + + try: + from libnacl import crypto_secretbox_KEYBYTES as KEYLEN + from libnacl.secret import SecretBox + key = secret.encode("utf-8")[:KEYLEN].ljust(KEYLEN, b'\0') + ctxt = base64.b64encode(SecretBox(key).encrypt( + json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8")) + ).decode("utf-8") + except (ImportError, OSError): + ctxt = '' + + mctxt = base64.b64encode( + pickle.dumps( + (secret.encode("utf-8"), + json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8")) + ) + ).decode("utf-8") + return (ctxt, mctxt) + + +TEST_SECRET_KEY = 's3cretkey' + +CIPHERTEXT, MOCK_CIPHERTEXT = generate_ciphers(TEST_SECRET_KEY) + +ENCRYPTED_LOCATION_MESSAGE = { + # Encrypted version of LOCATION_MESSAGE using libsodium and TEST_SECRET_KEY + '_type': 'encrypted', + 'data': CIPHERTEXT +} + +MOCK_ENCRYPTED_LOCATION_MESSAGE = { + # Mock-encrypted version of LOCATION_MESSAGE using pickle + '_type': 'encrypted', + 'data': MOCK_CIPHERTEXT +} + + def mock_cipher(): """Return a dummy pickle-based cipher.""" def mock_decrypt(ciphertext, key): @@ -748,7 +1213,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): CONF_SECRET: TEST_SECRET_KEY, }}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(2.0) + self.assert_location_latitude(LOCATION_MESSAGE['lat']) @patch('homeassistant.components.device_tracker.owntracks.get_cipher', mock_cipher) @@ -762,7 +1227,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): LOCATION_TOPIC: TEST_SECRET_KEY, }}}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(2.0) + self.assert_location_latitude(LOCATION_MESSAGE['lat']) @patch('homeassistant.components.device_tracker.owntracks.get_cipher', mock_cipher) @@ -834,4 +1299,4 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): }}) self.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(2.0) + self.assert_location_latitude(LOCATION_MESSAGE['lat']) diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index df4826470d0..f424fb92647 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -15,6 +15,30 @@ DEMO_DEVICES = [{ 'action.devices.types.LIGHT', 'willReportState': False +}, { + 'id': + 'switch.ac', + 'name': { + 'name': 'AC' + }, + 'traits': [ + 'action.devices.traits.OnOff' + ], + 'type': 'action.devices.types.SWITCH', + 'willReportState': + False +}, { + 'id': + 'switch.decorative_lights', + 'name': { + 'name': 'Decorative Lights' + }, + 'traits': [ + 'action.devices.traits.OnOff' + ], + 'type': 'action.devices.types.LIGHT', # This is used for custom type + 'willReportState': + False }, { 'id': 'light.ceiling_lights', @@ -54,6 +78,14 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.Scene'], 'type': 'action.devices.types.SCENE', 'willReportState': False +}, { + 'id': 'group.all_switches', + 'name': { + 'name': 'all switches' + }, + 'traits': ['action.devices.traits.Scene'], + 'type': 'action.devices.types.SCENE', + 'willReportState': False }, { 'id': 'cover.living_room_window', @@ -170,4 +202,32 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.Scene'], 'type': 'action.devices.types.SCENE', 'willReportState': False +}, { + 'id': 'climate.hvac', + 'name': { + 'name': 'Hvac' + }, + 'traits': ['action.devices.traits.TemperatureSetting'], + 'type': 'action.devices.types.THERMOSTAT', + 'willReportState': False, + 'attributes': { + 'availableThermostatModes': 'heat,cool,off', + 'thermostatTemperatureUnit': 'C', + }, +}, { + 'id': 'climate.heatpump', + 'name': { + 'name': 'HeatPump' + }, + 'traits': ['action.devices.traits.TemperatureSetting'], + 'type': 'action.devices.types.THERMOSTAT', + 'willReportState': False +}, { + 'id': 'climate.ecobee', + 'name': { + 'name': 'Ecobee' + }, + 'traits': ['action.devices.traits.TemperatureSetting'], + 'type': 'action.devices.types.THERMOSTAT', + 'willReportState': False }] diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 5a7cac6afc2..35be79469a9 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -6,7 +6,7 @@ import pytest from homeassistant import setup, const, core from homeassistant.components import ( - http, async_setup, light, cover, media_player, fan + http, async_setup, light, cover, media_player, fan, switch, climate ) from homeassistant.components import google_assistant as ga from tests.common import get_test_instance_port @@ -45,7 +45,7 @@ def assistant_client(loop, hass_fixture, test_client): @pytest.fixture def hass_fixture(loop, hass): - """Setup a hass instance for these tests.""" + """Set up a hass instance for these tests.""" # We need to do this to get access to homeassistant/turn_(on,off) loop.run_until_complete(async_setup(hass, {core.DOMAIN: {}})) @@ -62,6 +62,12 @@ def hass_fixture(loop, hass): 'platform': 'demo' }] })) + loop.run_until_complete( + setup.async_setup_component(hass, switch.DOMAIN, { + 'switch': [{ + 'platform': 'demo' + }] + })) loop.run_until_complete( setup.async_setup_component(hass, cover.DOMAIN, { 'cover': [{ @@ -83,6 +89,13 @@ def hass_fixture(loop, hass): }] })) + loop.run_until_complete( + setup.async_setup_component(hass, climate.DOMAIN, { + 'climate': [{ + 'platform': 'demo' + }] + })) + # Kitchen light is explicitly excluded from being exposed ceiling_lights_entity = hass.states.get('light.ceiling_lights') attrs = dict(ceiling_lights_entity.attributes) @@ -93,6 +106,16 @@ def hass_fixture(loop, hass): ceiling_lights_entity.state, attributes=attrs) + # By setting the google_assistant_type = 'light' + # we can override how a device is reported to GA + switch_light = hass.states.get('switch.decorative_lights') + attrs = dict(switch_light.attributes) + attrs[ga.const.ATTR_GOOGLE_ASSISTANT_TYPE] = "light" + hass.states.async_set( + switch_light.entity_id, + switch_light.state, + attributes=attrs) + return hass @@ -126,15 +149,18 @@ def test_sync_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid devices = body['payload']['devices'] - # assert len(devices) == 4 - assert len(devices) == len(DEMO_DEVICES) - # HACK this is kind of slow and lazy - for dev in devices: - for demo in DEMO_DEVICES: - if dev['id'] == demo['id']: - assert dev['name'] == demo['name'] - assert set(dev['traits']) == set(demo['traits']) - assert dev['type'] == demo['type'] + assert ( + sorted([dev['id'] for dev in devices]) + == sorted([dev['id'] for dev in DEMO_DEVICES])) + + for dev, demo in zip( + sorted(devices, key=lambda d: d['id']), + sorted(DEMO_DEVICES, key=lambda d: d['id'])): + assert dev['name'] == demo['name'] + assert set(dev['traits']) == set(demo['traits']) + assert dev['type'] == demo['type'] + if 'attributes' in demo: + assert dev['attributes'] == demo['attributes'] @asyncio.coroutine @@ -188,6 +214,8 @@ def test_execute_request(hass_fixture, assistant_client): "commands": [{ "devices": [{ "id": "light.ceiling_lights", + }, { + "id": "switch.decorative_lights", }, { "id": "light.bed_light", }], @@ -209,6 +237,7 @@ def test_execute_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid commands = body['payload']['commands'] - assert len(commands) == 2 + assert len(commands) == 3 ceiling = hass_fixture.states.get('light.ceiling_lights') assert ceiling.state == 'off' + assert hass_fixture.states.get('switch.decorative_lights').state == 'off' diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 9b3c5eab037..20db85b998e 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -3,6 +3,7 @@ import asyncio from homeassistant import const +from homeassistant.components import climate from homeassistant.components import google_assistant as ga DETERMINE_SERVICE_TESTS = [{ # Test light brightness @@ -15,6 +16,16 @@ DETERMINE_SERVICE_TESTS = [{ # Test light brightness const.SERVICE_TURN_ON, {'entity_id': 'light.test', 'brightness': 242} ) +}, { # Test switch to light custom type + 'entity_id': 'switch.decorative_lights', + 'command': ga.const.COMMAND_ONOFF, + 'params': { + 'on': True + }, + 'expected': ( + const.SERVICE_TURN_ON, + {'entity_id': 'switch.decorative_lights'} + ) }, { # Test light on / off 'entity_id': 'light.test', 'command': ga.const.COMMAND_ONOFF, @@ -63,6 +74,34 @@ DETERMINE_SERVICE_TESTS = [{ # Test light brightness const.SERVICE_VOLUME_SET, {'entity_id': 'media_player.living_room', 'volume_level': 0.3} ), +}, { # Test climate temperature + 'entity_id': 'climate.living_room', + 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, + 'params': {'thermostatTemperatureSetpoint': 24.5}, + 'expected': ( + climate.SERVICE_SET_TEMPERATURE, + {'entity_id': 'climate.living_room', 'temperature': 24.5} + ), +}, { # Test climate temperature range + 'entity_id': 'climate.living_room', + 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, + 'params': { + 'thermostatTemperatureSetpointHigh': 24.5, + 'thermostatTemperatureSetpointLow': 20.5, + }, + 'expected': ( + climate.SERVICE_SET_TEMPERATURE, + {'entity_id': 'climate.living_room', + 'target_temp_high': 24.5, 'target_temp_low': 20.5} + ), +}, { # Test climate operation mode + 'entity_id': 'climate.living_room', + 'command': ga.const.COMMAND_THERMOSTAT_SET_MODE, + 'params': {'thermostatMode': 'heat'}, + 'expected': ( + climate.SERVICE_SET_OPERATION_MODE, + {'entity_id': 'climate.living_room', 'operation_mode': 'heat'} + ), }] diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index e111fc3aa49..db7c35107d8 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -677,3 +677,120 @@ class TestLightMQTT(unittest.TestCase): state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) self.assertEqual([1, 1], state.attributes.get('xy_color')) + + def test_on_command_first(self): + """Test on command being sent before brightness.""" + config = {light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test_light/set', + 'brightness_command_topic': 'test_light/bright', + 'on_command_type': 'first', + }} + + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, config) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + light.turn_on(self.hass, 'light.test', brightness=50) + self.hass.block_till_done() + + # Should get the following MQTT messages. + # test_light/set: 'ON' + # test_light/bright: 50 + self.assertEqual(('test_light/set', 'ON', 0, False), + self.mock_publish.mock_calls[-4][1]) + self.assertEqual(('test_light/bright', 50, 0, False), + self.mock_publish.mock_calls[-2][1]) + + light.turn_off(self.hass, 'light.test') + self.hass.block_till_done() + + self.assertEqual(('test_light/set', 'OFF', 0, False), + self.mock_publish.mock_calls[-2][1]) + + def test_on_command_last(self): + """Test on command being sent after brightness.""" + config = {light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test_light/set', + 'brightness_command_topic': 'test_light/bright', + }} + + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, config) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + light.turn_on(self.hass, 'light.test', brightness=50) + self.hass.block_till_done() + + # Should get the following MQTT messages. + # test_light/bright: 50 + # test_light/set: 'ON' + self.assertEqual(('test_light/bright', 50, 0, False), + self.mock_publish.mock_calls[-4][1]) + self.assertEqual(('test_light/set', 'ON', 0, False), + self.mock_publish.mock_calls[-2][1]) + + light.turn_off(self.hass, 'light.test') + self.hass.block_till_done() + + self.assertEqual(('test_light/set', 'OFF', 0, False), + self.mock_publish.mock_calls[-2][1]) + + def test_on_command_brightness(self): + """Test on command being sent as only brightness.""" + config = {light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test_light/set', + 'brightness_command_topic': 'test_light/bright', + 'rgb_command_topic': "test_light/rgb", + 'on_command_type': 'brightness', + }} + + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, config) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # Turn on w/ no brightness - should set to max + light.turn_on(self.hass, 'light.test') + self.hass.block_till_done() + + # Should get the following MQTT messages. + # test_light/bright: 255 + self.assertEqual(('test_light/bright', 255, 0, False), + self.mock_publish.mock_calls[-2][1]) + + light.turn_off(self.hass, 'light.test') + self.hass.block_till_done() + + self.assertEqual(('test_light/set', 'OFF', 0, False), + self.mock_publish.mock_calls[-2][1]) + + # Turn on w/ brightness + light.turn_on(self.hass, 'light.test', brightness=50) + self.hass.block_till_done() + + self.assertEqual(('test_light/bright', 50, 0, False), + self.mock_publish.mock_calls[-2][1]) + + light.turn_off(self.hass, 'light.test') + self.hass.block_till_done() + + # Turn on w/ just a color to insure brightness gets + # added and sent. + light.turn_on(self.hass, 'light.test', rgb_color=[75, 75, 75]) + self.hass.block_till_done() + + self.assertEqual(('test_light/rgb', '75,75,75', 0, False), + self.mock_publish.mock_calls[-4][1]) + self.assertEqual(('test_light/bright', 50, 0, False), + self.mock_publish.mock_calls[-2][1]) diff --git a/tests/components/light/test_template.py b/tests/components/light/test_template.py index 5c32a1050a2..aaac0617590 100644 --- a/tests/components/light/test_template.py +++ b/tests/components/light/test_template.py @@ -625,3 +625,97 @@ class TestTemplateLight: assert state is not None assert state.attributes.get('friendly_name') == 'Template light' + + def test_icon_template(self): + """Test icon template.""" + with assert_setup_component(1, 'light'): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'friendly_name': 'Template light', + 'value_template': "{{ 1 == 1 }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + }, + 'icon_template': + "{% if states.light.test_state.state %}" + "mdi:check" + "{% endif %}" + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.attributes.get('icon') == '' + + state = self.hass.states.set('light.test_state', STATE_ON) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + + assert state.attributes['icon'] == 'mdi:check' + + def test_entity_picture_template(self): + """Test entity_picture template.""" + with assert_setup_component(1, 'light'): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'friendly_name': 'Template light', + 'value_template': "{{ 1 == 1 }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + }, + 'entity_picture_template': + "{% if states.light.test_state.state %}" + "/local/light.png" + "{% endif %}" + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.attributes.get('entity_picture') == '' + + state = self.hass.states.set('light.test_state', STATE_ON) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + + assert state.attributes['entity_picture'] == '/local/light.png' diff --git a/tests/components/media_player/test_monoprice.py b/tests/components/media_player/test_monoprice.py index 451b6b51feb..439b272fd4a 100644 --- a/tests/components/media_player/test_monoprice.py +++ b/tests/components/media_player/test_monoprice.py @@ -219,6 +219,12 @@ class TestMonopriceMediaPlayer(unittest.TestCase): self.media_player.update() self.assertEqual('one', self.media_player.source) + def test_media_title(self): + """Test media title property.""" + self.assertIsNone(self.media_player.media_title) + self.media_player.update() + self.assertEqual('one', self.media_player.media_title) + def test_source_list(self): """Test source list property.""" # Note, the list is sorted! diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 75caae0015c..df780675a18 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -29,7 +29,8 @@ class TestPersistentNotification: assert len(entity_ids) == 1 state = self.hass.states.get(entity_ids[0]) - assert state.state == 'Hello World 2' + assert state.state == pn.STATE + assert state.attributes.get('message') == 'Hello World 2' assert state.attributes.get('title') == '2 beers' def test_create_notification_id(self): @@ -41,7 +42,7 @@ class TestPersistentNotification: assert len(self.hass.states.entity_ids()) == 1 state = self.hass.states.get('persistent_notification.beer_2') - assert state.state == 'test' + assert state.attributes.get('message') == 'test' pn.create(self.hass, 'test 2', notification_id='Beer 2') self.hass.block_till_done() @@ -49,7 +50,7 @@ class TestPersistentNotification: # We should have overwritten old one assert len(self.hass.states.entity_ids()) == 1 state = self.hass.states.get('persistent_notification.beer_2') - assert state.state == 'test 2' + assert state.attributes.get('message') == 'test 2' def test_create_template_error(self): """Ensure we output templates if contain error.""" @@ -62,7 +63,7 @@ class TestPersistentNotification: assert len(entity_ids) == 1 state = self.hass.states.get(entity_ids[0]) - assert state.state == '{{ message + 1 }}' + assert state.attributes.get('message') == '{{ message + 1 }}' assert state.attributes.get('title') == '{{ title + 1 }}' def test_dismiss_notification(self): diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index ed04e96a43c..58b8dc1f839 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -195,7 +195,8 @@ def test_recorder_setup_failure(): with patch.object(Recorder, '_setup_connection') as setup, \ patch('homeassistant.components.recorder.time.sleep'): setup.side_effect = ImportError("driver not found") - rec = Recorder(hass, uri='sqlite://', include={}, exclude={}) + rec = Recorder(hass, keep_days=7, purge_interval=2, + uri='sqlite://', include={}, exclude={}) rec.start() rec.join() diff --git a/tests/components/sensor/test_coinmarketcap.py b/tests/components/sensor/test_coinmarketcap.py new file mode 100644 index 00000000000..15c254bfb27 --- /dev/null +++ b/tests/components/sensor/test_coinmarketcap.py @@ -0,0 +1,44 @@ +"""Tests for the CoinMarketCap sensor platform.""" +import json + +import unittest +from unittest.mock import patch + +import homeassistant.components.sensor as sensor +from homeassistant.setup import setup_component +from tests.common import ( + get_test_home_assistant, load_fixture, assert_setup_component) + +VALID_CONFIG = { + 'platform': 'coinmarketcap', + 'currency': 'ethereum', + 'display_currency': 'EUR', +} + + +class TestCoinMarketCapSensor(unittest.TestCase): + """Test the CoinMarketCap sensor.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('coinmarketcap.Market.ticker', + return_value=json.loads(load_fixture('coinmarketcap.json'))) + def test_setup(self, mock_request): + """Test the setup with custom settings.""" + with assert_setup_component(1, sensor.DOMAIN): + assert setup_component(self.hass, sensor.DOMAIN, { + 'sensor': VALID_CONFIG}) + + state = self.hass.states.get('sensor.ethereum') + assert state is not None + + assert state.state == '240.47' + assert state.attributes.get('symbol') == 'ETH' + assert state.attributes.get('unit_of_measurement') == 'EUR' diff --git a/tests/components/sensor/test_fail2ban.py b/tests/components/sensor/test_fail2ban.py new file mode 100644 index 00000000000..a6131e5dbc6 --- /dev/null +++ b/tests/components/sensor/test_fail2ban.py @@ -0,0 +1,220 @@ +"""The tests for local file sensor platform.""" +import unittest +from unittest.mock import Mock, patch + +from datetime import timedelta +from mock_open import MockOpen + +from homeassistant.setup import setup_component +from homeassistant.components.sensor.fail2ban import ( + BanSensor, BanLogParser, STATE_CURRENT_BANS, STATE_ALL_BANS +) + +from tests.common import get_test_home_assistant, assert_setup_component + + +def fake_log(log_key): + """Return a fake fail2ban log.""" + fake_log_dict = { + 'single_ban': ( + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Ban 111.111.111.111' + ), + 'multi_ban': ( + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Ban 111.111.111.111\n' + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Ban 222.222.222.222' + ), + 'multi_jail': ( + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Ban 111.111.111.111\n' + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_two] Ban 222.222.222.222' + ), + 'unban_all': ( + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Ban 111.111.111.111\n' + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Unban 111.111.111.111\n' + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Ban 222.222.222.222\n' + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Unban 222.222.222.222' + ), + 'unban_one': ( + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Ban 111.111.111.111\n' + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Ban 222.222.222.222\n' + '2017-01-01 12:23:35 fail2ban.actions [111]: ' + 'NOTICE [jail_one] Unban 111.111.111.111' + ) + } + return fake_log_dict[log_key] + + +class TestBanSensor(unittest.TestCase): + """Test the fail2ban sensor.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('os.path.isfile', Mock(return_value=True)) + def test_setup(self): + """Test that sensor can be setup.""" + config = { + 'sensor': { + 'platform': 'fail2ban', + 'jails': ['jail_one'] + } + } + mock_fh = MockOpen() + with patch('homeassistant.components.sensor.fail2ban.open', mock_fh, + create=True): + assert setup_component(self.hass, 'sensor', config) + self.hass.block_till_done() + assert_setup_component(1, 'sensor') + + @patch('os.path.isfile', Mock(return_value=True)) + def test_multi_jails(self): + """Test that multiple jails can be set up as sensors..""" + config = { + 'sensor': { + 'platform': 'fail2ban', + 'jails': ['jail_one', 'jail_two'] + } + } + mock_fh = MockOpen() + with patch('homeassistant.components.sensor.fail2ban.open', mock_fh, + create=True): + assert setup_component(self.hass, 'sensor', config) + self.hass.block_till_done() + assert_setup_component(2, 'sensor') + + def test_single_ban(self): + """Test that log is parsed correctly for single ban.""" + log_parser = BanLogParser(timedelta(seconds=-1), '/tmp') + sensor = BanSensor('fail2ban', 'jail_one', log_parser) + self.assertEqual(sensor.name, 'fail2ban jail_one') + mock_fh = MockOpen(read_data=fake_log('single_ban')) + with patch('homeassistant.components.sensor.fail2ban.open', mock_fh, + create=True): + sensor.update() + + self.assertEqual(sensor.state, '111.111.111.111') + self.assertEqual( + sensor.state_attributes[STATE_CURRENT_BANS], ['111.111.111.111'] + ) + self.assertEqual( + sensor.state_attributes[STATE_ALL_BANS], ['111.111.111.111'] + ) + + def test_multiple_ban(self): + """Test that log is parsed correctly for multiple ban.""" + log_parser = BanLogParser(timedelta(seconds=-1), '/tmp') + sensor = BanSensor('fail2ban', 'jail_one', log_parser) + self.assertEqual(sensor.name, 'fail2ban jail_one') + mock_fh = MockOpen(read_data=fake_log('multi_ban')) + with patch('homeassistant.components.sensor.fail2ban.open', mock_fh, + create=True): + sensor.update() + + self.assertEqual(sensor.state, '222.222.222.222') + self.assertEqual( + sensor.state_attributes[STATE_CURRENT_BANS], + ['111.111.111.111', '222.222.222.222'] + ) + self.assertEqual( + sensor.state_attributes[STATE_ALL_BANS], + ['111.111.111.111', '222.222.222.222'] + ) + + def test_unban_all(self): + """Test that log is parsed correctly when unbanning.""" + log_parser = BanLogParser(timedelta(seconds=-1), '/tmp') + sensor = BanSensor('fail2ban', 'jail_one', log_parser) + self.assertEqual(sensor.name, 'fail2ban jail_one') + mock_fh = MockOpen(read_data=fake_log('unban_all')) + with patch('homeassistant.components.sensor.fail2ban.open', mock_fh, + create=True): + sensor.update() + + self.assertEqual(sensor.state, 'None') + self.assertEqual(sensor.state_attributes[STATE_CURRENT_BANS], []) + self.assertEqual( + sensor.state_attributes[STATE_ALL_BANS], + ['111.111.111.111', '222.222.222.222'] + ) + + def test_unban_one(self): + """Test that log is parsed correctly when unbanning one ip.""" + log_parser = BanLogParser(timedelta(seconds=-1), '/tmp') + sensor = BanSensor('fail2ban', 'jail_one', log_parser) + self.assertEqual(sensor.name, 'fail2ban jail_one') + mock_fh = MockOpen(read_data=fake_log('unban_one')) + with patch('homeassistant.components.sensor.fail2ban.open', mock_fh, + create=True): + sensor.update() + + self.assertEqual(sensor.state, '222.222.222.222') + self.assertEqual( + sensor.state_attributes[STATE_CURRENT_BANS], + ['222.222.222.222'] + ) + self.assertEqual( + sensor.state_attributes[STATE_ALL_BANS], + ['111.111.111.111', '222.222.222.222'] + ) + + def test_multi_jail(self): + """Test that log is parsed correctly when using multiple jails.""" + log_parser = BanLogParser(timedelta(seconds=-1), '/tmp') + sensor1 = BanSensor('fail2ban', 'jail_one', log_parser) + sensor2 = BanSensor('fail2ban', 'jail_two', log_parser) + self.assertEqual(sensor1.name, 'fail2ban jail_one') + self.assertEqual(sensor2.name, 'fail2ban jail_two') + mock_fh = MockOpen(read_data=fake_log('multi_jail')) + with patch('homeassistant.components.sensor.fail2ban.open', mock_fh, + create=True): + sensor1.update() + sensor2.update() + + self.assertEqual(sensor1.state, '111.111.111.111') + self.assertEqual( + sensor1.state_attributes[STATE_CURRENT_BANS], ['111.111.111.111'] + ) + self.assertEqual( + sensor1.state_attributes[STATE_ALL_BANS], ['111.111.111.111'] + ) + self.assertEqual(sensor2.state, '222.222.222.222') + self.assertEqual( + sensor2.state_attributes[STATE_CURRENT_BANS], ['222.222.222.222'] + ) + self.assertEqual( + sensor2.state_attributes[STATE_ALL_BANS], ['222.222.222.222'] + ) + + def test_ban_active_after_update(self): + """Test that ban persists after subsequent update.""" + log_parser = BanLogParser(timedelta(seconds=-1), '/tmp') + sensor = BanSensor('fail2ban', 'jail_one', log_parser) + self.assertEqual(sensor.name, 'fail2ban jail_one') + mock_fh = MockOpen(read_data=fake_log('single_ban')) + with patch('homeassistant.components.sensor.fail2ban.open', mock_fh, + create=True): + sensor.update() + self.assertEqual(sensor.state, '111.111.111.111') + sensor.update() + self.assertEqual(sensor.state, '111.111.111.111') + self.assertEqual( + sensor.state_attributes[STATE_CURRENT_BANS], ['111.111.111.111'] + ) + self.assertEqual( + sensor.state_attributes[STATE_ALL_BANS], ['111.111.111.111'] + ) diff --git a/tests/components/sensor/test_hddtemp.py b/tests/components/sensor/test_hddtemp.py new file mode 100644 index 00000000000..35d1c08c08a --- /dev/null +++ b/tests/components/sensor/test_hddtemp.py @@ -0,0 +1,195 @@ +"""The tests for the hddtemp platform.""" +import unittest +from unittest.mock import patch + +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant + +VALID_CONFIG_MINIMAL = { + 'sensor': { + 'platform': 'hddtemp', + } +} + +VALID_CONFIG_NAME = { + 'sensor': { + 'platform': 'hddtemp', + 'name': 'FooBar', + } +} + +VALID_CONFIG_ONE_DISK = { + 'sensor': { + 'platform': 'hddtemp', + 'disks': [ + '/dev/sdd1', + ], + } +} + +VALID_CONFIG_WRONG_DISK = { + 'sensor': { + 'platform': 'hddtemp', + 'disks': [ + '/dev/sdx1', + ], + } +} + +VALID_CONFIG_MULTIPLE_DISKS = { + 'sensor': { + 'platform': 'hddtemp', + 'host': 'foobar.local', + 'disks': [ + '/dev/sda1', + '/dev/sdb1', + '/dev/sdc1', + ], + } +} + +VALID_CONFIG_HOST = { + 'sensor': { + 'platform': 'hddtemp', + 'host': 'alice.local', + } +} + + +class TelnetMock(): + """Mock class for the telnetlib.Telnet object.""" + + def __init__(self, host, port, timeout=0): + """Initialize Telnet object.""" + self.host = host + self.port = port + self.timeout = timeout + self.sample_data = bytes('|/dev/sda1|WDC WD30EZRX-12DC0B0|29|C|' + + '|/dev/sdb1|WDC WD15EADS-11P7B2|32|C|' + + '|/dev/sdc1|WDC WD20EARX-22MMMB0|29|C|' + + '|/dev/sdd1|WDC WD15EARS-00Z5B1|89|F|', + 'ascii') + + def read_all(self): + """Return sample values.""" + if self.host == 'alice.local': + raise ConnectionRefusedError + else: + return self.sample_data + return None + + +class TestHDDTempSensor(unittest.TestCase): + """Test the hddtemp sensor.""" + + def setUp(self): + """Set up things to run when tests begin.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG_ONE_DISK + self.reference = {'/dev/sda1': {'device': '/dev/sda1', + 'temperature': '29', + 'unit_of_measurement': '°C', + 'model': 'WDC WD30EZRX-12DC0B0', }, + '/dev/sdb1': {'device': '/dev/sdb1', + 'temperature': '32', + 'unit_of_measurement': '°C', + 'model': 'WDC WD15EADS-11P7B2', }, + '/dev/sdc1': {'device': '/dev/sdc1', + 'temperature': '29', + 'unit_of_measurement': '°C', + 'model': 'WDC WD20EARX-22MMMB0', }, + '/dev/sdd1': {'device': '/dev/sdd1', + 'temperature': '32', + 'unit_of_measurement': '°C', + 'model': 'WDC WD15EARS-00Z5B1', }, } + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('telnetlib.Telnet', new=TelnetMock) + def test_hddtemp_min_config(self): + """Test minimal hddtemp configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL) + + entity = self.hass.states.all()[0].entity_id + state = self.hass.states.get(entity) + + reference = self.reference[state.attributes.get('device')] + + self.assertEqual(state.state, reference['temperature']) + self.assertEqual(state.attributes.get('device'), reference['device']) + self.assertEqual(state.attributes.get('model'), reference['model']) + self.assertEqual(state.attributes.get('unit_of_measurement'), + reference['unit_of_measurement']) + self.assertEqual(state.attributes.get('friendly_name'), + 'HD Temperature ' + reference['device']) + + @patch('telnetlib.Telnet', new=TelnetMock) + def test_hddtemp_rename_config(self): + """Test hddtemp configuration with different name.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_NAME) + + entity = self.hass.states.all()[0].entity_id + state = self.hass.states.get(entity) + + reference = self.reference[state.attributes.get('device')] + + self.assertEqual(state.attributes.get('friendly_name'), + 'FooBar ' + reference['device']) + + @patch('telnetlib.Telnet', new=TelnetMock) + def test_hddtemp_one_disk(self): + """Test hddtemp one disk configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_ONE_DISK) + + state = self.hass.states.get('sensor.hd_temperature_devsdd1') + + reference = self.reference[state.attributes.get('device')] + + self.assertEqual(state.state, reference['temperature']) + self.assertEqual(state.attributes.get('device'), reference['device']) + self.assertEqual(state.attributes.get('model'), reference['model']) + self.assertEqual(state.attributes.get('unit_of_measurement'), + reference['unit_of_measurement']) + self.assertEqual(state.attributes.get('friendly_name'), + 'HD Temperature ' + reference['device']) + + @patch('telnetlib.Telnet', new=TelnetMock) + def test_hddtemp_wrong_disk(self): + """Test hddtemp wrong disk configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_WRONG_DISK) + + self.assertEqual(len(self.hass.states.all()), 0) + + @patch('telnetlib.Telnet', new=TelnetMock) + def test_hddtemp_multiple_disks(self): + """Test hddtemp multiple disk configuration.""" + assert setup_component(self.hass, + 'sensor', VALID_CONFIG_MULTIPLE_DISKS) + + for sensor in ['sensor.hd_temperature_devsda1', + 'sensor.hd_temperature_devsdb1', + 'sensor.hd_temperature_devsdc1']: + + state = self.hass.states.get(sensor) + + reference = self.reference[state.attributes.get('device')] + + self.assertEqual(state.state, + reference['temperature']) + self.assertEqual(state.attributes.get('device'), + reference['device']) + self.assertEqual(state.attributes.get('model'), + reference['model']) + self.assertEqual(state.attributes.get('unit_of_measurement'), + reference['unit_of_measurement']) + self.assertEqual(state.attributes.get('friendly_name'), + 'HD Temperature ' + reference['device']) + + @patch('telnetlib.Telnet', new=TelnetMock) + def test_hddtemp_host_unreachable(self): + """Test hddtemp if host unreachable.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_HOST) + self.assertEqual(len(self.hass.states.all()), 0) diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index a80477d4bb7..a083dbfb1a2 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -45,18 +45,18 @@ class TestRestSensorSetup(unittest.TestCase): side_effect=requests.exceptions.ConnectionError()) def test_setup_failed_connect(self, mock_req): """Test setup when connection error occurs.""" - self.assertFalse(rest.setup_platform(self.hass, { + self.assertTrue(rest.setup_platform(self.hass, { 'platform': 'rest', 'resource': 'http://localhost', - }, None)) + }, lambda devices, update=True: None) is None) @patch('requests.Session.send', side_effect=Timeout()) def test_setup_timeout(self, mock_req): """Test setup when connection timeout occurs.""" - self.assertFalse(rest.setup_platform(self.hass, { + self.assertTrue(rest.setup_platform(self.hass, { 'platform': 'rest', 'resource': 'http://localhost', - }, None)) + }, lambda devices, update=True: None) is None) @requests_mock.Mocker() def test_setup_minimum(self, mock_req): @@ -165,6 +165,7 @@ class TestRestSensor(unittest.TestCase): 'rest.RestData.update', side_effect=self.update_side_effect(None)) self.sensor.update() self.assertEqual(STATE_UNKNOWN, self.sensor.state) + self.assertFalse(self.sensor.available) def test_update_when_value_changed(self): """Test state gets updated when sensor returns a new status.""" @@ -173,6 +174,7 @@ class TestRestSensor(unittest.TestCase): '{ "key": "updated_state" }')) self.sensor.update() self.assertEqual('updated_state', self.sensor.state) + self.assertTrue(self.sensor.available) def test_update_with_no_template(self): """Test update when there is no value template.""" @@ -183,6 +185,7 @@ class TestRestSensor(unittest.TestCase): self.hass, self.rest, self.name, self.unit_of_measurement, None) self.sensor.update() self.assertEqual('plain_state', self.sensor.state) + self.assertTrue(self.sensor.available) class TestRestData(unittest.TestCase): diff --git a/tests/components/sensor/test_ring.py b/tests/components/sensor/test_ring.py index ada1992ac0c..fb31dc7c53c 100644 --- a/tests/components/sensor/test_ring.py +++ b/tests/components/sensor/test_ring.py @@ -38,7 +38,9 @@ class TestRingSensorSetup(unittest.TestCase): 'last_activity', 'last_ding', 'last_motion', - 'volume'] + 'volume', + 'wifi_signal_category', + 'wifi_signal_strength'] } def tearDown(self): @@ -55,6 +57,10 @@ class TestRingSensorSetup(unittest.TestCase): text=load_fixture('ring_devices.json')) mock.get('https://api.ring.com/clients_api/doorbots/987652/history', text=load_fixture('ring_doorbots.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987652/health', + text=load_fixture('ring_doorboot_health_attrs.json')) + mock.get('https://api.ring.com/clients_api/chimes/999999/health', + text=load_fixture('ring_chime_health_attrs.json')) base_ring.setup(self.hass, VALID_CONFIG) ring.setup_platform(self.hass, self.config, @@ -63,6 +69,12 @@ class TestRingSensorSetup(unittest.TestCase): for device in self.DEVICES: device.update() + if device.name == 'Front Battery': + self.assertEqual(80, device.state) + self.assertEqual('hp_cam_v1', + device.device_state_attributes['kind']) + self.assertEqual('stickup_cams', + device.device_state_attributes['type']) if device.name == 'Front Door Battery': self.assertEqual(100, device.state) self.assertEqual('lpd_v1', @@ -73,6 +85,8 @@ class TestRingSensorSetup(unittest.TestCase): self.assertEqual(2, device.state) self.assertEqual('1.2.3', device.device_state_attributes['firmware']) + self.assertEqual('ring_mock_wifi', + device.device_state_attributes['wifi_name']) self.assertEqual('mdi:bell-ring', device.icon) self.assertEqual('chimes', device.device_state_attributes['type']) @@ -81,6 +95,15 @@ class TestRingSensorSetup(unittest.TestCase): self.assertEqual('America/New_York', device.device_state_attributes['timezone']) + if device.name == 'Downstairs WiFi Signal Strength': + self.assertEqual(-39, device.state) + + if device.name == 'Front Door WiFi Signal Category': + self.assertEqual('good', device.state) + + if device.name == 'Front Door WiFi Signal Strength': + self.assertEqual(-58, device.state) + self.assertIsNone(device.entity_picture) self.assertEqual(ATTRIBUTION, device.device_state_attributes['attribution']) diff --git a/tests/components/sensor/test_season.py b/tests/components/sensor/test_season.py index 10e147bcff9..9dda0d2f2cb 100644 --- a/tests/components/sensor/test_season.py +++ b/tests/components/sensor/test_season.py @@ -3,11 +3,55 @@ import unittest from datetime import datetime +from homeassistant.setup import setup_component import homeassistant.components.sensor.season as season from tests.common import get_test_home_assistant +HEMISPHERE_NORTHERN = { + 'homeassistant': { + 'latitude': '48.864716', + 'longitude': '2.349014', + }, + 'sensor': { + 'platform': 'season', + 'type': 'astronomical', + } +} + +HEMISPHERE_SOUTHERN = { + 'homeassistant': { + 'latitude': '-33.918861', + 'longitude': '18.423300', + }, + 'sensor': { + 'platform': 'season', + 'type': 'astronomical', + } +} + +HEMISPHERE_EQUATOR = { + 'homeassistant': { + 'latitude': '0', + 'longitude': '-51.065100', + }, + 'sensor': { + 'platform': 'season', + 'type': 'astronomical', + } +} + +HEMISPHERE_EMPTY = { + 'homeassistant': { + }, + 'sensor': { + 'platform': 'season', + 'type': 'meteorological', + } +} + + # pylint: disable=invalid-name class TestSeason(unittest.TestCase): """Test the season platform.""" @@ -181,3 +225,39 @@ class TestSeason(unittest.TestCase): season.EQUATOR, season.TYPE_ASTRONOMICAL) self.assertEqual(None, current_season) + + def test_setup_hemisphere_northern(self): + """Test platform setup of northern hemisphere.""" + self.hass.config.latitude = HEMISPHERE_NORTHERN[ + 'homeassistant']['latitude'] + assert setup_component(self.hass, 'sensor', HEMISPHERE_NORTHERN) + self.assertEqual(self.hass.config.as_dict()['latitude'], + HEMISPHERE_NORTHERN['homeassistant']['latitude']) + state = self.hass.states.get('sensor.season') + self.assertEqual(state.attributes.get('friendly_name'), 'Season') + + def test_setup_hemisphere_southern(self): + """Test platform setup of southern hemisphere.""" + self.hass.config.latitude = HEMISPHERE_SOUTHERN[ + 'homeassistant']['latitude'] + assert setup_component(self.hass, 'sensor', HEMISPHERE_SOUTHERN) + self.assertEqual(self.hass.config.as_dict()['latitude'], + HEMISPHERE_SOUTHERN['homeassistant']['latitude']) + state = self.hass.states.get('sensor.season') + self.assertEqual(state.attributes.get('friendly_name'), 'Season') + + def test_setup_hemisphere_equator(self): + """Test platform setup of equator.""" + self.hass.config.latitude = HEMISPHERE_EQUATOR[ + 'homeassistant']['latitude'] + assert setup_component(self.hass, 'sensor', HEMISPHERE_EQUATOR) + self.assertEqual(self.hass.config.as_dict()['latitude'], + HEMISPHERE_EQUATOR['homeassistant']['latitude']) + state = self.hass.states.get('sensor.season') + self.assertEqual(state.attributes.get('friendly_name'), 'Season') + + def test_setup_hemisphere_empty(self): + """Test platform setup of missing latlong.""" + self.hass.config.latitude = None + assert setup_component(self.hass, 'sensor', HEMISPHERE_EMPTY) + self.assertEqual(self.hass.config.as_dict()['latitude'], None) diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index 5e6a4957c04..3033b41b142 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -74,6 +74,36 @@ class TestTemplateSensor: state = self.hass.states.get('sensor.test_template_sensor') assert state.attributes['icon'] == 'mdi:check' + def test_entity_picture_template(self): + """Test entity_picture template.""" + with assert_setup_component(1): + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'test_template_sensor': { + 'value_template': "State", + 'entity_picture_template': + "{% if states.sensor.test_state.state == " + "'Works' %}" + "/local/sensor.png" + "{% endif %}" + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test_template_sensor') + assert state.attributes.get('entity_picture') == '' + + self.hass.states.set('sensor.test_state', 'Works') + self.hass.block_till_done() + state = self.hass.states.get('sensor.test_template_sensor') + assert state.attributes['entity_picture'] == '/local/sensor.png' + def test_template_syntax_error(self): """Test templating syntax error.""" with assert_setup_component(0): diff --git a/tests/components/sensor/test_uptime.py b/tests/components/sensor/test_uptime.py index 991ecd3960b..541ea7ca771 100644 --- a/tests/components/sensor/test_uptime.py +++ b/tests/components/sensor/test_uptime.py @@ -21,7 +21,7 @@ class TestUptimeSensor(unittest.TestCase): self.hass.stop() def test_uptime_min_config(self): - """Test minimum uptime configutation.""" + """Test minimum uptime configuration.""" config = { 'sensor': { 'platform': 'uptime', @@ -49,6 +49,16 @@ class TestUptimeSensor(unittest.TestCase): } assert setup_component(self.hass, 'sensor', config) + def test_uptime_sensor_config_minutes(self): + """Test uptime sensor with minutes defined in config.""" + config = { + 'sensor': { + 'platform': 'uptime', + 'unit_of_measurement': 'minutes', + } + } + assert setup_component(self.hass, 'sensor', config) + def test_uptime_sensor_days_output(self): """Test uptime sensor output data.""" sensor = UptimeSensor('test', 'days') @@ -86,3 +96,22 @@ class TestUptimeSensor(unittest.TestCase): self.hass.loop ).result() self.assertEqual(sensor.state, 72.50) + + def test_uptime_sensor_minutes_output(self): + """Test uptime sensor output data.""" + sensor = UptimeSensor('test', 'minutes') + self.assertEqual(sensor.unit_of_measurement, 'minutes') + new_time = sensor.initial + timedelta(minutes=16) + with patch('homeassistant.util.dt.now', return_value=new_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop + ).result() + self.assertEqual(sensor.state, 16.00) + new_time = sensor.initial + timedelta(minutes=12.499) + with patch('homeassistant.util.dt.now', return_value=new_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop + ).result() + self.assertEqual(sensor.state, 12.50) diff --git a/tests/components/switch/test_template.py b/tests/components/switch/test_template.py index e4a1a1af558..7456ae11a0d 100644 --- a/tests/components/switch/test_template.py +++ b/tests/components/switch/test_template.py @@ -166,6 +166,45 @@ class TestTemplateSwitch: state = self.hass.states.get('switch.test_template_switch') assert state.attributes['icon'] == 'mdi:check' + def test_entity_picture_template(self): + """Test entity_picture template.""" + with assert_setup_component(1, 'switch'): + assert setup.setup_component(self.hass, 'switch', { + 'switch': { + 'platform': 'template', + 'switches': { + 'test_template_switch': { + 'value_template': + "{{ states.switch.test_state.state }}", + 'turn_on': { + 'service': 'switch.turn_on', + 'entity_id': 'switch.test_state' + }, + 'turn_off': { + 'service': 'switch.turn_off', + 'entity_id': 'switch.test_state' + }, + 'entity_picture_template': + "{% if states.switch.test_state.state %}" + "/local/switch.png" + "{% endif %}" + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('switch.test_template_switch') + assert state.attributes.get('entity_picture') == '' + + state = self.hass.states.set('switch.test_state', STATE_ON) + self.hass.block_till_done() + + state = self.hass.states.get('switch.test_template_switch') + assert state.attributes['entity_picture'] == '/local/switch.png' + def test_template_syntax_error(self): """Test templating syntax error.""" with assert_setup_component(0, 'switch'): diff --git a/tests/components/switch/test_wake_on_lan.py b/tests/components/switch/test_wake_on_lan.py new file mode 100644 index 00000000000..063cf93d871 --- /dev/null +++ b/tests/components/switch/test_wake_on_lan.py @@ -0,0 +1,188 @@ +"""The tests for the wake on lan switch platform.""" +import unittest +from unittest.mock import patch + +from homeassistant.setup import setup_component +from homeassistant.const import STATE_ON, STATE_OFF +import homeassistant.components.switch as switch + +from tests.common import get_test_home_assistant + + +TEST_STATE = None + + +def send_magic_packet(*macs, **kwargs): + """Fake call for sending magic packets.""" + return + + +def call(cmd, stdout, stderr): + """Return fake subprocess return codes.""" + if cmd[5] == 'validhostname' and TEST_STATE: + return 0 + return 2 + + +def system(): + """Fake system call to test the windows platform.""" + return 'Windows' + + +class TestWOLSwitch(unittest.TestCase): + """Test the wol switch.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('wakeonlan.wol.send_magic_packet', new=send_magic_packet) + @patch('subprocess.call', new=call) + def test_valid_hostname(self): + """Test with valid hostname.""" + global TEST_STATE + TEST_STATE = False + self.assertTrue(setup_component(self.hass, switch.DOMAIN, { + 'switch': { + 'platform': 'wake_on_lan', + 'mac_address': '00-01-02-03-04-05', + 'host': 'validhostname', + } + })) + + state = self.hass.states.get('switch.wake_on_lan') + self.assertEqual(STATE_OFF, state.state) + + TEST_STATE = True + + switch.turn_on(self.hass, 'switch.wake_on_lan') + self.hass.block_till_done() + + state = self.hass.states.get('switch.wake_on_lan') + self.assertEqual(STATE_ON, state.state) + + switch.turn_off(self.hass, 'switch.wake_on_lan') + self.hass.block_till_done() + + state = self.hass.states.get('switch.wake_on_lan') + self.assertEqual(STATE_ON, state.state) + + @patch('wakeonlan.wol.send_magic_packet', new=send_magic_packet) + @patch('subprocess.call', new=call) + @patch('platform.system', new=system) + def test_valid_hostname_windows(self): + """Test with valid hostname on windows.""" + global TEST_STATE + TEST_STATE = False + self.assertTrue(setup_component(self.hass, switch.DOMAIN, { + 'switch': { + 'platform': 'wake_on_lan', + 'mac_address': '00-01-02-03-04-05', + 'host': 'validhostname', + } + })) + + state = self.hass.states.get('switch.wake_on_lan') + self.assertEqual(STATE_OFF, state.state) + + TEST_STATE = True + + switch.turn_on(self.hass, 'switch.wake_on_lan') + self.hass.block_till_done() + + state = self.hass.states.get('switch.wake_on_lan') + self.assertEqual(STATE_ON, state.state) + + @patch('wakeonlan.wol.send_magic_packet', new=send_magic_packet) + def test_minimal_config(self): + """Test with minimal config.""" + self.assertTrue(setup_component(self.hass, switch.DOMAIN, { + 'switch': { + 'platform': 'wake_on_lan', + 'mac_address': '00-01-02-03-04-05', + } + })) + + @patch('wakeonlan.wol.send_magic_packet', new=send_magic_packet) + @patch('subprocess.call', new=call) + def test_broadcast_config(self): + """Test with broadcast address config.""" + self.assertTrue(setup_component(self.hass, switch.DOMAIN, { + 'switch': { + 'platform': 'wake_on_lan', + 'mac_address': '00-01-02-03-04-05', + 'broadcast_address': '255.255.255.255', + } + })) + + state = self.hass.states.get('switch.wake_on_lan') + self.assertEqual(STATE_OFF, state.state) + + switch.turn_on(self.hass, 'switch.wake_on_lan') + self.hass.block_till_done() + + @patch('wakeonlan.wol.send_magic_packet', new=send_magic_packet) + @patch('subprocess.call', new=call) + def test_off_script(self): + """Test with turn off script.""" + global TEST_STATE + TEST_STATE = False + self.assertTrue(setup_component(self.hass, switch.DOMAIN, { + 'switch': { + 'platform': 'wake_on_lan', + 'mac_address': '00-01-02-03-04-05', + 'host': 'validhostname', + 'turn_off': { + 'service': 'shell_command.turn_off_TARGET', + }, + } + })) + + state = self.hass.states.get('switch.wake_on_lan') + self.assertEqual(STATE_OFF, state.state) + + TEST_STATE = True + + switch.turn_on(self.hass, 'switch.wake_on_lan') + self.hass.block_till_done() + + state = self.hass.states.get('switch.wake_on_lan') + self.assertEqual(STATE_ON, state.state) + + TEST_STATE = False + + switch.turn_off(self.hass, 'switch.wake_on_lan') + self.hass.block_till_done() + + state = self.hass.states.get('switch.wake_on_lan') + self.assertEqual(STATE_OFF, state.state) + + @patch('wakeonlan.wol.send_magic_packet', new=send_magic_packet) + @patch('subprocess.call', new=call) + @patch('platform.system', new=system) + def test_invalid_hostname_windows(self): + """Test with invalid hostname on windows.""" + global TEST_STATE + TEST_STATE = False + self.assertTrue(setup_component(self.hass, switch.DOMAIN, { + 'switch': { + 'platform': 'wake_on_lan', + 'mac_address': '00-01-02-03-04-05', + 'host': 'invalidhostname', + } + })) + + state = self.hass.states.get('switch.wake_on_lan') + self.assertEqual(STATE_OFF, state.state) + + TEST_STATE = True + + switch.turn_on(self.hass, 'switch.wake_on_lan') + self.hass.block_till_done() + + state = self.hass.states.get('switch.wake_on_lan') + self.assertEqual(STATE_OFF, state.state) diff --git a/tests/components/test_apiai.py b/tests/components/test_dialogflow.py similarity index 93% rename from tests/components/test_apiai.py rename to tests/components/test_dialogflow.py index 0c15326bbfc..8275534123c 100644 --- a/tests/components/test_apiai.py +++ b/tests/components/test_dialogflow.py @@ -1,4 +1,4 @@ -"""The tests for the APIAI component.""" +"""The tests for the Dialogflow component.""" # pylint: disable=protected-access import json import unittest @@ -7,14 +7,14 @@ import requests from homeassistant.core import callback from homeassistant import setup, const -from homeassistant.components import apiai, http +from homeassistant.components import dialogflow, http from tests.common import get_test_instance_port, get_test_home_assistant -API_PASSWORD = "test1234" +API_PASSWORD = 'test1234' SERVER_PORT = get_test_instance_port() BASE_API_URL = "http://127.0.0.1:{}".format(SERVER_PORT) -INTENTS_API_URL = "{}{}".format(BASE_API_URL, apiai.INTENTS_API_ENDPOINT) +INTENTS_API_URL = "{}{}".format(BASE_API_URL, dialogflow.INTENTS_API_ENDPOINT) HA_HEADERS = { const.HTTP_HEADER_HA_AUTH: API_PASSWORD, @@ -27,9 +27,9 @@ INTENT_NAME = "tests" REQUEST_ID = "19ef7e78-fe15-4e94-99dd-0c0b1e8753c3" REQUEST_TIMESTAMP = "2017-01-21T17:54:18.952Z" CONTEXT_NAME = "78a5db95-b7d6-4d50-9c9b-2fc73a5e34c3_id_dialog_context" -MAX_RESPONSE_TIME = 5 # https://docs.api.ai/docs/webhook +MAX_RESPONSE_TIME = 5 # https://dialogflow.com/docs/fulfillment -# An unknown action takes 8s to return. Request timeout should be bigger to +# An unknown action takes 8 s to return. Request timeout should be bigger to # allow the test to finish REQUEST_TIMEOUT = 15 @@ -46,19 +46,23 @@ def setUpModule(): hass = get_test_home_assistant() setup.setup_component( - hass, http.DOMAIN, - {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, - http.CONF_SERVER_PORT: SERVER_PORT}}) + hass, http.DOMAIN, { + http.DOMAIN: { + http.CONF_API_PASSWORD: API_PASSWORD, + http.CONF_SERVER_PORT: SERVER_PORT, + } + } + ) @callback def mock_service(call): """Mock action call.""" calls.append(call) - hass.services.register("test", "apiai", mock_service) + hass.services.register('test', 'dialogflow', mock_service) - assert setup.setup_component(hass, apiai.DOMAIN, { - "apiai": {}, + assert setup.setup_component(hass, dialogflow.DOMAIN, { + "dialogflow": {}, }) assert setup.setup_component(hass, "intent_script", { "intent_script": { @@ -92,7 +96,7 @@ def setUpModule(): "text": "Service called", }, "action": { - "service": "test.apiai", + "service": "test.dialogflow", "data_template": { "hello": "{{ ZodiacSign }}" }, @@ -112,12 +116,13 @@ def tearDownModule(): def _intent_req(data): - return requests.post(INTENTS_API_URL, data=json.dumps(data), - timeout=REQUEST_TIMEOUT, headers=HA_HEADERS) + return requests.post( + INTENTS_API_URL, data=json.dumps(data), timeout=REQUEST_TIMEOUT, + headers=HA_HEADERS) -class TestApiai(unittest.TestCase): - """Test APIAI.""" +class TestDialogflow(unittest.TestCase): + """Test Dialogflow.""" def tearDown(self): """Stop everything that was started.""" @@ -167,7 +172,7 @@ class TestApiai(unittest.TestCase): self.assertEqual("", req.text) def test_intent_slot_filling(self): - """Test when API.AI asks for slot-filling return none.""" + """Test when Dialogflow asks for slot-filling return none.""" data = { "id": REQUEST_ID, "timestamp": REQUEST_TIMESTAMP, @@ -424,7 +429,7 @@ class TestApiai(unittest.TestCase): self.assertEqual(call_count + 1, len(calls)) call = calls[-1] self.assertEqual("test", call.domain) - self.assertEqual("apiai", call.service) + self.assertEqual("dialogflow", call.service) self.assertEqual(["switch.test"], call.data.get("entity_id")) self.assertEqual("virgo", call.data.get("hello")) @@ -471,7 +476,7 @@ class TestApiai(unittest.TestCase): self.assertEqual(200, req.status_code) text = req.json().get("speech") self.assertEqual( - "You have not defined an action in your api.ai intent.", text) + "You have not defined an action in your Dialogflow intent.", text) def test_intent_with_unknown_action(self): """Test a intent with an action not defined in the conf.""" diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index fdd33b99d2b..1b034cfe940 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -7,7 +7,7 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components.frontend import ( - DOMAIN, ATTR_THEMES, ATTR_EXTRA_HTML_URL, DATA_PANELS, register_panel) + DOMAIN, CONF_THEMES, CONF_EXTRA_HTML_URL, DATA_PANELS) @pytest.fixture @@ -22,7 +22,7 @@ def mock_http_client_with_themes(hass, test_client): """Start the Hass HTTP component.""" hass.loop.run_until_complete(async_setup_component(hass, 'frontend', { DOMAIN: { - ATTR_THEMES: { + CONF_THEMES: { 'happy': { 'primary-color': 'red' } @@ -36,7 +36,7 @@ def mock_http_client_with_urls(hass, test_client): """Start the Hass HTTP component.""" hass.loop.run_until_complete(async_setup_component(hass, 'frontend', { DOMAIN: { - ATTR_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"] + CONF_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"] }})) return hass.loop.run_until_complete(test_client(hass.http.app)) @@ -133,7 +133,7 @@ def test_themes_reload_themes(hass, mock_http_client_with_themes): """Test frontend.reload_themes service.""" with patch('homeassistant.components.frontend.load_yaml_config_file', return_value={DOMAIN: { - ATTR_THEMES: { + CONF_THEMES: { 'sad': {'primary-color': 'blue'} }}}): yield from hass.services.async_call(DOMAIN, 'set_theme', @@ -168,15 +168,7 @@ def test_extra_urls(mock_http_client_with_urls): @asyncio.coroutine def test_panel_without_path(hass): """Test panel registration without file path.""" - register_panel(hass, 'test_component', 'nonexistant_file') - assert hass.data[DATA_PANELS] == {} - - -@asyncio.coroutine -def test_panel_with_url(hass): - """Test panel registration without file path.""" - register_panel(hass, 'test_component', None, url='some_url') - assert hass.data[DATA_PANELS] == { - 'test_component': {'component_name': 'test_component', - 'url': 'some_url', - 'url_path': 'test_component'}} + yield from hass.components.frontend.async_register_panel( + 'test_component', 'nonexistant_file') + yield from async_setup_component(hass, 'frontend', {}) + assert 'test_component' not in hass.data[DATA_PANELS] diff --git a/tests/components/test_google_domains.py b/tests/components/test_google_domains.py new file mode 100644 index 00000000000..1884e18dc1a --- /dev/null +++ b/tests/components/test_google_domains.py @@ -0,0 +1,74 @@ +"""Test the Google Domains component.""" +import asyncio +from datetime import timedelta + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import google_domains +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + +DOMAIN = 'test.example.com' +USERNAME = 'abc123' +PASSWORD = 'xyz789' + +UPDATE_URL = google_domains.UPDATE_URL.format(USERNAME, PASSWORD) + + +@pytest.fixture +def setup_google_domains(hass, aioclient_mock): + """Fixture that sets up NamecheapDNS.""" + aioclient_mock.get(UPDATE_URL, params={ + 'hostname': DOMAIN + }, text='ok 0.0.0.0') + + hass.loop.run_until_complete(async_setup_component( + hass, google_domains.DOMAIN, { + 'google_domains': { + 'domain': DOMAIN, + 'username': USERNAME, + 'password': PASSWORD, + } + })) + + +@asyncio.coroutine +def test_setup(hass, aioclient_mock): + """Test setup works if update passes.""" + aioclient_mock.get(UPDATE_URL, params={ + 'hostname': DOMAIN + }, text='nochg 0.0.0.0') + + result = yield from async_setup_component(hass, google_domains.DOMAIN, { + 'google_domains': { + 'domain': DOMAIN, + 'username': USERNAME, + 'password': PASSWORD + } + }) + assert result + assert aioclient_mock.call_count == 1 + + async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) + yield from hass.async_block_till_done() + assert aioclient_mock.call_count == 2 + + +@asyncio.coroutine +def test_setup_fails_if_update_fails(hass, aioclient_mock): + """Test setup fails if first update fails.""" + aioclient_mock.get(UPDATE_URL, params={ + 'hostname': DOMAIN + }, text='nohost') + + result = yield from async_setup_component(hass, google_domains.DOMAIN, { + 'google_domains': { + 'domain': DOMAIN, + 'username': USERNAME, + 'password': PASSWORD + } + }) + assert not result + assert aioclient_mock.call_count == 1 diff --git a/tests/components/test_input_number.py b/tests/components/test_input_number.py index 7d11325dabb..fde940efa1a 100644 --- a/tests/components/test_input_number.py +++ b/tests/components/test_input_number.py @@ -5,7 +5,8 @@ import unittest from homeassistant.core import CoreState, State from homeassistant.setup import setup_component, async_setup_component -from homeassistant.components.input_number import (DOMAIN, set_value) +from homeassistant.components.input_number import ( + DOMAIN, set_value, increment, decrement) from tests.common import get_test_home_assistant, mock_restore_cache @@ -70,6 +71,58 @@ class TestInputNumber(unittest.TestCase): state = self.hass.states.get(entity_id) self.assertEqual(70, float(state.state)) + def test_increment(self): + """Test increment method.""" + self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: { + 'test_2': { + 'initial': 50, + 'min': 0, + 'max': 51, + }, + }})) + entity_id = 'input_number.test_2' + + state = self.hass.states.get(entity_id) + self.assertEqual(50, float(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(51, float(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(51, float(state.state)) + + def test_decrement(self): + """Test decrement method.""" + self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: { + 'test_3': { + 'initial': 50, + 'min': 49, + 'max': 100, + }, + }})) + entity_id = 'input_number.test_3' + + state = self.hass.states.get(entity_id) + self.assertEqual(50, float(state.state)) + + decrement(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(49, float(state.state)) + + decrement(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(49, float(state.state)) + def test_mode(self): """Test mode settings.""" self.assertTrue( diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 07c89b5dcd1..6a79994586c 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -3,7 +3,6 @@ import logging from datetime import timedelta import unittest -from unittest.mock import patch from homeassistant.components import sun import homeassistant.core as ha @@ -32,10 +31,8 @@ class TestComponentLogbook(unittest.TestCase): init_recorder_component(self.hass) # Force an in memory DB mock_http_component(self.hass) self.hass.config.components |= set(['frontend', 'recorder', 'api']) - with patch('homeassistant.components.logbook.' - 'register_built_in_panel'): - assert setup_component(self.hass, logbook.DOMAIN, - self.EMPTY_CONFIG) + assert setup_component(self.hass, logbook.DOMAIN, + self.EMPTY_CONFIG) self.hass.start() def tearDown(self): diff --git a/tests/components/test_mqtt_statestream.py b/tests/components/test_mqtt_statestream.py index 802d62bfdd1..cc1ea277a34 100644 --- a/tests/components/test_mqtt_statestream.py +++ b/tests/components/test_mqtt_statestream.py @@ -127,7 +127,11 @@ class TestMqttStateStream(object): # mqtt_statestream state change on initialization, etc. mock_pub.reset_mock() - test_attributes = {"testing": "YES"} + test_attributes = { + "testing": "YES", + "list": ["a", "b", "c"], + "bool": True + } # Set a state of an entity mock_state_change_event(self.hass, State(e_id, 'off', @@ -138,7 +142,11 @@ class TestMqttStateStream(object): calls = [ call.async_publish(self.hass, 'pub/fake/entity/state', 'off', 1, True), - call.async_publish(self.hass, 'pub/fake/entity/testing', 'YES', + call.async_publish(self.hass, 'pub/fake/entity/testing', '"YES"', + 1, True), + call.async_publish(self.hass, 'pub/fake/entity/list', + '["a", "b", "c"]', 1, True), + call.async_publish(self.hass, 'pub/fake/entity/bool', "true", 1, True) ] diff --git a/tests/components/test_namecheapdns.py b/tests/components/test_namecheapdns.py index b225c0af7c8..31c9acd962c 100644 --- a/tests/components/test_namecheapdns.py +++ b/tests/components/test_namecheapdns.py @@ -12,7 +12,7 @@ from tests.common import async_fire_time_changed HOST = 'test' DOMAIN = 'bla' -TOKEN = 'abcdefgh' +PASSWORD = 'abcdefgh' @pytest.fixture @@ -21,7 +21,7 @@ def setup_namecheapdns(hass, aioclient_mock): aioclient_mock.get(namecheapdns.UPDATE_URL, params={ 'host': HOST, 'domain': DOMAIN, - 'password': TOKEN + 'password': PASSWORD, }, text='0') hass.loop.run_until_complete(async_setup_component( @@ -29,7 +29,7 @@ def setup_namecheapdns(hass, aioclient_mock): 'namecheapdns': { 'host': HOST, 'domain': DOMAIN, - 'access_token': TOKEN + 'password': PASSWORD, } })) @@ -40,14 +40,14 @@ def test_setup(hass, aioclient_mock): aioclient_mock.get(namecheapdns.UPDATE_URL, params={ 'host': HOST, 'domain': DOMAIN, - 'password': TOKEN + 'password': PASSWORD }, text='0') result = yield from async_setup_component(hass, namecheapdns.DOMAIN, { 'namecheapdns': { 'host': HOST, 'domain': DOMAIN, - 'access_token': TOKEN + 'password': PASSWORD, } }) assert result @@ -64,14 +64,14 @@ def test_setup_fails_if_update_fails(hass, aioclient_mock): aioclient_mock.get(namecheapdns.UPDATE_URL, params={ 'host': HOST, 'domain': DOMAIN, - 'password': TOKEN + 'password': PASSWORD, }, text='1') result = yield from async_setup_component(hass, namecheapdns.DOMAIN, { 'namecheapdns': { 'host': HOST, 'domain': DOMAIN, - 'access_token': TOKEN + 'password': PASSWORD, } }) assert not result diff --git a/tests/components/test_no_ip.py b/tests/components/test_no_ip.py new file mode 100644 index 00000000000..8e4e2d3e5b1 --- /dev/null +++ b/tests/components/test_no_ip.py @@ -0,0 +1,87 @@ +"""Test the NO-IP component.""" +import asyncio +from datetime import timedelta + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import no_ip +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + +DOMAIN = 'test.example.com' + +PASSWORD = 'xyz789' + +UPDATE_URL = no_ip.UPDATE_URL + +USERNAME = 'abc@123.com' + + +@pytest.fixture +def setup_no_ip(hass, aioclient_mock): + """Fixture that sets up NO-IP.""" + aioclient_mock.get( + UPDATE_URL, params={'hostname': DOMAIN}, text='good 0.0.0.0') + + hass.loop.run_until_complete(async_setup_component(hass, no_ip.DOMAIN, { + no_ip.DOMAIN: { + 'domain': DOMAIN, + 'username': USERNAME, + 'password': PASSWORD, + } + })) + + +@asyncio.coroutine +def test_setup(hass, aioclient_mock): + """Test setup works if update passes.""" + aioclient_mock.get( + UPDATE_URL, params={'hostname': DOMAIN}, text='nochg 0.0.0.0') + + result = yield from async_setup_component(hass, no_ip.DOMAIN, { + no_ip.DOMAIN: { + 'domain': DOMAIN, + 'username': USERNAME, + 'password': PASSWORD, + } + }) + assert result + assert aioclient_mock.call_count == 1 + + async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) + yield from hass.async_block_till_done() + assert aioclient_mock.call_count == 2 + + +@asyncio.coroutine +def test_setup_fails_if_update_fails(hass, aioclient_mock): + """Test setup fails if first update fails.""" + aioclient_mock.get(UPDATE_URL, params={'hostname': DOMAIN}, text='nohost') + + result = yield from async_setup_component(hass, no_ip.DOMAIN, { + no_ip.DOMAIN: { + 'domain': DOMAIN, + 'username': USERNAME, + 'password': PASSWORD, + } + }) + assert not result + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_setup_fails_if_wrong_auth(hass, aioclient_mock): + """Test setup fails if first update fails through wrong authentication.""" + aioclient_mock.get(UPDATE_URL, params={'hostname': DOMAIN}, text='badauth') + + result = yield from async_setup_component(hass, no_ip.DOMAIN, { + no_ip.DOMAIN: { + 'domain': DOMAIN, + 'username': USERNAME, + 'password': PASSWORD, + } + }) + assert not result + assert aioclient_mock.call_count == 1 diff --git a/tests/components/test_panel_custom.py b/tests/components/test_panel_custom.py index 073a2fdcce9..d33221da2a7 100644 --- a/tests/components/test_panel_custom.py +++ b/tests/components/test_panel_custom.py @@ -1,89 +1,77 @@ """The tests for the panel_custom component.""" -import os -import shutil -import unittest +import asyncio from unittest.mock import Mock, patch +import pytest + from homeassistant import setup -from homeassistant.components import panel_custom +from homeassistant.components import frontend -from tests.common import get_test_home_assistant +from tests.common import mock_component -@patch('homeassistant.components.frontend.setup', - autospec=True, return_value=True) -class TestPanelCustom(unittest.TestCase): - """Test the panel_custom component.""" +@pytest.fixture(autouse=True) +def mock_frontend_loaded(hass): + """Mock frontend is loaded.""" + mock_component(hass, 'frontend') - def setup_method(self, method): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - shutil.rmtree(self.hass.config.path(panel_custom.PANEL_DIR), - ignore_errors=True) +@asyncio.coroutine +def test_webcomponent_custom_path_not_found(hass): + """Test if a web component is found in config panels dir.""" + filename = 'mock.file' - @patch('homeassistant.components.panel_custom.register_panel') - def test_webcomponent_in_panels_dir(self, mock_register, _mock_setup): - """Test if a web component is found in config panels dir.""" - config = { - 'panel_custom': { - 'name': 'todomvc', - } + config = { + 'panel_custom': { + 'name': 'todomvc', + 'webcomponent_path': filename, + 'sidebar_title': 'Sidebar Title', + 'sidebar_icon': 'mdi:iconicon', + 'url_path': 'nice_url', + 'config': 5, } + } - assert not setup.setup_component(self.hass, 'panel_custom', config) - assert not mock_register.called + with patch('os.path.isfile', Mock(return_value=False)): + result = yield from setup.async_setup_component( + hass, 'panel_custom', config + ) + assert not result + assert len(hass.data.get(frontend.DATA_PANELS, {})) == 0 - path = self.hass.config.path(panel_custom.PANEL_DIR) - os.mkdir(path) - self.hass.data.pop(setup.DATA_SETUP) - with open(os.path.join(path, 'todomvc.html'), 'a'): - assert setup.setup_component(self.hass, 'panel_custom', config) - assert mock_register.called +@asyncio.coroutine +def test_webcomponent_custom_path(hass): + """Test if a web component is found in config panels dir.""" + filename = 'mock.file' - @patch('homeassistant.components.panel_custom.register_panel') - def test_webcomponent_custom_path(self, mock_register, _mock_setup): - """Test if a web component is found in config panels dir.""" - filename = 'mock.file' - - config = { - 'panel_custom': { - 'name': 'todomvc', - 'webcomponent_path': filename, - 'sidebar_title': 'Sidebar Title', - 'sidebar_icon': 'mdi:iconicon', - 'url_path': 'nice_url', - 'config': 5, - } + config = { + 'panel_custom': { + 'name': 'todomvc', + 'webcomponent_path': filename, + 'sidebar_title': 'Sidebar Title', + 'sidebar_icon': 'mdi:iconicon', + 'url_path': 'nice_url', + 'config': 5, } + } - with patch('os.path.isfile', Mock(return_value=False)): - assert not setup.setup_component( - self.hass, 'panel_custom', config + with patch('os.path.isfile', Mock(return_value=True)): + with patch('os.access', Mock(return_value=True)): + result = yield from setup.async_setup_component( + hass, 'panel_custom', config ) - assert not mock_register.called + assert result - self.hass.data.pop(setup.DATA_SETUP) + panels = hass.data.get(frontend.DATA_PANELS, []) - with patch('os.path.isfile', Mock(return_value=True)): - with patch('os.access', Mock(return_value=True)): - assert setup.setup_component( - self.hass, 'panel_custom', config - ) + assert len(panels) == 1 + assert 'nice_url' in panels - assert mock_register.called + panel = panels['nice_url'] - args = mock_register.mock_calls[0][1] - assert args == (self.hass, 'todomvc', filename) - - kwargs = mock_register.mock_calls[0][2] - assert kwargs == { - 'config': 5, - 'url_path': 'nice_url', - 'sidebar_icon': 'mdi:iconicon', - 'sidebar_title': 'Sidebar Title' - } + assert panel.config == 5 + assert panel.frontend_url_path == 'nice_url' + assert panel.sidebar_icon == 'mdi:iconicon' + assert panel.sidebar_title == 'Sidebar Title' + assert panel.path == filename diff --git a/tests/components/test_panel_iframe.py b/tests/components/test_panel_iframe.py index 5f9cdcfa57c..00c824418be 100644 --- a/tests/components/test_panel_iframe.py +++ b/tests/components/test_panel_iframe.py @@ -33,8 +33,8 @@ class TestPanelIframe(unittest.TestCase): 'panel_iframe': conf }) - @patch.dict('homeassistant.components.frontend.FINGERPRINTS', { - 'panels/ha-panel-iframe.html': 'md5md5'}) + @patch.dict('hass_frontend.FINGERPRINTS', + {'panels/ha-panel-iframe.html': 'md5md5'}) def test_correct_config(self): """Test correct config.""" assert setup.setup_component( @@ -53,20 +53,22 @@ class TestPanelIframe(unittest.TestCase): }, }) - assert self.hass.data[frontend.DATA_PANELS].get('router') == { + panels = self.hass.data[frontend.DATA_PANELS] + + assert panels.get('router').as_dict() == { 'component_name': 'iframe', 'config': {'url': 'http://192.168.1.1'}, 'icon': 'mdi:network-wireless', 'title': 'Router', - 'url': '/frontend/panels/iframe-md5md5.html', + 'url': '/static/panels/ha-panel-iframe-md5md5.html', 'url_path': 'router' } - assert self.hass.data[frontend.DATA_PANELS].get('weather') == { + assert panels.get('weather').as_dict() == { 'component_name': 'iframe', 'config': {'url': 'https://www.wunderground.com/us/ca/san-diego'}, 'icon': 'mdi:weather', 'title': 'Weather', - 'url': '/frontend/panels/iframe-md5md5.html', + 'url': '/static/panels/ha-panel-iframe-md5md5.html', 'url_path': 'weather', } diff --git a/tests/components/test_python_script.py b/tests/components/test_python_script.py index 667d1849100..e5d6b0c4aad 100644 --- a/tests/components/test_python_script.py +++ b/tests/components/test_python_script.py @@ -47,7 +47,8 @@ def test_setup_fails_on_no_dir(hass, caplog): res = yield from async_setup_component(hass, 'python_script', {}) assert not res - assert 'Folder python_scripts not found in config folder' in caplog.text + assert 'Folder python_scripts not found in configuration folder' in \ + caplog.text @asyncio.coroutine diff --git a/tests/components/test_remember_the_milk.py b/tests/components/test_remember_the_milk.py new file mode 100644 index 00000000000..b59c840d765 --- /dev/null +++ b/tests/components/test_remember_the_milk.py @@ -0,0 +1,49 @@ +"""Tests for the Remember The Milk component.""" + +import logging +import unittest +from unittest.mock import patch, mock_open, Mock + +import homeassistant.components.remember_the_milk as rtm + +from tests.common import get_test_home_assistant + +_LOGGER = logging.getLogger(__name__) + + +class TestConfiguration(unittest.TestCase): + """Basic tests for the class RememberTheMilkConfiguration.""" + + def setUp(self): + """Set up test home assistant main loop.""" + self.hass = get_test_home_assistant() + self.profile = "myprofile" + self.token = "mytoken" + self.json_string = '{"myprofile": {"token": "mytoken"}}' + + def tearDown(self): + """Exit home assistant.""" + self.hass.stop() + + def test_create_new(self): + """Test creating a new config file.""" + with patch("builtins.open", mock_open()), \ + patch("os.path.isfile", Mock(return_value=False)): + config = rtm.RememberTheMilkConfiguration(self.hass) + config.set_token(self.profile, self.token) + self.assertEqual(config.get_token(self.profile), self.token) + + def test_load_config(self): + """Test loading an existing token from the file.""" + with patch("builtins.open", mock_open(read_data=self.json_string)), \ + patch("os.path.isfile", Mock(return_value=True)): + config = rtm.RememberTheMilkConfiguration(self.hass) + self.assertEqual(config.get_token(self.profile), self.token) + + def test_invalid_data(self): + """Test starts with invalid data and should not raise an exception.""" + with patch("builtins.open", + mock_open(read_data='random charachters')),\ + patch("os.path.isfile", Mock(return_value=True)): + config = rtm.RememberTheMilkConfiguration(self.hass) + self.assertIsNotNone(config) diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index 039fa4ba452..c310b0d5445 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -288,8 +288,8 @@ def test_get_config(hass, websocket_client): @asyncio.coroutine def test_get_panels(hass, websocket_client): """Test get_panels command.""" - frontend.register_built_in_panel(hass, 'map', 'Map', - 'mdi:account-location') + yield from hass.components.frontend.async_register_built_in_panel( + 'map', 'Map', 'mdi:account-location') websocket_client.send_json({ 'id': 5, @@ -300,7 +300,8 @@ def test_get_panels(hass, websocket_client): assert msg['id'] == 5 assert msg['type'] == wapi.TYPE_RESULT assert msg['success'] - assert msg['result'] == hass.data[frontend.DATA_PANELS] + assert msg['result'] == {url: panel.as_dict() for url, panel + in hass.data[frontend.DATA_PANELS].items()} @asyncio.coroutine diff --git a/tests/components/timer/__init__.py b/tests/components/timer/__init__.py new file mode 100644 index 00000000000..160fc633701 --- /dev/null +++ b/tests/components/timer/__init__.py @@ -0,0 +1 @@ +"""Test env for timer component.""" diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py new file mode 100644 index 00000000000..5b36273f046 --- /dev/null +++ b/tests/components/timer/test_init.py @@ -0,0 +1,199 @@ +"""The tests for the timer component.""" +# pylint: disable=protected-access +import asyncio +import unittest +import logging +from datetime import timedelta + +from homeassistant.core import CoreState +from homeassistant.setup import setup_component, async_setup_component +from homeassistant.components.timer import ( + DOMAIN, CONF_DURATION, CONF_NAME, STATUS_ACTIVE, STATUS_IDLE, + STATUS_PAUSED, CONF_ICON, ATTR_DURATION, EVENT_TIMER_FINISHED, + EVENT_TIMER_CANCELLED, SERVICE_START, SERVICE_PAUSE, SERVICE_CANCEL, + SERVICE_FINISH) +from homeassistant.const import (ATTR_ICON, ATTR_FRIENDLY_NAME, CONF_ENTITY_ID) +from homeassistant.util.dt import utcnow + +from tests.common import (get_test_home_assistant, async_fire_time_changed) + +_LOGGER = logging.getLogger(__name__) + + +class TestTimer(unittest.TestCase): + """Test the timer component.""" + + # pylint: disable=invalid-name + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_config(self): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] + + for cfg in invalid_configs: + self.assertFalse( + setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + + def test_config_options(self): + """Test configuration options.""" + count_start = len(self.hass.states.entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_DURATION: 10, + } + } + } + + assert setup_component(self.hass, 'timer', config) + self.hass.block_till_done() + + self.assertEqual(count_start + 2, len(self.hass.states.entity_ids())) + self.hass.block_till_done() + + state_1 = self.hass.states.get('timer.test_1') + state_2 = self.hass.states.get('timer.test_2') + + self.assertIsNotNone(state_1) + self.assertIsNotNone(state_2) + + self.assertEqual(STATUS_IDLE, state_1.state) + self.assertNotIn(ATTR_ICON, state_1.attributes) + self.assertNotIn(ATTR_FRIENDLY_NAME, state_1.attributes) + + self.assertEqual(STATUS_IDLE, state_2.state) + self.assertEqual('Hello World', + state_2.attributes.get(ATTR_FRIENDLY_NAME)) + self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON)) + self.assertEqual('0:00:10', state_2.attributes.get(ATTR_DURATION)) + + +@asyncio.coroutine +def test_methods_and_events(hass): + """Test methods and events.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': { + CONF_DURATION: 10, + } + }}) + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_IDLE + + results = [] + + def fake_event_listener(event): + """Fake event listener for trigger.""" + results.append(event) + + hass.bus.async_listen(EVENT_TIMER_FINISHED, fake_event_listener) + hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener) + + yield from hass.services.async_call(DOMAIN, + SERVICE_START, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_ACTIVE + + yield from hass.services.async_call(DOMAIN, + SERVICE_PAUSE, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_PAUSED + + yield from hass.services.async_call(DOMAIN, + SERVICE_CANCEL, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_IDLE + + assert len(results) == 1 + assert results[-1].event_type == EVENT_TIMER_CANCELLED + + yield from hass.services.async_call(DOMAIN, + SERVICE_START, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_ACTIVE + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_IDLE + + assert len(results) == 2 + assert results[-1].event_type == EVENT_TIMER_FINISHED + + yield from hass.services.async_call(DOMAIN, + SERVICE_START, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_ACTIVE + + yield from hass.services.async_call(DOMAIN, + SERVICE_FINISH, + {CONF_ENTITY_ID: 'timer.test1'}) + yield from hass.async_block_till_done() + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_IDLE + + assert len(results) == 3 + assert results[-1].event_type == EVENT_TIMER_FINISHED + + +@asyncio.coroutine +def test_no_initial_state_and_no_restore_state(hass): + """Ensure that entity is create without initial and restore feature.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': { + CONF_DURATION: 10, + } + }}) + + state = hass.states.get('timer.test1') + assert state + assert state.state == STATUS_IDLE diff --git a/tests/fixtures/coinmarketcap.json b/tests/fixtures/coinmarketcap.json new file mode 100644 index 00000000000..20f5e4fe91e --- /dev/null +++ b/tests/fixtures/coinmarketcap.json @@ -0,0 +1,21 @@ +[ + { + "id": "ethereum", + "name": "Ethereum", + "symbol": "ETH", + "rank": "2", + "price_usd": "282.423", + "price_btc": "0.048844", + "24h_volume_usd": "407024000.0", + "market_cap_usd": "26908205315.0", + "available_supply": "95276253.0", + "total_supply": "95276253.0", + "percent_change_1h": "0.06", + "percent_change_24h": "-4.57", + "percent_change_7d": "-16.39", + "last_updated": "1508776751", + "price_eur": "240.473299695", + "24h_volume_eur": "346566690.16", + "market_cap_eur": "22911395039.0" + } +] \ No newline at end of file diff --git a/tests/fixtures/ring_chime_health_attrs.json b/tests/fixtures/ring_chime_health_attrs.json new file mode 100644 index 00000000000..027470b480e --- /dev/null +++ b/tests/fixtures/ring_chime_health_attrs.json @@ -0,0 +1,18 @@ +{ + "device_health": { + "average_signal_category": "good", + "average_signal_strength": -39, + "battery_percentage": 100, + "battery_percentage_category": null, + "battery_voltage": null, + "battery_voltage_category": null, + "firmware": "1.2.3", + "firmware_out_of_date": false, + "id": 999999, + "latest_signal_category": "good", + "latest_signal_strength": -39, + "updated_at": "2017-09-30T07:05:03Z", + "wifi_is_ring_network": false, + "wifi_name": "ring_mock_wifi" + } +} diff --git a/tests/fixtures/ring_devices.json b/tests/fixtures/ring_devices.json index 4d204ba5250..4248bbf812d 100644 --- a/tests/fixtures/ring_devices.json +++ b/tests/fixtures/ring_devices.json @@ -75,5 +75,143 @@ "high"]}, "subscribed": true, "subscribed_motions": true, + "time_zone": "America/New_York"}], + "stickup_cams": [ + { + "address": "123 Main St", + "alerts": {"connection": "online"}, + "battery_life": 80, + "description": "Front", + "device_id": "aacdef123", + "external_connection": false, + "features": { + "advanced_motion_enabled": false, + "motion_message_enabled": false, + "motions_enabled": true, + "night_vision_enabled": false, + "people_only_enabled": false, + "shadow_correction_enabled": false, + "show_recordings": true}, + "firmware_version": "1.9.3", + "id": 987652, + "kind": "hp_cam_v1", + "latitude": 12.000000, + "led_status": "off", + "location_id": null, + "longitude": -70.12345, + "motion_snooze": {"scheduled": true}, + "night_mode_status": "false", + "owned": true, + "owner": { + "email": "foo@bar.org", + "first_name": "Foo", + "id": 999999, + "last_name": "Bar"}, + "ring_cam_light_installed": "false", + "ring_id": null, + "settings": { + "chime_settings": { + "duration": 10, + "enable": true, + "type": 0}, + "doorbell_volume": 11, + "enable_vod": true, + "floodlight_settings": { + "duration": 30, + "priority": 0}, + "light_schedule_settings": { + "end_hour": 0, + "end_minute": 0, + "start_hour": 0, + "start_minute": 0}, + "live_view_preset_profile": "highest", + "live_view_presets": [ + "low", + "middle", + "high", + "highest"], + "motion_announcement": false, + "motion_snooze_preset_profile": "low", + "motion_snooze_presets": [ + "none", + "low", + "medium", + "high"], + "motion_zones": { + "active_motion_filter": 1, + "advanced_object_settings": { + "human_detection_confidence": { + "day": 0.7, + "night": 0.7}, + "motion_zone_overlap": { + "day": 0.1, + "night": 0.2}, + "object_size_maximum": { + "day": 0.8, + "night": 0.8}, + "object_size_minimum": { + "day": 0.03, + "night": 0.05}, + "object_time_overlap": { + "day": 0.1, + "night": 0.6} + }, + "enable_audio": false, + "pir_settings": { + "sensitivity1": 1, + "sensitivity2": 1, + "sensitivity3": 1, + "zone_mask": 6}, + "sensitivity": 5, + "zone1": { + "name": "Zone 1", + "state": 2, + "vertex1": {"x": 0.0, "y": 0.0}, + "vertex2": {"x": 0.0, "y": 0.0}, + "vertex3": {"x": 0.0, "y": 0.0}, + "vertex4": {"x": 0.0, "y": 0.0}, + "vertex5": {"x": 0.0, "y": 0.0}, + "vertex6": {"x": 0.0, "y": 0.0}, + "vertex7": {"x": 0.0, "y": 0.0}, + "vertex8": {"x": 0.0, "y": 0.0}}, + "zone2": { + "name": "Zone 2", + "state": 2, + "vertex1": {"x": 0.0, "y": 0.0}, + "vertex2": {"x": 0.0, "y": 0.0}, + "vertex3": {"x": 0.0, "y": 0.0}, + "vertex4": {"x": 0.0, "y": 0.0}, + "vertex5": {"x": 0.0, "y": 0.0}, + "vertex6": {"x": 0.0, "y": 0.0}, + "vertex7": {"x": 0.0, "y": 0.0}, + "vertex8": {"x": 0.0, "y": 0.0}}, + "zone3": { + "name": "Zone 3", + "state": 2, + "vertex1": {"x": 0.0, "y": 0.0}, + "vertex2": {"x": 0.0, "y": 0.0}, + "vertex3": {"x": 0.0, "y": 0.0}, + "vertex4": {"x": 0.0, "y": 0.0}, + "vertex5": {"x": 0.0, "y": 0.0}, + "vertex6": {"x": 0.0, "y": 0.0}, + "vertex7": {"x": 0.0, "y": 0.0}, + "vertex8": {"x": 0.0, "y": 0.0}}}, + "pir_motion_zones": [0, 1, 1], + "pir_settings": { + "sensitivity1": 1, + "sensitivity2": 1, + "sensitivity3": 1, + "zone_mask": 6}, + "stream_setting": 0, + "video_settings": { + "ae_level": 0, + "birton": null, + "brightness": 0, + "contrast": 64, + "saturation": 80}}, + "siren_status": {"seconds_remaining": 0}, + "stolen": false, + "subscribed": true, + "subscribed_motions": true, "time_zone": "America/New_York"}] } diff --git a/tests/fixtures/ring_doorboot_health_attrs.json b/tests/fixtures/ring_doorboot_health_attrs.json new file mode 100644 index 00000000000..f84678d9ab0 --- /dev/null +++ b/tests/fixtures/ring_doorboot_health_attrs.json @@ -0,0 +1,18 @@ +{ + "device_health": { + "average_signal_category": "good", + "average_signal_strength": -39, + "battery_percentage": 100, + "battery_percentage_category": null, + "battery_voltage": null, + "battery_voltage_category": null, + "firmware": "1.9.2", + "firmware_out_of_date": false, + "id": 987652, + "latest_signal_category": "good", + "latest_signal_strength": -58, + "updated_at": "2017-09-30T07:05:03Z", + "wifi_is_ring_network": false, + "wifi_name": "ring_mock_wifi" + } +} diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 6a00978fbe4..3d9ac2b2fd0 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -128,7 +128,7 @@ class TestHelpersEntityComponent(unittest.TestCase): assert poll_ent.async_update.called def test_polling_updates_entities_with_exception(self): - """Test the updated entities that not brake with a exception.""" + """Test the updated entities that not break with a exception.""" component = EntityComponent( _LOGGER, DOMAIN, self.hass, timedelta(seconds=20)) @@ -512,6 +512,26 @@ def test_extract_from_service_available_device(hass): component.async_extract_from_service(call_2)) +@asyncio.coroutine +def test_updated_state_used_for_entity_id(hass): + """Test that first update results used for entity ID generation.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + class EntityTestNameFetcher(EntityTest): + """Mock entity that fetches a friendly name.""" + + @asyncio.coroutine + def async_update(self): + """Mock update that assigns a name.""" + self._values['name'] = "Living Room" + + yield from component.async_add_entities([EntityTestNameFetcher()], True) + + entity_ids = hass.states.async_entity_ids() + assert 1 == len(entity_ids) + assert entity_ids[0] == "test_domain.living_room" + + @asyncio.coroutine def test_platform_not_ready(hass): """Test that we retry when platform not ready.""" diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py new file mode 100644 index 00000000000..797cd257833 --- /dev/null +++ b/tests/helpers/test_entityfilter.py @@ -0,0 +1,95 @@ +"""The tests for the EntityFitler component.""" +from homeassistant.helpers.entityfilter import generate_filter + + +def test_no_filters_case_1(): + """If include and exclude not included, pass everything.""" + incl_dom = {} + incl_ent = {} + excl_dom = {} + excl_ent = {} + testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent) + + for value in ("sensor.test", "sun.sun", "light.test"): + assert testfilter(value) + + +def test_includes_only_case_2(): + """If include specified, only pass if specified (Case 2).""" + incl_dom = {'light', 'sensor'} + incl_ent = {'binary_sensor.working'} + excl_dom = {} + excl_ent = {} + testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent) + + assert testfilter("sensor.test") + assert testfilter("light.test") + assert testfilter("binary_sensor.working") + assert testfilter("binary_sensor.notworking") is False + assert testfilter("sun.sun") is False + + +def test_excludes_only_case_3(): + """If exclude specified, pass all but specified (Case 3).""" + incl_dom = {} + incl_ent = {} + excl_dom = {'light', 'sensor'} + excl_ent = {'binary_sensor.working'} + testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent) + + assert testfilter("sensor.test") is False + assert testfilter("light.test") is False + assert testfilter("binary_sensor.working") is False + assert testfilter("binary_sensor.another") + assert testfilter("sun.sun") is True + + +def test_with_include_domain_case4a(): + """Test case 4a - include and exclude specified, with included domain.""" + incl_dom = {'light', 'sensor'} + incl_ent = {'binary_sensor.working'} + excl_dom = {} + excl_ent = {'light.ignoreme', 'sensor.notworking'} + testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent) + + assert testfilter("sensor.test") + assert testfilter("sensor.notworking") is False + assert testfilter("light.test") + assert testfilter("light.ignoreme") is False + assert testfilter("binary_sensor.working") + assert testfilter("binary_sensor.another") is False + assert testfilter("sun.sun") is False + + +def test_exclude_domain_case4b(): + """Test case 4b - include and exclude specified, with excluded domain.""" + incl_dom = {} + incl_ent = {'binary_sensor.working'} + excl_dom = {'binary_sensor'} + excl_ent = {'light.ignoreme', 'sensor.notworking'} + testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent) + + assert testfilter("sensor.test") + assert testfilter("sensor.notworking") is False + assert testfilter("light.test") + assert testfilter("light.ignoreme") is False + assert testfilter("binary_sensor.working") + assert testfilter("binary_sensor.another") is False + assert testfilter("sun.sun") is True + + +def testno_domain_case4c(): + """Test case 4c - include and exclude specified, with no domains.""" + incl_dom = {} + incl_ent = {'binary_sensor.working'} + excl_dom = {} + excl_ent = {'light.ignoreme', 'sensor.notworking'} + testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent) + + assert testfilter("sensor.test") is False + assert testfilter("sensor.notworking") is False + assert testfilter("light.test") is False + assert testfilter("light.ignoreme") is False + assert testfilter("binary_sensor.working") + assert testfilter("binary_sensor.another") is False + assert testfilter("sun.sun") is False diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 5ad4ec8cdb3..a454a5a64b4 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -22,9 +22,9 @@ BASE_CONFIG = ( def change_yaml_files(check_dict): - """Change the ['yaml_files'] property and remove the config path. + """Change the ['yaml_files'] property and remove the configuration path. - Also removes other files like service.yaml that gets loaded + Also removes other files like service.yaml that gets loaded. """ root = get_test_config_dir() keys = check_dict['yaml_files'].keys() @@ -178,7 +178,6 @@ class TestCheckConfig(unittest.TestCase): self.assertDictEqual({ 'components': {'http': {'api_password': 'abc123', 'cors_allowed_origins': [], - 'development': '0', 'ip_ban_enabled': True, 'login_attempts_threshold': -1, 'server_host': '0.0.0.0', diff --git a/tests/test_core.py b/tests/test_core.py index b7d049cb747..c3fea749f5d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -12,7 +12,8 @@ import pytz import pytest import homeassistant.core as ha -from homeassistant.exceptions import InvalidEntityFormatError +from homeassistant.exceptions import (InvalidEntityFormatError, + InvalidStateError) from homeassistant.util.async import run_coroutine_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import (METRIC_SYSTEM) @@ -421,6 +422,10 @@ class TestState(unittest.TestCase): InvalidEntityFormatError, ha.State, 'invalid_entity_format', 'test_state') + self.assertRaises( + InvalidStateError, ha.State, + 'domain.long_state', 't' * 256) + def test_domain(self): """Test domain.""" state = ha.State('some_domain.hello', 'world') diff --git a/tests/util/test_color.py b/tests/util/test_color.py index dfb2cd0733c..4c14258f2f2 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -57,7 +57,7 @@ class TestColorUtil(unittest.TestCase): color_util.color_RGB_to_hsv(255, 0, 0)) def test_color_hsv_to_RGB(self): - """Test color_RGB_to_hsv.""" + """Test color_hsv_to_RGB.""" self.assertEqual((0, 0, 0), color_util.color_hsv_to_RGB(0, 0, 0)) @@ -73,6 +73,23 @@ class TestColorUtil(unittest.TestCase): self.assertEqual((255, 0, 0), color_util.color_hsv_to_RGB(0, 255, 255)) + def test_color_hsb_to_RGB(self): + """Test color_hsb_to_RGB.""" + self.assertEqual((0, 0, 0), + color_util.color_hsb_to_RGB(0, 0, 0)) + + self.assertEqual((255, 255, 255), + color_util.color_hsb_to_RGB(0, 0, 1.0)) + + self.assertEqual((0, 0, 255), + color_util.color_hsb_to_RGB(240, 1.0, 1.0)) + + self.assertEqual((0, 255, 0), + color_util.color_hsb_to_RGB(120, 1.0, 1.0)) + + self.assertEqual((255, 0, 0), + color_util.color_hsb_to_RGB(0, 1.0, 1.0)) + def test_color_xy_to_hs(self): """Test color_xy_to_hs.""" self.assertEqual((8609, 255), diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 131819a6ca0..06676140702 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -3,7 +3,7 @@ # Keep this file as close as possible to the production Dockerfile, so the environments match. FROM python:3.6 -MAINTAINER Paulus Schoutsen +LABEL maintainer="Paulus Schoutsen " # Uncomment any of the following lines to disable the installation. #ENV INSTALL_TELLSTICK no