diff --git a/.coveragerc b/.coveragerc index c93fecc9c2e..c74de0026b1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -35,23 +35,35 @@ omit = homeassistant/components/bloomsky.py homeassistant/components/*/bloomsky.py + homeassistant/components/comfoconnect.py + homeassistant/components/*/comfoconnect.py + homeassistant/components/digital_ocean.py homeassistant/components/*/digital_ocean.py homeassistant/components/dweet.py homeassistant/components/*/dweet.py + homeassistant/components/eight_sleep.py + homeassistant/components/*/eight_sleep.py + homeassistant/components/ecobee.py homeassistant/components/*/ecobee.py + homeassistant/components/enocean.py + homeassistant/components/*/enocean.py + homeassistant/components/envisalink.py homeassistant/components/*/envisalink.py homeassistant/components/google.py homeassistant/components/*/google.py - homeassistant/components/insteon_hub.py - homeassistant/components/*/insteon_hub.py + homeassistant/components/hdmi_cec.py + homeassistant/components/*/hdmi_cec.py + + homeassistant/components/homematic.py + homeassistant/components/*/homematic.py homeassistant/components/insteon_local.py homeassistant/components/*/insteon_local.py @@ -65,12 +77,18 @@ omit = homeassistant/components/isy994.py homeassistant/components/*/isy994.py + homeassistant/components/joaoapps_join.py + homeassistant/components/*/joaoapps_join.py + homeassistant/components/juicenet.py homeassistant/components/*/juicenet.py homeassistant/components/kira.py homeassistant/components/*/kira.py + homeassistant/components/knx.py + homeassistant/components/*/knx.py + homeassistant/components/lutron.py homeassistant/components/*/lutron.py @@ -80,15 +98,27 @@ omit = homeassistant/components/mailgun.py homeassistant/components/*/mailgun.py + homeassistant/components/maxcube.py + homeassistant/components/*/maxcube.py + + homeassistant/components/mochad.py + homeassistant/components/*/mochad.py + homeassistant/components/modbus.py homeassistant/components/*/modbus.py homeassistant/components/mysensors.py homeassistant/components/*/mysensors.py + homeassistant/components/neato.py + homeassistant/components/*/neato.py + homeassistant/components/nest.py homeassistant/components/*/nest.py + homeassistant/components/netatmo.py + homeassistant/components/*/netatmo.py + homeassistant/components/octoprint.py homeassistant/components/*/octoprint.py @@ -116,6 +146,9 @@ omit = homeassistant/components/scsgate.py homeassistant/components/*/scsgate.py + homeassistant/components/tado.py + homeassistant/components/*/tado.py + homeassistant/components/tellduslive.py homeassistant/components/*/tellduslive.py @@ -148,45 +181,18 @@ omit = homeassistant/components/wink.py homeassistant/components/*/wink.py - homeassistant/components/zigbee.py - homeassistant/components/*/zigbee.py - - homeassistant/components/enocean.py - homeassistant/components/*/enocean.py - - homeassistant/components/netatmo.py - homeassistant/components/*/netatmo.py - - homeassistant/components/neato.py - homeassistant/components/*/neato.py - - homeassistant/components/homematic.py - homeassistant/components/*/homematic.py - - homeassistant/components/knx.py - homeassistant/components/*/knx.py - - homeassistant/components/zoneminder.py - homeassistant/components/*/zoneminder.py - - homeassistant/components/mochad.py - homeassistant/components/*/mochad.py - homeassistant/components/zabbix.py homeassistant/components/*/zabbix.py - homeassistant/components/maxcube.py - homeassistant/components/*/maxcube.py - - homeassistant/components/tado.py - homeassistant/components/*/tado.py - homeassistant/components/zha/__init__.py homeassistant/components/zha/const.py homeassistant/components/*/zha.py - homeassistant/components/eight_sleep.py - homeassistant/components/*/eight_sleep.py + homeassistant/components/zigbee.py + homeassistant/components/*/zigbee.py + + homeassistant/components/zoneminder.py + homeassistant/components/*/zoneminder.py homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/concord232.py @@ -224,11 +230,11 @@ omit = homeassistant/components/climate/sensibo.py homeassistant/components/cover/garadget.py homeassistant/components/cover/homematic.py + homeassistant/components/cover/knx.py homeassistant/components/cover/myq.py homeassistant/components/cover/opengarage.py homeassistant/components/cover/rpi_gpio.py homeassistant/components/cover/scsgate.py - homeassistant/components/cover/wink.py homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py homeassistant/components/device_tracker/asuswrt.py @@ -242,6 +248,7 @@ omit = homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/icloud.py homeassistant/components/device_tracker/linksys_ap.py + homeassistant/components/device_tracker/linksys_smart.py homeassistant/components/device_tracker/luci.py homeassistant/components/device_tracker/mikrotik.py homeassistant/components/device_tracker/netgear.py @@ -263,12 +270,10 @@ omit = homeassistant/components/fan/mqtt.py homeassistant/components/feedreader.py homeassistant/components/foursquare.py - homeassistant/components/hdmi_cec.py homeassistant/components/ifttt.py homeassistant/components/image_processing/dlib_face_detect.py homeassistant/components/image_processing/dlib_face_identify.py homeassistant/components/image_processing/seven_segments.py - homeassistant/components/joaoapps_join.py homeassistant/components/keyboard.py homeassistant/components/keyboard_remote.py homeassistant/components/light/avion.py @@ -278,7 +283,7 @@ omit = homeassistant/components/light/flux_led.py homeassistant/components/light/hue.py homeassistant/components/light/hyperion.py - homeassistant/components/light/lifx/*.py + homeassistant/components/light/lifx.py homeassistant/components/light/lifx_legacy.py homeassistant/components/light/limitlessled.py homeassistant/components/light/mystrom.py @@ -312,7 +317,6 @@ omit = homeassistant/components/media_player/frontier_silicon.py homeassistant/components/media_player/gpmdp.py homeassistant/components/media_player/gstreamer.py - homeassistant/components/media_player/hdmi_cec.py homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py homeassistant/components/media_player/lg_netcast.py @@ -342,13 +346,13 @@ omit = homeassistant/components/notify/aws_sns.py homeassistant/components/notify/aws_sqs.py homeassistant/components/notify/ciscospark.py + homeassistant/components/notify/clicksend.py homeassistant/components/notify/discord.py homeassistant/components/notify/facebook.py homeassistant/components/notify/free_mobile.py homeassistant/components/notify/gntp.py homeassistant/components/notify/group.py homeassistant/components/notify/instapush.py - homeassistant/components/notify/joaoapps_join.py homeassistant/components/notify/kodi.py homeassistant/components/notify/lannouncer.py homeassistant/components/notify/llamalab_automate.py @@ -379,8 +383,10 @@ omit = homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py + homeassistant/components/sensor/bh1750.py homeassistant/components/sensor/bitcoin.py homeassistant/components/sensor/blockchain.py + homeassistant/components/sensor/bme280.py homeassistant/components/sensor/bom.py homeassistant/components/sensor/broadlink.py homeassistant/components/sensor/buienradar.py @@ -419,6 +425,7 @@ omit = 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 @@ -473,6 +480,7 @@ omit = homeassistant/components/sensor/transmission.py homeassistant/components/sensor/twitch.py homeassistant/components/sensor/uber.py + homeassistant/components/sensor/upnp.py homeassistant/components/sensor/ups.py homeassistant/components/sensor/usps.py homeassistant/components/sensor/vasttrafik.py @@ -480,6 +488,7 @@ omit = homeassistant/components/sensor/xbox_live.py homeassistant/components/sensor/yweather.py homeassistant/components/sensor/zamg.py + homeassistant/components/shiftr.py homeassistant/components/spc.py homeassistant/components/switch/acer_projector.py homeassistant/components/switch/anel_pwrctrl.py @@ -489,7 +498,6 @@ omit = homeassistant/components/switch/dlink.py homeassistant/components/switch/edimax.py homeassistant/components/switch/fritzdect.py - homeassistant/components/switch/hdmi_cec.py homeassistant/components/switch/hikvisioncam.py homeassistant/components/switch/hook.py homeassistant/components/switch/kankun.py diff --git a/.dockerignore b/.dockerignore index e64c35dd6b8..3d8c32cfb92 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,14 @@ -.tox +# General files .git +.github +config + +# Test related files +.tox + +# Other virtualization methods +venv +.vagrant + +# Temporary files +**/__pycache__ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6dae36bb24b..a877f154037 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,7 @@ +# Notice: +# When updating this file, please also update virtualization/Docker/Dockerfile.dev +# This way, the development image and the production image are kept in sync. + FROM python:3.6 MAINTAINER Paulus Schoutsen @@ -21,8 +25,12 @@ RUN virtualization/Docker/setup_docker_prereqs # Install hass component dependencies COPY requirements_all.txt requirements_all.txt + +# Uninstall enum34 because some depenndecies install it but breaks Python 3.4+. +# See PR #8103 for more info. RUN pip3 install --no-cache-dir -r requirements_all.txt && \ - pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet + pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet && \ + pip3 uninstall -y enum34 # Copy source COPY . . diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py index 32f28865a41..66764f58c26 100644 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.verisure/ """ import logging +from time import sleep import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.verisure import HUB as hub @@ -20,20 +21,29 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Verisure platform.""" alarms = [] if int(hub.config.get(CONF_ALARM, 1)): - hub.update_alarms() - alarms.extend([ - VerisureAlarm(value.id) - for value in hub.alarm_status.values() - ]) + hub.update_overview() + alarms.append(VerisureAlarm()) add_devices(alarms) +def set_arm_state(state, code=None): + """Send set arm state command.""" + transaction_id = hub.session.set_arm_state(code, state)[ + 'armStateChangeTransactionId'] + _LOGGER.info('verisure set arm state %s', state) + transaction = {} + while 'result' not in transaction: + sleep(0.5) + transaction = hub.session.get_arm_state_transaction(transaction_id) + # pylint: disable=unexpected-keyword-arg + hub.update_overview(no_throttle=True) + + class VerisureAlarm(alarm.AlarmControlPanel): """Representation of a Verisure alarm status.""" - def __init__(self, device_id): - """Initialize the Verisure alarm panel.""" - self._id = device_id + def __init__(self): + """Initalize the Verisure alarm panel.""" self._state = STATE_UNKNOWN self._digits = hub.config.get(CONF_CODE_DIGITS) self._changed_by = None @@ -41,18 +51,13 @@ class VerisureAlarm(alarm.AlarmControlPanel): @property def name(self): """Return the name of the device.""" - return 'Alarm {}'.format(self._id) + return '{} alarm'.format(hub.session.installations[0]['alias']) @property def state(self): """Return the state of the device.""" return self._state - @property - def available(self): - """Return True if entity is available.""" - return hub.available - @property def code_format(self): """Return the code format as regex.""" @@ -65,33 +70,26 @@ class VerisureAlarm(alarm.AlarmControlPanel): def update(self): """Update alarm status.""" - hub.update_alarms() - - if hub.alarm_status[self._id].status == 'unarmed': + hub.update_overview() + status = hub.get_first("$.armState.statusType") + if status == 'DISARMED': self._state = STATE_ALARM_DISARMED - elif hub.alarm_status[self._id].status == 'armedhome': + elif status == 'ARMED_HOME': self._state = STATE_ALARM_ARMED_HOME - elif hub.alarm_status[self._id].status == 'armed': + elif status == 'ARMED_AWAY': self._state = STATE_ALARM_ARMED_AWAY - elif hub.alarm_status[self._id].status != 'pending': - _LOGGER.error( - "Unknown alarm state %s", hub.alarm_status[self._id].status) - self._changed_by = hub.alarm_status[self._id].name + elif status != 'PENDING': + _LOGGER.error('Unknown alarm state %s', status) + self._changed_by = hub.get_first("$.armState.name") def alarm_disarm(self, code=None): """Send disarm command.""" - hub.my_pages.alarm.set(code, 'DISARMED') - _LOGGER.info("Verisure alarm disarming") - hub.my_pages.alarm.wait_while_pending() + set_arm_state('DISARMED', code) def alarm_arm_home(self, code=None): """Send arm home command.""" - hub.my_pages.alarm.set(code, 'ARMED_HOME') - _LOGGER.info("Verisure alarm arming home") - hub.my_pages.alarm.wait_while_pending() + set_arm_state('ARMED_HOME', code) def alarm_arm_away(self, code=None): """Send arm away command.""" - hub.my_pages.alarm.set(code, 'ARMED_AWAY') - _LOGGER.info("Verisure alarm arming away") - hub.my_pages.alarm.wait_while_pending() + set_arm_state('ARMED_AWAY', code) diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py index 09db0f84346..b4de3c4a0f5 100644 --- a/homeassistant/components/alert.py +++ b/homeassistant/components/alert.py @@ -25,6 +25,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'alert' ENTITY_ID_FORMAT = DOMAIN + '.{}' +CONF_DONE_MESSAGE = 'done_message' CONF_CAN_ACK = 'can_acknowledge' CONF_NOTIFIERS = 'notifiers' CONF_REPEAT = 'repeat' @@ -35,6 +36,7 @@ DEFAULT_SKIP_FIRST = False ALERT_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_DONE_MESSAGE, default=None): cv.string, vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_STATE, default=STATE_ON): cv.string, vol.Required(CONF_REPEAT): vol.All(cv.ensure_list, [vol.Coerce(float)]), @@ -121,10 +123,10 @@ def async_setup(hass, config): # Setup alerts for entity_id, alert in alerts.items(): entity = Alert(hass, entity_id, - alert[CONF_NAME], alert[CONF_ENTITY_ID], - alert[CONF_STATE], alert[CONF_REPEAT], - alert[CONF_SKIP_FIRST], alert[CONF_NOTIFIERS], - alert[CONF_CAN_ACK]) + alert[CONF_NAME], alert[CONF_DONE_MESSAGE], + alert[CONF_ENTITY_ID], alert[CONF_STATE], + alert[CONF_REPEAT], alert[CONF_SKIP_FIRST], + alert[CONF_NOTIFIERS], alert[CONF_CAN_ACK]) all_alerts[entity.entity_id] = entity # Read descriptions @@ -154,8 +156,8 @@ def async_setup(hass, config): class Alert(ToggleEntity): """Representation of an alert.""" - def __init__(self, hass, entity_id, name, watched_entity_id, state, - repeat, skip_first, notifiers, can_ack): + def __init__(self, hass, entity_id, name, done_message, watched_entity_id, + state, repeat, skip_first, notifiers, can_ack): """Initialize the alert.""" self.hass = hass self._name = name @@ -163,6 +165,7 @@ class Alert(ToggleEntity): self._skip_first = skip_first self._notifiers = notifiers self._can_ack = can_ack + self._done_message = done_message self._delay = [timedelta(minutes=val) for val in repeat] self._next_delay = 0 @@ -170,6 +173,7 @@ class Alert(ToggleEntity): self._firing = False self._ack = False self._cancel = None + self._send_done_message = False self.entity_id = ENTITY_ID_FORMAT.format(entity_id) event.async_track_state_change( @@ -230,6 +234,8 @@ class Alert(ToggleEntity): self._cancel() self._ack = False self._firing = False + if self._done_message and self._send_done_message: + yield from self._notify_done_message() self.hass.async_add_job(self.async_update_ha_state) @asyncio.coroutine @@ -249,11 +255,21 @@ class Alert(ToggleEntity): if not self._ack: _LOGGER.info("Alerting: %s", self._name) + self._send_done_message = True for target in self._notifiers: yield from self.hass.services.async_call( 'notify', target, {'message': self._name}) yield from self._schedule_notify() + @asyncio.coroutine + def _notify_done_message(self, *args): + """Send notification of complete alert.""" + _LOGGER.info("Alerting: %s", self._done_message) + self._send_done_message = False + for target in self._notifiers: + yield from self.hass.services.async_call( + 'notify', target, {'message': self._done_message}) + @asyncio.coroutine def async_turn_on(self): """Async Unacknowledge alert.""" diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index feb77209237..630420bd3e5 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -1,27 +1,27 @@ """ This component provides basic support for Netgear Arlo IP cameras. -For more details about this platform, please refer to the documentation at +For more details about this component, please refer to the documentation at https://home-assistant.io/components/arlo/ """ import logging + import voluptuous as vol -from homeassistant.helpers import config_validation as cv - -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -import homeassistant.loader as loader - from requests.exceptions import HTTPError, ConnectTimeout +import homeassistant.loader as loader +from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD + REQUIREMENTS = ['pyarlo==0.0.4'] _LOGGER = logging.getLogger(__name__) -CONF_ATTRIBUTION = 'Data provided by arlo.netgear.com' - -DOMAIN = 'arlo' +CONF_ATTRIBUTION = "Data provided by arlo.netgear.com" +DATA_ARLO = 'data_arlo' DEFAULT_BRAND = 'Netgear Arlo' +DOMAIN = 'arlo' NOTIFICATION_ID = 'arlo_notification' NOTIFICATION_TITLE = 'Arlo Camera Setup' @@ -47,7 +47,7 @@ def setup(hass, config): arlo = PyArlo(username, password, preload=False) if not arlo.is_connected: return False - hass.data['arlo'] = arlo + hass.data[DATA_ARLO] = arlo except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Netgar Arlo: %s", str(ex)) persistent_notification.create( diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index 593eee2356e..bb1ec05496a 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -11,6 +11,7 @@ import os import voluptuous as vol +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_TRIGGER_TIME, @@ -18,11 +19,12 @@ from homeassistant.const import (ATTR_LOCATION, ATTR_TRIPPED, from homeassistant.components.discovery import SERVICE_AXIS from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component -REQUIREMENTS = ['axis==7'] +REQUIREMENTS = ['axis==8'] _LOGGER = logging.getLogger(__name__) @@ -59,6 +61,21 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +SERVICE_VAPIX_CALL = 'vapix_call' +SERVICE_VAPIX_CALL_RESPONSE = 'vapix_call_response' +SERVICE_CGI = 'cgi' +SERVICE_ACTION = 'action' +SERVICE_PARAM = 'param' +SERVICE_DEFAULT_CGI = 'param.cgi' +SERVICE_DEFAULT_ACTION = 'update' + +SERVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(SERVICE_PARAM): cv.string, + vol.Optional(SERVICE_CGI, default=SERVICE_DEFAULT_CGI): cv.string, + vol.Optional(SERVICE_ACTION, default=SERVICE_DEFAULT_ACTION): cv.string, +}) + def request_configuration(hass, name, host, serialnumber): """Request configuration steps from the user.""" @@ -135,23 +152,34 @@ def setup(hass, base_config): def axis_device_discovered(service, discovery_info): """Called when axis devices has been found.""" - host = discovery_info['host'] + host = discovery_info[CONF_HOST] name = discovery_info['hostname'] serialnumber = discovery_info['properties']['macaddress'] if serialnumber not in AXIS_DEVICES: config_file = _read_config(hass) if serialnumber in config_file: + # Device config saved to file try: config = DEVICE_SCHEMA(config_file[serialnumber]) + config[CONF_HOST] = host except vol.Invalid as err: _LOGGER.error("Bad data from %s. %s", CONFIG_FILE, err) return False if not setup_device(hass, config): - _LOGGER.error("Couldn\'t set up %s", config['name']) + _LOGGER.error("Couldn\'t set up %s", config[CONF_NAME]) else: + # New device, create configuration request for UI request_configuration(hass, name, host, serialnumber) + 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) + # Register discovery service discovery.listen(hass, SERVICE_AXIS, axis_device_discovered) if DOMAIN in base_config: @@ -160,7 +188,30 @@ def setup(hass, base_config): if CONF_NAME not in config: config[CONF_NAME] = device if not setup_device(hass, config): - _LOGGER.error("Couldn\'t set up %s", config['name']) + _LOGGER.error("Couldn\'t set up %s", config[CONF_NAME]) + + # Services to communicate with device. + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def vapix_service(call): + """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) + return True + _LOGGER.info("Couldn\'t find device %s", call.data[CONF_NAME]) + return False + + # Register service with Home Assistant. + hass.services.register(DOMAIN, + SERVICE_VAPIX_CALL, + vapix_service, + descriptions[DOMAIN][SERVICE_VAPIX_CALL], + schema=SERVICE_SCHEMA) return True @@ -190,8 +241,16 @@ def setup_device(hass, config): if enable_metadatastream: device.initialize_new_event = event_initialized - device.initiate_metadatastream() + if not device.initiate_metadatastream(): + notification = get_component('persistent_notification') + notification.create(hass, + 'Dependency missing for sensors, ' + 'please check documentation', + title=DOMAIN, + notification_id='axis_notification') + AXIS_DEVICES[device.serial_number] = device + return True @@ -311,4 +370,4 @@ REMAP = [{'type': 'motion', 'class': 'input', 'topic': 'tns1:Device/tnsaxis:IO/Port', 'subscribe': 'onvif:Device/axis:IO/Port', - 'platform': 'sensor'}, ] + 'platform': 'binary_sensor'}, ] diff --git a/homeassistant/components/binary_sensor/arest.py b/homeassistant/components/binary_sensor/arest.py index efa6043a035..972d9ca835c 100644 --- a/homeassistant/components/binary_sensor/arest.py +++ b/homeassistant/components/binary_sensor/arest.py @@ -51,7 +51,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([ArestBinarySensor( arest, resource, config.get(CONF_NAME, response[CONF_NAME]), - device_class, pin)]) + device_class, pin)], True) class ArestBinarySensor(BinarySensorDevice): @@ -64,7 +64,6 @@ class ArestBinarySensor(BinarySensorDevice): self._name = name self._device_class = device_class self._pin = pin - self.update() if self._pin is not None: request = requests.get( diff --git a/homeassistant/components/binary_sensor/digital_ocean.py b/homeassistant/components/binary_sensor/digital_ocean.py index ea02196f3eb..d9a0ac6711b 100644 --- a/homeassistant/components/binary_sensor/digital_ocean.py +++ b/homeassistant/components/binary_sensor/digital_ocean.py @@ -8,19 +8,18 @@ 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.digital_ocean import ( CONF_DROPLETS, ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME, ATTR_FEATURES, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, - ATTR_REGION, ATTR_VCPUS) -from homeassistant.loader import get_component -import homeassistant.helpers.config_validation as cv + ATTR_REGION, ATTR_VCPUS, DATA_DIGITAL_OCEAN) _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Droplet' -DEFAULT_SENSOR_CLASS = 'motion' +DEFAULT_SENSOR_CLASS = 'moving' DEPENDENCIES = ['digital_ocean'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -30,19 +29,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Digital Ocean droplet sensor.""" - digital_ocean = get_component('digital_ocean') + digital = hass.data.get(DATA_DIGITAL_OCEAN) + if not digital: + return False + droplets = config.get(CONF_DROPLETS) dev = [] for droplet in droplets: - droplet_id = digital_ocean.DIGITAL_OCEAN.get_droplet_id(droplet) + droplet_id = digital.get_droplet_id(droplet) if droplet_id is None: _LOGGER.error("Droplet %s is not available", droplet) return False - dev.append(DigitalOceanBinarySensor( - digital_ocean.DIGITAL_OCEAN, droplet_id)) + dev.append(DigitalOceanBinarySensor(digital, droplet_id)) - add_devices(dev) + add_devices(dev, True) class DigitalOceanBinarySensor(BinarySensorDevice): @@ -53,7 +54,7 @@ class DigitalOceanBinarySensor(BinarySensorDevice): self._digital_ocean = do self._droplet_id = droplet_id self._state = None - self.update() + self.data = None @property def name(self): diff --git a/homeassistant/components/binary_sensor/modbus.py b/homeassistant/components/binary_sensor/modbus.py index 54e4cefb230..3a9b57ba6de 100644 --- a/homeassistant/components/binary_sensor/modbus.py +++ b/homeassistant/components/binary_sensor/modbus.py @@ -50,6 +50,10 @@ class ModbusCoilSensor(BinarySensorDevice): self._coil = int(coil) self._value = None + def name(self): + """Return the name of the sensor.""" + return self._name + @property def is_on(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/binary_sensor/rfxtrx.py b/homeassistant/components/binary_sensor/rfxtrx.py new file mode 100644 index 00000000000..9a2c23206c1 --- /dev/null +++ b/homeassistant/components/binary_sensor/rfxtrx.py @@ -0,0 +1,233 @@ +""" +Support for RFXtrx binary sensors. + +Lighting4 devices (sensors based on PT2262 encoder) are supported and +tested. Other types may need some work. + +""" + +import logging +import voluptuous as vol +from homeassistant.components import rfxtrx +from homeassistant.util import slugify +from homeassistant.util import dt as dt_util +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import event as evt +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.rfxtrx import ( + ATTR_AUTOMATIC_ADD, ATTR_NAME, ATTR_OFF_DELAY, ATTR_FIREEVENT, + ATTR_DATA_BITS, CONF_DEVICES +) +from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF +) + +DEPENDENCIES = ["rfxtrx"] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required("platform"): rfxtrx.DOMAIN, + vol.Optional(CONF_DEVICES, default={}): vol.All( + dict, rfxtrx.valid_binary_sensor), + vol.Optional(ATTR_AUTOMATIC_ADD, default=False): cv.boolean, +}, extra=vol.ALLOW_EXTRA) + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup the Binary Sensor platform to rfxtrx.""" + import RFXtrx as rfxtrxmod + sensors = [] + + for packet_id, entity in config['devices'].items(): + event = rfxtrx.get_rfx_object(packet_id) + device_id = slugify(event.device.id_string.lower()) + + if device_id in rfxtrx.RFX_DEVICES: + continue + + if entity[ATTR_DATA_BITS] is not None: + _LOGGER.info("Masked device id: %s", + rfxtrx.get_pt2262_deviceid(device_id, + entity[ATTR_DATA_BITS])) + + _LOGGER.info("Add %s rfxtrx.binary_sensor (class %s)", + entity[ATTR_NAME], entity[CONF_DEVICE_CLASS]) + + device = RfxtrxBinarySensor(event, entity[ATTR_NAME], + entity[CONF_DEVICE_CLASS], + entity[ATTR_FIREEVENT], + entity[ATTR_OFF_DELAY], + entity[ATTR_DATA_BITS], + entity[CONF_COMMAND_ON], + entity[CONF_COMMAND_OFF]) + device.hass = hass + sensors.append(device) + rfxtrx.RFX_DEVICES[device_id] = device + + add_devices_callback(sensors) + + # pylint: disable=too-many-branches + def binary_sensor_update(event): + """Callback for control updates from the RFXtrx gateway.""" + if not isinstance(event, rfxtrxmod.ControlEvent): + return + + device_id = slugify(event.device.id_string.lower()) + + if device_id in rfxtrx.RFX_DEVICES: + sensor = rfxtrx.RFX_DEVICES[device_id] + else: + sensor = rfxtrx.get_pt2262_device(device_id) + + if sensor is None: + # Add the entity if not exists and automatic_add is True + 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) + + pkt_id = "".join("{0:02x}".format(x) for x in event.data) + sensor = RfxtrxBinarySensor(event, pkt_id) + rfxtrx.RFX_DEVICES[device_id] = sensor + add_devices_callback([sensor]) + _LOGGER.info("Added binary sensor %s " + "(Device_id: %s Class: %s Sub: %s)", + pkt_id, + slugify(event.device.id_string.lower()), + event.device.__class__.__name__, + event.device.subtype) + + elif not isinstance(sensor, RfxtrxBinarySensor): + return + else: + _LOGGER.info("Binary sensor update " + "(Device_id: %s Class: %s Sub: %s)", + slugify(event.device.id_string.lower()), + event.device.__class__.__name__, + event.device.subtype) + + if sensor.is_pt2262: + cmd = rfxtrx.get_pt2262_cmd(device_id, sensor.data_bits) + _LOGGER.info("applying cmd %s to device_id: %s)", + cmd, sensor.masked_id) + sensor.apply_cmd(int(cmd, 16)) + else: + rfxtrx.apply_received_command(event) + + if (sensor.is_on and sensor.off_delay is not None and + sensor.delay_listener is None): + + def off_delay_listener(now): + """Switch device off after a delay.""" + sensor.delay_listener = None + sensor.update_state(False) + + sensor.delay_listener = evt.track_point_in_time( + hass, off_delay_listener, dt_util.utcnow() + sensor.off_delay + ) + + # Subscribe to main rfxtrx events + if binary_sensor_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS: + rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(binary_sensor_update) + + +# pylint: disable=too-many-instance-attributes,too-many-arguments +class RfxtrxBinarySensor(BinarySensorDevice): + """An Rfxtrx binary sensor.""" + + def __init__(self, event, name, device_class=None, + should_fire=False, off_delay=None, data_bits=None, + cmd_on=None, cmd_off=None): + """Initialize the sensor.""" + self.event = event + self._name = name + self._should_fire_event = should_fire + self._device_class = device_class + self._off_delay = off_delay + self._state = False + self.delay_listener = None + self._data_bits = data_bits + self._cmd_on = cmd_on + self._cmd_off = cmd_off + + if data_bits is not None: + self._masked_id = rfxtrx.get_pt2262_deviceid( + event.device.id_string.lower(), + data_bits) + + def __str__(self): + """Return the name of the sensor.""" + return self._name + + @property + def name(self): + """Return the device name.""" + return self._name + + @property + def is_pt2262(self): + """Return true if the device is PT2262-based.""" + return self._data_bits is not None + + @property + def masked_id(self): + """Return the masked device id (isolated address bits).""" + return self._masked_id + + @property + def data_bits(self): + """Return the number of data bits.""" + return self._data_bits + + @property + def cmd_on(self): + """Return the value of the 'On' command.""" + return self._cmd_on + + @property + def cmd_off(self): + """Return the value of the 'Off' command.""" + return self._cmd_off + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def should_fire_event(self): + """Return is the device must fire event.""" + return self._should_fire_event + + @property + def device_class(self): + """Return the sensor class.""" + return self._device_class + + @property + def off_delay(self): + """Return the off_delay attribute value.""" + return self._off_delay + + @property + def is_on(self): + """Return true if the sensor state is True.""" + return self._state + + def apply_cmd(self, cmd): + """Apply a command for updating the state.""" + if cmd == self.cmd_on: + self.update_state(True) + elif cmd == self.cmd_off: + self.update_state(False) + + def update_state(self, state): + """Update the state of the device.""" + self._state = state + self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/verisure.py b/homeassistant/components/binary_sensor/verisure.py new file mode 100644 index 00000000000..8702c8bd770 --- /dev/null +++ b/homeassistant/components/binary_sensor/verisure.py @@ -0,0 +1,59 @@ +""" +Interfaces with Verisure sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.verisure/ +""" +import logging + +from homeassistant.components.verisure import HUB as hub +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.verisure import CONF_DOOR_WINDOW + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup Verisure binary sensors.""" + sensors = [] + hub.update_overview() + + if int(hub.config.get(CONF_DOOR_WINDOW, 1)): + sensors.extend([ + VerisureDoorWindowSensor(device_label) + for device_label in hub.get( + "$.doorWindow.doorWindowDevice[*].deviceLabel")]) + add_devices(sensors) + + +class VerisureDoorWindowSensor(BinarySensorDevice): + """Verisure door window sensor.""" + + def __init__(self, device_label): + """Initialize the modbus coil sensor.""" + self._device_label = device_label + + @property + def name(self): + """Return the name of the binary sensor.""" + return hub.get_first( + "$.doorWindow.doorWindowDevice[?(@.deviceLabel=='%s')].area", + self._device_label) + + @property + def is_on(self): + """Return the state of the sensor.""" + return hub.get_first( + "$.doorWindow.doorWindowDevice[?(@.deviceLabel=='%s')].state", + self._device_label) == "OPEN" + + @property + def available(self): + """Return True if entity is available.""" + return hub.get_first( + "$.doorWindow.doorWindowDevice[?(@.deviceLabel=='%s')]", + self._device_label) is not None + + def update(self): + """Update the state of the sensor.""" + hub.update_overview() diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py index 67d2e7179ba..098c7c70834 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -30,7 +30,7 @@ def setup_platform(hass, config, add_devices, disc_info=None): if disc_info is None: return - if not any([data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]]): + if not any(data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]): return calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE)) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 7c21b99ddda..c84421f50ea 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -12,13 +12,16 @@ from datetime import timedelta import logging import hashlib from random import SystemRandom +import os import aiohttp from aiohttp import web import async_timeout +import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import ATTR_ENTITY_PICTURE +from homeassistant.const import (ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE) +from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity @@ -26,9 +29,12 @@ 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 _LOGGER = logging.getLogger(__name__) +SERVICE_EN_MOTION = 'enable_motion_detection' +SERVICE_DISEN_MOTION = 'disable_motion_detection' DOMAIN = 'camera' DEPENDENCIES = ['http'] SCAN_INTERVAL = timedelta(seconds=30) @@ -38,11 +44,30 @@ STATE_RECORDING = 'recording' STATE_STREAMING = 'streaming' STATE_IDLE = 'idle' +DEFAULT_CONTENT_TYPE = 'image/jpeg' ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}' TOKEN_CHANGE_INTERVAL = timedelta(minutes=5) _RND = SystemRandom() +CAMERA_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + + +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)) + + +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)) + @asyncio.coroutine def async_get_image(hass, entity_id, timeout=10): @@ -92,6 +117,44 @@ def async_setup(hass, config): hass.async_add_job(entity.async_update_ha_state()) async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) + + @asyncio.coroutine + def async_handle_camera_service(service): + """Handle calls to the camera services.""" + target_cameras = component.async_extract_from_service(service) + + for camera in target_cameras: + if service.service == SERVICE_EN_MOTION: + yield from camera.async_enable_motion_detection() + elif service.service == SERVICE_DISEN_MOTION: + yield from camera.async_disable_motion_detection() + + update_tasks = [] + for camera in target_cameras: + if not camera.should_poll: + continue + + update_coro = hass.async_add_job( + camera.async_update_ha_state(True)) + if hasattr(camera, 'async_update'): + update_tasks.append(update_coro) + else: + yield from update_coro + + 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')) + + hass.services.async_register( + DOMAIN, SERVICE_EN_MOTION, async_handle_camera_service, + descriptions.get(SERVICE_EN_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) + return True @@ -101,6 +164,7 @@ class Camera(Entity): def __init__(self): """Initialize a camera.""" self.is_streaming = False + self.content_type = DEFAULT_CONTENT_TYPE self.access_tokens = collections.deque([], 2) self.async_update_token() @@ -124,6 +188,11 @@ class Camera(Entity): """Return the camera brand.""" return None + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return None + @property def model(self): """Return the camera model.""" @@ -149,16 +218,17 @@ class Camera(Entity): response = web.StreamResponse() response.content_type = ('multipart/x-mixed-replace; ' - 'boundary=--jpegboundary') + 'boundary=--frameboundary') yield from response.prepare(request) def write(img_bytes): """Write image to stream.""" response.write(bytes( - '--jpegboundary\r\n' - 'Content-Type: image/jpeg\r\n' + '--frameboundary\r\n' + 'Content-Type: {}\r\n' 'Content-Length: {}\r\n\r\n'.format( - len(img_bytes)), 'utf-8') + img_bytes + b'\r\n') + self.content_type, len(img_bytes)), + 'utf-8') + img_bytes + b'\r\n') last_image = None @@ -199,6 +269,22 @@ class Camera(Entity): else: return STATE_IDLE + def enable_motion_detection(self): + """Enable motion detection in the camera.""" + raise NotImplementedError() + + def async_enable_motion_detection(self): + """Call the job and enable motion detection.""" + return self.hass.async_add_job(self.enable_motion_detection) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + raise NotImplementedError() + + def async_disable_motion_detection(self): + """Call the job and disable motion detection.""" + return self.hass.async_add_job(self.disable_motion_detection) + @property def state_attributes(self): """Return the camera state attributes.""" @@ -212,6 +298,9 @@ class Camera(Entity): if self.brand: attr['brand'] = self.brand + if self.motion_detection_enabled: + attr['motion_detection'] = self.motion_detection_enabled + return attr @callback @@ -269,7 +358,8 @@ class CameraImageView(CameraView): image = yield from camera.async_camera_image() if image: - return web.Response(body=image, content_type='image/jpeg') + return web.Response(body=image, + content_type=camera.content_type) return web.Response(status=500) diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index 16688370b07..be6aab30af5 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -6,32 +6,32 @@ https://home-assistant.io/components/camera.arlo/ """ import asyncio import logging + import voluptuous as vol from homeassistant.helpers import config_validation as cv -from homeassistant.components.arlo import DEFAULT_BRAND - -from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.components.arlo import DEFAULT_BRAND, DATA_ARLO +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA from homeassistant.components.ffmpeg import DATA_FFMPEG -from homeassistant.helpers.aiohttp_client import ( - async_aiohttp_proxy_stream) DEPENDENCIES = ['arlo', 'ffmpeg'] _LOGGER = logging.getLogger(__name__) CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' +ARLO_MODE_ARMED = 'armed' +ARLO_MODE_DISARMED = 'disarmed' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_FFMPEG_ARGUMENTS): - cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, }) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up an Arlo IP Camera.""" - arlo = hass.data.get('arlo') + arlo = hass.data.get(DATA_ARLO) if not arlo: return False @@ -40,7 +40,6 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): cameras.append(ArloCam(hass, camera, config)) async_add_devices(cameras, True) - return True class ArloCam(Camera): @@ -49,14 +48,15 @@ class ArloCam(Camera): def __init__(self, hass, camera, device_info): """Initialize an Arlo camera.""" super().__init__() - self._camera = camera + self._base_stn = hass.data['arlo'].base_stations[0] self._name = self._camera.name + self._motion_status = False self._ffmpeg = hass.data[DATA_FFMPEG] self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" return self._camera.last_image @asyncio.coroutine @@ -90,3 +90,27 @@ class ArloCam(Camera): def brand(self): """Camera brand.""" return DEFAULT_BRAND + + @property + def should_poll(self): + """Camera should poll periodically.""" + return True + + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + return self._motion_status + + def set_base_station_mode(self, mode): + """Set the mode in the base station.""" + self._base_stn.mode = mode + + def enable_motion_detection(self): + """Enable the Motion detection in base station (Arm).""" + self._motion_status = True + self.set_base_station_mode(ARLO_MODE_ARMED) + + def disable_motion_detection(self): + """Disable the motion detection in base station (Disarm).""" + self._motion_status = False + self.set_base_station_mode(ARLO_MODE_DISARMED) diff --git a/homeassistant/components/camera/axis.py b/homeassistant/components/camera/axis.py index 3de1c568745..b0295b9ee34 100644 --- a/homeassistant/components/camera/axis.py +++ b/homeassistant/components/camera/axis.py @@ -7,15 +7,16 @@ https://home-assistant.io/components/camera.axis/ import logging from homeassistant.const import ( - CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, 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 _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['axis'] DOMAIN = 'axis' +DEPENDENCIES = [DOMAIN] def _get_image_url(host, mode): @@ -27,12 +28,29 @@ def _get_image_url(host, mode): def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Axis camera.""" - device_info = { - CONF_NAME: discovery_info['name'], - CONF_USERNAME: discovery_info['username'], - CONF_PASSWORD: discovery_info['password'], - CONF_MJPEG_URL: _get_image_url(discovery_info['host'], 'mjpeg'), - CONF_STILL_IMAGE_URL: _get_image_url(discovery_info['host'], 'single'), + config = { + CONF_NAME: discovery_info[CONF_NAME], + CONF_USERNAME: discovery_info[CONF_USERNAME], + CONF_PASSWORD: discovery_info[CONF_PASSWORD], + CONF_MJPEG_URL: _get_image_url(discovery_info[CONF_HOST], 'mjpeg'), + CONF_STILL_IMAGE_URL: _get_image_url(discovery_info[CONF_HOST], + 'single'), CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, } - add_devices([MjpegCamera(hass, device_info)]) + add_devices([AxisCamera(hass, config)]) + + +class AxisCamera(MjpegCamera): + """AxisCamera class.""" + + def __init__(self, hass, config): + """Initialize Axis Communications camera component.""" + super().__init__(hass, config) + async_dispatcher_connect(hass, + DOMAIN + '_' + config[CONF_NAME] + '_new_ip', + self._new_ip) + + def _new_ip(self, host): + """Set new IP for video stream.""" + self._mjpeg_url = _get_image_url(host, 'mjpeg') + self._still_image_url = _get_image_url(host, 'mjpeg') diff --git a/homeassistant/components/camera/demo.py b/homeassistant/components/camera/demo.py index 158f6c11751..d009f156e9d 100644 --- a/homeassistant/components/camera/demo.py +++ b/homeassistant/components/camera/demo.py @@ -5,25 +5,29 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ import os - +import logging import homeassistant.util.dt as dt_util from homeassistant.components.camera import Camera +_LOGGER = logging.getLogger(__name__) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo camera platform.""" add_devices([ - DemoCamera('Demo camera') + DemoCamera(hass, config, 'Demo camera') ]) class DemoCamera(Camera): """The representation of a Demo camera.""" - def __init__(self, name): + def __init__(self, hass, config, name): """Initialize demo camera component.""" super().__init__() + self._parent = hass self._name = name + self._motion_status = False def camera_image(self): """Return a faked still image response.""" @@ -38,3 +42,21 @@ class DemoCamera(Camera): def name(self): """Return the name of this camera.""" return self._name + + @property + def should_poll(self): + """Camera should poll periodically.""" + return True + + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + return self._motion_status + + def enable_motion_detection(self): + """Enable the Motion detection in base station (Arm).""" + self._motion_status = True + + def disable_motion_detection(self): + """Disable the motion detection in base station (Disarm).""" + self._motion_status = False diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index 8a9854ab97e..3f8c4bedc75 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -17,13 +17,15 @@ from homeassistant.const import ( CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) from homeassistant.exceptions import TemplateError -from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera) +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, DEFAULT_CONTENT_TYPE, Camera) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers import config_validation as cv from homeassistant.util.async import run_coroutine_threadsafe _LOGGER = logging.getLogger(__name__) +CONF_CONTENT_TYPE = 'content_type' CONF_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change' CONF_STILL_IMAGE_URL = 'still_image_url' @@ -37,6 +39,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_CONTENT_TYPE, default=DEFAULT_CONTENT_TYPE): cv.string, }) @@ -59,6 +62,7 @@ class GenericCamera(Camera): self._still_image_url = device_info[CONF_STILL_IMAGE_URL] self._still_image_url.hass = hass self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE] + self.content_type = device_info[CONF_CONTENT_TYPE] username = device_info.get(CONF_USERNAME) password = device_info.get(CONF_PASSWORD) diff --git a/homeassistant/components/camera/local_file.py b/homeassistant/components/camera/local_file.py index 1f3a71c9d05..95d24c7d42e 100644 --- a/homeassistant/components/camera/local_file.py +++ b/homeassistant/components/camera/local_file.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.local_file/ """ import logging +import mimetypes import os import voluptuous as vol @@ -46,6 +47,10 @@ class LocalFile(Camera): self._name = name self._file_path = file_path + # Set content type of local file + content, _ = mimetypes.guess_type(file_path) + if content is not None: + self.content_type = content def camera_image(self): """Return image response.""" diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml new file mode 100644 index 00000000000..b6ed22f708a --- /dev/null +++ b/homeassistant/components/camera/services.yaml @@ -0,0 +1,17 @@ +# Describes the format for available camera services + +enable_motion_detection: + description: Enable the motion detection in a camera + + fields: + entity_id: + 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 + + fields: + entity_id: + description: Name(s) of entities to disable motion detection + example: 'camera.living_room_camera' diff --git a/homeassistant/components/camera/verisure.py b/homeassistant/components/camera/verisure.py index 1c2e7e382fe..b637858303e 100644 --- a/homeassistant/components/camera/verisure.py +++ b/homeassistant/components/camera/verisure.py @@ -24,22 +24,23 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if not os.access(directory_path, os.R_OK): _LOGGER.error("file path %s is not readable", directory_path) return False - hub.update_smartcam() + hub.update_overview() smartcams = [] smartcams.extend([ - VerisureSmartcam(hass, value.deviceLabel, directory_path) - for value in hub.smartcam_status.values()]) + VerisureSmartcam(hass, device_label, directory_path) + for device_label in hub.get( + "$.customerImageCameras[*].deviceLabel")]) add_devices(smartcams) class VerisureSmartcam(Camera): """Representation of a Verisure camera.""" - def __init__(self, hass, device_id, directory_path): + def __init__(self, hass, device_label, directory_path): """Initialize Verisure File Camera component.""" super().__init__() - self._device_id = device_id + self._device_label = device_label self._directory_path = directory_path self._image = None self._image_id = None @@ -58,28 +59,27 @@ class VerisureSmartcam(Camera): def check_imagelist(self): """Check the contents of the image list.""" - hub.update_smartcam_imagelist() - if (self._device_id not in hub.smartcam_dict or - not hub.smartcam_dict[self._device_id]): + hub.update_smartcam_imageseries() + image_ids = hub.get_image_info( + "$.imageSeries[?(@.deviceLabel=='%s')].image[0].imageId", + self._device_label) + if not image_ids: return - images = hub.smartcam_dict[self._device_id] - new_image_id = images[0] - _LOGGER.debug("self._device_id=%s, self._images=%s, " - "self._new_image_id=%s", self._device_id, - images, new_image_id) + new_image_id = image_ids[0] if (new_image_id == '-1' or self._image_id == new_image_id): _LOGGER.debug("The image is the same, or loading image_id") return _LOGGER.debug("Download new image %s", new_image_id) - hub.my_pages.smartcam.download_image( - self._device_id, new_image_id, self._directory_path) + new_image_path = os.path.join( + self._directory_path, '{}{}'.format(new_image_id, '.jpg')) + hub.session.download_image( + self._device_label, new_image_id, new_image_path) _LOGGER.debug("Old image_id=%s", self._image_id) self.delete_image(self) self._image_id = new_image_id - self._image = os.path.join( - self._directory_path, '{}{}'.format(self._image_id, '.jpg')) + self._image = new_image_path def delete_image(self, event): """Delete an old image.""" @@ -95,4 +95,6 @@ class VerisureSmartcam(Camera): @property def name(self): """Return the name of this camera.""" - return hub.smartcam_status[self._device_id].location + return hub.get_first( + "$.customerImageCameras[?(@.deviceLabel=='%s')].area", + self._device_label) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index f9405e4b040..d00d30c3b19 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -693,8 +693,14 @@ class ClimateDevice(Entity): def _convert_for_display(self, temp): """Convert temperature into preferred units for display purposes.""" - if temp is None or not isinstance(temp, Number): + if temp is None: return temp + + # if the temperature is not a number this can cause issues + # with polymer components, so bail early there. + if not isinstance(temp, Number): + raise TypeError("Temperature is not a number: %s" % temp) + if self.temperature_unit != self.unit_of_measurement: temp = convert_temperature( temp, self.temperature_unit, self.unit_of_measurement) diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py index adf44a81829..82ed8a94e2b 100755 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -67,7 +67,12 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self._values.get(self.gateway.const.SetReq.V_TEMP) + value = self._values.get(self.gateway.const.SetReq.V_TEMP) + + if value is not None: + value = float(value) + + return value @property def target_temperature(self): @@ -79,21 +84,21 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) if temp is None: temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) - return temp + return float(temp) @property def target_temperature_high(self): """Return the highbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq if set_req.V_HVAC_SETPOINT_HEAT in self._values: - return self._values.get(set_req.V_HVAC_SETPOINT_COOL) + return float(self._values.get(set_req.V_HVAC_SETPOINT_COOL)) @property def target_temperature_low(self): """Return the lowbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq if set_req.V_HVAC_SETPOINT_COOL in self._values: - return self._values.get(set_req.V_HVAC_SETPOINT_HEAT) + return float(self._values.get(set_req.V_HVAC_SETPOINT_HEAT)) @property def current_operation(self): diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py index 5bbed953047..8011f53f375 100644 --- a/homeassistant/components/climate/radiotherm.py +++ b/homeassistant/components/climate/radiotherm.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( from homeassistant.const import CONF_HOST, TEMP_FAHRENHEIT, ATTR_TEMPERATURE import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['radiotherm==1.2'] +REQUIREMENTS = ['radiotherm==1.3'] _LOGGER = logging.getLogger(__name__) @@ -84,6 +84,7 @@ class RadioThermostat(ClimateDevice): self._name = None self._fmode = None self._tmode = None + self._tstate = None self._hold_temp = hold_temp self._away = False self._away_temps = away_temps @@ -140,6 +141,7 @@ class RadioThermostat(ClimateDevice): self._name = self.device.name['raw'] self._fmode = self.device.fmode['human'] self._tmode = self.device.tmode['human'] + self._tstate = self.device.tstate['human'] if self._tmode == 'Cool': self._target_temperature = self.device.t_cool['raw'] @@ -147,6 +149,12 @@ class RadioThermostat(ClimateDevice): elif self._tmode == 'Heat': self._target_temperature = self.device.t_heat['raw'] self._current_operation = STATE_HEAT + elif self._tmode == 'Auto': + if self._tstate == 'Cool': + self._target_temperature = self.device.t_cool['raw'] + elif self._tstate == 'Heat': + self._target_temperature = self.device.t_heat['raw'] + self._current_operation = STATE_AUTO else: self._current_operation = STATE_IDLE @@ -159,6 +167,12 @@ class RadioThermostat(ClimateDevice): self.device.t_cool = round(temperature * 2.0) / 2.0 elif self._current_operation == STATE_HEAT: self.device.t_heat = round(temperature * 2.0) / 2.0 + elif self._current_operation == STATE_AUTO: + if self._tstate == 'Cool': + self.device.t_cool = round(temperature * 2.0) / 2.0 + elif self._tstate == 'Heat': + self.device.t_heat = round(temperature * 2.0) / 2.0 + if self._hold_temp or self._away: self.device.hold = 1 else: diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index 79d231a69c5..501a346d8c5 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, TEMP_CELSIUS, TEMP_FAHRENHEIT) from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ClimateDevice, PLATFORM_SCHEMA) +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.temperature import convert as convert_temperature @@ -52,9 +53,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): yield from client.async_get_devices(_INITIAL_FETCH_FIELDS)): if config[CONF_ID] == ALL or dev['id'] in config[CONF_ID]: devices.append(SensiboClimate(client, dev)) - except aiohttp.client_exceptions.ClientConnectorError: + except (aiohttp.client_exceptions.ClientConnectorError, + asyncio.TimeoutError): _LOGGER.exception('Failed to connct to Sensibo servers.') - return False + raise PlatformNotReady if devices: async_add_devices(devices) diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py index af9ad44fd7e..832bca6f9b6 100644 --- a/homeassistant/components/climate/tado.py +++ b/homeassistant/components/climate/tado.py @@ -24,6 +24,17 @@ CONST_OVERLAY_MANUAL = 'MANUAL' # the temperature will be reset after a timespan CONST_OVERLAY_TIMER = 'TIMER' +CONST_MODE_FAN_HIGH = 'HIGH' +CONST_MODE_FAN_MIDDLE = 'MIDDLE' +CONST_MODE_FAN_LOW = 'LOW' + +FAN_MODES_LIST = { + CONST_MODE_FAN_HIGH: 'High', + CONST_MODE_FAN_MIDDLE: 'Middle', + CONST_MODE_FAN_LOW: 'Low', + CONST_MODE_OFF: 'Off', +} + OPERATION_LIST = { CONST_OVERLAY_MANUAL: 'Manual', CONST_OVERLAY_TIMER: 'Timer', @@ -60,9 +71,15 @@ def create_climate_device(tado, hass, zone, name, zone_id): capabilities = tado.get_capabilities(zone_id) unit = TEMP_CELSIUS - min_temp = float(capabilities['temperatures']['celsius']['min']) - max_temp = float(capabilities['temperatures']['celsius']['max']) - ac_mode = capabilities['type'] != 'HEATING' + ac_mode = capabilities['type'] == 'AIR_CONDITIONING' + + if ac_mode: + temperatures = capabilities['HEAT']['temperatures'] + else: + temperatures = capabilities['temperatures'] + + min_temp = float(temperatures['celsius']['min']) + max_temp = float(temperatures['celsius']['max']) data_id = 'zone {} {}'.format(name, zone_id) device = TadoClimate(tado, @@ -107,7 +124,9 @@ class TadoClimate(ClimateDevice): self._max_temp = max_temp self._target_temp = None self._tolerance = tolerance + self._cooling = False + self._current_fan = CONST_MODE_OFF self._current_operation = CONST_MODE_SMART_SCHEDULE self._overlay_mode = CONST_MODE_SMART_SCHEDULE @@ -129,13 +148,32 @@ class TadoClimate(ClimateDevice): @property def current_operation(self): """Return current readable operation mode.""" - return OPERATION_LIST.get(self._current_operation) + if self._cooling: + return "Cooling" + else: + return OPERATION_LIST.get(self._current_operation) @property def operation_list(self): """Return the list of available operation modes (readable).""" return list(OPERATION_LIST.values()) + @property + def current_fan_mode(self): + """Return the fan setting.""" + if self.ac_mode: + return FAN_MODES_LIST.get(self._current_fan) + else: + return None + + @property + def fan_list(self): + """List of available fan modes.""" + if self.ac_mode: + return list(FAN_MODES_LIST.values()) + else: + return None + @property def temperature_unit(self): """Return the unit of measurement used by the platform.""" @@ -205,27 +243,27 @@ class TadoClimate(ClimateDevice): if 'sensorDataPoints' in data: sensor_data = data['sensorDataPoints'] - temperature = float( - sensor_data['insideTemperature']['celsius']) - humidity = float( - sensor_data['humidity']['percentage']) - setting = 0 + + unit = TEMP_CELSIUS + + if 'insideTemperature' in sensor_data: + temperature = float( + sensor_data['insideTemperature']['celsius']) + self._cur_temp = self.hass.config.units.temperature( + temperature, unit) + + if 'humidity' in sensor_data: + humidity = float( + sensor_data['humidity']['percentage']) + self._cur_humidity = humidity # temperature setting will not exist when device is off if 'temperature' in data['setting'] and \ data['setting']['temperature'] is not None: setting = float( data['setting']['temperature']['celsius']) - - unit = TEMP_CELSIUS - - self._cur_temp = self.hass.config.units.temperature( - temperature, unit) - - self._target_temp = self.hass.config.units.temperature( - setting, unit) - - self._cur_humidity = humidity + self._target_temp = self.hass.config.units.temperature( + setting, unit) if 'tadoMode' in data: mode = data['tadoMode'] @@ -235,29 +273,39 @@ class TadoClimate(ClimateDevice): power = data['setting']['power'] if power == 'OFF': self._current_operation = CONST_MODE_OFF + self._current_fan = CONST_MODE_OFF + # There is no overlay, the mode will always be + # "SMART_SCHEDULE" + self._overlay_mode = CONST_MODE_SMART_SCHEDULE self._device_is_active = False else: self._device_is_active = True - if 'overlay' in data and data['overlay'] is not None: - overlay = True - termination = data['overlay']['termination']['type'] - else: + if self._device_is_active: overlay = False - termination = "" + overlay_data = None + termination = self._current_operation + cooling = False + fan_speed = CONST_MODE_OFF - # If you set mode manualy to off, there will be an overlay - # and a termination, but we want to see the mode "OFF" + if 'overlay' in data: + overlay_data = data['overlay'] + overlay = overlay_data is not None + + if overlay: + termination = overlay_data['termination']['type'] + + if 'setting' in overlay_data: + cooling = overlay_data['setting']['mode'] == 'COOL' + fan_speed = overlay_data['setting']['fanSpeed'] + + # If you set mode manualy to off, there will be an overlay + # and a termination, but we want to see the mode "OFF" - if overlay and self._device_is_active: - # There is an overlay the device is on self._overlay_mode = termination self._current_operation = termination - else: - # There is no overlay, the mode will always be - # "SMART_SCHEDULE" - self._overlay_mode = CONST_MODE_SMART_SCHEDULE - self._current_operation = CONST_MODE_SMART_SCHEDULE + self._cooling = cooling + self._current_fan = fan_speed def _control_heating(self): """Send new target temperature to mytado.""" diff --git a/homeassistant/components/comfoconnect.py b/homeassistant/components/comfoconnect.py new file mode 100644 index 00000000000..ba2180078e3 --- /dev/null +++ b/homeassistant/components/comfoconnect.py @@ -0,0 +1,134 @@ +""" +Support to control a Zehnder ComfoAir Q350/450/600 ventilation unit. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/comfoconnect/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_TOKEN, CONF_PIN, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import (discovery) +from homeassistant.helpers.dispatcher import (dispatcher_send) + +REQUIREMENTS = ['pycomfoconnect==0.3'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'comfoconnect' + +SIGNAL_COMFOCONNECT_UPDATE_RECEIVED = 'comfoconnect_update_received' + +ATTR_CURRENT_TEMPERATURE = 'current_temperature' +ATTR_CURRENT_HUMIDITY = 'current_humidity' +ATTR_OUTSIDE_TEMPERATURE = 'outside_temperature' +ATTR_OUTSIDE_HUMIDITY = 'outside_humidity' +ATTR_AIR_FLOW_SUPPLY = 'air_flow_supply' +ATTR_AIR_FLOW_EXHAUST = 'air_flow_exhaust' + +CONF_USER_AGENT = 'user_agent' + +DEFAULT_NAME = 'ComfoAirQ' +DEFAULT_PIN = 0 +DEFAULT_TOKEN = '00000000000000000000000000000001' +DEFAULT_USER_AGENT = 'Home Assistant' + +DEVICE = None + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TOKEN, default=DEFAULT_TOKEN): + vol.Length(min=32, max=32, msg='invalid token'), + vol.Optional(CONF_USER_AGENT, default=DEFAULT_USER_AGENT): cv.string, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the ComfoConnect bridge.""" + from pycomfoconnect import (Bridge) + + conf = config[DOMAIN] + host = conf.get(CONF_HOST) + name = conf.get(CONF_NAME) + token = conf.get(CONF_TOKEN) + user_agent = conf.get(CONF_USER_AGENT) + pin = conf.get(CONF_PIN) + + # Run discovery on the configured ip + bridges = Bridge.discover(host) + if not bridges: + _LOGGER.error("Could not connect to ComfoConnect bridge on %s", host) + return False + bridge = bridges[0] + _LOGGER.info("Bridge found: %s (%s)", bridge.uuid.hex(), bridge.host) + + # Setup ComfoConnect Bridge + ccb = ComfoConnectBridge(hass, bridge, name, token, user_agent, pin) + hass.data[DOMAIN] = ccb + + # Start connection with bridge + ccb.connect() + + # Schedule disconnect on shutdown + def _shutdown(_event): + ccb.disconnect() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + + # Load platforms + discovery.load_platform(hass, 'fan', DOMAIN, {}, config) + + return True + + +class ComfoConnectBridge(object): + """Representation of a ComfoConnect bridge.""" + + def __init__(self, hass, bridge, name, token, friendly_name, pin): + """Initialize the ComfoConnect bridge.""" + from pycomfoconnect import (ComfoConnect) + + self.data = {} + self.name = name + self.hass = hass + + self.comfoconnect = ComfoConnect( + bridge=bridge, local_uuid=bytes.fromhex(token), + local_devicename=friendly_name, pin=pin) + self.comfoconnect.callback_sensor = self.sensor_callback + + def connect(self): + """Connect with the bridge.""" + _LOGGER.debug("Connecting with bridge") + self.comfoconnect.connect(True) + + def disconnect(self): + """Disconnect from the bridge.""" + _LOGGER.debug("Disconnecting from bridge") + self.comfoconnect.disconnect() + + def sensor_callback(self, var, value): + """Callback function for sensor updates.""" + _LOGGER.debug("Got value from bridge: %d = %d", var, value) + + from pycomfoconnect import ( + SENSOR_TEMPERATURE_EXTRACT, SENSOR_TEMPERATURE_OUTDOOR) + + if var in [SENSOR_TEMPERATURE_EXTRACT, SENSOR_TEMPERATURE_OUTDOOR]: + self.data[var] = value / 10 + else: + self.data[var] = value + + # Notify listeners that we have received an update + dispatcher_send(self.hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, var) + + def subscribe_sensor(self, sensor_id): + """Subscribe for the specified sensor.""" + self.comfoconnect.register_sensor(sensor_id) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index d4e7d4b0db6..d323ad324c7 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -40,6 +40,8 @@ DEVICE_CLASSES = [ 'garage', # Garage door control ] +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) + SUPPORT_OPEN = 1 SUPPORT_CLOSE = 2 SUPPORT_SET_POSITION = 4 diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py new file mode 100644 index 00000000000..4883cfe3648 --- /dev/null +++ b/homeassistant/components/cover/knx.py @@ -0,0 +1,185 @@ +""" +Support for KNX covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.knx/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.cover import ( + CoverDevice, PLATFORM_SCHEMA, ATTR_POSITION, DEVICE_CLASSES_SCHEMA, + SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, SUPPORT_STOP, + SUPPORT_SET_TILT_POSITION +) +from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) +from homeassistant.const import (CONF_NAME, CONF_DEVICE_CLASS) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_GETPOSITION_ADDRESS = 'getposition_address' +CONF_SETPOSITION_ADDRESS = 'setposition_address' +CONF_GETANGLE_ADDRESS = 'getangle_address' +CONF_SETANGLE_ADDRESS = 'setangle_address' +CONF_STOP = 'stop_address' +CONF_UPDOWN = 'updown_address' +CONF_INVERT_POSITION = 'invert_position' +CONF_INVERT_ANGLE = 'invert_angle' + +DEFAULT_NAME = 'KNX Cover' +DEPENDENCIES = ['knx'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_UPDOWN): cv.string, + vol.Required(CONF_STOP): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_GETPOSITION_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SETPOSITION_ADDRESS): cv.string, + vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, + vol.Inclusive(CONF_GETANGLE_ADDRESS, 'angle'): cv.string, + vol.Inclusive(CONF_SETANGLE_ADDRESS, 'angle'): cv.string, + vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Create and add an entity based on the configuration.""" + add_devices([KNXCover(hass, KNXConfig(config))]) + + +class KNXCover(KNXMultiAddressDevice, CoverDevice): + """Representation of a KNX cover. e.g. a rollershutter.""" + + def __init__(self, hass, config): + """Initialize the cover.""" + KNXMultiAddressDevice.__init__( + self, hass, config, + ['updown', 'stop'], # required + optional=['setposition', 'getposition', + 'getangle', 'setangle'] + ) + self._device_class = config.config.get(CONF_DEVICE_CLASS) + self._invert_position = config.config.get(CONF_INVERT_POSITION) + self._invert_angle = config.config.get(CONF_INVERT_ANGLE) + self._hass = hass + self._current_pos = None + self._target_pos = None + self._current_tilt = None + self._target_tilt = None + self._supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ + SUPPORT_SET_POSITION | SUPPORT_STOP + + # Tilt is only supported, if there is a angle get and set address + if CONF_SETANGLE_ADDRESS in config.config: + _LOGGER.debug("%s: Tilt supported at addresses %s, %s", + self.name, config.config.get(CONF_SETANGLE_ADDRESS), + config.config.get(CONF_GETANGLE_ADDRESS)) + self._supported_features = self._supported_features | \ + SUPPORT_SET_TILT_POSITION + + @property + def should_poll(self): + """Polling is needed for the KNX cover.""" + return True + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is not None: + if self.current_cover_position > 0: + return False + else: + return True + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._current_pos + + @property + def target_position(self): + """Return the position we are trying to reach: 0 - 100.""" + return self._target_pos + + @property + def current_cover_tilt_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._current_tilt + + @property + def target_tilt(self): + """Return the tilt angle (in %) we are trying to reach: 0 - 100.""" + return self._target_tilt + + def set_cover_position(self, **kwargs): + """Set new target position.""" + position = kwargs.get(ATTR_POSITION) + if position is None: + return + + if self._invert_position: + position = 100-position + + self._target_pos = position + self.set_percentage('setposition', position) + _LOGGER.debug("%s: Set target position to %d", self.name, position) + + def update(self): + """Update device state.""" + super().update() + value = self.get_percentage('getposition') + if value is not None: + self._current_pos = value + if self._invert_position: + self._current_pos = 100-value + _LOGGER.debug("%s: position = %d", self.name, value) + + if self._supported_features & SUPPORT_SET_TILT_POSITION: + value = self.get_percentage('getangle') + if value is not None: + self._current_tilt = value + if self._invert_angle: + self._current_tilt = 100-value + _LOGGER.debug("%s: tilt = %d", self.name, value) + + def open_cover(self, **kwargs): + """Open the cover.""" + _LOGGER.debug("%s: open: updown = 0", self.name) + self.set_int_value('updown', 0) + + def close_cover(self, **kwargs): + """Close the cover.""" + _LOGGER.debug("%s: open: updown = 1", self.name) + self.set_int_value('updown', 1) + + def stop_cover(self, **kwargs): + """Stop the cover movement.""" + _LOGGER.debug("%s: stop: stop = 1", self.name) + self.set_int_value('stop', 1) + + def set_cover_tilt_position(self, tilt_position, **kwargs): + """Move the cover til to a specific position.""" + if self._invert_angle: + tilt_position = 100-tilt_position + + self._target_tilt = round(tilt_position, -1) + self.set_percentage('setangle', tilt_position) + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return self._device_class diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py new file mode 100644 index 00000000000..fd746131288 --- /dev/null +++ b/homeassistant/components/cover/template.py @@ -0,0 +1,354 @@ +""" +Support for covers which integrate with other components. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.template/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.cover import ( + ENTITY_ID_FORMAT, CoverDevice, PLATFORM_SCHEMA, + SUPPORT_OPEN_TILT, SUPPORT_CLOSE_TILT, SUPPORT_STOP_TILT, + SUPPORT_SET_TILT_POSITION, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP, + SUPPORT_SET_POSITION, ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.const import ( + CONF_FRIENDLY_NAME, CONF_ENTITY_ID, + EVENT_HOMEASSISTANT_START, MATCH_ALL, + CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, + 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 +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.script import Script + +_LOGGER = logging.getLogger(__name__) +_VALID_STATES = [STATE_OPEN, STATE_CLOSED, 'true', 'false'] + +CONF_COVERS = 'covers' + +CONF_POSITION_TEMPLATE = 'position_template' +CONF_TILT_TEMPLATE = 'tilt_template' +OPEN_ACTION = 'open_cover' +CLOSE_ACTION = 'close_cover' +STOP_ACTION = 'stop_cover' +POSITION_ACTION = 'set_cover_position' +TILT_ACTION = 'set_cover_tilt_position' +CONF_VALUE_OR_POSITION_TEMPLATE = 'value_or_position' + +TILT_FEATURES = (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT | + SUPPORT_SET_TILT_POSITION) + +COVER_SCHEMA = vol.Schema({ + vol.Required(OPEN_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CLOSE_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(STOP_ACTION): cv.SCRIPT_SCHEMA, + vol.Exclusive(CONF_POSITION_TEMPLATE, + CONF_VALUE_OR_POSITION_TEMPLATE): cv.template, + vol.Exclusive(CONF_VALUE_TEMPLATE, + CONF_VALUE_OR_POSITION_TEMPLATE): cv.template, + vol.Optional(CONF_POSITION_TEMPLATE): cv.template, + vol.Optional(CONF_TILT_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME, default=None): cv.string, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Template cover.""" + covers = [] + + for device, device_config in config[CONF_COVERS].items(): + friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) + state_template = device_config.get(CONF_VALUE_TEMPLATE) + position_template = device_config.get(CONF_POSITION_TEMPLATE) + tilt_template = device_config.get(CONF_TILT_TEMPLATE) + icon_template = device_config.get(CONF_ICON_TEMPLATE) + open_action = device_config[OPEN_ACTION] + close_action = device_config[CLOSE_ACTION] + stop_action = device_config[STOP_ACTION] + position_action = device_config.get(POSITION_ACTION) + tilt_action = device_config.get(TILT_ACTION) + + if position_template is None and state_template is None: + _LOGGER.error('Must specify either %s' or '%s', + CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE) + continue + + template_entity_ids = set() + if state_template is not None: + temp_ids = state_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if position_template is not None: + temp_ids = position_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if tilt_template is not None: + temp_ids = tilt_template.extract_entities() + 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 not template_entity_ids: + template_entity_ids = MATCH_ALL + + entity_ids = device_config.get(CONF_ENTITY_ID, template_entity_ids) + + covers.append( + CoverTemplate( + hass, + device, friendly_name, state_template, + position_template, tilt_template, icon_template, + open_action, close_action, stop_action, + position_action, tilt_action, entity_ids + ) + ) + if not covers: + _LOGGER.error("No covers added") + return False + + async_add_devices(covers, True) + return True + + +class CoverTemplate(CoverDevice): + """Representation of a Template cover.""" + + 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_ids): + """Initialize the Template cover.""" + 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._position_template = position_template + self._tilt_template = tilt_template + self._icon_template = icon_template + self._open_script = Script(hass, open_action) + self._close_script = Script(hass, close_action) + self._stop_script = Script(hass, stop_action) + self._position_script = None + if position_action is not None: + self._position_script = Script(hass, position_action) + self._tilt_script = None + if tilt_action is not None: + self._tilt_script = Script(hass, tilt_action) + self._icon = None + self._position = None + self._tilt_value = None + self._entities = entity_ids + + if self._template is not None: + self._template.hass = self.hass + if self._position_template is not None: + self._position_template.hass = self.hass + if self._tilt_template is not None: + self._tilt_template.hass = self.hass + if self._icon_template is not None: + self._icon_template.hass = self.hass + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + state = yield from async_get_last_state(self.hass, self.entity_id) + if state: + self._position = 100 if state.state == STATE_OPEN else 0 + + @callback + def template_cover_state_listener(entity, old_state, new_state): + """Handle target device state changes.""" + self.hass.async_add_job(self.async_update_ha_state(True)) + + @callback + def template_cover_startup(event): + """Update template on startup.""" + async_track_state_change( + self.hass, self._entities, template_cover_state_listener) + + self.hass.async_add_job(self.async_update_ha_state(True)) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_cover_startup) + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self._position == 0 + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._position + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._tilt_value + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + + if self.current_cover_position is not None: + supported_features |= SUPPORT_SET_POSITION + + if self.current_cover_tilt_position is not None: + supported_features |= TILT_FEATURES + + return supported_features + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @asyncio.coroutine + def async_open_cover(self, **kwargs): + """Move the cover up.""" + self.hass.async_add_job(self._open_script.async_run()) + + @asyncio.coroutine + def async_close_cover(self, **kwargs): + """Move the cover down.""" + self.hass.async_add_job(self._close_script.async_run()) + + @asyncio.coroutine + def async_stop_cover(self, **kwargs): + """Fire the stop action.""" + self.hass.async_add_job(self._stop_script.async_run()) + + @asyncio.coroutine + def async_set_cover_position(self, **kwargs): + """Set cover position.""" + if ATTR_POSITION not in kwargs: + return + self._position = kwargs[ATTR_POSITION] + self.hass.async_add_job(self._position_script.async_run( + {"position": self._position})) + + @asyncio.coroutine + def async_open_cover_tilt(self, **kwargs): + """Tilt the cover open.""" + self._tilt_value = 100 + self.hass.async_add_job(self._tilt_script.async_run( + {"tilt": self._tilt_value})) + + @asyncio.coroutine + def async_close_cover_tilt(self, **kwargs): + """Tilt the cover closed.""" + self._tilt_value = 0 + self.hass.async_add_job(self._tilt_script.async_run( + {"tilt": self._tilt_value})) + + @asyncio.coroutine + def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + if ATTR_TILT_POSITION not in kwargs: + return + self._tilt_value = kwargs[ATTR_TILT_POSITION] + self.hass.async_add_job(self._tilt_script.async_run( + {"tilt": self._tilt_value})) + + @asyncio.coroutine + def async_update(self): + """Update the state from the template.""" + if self._template is not None: + try: + state = self._template.async_render().lower() + if state in _VALID_STATES: + if state in ('true', STATE_OPEN): + self._position = 100 + else: + self._position = 0 + else: + _LOGGER.error( + 'Received invalid cover is_on state: %s. Expected: %s', + state, ', '.join(_VALID_STATES)) + self._position = None + except TemplateError as ex: + _LOGGER.error(ex) + self._position = None + if self._position_template is not None: + try: + state = float(self._position_template.async_render()) + if state < 0 or state > 100: + self._position = None + _LOGGER.error("Cover position value must be" + " between 0 and 100." + " Value was: %.2f", state) + else: + self._position = state + except TemplateError as ex: + _LOGGER.error(ex) + self._position = None + except ValueError as ex: + _LOGGER.error(ex) + self._position = None + if self._tilt_template is not None: + try: + state = float(self._tilt_template.async_render()) + if state < 0 or state > 100: + self._tilt_value = None + _LOGGER.error("Tilt value must be between 0 and 100." + " Value was: %.2f", state) + else: + self._tilt_value = state + except TemplateError as ex: + _LOGGER.error(ex) + self._tilt_value = None + except ValueError as ex: + _LOGGER.error(ex) + self._tilt_value = None + if self._icon_template is not None: + try: + self._icon = self._icon_template.async_render() + except TemplateError as ex: + 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) + return + self._icon = super().icon + _LOGGER.error('Could not render icon template %s: %s', + self._name, ex) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index f77a5f05f62..222a031d380 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -210,6 +210,7 @@ def async_setup(hass, config): description=("Press the button on the bridge to register Philips " "Hue with Home Assistant."), description_image="/static/images/config_philips_hue.jpg", + fields=[{'id': 'username', 'name': 'Username'}], submit_caption="I have pressed the button" ) configurator_ids.append(request_id) diff --git a/homeassistant/components/device_tracker/linksys_smart.py b/homeassistant/components/device_tracker/linksys_smart.py new file mode 100644 index 00000000000..2d7fbfea33c --- /dev/null +++ b/homeassistant/components/device_tracker/linksys_smart.py @@ -0,0 +1,110 @@ +"""Support for Linksys Smart Wifi routers.""" +import logging +import threading +from datetime import timedelta + +import requests +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 +from homeassistant.util import Throttle + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) +DEFAULT_TIMEOUT = 10 + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, +}) + + +def get_scanner(hass, config): + """Validate the configuration and return a Linksys AP scanner.""" + try: + return LinksysSmartWifiDeviceScanner(config[DOMAIN]) + except ConnectionError: + return None + + +class LinksysSmartWifiDeviceScanner(DeviceScanner): + """This class queries a Linksys Access Point.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.host = config[CONF_HOST] + + self.lock = threading.Lock() + self.last_results = {} + + # Check if the access point is accessible + response = self._make_request() + if not response.status_code == 200: + raise ConnectionError("Cannot connect to Linksys Access Point") + + def scan_devices(self): + """Scan for new devices and return a list with device IDs (MACs).""" + self._update_info() + + return self.last_results.keys() + + def get_device_name(self, mac): + """Return the name (if known) of the device.""" + return self.last_results.get(mac) + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """Check for connected devices.""" + with self.lock: + _LOGGER.info("Checking Linksys Smart Wifi") + + self.last_results = {} + response = self._make_request() + if response.status_code != 200: + _LOGGER.error( + "Got HTTP status code %d when getting device list", + response.status_code) + return False + try: + data = response.json() + result = data["responses"][0] + devices = result["output"]["devices"] + for device in devices: + macs = device["knownMACAddresses"] + if not macs: + _LOGGER.warning( + "Skipping device without known MAC address") + continue + mac = macs[-1] + connections = device["connections"] + if not connections: + _LOGGER.debug("Device %s is not connected", mac) + continue + name = device["friendlyName"] + properties = device["properties"] + for prop in properties: + if prop["name"] == "userDeviceName": + name = prop["value"] + _LOGGER.debug("Device %s is connected", mac) + self.last_results[mac] = name + except (KeyError, IndexError): + _LOGGER.exception("Router returned unexpected response") + return False + return True + + def _make_request(self): + # Weirdly enough, this doesn't seem to require authentication + data = [{ + "request": { + "sinceRevision": 0 + }, + "action": "http://linksys.com/jnap/devicelist/GetDevices" + }] + headers = {"X-JNAP-Action": "http://linksys.com/jnap/core/Transaction"} + return requests.post('http://{}/JNAP/'.format(self.host), + timeout=DEFAULT_TIMEOUT, + headers=headers, + json=data) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index af543548fbd..fc1918f08cc 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -158,6 +158,11 @@ class MikrotikScanner(DeviceScanner): for device in devices } else: - self.last_results = mac_names + self.last_results = { + device.get('mac-address'): + mac_names.get(device.get('mac-address')) + for device in device_names + if device.get('active-address') + } return True diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 2f41d8fe0d3..6aa44c17c9a 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -21,7 +21,7 @@ from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import PLATFORM_SCHEMA DEPENDENCIES = ['mqtt'] -REQUIREMENTS = ['libnacl==1.5.0'] +REQUIREMENTS = ['libnacl==1.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py old mode 100755 new mode 100644 index b1d5aa499b5..e3cef60c376 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -18,6 +18,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.util import Throttle +from homeassistant.exceptions import HomeAssistantError # Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) @@ -38,6 +39,23 @@ def get_scanner(hass, config): return scanner if scanner.success_init else None +def _refresh_on_acccess_denied(func): + """If remove rebooted, it lost our session so rebuld one and try again.""" + def decorator(self, *args, **kwargs): + """Wrapper function to refresh session_id on PermissionError.""" + try: + return func(self, *args, **kwargs) + except PermissionError: + _LOGGER.warning("Invalid session detected." + + " Tryign to refresh session_id and re-run the rpc") + self.session_id = _get_session_id(self.url, self.username, + self.password) + + return func(self, *args, **kwargs) + + return decorator + + class UbusDeviceScanner(DeviceScanner): """ This class queries a wireless router running OpenWrt firmware. @@ -48,14 +66,16 @@ class UbusDeviceScanner(DeviceScanner): def __init__(self, config): """Initialize the scanner.""" host = config[CONF_HOST] - username, password = config[CONF_USERNAME], config[CONF_PASSWORD] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] self.parse_api_pattern = re.compile(r"(?P\w*) = (?P.*);") self.lock = threading.Lock() self.last_results = {} self.url = 'http://{}/ubus'.format(host) - self.session_id = _get_session_id(self.url, username, password) + self.session_id = _get_session_id(self.url, self.username, + self.password) self.hostapd = [] self.leasefile = None self.mac2name = None @@ -66,6 +86,7 @@ class UbusDeviceScanner(DeviceScanner): self._update_info() return self.last_results + @_refresh_on_acccess_denied def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" with self.lock: @@ -95,6 +116,7 @@ class UbusDeviceScanner(DeviceScanner): return self.mac2name.get(device.upper(), None) @Throttle(MIN_TIME_BETWEEN_SCANS) + @_refresh_on_acccess_denied def _update_info(self): """Ensure the information from the Luci router is up to date. @@ -142,6 +164,12 @@ def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params): if res.status_code == 200: response = res.json() + if 'error' in response: + if 'message' in response['error'] and \ + response['error']['message'] == "Access denied": + raise PermissionError(response['error']['message']) + else: + raise HomeAssistantError(response['error']['message']) if rpcmethod == "call": try: diff --git a/homeassistant/components/digital_ocean.py b/homeassistant/components/digital_ocean.py index d4c567a295a..6ba2c824859 100644 --- a/homeassistant/components/digital_ocean.py +++ b/homeassistant/components/digital_ocean.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-digitalocean==1.11'] +REQUIREMENTS = ['python-digitalocean==1.12'] _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,7 @@ ATTR_VCPUS = 'vcpus' CONF_DROPLETS = 'droplets' -DIGITAL_OCEAN = None +DATA_DIGITAL_OCEAN = 'data_do' DIGITAL_OCEAN_PLATFORMS = ['switch', 'binary_sensor'] DOMAIN = 'digital_ocean' @@ -47,13 +47,14 @@ def setup(hass, config): conf = config[DOMAIN] access_token = conf.get(CONF_ACCESS_TOKEN) - global DIGITAL_OCEAN - DIGITAL_OCEAN = DigitalOcean(access_token) + digital = DigitalOcean(access_token) - if not DIGITAL_OCEAN.manager.get_account(): + if not digital.manager.get_account(): _LOGGER.error("No Digital Ocean account found for the given API Token") return False + hass.data[DATA_DIGITAL_OCEAN] = digital + return True diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 6132bd565dd..fc239bf70c5 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -55,6 +55,7 @@ SERVICE_HANDLERS = { 'apple_tv': ('media_player', 'apple_tv'), 'frontier_silicon': ('media_player', 'frontier_silicon'), 'openhome': ('media_player', 'openhome'), + 'harmony': ('remote', 'harmony'), 'bose_soundtouch': ('media_player', 'soundtouch'), } diff --git a/homeassistant/components/fan/comfoconnect.py b/homeassistant/components/fan/comfoconnect.py new file mode 100644 index 00000000000..ab32e588c03 --- /dev/null +++ b/homeassistant/components/fan/comfoconnect.py @@ -0,0 +1,118 @@ +""" +Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/fan.comfoconnect/ +""" +import logging + +from homeassistant.components.comfoconnect import ( + DOMAIN, ComfoConnectBridge, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED) +from homeassistant.components.fan import ( + FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, + SUPPORT_SET_SPEED) +from homeassistant.const import STATE_UNKNOWN +from homeassistant.helpers.dispatcher import (dispatcher_connect) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['comfoconnect'] + +SPEED_MAPPING = { + 0: SPEED_OFF, + 1: SPEED_LOW, + 2: SPEED_MEDIUM, + 3: SPEED_HIGH +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the ComfoConnect fan platform.""" + ccb = hass.data[DOMAIN] + + add_devices([ComfoConnectFan(hass, name=ccb.name, ccb=ccb)], True) + return + + +class ComfoConnectFan(FanEntity): + """Representation of the ComfoConnect fan platform.""" + + def __init__(self, hass, name, ccb: ComfoConnectBridge): + """Initialize the ComfoConnect fan.""" + from pycomfoconnect import SENSOR_FAN_SPEED_MODE + + self._ccb = ccb + self._name = name + + # Ask the bridge to keep us updated + self._ccb.comfoconnect.register_sensor(SENSOR_FAN_SPEED_MODE) + + def _handle_update(var): + if var == SENSOR_FAN_SPEED_MODE: + _LOGGER.debug("Dispatcher update for %s", var) + self.schedule_update_ha_state() + + # Register for dispatcher updates + dispatcher_connect( + hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, _handle_update) + + @property + def name(self): + """Return the name of the fan.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return 'mdi:air-conditioner' + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED + + @property + def speed(self): + """Return the current fan mode.""" + from pycomfoconnect import (SENSOR_FAN_SPEED_MODE) + + try: + speed = self._ccb.data[SENSOR_FAN_SPEED_MODE] + return SPEED_MAPPING[speed] + except KeyError: + return STATE_UNKNOWN + + @property + def speed_list(self): + """List of available fan modes.""" + return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + def turn_on(self, speed: str=None, **kwargs) -> None: + """Turn on the fan.""" + if speed is None: + speed = SPEED_LOW + self.set_speed(speed) + + def turn_off(self) -> None: + """Turn off the fan (to away).""" + self.set_speed(SPEED_OFF) + + def set_speed(self, mode): + """Set fan speed.""" + _LOGGER.debug('Changing fan mode to %s.', mode) + + from pycomfoconnect import ( + CMD_FAN_MODE_AWAY, CMD_FAN_MODE_LOW, CMD_FAN_MODE_MEDIUM, + CMD_FAN_MODE_HIGH) + + if mode == SPEED_OFF: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_AWAY) + elif mode == SPEED_LOW: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_LOW) + elif mode == SPEED_MEDIUM: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_MEDIUM) + elif mode == SPEED_HIGH: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_HIGH) + + # Update current mode + self.schedule_update_ha_state() diff --git a/homeassistant/components/fan/insteon_local.py b/homeassistant/components/fan/insteon_local.py new file mode 100644 index 00000000000..24039f94c00 --- /dev/null +++ b/homeassistant/components/fan/insteon_local.py @@ -0,0 +1,195 @@ +""" +Support for Insteon fans via local hub control. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/fan.insteon_local/ +""" +import json +import logging +import os +from datetime import timedelta + +from homeassistant.components.fan import ( + ATTR_SPEED, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, + SUPPORT_SET_SPEED, FanEntity) +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.loader import get_component +import homeassistant.util as util + +_CONFIGURING = {} +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['insteon_local'] +DOMAIN = 'fan' + +INSTEON_LOCAL_FANS_CONF = 'insteon_local_fans.conf' + +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) + +SUPPORT_INSTEON_LOCAL = SUPPORT_SET_SPEED + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Insteon local fan platform.""" + insteonhub = hass.data['insteon_local'] + + conf_fans = config_from_file(hass.config.path(INSTEON_LOCAL_FANS_CONF)) + if len(conf_fans): + for device_id in conf_fans: + setup_fan(device_id, conf_fans[device_id], insteonhub, hass, + add_devices) + + else: + linked = insteonhub.get_linked() + + for device_id in linked: + if (linked[device_id]['cat_type'] == 'dimmer' and + linked[device_id]['sku'] == '2475F' and + device_id not in conf_fans): + request_configuration(device_id, + insteonhub, + linked[device_id]['model_name'] + ' ' + + linked[device_id]['sku'], + hass, add_devices) + + +def request_configuration(device_id, insteonhub, model, hass, + add_devices_callback): + """Request configuration steps from the user.""" + configurator = get_component('configurator') + + # We got an error if this method is called while we are configuring + if device_id in _CONFIGURING: + configurator.notify_errors( + _CONFIGURING[device_id], 'Failed to register, please try again.') + + return + + def insteon_fan_config_callback(data): + """The actions to do when our configuration callback is called.""" + setup_fan(device_id, data.get('name'), insteonhub, hass, + add_devices_callback) + + _CONFIGURING[device_id] = configurator.request_config( + hass, 'Insteon ' + model + ' addr: ' + device_id, + insteon_fan_config_callback, + description=('Enter a name for ' + model + ' Fan addr: ' + device_id), + entity_picture='/static/images/config_insteon.png', + submit_caption='Confirm', + fields=[{'id': 'name', 'name': 'Name', 'type': ''}] + ) + + +def setup_fan(device_id, name, insteonhub, hass, add_devices_callback): + """Set up the fan.""" + if device_id in _CONFIGURING: + request_id = _CONFIGURING.pop(device_id) + configurator = get_component('configurator') + configurator.request_done(request_id) + _LOGGER.info("Device configuration done!") + + conf_fans = config_from_file(hass.config.path(INSTEON_LOCAL_FANS_CONF)) + if device_id not in conf_fans: + conf_fans[device_id] = name + + if not config_from_file( + hass.config.path(INSTEON_LOCAL_FANS_CONF), + conf_fans): + _LOGGER.error("Failed to save configuration file") + + device = insteonhub.fan(device_id) + add_devices_callback([InsteonLocalFanDevice(device, name)]) + + +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 configuration file failed: %s", error) + # This won't work yet + return False + else: + return {} + + +class InsteonLocalFanDevice(FanEntity): + """An abstract Class for an Insteon node.""" + + def __init__(self, node, name): + """Initialize the device.""" + self.node = node + self.node.deviceName = name + self._speed = SPEED_OFF + + @property + def name(self): + """Return the the name of the node.""" + return self.node.deviceName + + @property + def unique_id(self): + """Return the ID of this Insteon node.""" + return 'insteon_local_{}_fan'.format(self.node.device_id) + + @property + def speed(self) -> str: + """Return the current speed.""" + return self._speed + + @property + def speed_list(self: ToggleEntity) -> list: + """Get the list of available speeds.""" + return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) + def update(self): + """Update state of the fan.""" + resp = self.node.status() + if 'cmd2' in resp: + if resp['cmd2'] == '00': + self._speed = SPEED_OFF + elif resp['cmd2'] == '55': + self._speed = SPEED_LOW + elif resp['cmd2'] == 'AA': + self._speed = SPEED_MEDIUM + elif resp['cmd2'] == 'FF': + self._speed = SPEED_HIGH + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_INSTEON_LOCAL + + def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + """Turn device on.""" + if speed is None: + if ATTR_SPEED in kwargs: + speed = kwargs[ATTR_SPEED] + else: + speed = SPEED_MEDIUM + + self.set_speed(speed) + + def turn_off(self: ToggleEntity, **kwargs) -> None: + """Turn device off.""" + self.node.off() + + def set_speed(self: ToggleEntity, speed: str) -> None: + """Set the speed of the fan.""" + if self.node.on(speed): + self._speed = speed diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 41abfc5eba6..b4115e874e1 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,21 +3,21 @@ FINGERPRINTS = { "compatibility.js": "8e4c44b5f4288cc48ec1ba94a9bec812", "core.js": "d4a7cb8c80c62b536764e0e81385f6aa", - "frontend.html": "cca45decbed803e7f0ec0b4f6e18fe53", - "mdi.html": "1a5ad9654c1f0e57440e30afd92846a5", + "frontend.html": "f170a7221615ca2839cb8fd51a82f50a", + "mdi.html": "c92bd28c434865d6cabb34cd3c0a3e4c", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-automation.html": "21cba0a4fee9d2b45dda47f7a1dd82d8", - "panels/ha-panel-config.html": "59d9eb28758b497a4d9b2428f978b9b1", - "panels/ha-panel-dev-event.html": "2db9c218065ef0f61d8d08db8093cad2", - "panels/ha-panel-dev-info.html": "61610e015a411cfc84edd2c4d489e71d", - "panels/ha-panel-dev-service.html": "415552027cb083badeff5f16080410ed", - "panels/ha-panel-dev-state.html": "d70314913b8923d750932367b1099750", - "panels/ha-panel-dev-template.html": "567fbf86735e1b891e40c2f4060fec9b", + "panels/ha-panel-automation.html": "4f98839bb082885657bbcd0ac04fc680", + "panels/ha-panel-config.html": "76853de505d173e82249bf605eb73505", + "panels/ha-panel-dev-event.html": "4886c821235492b1b92739b580d21c61", + "panels/ha-panel-dev-info.html": "24e888ec7a8acd0c395b34396e9001bc", + "panels/ha-panel-dev-service.html": "92c6be30b1af95791d5a6429df505852", + "panels/ha-panel-dev-state.html": "8f1a27c04db6329d31cfcc7d0d6a0869", + "panels/ha-panel-dev-template.html": "d33a55b937b50cdfe8b6fae81f70a139", "panels/ha-panel-hassio.html": "9474ba65077371622f21ed9a30cf5229", - "panels/ha-panel-history.html": "89062c48c76206cad1cec14ddbb1cbb1", + "panels/ha-panel-history.html": "35177e2046c9a4191c8f51f8160255ce", "panels/ha-panel-iframe.html": "d920f0aa3c903680f2f8795e2255daab", - "panels/ha-panel-logbook.html": "6dd6a16f52117318b202e60f98400163", - "panels/ha-panel-map.html": "31c592c239636f91e07c7ac232a5ebc4", - "panels/ha-panel-zwave.html": "92edac58dd52c297c761fd9acec7f436", + "panels/ha-panel-logbook.html": "7c45bd41c146ec38b9938b8a5188bb0d", + "panels/ha-panel-map.html": "0ba605729197c4724ecc7310b08f7050", + "panels/ha-panel-zwave.html": "2ea2223339d1d2faff478751c2927d11", "websocket_test.html": "575de64b431fe11c3785bf96d7813450" } diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index a1608c3b16c..9c62b561cd5 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,747 +1,5 @@ - \ No newline at end of file + \ 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 index 8b08356adcf..dba8daa1c71 100644 Binary files a/homeassistant/components/frontend/www_static/frontend.html.gz and b/homeassistant/components/frontend/www_static/frontend.html.gz differ diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 81ab4ff8a8e..1ad42592134 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 81ab4ff8a8ef7cc4b96b60f63c16472b0427adc7 +Subproject commit 1ad42592134c290119879e8f8505ef5736a3071e diff --git a/homeassistant/components/frontend/www_static/mdi.html b/homeassistant/components/frontend/www_static/mdi.html index 853712565bc..e5029bc4289 100644 --- a/homeassistant/components/frontend/www_static/mdi.html +++ b/homeassistant/components/frontend/www_static/mdi.html @@ -1 +1 @@ - \ No newline at end of file + \ 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 index 2d7ba719580..99be72e33ea 100644 Binary files a/homeassistant/components/frontend/www_static/mdi.html.gz and b/homeassistant/components/frontend/www_static/mdi.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html index 453d631c1da..51724060959 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html @@ -1,2 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz index f3137a76bca..804850c2558 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz 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 index fc00afe012c..5e6cf7b6483 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html @@ -1,47 +1 @@ - \ No newline at end of file + \ 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 index 38c74dc5507..8c4cf065663 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz 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 index 37494be9377..6f501f7db26 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html @@ -1 +1 @@ - \ No newline at end of file + \ 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 index 3d3ac6f70aa..1a8faa4f3e6 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz 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 index bc39eb3a296..bea033c5572 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html @@ -1 +1 @@ - \ No newline at end of file + \ 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 index 50b91dba116..dfdd56f87e1 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz 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 index 887d95325d6..a8477f931b4 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html @@ -1 +1 @@ - \ No newline at end of file + \ 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 index 5ac8c8c977c..1cb7353dd33 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz 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 index 37227fb4d52..6f03db94edf 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html @@ -1 +1 @@ - \ No newline at end of file + \ 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 index bc975d427c0..742f5eef9ee 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz 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 index d905cc92a78..c6005c3b639 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html @@ -1 +1 @@ - \ No newline at end of file + \ 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 index 6ec343e910f..32190ba8cec 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz 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 index e5ee783bb96..810673d49d3 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html @@ -1 +1 @@ - \ No newline at end of file + \ 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 index 74325f647e3..8c21bb232bd 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz 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 index 57a39eecd74..67676543b1f 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html @@ -1,2 +1 @@ - \ No newline at end of file + \ 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 index 9919db308be..8e4c63b72ad 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz 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 index dbc34a17448..345b2140db8 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html @@ -2,166 +2,4 @@ n.DomUtil.addClass(t,"leaflet-container"+(n.Browser.touch?" leaflet-touch":"")+(n.Browser.retina?" leaflet-retina":"")+(n.Browser.ielt9?" leaflet-oldie":"")+(n.Browser.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":""));var e=n.DomUtil.getStyle(t,"position");"absolute"!==e&&"relative"!==e&&"fixed"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),n.DomUtil.setPosition(this._mapPane,new n.Point(0,0)),this.createPane("tilePane"),this.createPane("shadowPane"),this.createPane("overlayPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(n.DomUtil.addClass(t.markerPane,"leaflet-zoom-hide"),n.DomUtil.addClass(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e){n.DomUtil.setPosition(this._mapPane,new n.Point(0,0));var i=!this._loaded;this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset");var o=this._zoom!==e;this._moveStart(o)._move(t,e)._moveEnd(o),this.fire("viewreset"),i&&this.fire("load")},_moveStart:function(t){return t&&this.fire("zoomstart"),this.fire("movestart")},_move:function(t,e,n){e===i&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),(o||n&&n.pinch)&&this.fire("zoom",n),this.fire("move",n)},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return n.Util.cancelAnimFrame(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){n.DomUtil.setPosition(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(e){if(n.DomEvent){this._targets={},this._targets[n.stamp(this._container)]=this;var i=e?"off":"on";n.DomEvent[i](this._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress",this._handleDOMEvent,this),this.options.trackResize&&n.DomEvent[i](t,"resize",this._onResize,this),n.Browser.any3d&&this.options.transform3DLimit&&this[i]("moveend",this._onMoveEnd)}},_onResize:function(){n.Util.cancelAnimFrame(this._resizeRequest),this._resizeRequest=n.Util.requestAnimFrame(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,o=[],s="mouseout"===e||"mouseover"===e,r=t.target||t.srcElement,a=!1;r;){if((i=this._targets[n.stamp(r)])&&("click"===e||"preclick"===e)&&!t._simulated&&this._draggableMoved(i)){a=!0;break}if(i&&i.listens(e,!0)){if(s&&!n.DomEvent._isExternalTarget(r,t))break;if(o.push(i),s)break}if(r===this._container)break;r=r.parentNode}return o.length||a||s||!n.DomEvent._isExternalTarget(r,t)||(o=[this]),o},_handleDOMEvent:function(t){if(this._loaded&&!n.DomEvent._skipped(t)){var e="keypress"===t.type&&13===t.keyCode?"click":t.type;"mousedown"===e&&n.DomUtil.preventOutline(t.target||t.srcElement),this._fireDOMEvent(t,e)}},_fireDOMEvent:function(t,e,i){if("click"===t.type){var o=n.Util.extend({},t);o.type="preclick",this._fireDOMEvent(o,o.type,i)}if(!t._stopped&&(i=(i||[]).concat(this._findEventTargets(t,e)),i.length)){var s=i[0];"contextmenu"===e&&s.listens(e,!0)&&n.DomEvent.preventDefault(t);var r={originalEvent:t};if("keypress"!==t.type){var a=s instanceof n.Marker;r.containerPoint=a?this.latLngToContainerPoint(s.getLatLng()):this.mouseEventToContainerPoint(t),r.layerPoint=this.containerPointToLayerPoint(r.containerPoint),r.latlng=a?s.getLatLng():this.layerPointToLatLng(r.layerPoint)}for(var h=0;h0?Math.round(t-e)/2:Math.max(0,Math.ceil(t))-Math.max(0,Math.floor(e))},_limitZoom:function(t){var e=this.getMinZoom(),i=this.getMaxZoom(),o=n.Browser.any3d?this.options.zoomSnap:1;return o&&(t=Math.round(t/o)*o),Math.max(e,Math.min(i,t))},_onPanTransitionStep:function(){this.fire("move")},_onPanTransitionEnd:function(){n.DomUtil.removeClass(this._mapPane,"leaflet-pan-anim"),this.fire("moveend")},_tryAnimatedPan:function(t,e){var i=this._getCenterOffset(t)._floor();return!(!0!==(e&&e.animate)&&!this.getSize().contains(i)||(this.panBy(i,e),0))},_createAnimProxy:function(){var t=this._proxy=n.DomUtil.create("div","leaflet-proxy leaflet-zoom-animated");this._panes.mapPane.appendChild(t),this.on("zoomanim",function(e){var i=n.DomUtil.TRANSFORM,o=t.style[i];n.DomUtil.setTransform(t,this.project(e.center,e.zoom),this.getZoomScale(e.zoom,1)),o===t.style[i]&&this._animatingZoom&&this._onZoomTransitionEnd()},this),this.on("load moveend",function(){var e=this.getCenter(),i=this.getZoom();n.DomUtil.setTransform(t,this.project(e,i),this.getZoomScale(i,1))},this)},_catchTransitionEnd:function(t){this._animatingZoom&&t.propertyName.indexOf("transform")>=0&&this._onZoomTransitionEnd()},_nothingToAnimate:function(){return!this._container.getElementsByClassName("leaflet-zoom-animated").length},_tryAnimatedZoom:function(t,e,i){if(this._animatingZoom)return!0;if(i=i||{},!this._zoomAnimated||!1===i.animate||this._nothingToAnimate()||Math.abs(e-this._zoom)>this.options.zoomAnimationThreshold)return!1;var o=this.getZoomScale(e),s=this._getCenterOffset(t)._divideBy(1-1/o);return!(!0!==i.animate&&!this.getSize().contains(s)||(n.Util.requestAnimFrame(function(){this._moveStart(!0)._animateZoom(t,e,!0)},this),0))},_animateZoom:function(t,e,i,o){i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,n.DomUtil.addClass(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:o}),setTimeout(n.bind(this._onZoomTransitionEnd,this),250)},_onZoomTransitionEnd:function(){this._animatingZoom&&(n.DomUtil.removeClass(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom),n.Util.requestAnimFrame(function(){this._moveEnd(!0)},this))}}),n.map=function(t,e){return new n.Map(t,e)},n.Layer=n.Evented.extend({options:{pane:"overlayPane",nonBubblingEvents:[],attribution:null},addTo:function(t){return t.addLayer(this),this},remove:function(){return this.removeFrom(this._map||this._mapToAdd)},removeFrom:function(t){return t&&t.removeLayer(this),this},getPane:function(t){return this._map.getPane(t?this.options[t]||t:this.options.pane)},addInteractiveTarget:function(t){return this._map._targets[n.stamp(t)]=this,this},removeInteractiveTarget:function(t){return delete this._map._targets[n.stamp(t)],this},getAttribution:function(){return this.options.attribution},_layerAdd:function(t){var e=t.target;if(e.hasLayer(this)){if(this._map=e,this._zoomAnimated=e._zoomAnimated,this.getEvents){var i=this.getEvents();e.on(i,this),this.once("remove",function(){e.off(i,this)},this)}this.onAdd(e),this.getAttribution&&e.attributionControl&&e.attributionControl.addAttribution(this.getAttribution()),this.fire("add"),e.fire("layeradd",{layer:this})}}}),n.Map.include({addLayer:function(t){var e=n.stamp(t);return this._layers[e]?this:(this._layers[e]=t,t._mapToAdd=this,t.beforeAdd&&t.beforeAdd(this),this.whenReady(t._layerAdd,t),this)},removeLayer:function(t){var e=n.stamp(t);return this._layers[e]?(this._loaded&&t.onRemove(this),t.getAttribution&&this.attributionControl&&this.attributionControl.removeAttribution(t.getAttribution()),delete this._layers[e],this._loaded&&(this.fire("layerremove",{layer:t}),t.fire("remove")),t._map=t._mapToAdd=null,this):this},hasLayer:function(t){return!!t&&n.stamp(t)in this._layers},eachLayer:function(t,e){for(var i in this._layers)t.call(e,this._layers[i]);return this},_addLayers:function(t){t=t?n.Util.isArray(t)?t:[t]:[];for(var e=0,i=t.length;ethis._layersMaxZoom&&this.setZoom(this._layersMaxZoom),this.options.minZoom===i&&this._layersMinZoom&&this.getZoom()100&&o<500||t.target._simulatedClick&&!t._simulated?void n.DomEvent.stop(t):(n.DomEvent._lastClick=i,void e(t))}},n.DomEvent.addListener=n.DomEvent.on,n.DomEvent.removeListener=n.DomEvent.off,n.PosAnimation=n.Evented.extend({run:function(t,e,i,o){this.stop(),this._el=t,this._inProgress=!0,this._duration=i||.25,this._easeOutPower=1/Math.max(o||.5,.2),this._startPos=n.DomUtil.getPosition(t),this._offset=e.subtract(this._startPos),this._startTime=+new Date,this.fire("start"),this._animate()},stop:function(){this._inProgress&&(this._step(!0),this._complete())},_animate:function(){this._animId=n.Util.requestAnimFrame(this._animate,this),this._step()},_step:function(t){var e=+new Date-this._startTime,i=1e3*this._duration;e1e-7;l++)e=r*Math.sin(h),e=Math.pow((1-e)/(1+e),r/2),u=Math.PI/2-2*Math.atan(a*e)-h,h+=u;return new n.LatLng(h*i,t.x*i/o)}},n.CRS.EPSG3395=n.extend({},n.CRS.Earth,{code:"EPSG:3395",projection:n.Projection.Mercator,transformation:function(){var t=.5/(Math.PI*n.Projection.Mercator.R);return new n.Transformation(t,.5,-t,.5)}()}),n.GridLayer=n.Layer.extend({options:{tileSize:256,opacity:1,updateWhenIdle:n.Browser.mobile,updateWhenZooming:!0,updateInterval:200,zIndex:1,bounds:null,minZoom:0,maxZoom:i,noWrap:!1,pane:"tilePane",className:"",keepBuffer:2},initialize:function(t){n.setOptions(this,t)},onAdd:function(){this._initContainer(),this._levels={},this._tiles={},this._resetView(),this._update()},beforeAdd:function(t){t._addZoomLimit(this)},onRemove:function(t){this._removeAllTiles(),n.DomUtil.remove(this._container),t._removeZoomLimit(this),this._container=null,this._tileZoom=null},bringToFront:function(){return this._map&&(n.DomUtil.toFront(this._container),this._setAutoZIndex(Math.max)),this},bringToBack:function(){return this._map&&(n.DomUtil.toBack(this._container),this._setAutoZIndex(Math.min)),this},getContainer:function(){return this._container},setOpacity:function(t){return this.options.opacity=t,this._updateOpacity(),this},setZIndex:function(t){return this.options.zIndex=t,this._updateZIndex(),this},isLoading:function(){return this._loading},redraw:function(){return this._map&&(this._removeAllTiles(),this._update()),this},getEvents:function(){var t={viewprereset:this._invalidateAll,viewreset:this._resetView,zoom:this._resetView,moveend:this._onMoveEnd};return this.options.updateWhenIdle||(this._onMove||(this._onMove=n.Util.throttle(this._onMoveEnd,this.options.updateInterval,this)),t.move=this._onMove),this._zoomAnimated&&(t.zoomanim=this._animateZoom),t},createTile:function(){return e.createElement("div")},getTileSize:function(){var t=this.options.tileSize;return t instanceof n.Point?t:new n.Point(t,t)},_updateZIndex:function(){this._container&&this.options.zIndex!==i&&null!==this.options.zIndex&&(this._container.style.zIndex=this.options.zIndex)},_setAutoZIndex:function(t){for(var e,i=this.getPane().children,n=-t(-1/0,1/0),o=0,s=i.length;othis.options.maxZoom||io&&this._retainParent(s,r,a,o))},_retainChildren:function(t,e,i,o){for(var s=2*t;s<2*t+2;s++)for(var r=2*e;r<2*e+2;r++){var a=new n.Point(s,r);a.z=i+1;var h=this._tileCoordsToKey(a),l=this._tiles[h];l&&l.active?l.retain=!0:(l&&l.loaded&&(l.retain=!0),i+1this.options.maxZoom||this.options.minZoom!==i&&s1)return void this._setView(t,s);for(var m=a.min.y;m<=a.max.y;m++)for(var p=a.min.x;p<=a.max.x;p++){var f=new n.Point(p,m);if(f.z=this._tileZoom,this._isValidTile(f)){var g=this._tiles[this._tileCoordsToKey(f)];g?g.current=!0:l.push(f)}}if(l.sort(function(t,e){return t.distanceTo(h)-e.distanceTo(h)}),0!==l.length){this._loading||(this._loading=!0,this.fire("loading"));var v=e.createDocumentFragment();for(p=0;pi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}if(!this.options.bounds)return!0;var o=this._tileCoordsToBounds(t);return n.latLngBounds(this.options.bounds).overlaps(o)},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToBounds:function(t){var e=this._map,i=this.getTileSize(),o=t.scaleBy(i),s=o.add(i),r=e.unproject(o,t.z),a=e.unproject(s,t.z),h=new n.LatLngBounds(r,a);return this.options.noWrap||e.wrapLatLngBounds(h),h},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var e=t.split(":"),i=new n.Point(+e[0],+e[1]);return i.z=+e[2],i},_removeTile:function(t){var e=this._tiles[t];e&&(n.DomUtil.remove(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){n.DomUtil.addClass(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=n.Util.falseFn,t.onmousemove=n.Util.falseFn,n.Browser.ielt9&&this.options.opacity<1&&n.DomUtil.setOpacity(t,this.options.opacity),n.Browser.android&&!n.Browser.android23&&(t.style.WebkitBackfaceVisibility="hidden")},_addTile:function(t,e){var i=this._getTilePos(t),o=this._tileCoordsToKey(t),s=this.createTile(this._wrapCoords(t),n.bind(this._tileReady,this,t));this._initTile(s),this.createTile.length<2&&n.Util.requestAnimFrame(n.bind(this._tileReady,this,t,null,s)),n.DomUtil.setPosition(s,i),this._tiles[o]={el:s,coords:t,current:!0},e.appendChild(s),this.fire("tileloadstart",{tile:s,coords:t})},_tileReady:function(t,e,i){if(this._map){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var o=this._tileCoordsToKey(t);(i=this._tiles[o])&&(i.loaded=+new Date,this._map._fadeAnimated?(n.DomUtil.setOpacity(i.el,0),n.Util.cancelAnimFrame(this._fadeFrame),this._fadeFrame=n.Util.requestAnimFrame(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(n.DomUtil.addClass(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),n.Browser.ielt9||!this._map._fadeAnimated?n.Util.requestAnimFrame(this._pruneTiles,this):setTimeout(n.bind(this._pruneTiles,this),250)))}},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new n.Point(this._wrapX?n.Util.wrapNum(t.x,this._wrapX):t.x,this._wrapY?n.Util.wrapNum(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new n.Bounds(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}}),n.gridLayer=function(t){return new n.GridLayer(t)},n.TileLayer=n.GridLayer.extend({options:{minZoom:0,maxZoom:18,maxNativeZoom:null,minNativeZoom:null,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1},initialize:function(t,e){this._url=t,e=n.setOptions(this,e),e.detectRetina&&n.Browser.retina&&e.maxZoom>0&&(e.tileSize=Math.floor(e.tileSize/2),e.zoomReverse?(e.zoomOffset--,e.minZoom++):(e.zoomOffset++,e.maxZoom--),e.minZoom=Math.max(0,e.minZoom)),"string"==typeof e.subdomains&&(e.subdomains=e.subdomains.split("")),n.Browser.android||this.on("tileunload",this._onTileRemove)},setUrl:function(t,e){return this._url=t,e||this.redraw(),this},createTile:function(t,i){var o=e.createElement("img");return n.DomEvent.on(o,"load",n.bind(this._tileOnLoad,this,i,o)),n.DomEvent.on(o,"error",n.bind(this._tileOnError,this,i,o)),this.options.crossOrigin&&(o.crossOrigin=""),o.alt="",o.setAttribute("role","presentation"),o.src=this.getTileUrl(t),o},getTileUrl:function(t){var e={r:n.Browser.retina?"@2x":"",s:this._getSubdomain(t),x:t.x,y:t.y,z:this._getZoomForUrl()};if(this._map&&!this._map.options.crs.infinite){var i=this._globalTileRange.max.y-t.y;this.options.tms&&(e.y=i),e["-y"]=i}return n.Util.template(this._url,n.extend(e,this.options))},_tileOnLoad:function(t,e){n.Browser.ielt9?setTimeout(n.bind(t,this,null,e),0):t(null,e)},_tileOnError:function(t,e,i){var n=this.options.errorTileUrl;n&&e.src!==n&&(e.src=n),t(i,e)},getTileSize:function(){var t=this._map,e=n.GridLayer.prototype.getTileSize.call(this),i=this._tileZoom+this.options.zoomOffset,o=this.options.minNativeZoom,s=this.options.maxNativeZoom;return null!==o&&is?e.divideBy(t.getZoomScale(s,i)).round():e},_onTileRemove:function(t){t.tile.onload=null},_getZoomForUrl:function(){var t=this._tileZoom,e=this.options.maxZoom,i=this.options.zoomReverse,n=this.options.zoomOffset,o=this.options.minNativeZoom,s=this.options.maxNativeZoom;return i&&(t=e-t),t+=n,null!==o&&ts?s:t},_getSubdomain:function(t){var e=Math.abs(t.x+t.y)%this.options.subdomains.length;return this.options.subdomains[e]},_abortLoading:function(){var t,e;for(t in this._tiles)this._tiles[t].coords.z!==this._tileZoom&&(e=this._tiles[t].el,e.onload=n.Util.falseFn,e.onerror=n.Util.falseFn,e.complete||(e.src=n.Util.emptyImageUrl,n.DomUtil.remove(e)))}}),n.tileLayer=function(t,e){return new n.TileLayer(t,e)},n.TileLayer.WMS=n.TileLayer.extend({defaultWmsParams:{service:"WMS",request:"GetMap",layers:"",styles:"",format:"image/jpeg",transparent:!1,version:"1.1.1"},options:{crs:null,uppercase:!1},initialize:function(t,e){this._url=t;var i=n.extend({},this.defaultWmsParams);for(var o in e)o in this.options||(i[o]=e[o]);e=n.setOptions(this,e),i.width=i.height=e.tileSize*(e.detectRetina&&n.Browser.retina?2:1),this.wmsParams=i},onAdd:function(t){this._crs=this.options.crs||t.options.crs,this._wmsVersion=parseFloat(this.wmsParams.version);var e=this._wmsVersion>=1.3?"crs":"srs";this.wmsParams[e]=this._crs.code,n.TileLayer.prototype.onAdd.call(this,t)},getTileUrl:function(t){var e=this._tileCoordsToBounds(t),i=this._crs.project(e.getNorthWest()),o=this._crs.project(e.getSouthEast()),s=(this._wmsVersion>=1.3&&this._crs===n.CRS.EPSG4326?[o.y,i.x,i.y,o.x]:[i.x,o.y,o.x,i.y]).join(","),r=n.TileLayer.prototype.getTileUrl.call(this,t);return r+n.Util.getParamString(this.wmsParams,r,this.options.uppercase)+(this.options.uppercase?"&BBOX=":"&bbox=")+s},setParams:function(t,e){return n.extend(this.wmsParams,t),e||this.redraw(),this}}),n.tileLayer.wms=function(t,e){return new n.TileLayer.WMS(t,e)},n.ImageOverlay=n.Layer.extend({options:{opacity:1,alt:"",interactive:!1,crossOrigin:!1},initialize:function(t,e,i){this._url=t,this._bounds=n.latLngBounds(e),n.setOptions(this,i)},onAdd:function(){this._image||(this._initImage(),this.options.opacity<1&&this._updateOpacity()),this.options.interactive&&(n.DomUtil.addClass(this._image,"leaflet-interactive"),this.addInteractiveTarget(this._image)),this.getPane().appendChild(this._image),this._reset()},onRemove:function(){n.DomUtil.remove(this._image),this.options.interactive&&this.removeInteractiveTarget(this._image)},setOpacity:function(t){return this.options.opacity=t,this._image&&this._updateOpacity(),this},setStyle:function(t){return t.opacity&&this.setOpacity(t.opacity),this},bringToFront:function(){return this._map&&n.DomUtil.toFront(this._image),this},bringToBack:function(){return this._map&&n.DomUtil.toBack(this._image),this},setUrl:function(t){return this._url=t,this._image&&(this._image.src=t),this},setBounds:function(t){return this._bounds=t,this._map&&this._reset(),this},getEvents:function(){var t={zoom:this._reset,viewreset:this._reset};return this._zoomAnimated&&(t.zoomanim=this._animateZoom),t},getBounds:function(){return this._bounds},getElement:function(){return this._image},_initImage:function(){var t=this._image=n.DomUtil.create("img","leaflet-image-layer "+(this._zoomAnimated?"leaflet-zoom-animated":""));t.onselectstart=n.Util.falseFn,t.onmousemove=n.Util.falseFn,t.onload=n.bind(this.fire,this,"load"),this.options.crossOrigin&&(t.crossOrigin=""),t.src=this._url,t.alt=this.options.alt},_animateZoom:function(t){ var e=this._map.getZoomScale(t.zoom),i=this._map._latLngBoundsToNewLayerBounds(this._bounds,t.zoom,t.center).min;n.DomUtil.setTransform(this._image,i,e)},_reset:function(){var t=this._image,e=new n.Bounds(this._map.latLngToLayerPoint(this._bounds.getNorthWest()),this._map.latLngToLayerPoint(this._bounds.getSouthEast())),i=e.getSize();n.DomUtil.setPosition(t,e.min),t.style.width=i.x+"px",t.style.height=i.y+"px"},_updateOpacity:function(){n.DomUtil.setOpacity(this._image,this.options.opacity)}}),n.imageOverlay=function(t,e,i){return new n.ImageOverlay(t,e,i)},n.Icon=n.Class.extend({initialize:function(t){n.setOptions(this,t)},createIcon:function(t){return this._createIcon("icon",t)},createShadow:function(t){return this._createIcon("shadow",t)},_createIcon:function(t,e){var i=this._getIconUrl(t);if(!i){if("icon"===t)throw new Error("iconUrl not set in Icon options (see the docs).");return null}var n=this._createImg(i,e&&"IMG"===e.tagName?e:null);return this._setIconStyles(n,t),n},_setIconStyles:function(t,e){var i=this.options,o=i[e+"Size"];"number"==typeof o&&(o=[o,o]);var s=n.point(o),r=n.point("shadow"===e&&i.shadowAnchor||i.iconAnchor||s&&s.divideBy(2,!0));t.className="leaflet-marker-"+e+" "+(i.className||""),r&&(t.style.marginLeft=-r.x+"px",t.style.marginTop=-r.y+"px"),s&&(t.style.width=s.x+"px",t.style.height=s.y+"px")},_createImg:function(t,i){return i=i||e.createElement("img"),i.src=t,i},_getIconUrl:function(t){return n.Browser.retina&&this.options[t+"RetinaUrl"]||this.options[t+"Url"]}}),n.icon=function(t){return new n.Icon(t)},n.Icon.Default=n.Icon.extend({options:{iconUrl:"marker-icon.png",iconRetinaUrl:"marker-icon-2x.png",shadowUrl:"marker-shadow.png",iconSize:[25,41],iconAnchor:[12,41],popupAnchor:[1,-34],tooltipAnchor:[16,-28],shadowSize:[41,41]},_getIconUrl:function(t){return n.Icon.Default.imagePath||(n.Icon.Default.imagePath=this._detectIconPath()),(this.options.imagePath||n.Icon.Default.imagePath)+n.Icon.prototype._getIconUrl.call(this,t)},_detectIconPath:function(){var t=n.DomUtil.create("div","leaflet-default-icon-path",e.body),i=n.DomUtil.getStyle(t,"background-image")||n.DomUtil.getStyle(t,"backgroundImage");return e.body.removeChild(t),0===i.indexOf("url")?i.replace(/^url\([\"\']?/,"").replace(/marker-icon\.png[\"\']?\)$/,""):""}}),n.Marker=n.Layer.extend({options:{icon:new n.Icon.Default,interactive:!0,draggable:!1,keyboard:!0,title:"",alt:"",zIndexOffset:0,opacity:1,riseOnHover:!1,riseOffset:250,pane:"markerPane",nonBubblingEvents:["click","dblclick","mouseover","mouseout","contextmenu"]},initialize:function(t,e){n.setOptions(this,e),this._latlng=n.latLng(t)},onAdd:function(t){this._zoomAnimated=this._zoomAnimated&&t.options.markerZoomAnimation,this._zoomAnimated&&t.on("zoomanim",this._animateZoom,this),this._initIcon(),this.update()},onRemove:function(t){this.dragging&&this.dragging.enabled()&&(this.options.draggable=!0,this.dragging.removeHooks()),this._zoomAnimated&&t.off("zoomanim",this._animateZoom,this),this._removeIcon(),this._removeShadow()},getEvents:function(){return{zoom:this.update,viewreset:this.update}},getLatLng:function(){return this._latlng},setLatLng:function(t){var e=this._latlng;return this._latlng=n.latLng(t),this.update(),this.fire("move",{oldLatLng:e,latlng:this._latlng})},setZIndexOffset:function(t){return this.options.zIndexOffset=t,this.update()},setIcon:function(t){return this.options.icon=t,this._map&&(this._initIcon(),this.update()),this._popup&&this.bindPopup(this._popup,this._popup.options),this},getElement:function(){return this._icon},update:function(){if(this._icon){var t=this._map.latLngToLayerPoint(this._latlng).round();this._setPos(t)}return this},_initIcon:function(){var t=this.options,e="leaflet-zoom-"+(this._zoomAnimated?"animated":"hide"),i=t.icon.createIcon(this._icon),o=!1;i!==this._icon&&(this._icon&&this._removeIcon(),o=!0,t.title&&(i.title=t.title),t.alt&&(i.alt=t.alt)),n.DomUtil.addClass(i,e),t.keyboard&&(i.tabIndex="0"),this._icon=i,t.riseOnHover&&this.on({mouseover:this._bringToFront,mouseout:this._resetZIndex});var s=t.icon.createShadow(this._shadow),r=!1;s!==this._shadow&&(this._removeShadow(),r=!0),s&&(n.DomUtil.addClass(s,e),s.alt=""),this._shadow=s,t.opacity<1&&this._updateOpacity(),o&&this.getPane().appendChild(this._icon),this._initInteraction(),s&&r&&this.getPane("shadowPane").appendChild(this._shadow)},_removeIcon:function(){this.options.riseOnHover&&this.off({mouseover:this._bringToFront,mouseout:this._resetZIndex}),n.DomUtil.remove(this._icon),this.removeInteractiveTarget(this._icon),this._icon=null},_removeShadow:function(){this._shadow&&n.DomUtil.remove(this._shadow),this._shadow=null},_setPos:function(t){n.DomUtil.setPosition(this._icon,t),this._shadow&&n.DomUtil.setPosition(this._shadow,t),this._zIndex=t.y+this.options.zIndexOffset,this._resetZIndex()},_updateZIndex:function(t){this._icon.style.zIndex=this._zIndex+t},_animateZoom:function(t){var e=this._map._latLngToNewLayerPoint(this._latlng,t.zoom,t.center).round();this._setPos(e)},_initInteraction:function(){if(this.options.interactive&&(n.DomUtil.addClass(this._icon,"leaflet-interactive"),this.addInteractiveTarget(this._icon),n.Handler.MarkerDrag)){var t=this.options.draggable;this.dragging&&(t=this.dragging.enabled(),this.dragging.disable()),this.dragging=new n.Handler.MarkerDrag(this),t&&this.dragging.enable()}},setOpacity:function(t){return this.options.opacity=t,this._map&&this._updateOpacity(),this},_updateOpacity:function(){var t=this.options.opacity;n.DomUtil.setOpacity(this._icon,t),this._shadow&&n.DomUtil.setOpacity(this._shadow,t)},_bringToFront:function(){this._updateZIndex(this.options.riseOffset)},_resetZIndex:function(){this._updateZIndex(0)},_getPopupAnchor:function(){return this.options.icon.options.popupAnchor||[0,0]},_getTooltipAnchor:function(){return this.options.icon.options.tooltipAnchor||[0,0]}}),n.marker=function(t,e){return new n.Marker(t,e)},n.DivIcon=n.Icon.extend({options:{iconSize:[12,12],html:!1,bgPos:null,className:"leaflet-div-icon"},createIcon:function(t){var i=t&&"DIV"===t.tagName?t:e.createElement("div"),o=this.options;if(i.innerHTML=!1!==o.html?o.html:"",o.bgPos){var s=n.point(o.bgPos);i.style.backgroundPosition=-s.x+"px "+-s.y+"px"}return this._setIconStyles(i,"icon"),i},createShadow:function(){return null}}),n.divIcon=function(t){return new n.DivIcon(t)},n.DivOverlay=n.Layer.extend({options:{offset:[0,7],className:"",pane:"popupPane"},initialize:function(t,e){n.setOptions(this,t),this._source=e},onAdd:function(t){this._zoomAnimated=t._zoomAnimated,this._container||this._initLayout(),t._fadeAnimated&&n.DomUtil.setOpacity(this._container,0),clearTimeout(this._removeTimeout),this.getPane().appendChild(this._container),this.update(),t._fadeAnimated&&n.DomUtil.setOpacity(this._container,1),this.bringToFront()},onRemove:function(t){t._fadeAnimated?(n.DomUtil.setOpacity(this._container,0),this._removeTimeout=setTimeout(n.bind(n.DomUtil.remove,n.DomUtil,this._container),200)):n.DomUtil.remove(this._container)},getLatLng:function(){return this._latlng},setLatLng:function(t){return this._latlng=n.latLng(t),this._map&&(this._updatePosition(),this._adjustPan()),this},getContent:function(){return this._content},setContent:function(t){return this._content=t,this.update(),this},getElement:function(){return this._container},update:function(){this._map&&(this._container.style.visibility="hidden",this._updateContent(),this._updateLayout(),this._updatePosition(),this._container.style.visibility="",this._adjustPan())},getEvents:function(){var t={zoom:this._updatePosition,viewreset:this._updatePosition};return this._zoomAnimated&&(t.zoomanim=this._animateZoom),t},isOpen:function(){return!!this._map&&this._map.hasLayer(this)},bringToFront:function(){return this._map&&n.DomUtil.toFront(this._container),this},bringToBack:function(){return this._map&&n.DomUtil.toBack(this._container),this},_updateContent:function(){if(this._content){var t=this._contentNode,e="function"==typeof this._content?this._content(this._source||this):this._content;if("string"==typeof e)t.innerHTML=e;else{for(;t.hasChildNodes();)t.removeChild(t.firstChild);t.appendChild(e)}this.fire("contentupdate")}},_updatePosition:function(){if(this._map){var t=this._map.latLngToLayerPoint(this._latlng),e=n.point(this.options.offset),i=this._getAnchor();this._zoomAnimated?n.DomUtil.setPosition(this._container,t.add(i)):e=e.add(t).add(i);var o=this._containerBottom=-e.y,s=this._containerLeft=-Math.round(this._containerWidth/2)+e.x;this._container.style.bottom=o+"px",this._container.style.left=s+"px"}},_getAnchor:function(){return[0,0]}}),n.Popup=n.DivOverlay.extend({options:{maxWidth:300,minWidth:50,maxHeight:null,autoPan:!0,autoPanPaddingTopLeft:null,autoPanPaddingBottomRight:null,autoPanPadding:[5,5],keepInView:!1,closeButton:!0,autoClose:!0,className:""},openOn:function(t){return t.openPopup(this),this},onAdd:function(t){n.DivOverlay.prototype.onAdd.call(this,t),t.fire("popupopen",{popup:this}),this._source&&(this._source.fire("popupopen",{popup:this},!0),this._source instanceof n.Path||this._source.on("preclick",n.DomEvent.stopPropagation))},onRemove:function(t){n.DivOverlay.prototype.onRemove.call(this,t),t.fire("popupclose",{popup:this}),this._source&&(this._source.fire("popupclose",{popup:this},!0),this._source instanceof n.Path||this._source.off("preclick",n.DomEvent.stopPropagation))},getEvents:function(){var t=n.DivOverlay.prototype.getEvents.call(this);return("closeOnClick"in this.options?this.options.closeOnClick:this._map.options.closePopupOnClick)&&(t.preclick=this._close),this.options.keepInView&&(t.moveend=this._adjustPan),t},_close:function(){this._map&&this._map.closePopup(this)},_initLayout:function(){var t="leaflet-popup",e=this._container=n.DomUtil.create("div",t+" "+(this.options.className||"")+" leaflet-zoom-animated");if(this.options.closeButton){var i=this._closeButton=n.DomUtil.create("a",t+"-close-button",e);i.href="#close",i.innerHTML="×",n.DomEvent.on(i,"click",this._onCloseButtonClick,this)}var o=this._wrapper=n.DomUtil.create("div",t+"-content-wrapper",e);this._contentNode=n.DomUtil.create("div",t+"-content",o),n.DomEvent.disableClickPropagation(o).disableScrollPropagation(this._contentNode).on(o,"contextmenu",n.DomEvent.stopPropagation),this._tipContainer=n.DomUtil.create("div",t+"-tip-container",e),this._tip=n.DomUtil.create("div",t+"-tip",this._tipContainer)},_updateLayout:function(){var t=this._contentNode,e=t.style;e.width="",e.whiteSpace="nowrap";var i=t.offsetWidth;i=Math.min(i,this.options.maxWidth),i=Math.max(i,this.options.minWidth),e.width=i+1+"px",e.whiteSpace="",e.height="";var o=t.offsetHeight,s=this.options.maxHeight,r="leaflet-popup-scrolled";s&&o>s?(e.height=s+"px",n.DomUtil.addClass(t,r)):n.DomUtil.removeClass(t,r),this._containerWidth=this._container.offsetWidth},_animateZoom:function(t){var e=this._map._latLngToNewLayerPoint(this._latlng,t.zoom,t.center),i=this._getAnchor();n.DomUtil.setPosition(this._container,e.add(i))},_adjustPan:function(){if(!(!this.options.autoPan||this._map._panAnim&&this._map._panAnim._inProgress)){var t=this._map,e=parseInt(n.DomUtil.getStyle(this._container,"marginBottom"),10)||0,i=this._container.offsetHeight+e,o=this._containerWidth,s=new n.Point(this._containerLeft,-i-this._containerBottom);s._add(n.DomUtil.getPosition(this._container));var r=t.layerPointToContainerPoint(s),a=n.point(this.options.autoPanPadding),h=n.point(this.options.autoPanPaddingTopLeft||a),l=n.point(this.options.autoPanPaddingBottomRight||a),u=t.getSize(),c=0,d=0;r.x+o+l.x>u.x&&(c=r.x+o-u.x+l.x),r.x-c-h.x<0&&(c=r.x-h.x),r.y+i+l.y>u.y&&(d=r.y+i-u.y+l.y),r.y-d-h.y<0&&(d=r.y-h.y),(c||d)&&t.fire("autopanstart").panBy([c,d])}},_onCloseButtonClick:function(t){this._close(),n.DomEvent.stop(t)},_getAnchor:function(){return n.point(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}}),n.popup=function(t,e){return new n.Popup(t,e)},n.Map.mergeOptions({closePopupOnClick:!0}),n.Map.include({openPopup:function(t,e,i){return t instanceof n.Popup||(t=new n.Popup(i).setContent(t)),e&&t.setLatLng(e),this.hasLayer(t)?this:(this._popup&&this._popup.options.autoClose&&this.closePopup(),this._popup=t,this.addLayer(t))},closePopup:function(t){return t&&t!==this._popup||(t=this._popup,this._popup=null),t&&this.removeLayer(t),this}}),n.Layer.include({bindPopup:function(t,e){return t instanceof n.Popup?(n.setOptions(t,e),this._popup=t,t._source=this):(this._popup&&!e||(this._popup=new n.Popup(e,this)),this._popup.setContent(t)),this._popupHandlersAdded||(this.on({click:this._openPopup,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t,e){if(t instanceof n.Layer||(e=t,t=this),t instanceof n.FeatureGroup)for(var i in this._layers){t=this._layers[i];break}return e||(e=t.getCenter?t.getCenter():t.getLatLng()),this._popup&&this._map&&(this._popup._source=t,this._popup.update(),this._map.openPopup(this._popup,e)),this},closePopup:function(){return this._popup&&this._popup._close(),this},togglePopup:function(t){return this._popup&&(this._popup._map?this.closePopup():this.openPopup(t)),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e=t.layer||t.target;if(this._popup&&this._map)return n.DomEvent.stop(t),e instanceof n.Path?void this.openPopup(t.layer||t.target,t.latlng):void(this._map.hasLayer(this._popup)&&this._popup._source===e?this.closePopup():this.openPopup(e,t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)}}),n.Tooltip=n.DivOverlay.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,interactive:!1,opacity:.9},onAdd:function(t){n.DivOverlay.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&this._source.fire("tooltipopen",{tooltip:this},!0)},onRemove:function(t){n.DivOverlay.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&this._source.fire("tooltipclose",{tooltip:this},!0)},getEvents:function(){var t=n.DivOverlay.prototype.getEvents.call(this);return n.Browser.touch&&!this.options.permanent&&(t.preclick=this._close),t},_close:function(){this._map&&this._map.closeTooltip(this)},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=n.DomUtil.create("div",t)},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e=this._map,i=this._container,o=e.latLngToContainerPoint(e.getCenter()),s=e.layerPointToContainerPoint(t),r=this.options.direction,a=i.offsetWidth,h=i.offsetHeight,l=n.point(this.options.offset),u=this._getAnchor();"top"===r?t=t.add(n.point(-a/2+l.x,-h+l.y+u.y,!0)):"bottom"===r?t=t.subtract(n.point(a/2-l.x,-l.y,!0)):"center"===r?t=t.subtract(n.point(a/2+l.x,h/2-u.y+l.y,!0)):"right"===r||"auto"===r&&s.xh&&(s=r,h=a);h>i&&(e[s]=1,this._simplifyDPStep(t,e,i,n,s),this._simplifyDPStep(t,e,i,s,o))},_reducePoints:function(t,e){for(var i=[t[0]],n=1,o=0,s=t.length;ne&&(i.push(t[n]),o=n);return oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i},_sqDist:function(t,e){var i=e.x-t.x,n=e.y-t.y;return i*i+n*n},_sqClosestPointOnSegment:function(t,e,i,o){var s,r=e.x,a=e.y,h=i.x-r,l=i.y-a,u=h*h+l*l;return u>0&&(s=((t.x-r)*h+(t.y-a)*l)/u,s>1?(r=i.x,a=i.y):s>0&&(r+=h*s,a+=l*s)),h=t.x-r,l=t.y-a,o?h*h+l*l:new n.Point(r,a)}},n.Polyline=n.Path.extend({options:{smoothFactor:1,noClip:!1},initialize:function(t,e){n.setOptions(this,e),this._setLatLngs(t)},getLatLngs:function(){return this._latlngs},setLatLngs:function(t){return this._setLatLngs(t),this.redraw()},isEmpty:function(){return!this._latlngs.length},closestLayerPoint:function(t){for(var e,i,o=1/0,s=null,r=n.LineUtil._sqClosestPointOnSegment,a=0,h=this._parts.length;ae)return r=(n-e)/i,this._map.layerPointToLatLng([s.x-r*(s.x-o.x),s.y-r*(s.y-o.y)])},getBounds:function(){return this._bounds},addLatLng:function(t,e){return e=e||this._defaultShape(),t=n.latLng(t),e.push(t),this._bounds.extend(t),this.redraw()},_setLatLngs:function(t){this._bounds=new n.LatLngBounds,this._latlngs=this._convertLatLngs(t)},_defaultShape:function(){return n.Polyline._flat(this._latlngs)?this._latlngs:this._latlngs[0]},_convertLatLngs:function(t){for(var e=[],i=n.Polyline._flat(t),o=0,s=t.length;o=2&&e[0]instanceof n.LatLng&&e[0].equals(e[i-1])&&e.pop(),e},_setLatLngs:function(t){n.Polyline.prototype._setLatLngs.call(this,t),n.Polyline._flat(this._latlngs)&&(this._latlngs=[this._latlngs])},_defaultShape:function(){return n.Polyline._flat(this._latlngs[0])?this._latlngs[0]:this._latlngs[0][0]},_clipPoints:function(){var t=this._renderer._bounds,e=this.options.weight,i=new n.Point(e,e);if(t=new n.Bounds(t.min.subtract(i),t.max.add(i)),this._parts=[],this._pxBounds&&this._pxBounds.intersects(t)){if(this.options.noClip)return void(this._parts=this._rings);for(var o,s=0,r=this._rings.length;s';var i=t.firstChild;return i.style.behavior="url(#default#VML)",i&&"object"==typeof i.adj}catch(t){return!1}}(),n.SVG.include(n.Browser.vml?{_initContainer:function(){this._container=n.DomUtil.create("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(n.Renderer.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=n.SVG.create("shape");n.DomUtil.addClass(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=n.SVG.create("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[n.stamp(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;n.DomUtil.remove(e),t.removeInteractiveTarget(e),delete this._layers[n.stamp(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,o=t.options,s=t._container;s.stroked=!!o.stroke,s.filled=!!o.fill,o.stroke?(e||(e=t._stroke=n.SVG.create("stroke")),s.appendChild(e),e.weight=o.weight+"px",e.color=o.color,e.opacity=o.opacity,o.dashArray?e.dashStyle=n.Util.isArray(o.dashArray)?o.dashArray.join(" "):o.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=o.lineCap.replace("butt","flat"),e.joinstyle=o.lineJoin):e&&(s.removeChild(e),t._stroke=null),o.fill?(i||(i=t._fill=n.SVG.create("fill")),s.appendChild(i),i.color=o.fillColor||o.color,i.opacity=o.fillOpacity):i&&(s.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){n.DomUtil.toFront(t._container)},_bringToBack:function(t){n.DomUtil.toBack(t._container)}}:{}),n.Browser.vml&&(n.SVG.create=function(){try{return e.namespaces.add("lvml","urn:schemas-microsoft-com:vml"),function(t){return e.createElement("')}}catch(t){return function(t){return e.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}}()),n.Canvas=n.Renderer.extend({getEvents:function(){var t=n.Renderer.prototype.getEvents.call(this);return t.viewprereset=this._onViewPreReset,t},_onViewPreReset:function(){this._postponeUpdatePaths=!0},onAdd:function(){n.Renderer.prototype.onAdd.call(this),this._draw()},_initContainer:function(){var t=this._container=e.createElement("canvas");n.DomEvent.on(t,"mousemove",n.Util.throttle(this._onMouseMove,32,this),this).on(t,"click dblclick mousedown mouseup contextmenu",this._onClick,this).on(t,"mouseout",this._handleMouseOut,this),this._ctx=t.getContext("2d")},_updatePaths:function(){if(!this._postponeUpdatePaths){var t;this._redrawBounds=null;for(var e in this._layers)t=this._layers[e],t._update();this._redraw()}},_update:function(){if(!this._map._animatingZoom||!this._bounds){this._drawnLayers={},n.Renderer.prototype._update.call(this);var t=this._bounds,e=this._container,i=t.getSize(),o=n.Browser.retina?2:1;n.DomUtil.setPosition(e,t.min),e.width=o*i.x,e.height=o*i.y,e.style.width=i.x+"px",e.style.height=i.y+"px",n.Browser.retina&&this._ctx.scale(2,2),this._ctx.translate(-t.min.x,-t.min.y),this.fire("update")}},_reset:function(){n.Renderer.prototype._reset.call(this),this._postponeUpdatePaths&&(this._postponeUpdatePaths=!1,this._updatePaths())},_initPath:function(t){this._updateDashArray(t),this._layers[n.stamp(t)]=t;var e=t._order={layer:t,prev:this._drawLast,next:null};this._drawLast&&(this._drawLast.next=e),this._drawLast=e,this._drawFirst=this._drawFirst||this._drawLast},_addPath:function(t){this._requestRedraw(t)},_removePath:function(t){var e=t._order,i=e.next,o=e.prev;i?i.prev=o:this._drawLast=o,o?o.next=i:this._drawFirst=i,delete t._order,delete this._layers[n.stamp(t)],this._requestRedraw(t)},_updatePath:function(t){this._extendRedrawBounds(t),t._project(),t._update(),this._requestRedraw(t)},_updateStyle:function(t){this._updateDashArray(t),this._requestRedraw(t)},_updateDashArray:function(t){if(t.options.dashArray){var e,i=t.options.dashArray.split(","),n=[];for(e=0;et.y!=o.y>t.y&&t.x<(o.x-i.x)*(t.y-i.y)/(o.y-i.y)+i.x&&(u=!u);return u||n.Polyline.prototype._containsPoint.call(this,t,!0)},n.CircleMarker.prototype._containsPoint=function(t){return t.distanceTo(this._point)<=this._radius+this._clickTolerance()},n.GeoJSON=n.FeatureGroup.extend({initialize:function(t,e){n.setOptions(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,o,s=n.Util.isArray(t)?t:t.features;if(s){for(e=0,i=s.length;e1)return void(this._moved=!0);var o=i.touches&&1===i.touches.length?i.touches[0]:i,s=new n.Point(o.clientX,o.clientY),r=s.subtract(this._startPoint);(r.x||r.y)&&(Math.abs(r.x)+Math.abs(r.y)50&&(this._positions.shift(),this._times.shift())}this._map.fire("move",t).fire("drag",t)},_onZoomEnd:function(){var t=this._map.getSize().divideBy(2),e=this._map.latLngToLayerPoint([0,0]);this._initialWorldOffset=e.subtract(t).x,this._worldWidth=this._map.getPixelWorldBounds().getSize().x},_viscousLimit:function(t,e){return t-(t-e)*this._viscosity},_onPreDragLimit:function(){if(this._viscosity&&this._offsetLimit){var t=this._draggable._newPos.subtract(this._draggable._startPos),e=this._offsetLimit;t.xe.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t)}},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,s=(n+e+i)%t-e-i,r=Math.abs(o+i)0?s:-s))-e;this._delta=0,this._startTime=null,r&&("center"===t.options.scrollWheelZoom?t.setZoom(e+r):t.setZoomAround(this._lastMousePos,e+r))}}),n.Map.addInitHook("addHandler","scrollWheelZoom",n.Map.ScrollWheelZoom),n.extend(n.DomEvent,{_touchstart:n.Browser.msPointer?"MSPointerDown":n.Browser.pointer?"pointerdown":"touchstart",_touchend:n.Browser.msPointer?"MSPointerUp":n.Browser.pointer?"pointerup":"touchend",addDoubleTapListener:function(t,e,i){function o(t){var e;if(n.Browser.pointer){if(!n.Browser.edge||"mouse"===t.pointerType)return;e=n.DomEvent._pointersCount}else e=t.touches.length;if(!(e>1)){var i=Date.now(),o=i-(r||i);a=t.touches?t.touches[0]:t,h=o>0&&o<=l,r=i}}function s(t){if(h&&!a.cancelBubble){if(n.Browser.pointer){if(!n.Browser.edge||"mouse"===t.pointerType)return;var i,o,s={};for(o in a)i=a[o],s[o]=i&&i.bind?i.bind(a):i;a=s}a.type="dblclick",e(a),r=null}}var r,a,h=!1,l=250,u="_leaflet_",c=this._touchstart,d=this._touchend;return t[u+c+i]=o,t[u+d+i]=s,t[u+"dblclick"+i]=e,t.addEventListener(c,o,!1),t.addEventListener(d,s,!1),t.addEventListener("dblclick",e,!1),this},removeDoubleTapListener:function(t,e){var i="_leaflet_",o=t[i+this._touchstart+e],s=t[i+this._touchend+e],r=t[i+"dblclick"+e];return t.removeEventListener(this._touchstart,o,!1),t.removeEventListener(this._touchend,s,!1),n.Browser.edge||t.removeEventListener("dblclick",r,!1),this}}),n.extend(n.DomEvent,{POINTER_DOWN:n.Browser.msPointer?"MSPointerDown":"pointerdown",POINTER_MOVE:n.Browser.msPointer?"MSPointerMove":"pointermove",POINTER_UP:n.Browser.msPointer?"MSPointerUp":"pointerup",POINTER_CANCEL:n.Browser.msPointer?"MSPointerCancel":"pointercancel",TAG_WHITE_LIST:["INPUT","SELECT","OPTION"],_pointers:{},_pointersCount:0,addPointerListener:function(t,e,i,n){return"touchstart"===e?this._addPointerStart(t,i,n):"touchmove"===e?this._addPointerMove(t,i,n):"touchend"===e&&this._addPointerEnd(t,i,n),this},removePointerListener:function(t,e,i){var n=t["_leaflet_"+e+i];return"touchstart"===e?t.removeEventListener(this.POINTER_DOWN,n,!1):"touchmove"===e?t.removeEventListener(this.POINTER_MOVE,n,!1):"touchend"===e&&(t.removeEventListener(this.POINTER_UP,n,!1),t.removeEventListener(this.POINTER_CANCEL,n,!1)),this},_addPointerStart:function(t,i,o){var s=n.bind(function(t){if("mouse"!==t.pointerType&&t.MSPOINTER_TYPE_MOUSE&&t.pointerType!==t.MSPOINTER_TYPE_MOUSE){if(!(this.TAG_WHITE_LIST.indexOf(t.target.tagName)<0))return;n.DomEvent.preventDefault(t)}this._handlePointer(t,i)},this);if(t["_leaflet_touchstart"+o]=s,t.addEventListener(this.POINTER_DOWN,s,!1),!this._pointerDocListener){var r=n.bind(this._globalPointerUp,this);e.documentElement.addEventListener(this.POINTER_DOWN,n.bind(this._globalPointerDown,this),!0),e.documentElement.addEventListener(this.POINTER_MOVE,n.bind(this._globalPointerMove,this),!0),e.documentElement.addEventListener(this.POINTER_UP,r,!0),e.documentElement.addEventListener(this.POINTER_CANCEL,r,!0),this._pointerDocListener=!0}},_globalPointerDown:function(t){this._pointers[t.pointerId]=t,this._pointersCount++},_globalPointerMove:function(t){this._pointers[t.pointerId]&&(this._pointers[t.pointerId]=t)},_globalPointerUp:function(t){delete this._pointers[t.pointerId],this._pointersCount--},_handlePointer:function(t,e){t.touches=[];for(var i in this._pointers)t.touches.push(this._pointers[i]);t.changedTouches=[t],e(t)},_addPointerMove:function(t,e,i){var o=n.bind(function(t){(t.pointerType!==t.MSPOINTER_TYPE_MOUSE&&"mouse"!==t.pointerType||0!==t.buttons)&&this._handlePointer(t,e)},this);t["_leaflet_touchmove"+i]=o,t.addEventListener(this.POINTER_MOVE,o,!1)},_addPointerEnd:function(t,e,i){var o=n.bind(function(t){this._handlePointer(t,e)},this);t["_leaflet_touchend"+i]=o,t.addEventListener(this.POINTER_UP,o,!1),t.addEventListener(this.POINTER_CANCEL,o,!1)}}),n.Map.mergeOptions({touchZoom:n.Browser.touch&&!n.Browser.android23,bounceAtZoomLimits:!0}),n.Map.TouchZoom=n.Handler.extend({addHooks:function(){n.DomUtil.addClass(this._map._container,"leaflet-touch-zoom"),n.DomEvent.on(this._map._container,"touchstart",this._onTouchStart,this)},removeHooks:function(){n.DomUtil.removeClass(this._map._container,"leaflet-touch-zoom"),n.DomEvent.off(this._map._container,"touchstart",this._onTouchStart,this)},_onTouchStart:function(t){var i=this._map;if(t.touches&&2===t.touches.length&&!i._animatingZoom&&!this._zooming){var o=i.mouseEventToContainerPoint(t.touches[0]),s=i.mouseEventToContainerPoint(t.touches[1]);this._centerPoint=i.getSize()._divideBy(2),this._startLatLng=i.containerPointToLatLng(this._centerPoint),"center"!==i.options.touchZoom&&(this._pinchStartLatLng=i.containerPointToLatLng(o.add(s)._divideBy(2))),this._startDist=o.distanceTo(s),this._startZoom=i.getZoom(),this._moved=!1,this._zooming=!0,i._stop(),n.DomEvent.on(e,"touchmove",this._onTouchMove,this).on(e,"touchend",this._onTouchEnd,this),n.DomEvent.preventDefault(t)}},_onTouchMove:function(t){if(t.touches&&2===t.touches.length&&this._zooming){var e=this._map,i=e.mouseEventToContainerPoint(t.touches[0]),o=e.mouseEventToContainerPoint(t.touches[1]),s=i.distanceTo(o)/this._startDist;if(this._zoom=e.getScaleZoom(s,this._startZoom),!e.options.bounceAtZoomLimits&&(this._zoome.getMaxZoom()&&s>1)&&(this._zoom=e._limitZoom(this._zoom)),"center"===e.options.touchZoom){if(this._center=this._startLatLng,1===s)return}else{var r=i._add(o)._divideBy(2)._subtract(this._centerPoint);if(1===s&&0===r.x&&0===r.y)return;this._center=e.unproject(e.project(this._pinchStartLatLng,this._zoom).subtract(r),this._zoom)}this._moved||(e._moveStart(!0),this._moved=!0),n.Util.cancelAnimFrame(this._animRequest);var a=n.bind(e._move,e,this._center,this._zoom,{pinch:!0,round:!1});this._animRequest=n.Util.requestAnimFrame(a,this,!0),n.DomEvent.preventDefault(t)}},_onTouchEnd:function(){return this._moved&&this._zooming?(this._zooming=!1,n.Util.cancelAnimFrame(this._animRequest),n.DomEvent.off(e,"touchmove",this._onTouchMove).off(e,"touchend",this._onTouchEnd),void(this._map.options.zoomAnimation?this._map._animateZoom(this._center,this._map._limitZoom(this._zoom),!0,this._map.options.zoomSnap):this._map._resetView(this._center,this._map._limitZoom(this._zoom)))):void(this._zooming=!1)}}),n.Map.addInitHook("addHandler","touchZoom",n.Map.TouchZoom),n.Map.mergeOptions({tap:!0,tapTolerance:15}),n.Map.Tap=n.Handler.extend({addHooks:function(){n.DomEvent.on(this._map._container,"touchstart",this._onDown,this)},removeHooks:function(){n.DomEvent.off(this._map._container,"touchstart",this._onDown,this)},_onDown:function(t){if(t.touches){if(n.DomEvent.preventDefault(t),this._fireClick=!0,t.touches.length>1)return this._fireClick=!1,void clearTimeout(this._holdTimeout);var i=t.touches[0],o=i.target;this._startPos=this._newPos=new n.Point(i.clientX,i.clientY),o.tagName&&"a"===o.tagName.toLowerCase()&&n.DomUtil.addClass(o,"leaflet-active"),this._holdTimeout=setTimeout(n.bind(function(){this._isTapValid()&&(this._fireClick=!1,this._onUp(),this._simulateEvent("contextmenu",i))},this),1e3),this._simulateEvent("mousedown",i),n.DomEvent.on(e,{touchmove:this._onMove,touchend:this._onUp},this)}},_onUp:function(t){if(clearTimeout(this._holdTimeout),n.DomEvent.off(e,{touchmove:this._onMove,touchend:this._onUp},this), -this._fireClick&&t&&t.changedTouches){var i=t.changedTouches[0],o=i.target;o&&o.tagName&&"a"===o.tagName.toLowerCase()&&n.DomUtil.removeClass(o,"leaflet-active"),this._simulateEvent("mouseup",i),this._isTapValid()&&this._simulateEvent("click",i)}},_isTapValid:function(){return this._newPos.distanceTo(this._startPos)<=this._map.options.tapTolerance},_onMove:function(t){var e=t.touches[0];this._newPos=new n.Point(e.clientX,e.clientY),this._simulateEvent("mousemove",e)},_simulateEvent:function(i,n){var o=e.createEvent("MouseEvents");o._simulated=!0,n.target._simulatedClick=!0,o.initMouseEvent(i,!0,!0,t,1,n.screenX,n.screenY,n.clientX,n.clientY,!1,!1,!1,!1,0,null),n.target.dispatchEvent(o)}}),n.Browser.touch&&!n.Browser.pointer&&n.Map.addInitHook("addHandler","tap",n.Map.Tap),n.Map.mergeOptions({boxZoom:!0}),n.Map.BoxZoom=n.Handler.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane},addHooks:function(){n.DomEvent.on(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){n.DomEvent.off(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_resetState:function(){this._moved=!1},_onMouseDown:function(t){return!(!t.shiftKey||1!==t.which&&1!==t.button)&&(this._resetState(),n.DomUtil.disableTextSelection(),n.DomUtil.disableImageDrag(),this._startPoint=this._map.mouseEventToContainerPoint(t),void n.DomEvent.on(e,{contextmenu:n.DomEvent.stop,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this))},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=n.DomUtil.create("div","leaflet-zoom-box",this._container),n.DomUtil.addClass(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var e=new n.Bounds(this._point,this._startPoint),i=e.getSize();n.DomUtil.setPosition(this._box,e.min),this._box.style.width=i.x+"px",this._box.style.height=i.y+"px"},_finish:function(){this._moved&&(n.DomUtil.remove(this._box),n.DomUtil.removeClass(this._container,"leaflet-crosshair")),n.DomUtil.enableTextSelection(),n.DomUtil.enableImageDrag(),n.DomEvent.off(e,{contextmenu:n.DomEvent.stop,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){if((1===t.which||1===t.button)&&(this._finish(),this._moved)){setTimeout(n.bind(this._resetState,this),0);var e=new n.LatLngBounds(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point));this._map.fitBounds(e).fire("boxzoomend",{boxZoomBounds:e})}},_onKeyDown:function(t){27===t.keyCode&&this._finish()}}),n.Map.addInitHook("addHandler","boxZoom",n.Map.BoxZoom),n.Map.mergeOptions({keyboard:!0,keyboardPanDelta:80}),n.Map.Keyboard=n.Handler.extend({keyCodes:{left:[37],right:[39],down:[40],up:[38],zoomIn:[187,107,61,171],zoomOut:[189,109,54,173]},initialize:function(t){this._map=t,this._setPanDelta(t.options.keyboardPanDelta),this._setZoomDelta(t.options.zoomDelta)},addHooks:function(){var t=this._map._container;t.tabIndex<=0&&(t.tabIndex="0"),n.DomEvent.on(t,{focus:this._onFocus,blur:this._onBlur,mousedown:this._onMouseDown},this),this._map.on({focus:this._addHooks,blur:this._removeHooks},this)},removeHooks:function(){this._removeHooks(),n.DomEvent.off(this._map._container,{focus:this._onFocus,blur:this._onBlur,mousedown:this._onMouseDown},this),this._map.off({focus:this._addHooks,blur:this._removeHooks},this)},_onMouseDown:function(){if(!this._focused){var i=e.body,n=e.documentElement,o=i.scrollTop||n.scrollTop,s=i.scrollLeft||n.scrollLeft;this._map._container.focus(),t.scrollTo(s,o)}},_onFocus:function(){this._focused=!0,this._map.fire("focus")},_onBlur:function(){this._focused=!1,this._map.fire("blur")},_setPanDelta:function(t){var e,i,n=this._panKeys={},o=this.keyCodes;for(e=0,i=o.left.length;e0&&t.screenY>0&&this._map.getContainer().focus()}}),n.control=function(t){return new n.Control(t)},n.Map.include({addControl:function(t){return t.addTo(this),this},removeControl:function(t){return t.remove(),this},_initControlPos:function(){function t(t,s){var r=i+t+" "+i+s;e[t+s]=n.DomUtil.create("div",r,o)}var e=this._controlCorners={},i="leaflet-",o=this._controlContainer=n.DomUtil.create("div",i+"control-container",this._container);t("top","left"),t("top","right"),t("bottom","left"),t("bottom","right")},_clearControlPos:function(){n.DomUtil.remove(this._controlContainer)}}),n.Control.Zoom=n.Control.extend({options:{position:"topleft",zoomInText:"+",zoomInTitle:"Zoom in",zoomOutText:"-",zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=n.DomUtil.create("div",e+" leaflet-bar"),o=this.options;return this._zoomInButton=this._createButton(o.zoomInText,o.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(o.zoomOutText,o.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,o,s){var r=n.DomUtil.create("a",i,o);return r.innerHTML=t,r.href="#",r.title=e,r.setAttribute("role","button"),r.setAttribute("aria-label",e),n.DomEvent.on(r,"mousedown dblclick",n.DomEvent.stopPropagation).on(r,"click",n.DomEvent.stop).on(r,"click",s,this).on(r,"click",this._refocusOnMap,this),r},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";n.DomUtil.removeClass(this._zoomInButton,e),n.DomUtil.removeClass(this._zoomOutButton,e),(this._disabled||t._zoom===t.getMinZoom())&&n.DomUtil.addClass(this._zoomOutButton,e),(this._disabled||t._zoom===t.getMaxZoom())&&n.DomUtil.addClass(this._zoomInButton,e)}}),n.Map.mergeOptions({zoomControl:!0}),n.Map.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new n.Control.Zoom,this.addControl(this.zoomControl))}),n.control.zoom=function(t){return new n.Control.Zoom(t)},n.Control.Attribution=n.Control.extend({options:{position:"bottomright",prefix:'Leaflet'},initialize:function(t){n.setOptions(this,t),this._attributions={}},onAdd:function(t){t.attributionControl=this,this._container=n.DomUtil.create("div","leaflet-control-attribution"),n.DomEvent&&n.DomEvent.disableClickPropagation(this._container);for(var e in t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),this._container},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t?(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update(),this):this},removeAttribution:function(t){return t?(this._attributions[t]&&(this._attributions[t]--,this._update()),this):this},_update:function(){if(this._map){var t=[];for(var e in this._attributions)this._attributions[e]&&t.push(e);var i=[];this.options.prefix&&i.push(this.options.prefix),t.length&&i.push(t.join(", ")),this._container.innerHTML=i.join(" | ")}}}),n.Map.mergeOptions({attributionControl:!0}),n.Map.addInitHook(function(){this.options.attributionControl&&(new n.Control.Attribution).addTo(this)}),n.control.attribution=function(t){return new n.Control.Attribution(t)},n.Control.Scale=n.Control.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=n.DomUtil.create("div",e),o=this.options;return this._addScales(o,e+"-line",i),t.on(o.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=n.DomUtil.create("div",e,i)),t.imperial&&(this._iScale=n.DomUtil.create("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,i=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(i)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t),i=e<1e3?e+" m":e/1e3+" km";this._updateScale(this._mScale,i,e/t)},_updateImperial:function(t){var e,i,n,o=3.2808399*t;o>5280?(e=o/5280,i=this._getRoundNum(e),this._updateScale(this._iScale,i+" mi",i/e)):(n=this._getRoundNum(o),this._updateScale(this._iScale,n+" ft",n/o))},_updateScale:function(t,e,i){t.style.width=Math.round(this.options.maxWidth*i)+"px",t.innerHTML=e},_getRoundNum:function(t){var e=Math.pow(10,(Math.floor(t)+"").length-1),i=t/e;return i=i>=10?10:i>=5?5:i>=3?3:i>=2?2:1,e*i}}),n.control.scale=function(t){return new n.Control.Scale(t)},n.Control.Layers=n.Control.extend({options:{collapsed:!0,position:"topright",autoZIndex:!0,hideSingleBase:!1,sortLayers:!1,sortFunction:function(t,e,i,n){return i1,this._baseLayersList.style.display=t?"":"none"),this._separator.style.display=e&&t?"":"none",this},_onLayerChange:function(t){this._handlingClick||this._update();var e=this._getLayer(n.stamp(t.target)),i=e.overlay?"add"===t.type?"overlayadd":"overlayremove":"add"===t.type?"baselayerchange":null;i&&this._map.fire(i,e)},_createRadioElement:function(t,i){var n='",o=e.createElement("div");return o.innerHTML=n,o.firstChild},_addItem:function(t){var i,o=e.createElement("label"),s=this._map.hasLayer(t.layer);t.overlay?(i=e.createElement("input"),i.type="checkbox",i.className="leaflet-control-layers-selector",i.defaultChecked=s):i=this._createRadioElement("leaflet-base-layers",s),i.layerId=n.stamp(t.layer),n.DomEvent.on(i,"click",this._onInputClick,this);var r=e.createElement("span");r.innerHTML=" "+t.name;var a=e.createElement("div");return o.appendChild(a),a.appendChild(i),a.appendChild(r),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(o),this._checkDisabledLayers(),o},_onInputClick:function(){var t,e,i,n=this._form.getElementsByTagName("input"),o=[],s=[];this._handlingClick=!0;for(var r=n.length-1;r>=0;r--)t=n[r],e=this._getLayer(t.layerId).layer,i=this._map.hasLayer(e),t.checked&&!i?o.push(e):!t.checked&&i&&s.push(e);for(r=0;r=0;s--)t=n[s],e=this._getLayer(t.layerId).layer,t.disabled=e.options.minZoom!==i&&oe.options.maxZoom},_expand:function(){return this.expand()},_collapse:function(){return this.collapse()}}),n.control.layers=function(t,e,i){return new n.Control.Layers(t,e,i)}}(window,document) \ No newline at end of file +this._fireClick&&t&&t.changedTouches){var i=t.changedTouches[0],o=i.target;o&&o.tagName&&"a"===o.tagName.toLowerCase()&&n.DomUtil.removeClass(o,"leaflet-active"),this._simulateEvent("mouseup",i),this._isTapValid()&&this._simulateEvent("click",i)}},_isTapValid:function(){return this._newPos.distanceTo(this._startPos)<=this._map.options.tapTolerance},_onMove:function(t){var e=t.touches[0];this._newPos=new n.Point(e.clientX,e.clientY),this._simulateEvent("mousemove",e)},_simulateEvent:function(i,n){var o=e.createEvent("MouseEvents");o._simulated=!0,n.target._simulatedClick=!0,o.initMouseEvent(i,!0,!0,t,1,n.screenX,n.screenY,n.clientX,n.clientY,!1,!1,!1,!1,0,null),n.target.dispatchEvent(o)}}),n.Browser.touch&&!n.Browser.pointer&&n.Map.addInitHook("addHandler","tap",n.Map.Tap),n.Map.mergeOptions({boxZoom:!0}),n.Map.BoxZoom=n.Handler.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane},addHooks:function(){n.DomEvent.on(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){n.DomEvent.off(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_resetState:function(){this._moved=!1},_onMouseDown:function(t){return!(!t.shiftKey||1!==t.which&&1!==t.button)&&(this._resetState(),n.DomUtil.disableTextSelection(),n.DomUtil.disableImageDrag(),this._startPoint=this._map.mouseEventToContainerPoint(t),void n.DomEvent.on(e,{contextmenu:n.DomEvent.stop,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this))},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=n.DomUtil.create("div","leaflet-zoom-box",this._container),n.DomUtil.addClass(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var e=new n.Bounds(this._point,this._startPoint),i=e.getSize();n.DomUtil.setPosition(this._box,e.min),this._box.style.width=i.x+"px",this._box.style.height=i.y+"px"},_finish:function(){this._moved&&(n.DomUtil.remove(this._box),n.DomUtil.removeClass(this._container,"leaflet-crosshair")),n.DomUtil.enableTextSelection(),n.DomUtil.enableImageDrag(),n.DomEvent.off(e,{contextmenu:n.DomEvent.stop,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){if((1===t.which||1===t.button)&&(this._finish(),this._moved)){setTimeout(n.bind(this._resetState,this),0);var e=new n.LatLngBounds(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point));this._map.fitBounds(e).fire("boxzoomend",{boxZoomBounds:e})}},_onKeyDown:function(t){27===t.keyCode&&this._finish()}}),n.Map.addInitHook("addHandler","boxZoom",n.Map.BoxZoom),n.Map.mergeOptions({keyboard:!0,keyboardPanDelta:80}),n.Map.Keyboard=n.Handler.extend({keyCodes:{left:[37],right:[39],down:[40],up:[38],zoomIn:[187,107,61,171],zoomOut:[189,109,54,173]},initialize:function(t){this._map=t,this._setPanDelta(t.options.keyboardPanDelta),this._setZoomDelta(t.options.zoomDelta)},addHooks:function(){var t=this._map._container;t.tabIndex<=0&&(t.tabIndex="0"),n.DomEvent.on(t,{focus:this._onFocus,blur:this._onBlur,mousedown:this._onMouseDown},this),this._map.on({focus:this._addHooks,blur:this._removeHooks},this)},removeHooks:function(){this._removeHooks(),n.DomEvent.off(this._map._container,{focus:this._onFocus,blur:this._onBlur,mousedown:this._onMouseDown},this),this._map.off({focus:this._addHooks,blur:this._removeHooks},this)},_onMouseDown:function(){if(!this._focused){var i=e.body,n=e.documentElement,o=i.scrollTop||n.scrollTop,s=i.scrollLeft||n.scrollLeft;this._map._container.focus(),t.scrollTo(s,o)}},_onFocus:function(){this._focused=!0,this._map.fire("focus")},_onBlur:function(){this._focused=!1,this._map.fire("blur")},_setPanDelta:function(t){var e,i,n=this._panKeys={},o=this.keyCodes;for(e=0,i=o.left.length;e0&&t.screenY>0&&this._map.getContainer().focus()}}),n.control=function(t){return new n.Control(t)},n.Map.include({addControl:function(t){return t.addTo(this),this},removeControl:function(t){return t.remove(),this},_initControlPos:function(){function t(t,s){var r=i+t+" "+i+s;e[t+s]=n.DomUtil.create("div",r,o)}var e=this._controlCorners={},i="leaflet-",o=this._controlContainer=n.DomUtil.create("div",i+"control-container",this._container);t("top","left"),t("top","right"),t("bottom","left"),t("bottom","right")},_clearControlPos:function(){n.DomUtil.remove(this._controlContainer)}}),n.Control.Zoom=n.Control.extend({options:{position:"topleft",zoomInText:"+",zoomInTitle:"Zoom in",zoomOutText:"-",zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=n.DomUtil.create("div",e+" leaflet-bar"),o=this.options;return this._zoomInButton=this._createButton(o.zoomInText,o.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(o.zoomOutText,o.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,o,s){var r=n.DomUtil.create("a",i,o);return r.innerHTML=t,r.href="#",r.title=e,r.setAttribute("role","button"),r.setAttribute("aria-label",e),n.DomEvent.on(r,"mousedown dblclick",n.DomEvent.stopPropagation).on(r,"click",n.DomEvent.stop).on(r,"click",s,this).on(r,"click",this._refocusOnMap,this),r},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";n.DomUtil.removeClass(this._zoomInButton,e),n.DomUtil.removeClass(this._zoomOutButton,e),(this._disabled||t._zoom===t.getMinZoom())&&n.DomUtil.addClass(this._zoomOutButton,e),(this._disabled||t._zoom===t.getMaxZoom())&&n.DomUtil.addClass(this._zoomInButton,e)}}),n.Map.mergeOptions({zoomControl:!0}),n.Map.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new n.Control.Zoom,this.addControl(this.zoomControl))}),n.control.zoom=function(t){return new n.Control.Zoom(t)},n.Control.Attribution=n.Control.extend({options:{position:"bottomright",prefix:'Leaflet'},initialize:function(t){n.setOptions(this,t),this._attributions={}},onAdd:function(t){t.attributionControl=this,this._container=n.DomUtil.create("div","leaflet-control-attribution"),n.DomEvent&&n.DomEvent.disableClickPropagation(this._container);for(var e in t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),this._container},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t?(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update(),this):this},removeAttribution:function(t){return t?(this._attributions[t]&&(this._attributions[t]--,this._update()),this):this},_update:function(){if(this._map){var t=[];for(var e in this._attributions)this._attributions[e]&&t.push(e);var i=[];this.options.prefix&&i.push(this.options.prefix),t.length&&i.push(t.join(", ")),this._container.innerHTML=i.join(" | ")}}}),n.Map.mergeOptions({attributionControl:!0}),n.Map.addInitHook(function(){this.options.attributionControl&&(new n.Control.Attribution).addTo(this)}),n.control.attribution=function(t){return new n.Control.Attribution(t)},n.Control.Scale=n.Control.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=n.DomUtil.create("div",e),o=this.options;return this._addScales(o,e+"-line",i),t.on(o.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=n.DomUtil.create("div",e,i)),t.imperial&&(this._iScale=n.DomUtil.create("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,i=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(i)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t),i=e<1e3?e+" m":e/1e3+" km";this._updateScale(this._mScale,i,e/t)},_updateImperial:function(t){var e,i,n,o=3.2808399*t;o>5280?(e=o/5280,i=this._getRoundNum(e),this._updateScale(this._iScale,i+" mi",i/e)):(n=this._getRoundNum(o),this._updateScale(this._iScale,n+" ft",n/o))},_updateScale:function(t,e,i){t.style.width=Math.round(this.options.maxWidth*i)+"px",t.innerHTML=e},_getRoundNum:function(t){var e=Math.pow(10,(Math.floor(t)+"").length-1),i=t/e;return i=i>=10?10:i>=5?5:i>=3?3:i>=2?2:1,e*i}}),n.control.scale=function(t){return new n.Control.Scale(t)},n.Control.Layers=n.Control.extend({options:{collapsed:!0,position:"topright",autoZIndex:!0,hideSingleBase:!1,sortLayers:!1,sortFunction:function(t,e,i,n){return i1,this._baseLayersList.style.display=t?"":"none"),this._separator.style.display=e&&t?"":"none",this},_onLayerChange:function(t){this._handlingClick||this._update();var e=this._getLayer(n.stamp(t.target)),i=e.overlay?"add"===t.type?"overlayadd":"overlayremove":"add"===t.type?"baselayerchange":null;i&&this._map.fire(i,e)},_createRadioElement:function(t,i){var n='",o=e.createElement("div");return o.innerHTML=n,o.firstChild},_addItem:function(t){var i,o=e.createElement("label"),s=this._map.hasLayer(t.layer);t.overlay?(i=e.createElement("input"),i.type="checkbox",i.className="leaflet-control-layers-selector",i.defaultChecked=s):i=this._createRadioElement("leaflet-base-layers",s),i.layerId=n.stamp(t.layer),n.DomEvent.on(i,"click",this._onInputClick,this);var r=e.createElement("span");r.innerHTML=" "+t.name;var a=e.createElement("div");return o.appendChild(a),a.appendChild(i),a.appendChild(r),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(o),this._checkDisabledLayers(),o},_onInputClick:function(){var t,e,i,n=this._form.getElementsByTagName("input"),o=[],s=[];this._handlingClick=!0;for(var r=n.length-1;r>=0;r--)t=n[r],e=this._getLayer(t.layerId).layer,i=this._map.hasLayer(e),t.checked&&!i?o.push(e):!t.checked&&i&&s.push(e);for(r=0;r=0;s--)t=n[s],e=this._getLayer(t.layerId).layer,t.disabled=e.options.minZoom!==i&&oe.options.maxZoom},_expand:function(){return this.expand()},_collapse:function(){return this.collapse()}}),n.control.layers=function(t,e,i){return new n.Control.Layers(t,e,i)}}(window,document) \ 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 index 958e6a3dbe7..9e633a1d0cc 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html b/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html index f91812510db..dd54aa1192f 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html @@ -1,728 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html.gz index b7c938b88f1..93eae9a0374 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html.gz differ diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index d35869342e4..120ee57332e 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","25a3e24e6c27c3d91f0a8d68a7dfd753"],["/frontend/panels/dev-event-2db9c218065ef0f61d8d08db8093cad2.html","b5b751e49b1bba55f633ae0d7a92677d"],["/frontend/panels/dev-info-61610e015a411cfc84edd2c4d489e71d.html","6568377ee31cbd78fedc003b317f7faf"],["/frontend/panels/dev-service-415552027cb083badeff5f16080410ed.html","a4b1ec9bfa5bc3529af7783ae56cb55c"],["/frontend/panels/dev-state-d70314913b8923d750932367b1099750.html","c61b5b1461959aac106400e122993e9e"],["/frontend/panels/dev-template-567fbf86735e1b891e40c2f4060fec9b.html","d2853ecf45de1dbadf49fe99a7424ef3"],["/frontend/panels/map-31c592c239636f91e07c7ac232a5ebc4.html","182580419ce2c935ae6ec65502b6db96"],["/static/compatibility-8e4c44b5f4288cc48ec1ba94a9bec812.js","4704a985ad259e324c3d8a0a40f6d937"],["/static/core-d4a7cb8c80c62b536764e0e81385f6aa.js","37e34ec6aa0fa155c7d50e2883be1ead"],["/static/frontend-cca45decbed803e7f0ec0b4f6e18fe53.html","b23434348d7d71de510f230ca7b79f27"],["/static/mdi-1a5ad9654c1f0e57440e30afd92846a5.html","952d564236c75932c8eb1533a3a5a5ba"],["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"],["static/webcomponents-lite.min.js","32b5a9b7ada86304bec6b43d3f2194f0"]],cacheName="sw-precache-v3--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},cleanResponse=function(e){return e.redirected?("body"in e?Promise.resolve(e.body):e.blob()).then(function(t){return new Response(t,{headers:e.headers,status:e.status,statusText:e.statusText})}):Promise.resolve(e)},createCacheKey=function(e,t,a,n){var c=new URL(e);return n&&c.pathname.match(n)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(a)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],a=e[1],n=new URL(t,self.location),c=createCacheKey(n,hashParamName,a,!1);return[n.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(a){if(!t.has(a)){var n=new Request(a,{credentials:"same-origin"});return fetch(n).then(function(t){if(!t.ok)throw new Error("Request for "+a+" returned a response with status "+t.status);return cleanResponse(t).then(function(t){return e.put(a,t)})})}}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(a){return Promise.all(a.map(function(a){if(!t.has(a.url))return e.delete(a)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,a=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(a);t||(a=addDirectoryIndex(a,"index.html"),t=urlsToCacheKeys.has(a));!t&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(a=new URL("/",self.location).toString(),t=urlsToCacheKeys.has(a)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(a)).then(function(e){if(e)return e;throw Error("The cached response that was expected is missing.")})}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url)&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var a,n;for(a=0;a None: """Send the turn off command to the ISY994 light device.""" if not self._node.off(): - _LOGGER.debug("Unable to turn on light") + _LOGGER.debug("Unable to turn off light") def turn_on(self, brightness=None, **kwargs) -> None: """Send the turn on command to the ISY994 light device.""" diff --git a/homeassistant/components/light/lifx/__init__.py b/homeassistant/components/light/lifx.py similarity index 50% rename from homeassistant/components/light/lifx/__init__.py rename to homeassistant/components/light/lifx.py index 68d36058f8f..f1784618d94 100644 --- a/homeassistant/components/light/lifx/__init__.py +++ b/homeassistant/components/light/lifx.py @@ -11,18 +11,19 @@ import math from os import path from functools import partial from datetime import timedelta -import async_timeout import voluptuous as vol from homeassistant.components.light import ( Light, DOMAIN, PLATFORM_SCHEMA, LIGHT_TURN_ON_SCHEMA, - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, - ATTR_XY_COLOR, ATTR_COLOR_TEMP, ATTR_TRANSITION, ATTR_EFFECT, + ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, ATTR_RGB_COLOR, + ATTR_XY_COLOR, ATTR_COLOR_TEMP, ATTR_KELVIN, ATTR_TRANSITION, ATTR_EFFECT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, SUPPORT_XY_COLOR, SUPPORT_TRANSITION, SUPPORT_EFFECT, + VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, preprocess_turn_on_alternatives) from homeassistant.config import load_yaml_config_file +from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP from homeassistant import util from homeassistant.core import callback from homeassistant.helpers.event import async_track_point_in_utc_time @@ -30,34 +31,79 @@ from homeassistant.helpers.service import extract_entity_ids import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -from . import effects as lifx_effects - _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.4.8'] +REQUIREMENTS = ['aiolifx==0.5.0', 'aiolifx_effects==0.1.0'] UDP_BROADCAST_PORT = 56700 -# Delay (in ms) expected for changes to take effect in the physical bulb -BULB_LATENCY = 500 - CONF_SERVER = 'server' -SERVICE_LIFX_SET_STATE = 'lifx_set_state' - -ATTR_HSBK = 'hsbk' -ATTR_INFRARED = 'infrared' -ATTR_POWER = 'power' - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SERVER, default='0.0.0.0'): cv.string, }) +SERVICE_LIFX_SET_STATE = 'lifx_set_state' + +ATTR_INFRARED = 'infrared' +ATTR_POWER = 'power' + LIFX_SET_STATE_SCHEMA = LIGHT_TURN_ON_SCHEMA.extend({ ATTR_INFRARED: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)), ATTR_POWER: cv.boolean, }) +SERVICE_EFFECT_PULSE = 'lifx_effect_pulse' +SERVICE_EFFECT_COLORLOOP = 'lifx_effect_colorloop' +SERVICE_EFFECT_STOP = 'lifx_effect_stop' + +ATTR_POWER_ON = 'power_on' +ATTR_MODE = 'mode' +ATTR_PERIOD = 'period' +ATTR_CYCLES = 'cycles' +ATTR_SPREAD = 'spread' +ATTR_CHANGE = 'change' + +PULSE_MODE_BLINK = 'blink' +PULSE_MODE_BREATHE = 'breathe' +PULSE_MODE_PING = 'ping' +PULSE_MODE_STROBE = 'strobe' +PULSE_MODE_SOLID = 'solid' + +PULSE_MODES = [PULSE_MODE_BLINK, PULSE_MODE_BREATHE, PULSE_MODE_PING, + PULSE_MODE_STROBE, PULSE_MODE_SOLID] + +LIFX_EFFECT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_POWER_ON, default=True): cv.boolean, +}) + +LIFX_EFFECT_PULSE_SCHEMA = LIFX_EFFECT_SCHEMA.extend({ + ATTR_BRIGHTNESS: VALID_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT, + ATTR_COLOR_NAME: cv.string, + ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), + vol.Coerce(tuple)), + ATTR_COLOR_TEMP: vol.All(vol.Coerce(int), vol.Range(min=1)), + ATTR_KELVIN: vol.All(vol.Coerce(int), vol.Range(min=0)), + ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Range(min=0.05)), + ATTR_CYCLES: vol.All(vol.Coerce(float), vol.Range(min=1)), + ATTR_MODE: vol.In(PULSE_MODES), +}) + +LIFX_EFFECT_COLORLOOP_SCHEMA = LIFX_EFFECT_SCHEMA.extend({ + ATTR_BRIGHTNESS: VALID_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT, + ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Clamp(min=0.05)), + ATTR_CHANGE: vol.All(vol.Coerce(float), vol.Clamp(min=0, max=360)), + ATTR_SPREAD: vol.All(vol.Coerce(float), vol.Clamp(min=0, max=360)), + ATTR_TRANSITION: vol.All(vol.Coerce(float), vol.Range(min=0)), +}) + +LIFX_EFFECT_STOP_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -71,27 +117,79 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): server_addr = config.get(CONF_SERVER) lifx_manager = LIFXManager(hass, async_add_devices) + lifx_discovery = aiolifx.LifxDiscovery(hass.loop, lifx_manager) coro = hass.loop.create_datagram_endpoint( - partial(aiolifx.LifxDiscovery, hass.loop, lifx_manager), - local_addr=(server_addr, UDP_BROADCAST_PORT)) + lambda: lifx_discovery, local_addr=(server_addr, UDP_BROADCAST_PORT)) hass.async_add_job(coro) - lifx_effects.setup(hass, lifx_manager) + @callback + def cleanup(event): + """Clean up resources.""" + lifx_discovery.cleanup() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) return True +def find_hsbk(**kwargs): + """Find the desired color from a number of possible inputs.""" + hue, saturation, brightness, kelvin = [None]*4 + + preprocess_turn_on_alternatives(kwargs) + + if ATTR_RGB_COLOR in kwargs: + hue, saturation, brightness = \ + color_util.color_RGB_to_hsv(*kwargs[ATTR_RGB_COLOR]) + saturation = convert_8_to_16(saturation) + brightness = convert_8_to_16(brightness) + kelvin = 3500 + + if ATTR_XY_COLOR in kwargs: + hue, saturation = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) + saturation = convert_8_to_16(saturation) + kelvin = 3500 + + if ATTR_COLOR_TEMP in kwargs: + kelvin = int(color_util.color_temperature_mired_to_kelvin( + kwargs[ATTR_COLOR_TEMP])) + saturation = 0 + + if ATTR_BRIGHTNESS in kwargs: + brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) + + hsbk = [hue, saturation, brightness, kelvin] + return None if hsbk == [None]*4 else hsbk + + +def merge_hsbk(base, change): + """Copy change on top of base, except when None.""" + if change is None: + return None + return list(map(lambda x, y: y if y is not None else x, base, change)) + + class LIFXManager(object): """Representation of all known LIFX entities.""" def __init__(self, hass, async_add_devices): """Initialize the light.""" + import aiolifx_effects self.entities = {} self.hass = hass self.async_add_devices = async_add_devices + self.effects_conductor = aiolifx_effects.Conductor(loop=hass.loop) + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + self.register_set_state(descriptions) + self.register_effects(descriptions) + + def register_set_state(self, descriptions): + """Register the LIFX set_state service call.""" @asyncio.coroutine def async_service_handle(service): """Apply a service.""" @@ -99,22 +197,73 @@ class LIFXManager(object): for light in self.service_to_entities(service): if service.service == SERVICE_LIFX_SET_STATE: task = light.async_set_state(**service.data) - tasks.append(hass.async_add_job(task)) + tasks.append(self.hass.async_add_job(task)) if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) + yield from asyncio.wait(tasks, loop=self.hass.loop) - descriptions = self.get_descriptions() - - hass.services.async_register( + self.hass.services.async_register( DOMAIN, SERVICE_LIFX_SET_STATE, async_service_handle, descriptions.get(SERVICE_LIFX_SET_STATE), schema=LIFX_SET_STATE_SCHEMA) - @staticmethod - def get_descriptions(): - """Load and return descriptions for our own service calls.""" - return load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) + def register_effects(self, descriptions): + """Register the LIFX effects as hass service calls.""" + @asyncio.coroutine + def async_service_handle(service): + """Apply a service, i.e. start an effect.""" + entities = self.service_to_entities(service) + if entities: + yield from self.start_effect( + entities, service.service, **service.data) + + self.hass.services.async_register( + DOMAIN, SERVICE_EFFECT_PULSE, async_service_handle, + descriptions.get(SERVICE_EFFECT_PULSE), + schema=LIFX_EFFECT_PULSE_SCHEMA) + + self.hass.services.async_register( + DOMAIN, SERVICE_EFFECT_COLORLOOP, async_service_handle, + descriptions.get(SERVICE_EFFECT_COLORLOOP), + schema=LIFX_EFFECT_COLORLOOP_SCHEMA) + + self.hass.services.async_register( + DOMAIN, SERVICE_EFFECT_STOP, async_service_handle, + descriptions.get(SERVICE_EFFECT_STOP), + schema=LIFX_EFFECT_STOP_SCHEMA) + + @asyncio.coroutine + def start_effect(self, entities, service, **kwargs): + """Start a light effect on entities.""" + import aiolifx_effects + devices = list(map(lambda l: l.device, entities)) + + if service == SERVICE_EFFECT_PULSE: + effect = aiolifx_effects.EffectPulse( + power_on=kwargs.get(ATTR_POWER_ON), + period=kwargs.get(ATTR_PERIOD), + cycles=kwargs.get(ATTR_CYCLES), + mode=kwargs.get(ATTR_MODE), + hsbk=find_hsbk(**kwargs), + ) + yield from self.effects_conductor.start(effect, devices) + elif service == SERVICE_EFFECT_COLORLOOP: + preprocess_turn_on_alternatives(kwargs) + + brightness = None + if ATTR_BRIGHTNESS in kwargs: + brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) + + effect = aiolifx_effects.EffectColorloop( + power_on=kwargs.get(ATTR_POWER_ON), + period=kwargs.get(ATTR_PERIOD), + change=kwargs.get(ATTR_CHANGE), + spread=kwargs.get(ATTR_SPREAD), + transition=kwargs.get(ATTR_TRANSITION), + brightness=brightness, + ) + yield from self.effects_conductor.start(effect, devices) + elif service == SERVICE_EFFECT_STOP: + yield from self.effects_conductor.stop(devices) def service_to_entities(self, service): """Return the known devices that a service call mentions.""" @@ -148,7 +297,7 @@ class LIFXManager(object): @callback def ready(self, device, msg): """Handle the device once all data is retrieved.""" - entity = LIFXLight(device) + entity = LIFXLight(device, self.effects_conductor) _LOGGER.debug("%s register READY", entity.who) self.entities[device.mac_addr] = entity self.async_add_devices([entity]) @@ -182,17 +331,13 @@ class AwaitAioLIFX: @asyncio.coroutine def wait(self, method): - """Call an aiolifx method and wait for its response or a timeout.""" + """Call an aiolifx method and wait for its response.""" + self.device = None + self.message = None self.event.clear() - method(self.callback) - - while self.light.available and not self.event.is_set(): - try: - with async_timeout.timeout(1.0, loop=self.light.hass.loop): - yield from self.event.wait() - except asyncio.TimeoutError: - pass + method(callb=self.callback) + yield from self.event.wait() return self.message @@ -209,17 +354,13 @@ def convert_16_to_8(value): class LIFXLight(Light): """Representation of a LIFX light.""" - def __init__(self, device): + def __init__(self, device, effects_conductor): """Initialize the light.""" self.device = device + self.effects_conductor = effects_conductor self.registered = True self.product = device.product - self.blocker = None - self.effect_data = None self.postponed_update = None - self._name = device.label - self.set_power(device.power_level) - self.set_color(*device.color) @property def lifxwhite(self): @@ -235,34 +376,33 @@ class LIFXLight(Light): @property def name(self): """Return the name of the device.""" - return self._name + return self.device.label @property def who(self): """Return a string identifying the device.""" - ip_addr = '-' - if self.device: - ip_addr = self.device.ip_addr[0] - return "%s (%s)" % (ip_addr, self.name) + return "%s (%s)" % (self.device.ip_addr, self.name) @property def rgb_color(self): """Return the RGB value.""" - _LOGGER.debug( - "rgb_color: [%d %d %d]", self._rgb[0], self._rgb[1], self._rgb[2]) - return self._rgb + hue, sat, bri, _ = self.device.color + + return color_util.color_hsv_to_RGB( + hue, convert_16_to_8(sat), convert_16_to_8(bri)) @property def brightness(self): """Return the brightness of this light between 0..255.""" - brightness = convert_16_to_8(self._bri) + brightness = convert_16_to_8(self.device.color[2]) _LOGGER.debug("brightness: %d", brightness) return brightness @property def color_temp(self): """Return the color temperature.""" - temperature = color_util.color_temperature_kelvin_to_mired(self._kel) + kelvin = self.device.color[3] + temperature = color_util.color_temperature_kelvin_to_mired(kelvin) _LOGGER.debug("color_temp: %d", temperature) return temperature @@ -290,13 +430,15 @@ class LIFXLight(Light): @property def is_on(self): """Return true if device is on.""" - _LOGGER.debug("is_on: %d", self._power) - return self._power != 0 + return self.device.power_level != 0 @property def effect(self): - """Return the currently running effect.""" - return self.effect_data.effect.name if self.effect_data else None + """Return the name of the currently running effect.""" + effect = self.effects_conductor.effect(self.device) + if effect: + return 'lifx_effect_' + effect.name + return None @property def supported_features(self): @@ -311,38 +453,35 @@ class LIFXLight(Light): @property def effect_list(self): - """Return the list of supported effects.""" - return lifx_effects.effect_list(self) + """Return the list of supported effects for this light.""" + if self.lifxwhite: + return [ + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_STOP, + ] + + return [ + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_STOP, + ] @asyncio.coroutine def update_after_transition(self, now): """Request new status after completion of the last transition.""" self.postponed_update = None - yield from self.refresh_state() - yield from self.async_update_ha_state() - - @asyncio.coroutine - def unblock_updates(self, now): - """Allow async_update after the new state has settled on the bulb.""" - self.blocker = None - yield from self.refresh_state() + yield from self.async_update() yield from self.async_update_ha_state() def update_later(self, when): - """Block immediate update requests and schedule one for later.""" - if self.blocker: - self.blocker() - self.blocker = async_track_point_in_utc_time( - self.hass, self.unblock_updates, - util.dt.utcnow() + timedelta(milliseconds=BULB_LATENCY)) - + """Schedule an update requests when a transition is over.""" if self.postponed_update: self.postponed_update() self.postponed_update = None - if when > BULB_LATENCY: + if when > 0: self.postponed_update = async_track_point_in_utc_time( self.hass, self.update_after_transition, - util.dt.utcnow() + timedelta(milliseconds=when+BULB_LATENCY)) + util.dt.utcnow() + timedelta(milliseconds=when)) @asyncio.coroutine def async_turn_on(self, **kwargs): @@ -359,10 +498,10 @@ class LIFXLight(Light): @asyncio.coroutine def async_set_state(self, **kwargs): """Set a color on the light and turn it on/off.""" - yield from self.stop_effect() + yield from self.effects_conductor.stop([self.device]) if ATTR_EFFECT in kwargs: - yield from lifx_effects.default_effect(self, **kwargs) + yield from self.default_effect(**kwargs) return if ATTR_INFRARED in kwargs: @@ -377,124 +516,44 @@ class LIFXLight(Light): power_on = kwargs.get(ATTR_POWER, False) power_off = not kwargs.get(ATTR_POWER, True) - hsbk, changed_color = self.find_hsbk(**kwargs) - _LOGGER.debug("turn_on: %s (%d) %d %d %d %d %d", - self.who, self._power, fade, *hsbk) + hsbk = merge_hsbk(self.device.color, find_hsbk(**kwargs)) - if self._power == 0: + # Send messages, waiting for ACK each time + ack = AwaitAioLIFX(self).wait + bulb = self.device + + if not self.is_on: if power_off: - self.device.set_power(False, None, 0) - if changed_color: - self.device.set_color(hsbk, None, 0) + yield from ack(partial(bulb.set_power, False)) + if hsbk: + yield from ack(partial(bulb.set_color, hsbk)) if power_on: - self.device.set_power(True, None, fade) + yield from ack(partial(bulb.set_power, True, duration=fade)) else: if power_on: - self.device.set_power(True, None, 0) - if changed_color: - self.device.set_color(hsbk, None, fade) + yield from ack(partial(bulb.set_power, True)) + if hsbk: + yield from ack(partial(bulb.set_color, hsbk, duration=fade)) if power_off: - self.device.set_power(False, None, fade) + yield from ack(partial(bulb.set_power, False, duration=fade)) - if power_on: - self.update_later(0) - else: - self.update_later(fade) + # Schedule an update when the transition is complete + self.update_later(fade) - if fade <= BULB_LATENCY: - if power_on: - self.set_power(1) - if power_off: - self.set_power(0) - if changed_color: - self.set_color(*hsbk) + @asyncio.coroutine + def default_effect(self, **kwargs): + """Start an effect with default parameters.""" + service = kwargs[ATTR_EFFECT] + data = { + ATTR_ENTITY_ID: self.entity_id, + } + yield from self.hass.services.async_call(DOMAIN, service, data) @asyncio.coroutine def async_update(self): - """Update bulb status (if it is available).""" + """Update bulb status.""" _LOGGER.debug("%s async_update", self.who) - if self.blocker is None: - yield from self.refresh_state() - - @asyncio.coroutine - def stop_effect(self): - """Stop the currently running effect (if any).""" - if self.effect_data: - yield from self.effect_data.effect.async_restore(self) - - @asyncio.coroutine - def refresh_state(self): - """Ask the device about its current state and update our copy.""" if self.available: - msg = yield from AwaitAioLIFX(self).wait(self.device.get_color) - if msg is not None: - self.set_power(self.device.power_level) - self.set_color(*self.device.color) - self._name = self.device.label - - def find_hsbk(self, **kwargs): - """Find the desired color from a number of possible inputs.""" - changed_color = False - - hsbk = kwargs.pop(ATTR_HSBK, None) - if hsbk is not None: - return [hsbk, True] - - preprocess_turn_on_alternatives(kwargs) - - if ATTR_RGB_COLOR in kwargs: - hue, saturation, brightness = \ - color_util.color_RGB_to_hsv(*kwargs[ATTR_RGB_COLOR]) - saturation = convert_8_to_16(saturation) - brightness = convert_8_to_16(brightness) - changed_color = True - else: - hue = self._hue - saturation = self._sat - brightness = self._bri - - if ATTR_XY_COLOR in kwargs: - hue, saturation = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) - saturation = convert_8_to_16(saturation) - changed_color = True - - # When color or temperature is set, use a default value for the other - if ATTR_COLOR_TEMP in kwargs: - kelvin = int(color_util.color_temperature_mired_to_kelvin( - kwargs[ATTR_COLOR_TEMP])) - if not changed_color: - saturation = 0 - changed_color = True - else: - if changed_color: - kelvin = 3500 - else: - kelvin = self._kel - - if ATTR_BRIGHTNESS in kwargs: - brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) - changed_color = True - else: - brightness = self._bri - - return [[hue, saturation, brightness, kelvin], changed_color] - - def set_power(self, power): - """Set power state value.""" - _LOGGER.debug("set_power: %d", power) - self._power = (power != 0) - - def set_color(self, hue, sat, bri, kel): - """Set color state values.""" - self._hue = hue - self._sat = sat - self._bri = bri - self._kel = kel - - red, green, blue = color_util.color_hsv_to_RGB( - hue, convert_16_to_8(sat), convert_16_to_8(bri)) - - _LOGGER.debug("set_color: %d %d %d %d [%d %d %d]", - hue, sat, bri, kel, red, green, blue) - - self._rgb = [red, green, blue] + # Avoid state ping-pong by holding off updates as the state settles + yield from asyncio.sleep(0.25) + yield from AwaitAioLIFX(self).wait(self.device.get_color) diff --git a/homeassistant/components/light/lifx/effects.py b/homeassistant/components/light/lifx/effects.py deleted file mode 100644 index cdd5e44147a..00000000000 --- a/homeassistant/components/light/lifx/effects.py +++ /dev/null @@ -1,388 +0,0 @@ -"""Support for light effects for the LIFX light platform.""" -import logging -import asyncio -import random - -import voluptuous as vol - -from homeassistant.components.light import ( - DOMAIN, ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, - ATTR_RGB_COLOR, ATTR_COLOR_TEMP, ATTR_KELVIN, ATTR_EFFECT, ATTR_TRANSITION, - VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT) -from homeassistant.const import (ATTR_ENTITY_ID) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -SERVICE_EFFECT_BREATHE = 'lifx_effect_breathe' -SERVICE_EFFECT_PULSE = 'lifx_effect_pulse' -SERVICE_EFFECT_COLORLOOP = 'lifx_effect_colorloop' -SERVICE_EFFECT_STOP = 'lifx_effect_stop' - -ATTR_POWER_ON = 'power_on' -ATTR_PERIOD = 'period' -ATTR_CYCLES = 'cycles' -ATTR_MODE = 'mode' -ATTR_SPREAD = 'spread' -ATTR_CHANGE = 'change' - -MODE_BLINK = 'blink' -MODE_BREATHE = 'breathe' -MODE_PING = 'ping' -MODE_STROBE = 'strobe' -MODE_SOLID = 'solid' - -MODES = [MODE_BLINK, MODE_BREATHE, MODE_PING, MODE_STROBE, MODE_SOLID] - -# aiolifx waveform modes -WAVEFORM_SINE = 1 -WAVEFORM_PULSE = 4 - -NEUTRAL_WHITE = 3500 - -LIFX_EFFECT_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(ATTR_POWER_ON, default=True): cv.boolean, -}) - -LIFX_EFFECT_BREATHE_SCHEMA = LIFX_EFFECT_SCHEMA.extend({ - ATTR_BRIGHTNESS: VALID_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT, - ATTR_COLOR_NAME: cv.string, - ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), - vol.Coerce(tuple)), - ATTR_COLOR_TEMP: vol.All(vol.Coerce(int), vol.Range(min=1)), - ATTR_KELVIN: vol.All(vol.Coerce(int), vol.Range(min=0)), - ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Range(min=0.05)), - ATTR_CYCLES: vol.All(vol.Coerce(float), vol.Range(min=1)), -}) - -LIFX_EFFECT_PULSE_SCHEMA = LIFX_EFFECT_BREATHE_SCHEMA.extend({ - vol.Optional(ATTR_MODE, default=MODE_BLINK): vol.In(MODES), -}) - -LIFX_EFFECT_COLORLOOP_SCHEMA = LIFX_EFFECT_SCHEMA.extend({ - ATTR_BRIGHTNESS: VALID_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT, - vol.Optional(ATTR_PERIOD, default=60): - vol.All(vol.Coerce(float), vol.Clamp(min=0.05)), - vol.Optional(ATTR_CHANGE, default=20): - vol.All(vol.Coerce(float), vol.Clamp(min=0, max=360)), - vol.Optional(ATTR_SPREAD, default=30): - vol.All(vol.Coerce(float), vol.Clamp(min=0, max=360)), - ATTR_TRANSITION: vol.All(vol.Coerce(float), vol.Range(min=0)), -}) - -LIFX_EFFECT_STOP_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(ATTR_POWER_ON, default=False): cv.boolean, -}) - - -def setup(hass, lifx_manager): - """Register the LIFX effects as hass service calls.""" - @asyncio.coroutine - def async_service_handle(service): - """Apply a service.""" - entities = lifx_manager.service_to_entities(service) - if entities: - yield from start_effect(hass, entities, - service.service, **service.data) - - descriptions = lifx_manager.get_descriptions() - - hass.services.async_register( - DOMAIN, SERVICE_EFFECT_BREATHE, async_service_handle, - descriptions.get(SERVICE_EFFECT_BREATHE), - schema=LIFX_EFFECT_BREATHE_SCHEMA) - - hass.services.async_register( - DOMAIN, SERVICE_EFFECT_PULSE, async_service_handle, - descriptions.get(SERVICE_EFFECT_PULSE), - schema=LIFX_EFFECT_PULSE_SCHEMA) - - hass.services.async_register( - DOMAIN, SERVICE_EFFECT_COLORLOOP, async_service_handle, - descriptions.get(SERVICE_EFFECT_COLORLOOP), - schema=LIFX_EFFECT_COLORLOOP_SCHEMA) - - hass.services.async_register( - DOMAIN, SERVICE_EFFECT_STOP, async_service_handle, - descriptions.get(SERVICE_EFFECT_STOP), - schema=LIFX_EFFECT_STOP_SCHEMA) - - -@asyncio.coroutine -def start_effect(hass, devices, service, **data): - """Start a light effect.""" - tasks = [] - for light in devices: - tasks.append(hass.async_add_job(light.stop_effect())) - yield from asyncio.wait(tasks, loop=hass.loop) - - if service in SERVICE_EFFECT_BREATHE: - effect = LIFXEffectBreathe(hass, devices) - elif service in SERVICE_EFFECT_PULSE: - effect = LIFXEffectPulse(hass, devices) - elif service == SERVICE_EFFECT_COLORLOOP: - effect = LIFXEffectColorloop(hass, devices) - elif service == SERVICE_EFFECT_STOP: - effect = LIFXEffectStop(hass, devices) - - hass.async_add_job(effect.async_perform(**data)) - - -@asyncio.coroutine -def default_effect(light, **kwargs): - """Start an effect with default parameters.""" - service = kwargs[ATTR_EFFECT] - data = { - ATTR_ENTITY_ID: light.entity_id, - } - yield from light.hass.services.async_call(DOMAIN, service, data) - - -def effect_list(light): - """Return the list of supported effects for this light.""" - if light.lifxwhite: - return [ - SERVICE_EFFECT_BREATHE, - SERVICE_EFFECT_PULSE, - SERVICE_EFFECT_STOP, - ] - else: - return [ - SERVICE_EFFECT_COLORLOOP, - SERVICE_EFFECT_BREATHE, - SERVICE_EFFECT_PULSE, - SERVICE_EFFECT_STOP, - ] - - -class LIFXEffectData(object): - """Structure describing a running effect.""" - - def __init__(self, effect, power, color): - """Initialize data structure.""" - self.effect = effect - self.power = power - self.color = color - - -class LIFXEffect(object): - """Representation of a light effect running on a number of lights.""" - - def __init__(self, hass, lights): - """Initialize the effect.""" - self.hass = hass - self.lights = lights - - @asyncio.coroutine - def async_perform(self, **kwargs): - """Do common setup and play the effect.""" - yield from self.async_setup(**kwargs) - yield from self.async_play(**kwargs) - - @asyncio.coroutine - def async_setup(self, **kwargs): - """Prepare all lights for the effect.""" - for light in self.lights: - # Remember the current state (as far as we know it) - yield from light.refresh_state() - light.effect_data = LIFXEffectData( - self, light.is_on, light.device.color) - - # Temporarily turn on power for the effect to be visible - if kwargs[ATTR_POWER_ON] and not light.is_on: - hsbk = self.from_poweroff_hsbk(light, **kwargs) - light.device.set_color(hsbk) - light.device.set_power(True) - - # pylint: disable=no-self-use - @asyncio.coroutine - def async_play(self, **kwargs): - """Play the effect.""" - yield None - - @asyncio.coroutine - def async_restore(self, light): - """Restore to the original state (if we are still running).""" - if light in self.lights: - self.lights.remove(light) - - if light.effect_data and light.effect_data.effect == self: - if not light.effect_data.power: - light.device.set_power(False) - yield from asyncio.sleep(0.5) - - light.device.set_color(light.effect_data.color) - yield from asyncio.sleep(0.5) - - light.effect_data = None - yield from light.refresh_state() - - def from_poweroff_hsbk(self, light, **kwargs): - """Return the color when starting from a powered off state.""" - return [random.randint(0, 65535), 65535, 0, NEUTRAL_WHITE] - - -class LIFXEffectPulse(LIFXEffect): - """Representation of a pulse effect.""" - - def __init__(self, hass, lights): - """Initialize the pulse effect.""" - super().__init__(hass, lights) - self.name = SERVICE_EFFECT_PULSE - - @asyncio.coroutine - def async_play(self, **kwargs): - """Play the effect on all lights.""" - for light in self.lights: - self.hass.async_add_job(self.async_light_play(light, **kwargs)) - - @asyncio.coroutine - def async_light_play(self, light, **kwargs): - """Play a light effect on the bulb.""" - hsbk, color_changed = light.find_hsbk(**kwargs) - - if kwargs[ATTR_MODE] == MODE_STROBE: - # Strobe must flash from a dark color - light.device.set_color([0, 0, 0, NEUTRAL_WHITE]) - yield from asyncio.sleep(0.1) - default_period = 0.1 - default_cycles = 10 - else: - default_period = 1.0 - default_cycles = 1 - - period = kwargs.get(ATTR_PERIOD, default_period) - cycles = kwargs.get(ATTR_CYCLES, default_cycles) - - # Breathe has a special waveform - if kwargs[ATTR_MODE] == MODE_BREATHE: - waveform = WAVEFORM_SINE - else: - waveform = WAVEFORM_PULSE - - # Ping and solid have special duty cycles - if kwargs[ATTR_MODE] == MODE_PING: - ping_duration = int(5000 - min(2500, 300*period)) - duty_cycle = 2**15 - ping_duration - elif kwargs[ATTR_MODE] == MODE_SOLID: - duty_cycle = -2**15 - else: - duty_cycle = 0 - - # Set default effect color based on current setting - if not color_changed: - if kwargs[ATTR_MODE] == MODE_STROBE: - # Strobe: cold white - hsbk = [hsbk[0], 0, 65535, 5600] - elif light.lifxwhite or hsbk[1] < 65536/2: - # White: toggle brightness - hsbk[2] = 65535 if hsbk[2] < 65536/2 else 0 - else: - # Color: fully desaturate with full brightness - hsbk = [hsbk[0], 0, 65535, 4000] - - # Start the effect - args = { - 'transient': 1, - 'color': hsbk, - 'period': int(period*1000), - 'cycles': cycles, - 'duty_cycle': duty_cycle, - 'waveform': waveform, - } - light.device.set_waveform(args) - - # Wait for completion and restore the initial state - yield from asyncio.sleep(period*cycles) - yield from self.async_restore(light) - - def from_poweroff_hsbk(self, light, **kwargs): - """Return the color is the target color, but no brightness.""" - hsbk, _ = light.find_hsbk(**kwargs) - return [hsbk[0], hsbk[1], 0, hsbk[2]] - - -class LIFXEffectBreathe(LIFXEffectPulse): - """Representation of a breathe effect.""" - - def __init__(self, hass, lights): - """Initialize the breathe effect.""" - super().__init__(hass, lights) - self.name = SERVICE_EFFECT_BREATHE - _LOGGER.warning("'lifx_effect_breathe' is deprecated. Please use " - "'lifx_effect_pulse' with 'mode: breathe'") - - @asyncio.coroutine - def async_perform(self, **kwargs): - """Prepare all lights for the effect.""" - kwargs[ATTR_MODE] = MODE_BREATHE - yield from super().async_perform(**kwargs) - - -class LIFXEffectColorloop(LIFXEffect): - """Representation of a colorloop effect.""" - - def __init__(self, hass, lights): - """Initialize the colorloop effect.""" - super().__init__(hass, lights) - self.name = SERVICE_EFFECT_COLORLOOP - - @asyncio.coroutine - def async_play(self, **kwargs): - """Play the effect on all lights.""" - period = kwargs[ATTR_PERIOD] - spread = kwargs[ATTR_SPREAD] - change = kwargs[ATTR_CHANGE] - direction = 1 if random.randint(0, 1) else -1 - - # Random start - hue = random.uniform(0, 360) % 360 - - while self.lights: - hue = (hue + direction*change) % 360 - - random.shuffle(self.lights) - lhue = hue - - for light in self.lights: - if ATTR_TRANSITION in kwargs: - transition = int(1000*kwargs[ATTR_TRANSITION]) - elif light == self.lights[0] or spread > 0: - transition = int(1000 * random.uniform(period/2, period)) - - if ATTR_BRIGHTNESS in kwargs: - brightness = int(65535/255*kwargs[ATTR_BRIGHTNESS]) - else: - brightness = light.effect_data.color[2] - - hsbk = [ - int(65535/360*lhue), - int(random.uniform(0.8, 1.0)*65535), - brightness, - NEUTRAL_WHITE, - ] - light.device.set_color(hsbk, None, transition) - - # Adjust the next light so the full spread is used - if len(self.lights) > 1: - lhue = (lhue + spread/(len(self.lights)-1)) % 360 - - yield from asyncio.sleep(period) - - -class LIFXEffectStop(LIFXEffect): - """A no-op effect, but starting it will stop an existing effect.""" - - def __init__(self, hass, lights): - """Initialize the stop effect.""" - super().__init__(hass, lights) - self.name = SERVICE_EFFECT_STOP - - @asyncio.coroutine - def async_perform(self, **kwargs): - """Do nothing.""" - yield None diff --git a/homeassistant/components/light/lifx/services.yaml b/homeassistant/components/light/lifx/services.yaml deleted file mode 100644 index 6028b0c4f9c..00000000000 --- a/homeassistant/components/light/lifx/services.yaml +++ /dev/null @@ -1,98 +0,0 @@ -lifx_set_state: - 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 - example: 'light.garage' - - '...': - description: All turn_on parameters can be used to specify a color - - infrared: - description: Automatic infrared level (0..255) when light brightness is low - example: 255 - - transition: - 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_breathe: - description: Deprecated, use lifx_effect_pulse - -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 - example: 'light.kitchen' - - mode: - 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 - example: 120 - - color_name: - description: A human readable color name - example: 'red' - - rgb_color: - description: The temporary color in RGB-format - example: '[255, 100, 100]' - - period: - description: Duration of the effect in seconds (default 1.0) - example: 3 - - cycles: - 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) - 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 - 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 - example: 120 - - period: - 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) - example: 45 - - spread: - 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) - 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. - example: 'light.bedroom' diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 4e44351b7dd..aad2abdd183 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -24,11 +24,13 @@ CONF_BRIDGES = 'bridges' CONF_GROUPS = 'groups' CONF_NUMBER = 'number' CONF_VERSION = 'version' +CONF_FADE = 'fade' DEFAULT_LED_TYPE = 'rgbw' DEFAULT_PORT = 5987 DEFAULT_TRANSITION = 0 DEFAULT_VERSION = 6 +DEFAULT_FADE = False LED_TYPE = ['rgbw', 'rgbww', 'white', 'bridge-led'] @@ -58,6 +60,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_TYPE, default=DEFAULT_LED_TYPE): vol.In(LED_TYPE), vol.Required(CONF_NUMBER): cv.positive_int, + vol.Optional(CONF_FADE, default=DEFAULT_FADE): cv.boolean, } ]), }, @@ -112,7 +115,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): group_conf.get(CONF_NUMBER), group_conf.get(CONF_NAME), group_conf.get(CONF_TYPE, DEFAULT_LED_TYPE)) - lights.append(LimitlessLEDGroup.factory(group)) + lights.append(LimitlessLEDGroup.factory(group, { + 'fade': group_conf[CONF_FADE] + })) add_devices(lights) @@ -152,25 +157,26 @@ def state(new_state): class LimitlessLEDGroup(Light): """Representation of a LimitessLED group.""" - def __init__(self, group): + def __init__(self, group, config): """Initialize a group.""" self.group = group self.repeating = False self._is_on = False self._brightness = None + self.config = config @staticmethod - def factory(group): + def factory(group, config): """Produce LimitlessLEDGroup objects.""" from limitlessled.group.rgbw import RgbwGroup from limitlessled.group.white import WhiteGroup from limitlessled.group.rgbww import RgbwwGroup if isinstance(group, WhiteGroup): - return LimitlessLEDWhiteGroup(group) + return LimitlessLEDWhiteGroup(group, config) elif isinstance(group, RgbwGroup): - return LimitlessLEDRGBWGroup(group) + return LimitlessLEDRGBWGroup(group, config) elif isinstance(group, RgbwwGroup): - return LimitlessLEDRGBWWGroup(group) + return LimitlessLEDRGBWWGroup(group, config) @property def should_poll(self): @@ -196,15 +202,17 @@ class LimitlessLEDGroup(Light): def turn_off(self, transition_time, pipeline, **kwargs): """Turn off a group.""" if self.is_on: - pipeline.transition(transition_time, brightness=0.0).off() + if self.config[CONF_FADE]: + pipeline.transition(transition_time, brightness=0.0) + pipeline.off() class LimitlessLEDWhiteGroup(LimitlessLEDGroup): """Representation of a LimitlessLED White group.""" - def __init__(self, group): + def __init__(self, group, config): """Initialize White group.""" - super().__init__(group) + super().__init__(group, config) # Initialize group with known values. self.group.on = True self.group.temperature = 1.0 @@ -242,9 +250,9 @@ class LimitlessLEDWhiteGroup(LimitlessLEDGroup): class LimitlessLEDRGBWGroup(LimitlessLEDGroup): """Representation of a LimitlessLED RGBW group.""" - def __init__(self, group): + def __init__(self, group, config): """Initialize RGBW group.""" - super().__init__(group) + super().__init__(group, config) # Initialize group with known values. self.group.on = True self.group.white() @@ -301,9 +309,9 @@ class LimitlessLEDRGBWGroup(LimitlessLEDGroup): class LimitlessLEDRGBWWGroup(LimitlessLEDGroup): """Representation of a LimitlessLED RGBWW group.""" - def __init__(self, group): + def __init__(self, group, config): """Initialize RGBWW group.""" - super().__init__(group) + super().__init__(group, config) # Initialize group with known values. self.group.on = True self.group.white() diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 6ccd45dda66..ef99f18fb42 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -101,3 +101,98 @@ hue_activate_scene: scene_name: 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 + + fields: + entity_id: + 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 + + infrared: + description: Automatic infrared level (0..255) when light brightness is low + example: 255 + + transition: + 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 + example: 'light.kitchen' + + mode: + 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 + example: 120 + + color_name: + description: A human readable color name + example: 'red' + + rgb_color: + description: The temporary color in RGB-format + example: '[255, 100, 100]' + + period: + description: Duration of the effect in seconds (default 1.0) + example: 3 + + cycles: + 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) + 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 + 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 + example: 120 + + period: + 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) + example: 45 + + spread: + 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) + 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. + example: 'light.bedroom' diff --git a/homeassistant/components/lock/verisure.py b/homeassistant/components/lock/verisure.py index e4fa2104da6..4af19f52611 100644 --- a/homeassistant/components/lock/verisure.py +++ b/homeassistant/components/lock/verisure.py @@ -5,7 +5,8 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/verisure/ """ import logging - +from time import sleep +from time import time from homeassistant.components.verisure import HUB as hub from homeassistant.components.verisure import (CONF_LOCKS, CONF_CODE_DIGITS) from homeassistant.components.lock import LockDevice @@ -19,28 +20,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Verisure platform.""" locks = [] if int(hub.config.get(CONF_LOCKS, 1)): - hub.update_locks() + hub.update_overview() locks.extend([ - VerisureDoorlock(device_id) - for device_id in hub.lock_status - ]) + VerisureDoorlock(device_label) + for device_label in hub.get( + "$.doorLockStatusList[*].deviceLabel")]) + add_devices(locks) class VerisureDoorlock(LockDevice): """Representation of a Verisure doorlock.""" - def __init__(self, device_id): + def __init__(self, device_label): """Initialize the Verisure lock.""" - self._id = device_id + self._device_label = device_label self._state = STATE_UNKNOWN self._digits = hub.config.get(CONF_CODE_DIGITS) self._changed_by = None + self._change_timestamp = 0 @property def name(self): """Return the name of the lock.""" - return '{}'.format(hub.lock_status[self._id].location) + return hub.get_first( + "$.doorLockStatusList[?(@.deviceLabel=='%s')].area", + self._device_label) @property def state(self): @@ -50,7 +55,9 @@ class VerisureDoorlock(LockDevice): @property def available(self): """Return True if entity is available.""" - return hub.available + return hub.get_first( + "$.doorLockStatusList[?(@.deviceLabel=='%s')]", + self._device_label) is not None @property def changed_by(self): @@ -64,32 +71,52 @@ class VerisureDoorlock(LockDevice): def update(self): """Update lock status.""" - hub.update_locks() - - if hub.lock_status[self._id].status == 'unlocked': + if time() - self._change_timestamp < 10: + return + hub.update_overview() + status = hub.get_first( + "$.doorLockStatusList[?(@.deviceLabel=='%s')].lockedState", + self._device_label) + if status == 'UNLOCKED': self._state = STATE_UNLOCKED - elif hub.lock_status[self._id].status == 'locked': + elif status == 'LOCKED': self._state = STATE_LOCKED - elif hub.lock_status[self._id].status != 'pending': - _LOGGER.error( - "Unknown lock state %s", hub.lock_status[self._id].status) - self._changed_by = hub.lock_status[self._id].name + elif status != 'PENDING': + _LOGGER.error('Unknown lock state %s', status) + self._changed_by = hub.get_first( + "$.doorLockStatusList[?(@.deviceLabel=='%s')].userString", + self._device_label) @property def is_locked(self): """Return true if lock is locked.""" - return hub.lock_status[self._id].status + return self._state == STATE_LOCKED def unlock(self, **kwargs): """Send unlock command.""" - hub.my_pages.lock.set(kwargs[ATTR_CODE], self._id, 'UNLOCKED') - _LOGGER.debug("Verisure doorlock unlocking") - hub.my_pages.lock.wait_while_pending() - self.update() + if self._state == STATE_UNLOCKED: + return + self.set_lock_state(kwargs[ATTR_CODE], STATE_UNLOCKED) def lock(self, **kwargs): """Send lock command.""" - hub.my_pages.lock.set(kwargs[ATTR_CODE], self._id, 'LOCKED') - _LOGGER.debug("Verisure doorlock locking") - hub.my_pages.lock.wait_while_pending() - self.update() + if self._state == STATE_LOCKED: + return + self.set_lock_state(kwargs[ATTR_CODE], STATE_LOCKED) + + def set_lock_state(self, code, state): + """Send set lock state command.""" + lock_state = 'lock' if state == STATE_LOCKED else 'unlock' + transaction_id = hub.session.set_lock_state( + code, + self._device_label, + lock_state)['doorLockStateChangeTransactionId'] + _LOGGER.debug("Verisure doorlock %s", state) + transaction = {} + while 'result' not in transaction: + sleep(0.5) + transaction = hub.session.get_lock_state_transaction( + transaction_id) + if transaction['result'] == 'OK': + self._state = state + self._change_timestamp = time() diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index ea8b7e389ec..5f3b88ccf52 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/media_player.denon/ """ import logging +from collections import namedtuple import voluptuous as vol from homeassistant.components.media_player import ( @@ -16,16 +17,19 @@ from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_VOLUME_SET, SUPPORT_PLAY) from homeassistant.const import ( CONF_HOST, STATE_OFF, STATE_PLAYING, STATE_PAUSED, - CONF_NAME, STATE_ON) + CONF_NAME, STATE_ON, CONF_ZONE) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.4.4'] +REQUIREMENTS = ['denonavr==0.5.1'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = None DEFAULT_SHOW_SOURCES = False CONF_SHOW_ALL_SOURCES = 'show_all_sources' +CONF_ZONES = 'zones' +CONF_VALID_ZONES = ['Zone2', 'Zone3'] +CONF_INVALID_ZONES_ERR = 'Invalid Zone (expected Zone2 or Zone3)' KEY_DENON_CACHE = 'denonavr_hosts' SUPPORT_DENON = SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | \ @@ -36,16 +40,26 @@ SUPPORT_MEDIA_MODES = SUPPORT_PLAY_MEDIA | \ SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_SET | SUPPORT_PLAY +DENON_ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_ZONE): vol.In(CONF_VALID_ZONES, CONF_INVALID_ZONES_ERR), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES): cv.boolean, + vol.Optional(CONF_ZONES): + vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]) }) +NewHost = namedtuple('NewHost', ['host', 'name']) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Denon platform.""" + # pylint: disable=import-error import denonavr # Initialize list with receivers to be started @@ -55,28 +69,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if cache is None: cache = hass.data[KEY_DENON_CACHE] = set() - # Start assignment of host and name + # Get config option for show_all_sources show_all_sources = config.get(CONF_SHOW_ALL_SOURCES) + + # Get config option for additional zones + zones = config.get(CONF_ZONES) + if zones is not None: + add_zones = {} + for entry in zones: + add_zones[entry[CONF_ZONE]] = entry[CONF_NAME] + else: + add_zones = None + + # Start assignment of host and name + new_hosts = [] # 1. option: manual setting if config.get(CONF_HOST) is not None: host = config.get(CONF_HOST) name = config.get(CONF_NAME) - # Check if host not in cache, append it and save for later starting - if host not in cache: - cache.add(host) - receivers.append( - DenonDevice(denonavr.DenonAVR(host, name, show_all_sources))) - _LOGGER.info("Denon receiver at host %s initialized", host) + new_hosts.append(NewHost(host=host, name=name)) + # 2. option: discovery using netdisco if discovery_info is not None: host = discovery_info.get('host') name = discovery_info.get('name') - # Check if host not in cache, append it and save for later starting - if host not in cache: - cache.add(host) - receivers.append( - DenonDevice(denonavr.DenonAVR(host, name, show_all_sources))) - _LOGGER.info("Denon receiver at host %s initialized", host) + new_hosts.append(NewHost(host=host, name=name)) + # 3. option: discovery using denonavr library if config.get(CONF_HOST) is None and discovery_info is None: d_receivers = denonavr.discover() @@ -85,14 +103,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for d_receiver in d_receivers: host = d_receiver["host"] name = d_receiver["friendlyName"] - # Check if host not in cache, append it and save for later - # starting - if host not in cache: - cache.add(host) - receivers.append( - DenonDevice( - denonavr.DenonAVR(host, name, show_all_sources))) - _LOGGER.info("Denon receiver at host %s initialized", host) + new_hosts.append(NewHost(host=host, name=name)) + + for entry in new_hosts: + # Check if host not in cache, append it and save for later + # starting + if entry.host not in cache: + new_device = denonavr.DenonAVR( + entry.host, entry.name, show_all_sources, add_zones) + for new_zone in new_device.zones.values(): + receivers.append(DenonDevice(new_zone)) + cache.add(host) + _LOGGER.info("Denon receiver at host %s initialized", host) # Add all freshly discovered receivers if receivers: diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index 7fc18ef8fee..9f9e9e19dfe 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -21,7 +21,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pyemby==1.2'] +REQUIREMENTS = ['pyemby==1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index b7cc45b68f5..18860acb9a6 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -540,7 +540,7 @@ class KodiDevice(MediaPlayerDevice): elif self._turn_off_action == 'shutdown': yield from self.server.System.Shutdown() else: - _LOGGER.warning('turn_off requested but turn_off_action is none') + _LOGGER.warning("turn_off requested but turn_off_action is none") @cmd @asyncio.coroutine @@ -694,22 +694,26 @@ class KodiDevice(MediaPlayerDevice): def async_call_method(self, method, **kwargs): """Run Kodi JSONRPC API method with params.""" import jsonrpc_base - _LOGGER.debug('Run API method "%s", kwargs=%s', method, kwargs) + _LOGGER.debug("Run API method %s, kwargs=%s", method, kwargs) result_ok = False try: result = yield from getattr(self.server, method)(**kwargs) result_ok = True except jsonrpc_base.jsonrpc.ProtocolError as exc: result = exc.args[2]['error'] - _LOGGER.error('Run API method %s.%s(%s) error: %s', + _LOGGER.error("Run API method %s.%s(%s) error: %s", self.entity_id, method, kwargs, result) + except jsonrpc_base.jsonrpc.TransportError: + result = None + _LOGGER.warning("TransportError trying to run API method " + "%s.%s(%s)", self.entity_id, method, kwargs) if isinstance(result, dict): event_data = {'entity_id': self.entity_id, 'result': result, 'result_ok': result_ok, 'input': {'method': method, 'params': kwargs}} - _LOGGER.debug('EVENT kodi_call_method_result: %s', event_data) + _LOGGER.debug("EVENT kodi_call_method_result: %s", event_data) self.hass.bus.async_fire(EVENT_KODI_CALL_METHOD_RESULT, event_data=event_data) return result @@ -753,10 +757,13 @@ class KodiDevice(MediaPlayerDevice): yield from self.server.Playlist.Add(params) except jsonrpc_base.jsonrpc.ProtocolError as exc: result = exc.args[2]['error'] - _LOGGER.error('Run API method %s.Playlist.Add(%s) error: %s', + _LOGGER.error("Run API method %s.Playlist.Add(%s) error: %s", self.entity_id, media_type, result) + except jsonrpc_base.jsonrpc.TransportError: + _LOGGER.warning("TransportError trying to add playlist to %s", + self.entity_id) else: - _LOGGER.warning('No media detected for Playlist.Add') + _LOGGER.warning("No media detected for Playlist.Add") @asyncio.coroutine def async_add_all_albums(self, artist_name): @@ -800,7 +807,7 @@ class KodiDevice(MediaPlayerDevice): artist_name, [a['artist'] for a in artists['artists']]) return artists['artists'][out[0][0]]['artistid'] except KeyError: - _LOGGER.warning('No artists were found: %s', artist_name) + _LOGGER.warning("No artists were found: %s", artist_name) return None @asyncio.coroutine @@ -839,7 +846,7 @@ class KodiDevice(MediaPlayerDevice): album_name, [a['label'] for a in albums['albums']]) return albums['albums'][out[0][0]]['albumid'] except KeyError: - _LOGGER.warning('No albums were found with artist: %s, album: %s', + _LOGGER.warning("No albums were found with artist: %s, album: %s", artist_name, album_name) return None diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 572898dd60a..f4dad2d001b 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -14,7 +14,8 @@ from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, SUPPORT_PLAY, MEDIA_TYPE_PLAYLIST, - SUPPORT_SELECT_SOURCE, MediaPlayerDevice) + SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, SUPPORT_SHUFFLE_SET, + SUPPORT_SEEK, MediaPlayerDevice) from homeassistant.const import ( STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_PORT, CONF_PASSWORD, CONF_HOST, CONF_NAME) @@ -32,7 +33,8 @@ PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=120) SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_TURN_OFF | \ SUPPORT_TURN_ON | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ - SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE + SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE | \ + SUPPORT_CLEAR_PLAYLIST | SUPPORT_SHUFFLE_SET | SUPPORT_SEEK PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -266,3 +268,20 @@ class MpdDevice(MediaPlayerDevice): self.client.clear() self.client.add(media_id) self.client.play() + + @property + def shuffle(self): + """Boolean if shuffle is enabled.""" + return bool(self.status['random']) + + def set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + self.client.random(int(shuffle)) + + def clear_playlist(self): + """Clear players playlist.""" + self.client.clear() + + def media_seek(self, position): + """Send seek command.""" + self.client.seekcur(position) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index f4b0ca1c0d6..d54b8f2ca77 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -88,6 +88,8 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): elif discovery_info is not None: # Parse discovery data host = discovery_info.get('host') + port = discovery_info.get('port') + host = '%s:%s' % (host, port) _LOGGER.info("Discovered PLEX server: %s", host) if host in _CONFIGURING: @@ -106,6 +108,7 @@ def setup_plexserver(host, token, hass, config, add_devices_callback): try: plexserver = plexapi.server.PlexServer('http://%s' % host, token) + _LOGGER.info("Discovery configuration done (no token needed)") except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized, plexapi.exceptions.NotFound) as error: _LOGGER.info(error) diff --git a/homeassistant/components/media_player/soundtouch.py b/homeassistant/components/media_player/soundtouch.py index 6a118844727..3890e52bd73 100644 --- a/homeassistant/components/media_player/soundtouch.py +++ b/homeassistant/components/media_player/soundtouch.py @@ -20,7 +20,7 @@ from homeassistant.const import (CONF_HOST, CONF_NAME, STATE_OFF, CONF_PORT, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE) -REQUIREMENTS = ['libsoundtouch==0.3.0'] +REQUIREMENTS = ['libsoundtouch==0.6.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 7c5d1a4faab..6be47581a50 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -29,7 +29,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, CONF_PAYLOAD) from homeassistant.components.mqtt.server import HBMQTT_CONFIG_SCHEMA -REQUIREMENTS = ['paho-mqtt==1.2.3'] +REQUIREMENTS = ['paho-mqtt==1.3.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 3ead94dca14..f76c4e9d527 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -17,8 +17,8 @@ from homeassistant.components.mqtt import CONF_STATE_TOPIC _LOGGER = logging.getLogger(__name__) TOPIC_MATCHER = re.compile( - r'(?P\w+)/(?P\w+)/(?P[a-zA-Z0-9_-]+)' - '/config') + r'(?P\w+)/(?P\w+)/' + r'(?:(?P[a-zA-Z0-9_-]+)/)?(?P[a-zA-Z0-9_-]+)/config') SUPPORTED_COMPONENTS = ['binary_sensor', 'light', 'sensor', 'switch'] @@ -44,7 +44,7 @@ def async_start(hass, discovery_topic, hass_config): if not match: return - prefix_topic, component, object_id = match.groups() + prefix_topic, component, node_id, object_id = match.groups() try: payload = json.loads(payload) @@ -65,21 +65,25 @@ def async_start(hass, discovery_topic, hass_config): payload[CONF_PLATFORM] = platform if CONF_STATE_TOPIC not in payload: - payload[CONF_STATE_TOPIC] = '{}/{}/{}/state'.format( - discovery_topic, component, object_id) + payload[CONF_STATE_TOPIC] = '{}/{}/{}{}/state'.format( + discovery_topic, component, '%s/' % node_id if node_id else '', + object_id) if ALREADY_DISCOVERED not in hass.data: hass.data[ALREADY_DISCOVERED] = set() - discovery_hash = (component, object_id) + # If present, the node_id will be included in the discovered object id + discovery_id = '_'.join((node_id, object_id)) if node_id else object_id + + discovery_hash = (component, discovery_id) if discovery_hash in hass.data[ALREADY_DISCOVERED]: _LOGGER.info("Component has already been discovered: %s %s", - component, object_id) + component, discovery_id) return hass.data[ALREADY_DISCOVERED].add(discovery_hash) - _LOGGER.info("Found new component: %s %s", component, object_id) + _LOGGER.info("Found new component: %s %s", component, discovery_id) yield from async_load_platform( hass, component, platform, payload, hass_config) diff --git a/homeassistant/components/notify/clicksend.py b/homeassistant/components/notify/clicksend.py new file mode 100644 index 00000000000..663f689a975 --- /dev/null +++ b/homeassistant/components/notify/clicksend.py @@ -0,0 +1,80 @@ +""" +Clicksend platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.clicksend/ +""" +import json +import logging +import requests + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_USERNAME, CONF_API_KEY, CONF_RECIPIENT, HTTP_HEADER_CONTENT_TYPE, + CONTENT_TYPE_JSON) +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) + +BASE_API_URL = 'https://rest.clicksend.com/v3' + +HEADERS = {HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_RECIPIENT): cv.string, +}) + + +def get_service(hass, config, discovery_info=None): + """Get the ClickSend notification service.""" + if _authenticate(config) is False: + _LOGGER.exception("You are not authorized to access ClickSend") + return None + + return ClicksendNotificationService(config) + + +class ClicksendNotificationService(BaseNotificationService): + """Implementation of a notification service for the ClickSend service.""" + + def __init__(self, config): + """Initialize the service.""" + self.username = config.get(CONF_USERNAME) + 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 = ({'messages': [{'source': 'hass.notify', 'from': self.recipient, + 'to': self.recipient, 'body': message}]}) + + api_url = "{}/sms/send".format(BASE_API_URL) + + resp = requests.post(api_url, data=json.dumps(data), headers=HEADERS, + auth=(self.username, self.api_key), timeout=5) + + obj = json.loads(resp.text) + response_msg = obj['response_msg'] + response_code = obj['response_code'] + + if resp.status_code != 200: + _LOGGER.error("Error %s : %s (Code %s)", resp.status_code, + response_msg, response_code) + + +def _authenticate(config): + """Authenticate with ClickSend.""" + api_url = '{}/account'.format(BASE_API_URL) + resp = requests.get(api_url, headers=HEADERS, + auth=(config.get(CONF_USERNAME), + config.get(CONF_API_KEY)), timeout=5) + + if resp.status_code != 200: + return False + + return True diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 49b4b4417e9..1fcd2e03898 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -25,7 +25,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.frontend import add_manifest_json_key from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['pywebpush==1.0.4', 'PyJWT==1.5.0'] +REQUIREMENTS = ['pywebpush==1.0.5', 'PyJWT==1.5.0'] DEPENDENCIES = ['frontend'] diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py index 6489345d91c..aaf1a729f2d 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -29,16 +29,18 @@ _LOGGER = logging.getLogger(__name__) ATTR_IMAGES = 'images' # optional embedded image file attachments ATTR_HTML = 'html' -CONF_STARTTLS = 'starttls' +CONF_ENCRYPTION = 'encryption' CONF_DEBUG = 'debug' CONF_SERVER = 'server' CONF_SENDER_NAME = 'sender_name' DEFAULT_HOST = 'localhost' -DEFAULT_PORT = 25 +DEFAULT_PORT = 587 DEFAULT_TIMEOUT = 5 DEFAULT_DEBUG = False -DEFAULT_STARTTLS = False +DEFAULT_ENCRYPTION = 'starttls' + +ENCRYPTION_OPTIONS = ['tls', 'starttls', 'none'] # pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -47,7 +49,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SERVER, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_STARTTLS, default=DEFAULT_STARTTLS): cv.boolean, + vol.Optional(CONF_ENCRYPTION, default=DEFAULT_ENCRYPTION): + vol.In(ENCRYPTION_OPTIONS), vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_SENDER_NAME): cv.string, @@ -62,7 +65,7 @@ def get_service(hass, config, discovery_info=None): config.get(CONF_PORT), config.get(CONF_TIMEOUT), config.get(CONF_SENDER), - config.get(CONF_STARTTLS), + config.get(CONF_ENCRYPTION), config.get(CONF_USERNAME), config.get(CONF_PASSWORD), config.get(CONF_RECIPIENT), @@ -78,28 +81,32 @@ def get_service(hass, config, discovery_info=None): class MailNotificationService(BaseNotificationService): """Implement the notification service for E-mail messages.""" - def __init__(self, server, port, timeout, sender, starttls, username, + def __init__(self, server, port, timeout, sender, encryption, username, password, recipients, sender_name, debug): """Initialize the SMTP service.""" self._server = server self._port = port self._timeout = timeout self._sender = sender - self.starttls = starttls + self.encryption = encryption self.username = username self.password = password self.recipients = recipients self._sender_name = sender_name - self._timeout = timeout self.debug = debug self.tries = 2 def connect(self): """Connect/authenticate to SMTP Server.""" - mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout) + if self.encryption == "tls": + mail = smtplib.SMTP_SSL( + self._server, self._port, timeout=self._timeout) + else: + mail = smtplib.SMTP( + self._server, self._port, timeout=self._timeout) mail.set_debuglevel(self.debug) mail.ehlo_or_helo_if_needed() - if self.starttls: + if self.encryption == "starttls": mail.starttls() mail.ehlo() if self.username and self.password: diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index 2a82689bcd1..cfb7098bbdf 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -9,7 +9,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import sanitize_filename DOMAIN = 'python_script' -REQUIREMENTS = ['restrictedpython==4.0a2'] +REQUIREMENTS = ['restrictedpython==4.0a3'] FOLDER = 'python_scripts' _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 54ee81091c8..49af353aab8 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -33,7 +33,7 @@ from . import purge, migration from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.1.10'] +REQUIREMENTS = ['sqlalchemy==1.1.11'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0fac32bdec7..043887b7cab 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -64,6 +64,8 @@ def _apply_update(engine, new_version): # Create indexes for states _create_index(engine, "states", "ix_states_last_updated") _create_index(engine, "states", "ix_states_entity_id_created") + elif new_version == 3: + _create_index(engine, "states", "ix_states_created_domain") else: raise ValueError("No schema migration defined for version {}" .format(new_version)) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 6dcb5dbd051..65c7dc6abb6 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -16,7 +16,7 @@ from homeassistant.remote import JSONEncoder # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 _LOGGER = logging.getLogger(__name__) @@ -75,7 +75,9 @@ class States(Base): # type: ignore Index('states__significant_changes', 'domain', 'last_updated', 'entity_id'), Index('ix_states_entity_id_created', - 'entity_id', 'created'),) + 'entity_id', 'created'), + Index('ix_states_created_domain', + 'created', 'domain'),) @staticmethod def from_event(event): diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index f0155cc4525..9c6bad77b31 100755 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -26,12 +26,13 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_PORT = 5222 DEVICES = [] +CONF_DEVICE_CACHE = 'device_cache' SERVICE_SYNC = 'harmony_sync' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Required(ATTR_ACTIVITY, default=None): cv.string, }) @@ -44,29 +45,65 @@ HARMONY_SYNC_SCHEMA = vol.Schema({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Harmony platform.""" import pyharmony - global DEVICES - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - _LOGGER.debug("Loading Harmony platform: %s", name) + host = None + activity = None - harmony_conf_file = hass.config.path( - '{}{}{}'.format('harmony_', slugify(name), '.conf')) + if CONF_DEVICE_CACHE not in hass.data: + hass.data[CONF_DEVICE_CACHE] = [] + if discovery_info: + # Find the discovered device in the list of user configurations + override = next((c for c in hass.data[CONF_DEVICE_CACHE] + if c.get(CONF_NAME) == discovery_info.get(CONF_NAME)), + False) + + port = DEFAULT_PORT + if override: + activity = override.get(ATTR_ACTIVITY) + port = override.get(CONF_PORT, DEFAULT_PORT) + + host = ( + discovery_info.get(CONF_NAME), + discovery_info.get(CONF_HOST), + port) + + # Ignore hub name when checking if this hub is known - ip and port only + if host and host[1:] in set([h[1:] for h in DEVICES]): + _LOGGER.debug("Discovered host already known: %s", host) + return + elif CONF_HOST in config: + host = ( + config.get(CONF_NAME), + config.get(CONF_HOST), + config.get(CONF_PORT), + ) + activity = config.get(ATTR_ACTIVITY) + else: + hass.data[CONF_DEVICE_CACHE].append(config) + return + + name, address, port = host + _LOGGER.info("Loading Harmony Platform: %s at %s:%s, startup activity: %s", + name, address, port, activity) try: _LOGGER.debug("Calling pyharmony.ha_get_token for remote at: %s:%s", - host, port) - token = urllib.parse.quote_plus(pyharmony.ha_get_token(host, port)) + address, port) + token = urllib.parse.quote_plus(pyharmony.ha_get_token(address, port)) + _LOGGER.debug("Received token: %s", token) except ValueError as err: _LOGGER.warning("%s for remote: %s", err.args[0], name) return False - _LOGGER.debug("Received token: %s", token) - DEVICES = [HarmonyRemote( - config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT), - config.get(ATTR_ACTIVITY), harmony_conf_file, token)] - add_devices(DEVICES, True) + harmony_conf_file = hass.config.path( + '{}{}{}'.format('harmony_', slugify(name), '.conf')) + device = HarmonyRemote( + name, address, port, + activity, harmony_conf_file, token) + + DEVICES.append(device) + + add_devices([device]) register_services(hass) return True diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index 3c3f1e00f68..fd34eddf916 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -10,9 +10,12 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, + ATTR_ENTITY_ID, TEMP_CELSIUS, + CONF_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF +) from homeassistant.helpers.entity import Entity -from homeassistant.const import (ATTR_ENTITY_ID, TEMP_CELSIUS) REQUIREMENTS = ['pyRFXtrx==0.18.0'] @@ -27,7 +30,9 @@ ATTR_STATE = 'state' ATTR_NAME = 'name' ATTR_FIREEVENT = 'fire_event' ATTR_DATA_TYPE = 'data_type' +ATTR_DATA_BITS = 'data_bits' ATTR_DUMMY = 'dummy' +ATTR_OFF_DELAY = 'off_delay' CONF_SIGNAL_REPETITIONS = 'signal_repetitions' CONF_DEVICES = 'devices' EVENT_BUTTON_PRESSED = 'button_pressed' @@ -43,7 +48,8 @@ DATA_TYPES = OrderedDict([ ('Total usage', 'W'), ('Sound', ''), ('Sensor Status', ''), - ('Counter value', '')]) + ('Counter value', ''), + ('UV', 'uv')]) RECEIVED_EVT_SUBSCRIBERS = [] RFX_DEVICES = {} @@ -77,6 +83,8 @@ def _valid_device(value, device_type): if device_type == 'sensor': config[key] = DEVICE_SCHEMA_SENSOR(device) + elif device_type == 'binary_sensor': + config[key] = DEVICE_SCHEMA_BINARYSENSOR(device) elif device_type == 'light_switch': config[key] = DEVICE_SCHEMA(device) else: @@ -92,6 +100,11 @@ def valid_sensor(value): return _valid_device(value, "sensor") +def valid_binary_sensor(value): + """Validate binary sensor configuration.""" + return _valid_device(value, "binary_sensor") + + def _valid_light_switch(value): return _valid_device(value, "light_switch") @@ -108,6 +121,17 @@ DEVICE_SCHEMA_SENSOR = vol.Schema({ vol.All(cv.ensure_list, [vol.In(DATA_TYPES.keys())]), }) +DEVICE_SCHEMA_BINARYSENSOR = vol.Schema({ + vol.Optional(ATTR_NAME, default=None): cv.string, + vol.Optional(CONF_DEVICE_CLASS, default=None): cv.string, + vol.Optional(ATTR_FIREEVENT, default=False): cv.boolean, + vol.Optional(ATTR_OFF_DELAY, default=None): + vol.Any(cv.time_period, cv.positive_timedelta), + vol.Optional(ATTR_DATA_BITS, default=None): cv.positive_int, + vol.Optional(CONF_COMMAND_ON, default=None): cv.byte, + vol.Optional(CONF_COMMAND_OFF, default=None): cv.byte +}) + DEFAULT_SCHEMA = vol.Schema({ vol.Required("platform"): DOMAIN, vol.Optional(CONF_DEVICES, default={}): vol.All(dict, _valid_light_switch), @@ -191,6 +215,78 @@ def get_rfx_object(packetid): return obj +def get_pt2262_deviceid(device_id, nb_data_bits): + """Extract and return the address bits from a Lighting4/PT2262 packet.""" + import binascii + try: + data = bytearray.fromhex(device_id) + except ValueError: + return None + + mask = 0xFF & ~((1 << nb_data_bits) - 1) + + data[len(data)-1] &= mask + + return binascii.hexlify(data) + + +def get_pt2262_cmd(device_id, data_bits): + """Extract and return the data bits from a Lighting4/PT2262 packet.""" + try: + data = bytearray.fromhex(device_id) + except ValueError: + return None + + mask = 0xFF & ((1 << data_bits) - 1) + + return hex(data[-1] & mask) + + +# pylint: disable=unused-variable +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.is_pt2262 and + 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 + return None + + +# pylint: disable=unused-variable +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): + size = None + for i in range(0, len(dev_id)): + if dev_id[i] != device_id[i]: + break + size = i + + if size is not None: + size = len(dev_id) - size - 1 + _LOGGER.info("rfxtrx: found possible device %s for %s " + "with the following configuration:\n" + "data_bits=%d\n" + "command_on=0x%s\n" + "command_off=0x%s\n", + device_id, + dev_id, + size * 4, + dev_id[-size:], device_id[-size:]) + return device + + return None + + def get_devices_from_config(config, device, hass): """Read rfxtrx configuration.""" signal_repetitions = config[CONF_SIGNAL_REPETITIONS] @@ -318,6 +414,11 @@ class RfxtrxDevice(Entity): """Return is the device must fire event.""" return self._should_fire_event + @property + def is_pt2262(self): + """Return true if the device is PT2262-based.""" + return False + @property def is_on(self): """Return true if device is on.""" diff --git a/homeassistant/components/sensor/arest.py b/homeassistant/components/sensor/arest.py index 6edef785280..b7e224a7447 100644 --- a/homeassistant/components/sensor/arest.py +++ b/homeassistant/components/sensor/arest.py @@ -102,7 +102,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): pin=pinnum, unit_of_measurement=pin.get( CONF_UNIT_OF_MEASUREMENT), renderer=renderer)) - add_devices(dev) + add_devices(dev, True) class ArestSensor(Entity): @@ -119,7 +119,6 @@ class ArestSensor(Entity): self._state = STATE_UNKNOWN self._unit_of_measurement = unit_of_measurement self._renderer = renderer - self.update() if self._pin is not None: request = requests.get( diff --git a/homeassistant/components/sensor/bh1750.py b/homeassistant/components/sensor/bh1750.py new file mode 100644 index 00000000000..b98ba85f8c0 --- /dev/null +++ b/homeassistant/components/sensor/bh1750.py @@ -0,0 +1,145 @@ +""" +Support for BH1750 light sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.bh1750/ +""" +import asyncio +from functools import partial +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['i2csense==0.0.4', + 'smbus-cffi==0.5.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_I2C_ADDRESS = 'i2c_address' +CONF_I2C_BUS = 'i2c_bus' +CONF_OPERATION_MODE = 'operation_mode' +CONF_SENSITIVITY = 'sensitivity' +CONF_DELAY = 'measurement_delay_ms' +CONF_MULTIPLIER = 'multiplier' + +# Operation modes for BH1750 sensor (from the datasheet). Time typically 120ms +# In one time measurements, device is set to Power Down after each sample. +CONTINUOUS_LOW_RES_MODE = "continuous_low_res_mode" +CONTINUOUS_HIGH_RES_MODE_1 = "continuous_high_res_mode_1" +CONTINUOUS_HIGH_RES_MODE_2 = "continuous_high_res_mode_2" +ONE_TIME_LOW_RES_MODE = "one_time_low_res_mode" +ONE_TIME_HIGH_RES_MODE_1 = "one_time_high_res_mode_1" +ONE_TIME_HIGH_RES_MODE_2 = "one_time_high_res_mode_2" +OPERATION_MODES = { + CONTINUOUS_LOW_RES_MODE: (0x13, True), # 4lx resolution + CONTINUOUS_HIGH_RES_MODE_1: (0x10, True), # 1lx resolution. + CONTINUOUS_HIGH_RES_MODE_2: (0X11, True), # 0.5lx resolution. + ONE_TIME_LOW_RES_MODE: (0x23, False), # 4lx resolution. + ONE_TIME_HIGH_RES_MODE_1: (0x20, False), # 1lx resolution. + ONE_TIME_HIGH_RES_MODE_2: (0x21, False), # 0.5lx resolution. +} + +SENSOR_UNIT = 'lx' +DEFAULT_NAME = 'BH1750 Light Sensor' +DEFAULT_I2C_ADDRESS = '0x23' +DEFAULT_I2C_BUS = 1 +DEFAULT_MODE = CONTINUOUS_HIGH_RES_MODE_1 +DEFAULT_DELAY_MS = 120 +DEFAULT_SENSITIVITY = 69 # from 31 to 254 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.string, + vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce(int), + vol.Optional(CONF_OPERATION_MODE, default=DEFAULT_MODE): + vol.In(OPERATION_MODES), + vol.Optional(CONF_SENSITIVITY, default=DEFAULT_SENSITIVITY): + cv.positive_int, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY_MS): cv.positive_int, + vol.Optional(CONF_MULTIPLIER, default=1.): vol.Range(min=0.1, max=10), +}) + + +# pylint: disable=import-error +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the BH1750 sensor.""" + import smbus + from i2csense.bh1750 import BH1750 + + name = config.get(CONF_NAME) + bus_number = config.get(CONF_I2C_BUS) + i2c_address = config.get(CONF_I2C_ADDRESS) + operation_mode = config.get(CONF_OPERATION_MODE) + + bus = smbus.SMBus(bus_number) + + sensor = yield from hass.async_add_job( + partial(BH1750, bus, i2c_address, + operation_mode=operation_mode, + measurement_delay=config.get(CONF_DELAY), + sensitivity=config.get(CONF_SENSITIVITY), + logger=_LOGGER) + ) + if not sensor.sample_ok: + _LOGGER.error("BH1750 sensor not detected at %s", i2c_address) + return False + + dev = [BH1750Sensor(sensor, name, SENSOR_UNIT, + config.get(CONF_MULTIPLIER))] + _LOGGER.info("Setup of BH1750 light sensor at %s in mode %s is complete.", + i2c_address, operation_mode) + + async_add_devices(dev) + + +class BH1750Sensor(Entity): + """Implementation of the BH1750 sensor.""" + + def __init__(self, bh1750_sensor, name, unit, multiplier=1.): + """Initialize the sensor.""" + self._name = name + self._unit_of_measurement = unit + self._multiplier = multiplier + self.bh1750_sensor = bh1750_sensor + if self.bh1750_sensor.light_level >= 0: + self._state = int(round(self.bh1750_sensor.light_level)) + else: + self._state = None + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self) -> int: + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return self._unit_of_measurement + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'light' + + @asyncio.coroutine + def async_update(self): + """Get the latest data from the BH1750 and update the states.""" + yield from self.hass.async_add_job(self.bh1750_sensor.update) + if self.bh1750_sensor.sample_ok \ + and self.bh1750_sensor.light_level >= 0: + self._state = int(round(self.bh1750_sensor.light_level + * self._multiplier)) + else: + _LOGGER.warning("Bad Update of sensor.%s: %s", + self.name, self.bh1750_sensor.light_level) diff --git a/homeassistant/components/sensor/bme280.py b/homeassistant/components/sensor/bme280.py new file mode 100644 index 00000000000..8f3949046ca --- /dev/null +++ b/homeassistant/components/sensor/bme280.py @@ -0,0 +1,180 @@ +""" +Support for BME280 temperature, humidity and pressure sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.bme280/ +""" +import asyncio +from datetime import timedelta +from functools import partial +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + TEMP_FAHRENHEIT, CONF_NAME, CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.util.temperature import celsius_to_fahrenheit + +REQUIREMENTS = ['i2csense==0.0.4', + 'smbus-cffi==0.5.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_I2C_ADDRESS = 'i2c_address' +CONF_I2C_BUS = 'i2c_bus' +CONF_OVERSAMPLING_TEMP = 'oversampling_temperature' +CONF_OVERSAMPLING_PRES = 'oversampling_pressure' +CONF_OVERSAMPLING_HUM = 'oversampling_humidity' +CONF_OPERATION_MODE = 'operation_mode' +CONF_T_STANDBY = 'time_standby' +CONF_FILTER_MODE = 'filter_mode' +CONF_DELTA_TEMP = 'delta_temperature' + +DEFAULT_NAME = 'BME280 Sensor' +DEFAULT_I2C_ADDRESS = '0x76' +DEFAULT_I2C_BUS = 1 +DEFAULT_OVERSAMPLING_TEMP = 1 # Temperature oversampling x 1 +DEFAULT_OVERSAMPLING_PRES = 1 # Pressure oversampling x 1 +DEFAULT_OVERSAMPLING_HUM = 1 # Humidity oversampling x 1 +DEFAULT_OPERATION_MODE = 3 # Normal mode (forced mode: 2) +DEFAULT_T_STANDBY = 5 # Tstandby 5ms +DEFAULT_FILTER_MODE = 0 # Filter off +DEFAULT_DELTA_TEMP = 0. + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3) + +SENSOR_TEMP = 'temperature' +SENSOR_HUMID = 'humidity' +SENSOR_PRESS = 'pressure' +SENSOR_TYPES = { + SENSOR_TEMP: ['Temperature', None], + SENSOR_HUMID: ['Humidity', '%'], + SENSOR_PRESS: ['Pressure', 'mb'] +} +DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce(int), + vol.Optional(CONF_OVERSAMPLING_TEMP, + default=DEFAULT_OVERSAMPLING_TEMP): vol.Coerce(int), + vol.Optional(CONF_OVERSAMPLING_PRES, + default=DEFAULT_OVERSAMPLING_PRES): vol.Coerce(int), + vol.Optional(CONF_OVERSAMPLING_HUM, + default=DEFAULT_OVERSAMPLING_HUM): vol.Coerce(int), + vol.Optional(CONF_OPERATION_MODE, + default=DEFAULT_OPERATION_MODE): vol.Coerce(int), + vol.Optional(CONF_T_STANDBY, + default=DEFAULT_T_STANDBY): vol.Coerce(int), + vol.Optional(CONF_FILTER_MODE, + default=DEFAULT_FILTER_MODE): vol.Coerce(int), + vol.Optional(CONF_DELTA_TEMP, + default=DEFAULT_DELTA_TEMP): vol.Coerce(float), +}) + + +# pylint: disable=import-error +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the BME280 sensor.""" + import smbus + from i2csense.bme280 import BME280 + + SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit + name = config.get(CONF_NAME) + i2c_address = config.get(CONF_I2C_ADDRESS) + + bus = smbus.SMBus(config.get(CONF_I2C_BUS)) + sensor = yield from hass.async_add_job( + partial(BME280, bus, i2c_address, + osrs_t=config.get(CONF_OVERSAMPLING_TEMP), + osrs_p=config.get(CONF_OVERSAMPLING_PRES), + osrs_h=config.get(CONF_OVERSAMPLING_HUM), + mode=config.get(CONF_OPERATION_MODE), + t_sb=config.get(CONF_T_STANDBY), + filter_mode=config.get(CONF_FILTER_MODE), + delta_temp=config.get(CONF_DELTA_TEMP), + logger=_LOGGER) + ) + if not sensor.sample_ok: + _LOGGER.error("BME280 sensor not detected at %s", i2c_address) + return False + + sensor_handler = yield from hass.async_add_job(BME280Handler, sensor) + + dev = [] + try: + for variable in config[CONF_MONITORED_CONDITIONS]: + dev.append(BME280Sensor( + sensor_handler, variable, SENSOR_TYPES[variable][1], name)) + except KeyError: + pass + + async_add_devices(dev) + + +class BME280Handler: + """BME280 sensor working in i2C bus.""" + + def __init__(self, sensor): + """Initialize the sensor handler.""" + self.sensor = sensor + self.update(True) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, first_reading=False): + """Read sensor data.""" + self.sensor.update(first_reading) + + +class BME280Sensor(Entity): + """Implementation of the BME280 sensor.""" + + def __init__(self, bme280_client, sensor_type, temp_unit, name): + """Initialize the sensor.""" + self.client_name = name + self._name = SENSOR_TYPES[sensor_type][0] + self.bme280_client = bme280_client + self.temp_unit = temp_unit + self.type = sensor_type + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @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 the sensor.""" + return self._unit_of_measurement + + @asyncio.coroutine + def async_update(self): + """Get the latest data from the BME280 and update the states.""" + yield from self.hass.async_add_job(self.bme280_client.update) + if self.bme280_client.sensor.sample_ok: + if self.type == SENSOR_TEMP: + temperature = round(self.bme280_client.sensor.temperature, 1) + if self.temp_unit == TEMP_FAHRENHEIT: + temperature = round(celsius_to_fahrenheit(temperature), 1) + self._state = temperature + elif self.type == SENSOR_HUMID: + self._state = round(self.bme280_client.sensor.humidity, 1) + elif self.type == SENSOR_PRESS: + self._state = round(self.bme280_client.sensor.pressure, 1) + else: + _LOGGER.warning("Bad update of sensor.%s", self.name) diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py index 4974e7c45ce..2d1e716fd1b 100755 --- a/homeassistant/components/sensor/buienradar.py +++ b/homeassistant/components/sensor/buienradar.py @@ -23,7 +23,7 @@ from homeassistant.helpers.event import ( async_track_point_in_utc_time) from homeassistant.util import dt as dt_util -REQUIREMENTS = ['buienradar==0.4'] +REQUIREMENTS = ['buienradar==0.6'] _LOGGER = logging.getLogger(__name__) @@ -37,30 +37,42 @@ SENSOR_TYPES = { 'mdi:thermometer'], 'windspeed': ['Wind speed', 'm/s', 'mdi:weather-windy'], 'windforce': ['Wind force', 'Bft', 'mdi:weather-windy'], - 'winddirection': ['Wind direction', '°', 'mdi:compass-outline'], - 'windazimuth': ['Wind direction azimuth', None, 'mdi:compass-outline'], + 'winddirection': ['Wind direction', None, 'mdi:compass-outline'], + 'windazimuth': ['Wind direction azimuth', '°', 'mdi:compass-outline'], 'pressure': ['Pressure', 'hPa', 'mdi:gauge'], 'visibility': ['Visibility', 'm', None], 'windgust': ['Wind gust', 'm/s', 'mdi:weather-windy'], 'precipitation': ['Precipitation', 'mm/h', 'mdi:weather-pouring'], 'irradiance': ['Irradiance', 'W/m2', 'mdi:sunglasses'], + 'precipitation_forecast_average': ['Precipitation forecast average', + 'mm/h', 'mdi:weather-pouring'], + 'precipitation_forecast_total': ['Precipitation forecast total', + 'mm/h', 'mdi:weather-pouring'] } +CONF_TIMEFRAME = 'timeframe' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MONITORED_CONDITIONS, default=['symbol', 'temperature']): vol.All( cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES.keys())]), - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Inclusive(CONF_LATITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.longitude, + vol.Optional(CONF_TIMEFRAME): cv.positive_int }) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup the buienradar sensor.""" + from homeassistant.components.weather.buienradar import DEFAULT_TIMEFRAME + latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + timeframe = config.get(CONF_TIMEFRAME, DEFAULT_TIMEFRAME) if None in (latitude, longitude): _LOGGER.error("Latitude or longitude not set in HomeAssistant config") @@ -74,7 +86,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): dev.append(BrSensor(sensor_type, config.get(CONF_NAME, 'br'))) async_add_devices(dev) - data = BrData(hass, coordinates, dev) + data = BrData(hass, coordinates, timeframe, dev) # schedule the first update in 1 minute from now: _LOGGER.debug("Start running....") yield from data.schedule_update(1) @@ -97,8 +109,8 @@ class BrSensor(Entity): def load_data(self, data): """Load the sensor with relevant data.""" # Find sensor - from buienradar.buienradar import (ATTRIBUTION, IMAGE, - STATIONNAME, SYMBOL) + from buienradar.buienradar import (ATTRIBUTION, IMAGE, STATIONNAME, + SYMBOL, PRECIPITATION_FORECAST) self._attribution = data.get(ATTRIBUTION) self._stationname = data.get(STATIONNAME) @@ -112,6 +124,14 @@ class BrSensor(Entity): self._state = new_state self._entity_picture = img return True + elif self.type.startswith(PRECIPITATION_FORECAST): + # update nested precipitation forecast sensors + nested = data.get(PRECIPITATION_FORECAST) + new_state = nested.get(self.type[len(PRECIPITATION_FORECAST)+1:]) + # pylint: disable=protected-access + if new_state != self._state: + self._state = new_state + return True else: # update all other sensors new_state = data.get(self.type) @@ -172,12 +192,13 @@ class BrSensor(Entity): class BrData(object): """Get the latest data and updates the states.""" - def __init__(self, hass, coordinates, devices): + def __init__(self, hass, coordinates, timeframe, devices): """Initialize the data object.""" self.devices = devices self.data = {} self.hass = hass self.coordinates = coordinates + self.timeframe = timeframe @asyncio.coroutine def update_devices(self): @@ -222,9 +243,6 @@ class BrData(object): except (asyncio.TimeoutError, aiohttp.ClientError) as err: result[MESSAGE] = "%s" % err return result - finally: - if resp is not None: - yield from resp.release() @asyncio.coroutine def async_update(self, *_): @@ -232,14 +250,24 @@ class BrData(object): from buienradar.buienradar import (parse_data, CONTENT, DATA, MESSAGE, STATUS_CODE, SUCCESS) - result = yield from self.get_data('http://xml.buienradar.nl') - if result.get(SUCCESS, False) is False: - result = yield from self.get_data('http://api.buienradar.nl') + content = yield from self.get_data('http://xml.buienradar.nl') + if not content.get(SUCCESS, False): + content = yield from self.get_data('http://api.buienradar.nl') - if result.get(SUCCESS): - result = parse_data(result.get(CONTENT), - latitude=self.coordinates[CONF_LATITUDE], - longitude=self.coordinates[CONF_LONGITUDE]) + # rounding coordinates prevents unnecessary redirects/calls + rainurl = 'http://gadgets.buienradar.nl/data/raintext/?lat={}&lon={}' + rainurl = rainurl.format( + round(self.coordinates[CONF_LATITUDE], 2), + round(self.coordinates[CONF_LONGITUDE], 2) + ) + raincontent = yield from self.get_data(rainurl) + + if content.get(SUCCESS) and raincontent.get(SUCCESS): + result = parse_data(content.get(CONTENT), + raincontent.get(CONTENT), + self.coordinates[CONF_LATITUDE], + self.coordinates[CONF_LONGITUDE], + self.timeframe) if result.get(SUCCESS): self.data = result.get(DATA) diff --git a/homeassistant/components/sensor/comfoconnect.py b/homeassistant/components/sensor/comfoconnect.py new file mode 100644 index 00000000000..c953ee53260 --- /dev/null +++ b/homeassistant/components/sensor/comfoconnect.py @@ -0,0 +1,141 @@ +""" +Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.comfoconnect/ +""" +import logging + +from homeassistant.components.comfoconnect import ( + DOMAIN, ComfoConnectBridge, ATTR_CURRENT_TEMPERATURE, + ATTR_CURRENT_HUMIDITY, ATTR_OUTSIDE_TEMPERATURE, + ATTR_OUTSIDE_HUMIDITY, ATTR_AIR_FLOW_SUPPLY, + ATTR_AIR_FLOW_EXHAUST, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED) +from homeassistant.const import ( + CONF_RESOURCES, TEMP_CELSIUS, STATE_UNKNOWN) +from homeassistant.helpers.dispatcher import dispatcher_connect +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['comfoconnect'] + +SENSOR_TYPES = {} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the ComfoConnect fan platform.""" + from pycomfoconnect import ( + SENSOR_TEMPERATURE_EXTRACT, SENSOR_HUMIDITY_EXTRACT, + SENSOR_TEMPERATURE_OUTDOOR, SENSOR_HUMIDITY_OUTDOOR, + SENSOR_FAN_SUPPLY_FLOW, SENSOR_FAN_EXHAUST_FLOW) + + global SENSOR_TYPES + SENSOR_TYPES = { + ATTR_CURRENT_TEMPERATURE: [ + 'Inside Temperature', + TEMP_CELSIUS, + 'mdi:thermometer', + SENSOR_TEMPERATURE_EXTRACT + ], + ATTR_CURRENT_HUMIDITY: [ + 'Inside Humidity', + '%', + 'mdi:water-percent', + SENSOR_HUMIDITY_EXTRACT + ], + ATTR_OUTSIDE_TEMPERATURE: [ + 'Outside Temperature', + TEMP_CELSIUS, + 'mdi:thermometer', + SENSOR_TEMPERATURE_OUTDOOR + ], + ATTR_OUTSIDE_HUMIDITY: [ + 'Outside Humidity', + '%', + 'mdi:water-percent', + SENSOR_HUMIDITY_OUTDOOR + ], + ATTR_AIR_FLOW_SUPPLY: [ + 'Supply airflow', + 'm³/h', + 'mdi:air-conditioner', + SENSOR_FAN_SUPPLY_FLOW + ], + ATTR_AIR_FLOW_EXHAUST: [ + 'Exhaust airflow', + 'm³/h', + 'mdi:air-conditioner', + SENSOR_FAN_EXHAUST_FLOW + ], + } + + ccb = hass.data[DOMAIN] + + sensors = [] + for resource in config[CONF_RESOURCES]: + sensor_type = resource.lower() + + if sensor_type not in SENSOR_TYPES: + _LOGGER.warning("Sensor type: %s is not a valid sensor.", + sensor_type) + continue + + sensors.append( + ComfoConnectSensor( + hass, + name="%s %s" % (ccb.name, SENSOR_TYPES[sensor_type][0]), + ccb=ccb, + sensor_type=sensor_type + ) + ) + + add_devices(sensors, True) + + return + + +class ComfoConnectSensor(Entity): + """Representation of a ComfoConnect sensor.""" + + def __init__(self, hass, name, ccb: ComfoConnectBridge, sensor_type): + """Initialize the ComfoConnect sensor.""" + self._ccb = ccb + self._sensor_type = sensor_type + self._sensor_id = SENSOR_TYPES[self._sensor_type][3] + self._name = name + + # Register the requested sensor + self._ccb.comfoconnect.register_sensor(self._sensor_id) + + def _handle_update(var): + if var == self._sensor_id: + _LOGGER.debug('Dispatcher update for %s.', var) + self.schedule_update_ha_state() + + # Register for dispatcher updates + dispatcher_connect( + hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, _handle_update) + + @property + def state(self): + """Return the state of the entity.""" + try: + return self._ccb.data[self._sensor_id] + except KeyError: + return STATE_UNKNOWN + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return SENSOR_TYPES[self._sensor_type][2] + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return SENSOR_TYPES[self._sensor_type][1] diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py index ef9ea3138bd..09738454bcb 100644 --- a/homeassistant/components/sensor/glances.py +++ b/homeassistant/components/sensor/glances.py @@ -13,7 +13,8 @@ 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) + CONF_HOST, CONF_PORT, STATE_UNKNOWN, CONF_NAME, CONF_RESOURCES, + TEMP_CELSIUS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -24,7 +25,7 @@ DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Glances' DEFAULT_PORT = '61208' -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) SENSOR_TYPES = { 'disk_use_percent': ['Disk Use', '%'], @@ -40,7 +41,8 @@ SENSOR_TYPES = { 'process_running': ['Running', 'Count'], 'process_total': ['Total', 'Count'], 'process_thread': ['Thread', 'Count'], - 'process_sleeping': ['Sleeping', 'Count'] + 'process_sleeping': ['Sleeping', 'Count'], + 'cpu_temp': ['CPU Temp', TEMP_CELSIUS], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -61,22 +63,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): url = 'http://{}:{}/{}'.format(host, port, _RESOURCE) var_conf = config.get(CONF_RESOURCES) - try: - response = requests.get(url, timeout=10) - if not response.ok: - _LOGGER.error("Response status is '%s'", response.status_code) - return False - except requests.exceptions.ConnectionError: - _LOGGER.error("No route to resource/endpoint: %s", url) - return False - rest = GlancesData(url) + rest.update() dev = [] for resource in var_conf: dev.append(GlancesSensor(rest, name, resource)) - add_devices(dev) + add_devices(dev, True) class GlancesSensor(Entity): @@ -89,7 +83,6 @@ class GlancesSensor(Entity): self.type = sensor_type self._state = STATE_UNKNOWN self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self.update() @property def name(self): @@ -104,52 +97,63 @@ class GlancesSensor(Entity): """Return the unit the value is expressed in.""" return self._unit_of_measurement + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self.rest.data is not None + @property def state(self): """Return the state of the resources.""" - value = self.rest.data - - if value is not None: - if self.type == 'disk_use_percent': - return value['fs'][0]['percent'] - elif self.type == 'disk_use': - return round(value['fs'][0]['used'] / 1024**3, 1) - elif self.type == 'disk_free': - try: - return round(value['fs'][0]['free'] / 1024**3, 1) - except KeyError: - return round((value['fs'][0]['size'] - - value['fs'][0]['used']) / 1024**3, 1) - elif self.type == 'memory_use_percent': - return value['mem']['percent'] - elif self.type == 'memory_use': - return round(value['mem']['used'] / 1024**2, 1) - elif self.type == 'memory_free': - return round(value['mem']['free'] / 1024**2, 1) - elif self.type == 'swap_use_percent': - return value['memswap']['percent'] - elif self.type == 'swap_use': - return round(value['memswap']['used'] / 1024**3, 1) - elif self.type == 'swap_free': - return round(value['memswap']['free'] / 1024**3, 1) - elif self.type == 'processor_load': - # Windows systems don't provide load details - try: - return value['load']['min15'] - except KeyError: - return value['cpu']['total'] - elif self.type == 'process_running': - return value['processcount']['running'] - elif self.type == 'process_total': - return value['processcount']['total'] - elif self.type == 'process_thread': - return value['processcount']['thread'] - elif self.type == 'process_sleeping': - return value['processcount']['sleeping'] + return self._state def update(self): """Get the latest data from REST API.""" self.rest.update() + value = self.rest.data + + if value is not None: + if self.type == 'disk_use_percent': + self._state = value['fs'][0]['percent'] + elif self.type == 'disk_use': + self._state = round(value['fs'][0]['used'] / 1024**3, 1) + elif self.type == 'disk_free': + try: + self._state = round(value['fs'][0]['free'] / 1024**3, 1) + except KeyError: + self._state = round((value['fs'][0]['size'] - + value['fs'][0]['used']) / 1024**3, 1) + elif self.type == 'memory_use_percent': + self._state = value['mem']['percent'] + elif self.type == 'memory_use': + self._state = round(value['mem']['used'] / 1024**2, 1) + elif self.type == 'memory_free': + self._state = round(value['mem']['free'] / 1024**2, 1) + elif self.type == 'swap_use_percent': + self._state = value['memswap']['percent'] + elif self.type == 'swap_use': + self._state = round(value['memswap']['used'] / 1024**3, 1) + elif self.type == 'swap_free': + self._state = round(value['memswap']['free'] / 1024**3, 1) + elif self.type == 'processor_load': + # Windows systems don't provide load details + try: + self._state = value['load']['min15'] + except KeyError: + self._state = value['cpu']['total'] + elif self.type == 'process_running': + self._state = value['processcount']['running'] + elif self.type == 'process_total': + self._state = value['processcount']['total'] + elif self.type == 'process_thread': + self._state = value['processcount']['thread'] + elif self.type == 'process_sleeping': + self._state = value['processcount']['sleeping'] + elif self.type == 'cpu_temp': + for sensor in value['sensors']: + if sensor['label'] == 'CPU': + self._state = sensor['value'] + self._state = None class GlancesData(object): @@ -167,5 +171,5 @@ class GlancesData(object): response = requests.get(self._resource, timeout=10) self.data = response.json() except requests.exceptions.ConnectionError: - _LOGGER.error("No route to host/endpoint: %s", self._resource) + _LOGGER.error("Connection error: %s", self._resource) self.data = None diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index 961b8067009..c5313a7c215 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -25,6 +25,7 @@ _LOGGER = logging.getLogger(__name__) CONF_DATA = 'data' CONF_DESTINATION = 'destination' CONF_ORIGIN = 'origin' +CONF_OFFSET = 'offset' DEFAULT_NAME = 'GTFS Sensor' DEFAULT_PATH = 'gtfs' @@ -38,15 +39,17 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DESTINATION): cv.string, vol.Required(CONF_DATA): cv.string, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): + cv.time_period_dict, }) -def get_next_departure(sched, start_station_id, end_station_id): +def get_next_departure(sched, start_station_id, end_station_id, offset): """Get the next departure for the given schedule.""" origin_station = sched.stops_by_id(start_station_id)[0] destination_station = sched.stops_by_id(end_station_id)[0] - now = datetime.datetime.now() + now = datetime.datetime.now() + offset day_name = now.strftime('%A').lower() now_str = now.strftime('%H:%M:%S') @@ -98,7 +101,7 @@ def get_next_departure(sched, start_station_id, end_station_id): if item == {}: return None - today = datetime.datetime.today().strftime('%Y-%m-%d') + today = now.strftime('%Y-%m-%d') departure_time_string = '{} {}'.format(today, item[2]) arrival_time_string = '{} {}'.format(today, item[3]) departure_time = datetime.datetime.strptime(departure_time_string, @@ -155,6 +158,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): origin = config.get(CONF_ORIGIN) destination = config.get(CONF_DESTINATION) name = config.get(CONF_NAME) + offset = config.get(CONF_OFFSET) if not os.path.exists(gtfs_dir): os.makedirs(gtfs_dir) @@ -175,17 +179,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if len(gtfs.feeds) < 1: pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data)) - add_devices([GTFSDepartureSensor(gtfs, name, origin, destination)]) + add_devices([GTFSDepartureSensor(gtfs, name, origin, destination, offset)]) class GTFSDepartureSensor(Entity): """Implementation of an GTFS departures sensor.""" - def __init__(self, pygtfs, name, origin, destination): + def __init__(self, pygtfs, name, origin, destination, offset): """Initialize the sensor.""" self._pygtfs = pygtfs self.origin = origin self.destination = destination + self._offset = offset self._custom_name = name self._name = '' self._unit_of_measurement = 'min' @@ -223,7 +228,7 @@ class GTFSDepartureSensor(Entity): """Get the latest data from GTFS and update the states.""" with self.lock: self._departure = get_next_departure( - self._pygtfs, self.origin, self.destination) + self._pygtfs, self.origin, self.destination, self._offset) if not self._departure: self._state = 0 self._attributes = {'Info': 'No more departures today'} @@ -249,6 +254,7 @@ class GTFSDepartureSensor(Entity): # Build attributes self._attributes = {} + self._attributes['offset'] = self._offset.seconds / 60 def dict_for_table(resource): """Return a dict for the SQLAlchemy resource given.""" diff --git a/homeassistant/components/sensor/htu21d.py b/homeassistant/components/sensor/htu21d.py new file mode 100644 index 00000000000..870ca0ca160 --- /dev/null +++ b/homeassistant/components/sensor/htu21d.py @@ -0,0 +1,122 @@ +""" +Support for HTU21D temperature and humidity sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.htu21d/ +""" +import asyncio +from datetime import timedelta +from functools import partial +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME, TEMP_FAHRENHEIT +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.util.temperature import celsius_to_fahrenheit + +REQUIREMENTS = ['i2csense==0.0.4', + 'smbus-cffi==0.5.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_I2C_BUS = 'i2c_bus' +DEFAULT_I2C_BUS = 1 + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) + +DEFAULT_NAME = 'HTU21D Sensor' + +SENSOR_TEMPERATURE = 'temperature' +SENSOR_HUMIDITY = 'humidity' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce(int), +}) + + +# pylint: disable=import-error +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the HTU21D sensor.""" + import smbus + from i2csense.htu21d import HTU21D + + name = config.get(CONF_NAME) + bus_number = config.get(CONF_I2C_BUS) + temp_unit = hass.config.units.temperature_unit + + bus = smbus.SMBus(config.get(CONF_I2C_BUS)) + sensor = yield from hass.async_add_job( + partial(HTU21D, bus, logger=_LOGGER) + ) + if not sensor.sample_ok: + _LOGGER.error("HTU21D sensor not detected in bus %s", bus_number) + return False + + sensor_handler = yield from hass.async_add_job(HTU21DHandler, sensor) + + dev = [HTU21DSensor(sensor_handler, name, SENSOR_TEMPERATURE, temp_unit), + HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, '%')] + + async_add_devices(dev) + + +class HTU21DHandler: + """Implement HTU21D communication.""" + + def __init__(self, sensor): + """Initialize the sensor handler.""" + self.sensor = sensor + self.sensor.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Read raw data and calculate temperature and humidity.""" + self.sensor.update() + + +class HTU21DSensor(Entity): + """Implementation of the HTU21D sensor.""" + + def __init__(self, htu21d_client, name, variable, unit): + """Initialize the sensor.""" + self._name = '{}_{}'.format(name, variable) + self._variable = variable + self._unit_of_measurement = unit + self._client = htu21d_client + self._state = None + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self) -> int: + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return self._unit_of_measurement + + @asyncio.coroutine + def async_update(self): + """Get the latest data from the HTU21D sensor and update the state.""" + yield from self.hass.async_add_job(self._client.update) + if self._client.sensor.sample_ok: + if self._variable == SENSOR_TEMPERATURE: + value = round(self._client.sensor.temperature, 1) + if self.unit_of_measurement == TEMP_FAHRENHEIT: + value = celsius_to_fahrenheit(value) + else: + value = round(self._client.sensor.humidity, 1) + self._state = value + else: + _LOGGER.warning("Bad sample") diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index c96909e5bc1..45a7b812039 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyhydroquebec==1.1.0'] +REQUIREMENTS = ['pyhydroquebec==1.2.0'] _LOGGER = logging.getLogger(__name__) @@ -36,6 +36,8 @@ REQUESTS_TIMEOUT = 15 MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) SENSOR_TYPES = { + 'balance': + ['Balance', PRICE, 'mdi:square-inc-cash'], 'period_total_bill': ['Current period bill', PRICE, 'mdi:square-inc-cash'], 'period_length': diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 229f8790291..d4752666821 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -4,109 +4,125 @@ Sensors of a KNX Device. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/knx/ """ +from enum import Enum + +import logging +import voluptuous as vol + from homeassistant.const import ( - TEMP_CELSIUS, TEMPERATURE, CONF_TYPE, ILLUMINANCE, SPEED_MS, CONF_MINIMUM, - CONF_MAXIMUM) + CONF_NAME, CONF_MAXIMUM, CONF_MINIMUM, + CONF_TYPE, TEMP_CELSIUS +) from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['knx'] -# Speed units -SPEED_METERPERSECOND = 'm/s' # type: str +DEFAULT_NAME = "KNX sensor" -# Illuminance units -ILLUMINANCE_LUX = 'lx' # type: str - -# Predefined Minimum, Maximum Values for Sensors -# Temperature as defined in KNX Standard 3.10 - 9.001 DPT_Value_Temp -KNX_TEMP_MIN = -273 -KNX_TEMP_MAX = 670760 - -# Luminance(LUX) as Defined in KNX Standard 3.10 - 9.004 DPT_Value_Lux -KNX_LUX_MIN = 0 -KNX_LUX_MAX = 670760 - -# Speed m/s as defined in KNX Standard 3.10 - 9.005 DPT_Value_Wsp -KNX_SPEED_MS_MIN = 0 -KNX_SPEED_MS_MAX = 670760 +CONF_TEMPERATURE = 'temperature' +CONF_ADDRESS = 'address' +CONF_ILLUMINANCE = 'illuminance' +CONF_PERCENTAGE = 'percentage' +CONF_SPEED_MS = 'speed_ms' -def setup_platform(hass, config, add_entities, discovery_info=None): +class KNXAddressType(Enum): + """Enum to indicate conversion type for the KNX address.""" + + FLOAT = 1 + PERCENT = 2 + + +# define the fixed settings required for each sensor type +FIXED_SETTINGS_MAP = { + # Temperature as defined in KNX Standard 3.10 - 9.001 DPT_Value_Temp + CONF_TEMPERATURE: { + 'unit': TEMP_CELSIUS, + 'default_minimum': -273, + 'default_maximum': 670760, + 'address_type': KNXAddressType.FLOAT + }, + # Speed m/s as defined in KNX Standard 3.10 - 9.005 DPT_Value_Wsp + CONF_SPEED_MS: { + 'unit': 'm/s', + 'default_minimum': 0, + 'default_maximum': 670760, + 'address_type': KNXAddressType.FLOAT + }, + # Luminance(LUX) as defined in KNX Standard 3.10 - 9.004 DPT_Value_Lux + CONF_ILLUMINANCE: { + 'unit': 'lx', + 'default_minimum': 0, + 'default_maximum': 670760, + 'address_type': KNXAddressType.FLOAT + }, + # Percentage(%) as defined in KNX Standard 3.10 - 5.001 DPT_Scaling + CONF_PERCENTAGE: { + 'unit': '%', + 'default_minimum': 0, + 'default_maximum': 100, + 'address_type': KNXAddressType.PERCENT + } +} + +SENSOR_TYPES = set(FIXED_SETTINGS_MAP.keys()) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MINIMUM): vol.Coerce(float), + vol.Optional(CONF_MAXIMUM): vol.Coerce(float) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the KNX Sensor platform.""" - # KNX Datapoint 9.001 DPT_Value_Temp - if config[CONF_TYPE] == TEMPERATURE: - minimum_value, maximum_value = \ - update_and_define_min_max(config, KNX_TEMP_MIN, KNX_TEMP_MAX) - - add_entities([ - KNXSensorFloatClass( - hass, KNXConfig(config), TEMP_CELSIUS, minimum_value, - maximum_value) - ]) - - # Add KNX Speed Sensors(Like Wind Speed) - # KNX Datapoint 9.005 DPT_Value_Wsp - elif config[CONF_TYPE] == SPEED_MS: - minimum_value, maximum_value = \ - update_and_define_min_max( - config, KNX_SPEED_MS_MIN, KNX_SPEED_MS_MAX) - - add_entities([ - KNXSensorFloatClass(hass, KNXConfig(config), SPEED_METERPERSECOND, - minimum_value, maximum_value) - ]) - - # Add KNX Illuminance Sensors(Lux) - # KNX Datapoint 9.004 DPT_Value_Lux - elif config[CONF_TYPE] == ILLUMINANCE: - minimum_value, maximum_value = \ - update_and_define_min_max(config, KNX_LUX_MIN, KNX_LUX_MAX) - - add_entities([ - KNXSensorFloatClass(hass, KNXConfig(config), ILLUMINANCE_LUX, - minimum_value, maximum_value) - ]) + add_devices([KNXSensor(hass, KNXConfig(config))]) -def update_and_define_min_max(config, minimum_default, maximum_default): - """Determine a min/max value defined in the configuration.""" - minimum_value = minimum_default - maximum_value = maximum_default - if config.get(CONF_MINIMUM): - minimum_value = config.get(CONF_MINIMUM) +class KNXSensor(KNXGroupAddress): + """Representation of a KNX Sensor device.""" - if config.get(CONF_MAXIMUM): - maximum_value = config.get(CONF_MAXIMUM) - - return minimum_value, maximum_value - - -class KNXSensorBaseClass(): - """Sensor Base Class for all KNX Sensors.""" - - @property - def cache(self): - """We don't want to cache any Sensor Value.""" - return False - - -class KNXSensorFloatClass(KNXGroupAddress, KNXSensorBaseClass): - """ - Base Implementation of a 2byte Floating Point KNX Telegram. - - Defined in KNX 3.7.2 - 3.10 - """ - - def __init__(self, hass, config, unit_of_measurement, minimum_sensor_value, - maximum_sensor_value): + def __init__(self, hass, config): """Initialize a KNX Float Sensor.""" - self._unit_of_measurement = unit_of_measurement - self._minimum_value = minimum_sensor_value - self._maximum_value = maximum_sensor_value - self._value = None - + # set up the KNX Group address KNXGroupAddress.__init__(self, hass, config) + device_type = config.config.get(CONF_TYPE) + sensor_config = FIXED_SETTINGS_MAP.get(device_type) + + if not sensor_config: + raise NotImplementedError() + + # set up the conversion function based on the address type + address_type = sensor_config.get('address_type') + if address_type == KNXAddressType.FLOAT: + self.convert = convert_float + elif address_type == KNXAddressType.PERCENT: + self.convert = convert_percent + else: + raise NotImplementedError() + + # other settings + self._unit_of_measurement = sensor_config.get('unit') + default_min = float(sensor_config.get('default_minimum')) + default_max = float(sensor_config.get('default_maximum')) + self._minimum_value = config.config.get(CONF_MINIMUM, default_min) + self._maximum_value = config.config.get(CONF_MAXIMUM, default_max) + _LOGGER.debug( + "%s: configured additional settings: unit=%s, " + "min=%f, max=%f, type=%s", + self.name, self._unit_of_measurement, + self._minimum_value, self._maximum_value, str(address_type) + ) + + self._value = None + @property def state(self): """Return the Value of the KNX Sensor.""" @@ -119,13 +135,49 @@ class KNXSensorFloatClass(KNXGroupAddress, KNXSensorBaseClass): def update(self): """Update KNX sensor.""" - from knxip.conversion import knx2_to_float - super().update() self._value = None if self._data: - value = 0 if self._data == 0 else knx2_to_float(self._data) + if self._data == 0: + value = 0 + else: + value = self.convert(self._data) if self._minimum_value <= value <= self._maximum_value: self._value = value + + @property + def cache(self): + """We don't want to cache any Sensor Value.""" + return False + + +def convert_float(raw_value): + """Conversion for 2 byte floating point values. + + 2byte Floating Point KNX Telegram. + Defined in KNX 3.7.2 - 3.10 + """ + from knxip.conversion import knx2_to_float + + return knx2_to_float(raw_value) + + +def convert_percent(raw_value): + """Conversion for scaled byte values. + + 1byte percentage scaled KNX Telegram. + Defined in KNX 3.7.2 - 3.10. + """ + summed_value = 0 + try: + # convert raw value in bytes + for val in raw_value: + summed_value *= 256 + summed_value += val + except TypeError: + # pknx returns a non-iterable type for unsuccessful reads + pass + + return round(summed_value * 100 / 255) diff --git a/homeassistant/components/sensor/netdata.py b/homeassistant/components/sensor/netdata.py index e575cae8529..ad7426eb1d3 100644 --- a/homeassistant/components/sensor/netdata.py +++ b/homeassistant/components/sensor/netdata.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/sensor.netdata/ """ import logging from datetime import timedelta +from urllib.parse import urlsplit import requests import voluptuous as vol @@ -15,7 +16,6 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_PORT, STATE_UNKNOWN, CONF_NAME, CONF_RESOURCES) from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) _RESOURCE = 'api/v1' @@ -25,7 +25,7 @@ DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Netdata' DEFAULT_PORT = '19999' -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +SCAN_INTERVAL = timedelta(minutes=1) SENSOR_TYPES = { 'memory_free': ['RAM Free', 'MiB', 'system.ram', 'free', 1], @@ -61,19 +61,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): host = config.get(CONF_HOST) port = config.get(CONF_PORT) url = 'http://{}:{}'.format(host, port) - version_url = '{}/version.txt'.format(url) data_url = '{}/{}/data?chart='.format(url, _RESOURCE) resources = config.get(CONF_RESOURCES) - try: - response = requests.get(version_url, timeout=10) - if not response.ok: - _LOGGER.error("Response status is '%s'", response.status_code) - return False - except requests.exceptions.ConnectionError: - _LOGGER.error("No route to resource/endpoint: %s", url) - return False - values = {} for key, value in sorted(SENSOR_TYPES.items()): if key in resources: @@ -83,23 +73,23 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for chart in values: rest_url = '{}{}&{}'.format(data_url, chart, _REALTIME) rest = NetdataData(rest_url) + rest.update() for sensor_type in values[chart]: dev.append(NetdataSensor(rest, name, sensor_type)) - add_devices(dev) + add_devices(dev, True) class NetdataSensor(Entity): """Implementation of a Netdata sensor.""" def __init__(self, rest, name, sensor_type): - """Initialize the sensor.""" + """Initialize the Netdata sensor.""" self.rest = rest self.type = sensor_type self._name = '{} {}'.format(name, SENSOR_TYPES[self.type][0]) self._precision = SENSOR_TYPES[self.type][4] self._unit_of_measurement = SENSOR_TYPES[self.type][1] - self.update() @property def name(self): @@ -123,6 +113,11 @@ class NetdataSensor(Entity): else: return STATE_UNKNOWN + @property + def available(self): + """Could the resource be accessed during the last update call.""" + return self.rest.available + def update(self): """Get the latest data from Netdata REST API.""" self.rest.update() @@ -135,15 +130,16 @@ class NetdataData(object): """Initialize the data object.""" self._resource = resource self.data = None + self.available = True - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from the Netdata REST API.""" try: response = requests.get(self._resource, timeout=5) det = response.json() self.data = {k: v for k, v in zip(det['labels'], det['data'][0])} - + self.available = True except requests.exceptions.ConnectionError: - _LOGGER.error("No route to host/endpoint: %s", self._resource) + _LOGGER.error("Connection error: %s", urlsplit(self._resource)[1]) self.data = None + self.available = False diff --git a/homeassistant/components/sensor/openhardwaremonitor.py b/homeassistant/components/sensor/openhardwaremonitor.py new file mode 100644 index 00000000000..1d805916d97 --- /dev/null +++ b/homeassistant/components/sensor/openhardwaremonitor.py @@ -0,0 +1,183 @@ +"""Support for Open Hardware Monitor Sensor Platform.""" + +from datetime import timedelta +import logging +import requests +import voluptuous as vol + +from homeassistant.util.dt import utcnow +from homeassistant.const import CONF_HOST, CONF_PORT +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +STATE_MIN_VALUE = 'minimal_value' +STATE_MAX_VALUE = 'maximum_value' +STATE_VALUE = 'value' +STATE_OBJECT = 'object' +CONF_INTERVAL = 'interval' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15) +SCAN_INTERVAL = timedelta(seconds=30) +RETRY_INTERVAL = timedelta(seconds=30) + +OHM_VALUE = 'Value' +OHM_MIN = 'Min' +OHM_MAX = 'Max' +OHM_CHILDREN = 'Children' +OHM_NAME = 'Text' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=8085): cv.port +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Open Hardware Monitor platform.""" + data = OpenHardwareMonitorData(config, hass) + add_devices(data.devices, True) + + +class OpenHardwareMonitorDevice(Entity): + """Device used to display information from OpenHardwareMonitor.""" + + def __init__(self, data, name, path, unit_of_measurement): + """Initialize an OpenHardwareMonitor sensor.""" + self._name = name + self._data = data + self.path = path + self.attributes = {} + self._unit_of_measurement = unit_of_measurement + + self.value = None + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def state(self): + """Return the state of the device.""" + return self.value + + @property + def state_attributes(self): + """Return the state attributes of the sun.""" + return self.attributes + + def update(self): + """Update the device from a new JSON object.""" + self._data.update() + + array = self._data.data[OHM_CHILDREN] + _attributes = {} + + for path_index in range(0, len(self.path)): + path_number = self.path[path_index] + values = array[path_number] + + if path_index == len(self.path) - 1: + self.value = values[OHM_VALUE].split(' ')[0] + _attributes.update({ + 'name': values[OHM_NAME], + STATE_MIN_VALUE: values[OHM_MIN].split(' ')[0], + STATE_MAX_VALUE: values[OHM_MAX].split(' ')[0] + }) + + self.attributes = _attributes + return + else: + array = array[path_number][OHM_CHILDREN] + _attributes.update({ + 'level_%s' % path_index: values[OHM_NAME] + }) + + +class OpenHardwareMonitorData(object): + """Class used to pull data from OHM and create sensors.""" + + def __init__(self, config, hass): + """Initialize the Open Hardware Monitor data-handler.""" + self.data = None + self._config = config + self._hass = hass + self.devices = [] + self.initialize(utcnow()) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Hit by the timer with the configured interval.""" + if self.data is None: + self.initialize(utcnow()) + else: + self.refresh() + + def refresh(self): + """Download and parse JSON from OHM.""" + data_url = "http://%s:%d/data.json" % ( + self._config.get(CONF_HOST), + self._config.get(CONF_PORT)) + + try: + response = requests.get(data_url, timeout=30) + self.data = response.json() + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is OpenHardwareMonitor running?") + + def initialize(self, now): + """Initial parsing of the sensors and adding of devices.""" + self.refresh() + + if self.data is None: + return + + self.devices = self.parse_children(self.data, [], [], []) + + def parse_children(self, json, devices, path, names): + """Recursively loop through child objects, finding the values.""" + result = devices.copy() + + if len(json[OHM_CHILDREN]) > 0: + for child_index in range(0, len(json[OHM_CHILDREN])): + child_path = path.copy() + child_path.append(child_index) + + child_names = names.copy() + if len(path) > 0: + child_names.append(json[OHM_NAME]) + + obj = json[OHM_CHILDREN][child_index] + + added_devices = self.parse_children( + obj, devices, child_path, child_names) + + result = result + added_devices + return result + + if json[OHM_VALUE].find(' ') == -1: + return result + + unit_of_measurement = json[OHM_VALUE].split(' ')[1] + child_names = names.copy() + child_names.append(json[OHM_NAME]) + fullname = ' '.join(child_names) + + dev = OpenHardwareMonitorDevice( + self, + fullname, + path, + unit_of_measurement + ) + + result.append(dev) + return result diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index f3620efdb43..8d55b343781 100755 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -23,6 +23,7 @@ _LOGGER = logging.getLogger(__name__) CONF_ATTRIBUTION = "Data provided by OpenWeatherMap" CONF_FORECAST = 'forecast' +CONF_LANGUAGE = 'language' DEFAULT_NAME = 'OWM' @@ -45,7 +46,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_FORECAST, default=False): cv.boolean + vol.Optional(CONF_FORECAST, default=False): cv.boolean, + vol.Optional(CONF_LANGUAGE, default=None): cv.string, }) @@ -61,8 +63,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) forecast = config.get(CONF_FORECAST) + language = config.get(CONF_LANGUAGE) + if isinstance(language, str): + language = language.lower()[:2] - owm = OWM(config.get(CONF_API_KEY)) + owm = OWM(API_key=config.get(CONF_API_KEY), language=language) if not owm: _LOGGER.error("Unable to connect to OpenWeatherMap") @@ -178,7 +183,10 @@ class WeatherData(object): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from OpenWeatherMap.""" - obs = self.owm.weather_at_coords(self.latitude, self.longitude) + try: + obs = self.owm.weather_at_coords(self.latitude, self.longitude) + except TypeError: + obs = None if obs is None: _LOGGER.warning("Failed to fetch data from OpenWeatherMap") return @@ -186,6 +194,9 @@ class WeatherData(object): self.data = obs.get_weather() if self.forecast == 1: - obs = self.owm.three_hours_forecast_at_coords( - self.latitude, self.longitude) - self.fc_data = obs.get_forecast() + try: + obs = self.owm.three_hours_forecast_at_coords( + self.latitude, self.longitude) + self.fc_data = obs.get_forecast() + except TypeError: + _LOGGER.warning("Failed to fetch forecast from OpenWeatherMap") diff --git a/homeassistant/components/sensor/pi_hole.py b/homeassistant/components/sensor/pi_hole.py index c75d2387413..bacb25ce2c4 100644 --- a/homeassistant/components/sensor/pi_hole.py +++ b/homeassistant/components/sensor/pi_hole.py @@ -17,13 +17,16 @@ from homeassistant.const import ( CONF_NAME, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS) _LOGGER = logging.getLogger(__name__) -_ENDPOINT = '/admin/api.php' +_ENDPOINT = '/api.php' ATTR_BLOCKED_DOMAINS = 'domains_blocked' ATTR_PERCENTAGE_TODAY = 'percentage_today' ATTR_QUERIES_TODAY = 'queries_today' +CONF_LOCATION = 'location' DEFAULT_HOST = 'localhost' + +DEFAULT_LOCATION = 'admin' DEFAULT_METHOD = 'GET' DEFAULT_NAME = 'Pi-Hole' DEFAULT_SSL = False @@ -44,6 +47,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_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_LOCATION, default=DEFAULT_LOCATION): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), @@ -55,13 +59,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) host = config.get(CONF_HOST) use_ssl = config.get(CONF_SSL) + location = config.get(CONF_LOCATION) verify_ssl = config.get(CONF_VERIFY_SSL) - api = PiHoleAPI(host, use_ssl, verify_ssl) - - if api.data is None: - _LOGGER.error("Unable to fetch data from Pi-Hole") - return False + api = PiHoleAPI('{}/{}'.format(host, location), use_ssl, verify_ssl) sensors = [PiHoleSensor(hass, api, name, condition) for condition in config[CONF_MONITORED_CONDITIONS]] @@ -113,6 +114,11 @@ class PiHoleSensor(Entity): ATTR_BLOCKED_DOMAINS: self._api.data['domains_being_blocked'], } + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._api.available + def update(self): """Get the latest data from the Pi-Hole API.""" self._api.update() @@ -130,7 +136,7 @@ class PiHoleAPI(object): self._rest = RestData('GET', resource, None, None, None, verify_ssl) self.data = None - + self.available = True self.update() def update(self): @@ -138,5 +144,7 @@ class PiHoleAPI(object): try: self._rest.update() self.data = json.loads(self._rest.data) + self.available = True except TypeError: _LOGGER.error("Unable to fetch data from Pi-Hole") + self.available = False diff --git a/homeassistant/components/sensor/upnp.py b/homeassistant/components/sensor/upnp.py new file mode 100644 index 00000000000..e5acae67916 --- /dev/null +++ b/homeassistant/components/sensor/upnp.py @@ -0,0 +1,75 @@ +""" +Support for UPnP Sensors (IGD). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.upnp/ +""" +import logging + +from homeassistant.components.upnp import DATA_UPNP, UNITS +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +# sensor_type: [friendly_name, convert_unit, icon] +SENSOR_TYPES = { + 'byte_received': ['received bytes', True, 'mdi:server-network'], + 'byte_sent': ['sent bytes', True, 'mdi:server-network'], + 'packets_in': ['packets received', False, 'mdi:server-network'], + 'packets_out': ['packets sent', False, 'mdi:server-network'], +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the IGD sensors.""" + upnp = hass.data[DATA_UPNP] + unit = discovery_info['unit'] + add_devices([ + IGDSensor(upnp, t, unit if SENSOR_TYPES[t][1] else None) + for t in SENSOR_TYPES], True) + + +class IGDSensor(Entity): + """Representation of a UPnP IGD sensor.""" + + def __init__(self, upnp, sensor_type, unit=""): + """Initialize the IGD sensor.""" + self._upnp = upnp + self.type = sensor_type + self.unit = unit + self.unit_factor = UNITS[unit] if unit is not None else 1 + self._name = 'IGD {}'.format(SENSOR_TYPES[sensor_type][0]) + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self._state is None: + return None + return format(self._state / self.unit_factor, '.1f') + + @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 of measurement of this entity, if any.""" + return self.unit + + def update(self): + """Get the latest information from the IGD.""" + if self.type == "byte_received": + self._state = self._upnp.totalbytereceived() + elif self.type == "byte_sent": + self._state = self._upnp.totalbytesent() + elif self.type == "packets_in": + self._state = self._upnp.totalpacketreceived() + elif self.type == "packets_out": + self._state = self._upnp.totalpacketsent() diff --git a/homeassistant/components/sensor/verisure.py b/homeassistant/components/sensor/verisure.py index 4b22512fd4d..5ab999ccabf 100644 --- a/homeassistant/components/sensor/verisure.py +++ b/homeassistant/components/sensor/verisure.py @@ -18,31 +18,25 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Verisure platform.""" sensors = [] + hub.update_overview() if int(hub.config.get(CONF_THERMOMETERS, 1)): - hub.update_climate() sensors.extend([ - VerisureThermometer(value.id) - for value in hub.climate_status.values() - if hasattr(value, 'temperature') and value.temperature - ]) + VerisureThermometer(device_label) + for device_label in hub.get( + '$.climateValues[?(@.temperature)].deviceLabel')]) if int(hub.config.get(CONF_HYDROMETERS, 1)): - hub.update_climate() sensors.extend([ - VerisureHygrometer(value.id) - for value in hub.climate_status.values() - if hasattr(value, 'humidity') and value.humidity - ]) + VerisureHygrometer(device_label) + for device_label in hub.get( + '$.climateValues[?(@.humidity)].deviceLabel')]) if int(hub.config.get(CONF_MOUSE, 1)): - hub.update_mousedetection() sensors.extend([ - VerisureMouseDetection(value.deviceLabel) - for value in hub.mouse_status.values() - # is this if needed? - if hasattr(value, 'amountText') and value.amountText - ]) + VerisureMouseDetection(device_label) + for device_label in hub.get( + "$.eventCounts[?(@.deviceType=='MOUSE1')].deviceLabel")]) add_devices(sensors) @@ -50,26 +44,30 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class VerisureThermometer(Entity): """Representation of a Verisure thermometer.""" - def __init__(self, device_id): + def __init__(self, device_label): """Initialize the sensor.""" - self._id = device_id + self._device_label = device_label @property def name(self): """Return the name of the device.""" - return '{} {}'.format( - hub.climate_status[self._id].location, 'Temperature') + return hub.get_first( + "$.climateValues[?(@.deviceLabel=='%s')].deviceArea", + self._device_label) + " temperature" @property def state(self): """Return the state of the device.""" - # Remove ° character - return hub.climate_status[self._id].temperature[:-1] + return hub.get_first( + "$.climateValues[?(@.deviceLabel=='%s')].temperature", + self._device_label) @property def available(self): """Return True if entity is available.""" - return hub.available + return hub.get_first( + "$.climateValues[?(@.deviceLabel=='%s')].temperature", + self._device_label) is not None @property def unit_of_measurement(self): @@ -78,71 +76,80 @@ class VerisureThermometer(Entity): def update(self): """Update the sensor.""" - hub.update_climate() + hub.update_overview() class VerisureHygrometer(Entity): """Representation of a Verisure hygrometer.""" - def __init__(self, device_id): + def __init__(self, device_label): """Initialize the sensor.""" - self._id = device_id + self._device_label = device_label @property def name(self): - """Return the name of the sensor.""" - return '{} {}'.format( - hub.climate_status[self._id].location, 'Humidity') + """Return the name of the device.""" + return hub.get_first( + "$.climateValues[?(@.deviceLabel=='%s')].deviceArea", + self._device_label) + " humidity" @property def state(self): - """Return the state of the sensor.""" - # remove % character - return hub.climate_status[self._id].humidity[:-1] + """Return the state of the device.""" + return hub.get_first( + "$.climateValues[?(@.deviceLabel=='%s')].humidity", + self._device_label) @property def available(self): """Return True if entity is available.""" - return hub.available + return hub.get_first( + "$.climateValues[?(@.deviceLabel=='%s')].humidity", + self._device_label) is not None @property def unit_of_measurement(self): - """Return the unit of measurement of this sensor.""" - return "%" + """Return the unit of measurement of this entity.""" + return '%' def update(self): """Update the sensor.""" - hub.update_climate() + hub.update_overview() class VerisureMouseDetection(Entity): """Representation of a Verisure mouse detector.""" - def __init__(self, device_id): + def __init__(self, device_label): """Initialize the sensor.""" - self._id = device_id + self._device_label = device_label @property def name(self): - """Return the name of the sensor.""" - return '{} {}'.format( - hub.mouse_status[self._id].location, 'Mouse') + """Return the name of the device.""" + return hub.get_first( + "$.eventCounts[?(@.deviceLabel=='%s')].area", + self._device_label) + " mouse" @property def state(self): - """Return the state of the sensor.""" - return hub.mouse_status[self._id].count + """Return the state of the device.""" + return hub.get_first( + "$.eventCounts[?(@.deviceLabel=='%s')].detections", + self._device_label) @property def available(self): """Return True if entity is available.""" - return hub.available + return hub.get_first( + "$.eventCounts[?(@.deviceLabel=='%s')]", + self._device_label) is not None @property def unit_of_measurement(self): - """Return the unit of measurement of this sensor.""" - return "Mice" + """Return the unit of measurement of this entity.""" + return 'Mice' def update(self): """Update the sensor.""" - hub.update_mousedetection() + hub.update_overview() diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index 67c96e66062..84f6d2d2ac0 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -13,9 +13,10 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, CONF_API_KEY, TEMP_FAHRENHEIT, TEMP_CELSIUS, - LENGTH_INCHES, LENGTH_KILOMETERS, LENGTH_MILES, LENGTH_FEET, - STATE_UNKNOWN, ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME) + CONF_MONITORED_CONDITIONS, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, + TEMP_FAHRENHEIT, TEMP_CELSIUS, LENGTH_INCHES, LENGTH_KILOMETERS, + LENGTH_MILES, LENGTH_FEET, STATE_UNKNOWN, ATTR_ATTRIBUTION, + ATTR_FRIENDLY_NAME) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -618,6 +619,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PWS_ID): cv.string, vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.All(vol.In(LANG_CODES)), + vol.Inclusive(CONF_LATITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.longitude, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) @@ -625,9 +630,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the WUnderground sensor.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + rest = WUndergroundData( hass, config.get(CONF_API_KEY), config.get(CONF_PWS_ID), - config.get(CONF_LANG)) + config.get(CONF_LANG), latitude, longitude) sensors = [] for variable in config[CONF_MONITORED_CONDITIONS]: sensors.append(WUndergroundSensor(rest, variable)) @@ -658,7 +666,7 @@ class WUndergroundSensor(Entity): try: val = val(self.rest) except (KeyError, IndexError) as err: - _LOGGER.error("Failed to parse response from WU API: %s", err) + _LOGGER.warning("Failed to parse response from WU API: %s", err) val = default except TypeError: pass # val was not callable - keep original value @@ -684,6 +692,9 @@ class WUndergroundSensor(Entity): attrs[attr] = callback(self.rest) except TypeError: attrs[attr] = callback + except (KeyError, IndexError) as err: + _LOGGER.warning("Failed to parse response from WU API: %s", + err) attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION attrs[ATTR_FRIENDLY_NAME] = self._cfg_expand("friendly_name") @@ -714,14 +725,14 @@ class WUndergroundSensor(Entity): class WUndergroundData(object): """Get data from WUnderground.""" - def __init__(self, hass, api_key, pws_id, lang): + def __init__(self, hass, api_key, pws_id, lang, latitude, longitude): """Initialize the data object.""" self._hass = hass self._api_key = api_key self._pws_id = pws_id self._lang = 'lang:{}'.format(lang) - self._latitude = hass.config.latitude - self._longitude = hass.config.longitude + self._latitude = latitude + self._longitude = longitude self._features = set() self.data = None diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 94b7002093f..d81d14fc991 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -453,3 +453,20 @@ eight_sleep: duration: description: Duration to heat at the target level in seconds. example: 3600 + +axis: + vapix_call: + description: Configure device using Vapix parameter management. + fields: + name: + description: Name of device to Configure. [Required] + example: M1065-W + cgi: + description: Which cgi to call on device. [Optional] Default is 'param.cgi' + example: 'applications/control.cgi' + action: + description: What type of call. [Optional] Default is 'update' + example: 'start' + param: + description: What parameter to operate on. [Required] + example: 'package=VideoMotionDetection' diff --git a/homeassistant/components/shiftr.py b/homeassistant/components/shiftr.py new file mode 100644 index 00000000000..3fc25de5a16 --- /dev/null +++ b/homeassistant/components/shiftr.py @@ -0,0 +1,77 @@ +""" +Support for Shiftr.io. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/shiftr/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, EVENT_STATE_CHANGED, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import state as state_helper + +REQUIREMENTS = ['paho-mqtt==1.3.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'shiftr' + +SHIFTR_BROKER = 'broker.shiftr.io' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Initialize the Shiftr.io MQTT consumer.""" + import paho.mqtt.client as mqtt + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + + client_id = 'HomeAssistant' + port = 1883 + keepalive = 600 + + mqttc = mqtt.Client(client_id, protocol=mqtt.MQTTv311) + mqttc.username_pw_set(username, password=password) + mqttc.connect(SHIFTR_BROKER, port=port, keepalive=keepalive) + + def stop_shiftr(event): + """Stop the Shiftr.io MQTT component.""" + mqttc.disconnect() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_shiftr) + + def shiftr_event_listener(event): + """Listen for new messages on the bus and sends them to Shiftr.io.""" + state = event.data.get('new_state') + topic = state.entity_id.replace('.', '/') + + try: + _state = state_helper.state_as_number(state) + except ValueError: + _state = state.state + + try: + mqttc.publish(topic, _state, qos=0, retain=False) + + if state.attributes: + for attribute, data in state.attributes.items(): + mqttc.publish( + '/{}/{}'.format(topic, attribute), str(data), qos=0, + retain=False) + except RuntimeError: + pass + + hass.bus.listen(EVENT_STATE_CHANGED, shiftr_event_listener) + + return True diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py new file mode 100644 index 00000000000..aa42833daf3 --- /dev/null +++ b/homeassistant/components/snips.py @@ -0,0 +1,138 @@ +""" +Support for Snips on-device ASR and NLU. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/snips/ +""" +import asyncio +import copy +import json +import logging +import voluptuous as vol +from homeassistant.helpers import template, script, config_validation as cv +import homeassistant.loader as loader + +DOMAIN = 'snips' +DEPENDENCIES = ['mqtt'] +CONF_INTENTS = 'intents' +CONF_ACTION = 'action' + +INTENT_TOPIC = 'hermes/nlu/intentParsed' + +LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + CONF_INTENTS: { + cv.string: { + vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, + } + } + } +}, extra=vol.ALLOW_EXTRA) + +INTENT_SCHEMA = vol.Schema({ + vol.Required('text'): str, + vol.Required('intent'): { + vol.Required('intent_name'): str + }, + vol.Optional('slots'): [{ + vol.Required('slot_name'): str, + vol.Required('value'): { + vol.Required('kind'): str, + vol.Required('value'): cv.match_all + } + }] +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Activate Snips component.""" + mqtt = loader.get_component('mqtt') + intents = config[DOMAIN].get(CONF_INTENTS, {}) + handler = IntentHandler(hass, intents) + + @asyncio.coroutine + def message_received(topic, payload, qos): + """Handle new messages on MQTT.""" + LOGGER.debug("New intent: %s", payload) + yield from handler.handle_intent(payload) + + yield from mqtt.async_subscribe(hass, INTENT_TOPIC, message_received) + + return True + + +class IntentHandler(object): + """Help handling intents.""" + + def __init__(self, hass, intents): + """Initialize the intent handler.""" + self.hass = hass + intents = copy.deepcopy(intents) + template.attach(hass, intents) + + for name, intent in intents.items(): + if CONF_ACTION in intent: + intent[CONF_ACTION] = script.Script( + hass, intent[CONF_ACTION], "Snips intent {}".format(name)) + + self.intents = intents + + @asyncio.coroutine + def handle_intent(self, payload): + """Handle an intent.""" + try: + response = json.loads(payload) + except TypeError: + LOGGER.error('Received invalid JSON: %s', payload) + return + + try: + response = INTENT_SCHEMA(response) + except vol.Invalid as err: + LOGGER.error('Intent has invalid schema: %s. %s', err, response) + return + + intent = response['intent']['intent_name'].split('__')[-1] + config = self.intents.get(intent) + + if config is None: + LOGGER.warning("Received unknown intent %s. %s", intent, response) + return + + action = config.get(CONF_ACTION) + + if action is not None: + slots = self.parse_slots(response) + yield from action.async_run(slots) + + def parse_slots(self, response): + """Parse the intent slots.""" + parameters = {} + + for slot in response.get('slots', []): + key = slot['slot_name'] + value = self.get_value(slot['value']) + if value is not None: + parameters[key] = value + + return parameters + + @staticmethod + def get_value(value): + """Return the value of a given slot.""" + kind = value['kind'] + + if kind == "Custom": + return value["value"] + elif kind == "Builtin": + try: + return value["value"]["value"] + except KeyError: + return None + else: + LOGGER.warning('Received unknown slot type: %s', kind) + + return None diff --git a/homeassistant/components/switch/digital_ocean.py b/homeassistant/components/switch/digital_ocean.py index c873439dd58..081eea80e2d 100644 --- a/homeassistant/components/switch/digital_ocean.py +++ b/homeassistant/components/switch/digital_ocean.py @@ -8,13 +8,12 @@ import logging import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.components.digital_ocean import ( CONF_DROPLETS, ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME, ATTR_FEATURES, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, - ATTR_REGION, ATTR_VCPUS) -from homeassistant.loader import get_component -import homeassistant.helpers.config_validation as cv + ATTR_REGION, ATTR_VCPUS, DATA_DIGITAL_OCEAN) _LOGGER = logging.getLogger(__name__) @@ -29,19 +28,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Digital Ocean droplet switch.""" - digital_ocean = get_component('digital_ocean') + digital = hass.data.get(DATA_DIGITAL_OCEAN) + if not digital: + return False + droplets = config.get(CONF_DROPLETS) dev = [] for droplet in droplets: - droplet_id = digital_ocean.DIGITAL_OCEAN.get_droplet_id(droplet) + droplet_id = digital.get_droplet_id(droplet) if droplet_id is None: _LOGGER.error("Droplet %s is not available", droplet) return False - dev.append(DigitalOceanSwitch( - digital_ocean.DIGITAL_OCEAN, droplet_id)) + dev.append(DigitalOceanSwitch(digital, droplet_id)) - add_devices(dev) + add_devices(dev, True) class DigitalOceanSwitch(SwitchDevice): @@ -54,8 +55,6 @@ class DigitalOceanSwitch(SwitchDevice): self.data = None self._state = None - self.update() - @property def name(self): """Return the name of the switch.""" diff --git a/homeassistant/components/switch/rachio.py b/homeassistant/components/switch/rachio.py index 73183da1128..63809fd4456 100644 --- a/homeassistant/components/switch/rachio.py +++ b/homeassistant/components/switch/rachio.py @@ -8,9 +8,7 @@ import homeassistant.util as util from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_ACCESS_TOKEN -REQUIREMENTS = ['https://github.com/Klikini/rachiopy' - '/archive/2c8996fcfa97a9f361a789e0c998797ed2805281.zip' - '#rachiopy==0.1.1'] +REQUIREMENTS = ['rachiopy==0.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/verisure.py b/homeassistant/components/switch/verisure.py index 3aeb092f35b..710580c2ec6 100644 --- a/homeassistant/components/switch/verisure.py +++ b/homeassistant/components/switch/verisure.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.verisure/ """ import logging +from time import time from homeassistant.components.verisure import HUB as hub from homeassistant.components.verisure import CONF_SMARTPLUGS @@ -18,11 +19,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if not int(hub.config.get(CONF_SMARTPLUGS, 1)): return False - hub.update_smartplugs() + hub.update_overview() switches = [] switches.extend([ - VerisureSmartplug(value.deviceLabel) - for value in hub.smartplug_status.values()]) + VerisureSmartplug(device_label) + for device_label in hub.get('$.smartPlugs[*].deviceLabel')]) add_devices(switches) @@ -31,35 +32,46 @@ class VerisureSmartplug(SwitchDevice): def __init__(self, device_id): """Initialize the Verisure device.""" - self._id = device_id + self._device_label = device_id + self._change_timestamp = 0 + self._state = False @property def name(self): """Return the name or location of the smartplug.""" - return hub.smartplug_status[self._id].location + return hub.get_first( + "$.smartPlugs[?(@.deviceLabel == '%s')].area", + self._device_label) @property def is_on(self): """Return true if on.""" - return hub.smartplug_status[self._id].status == 'on' + if time() - self._change_timestamp < 10: + return self._state + self._state = hub.get_first( + "$.smartPlugs[?(@.deviceLabel == '%s')].currentState", + self._device_label) == "ON" + return self._state @property def available(self): """Return True if entity is available.""" - return hub.available + return hub.get_first( + "$.smartPlugs[?(@.deviceLabel == '%s')]", + self._device_label) is not None def turn_on(self): """Set smartplug status on.""" - hub.my_pages.smartplug.set(self._id, 'on') - hub.my_pages.smartplug.wait_while_updating(self._id, 'on') - self.update() + hub.session.set_smartplug_state(self._device_label, True) + self._state = True + self._change_timestamp = time() def turn_off(self): """Set smartplug status off.""" - hub.my_pages.smartplug.set(self._id, 'off') - hub.my_pages.smartplug.wait_while_updating(self._id, 'off') - self.update() + hub.session.set_smartplug_state(self._device_label, False) + self._state = False + self._change_timestamp = time() def update(self): """Get the latest date of the smartplug.""" - hub.update_smartplugs() + hub.update_overview() diff --git a/homeassistant/components/switch/wake_on_lan.py b/homeassistant/components/switch/wake_on_lan.py index 8430a5b8810..c5928ab1809 100644 --- a/homeassistant/components/switch/wake_on_lan.py +++ b/homeassistant/components/switch/wake_on_lan.py @@ -101,5 +101,5 @@ class WOLSwitch(SwitchDevice): ping_cmd = ['ping', '-c', '1', '-W', str(DEFAULT_PING_TIMEOUT), str(self._host)] - status = sp.call(ping_cmd, stdout=sp.DEVNULL) + status = sp.call(ping_cmd, stdout=sp.DEVNULL, stderr=sp.DEVNULL) self._state = not bool(status) diff --git a/homeassistant/components/tado.py b/homeassistant/components/tado.py index a465119dc2d..24712fa2fbe 100644 --- a/homeassistant/components/tado.py +++ b/homeassistant/components/tado.py @@ -16,8 +16,8 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.util import Throttle REQUIREMENTS = ['https://github.com/wmalgadey/PyTado/archive/' - '0.1.10.zip#' - 'PyTado==0.1.10'] + '0.2.1.zip#' + 'PyTado==0.2.1'] _LOGGER = logging.getLogger(__name__) @@ -47,6 +47,7 @@ def setup(hass, config): try: tado = Tado(username, password) + tado.setDebugging(True) except (RuntimeError, urllib.error.HTTPError): _LOGGER.error("Unable to connect to mytado with username and password") return False diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 434c03fb181..3d16252120b 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -24,7 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['python-telegram-bot==6.0.3'] +REQUIREMENTS = ['python-telegram-bot==6.1.0'] _LOGGER = logging.getLogger(__name__) @@ -70,6 +70,7 @@ SERVICE_EDIT_MESSAGE = 'edit_message' SERVICE_EDIT_CAPTION = 'edit_caption' SERVICE_EDIT_REPLYMARKUP = 'edit_replymarkup' SERVICE_ANSWER_CALLBACK_QUERY = 'answer_callback_query' +SERVICE_DELETE_MESSAGE = 'delete_message' EVENT_TELEGRAM_CALLBACK = 'telegram_callback' EVENT_TELEGRAM_COMMAND = 'telegram_command' @@ -115,19 +116,22 @@ SERVICE_SCHEMA_SEND_LOCATION = BASE_SERVICE_SCHEMA.extend({ }) SERVICE_SCHEMA_EDIT_MESSAGE = SERVICE_SCHEMA_SEND_MESSAGE.extend({ - vol.Required(ATTR_MESSAGEID): vol.Any(cv.positive_int, cv.string), + vol.Required(ATTR_MESSAGEID): + vol.Any(cv.positive_int, vol.All(cv.string, 'last')), vol.Required(ATTR_CHAT_ID): vol.Coerce(int), }) SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema({ - vol.Required(ATTR_MESSAGEID): vol.Any(cv.positive_int, cv.string), + vol.Required(ATTR_MESSAGEID): + vol.Any(cv.positive_int, vol.All(cv.string, 'last')), vol.Required(ATTR_CHAT_ID): vol.Coerce(int), vol.Required(ATTR_CAPTION): cv.template, vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, }, extra=vol.ALLOW_EXTRA) SERVICE_SCHEMA_EDIT_REPLYMARKUP = vol.Schema({ - vol.Required(ATTR_MESSAGEID): vol.Any(cv.positive_int, cv.string), + vol.Required(ATTR_MESSAGEID): + vol.Any(cv.positive_int, vol.All(cv.string, 'last')), vol.Required(ATTR_CHAT_ID): vol.Coerce(int), vol.Required(ATTR_KEYBOARD_INLINE): cv.ensure_list, }, extra=vol.ALLOW_EXTRA) @@ -138,6 +142,12 @@ SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY = vol.Schema({ vol.Optional(ATTR_SHOW_ALERT): cv.boolean, }, extra=vol.ALLOW_EXTRA) +SERVICE_SCHEMA_DELETE_MESSAGE = vol.Schema({ + vol.Required(ATTR_CHAT_ID): vol.Coerce(int), + vol.Required(ATTR_MESSAGEID): + vol.Any(cv.positive_int, vol.All(cv.string, 'last')), +}, extra=vol.ALLOW_EXTRA) + SERVICE_MAP = { SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, @@ -147,10 +157,11 @@ SERVICE_MAP = { SERVICE_EDIT_CAPTION: SERVICE_SCHEMA_EDIT_CAPTION, SERVICE_EDIT_REPLYMARKUP: SERVICE_SCHEMA_EDIT_REPLYMARKUP, SERVICE_ANSWER_CALLBACK_QUERY: SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY, + SERVICE_DELETE_MESSAGE: SERVICE_SCHEMA_DELETE_MESSAGE, } -def load_data(url=None, filepath=None, username=None, password=None, +def load_data(hass, url=None, filepath=None, username=None, password=None, authentication=None, num_retries=5): """Load photo/document into ByteIO/File container from a source.""" try: @@ -180,8 +191,10 @@ def load_data(url=None, filepath=None, username=None, password=None, _LOGGER.warning("Can't load photo in %s after %s retries.", url, retry_num) elif filepath is not None: - # Load photo from file - return open(filepath, "rb") + if hass.config.is_allowed_path(filepath): + return open(filepath, "rb") + + _LOGGER.warning("'%s' are not secure to load data from!", filepath) else: _LOGGER.warning("Can't load photo. No photo found in params!") @@ -214,7 +227,7 @@ def async_setup(hass, config): try: receiver_service = yield from \ platform.async_setup_platform(hass, p_config) - if receiver_service is None: + if receiver_service is False: _LOGGER.error( "Failed to initialize Telegram bot %s", p_type) return False @@ -271,6 +284,9 @@ def async_setup(hass, config): elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY: yield from hass.async_add_job( partial(notify_service.answer_callback_query, **kwargs)) + elif msgtype == SERVICE_DELETE_MESSAGE: + yield from hass.async_add_job( + partial(notify_service.delete_message, **kwargs)) else: yield from hass.async_add_job( partial(notify_service.edit_message, msgtype, **kwargs)) @@ -407,11 +423,11 @@ class TelegramNotificationService: [_make_row_inline_keyboard(row) for row in keys]) return params - def _send_msg(self, func_send, msg_error, *args_rep, **kwargs_rep): + def _send_msg(self, func_send, msg_error, *args_msg, **kwargs_msg): """Send one message.""" from telegram.error import TelegramError try: - out = func_send(*args_rep, **kwargs_rep) + out = func_send(*args_msg, **kwargs_msg) if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID): chat_id = out.chat_id self._last_message_id[chat_id] = out[ATTR_MESSAGEID] @@ -421,8 +437,9 @@ class TelegramNotificationService: _LOGGER.warning("Update last message: out_type:%s, out=%s", type(out), out) return out - except TelegramError: - _LOGGER.exception(msg_error) + except TelegramError as exc: + _LOGGER.error("%s: %s. Args: %s, kwargs: %s", + msg_error, exc, args_msg, kwargs_msg) def send_message(self, message="", target=None, **kwargs): """Send a message to one or multiple pre-allowed chat IDs.""" @@ -436,6 +453,20 @@ class TelegramNotificationService: "Error sending message", chat_id, text, **params) + def delete_message(self, chat_id=None, **kwargs): + """Delete a previously sent message.""" + chat_id = self._get_target_chat_ids(chat_id)[0] + message_id, _ = self._get_msg_ids(kwargs, chat_id) + _LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id) + deleted = self._send_msg(self.bot.deleteMessage, + "Error deleting message", + chat_id, message_id) + # reduce message_id anyway: + if self._last_message_id[chat_id] is not None: + # change last msg_id for deque(n_msgs)? + self._last_message_id[chat_id] -= 1 + return deleted + def edit_message(self, type_edit, chat_id=None, **kwargs): """Edit a previously sent message.""" chat_id = self._get_target_chat_ids(chat_id)[0] @@ -482,6 +513,7 @@ class TelegramNotificationService: caption = kwargs.get(ATTR_CAPTION) func_send = self.bot.sendPhoto if is_photo else self.bot.sendDocument file_content = load_data( + self.hass, url=kwargs.get(ATTR_URL), filepath=kwargs.get(ATTR_FILE), username=kwargs.get(ATTR_USERNAME), diff --git a/homeassistant/components/telegram_bot/broadcast.py b/homeassistant/components/telegram_bot/broadcast.py new file mode 100644 index 00000000000..091aab58be8 --- /dev/null +++ b/homeassistant/components/telegram_bot/broadcast.py @@ -0,0 +1,28 @@ +""" +Telegram bot implementation to send messages only. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/telegram_bot.broadcast/ +""" +import asyncio +import logging + +from homeassistant.components.telegram_bot import ( + PLATFORM_SCHEMA as TELEGRAM_PLATFORM_SCHEMA) +from homeassistant.const import CONF_API_KEY + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = TELEGRAM_PLATFORM_SCHEMA + + +@asyncio.coroutine +def async_setup_platform(hass, config): + """Set up the Telegram broadcast platform.""" + # Check the API key works + import telegram + bot = telegram.Bot(config[CONF_API_KEY]) + bot_config = yield from hass.async_add_job(bot.getMe) + _LOGGER.debug("Telegram broadcast platform setup with bot %s", + bot_config['username']) + return True diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 60828d91cc3..d8ad6a6fecb 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -225,3 +225,15 @@ answer_callback_query: 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/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index f57c0620644..488fbdfec2b 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/telegram_bot.webhooks/ """ import asyncio import datetime as dt +from functools import partial from ipaddress import ip_network import logging @@ -70,7 +71,8 @@ def async_setup_platform(hass, config): return False if current_status and current_status['url'] != handler_url: - result = yield from hass.async_add_job(bot.setWebhook, handler_url) + result = yield from hass.async_add_job( + partial(bot.setWebhook, handler_url, timeout=10)) if result: _LOGGER.info("Set new telegram webhook %s", handler_url) else: diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index 4c85fe8a2bd..f923e09323c 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -126,7 +126,7 @@ class TelldusLiveClient(object): discovery.load_platform( self._hass, component, DOMAIN, [device_id], self._config) - known_ids = set([entity.device_id for entity in self.entities]) + known_ids = {entity.device_id for entity in self.entities} for device in self._client.devices: if device.device_id in known_ids: continue diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index f82d3fa5e88..888a1773189 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -29,7 +29,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ["mutagen==1.37.0"] +REQUIREMENTS = ['mutagen==1.38'] DOMAIN = 'tts' DEPENDENCIES = ['http'] diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py index e6ad66d0b51..a058fdae85e 100644 --- a/homeassistant/components/upnp.py +++ b/homeassistant/components/upnp.py @@ -1,5 +1,5 @@ """ -This module will attempt to open a port in your router for Home Assistant. +Will open a port in your router for Home Assistant and provide statistics. For more details about this component, please refer to the documentation at https://home-assistant.io/components/upnp/ @@ -10,14 +10,33 @@ from urllib.parse import urlsplit import voluptuous as vol from homeassistant.const import (EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery + +REQUIREMENTS = ['miniupnpc==1.9'] _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['api'] DOMAIN = 'upnp' +DATA_UPNP = 'UPNP' + +CONF_ENABLE_PORT_MAPPING = 'port_mapping' +CONF_UNITS = 'unit' + +UNITS = { + "Bytes": 1, + "KBytes": 1024, + "MBytes": 1024**2, + "GBytes": 1024**3, +} + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({}), + DOMAIN: vol.Schema({ + vol.Optional(CONF_ENABLE_PORT_MAPPING, default=True): cv.boolean, + vol.Optional(CONF_UNITS, default="MBytes"): vol.In(UNITS), + }), }, extra=vol.ALLOW_EXTRA) @@ -27,6 +46,7 @@ def setup(hass, config): import miniupnpc upnp = miniupnpc.UPnP() + hass.data[DATA_UPNP] = upnp upnp.discoverdelay = 200 upnp.discover() @@ -36,6 +56,13 @@ def setup(hass, config): _LOGGER.exception("Error when attempting to discover an UPnP IGD") return False + unit = config[DOMAIN].get(CONF_UNITS) + discovery.load_platform(hass, 'sensor', DOMAIN, {'unit': unit}, config) + + port_mapping = config[DOMAIN].get(CONF_ENABLE_PORT_MAPPING) + if not port_mapping: + return True + base_url = urlsplit(hass.config.api.base_url) host = base_url.hostname external_port = internal_port = base_url.port diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 5d37f0f93d8..cef185bc21f 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -164,7 +164,7 @@ class VeraDevice(Entity): attr = {} if self.vera_device.has_battery: - attr[ATTR_BATTERY_LEVEL] = self.vera_device.battery_level + '%' + attr[ATTR_BATTERY_LEVEL] = self.vera_device.battery_level if self.vera_device.is_armable: armed = self.vera_device.is_armed diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 72837b07019..1ec1f9e537d 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -6,19 +6,19 @@ https://home-assistant.io/components/verisure/ """ import logging import threading -import time import os.path from datetime import timedelta import voluptuous as vol -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import discovery from homeassistant.util import Throttle import homeassistant.config as conf_util import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['vsure==0.11.1'] +REQUIREMENTS = ['vsure==1.3.6', 'jsonpath==0.75'] _LOGGER = logging.getLogger(__name__) @@ -26,6 +26,7 @@ ATTR_DEVICE_SERIAL = 'device_serial' CONF_ALARM = 'alarm' CONF_CODE_DIGITS = 'code_digits' +CONF_DOOR_WINDOW = 'door_window' CONF_HYDROMETERS = 'hygrometers' CONF_LOCKS = 'locks' CONF_MOUSE = 'mouse' @@ -45,6 +46,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_ALARM, default=True): cv.boolean, vol.Optional(CONF_CODE_DIGITS, default=4): cv.positive_int, + vol.Optional(CONF_DOOR_WINDOW, default=True): cv.boolean, vol.Optional(CONF_HYDROMETERS, default=True): cv.boolean, vol.Optional(CONF_LOCKS, default=True): cv.boolean, vol.Optional(CONF_MOUSE, default=True): cv.boolean, @@ -66,9 +68,12 @@ def setup(hass, config): HUB = VerisureHub(config[DOMAIN], verisure) if not HUB.login(): return False + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, + lambda event: HUB.logout()) + HUB.update_overview() for component in ('sensor', 'switch', 'alarm_control_panel', 'lock', - 'camera'): + 'camera', 'binary_sensor'): discovery.load_platform(hass, component, DOMAIN, {}, config) descriptions = conf_util.load_yaml_config_file( @@ -93,132 +98,73 @@ class VerisureHub(object): def __init__(self, domain_config, verisure): """Initialize the Verisure hub.""" - self.alarm_status = {} - self.lock_status = {} - self.climate_status = {} - self.mouse_status = {} - self.smartplug_status = {} - self.smartcam_status = {} - self.smartcam_dict = {} + self.overview = {} + self.imageseries = {} self.config = domain_config self._verisure = verisure self._lock = threading.Lock() - # When MyPages is brought up from maintenance it sometimes give us a - # "wrong password" message. We will continue to retry after maintenance - # regardless of that error. - self._disable_wrong_password_error = False - self._password_retries = 1 - self._reconnect_timeout = time.time() - - self.my_pages = verisure.MyPages( + self.session = verisure.Session( domain_config[CONF_USERNAME], domain_config[CONF_PASSWORD]) + import jsonpath + self.jsonpath = jsonpath.jsonpath + def login(self): - """Login to Verisure MyPages.""" + """Login to Verisure.""" try: - self.my_pages.login() + self.session.login() except self._verisure.Error as ex: - _LOGGER.error("Could not log in to verisure mypages, %s", ex) + _LOGGER.error('Could not log in to verisure, %s', ex) return False return True - @Throttle(timedelta(seconds=1)) - def update_alarms(self): - """Update the status of the alarm.""" - self.update_component( - self.my_pages.alarm.get, - self.alarm_status) - - @Throttle(timedelta(seconds=1)) - def update_locks(self): - """Update the status of the locks.""" - self.update_component( - self.my_pages.lock.get, - self.lock_status) + def logout(self): + """Logout from Verisure.""" + try: + self.session.logout() + except self._verisure.Error as ex: + _LOGGER.error('Could not log out from verisure, %s', ex) + return False + return True @Throttle(timedelta(seconds=60)) - def update_climate(self): - """Update the status of the climate units.""" - self.update_component( - self.my_pages.climate.get, - self.climate_status) + def update_overview(self): + """Update the overview.""" + try: + self.overview = self.session.get_overview() + except self._verisure.ResponseError as ex: + _LOGGER.error('Could not read overview, %s', ex) + if ex.status_code == 503: # Service unavailable + _LOGGER.info('Trying to log in again') + self.login() + else: + raise @Throttle(timedelta(seconds=60)) - def update_mousedetection(self): - """Update the status of the mouse detectors.""" - self.update_component( - self.my_pages.mousedetection.get, - self.mouse_status) - - @Throttle(timedelta(seconds=1)) - def update_smartplugs(self): - """Update the status of the smartplugs.""" - self.update_component( - self.my_pages.smartplug.get, - self.smartplug_status) - - @Throttle(timedelta(seconds=30)) - def update_smartcam(self): - """Update the status of the smartcam.""" - self.update_component( - self.my_pages.smartcam.get, - self.smartcam_status) - - @Throttle(timedelta(seconds=30)) - def update_smartcam_imagelist(self): - """Update the imagelist for the camera.""" - _LOGGER.debug("Running update imagelist") - self.smartcam_dict = self.my_pages.smartcam.get_imagelist() - _LOGGER.debug("New dict: %s", self.smartcam_dict) + def update_smartcam_imageseries(self): + """Update the image series.""" + self.imageseries = self.session.get_camera_imageseries() @Throttle(timedelta(seconds=30)) def smartcam_capture(self, device_id): """Capture a new image from a smartcam.""" - self.my_pages.smartcam.capture(device_id) + self.session.capture_image(device_id) - @property - def available(self): - """Return True if hub is available.""" - return self._password_retries >= 0 + def get(self, jpath, *args): + """Get values from the overview that matches the jsonpath.""" + res = self.jsonpath(self.overview, jpath % args) + return res if res else [] - def update_component(self, get_function, status): - """Update the status of Verisure components.""" - try: - for overview in get_function(): - try: - status[overview.id] = overview - except AttributeError: - status[overview.deviceLabel] = overview - except self._verisure.Error as ex: - _LOGGER.info("Caught connection error %s, tries to reconnect", ex) - self.reconnect() + def get_first(self, jpath, *args): + """Get first value from the overview that matches the jsonpath.""" + res = self.get(jpath, *args) + return res[0] if res else None - def reconnect(self): - """Reconnect to Verisure MyPages.""" - if (self._reconnect_timeout > time.time() or - not self._lock.acquire(blocking=False) or - self._password_retries < 0): - return - try: - self.my_pages.login() - self._disable_wrong_password_error = False - self._password_retries = 1 - except self._verisure.LoginError as ex: - _LOGGER.error("Wrong user name or password for Verisure MyPages") - if self._disable_wrong_password_error: - self._reconnect_timeout = time.time() + 60*60 - else: - self._password_retries = self._password_retries - 1 - except self._verisure.MaintenanceError: - self._disable_wrong_password_error = True - self._reconnect_timeout = time.time() + 60*60 - _LOGGER.error("Verisure MyPages down for maintenance") - except self._verisure.Error as ex: - _LOGGER.error("Could not login to Verisure MyPages, %s", ex) - self._reconnect_timeout = time.time() + 60 - finally: - self._lock.release() + def get_image_info(self, jpath, *args): + """Get values from the imageseries that matches the jsonpath.""" + res = self.jsonpath(self.imageseries, jpath % args) + return res if res else [] diff --git a/homeassistant/components/weather/buienradar.py b/homeassistant/components/weather/buienradar.py index e46651a5e86..5b425c0a42b 100755 --- a/homeassistant/components/weather/buienradar.py +++ b/homeassistant/components/weather/buienradar.py @@ -21,6 +21,8 @@ import voluptuous as vol _LOGGER = logging.getLogger(__name__) +DEFAULT_TIMEFRAME = 60 + CONF_FORECAST = 'forecast' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -45,7 +47,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): CONF_LONGITUDE: float(longitude)} # create weather data: - data = BrData(hass, coordinates, None) + data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, None) # create weather device: async_add_devices([BrWeather(data, config.get(CONF_FORECAST, True), config.get(CONF_NAME, None))]) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index a79a986dc7d..ba970f94e95 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -151,7 +151,7 @@ CONFIG_SCHEMA = vol.Schema({ cv.positive_int, vol.Optional(CONF_USB_STICK_PATH, default=DEFAULT_CONF_USB_STICK_PATH): cv.string, - vol.Optional(CONF_NEW_ENTITY_IDS, default=False): cv.boolean, + vol.Optional(CONF_NEW_ENTITY_IDS, default=True): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index 3ca72c7fdda..ea8a6eaa036 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -19,7 +19,7 @@ add_node: description: Add a new (unsecure) node to the Z-Wave network. Refer to OZW.log for progress. add_node_secure: - description: Add a new node to the Z-Wave network with secure communications. Node must support this, and network key must be set. Note that unsecure devices can't directly talk to secure devices. Refer to OZW.log for progress. + description: Add a new node to the Z-Wave network with secure communications. Secure network key must be set, this process will fallback to add_node (unsecure) for unsupported devices. Note that unsecure devices can't directly talk to secure devices. Refer to OZW.log for progress. cancel_command: description: Cancel a running Z-Wave controller command. Use this to exit add_node, if you wasn't going to use it but activated it. @@ -31,14 +31,14 @@ remove_node: description: Remove a node from the Z-Wave network. Refer to OZW.log for progress. remove_failed_node: - descsription: This command will remove a failed node from the network. The node should be on the controllers failed nodes list, otherwise this command will fail. Refer to OZW.log for progress. + description: This command will remove a failed node from the network. The node should be on the controllers failed nodes list, otherwise this command will fail. Refer to OZW.log for progress. fields: node_id: description: Node id of the device to remove (integer). example: 10 replace_failed_node: - descsription: Replace a failed node with another. If the node is not in the controller's failed nodes list, or the node responds, this command will fail. Refer to OZW.log for progress. + description: Replace a failed node with another. If the node is not in the controller's failed nodes list, or the node responds, this command will fail. Refer to OZW.log for progress. fields: node_id: description: Node id of the device to replace (integer). @@ -105,7 +105,7 @@ test_network: description: This will send test to nodes in the Z-Wave network. This will greatly slow down the Z-Wave network while it is being processed. Refer to OZW.log for progress. rename_node: - description: Set the name(s) of a node. + description: Set the name of a node. This will also affect the IDs of all entities in the node. fields: node_id: description: ID of the node to rename. @@ -115,7 +115,7 @@ rename_node: example: 'kitchen' rename_value: - description: Set the name of a node value. Value IDs can be queried from /api/zwave/values/{node_id} + description: Set the name of a node value. This will affect the ID of the value entity. Value IDs can be queried from /api/zwave/values/{node_id} fields: node_id: description: ID of the node to rename. diff --git a/homeassistant/config.py b/homeassistant/config.py index 314a0f29d53..d91854c5162 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -16,7 +16,8 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_PACKAGES, CONF_UNIT_SYSTEM, CONF_TIME_ZONE, CONF_ELEVATION, CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, TEMP_CELSIUS, - __version__, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB) + __version__, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB, + CONF_WHITELIST_EXTERNAL_DIRS) from homeassistant.core import callback, DOMAIN as CONF_CORE from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import get_component, get_platform @@ -38,7 +39,7 @@ DATA_CUSTOMIZE = 'hass_customize' FILE_MIGRATION = [ ["ios.conf", ".ios.conf"], - ] +] DEFAULT_CORE_CONFIG = ( # Tuples (attribute, default, auto detect property, description) @@ -130,6 +131,9 @@ CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend({ vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, CONF_UNIT_SYSTEM: cv.unit_system, CONF_TIME_ZONE: cv.time_zone, + vol.Optional(CONF_WHITELIST_EXTERNAL_DIRS): + # pylint: disable=no-value-for-parameter + vol.All(cv.ensure_list, [vol.IsDir()]), vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, }) @@ -366,6 +370,12 @@ def async_process_ha_core_config(hass, config): if CONF_TIME_ZONE in config: set_time_zone(config.get(CONF_TIME_ZONE)) + # init whitelist external dir + hac.whitelist_external_dirs = set((hass.config.path('www'),)) + if CONF_WHITELIST_EXTERNAL_DIRS in config: + hac.whitelist_external_dirs.update( + set(config[CONF_WHITELIST_EXTERNAL_DIRS])) + # Customize cust_exact = dict(config[CONF_CUSTOMIZE]) cust_domain = dict(config[CONF_CUSTOMIZE_DOMAIN]) diff --git a/homeassistant/const.py b/homeassistant/const.py index d7588f08021..b83fca3dfd4 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 = 47 -PATCH_VERSION = '1' +MINOR_VERSION = 48 +PATCH_VERSION = 0 __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) @@ -161,6 +161,7 @@ CONF_VALUE_TEMPLATE = 'value_template' CONF_VERIFY_SSL = 'verify_ssl' CONF_WEEKDAY = 'weekday' CONF_WHITELIST = 'whitelist' +CONF_WHITELIST_EXTERNAL_DIRS = 'whitelist_external_dirs' CONF_WHITE_VALUE = 'white_value' CONF_XY = 'xy' CONF_ZONE = 'zone' diff --git a/homeassistant/core.py b/homeassistant/core.py index 5a9b185372e..4fcd938335c 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -10,6 +10,7 @@ from concurrent.futures import ThreadPoolExecutor import enum import logging import os +import pathlib import re import sys import threading @@ -47,9 +48,6 @@ SERVICE_CALL_LIMIT = 10 # seconds # Pattern for validating entity IDs (format: .) ENTITY_ID_PATTERN = re.compile(r"^(\w+)\.(\w+)$") -# Size of a executor pool -EXECUTOR_POOL_SIZE = 10 - # How long to wait till things that run on startup have to finish. TIMEOUT_EVENT_START = 15 @@ -113,9 +111,11 @@ class HomeAssistant(object): else: self.loop = loop or asyncio.get_event_loop() - executor_opts = { - 'max_workers': EXECUTOR_POOL_SIZE - } + executor_opts = {'max_workers': 10} + if sys.version_info[:2] >= (3, 5): + # It will default set to the number of processors on the machine, + # multiplied by 5. That is better for overlap I/O workers. + executor_opts['max_workers'] = None if sys.version_info[:2] >= (3, 6): executor_opts['thread_name_prefix'] = 'SyncWorker' @@ -181,6 +181,8 @@ class HomeAssistant(object): 'report the following info at http://bit.ly/2ogP58T : %s', ', '.join(self.config.components)) + # Allow automations to set up the start triggers before changing state + yield from asyncio.sleep(0, loop=self.loop) self.state = CoreState.running _async_create_timer(self) @@ -239,7 +241,7 @@ class HomeAssistant(object): target: target to call. args: parameters for method to call. """ - if is_callback(target): + if not asyncio.iscoroutine(target) and is_callback(target): target(*args) else: self.async_add_job(target, *args) @@ -1053,6 +1055,9 @@ class Config(object): # Directory that holds the configuration self.config_dir = None + # List of allowed external dirs to access + self.whitelist_external_dirs = set() + def distance(self: object, lat: float, lon: float) -> float: """Calculate distance from Home Assistant. @@ -1070,6 +1075,23 @@ class Config(object): raise HomeAssistantError("config_dir is not set") return os.path.join(self.config_dir, *path) + def is_allowed_path(self, path: str) -> bool: + """Check if the path is valid for access from outside.""" + parent = pathlib.Path(path).parent + try: + parent.resolve() # pylint: disable=no-member + except (FileNotFoundError, RuntimeError, PermissionError): + return False + + for whitelisted_path in self.whitelist_external_dirs: + try: + parent.relative_to(whitelisted_path) + return True + except ValueError: + pass + + return False + def as_dict(self): """Create a dictionary representation of this dict. @@ -1086,6 +1108,7 @@ class Config(object): 'time_zone': time_zone.zone, 'components': self.components, 'config_dir': self.config_dir, + 'whitelist_external_dirs': self.whitelist_external_dirs, 'version': __version__ } diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 4981e13beeb..2889d83af5c 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -26,3 +26,9 @@ class TemplateError(HomeAssistantError): """Init the error.""" super().__init__('{}: {}'.format(exception.__class__.__name__, exception)) + + +class PlatformNotReady(HomeAssistantError): + """Error to indicate that platform is not ready.""" + + pass diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 8cfc9984e2e..2833010789e 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -8,19 +8,22 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE, DEVICE_DEFAULT_NAME) from homeassistant.core import callback, valid_entity_id -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.loader import get_component from homeassistant.helpers import config_per_platform, discovery from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import ( + async_track_time_interval, async_track_point_in_time) from homeassistant.helpers.service import extract_entity_ids from homeassistant.util import slugify from homeassistant.util.async import ( run_callback_threadsafe, run_coroutine_threadsafe) +import homeassistant.util.dt as dt_util DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) SLOW_SETUP_WARNING = 10 SLOW_SETUP_MAX_WAIT = 60 +PLATFORM_NOT_READY_RETRIES = 10 class EntityComponent(object): @@ -113,7 +116,7 @@ class EntityComponent(object): @asyncio.coroutine def _async_setup_platform(self, platform_type, platform_config, - discovery_info=None): + discovery_info=None, tries=0): """Set up a platform for this component. This method must be run in the event loop. @@ -162,6 +165,16 @@ class EntityComponent(object): yield from entity_platform.async_block_entities_done() self.hass.config.components.add( '{}.{}'.format(self.domain, platform_type)) + except PlatformNotReady: + tries += 1 + wait_time = min(tries, 6) * 30 + self.logger.warning( + 'Platform %s not ready yet. Retrying in %d seconds.', + platform_type, wait_time) + async_track_point_in_time( + self.hass, self._async_setup_platform( + platform_type, platform_config, discovery_info, tries), + dt_util.utcnow() + timedelta(seconds=wait_time)) except asyncio.TimeoutError: self.logger.error( "Setup of platform %s is taking longer than %s seconds." diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 28e392edf31..f4230db153e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ pip>=7.1.0 jinja2>=2.9.5 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.1.0 +aiohttp==2.2.0 async_timeout==1.2.1 chardet==3.0.2 astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index d46518c82d4..6f596cc53ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,7 +6,7 @@ pip>=7.1.0 jinja2>=2.9.5 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.1.0 +aiohttp==2.2.0 async_timeout==1.2.1 chardet==3.0.2 astral==1.4 @@ -49,7 +49,10 @@ aiodns==1.1.1 aiohttp_cors==0.5.3 # homeassistant.components.light.lifx -aiolifx==0.4.8 +aiolifx==0.5.0 + +# homeassistant.components.light.lifx +aiolifx_effects==0.1.0 # homeassistant.components.scene.hunterdouglas_powerview aiopvapi==1.4 @@ -74,7 +77,7 @@ apns2==0.1.1 # avion==0.6 # homeassistant.components.axis -axis==7 +axis==8 # homeassistant.components.sensor.modem_callerid basicmodem==0.7 @@ -115,7 +118,7 @@ boto3==1.4.3 broadlink==0.3 # homeassistant.components.sensor.buienradar -buienradar==0.4 +buienradar==0.6 # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 @@ -141,10 +144,10 @@ datadog==0.15.0 datapoint==0.4.3 # homeassistant.components.light.decora -# decora==0.4 +# decora==0.6 # homeassistant.components.media_player.denonavr -denonavr==0.4.4 +denonavr==0.5.1 # homeassistant.components.media_player.directv directpy==0.1 @@ -270,9 +273,6 @@ holidays==0.8.1 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a -# homeassistant.components.switch.rachio -https://github.com/Klikini/rachiopy/archive/2c8996fcfa97a9f361a789e0c998797ed2805281.zip#rachiopy==0.1.1 - # homeassistant.components.switch.dlink https://github.com/LinuxChristian/pyW215/archive/v0.4.zip#pyW215==0.4 @@ -310,24 +310,29 @@ https://github.com/soldag/pyflic/archive/0.4.zip#pyflic==0.4 https://github.com/tfriedel/python-lightify/archive/1bb1db0e7bd5b14304d7bb267e2398cd5160df46.zip#lightify==1.0.5 # homeassistant.components.tado -https://github.com/wmalgadey/PyTado/archive/0.1.10.zip#PyTado==0.1.10 +https://github.com/wmalgadey/PyTado/archive/0.2.1.zip#PyTado==0.2.1 # homeassistant.components.media_player.lg_netcast https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 +# homeassistant.components.sensor.bh1750 +# homeassistant.components.sensor.bme280 +# homeassistant.components.sensor.htu21d +# i2csense==0.0.4 + # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb influxdb==3.0.0 -# homeassistant.components.insteon_hub -insteon_hub==0.4.5 - # homeassistant.components.insteon_local insteonlocal==0.52 # homeassistant.components.insteon_plm insteonplm==0.7.4 +# homeassistant.components.verisure +jsonpath==0.75 + # homeassistant.components.media_player.kodi # homeassistant.components.notify.kodi jsonrpc-async==0.6 @@ -342,7 +347,7 @@ keyring>=9.3,<10.0 knxip==0.3.3 # homeassistant.components.device_tracker.owntracks -libnacl==1.5.0 +libnacl==1.5.1 # homeassistant.components.dyson libpurecoollink==0.1.5 @@ -351,7 +356,7 @@ libpurecoollink==0.1.5 librouteros==1.0.2 # homeassistant.components.media_player.soundtouch -libsoundtouch==0.3.0 +libsoundtouch==0.6.2 # homeassistant.components.light.lifx_legacy liffylights==0.9.4 @@ -381,8 +386,11 @@ mficlient==0.3.0 # homeassistant.components.sensor.miflora miflora==0.1.16 +# homeassistant.components.upnp +miniupnpc==1.9 + # homeassistant.components.tts -mutagen==1.37.0 +mutagen==1.38 # homeassistant.components.sensor.usps myusps==1.1.2 @@ -422,7 +430,8 @@ openhomedevice==0.4.2 orvibo==1.1.1 # homeassistant.components.mqtt -paho-mqtt==1.2.3 +# homeassistant.components.shiftr +paho-mqtt==1.3.0 # homeassistant.components.media_player.panasonic_viera panasonic_viera==0.2 @@ -524,6 +533,9 @@ pychromecast==0.8.1 # homeassistant.components.media_player.cmus pycmus==0.1.0 +# homeassistant.components.comfoconnect +pycomfoconnect==0.3 + # homeassistant.components.sensor.cups # pycups==1.9.73 @@ -540,7 +552,7 @@ pyebox==0.1.0 pyeight==0.0.7 # homeassistant.components.media_player.emby -pyemby==1.2 +pyemby==1.3 # homeassistant.components.envisalink pyenvisalink==2.1 @@ -567,7 +579,7 @@ pyhik==0.1.2 pyhomematic==0.1.28 # homeassistant.components.sensor.hydroquebec -pyhydroquebec==1.1.0 +pyhydroquebec==1.2.0 # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 @@ -668,7 +680,7 @@ python-blockchain-api==0.0.2 python-clementine-remote==1.0.1 # homeassistant.components.digital_ocean -python-digitalocean==1.11 +python-digitalocean==1.12 # homeassistant.components.ecobee python-ecobee-api==0.0.7 @@ -721,7 +733,7 @@ python-roku==3.1.3 python-synology==0.1.0 # homeassistant.components.telegram_bot -python-telegram-bot==6.0.3 +python-telegram-bot==6.1.0 # homeassistant.components.sensor.twitch python-twitch==1.3.0 @@ -751,7 +763,7 @@ pyunifi==2.13 pyvera==0.2.33 # homeassistant.components.notify.html5 -pywebpush==1.0.4 +pywebpush==1.0.5 # homeassistant.components.wemo pywemo==0.4.19 @@ -762,14 +774,17 @@ pyzabbix==0.7.4 # homeassistant.components.sensor.qnap qnapstats==0.2.4 +# homeassistant.components.switch.rachio +rachiopy==0.1.2 + # homeassistant.components.climate.radiotherm -radiotherm==1.2 +radiotherm==1.3 # homeassistant.components.raspihats # raspihats==2.2.1 # homeassistant.components.python_script -restrictedpython==4.0a2 +restrictedpython==4.0a3 # homeassistant.components.rflink rflink==0.0.34 @@ -817,7 +832,10 @@ sleekxmpp==1.3.2 # homeassistant.components.sleepiq sleepyq==0.6 +# homeassistant.components.sensor.bh1750 +# homeassistant.components.sensor.bme280 # homeassistant.components.sensor.envirophat +# homeassistant.components.sensor.htu21d # smbus-cffi==0.5.1 # homeassistant.components.media_player.snapcast @@ -831,7 +849,7 @@ speedtest-cli==1.0.6 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.10 +sqlalchemy==1.1.11 # homeassistant.components.statsd statsd==3.2.1 @@ -884,7 +902,7 @@ uvcclient==0.10.0 volvooncall==0.3.3 # homeassistant.components.verisure -vsure==0.11.1 +vsure==1.3.6 # homeassistant.components.sensor.vasttrafik vtjp==0.1.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d64f2642ec4..f7b74c3b9d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -65,14 +65,15 @@ influxdb==3.0.0 libpurecoollink==0.1.5 # homeassistant.components.media_player.soundtouch -libsoundtouch==0.3.0 +libsoundtouch==0.6.2 # homeassistant.components.sensor.mfi # homeassistant.components.switch.mfi mficlient==0.3.0 # homeassistant.components.mqtt -paho-mqtt==1.2.3 +# homeassistant.components.shiftr +paho-mqtt==1.3.0 # homeassistant.components.device_tracker.aruba # homeassistant.components.device_tracker.asuswrt @@ -104,10 +105,10 @@ python-forecastio==1.3.5 pyunifi==2.13 # homeassistant.components.notify.html5 -pywebpush==1.0.4 +pywebpush==1.0.5 # homeassistant.components.python_script -restrictedpython==4.0a2 +restrictedpython==4.0a3 # homeassistant.components.rflink rflink==0.0.34 @@ -126,7 +127,7 @@ somecomfort==0.4.1 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.10 +sqlalchemy==1.1.11 # homeassistant.components.statsd statsd==3.2.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 92617a4ad60..f1f0678c60f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -28,7 +28,8 @@ COMMENT_REQUIREMENTS = ( 'face_recognition', 'blinkt', 'smbus-cffi', - 'envirophat' + 'envirophat', + 'i2csense' ) TEST_REQUIREMENTS = ( diff --git a/setup.py b/setup.py index c0accb33b6f..3a37874a08f 100755 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ REQUIRES = [ 'jinja2>=2.9.5', 'voluptuous==0.10.5', 'typing>=3,<4', - 'aiohttp==2.1.0', + 'aiohttp==2.2.0', 'async_timeout==1.2.1', 'chardet==3.0.2', 'astral==1.4', diff --git a/tests/common.py b/tests/common.py index 5915a45a84c..b2001a3c837 100644 --- a/tests/common.py +++ b/tests/common.py @@ -113,8 +113,6 @@ def get_test_home_assistant(): @asyncio.coroutine def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" - loop._thread_ident = threading.get_ident() - hass = ha.HomeAssistant(loop) INSTANCES.append(hass) @@ -177,7 +175,8 @@ def get_test_instance_port(): return _TEST_INSTANCE_PORT -def mock_service(hass, domain, service): +@ha.callback +def async_mock_service(hass, domain, service): """Set up a fake service & return a calls log list to this service.""" calls = [] @@ -186,14 +185,14 @@ def mock_service(hass, domain, service): """Mock service call.""" calls.append(call) - if hass.loop.__dict__.get("_thread_ident", 0) == threading.get_ident(): - hass.services.async_register(domain, service, mock_service_log) - else: - hass.services.register(domain, service, mock_service_log) + hass.services.async_register(domain, service, mock_service_log) return calls +mock_service = threadsafe_callback_factory(async_mock_service) + + @ha.callback def async_fire_mqtt_message(hass, topic, payload, qos=0): """Fire the MQTT message.""" diff --git a/tests/components/automation/test_homeassistant.py b/tests/components/automation/test_homeassistant.py index d63e59b3f54..d9cb5313c3e 100644 --- a/tests/components/automation/test_homeassistant.py +++ b/tests/components/automation/test_homeassistant.py @@ -6,13 +6,13 @@ from homeassistant.core import CoreState from homeassistant.setup import async_setup_component import homeassistant.components.automation as automation -from tests.common import mock_service, mock_coro +from tests.common import async_mock_service, mock_coro @asyncio.coroutine def test_if_fires_on_hass_start(hass): """Test the firing when HASS starts.""" - calls = mock_service(hass, 'test', 'automation') + calls = async_mock_service(hass, 'test', 'automation') hass.state = CoreState.not_running config = { automation.DOMAIN: { @@ -48,7 +48,7 @@ def test_if_fires_on_hass_start(hass): @asyncio.coroutine def test_if_fires_on_hass_shutdown(hass): """Test the firing when HASS starts.""" - calls = mock_service(hass, 'test', 'automation') + calls = async_mock_service(hass, 'test', 'automation') hass.state = CoreState.not_running res = yield from async_setup_component(hass, automation.DOMAIN, { diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index dfef66bf30e..7a8c097a730 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -14,7 +14,7 @@ import homeassistant.util.dt as dt_util from tests.common import ( assert_setup_component, get_test_home_assistant, fire_time_changed, - mock_service, mock_restore_cache) + mock_service, async_mock_service, mock_restore_cache) # pylint: disable=invalid-name @@ -565,7 +565,7 @@ def test_automation_restore_state(hass): assert state.state == STATE_OFF assert state.attributes.get('last_triggered') == time - calls = mock_service(hass, 'test', 'automation') + calls = async_mock_service(hass, 'test', 'automation') assert automation.is_on(hass, 'automation.bye') is False @@ -584,7 +584,7 @@ def test_automation_restore_state(hass): @asyncio.coroutine def test_initial_value_off(hass): """Test initial value off.""" - calls = mock_service(hass, 'test', 'automation') + calls = async_mock_service(hass, 'test', 'automation') res = yield from async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { @@ -611,7 +611,7 @@ def test_initial_value_off(hass): @asyncio.coroutine def test_initial_value_on(hass): """Test initial value on.""" - calls = mock_service(hass, 'test', 'automation') + calls = async_mock_service(hass, 'test', 'automation') res = yield from async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { @@ -638,7 +638,7 @@ def test_initial_value_on(hass): @asyncio.coroutine def test_initial_value_off_but_restore_on(hass): """Test initial value off and restored state is turned on.""" - calls = mock_service(hass, 'test', 'automation') + calls = async_mock_service(hass, 'test', 'automation') mock_restore_cache(hass, ( State('automation.hello', STATE_ON), )) @@ -668,7 +668,7 @@ def test_initial_value_off_but_restore_on(hass): @asyncio.coroutine def test_initial_value_on_but_restore_off(hass): """Test initial value on and restored state is turned off.""" - calls = mock_service(hass, 'test', 'automation') + calls = async_mock_service(hass, 'test', 'automation') mock_restore_cache(hass, ( State('automation.hello', STATE_OFF), )) @@ -698,7 +698,7 @@ def test_initial_value_on_but_restore_off(hass): @asyncio.coroutine def test_no_initial_value_and_restore_off(hass): """Test initial value off and restored state is turned on.""" - calls = mock_service(hass, 'test', 'automation') + calls = async_mock_service(hass, 'test', 'automation') mock_restore_cache(hass, ( State('automation.hello', STATE_OFF), )) @@ -727,7 +727,7 @@ def test_no_initial_value_and_restore_off(hass): @asyncio.coroutine def test_automation_is_on_if_no_initial_state_or_restore(hass): """Test initial value is on when no initial state or restored state.""" - calls = mock_service(hass, 'test', 'automation') + calls = async_mock_service(hass, 'test', 'automation') res = yield from async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { @@ -754,7 +754,7 @@ def test_automation_is_on_if_no_initial_state_or_restore(hass): def test_automation_not_trigger_on_bootstrap(hass): """Test if automation is not trigger on bootstrap.""" hass.state = CoreState.not_running - calls = mock_service(hass, 'test', 'automation') + calls = async_mock_service(hass, 'test', 'automation') res = yield from async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { diff --git a/tests/components/camera/test_demo.py b/tests/components/camera/test_demo.py new file mode 100644 index 00000000000..51e04fca351 --- /dev/null +++ b/tests/components/camera/test_demo.py @@ -0,0 +1,27 @@ +"""The tests for local file camera component.""" +import asyncio +from homeassistant.components import camera +from homeassistant.setup import async_setup_component + + +@asyncio.coroutine +def test_motion_detection(hass): + """Test motion detection services.""" + # Setup platform + yield from async_setup_component(hass, 'camera', { + 'camera': { + 'platform': 'demo' + } + }) + + # Fetch state and check motion detection attribute + state = hass.states.get('camera.demo_camera') + assert not state.attributes.get('motion_detection') + + # Call service to turn on motion detection + camera.enable_motion_detection(hass, 'camera.demo_camera') + yield from hass.async_block_till_done() + + # Check if state has been updated. + state = hass.states.get('camera.demo_camera') + assert state.attributes.get('motion_detection') diff --git a/tests/components/camera/test_generic.py b/tests/components/camera/test_generic.py index 7b1263dd3da..52bc3d9e048 100644 --- a/tests/components/camera/test_generic.py +++ b/tests/components/camera/test_generic.py @@ -2,7 +2,7 @@ import asyncio from unittest import mock -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component @asyncio.coroutine @@ -99,3 +99,40 @@ def test_limit_refetch(aioclient_mock, hass, test_client): assert resp.status == 200 body = yield from resp.text() assert body == 'hello planet' + + +@asyncio.coroutine +def test_camera_content_type(aioclient_mock, hass, test_client): + """Test generic camera with custom content_type.""" + svg_image = '' + urlsvg = 'https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg' + aioclient_mock.get(urlsvg, text=svg_image) + + cam_config_svg = { + 'name': 'config_test_svg', + 'platform': 'generic', + 'still_image_url': urlsvg, + 'content_type': 'image/svg+xml', + } + cam_config_normal = cam_config_svg.copy() + cam_config_normal.pop('content_type') + cam_config_normal['name'] = 'config_test_jpg' + + yield from async_setup_component(hass, 'camera', { + 'camera': [cam_config_svg, cam_config_normal]}) + + client = yield from test_client(hass.http.app) + + resp_1 = yield from client.get('/api/camera_proxy/camera.config_test_svg') + assert aioclient_mock.call_count == 1 + assert resp_1.status == 200 + assert resp_1.content_type == 'image/svg+xml' + body = yield from resp_1.text() + assert body == svg_image + + resp_2 = yield from client.get('/api/camera_proxy/camera.config_test_jpg') + assert aioclient_mock.call_count == 2 + assert resp_2.status == 200 + assert resp_2.content_type == 'image/jpeg' + body = yield from resp_2.text() + assert body == svg_image diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py index ccca77386d8..06e7e5e3515 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -6,7 +6,7 @@ from unittest import mock # https://bugs.python.org/issue23004 from mock_open import MockOpen -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component from tests.common import mock_http_component import logging @@ -65,3 +65,64 @@ def test_file_not_readable(hass, caplog): assert 'mock.file' in caplog.text yield from hass.loop.run_in_executor(None, run_test) + + +@asyncio.coroutine +def test_camera_content_type(hass, test_client): + """Test local_file camera content_type.""" + cam_config_jpg = { + 'name': 'test_jpg', + 'platform': 'local_file', + 'file_path': '/path/to/image.jpg', + } + cam_config_png = { + 'name': 'test_png', + 'platform': 'local_file', + 'file_path': '/path/to/image.png', + } + cam_config_svg = { + 'name': 'test_svg', + 'platform': 'local_file', + 'file_path': '/path/to/image.svg', + } + cam_config_noext = { + 'name': 'test_no_ext', + 'platform': 'local_file', + 'file_path': '/path/to/image', + } + + yield from async_setup_component(hass, 'camera', { + 'camera': [cam_config_jpg, cam_config_png, + cam_config_svg, cam_config_noext]}) + + client = yield from test_client(hass.http.app) + + image = 'hello' + m_open = MockOpen(read_data=image.encode()) + with mock.patch('homeassistant.components.camera.local_file.open', + m_open, create=True): + resp_1 = yield from client.get('/api/camera_proxy/camera.test_jpg') + resp_2 = yield from client.get('/api/camera_proxy/camera.test_png') + resp_3 = yield from client.get('/api/camera_proxy/camera.test_svg') + resp_4 = yield from client.get('/api/camera_proxy/camera.test_no_ext') + + assert resp_1.status == 200 + assert resp_1.content_type == 'image/jpeg' + body = yield from resp_1.text() + assert body == image + + assert resp_2.status == 200 + assert resp_2.content_type == 'image/png' + body = yield from resp_2.text() + assert body == image + + assert resp_3.status == 200 + assert resp_3.content_type == 'image/svg+xml' + body = yield from resp_3.text() + assert body == image + + # default mime type + assert resp_4.status == 200 + assert resp_4.content_type == 'image/jpeg' + body = yield from resp_4.text() + assert body == image diff --git a/tests/components/cover/test_template.py b/tests/components/cover/test_template.py new file mode 100644 index 00000000000..35ec21bfbdf --- /dev/null +++ b/tests/components/cover/test_template.py @@ -0,0 +1,630 @@ +"""The tests the cover command line platform.""" + +import logging +import unittest + +from homeassistant.core import callback +from homeassistant import setup +import homeassistant.components.cover as cover +from homeassistant.const import STATE_OPEN, STATE_CLOSED + +from tests.common import ( + get_test_home_assistant, assert_setup_component) +_LOGGER = logging.getLogger(__name__) + + +class TestTemplateCover(unittest.TestCase): + """Test the cover command line platform.""" + + hass = None + calls = None + # pylint: disable=invalid-name + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.calls = [] + + @callback + def record_call(service): + """Track function calls..""" + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_template_state_text(self): + """Test the state text of a 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' + }, + 'stop_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + 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.state == STATE_OPEN + + state = self.hass.states.set('cover.test_state', STATE_CLOSED) + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.state == STATE_CLOSED + + def test_template_state_boolean(self): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'stop_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + def test_template_position(self): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ states.cover.test.attributes.position }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test' + }, + 'stop_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test' + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.set('cover.test', STATE_CLOSED) + self.hass.block_till_done() + + entity = self.hass.states.get('cover.test') + attrs = dict() + attrs['position'] = 42 + self.hass.states.async_set( + entity.entity_id, entity.state, + attributes=attrs) + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 42.0 + assert state.state == STATE_OPEN + + state = self.hass.states.set('cover.test', STATE_OPEN) + self.hass.block_till_done() + entity = self.hass.states.get('cover.test') + attrs['position'] = 0.0 + self.hass.states.async_set( + entity.entity_id, entity.state, + attributes=attrs) + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 0.0 + assert state.state == STATE_CLOSED + + def test_template_tilt(self): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", + 'tilt_template': + "{{ 42 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'stop_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 42.0 + + def test_template_out_of_bounds(self): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ -1 }}", + 'tilt_template': + "{{ 110 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'stop_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') is None + assert state.attributes.get('current_position') is None + + def test_template_mutex(self): + """Test that only value or position template can be used.""" + with assert_setup_component(0, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", + 'position_template': + "{{ 42 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'stop_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'icon_template': + "{% if states.cover.test_state.state %}" + "mdi:check" + "{% endif %}" + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_template_position_or_value(self): + """Test that at least one of value or position template is used.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'stop_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'icon_template': + "{% if states.cover.test_state.state %}" + "mdi:check" + "{% endif %}" + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_template_non_numeric(self): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ on }}", + 'tilt_template': + "{% if states.cover.test_state.state %}" + "on" + "{% else %}" + "off" + "{% endif %}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'stop_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') is None + assert state.attributes.get('current_position') is None + + def test_open_action(self): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 0 }}", + 'open_cover': { + 'service': 'test.automation', + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'stop_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.state == STATE_CLOSED + + cover.open_cover(self.hass, 'cover.test_template_cover') + self.hass.block_till_done() + + assert len(self.calls) == 1 + + def test_close_stop_action(self): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'test.automation', + }, + 'stop_cover': { + 'service': 'test.automation', + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + cover.close_cover(self.hass, 'cover.test_template_cover') + self.hass.block_till_done() + + cover.stop_cover(self.hass, 'cover.test_template_cover') + self.hass.block_till_done() + + assert len(self.calls) == 2 + + def test_set_position(self): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'stop_cover': { + 'service': 'cover.stop_cover', + 'entity_id': 'cover.test_state' + }, + 'set_cover_position': { + 'service': 'test.automation', + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + cover.set_cover_position(self.hass, 42, + 'cover.test_template_cover') + self.hass.block_till_done() + + assert len(self.calls) == 1 + + def test_set_tilt_position(self): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'stop_cover': { + 'service': 'cover.stop_cover', + 'entity_id': 'cover.test_state' + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + cover.set_cover_tilt_position(self.hass, 42, + 'cover.test_template_cover') + self.hass.block_till_done() + + assert len(self.calls) == 1 + + def test_open_tilt_action(self): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'stop_cover': { + 'service': 'cover.stop_cover', + 'entity_id': 'cover.test_state' + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + cover.open_cover_tilt(self.hass, 'cover.test_template_cover') + self.hass.block_till_done() + + assert len(self.calls) == 1 + + def test_close_tilt_action(self): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'stop_cover': { + 'service': 'cover.stop_cover', + 'entity_id': 'cover.test_state' + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + cover.close_cover_tilt(self.hass, 'cover.test_template_cover') + self.hass.block_till_done() + + assert len(self.calls) == 1 + + def test_icon_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' + }, + 'stop_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'icon_template': + "{% if states.cover.test_state.state %}" + "mdi:check" + "{% endif %}" + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('icon') == '' + + 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['icon'] == 'mdi:check' diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 434950c175c..e4944035261 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -2,8 +2,8 @@ import asyncio import json import os -import unittest from collections import defaultdict +import unittest from unittest.mock import patch from tests.common import (assert_setup_component, fire_mqtt_message, @@ -187,9 +187,9 @@ REGION_LEAVE_ZERO_MESSAGE = { BAD_JSON_PREFIX = '--$this is bad json#--' BAD_JSON_SUFFIX = '** and it ends here ^^' -SECRET_KEY = 's3cretkey' +TEST_SECRET_KEY = 's3cretkey' ENCRYPTED_LOCATION_MESSAGE = { - # Encrypted version of LOCATION_MESSAGE using libsodium and SECRET_KEY + # Encrypted version of LOCATION_MESSAGE using libsodium and TEST_SECRET_KEY '_type': 'encrypted', 'data': ('qm1A83I6TVFRmH5343xy+cbex8jBBxDFkHRuJhELVKVRA/DgXcyKtghw' '9pOw75Lo4gHcyy2wV5CmkjrpKEBR7Qhye4AR0y7hOvlx6U/a3GuY1+W8' @@ -685,6 +685,18 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.assertTrue(wayp == new_wayp) +def mock_cipher(): + """Return a dummy pickle-based cipher.""" + def mock_decrypt(ciphertext, key): + """Decrypt/unpickle.""" + import pickle + (mkey, plaintext) = pickle.loads(ciphertext) + if key != mkey: + raise ValueError() + return plaintext + return (len(TEST_SECRET_KEY), mock_decrypt) + + class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): """Test the OwnTrack sensor.""" @@ -699,17 +711,6 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): """Tear down resources.""" self.hass.stop() - def mock_cipher(): # pylint: disable=no-method-argument - """Return a dummy pickle-based cipher.""" - def mock_decrypt(ciphertext, key): - """Decrypt/unpickle.""" - import pickle - (mkey, plaintext) = pickle.loads(ciphertext) - if key != mkey: - raise ValueError() - return plaintext - return (len(SECRET_KEY), mock_decrypt) - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', mock_cipher) def test_encrypted_payload(self): @@ -718,7 +719,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): assert setup_component(self.hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', - CONF_SECRET: SECRET_KEY, + CONF_SECRET: TEST_SECRET_KEY, }}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) self.assert_location_latitude(2.0) @@ -732,7 +733,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', CONF_SECRET: { - LOCATION_TOPIC: SECRET_KEY, + LOCATION_TOPIC: TEST_SECRET_KEY, }}}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) self.assert_location_latitude(2.0) @@ -803,7 +804,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): assert setup_component(self.hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', - CONF_SECRET: SECRET_KEY, + CONF_SECRET: TEST_SECRET_KEY, }}) self.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 04ea0f34fd5..e865b524f85 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -2,7 +2,8 @@ import asyncio from unittest.mock import patch -from homeassistant.components.mqtt.discovery import async_start +from homeassistant.components.mqtt.discovery import async_start, \ + ALREADY_DISCOVERED from tests.common import async_fire_mqtt_message, mock_coro @@ -73,6 +74,23 @@ def test_correct_config_discovery(hass, mqtt_mock, caplog): assert state is not None assert state.name == 'Beer' + assert ('binary_sensor', 'bla') in hass.data[ALREADY_DISCOVERED] + + +@asyncio.coroutine +def test_discovery_incl_nodeid(hass, mqtt_mock, caplog): + """Test sending in correct JSON with optional node_id included.""" + yield from async_start(hass, 'homeassistant', {}) + + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/my_node_id/bla' + '/config', '{ "name": "Beer" }') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.beer') + + assert state is not None + assert state.name == 'Beer' + assert ('binary_sensor', 'my_node_id_bla') in hass.data[ALREADY_DISCOVERED] @asyncio.coroutine diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 3be3d5d5ef6..3d068224243 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -249,6 +249,40 @@ class TestMQTT(unittest.TestCase): self.hass.block_till_done() self.assertEqual(0, len(self.calls)) + def test_subscribe_topic_sys_root(self): + """Test the subscription of $ root topics.""" + mqtt.subscribe(self.hass, '$test-topic/subtree/on', self.record_calls) + + fire_mqtt_message(self.hass, '$test-topic/subtree/on', 'test-payload') + + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual('$test-topic/subtree/on', self.calls[0][0]) + self.assertEqual('test-payload', self.calls[0][1]) + + def test_subscribe_topic_sys_root_and_wildcard_topic(self): + """Test the subscription of $ root and wildcard topics.""" + mqtt.subscribe(self.hass, '$test-topic/#', self.record_calls) + + fire_mqtt_message(self.hass, '$test-topic/some-topic', 'test-payload') + + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual('$test-topic/some-topic', self.calls[0][0]) + self.assertEqual('test-payload', self.calls[0][1]) + + def test_subscribe_topic_sys_root_and_wildcard_subtree_topic(self): + """Test the subscription of $ root and wildcard subtree topics.""" + mqtt.subscribe(self.hass, '$test-topic/subtree/#', self.record_calls) + + fire_mqtt_message(self.hass, '$test-topic/subtree/some-topic', + 'test-payload') + + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual('$test-topic/subtree/some-topic', self.calls[0][0]) + self.assertEqual('test-payload', self.calls[0][1]) + def test_subscribe_special_characters(self): """Test the subscription to topics with special characters.""" topic = '/test-topic/$(.)[^]{-}' diff --git a/tests/components/sensor/test_openhardwaremonitor.py b/tests/components/sensor/test_openhardwaremonitor.py new file mode 100644 index 00000000000..f66b6dcb3b5 --- /dev/null +++ b/tests/components/sensor/test_openhardwaremonitor.py @@ -0,0 +1,40 @@ +"""The tests for the Open Hardware Monitor platform.""" +import unittest +import requests_mock +from homeassistant.setup import setup_component +from tests.common import load_fixture, get_test_home_assistant + + +class TestOpenHardwareMonitorSetup(unittest.TestCase): + """Test the Open Hardware Monitor platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.config = { + 'sensor': { + 'platform': 'openhardwaremonitor', + 'host': 'localhost', + 'port': 8085 + } + } + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker() + def test_setup(self, mock_req): + """Test for successfully setting up the platform.""" + mock_req.get('http://localhost:8085/data.json', + text=load_fixture('openhardwaremonitor.json')) + + self.assertTrue(setup_component(self.hass, 'sensor', self.config)) + entities = self.hass.states.async_entity_ids('sensor') + self.assertEqual(len(entities), 38) + + state = self.hass.states.get( + 'sensor.testpc_intel_core_i77700_clocks_bus_speed') + + self.assertIsNot(state, None) + self.assertEqual(state.state, '100') diff --git a/tests/components/test_alert.py b/tests/components/test_alert.py index 8150d08ff72..a94e5747483 100644 --- a/tests/components/test_alert.py +++ b/tests/components/test_alert.py @@ -13,19 +13,21 @@ from homeassistant.const import (CONF_ENTITY_ID, STATE_IDLE, CONF_NAME, from tests.common import get_test_home_assistant NAME = "alert_test" +DONE_MESSAGE = "alert_gone" NOTIFIER = 'test' TEST_CONFIG = \ {alert.DOMAIN: { NAME: { CONF_NAME: NAME, + alert.CONF_DONE_MESSAGE: DONE_MESSAGE, CONF_ENTITY_ID: "sensor.test", CONF_STATE: STATE_ON, alert.CONF_REPEAT: 30, alert.CONF_SKIP_FIRST: False, alert.CONF_NOTIFIERS: [NOTIFIER]} }} -TEST_NOACK = [NAME, NAME, "sensor.test", STATE_ON, - [30], False, NOTIFIER, False] +TEST_NOACK = [NAME, NAME, DONE_MESSAGE, "sensor.test", + STATE_ON, [30], False, NOTIFIER, False] ENTITY_ID = alert.ENTITY_ID_FORMAT.format(NAME) @@ -119,6 +121,31 @@ class TestAlert(unittest.TestCase): hidden = self.hass.states.get(ENTITY_ID).attributes.get('hidden') self.assertFalse(hidden) + def test_notification_no_done_message(self): + """Test notifications.""" + events = [] + config = deepcopy(TEST_CONFIG) + del(config[alert.DOMAIN][NAME][alert.CONF_DONE_MESSAGE]) + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + self.hass.services.register( + notify.DOMAIN, NOTIFIER, record_event) + + assert setup_component(self.hass, alert.DOMAIN, config) + self.assertEqual(0, len(events)) + + self.hass.states.set("sensor.test", STATE_ON) + self.hass.block_till_done() + self.assertEqual(1, len(events)) + + self.hass.states.set("sensor.test", STATE_OFF) + self.hass.block_till_done() + self.assertEqual(1, len(events)) + def test_notification(self): """Test notifications.""" events = [] @@ -140,7 +167,7 @@ class TestAlert(unittest.TestCase): self.hass.states.set("sensor.test", STATE_OFF) self.hass.block_till_done() - self.assertEqual(1, len(events)) + self.assertEqual(2, len(events)) def test_skipfirst(self): """Test skipping first notification.""" @@ -170,3 +197,13 @@ class TestAlert(unittest.TestCase): self.hass.block_till_done() self.assertEqual(True, entity.hidden) + + def test_done_message_state_tracker_reset_on_cancel(self): + """Test that the done message is reset when cancelled.""" + entity = alert.Alert(self.hass, *TEST_NOACK) + entity._cancel = lambda *args: None + assert entity._send_done_message is False + entity._send_done_message = True + self.hass.add_job(entity.end_alerting) + self.hass.block_till_done() + assert entity._send_done_message is False diff --git a/tests/components/test_api.py b/tests/components/test_api.py index f110a832752..2d4842d7290 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -210,6 +210,9 @@ def test_api_get_config(hass, mock_api_client): result = yield from resp.json() if 'components' in result: result['components'] = set(result['components']) + if 'whitelist_external_dirs' in result: + result['whitelist_external_dirs'] = \ + set(result['whitelist_external_dirs']) assert hass.config.as_dict() == result diff --git a/tests/components/test_init.py b/tests/components/test_init.py index 6a0db023671..222d25f644a 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -16,7 +16,8 @@ from homeassistant.helpers import entity from homeassistant.util.async import run_coroutine_threadsafe from tests.common import ( - get_test_home_assistant, mock_service, patch_yaml_files, mock_coro) + get_test_home_assistant, mock_service, patch_yaml_files, mock_coro, + async_mock_service) class TestComponentsCore(unittest.TestCase): @@ -77,7 +78,7 @@ class TestComponentsCore(unittest.TestCase): @patch('homeassistant.core.ServiceRegistry.call') def test_turn_on_to_not_block_for_domains_without_service(self, mock_call): """Test if turn_on is blocking domain with no service.""" - mock_service(self.hass, 'light', SERVICE_TURN_ON) + async_mock_service(self.hass, 'light', SERVICE_TURN_ON) # We can't test if our service call results in services being called # because by mocking out the call service method, we mock out all diff --git a/tests/components/test_plant.py b/tests/components/test_plant.py index 57339f28941..70641afd6b4 100644 --- a/tests/components/test_plant.py +++ b/tests/components/test_plant.py @@ -1,73 +1,63 @@ """Unit tests for platform/plant.py.""" +import asyncio -import unittest - -from tests.common import get_test_home_assistant import homeassistant.components.plant as plant -class TestPlant(unittest.TestCase): - """test the processing of data.""" +GOOD_DATA = { + 'moisture': 50, + 'battery': 90, + 'temperature': 23.4, + 'conductivity': 777, + 'brightness': 987, +} - GOOD_DATA = { - 'moisture': 50, - 'battery': 90, - 'temperature': 23.4, - 'conductivity': 777, - 'brightness': 987, - } +GOOD_CONFIG = { + 'sensors': { + 'moisture': 'sensor.mqtt_plant_moisture', + 'battery': 'sensor.mqtt_plant_battery', + 'temperature': 'sensor.mqtt_plant_temperature', + 'conductivity': 'sensor.mqtt_plant_conductivity', + 'brightness': 'sensor.mqtt_plant_brightness', + }, + 'min_moisture': 20, + 'max_moisture': 60, + 'min_battery': 17, + 'min_conductivity': 500, + 'min_temperature': 15, +} - GOOD_CONFIG = { - 'sensors': { - 'moisture': 'sensor.mqtt_plant_moisture', - 'battery': 'sensor.mqtt_plant_battery', - 'temperature': 'sensor.mqtt_plant_temperature', - 'conductivity': 'sensor.mqtt_plant_conductivity', - 'brightness': 'sensor.mqtt_plant_brightness', - }, - 'min_moisture': 20, - 'max_moisture': 60, - 'min_battery': 17, - 'min_conductivity': 500, - 'min_temperature': 15, - } - class _MockState(object): +class _MockState(object): - def __init__(self, state=None): - self.state = state + def __init__(self, state=None): + self.state = state - 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() +@asyncio.coroutine +def test_valid_data(hass): + """Test processing valid data.""" + sensor = plant.Plant('my plant', GOOD_CONFIG) + sensor.hass = hass + for reading, value in GOOD_DATA.items(): + sensor.state_changed( + GOOD_CONFIG['sensors'][reading], None, + _MockState(value)) + assert sensor.state == 'ok' + attrib = sensor.state_attributes + for reading, value in GOOD_DATA.items(): + # battery level has a different name in + # the JSON format than in hass + assert attrib[reading] == value - def test_valid_data(self): - """Test processing valid data.""" - self.sensor = plant.Plant('my plant', self.GOOD_CONFIG) - self.sensor.hass = self.hass - for reading, value in self.GOOD_DATA.items(): - self.sensor.state_changed( - self.GOOD_CONFIG['sensors'][reading], None, - TestPlant._MockState(value)) - self.assertEqual(self.sensor.state, 'ok') - attrib = self.sensor.state_attributes - for reading, value in self.GOOD_DATA.items(): - # battery level has a different name in - # the JSON format than in hass - self.assertEqual(attrib[reading], value) - def test_low_battery(self): - """Test processing with low battery data and limit set.""" - self.sensor = plant.Plant(self.hass, self.GOOD_CONFIG) - self.sensor.hass = self.hass - self.assertEqual(self.sensor.state_attributes['problem'], 'none') - self.sensor.state_changed('sensor.mqtt_plant_battery', - TestPlant._MockState(45), - TestPlant._MockState(10)) - self.assertEqual(self.sensor.state, 'problem') - self.assertEqual(self.sensor.state_attributes['problem'], - 'battery low') +@asyncio.coroutine +def test_low_battery(hass): + """Test processing with low battery data and limit set.""" + sensor = plant.Plant(hass, GOOD_CONFIG) + sensor.hass = hass + assert sensor.state_attributes['problem'] == 'none' + sensor.state_changed('sensor.mqtt_plant_battery', + _MockState(45), _MockState(10)) + assert sensor.state == 'problem' + assert sensor.state_attributes['problem'] == 'battery low' diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py new file mode 100644 index 00000000000..09a33dae2be --- /dev/null +++ b/tests/components/test_snips.py @@ -0,0 +1,53 @@ +"""Test the Snips component.""" +import asyncio + +from homeassistant.bootstrap import async_setup_component +from tests.common import async_fire_mqtt_message, async_mock_service + +EXAMPLE_MSG = """ +{ + "text": "turn the lights green", + "intent": { + "intent_name": "Lights", + "probability": 1 + }, + "slots": [ + { + "slot_name": "light_color", + "value": { + "kind": "Custom", + "value": "blue" + } + } + ] +} +""" + + +@asyncio.coroutine +def test_snips_call_action(hass, mqtt_mock): + """Test calling action via Snips.""" + calls = async_mock_service(hass, 'test', 'service') + + result = yield from async_setup_component(hass, "snips", { + "snips": { + "intents": { + "Lights": { + "action": { + "service": "test.service", + "data_template": { + "color": "{{ light_color }}" + } + } + } + } + } + }) + assert result + + async_fire_mqtt_message(hass, 'hermes/nlu/intentParsed', + EXAMPLE_MSG) + yield from hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.data.get('color') == 'blue' diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index 9ca429f6f52..039fa4ba452 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -278,6 +278,9 @@ def test_get_config(hass, websocket_client): if 'components' in msg['result']: msg['result']['components'] = set(msg['result']['components']) + if 'whitelist_external_dirs' in msg['result']: + msg['result']['whitelist_external_dirs'] = \ + set(msg['result']['whitelist_external_dirs']) assert msg['result'] == hass.config.as_dict() diff --git a/tests/fixtures/openhardwaremonitor.json b/tests/fixtures/openhardwaremonitor.json new file mode 100644 index 00000000000..13c5b5481e0 --- /dev/null +++ b/tests/fixtures/openhardwaremonitor.json @@ -0,0 +1,571 @@ +{ + "id": 0, + "Text": "Sensor", + "Children": [ + { + "id": 1, + "Text": "TEST-PC", + "Children": [ + { + "id": 2, + "Text": "ASUS PRIME Z270-P", + "Children": [], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/mainboard.png" + }, + { + "id": 3, + "Text": "Intel Core i7-7700", + "Children": [ + { + "id": 4, + "Text": "Clocks", + "Children": [ + { + "id": 5, + "Text": "Bus Speed", + "Children": [], + "Min": "100 MHz", + "Value": "100 MHz", + "Max": "100 MHz", + "ImageURL": "images/transparent.png" + }, + { + "id": 6, + "Text": "CPU Core #1", + "Children": [], + "Min": "800 MHz", + "Value": "800 MHz", + "Max": "4200 MHz", + "ImageURL": "images/transparent.png" + }, + { + "id": 7, + "Text": "CPU Core #2", + "Children": [], + "Min": "800 MHz", + "Value": "800 MHz", + "Max": "4200 MHz", + "ImageURL": "images/transparent.png" + }, + { + "id": 8, + "Text": "CPU Core #3", + "Children": [], + "Min": "800 MHz", + "Value": "800 MHz", + "Max": "4200 MHz", + "ImageURL": "images/transparent.png" + }, + { + "id": 9, + "Text": "CPU Core #4", + "Children": [], + "Min": "800 MHz", + "Value": "800 MHz", + "Max": "4200 MHz", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/clock.png" + }, + { + "id": 10, + "Text": "Temperatures", + "Children": [ + { + "id": 11, + "Text": "CPU Core #1", + "Children": [], + "Min": "29.0 °C", + "Value": "31.0 °C", + "Max": "60.0 °C", + "ImageURL": "images/transparent.png" + }, + { + "id": 12, + "Text": "CPU Core #2", + "Children": [], + "Min": "29.0 °C", + "Value": "30.0 °C", + "Max": "61.0 °C", + "ImageURL": "images/transparent.png" + }, + { + "id": 13, + "Text": "CPU Core #3", + "Children": [], + "Min": "28.0 °C", + "Value": "29.0 °C", + "Max": "58.0 °C", + "ImageURL": "images/transparent.png" + }, + { + "id": 14, + "Text": "CPU Core #4", + "Children": [], + "Min": "29.0 °C", + "Value": "31.0 °C", + "Max": "57.0 °C", + "ImageURL": "images/transparent.png" + }, + { + "id": 15, + "Text": "CPU Package", + "Children": [], + "Min": "30.0 °C", + "Value": "31.0 °C", + "Max": "61.0 °C", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/temperature.png" + }, + { + "id": 16, + "Text": "Load", + "Children": [ + { + "id": 17, + "Text": "CPU Total", + "Children": [], + "Min": "0.0 %", + "Value": "1.0 %", + "Max": "42.2 %", + "ImageURL": "images/transparent.png" + }, + { + "id": 18, + "Text": "CPU Core #1", + "Children": [], + "Min": "0.0 %", + "Value": "1.6 %", + "Max": "50.8 %", + "ImageURL": "images/transparent.png" + }, + { + "id": 19, + "Text": "CPU Core #2", + "Children": [], + "Min": "0.0 %", + "Value": "1.6 %", + "Max": "52.0 %", + "ImageURL": "images/transparent.png" + }, + { + "id": 20, + "Text": "CPU Core #3", + "Children": [], + "Min": "0.0 %", + "Value": "0.0 %", + "Max": "52.2 %", + "ImageURL": "images/transparent.png" + }, + { + "id": 21, + "Text": "CPU Core #4", + "Children": [], + "Min": "0.0 %", + "Value": "0.8 %", + "Max": "51.8 %", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/load.png" + }, + { + "id": 22, + "Text": "Powers", + "Children": [ + { + "id": 23, + "Text": "CPU Package", + "Children": [], + "Min": "4.4 W", + "Value": "12.1 W", + "Max": "44.6 W", + "ImageURL": "images/transparent.png" + }, + { + "id": 24, + "Text": "CPU Cores", + "Children": [], + "Min": "0.9 W", + "Value": "1.0 W", + "Max": "33.5 W", + "ImageURL": "images/transparent.png" + }, + { + "id": 25, + "Text": "CPU Graphics", + "Children": [], + "Min": "0.0 W", + "Value": "0.0 W", + "Max": "0.0 W", + "ImageURL": "images/transparent.png" + }, + { + "id": 26, + "Text": "CPU DRAM", + "Children": [], + "Min": "1.0 W", + "Value": "1.0 W", + "Max": "2.4 W", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/power.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/cpu.png" + }, + { + "id": 27, + "Text": "Generic Memory", + "Children": [ + { + "id": 28, + "Text": "Load", + "Children": [ + { + "id": 29, + "Text": "Memory", + "Children": [], + "Min": "13.1 %", + "Value": "13.6 %", + "Max": "14.5 %", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/load.png" + }, + { + "id": 30, + "Text": "Data", + "Children": [ + { + "id": 31, + "Text": "Used Memory", + "Children": [], + "Min": "4.2 GB", + "Value": "4.3 GB", + "Max": "4.6 GB", + "ImageURL": "images/transparent.png" + }, + { + "id": 32, + "Text": "Available Memory", + "Children": [], + "Min": "27.2 GB", + "Value": "27.5 GB", + "Max": "27.7 GB", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/power.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/ram.png" + }, + { + "id": 33, + "Text": "NVIDIA GeForce GTX 1080", + "Children": [ + { + "id": 34, + "Text": "Clocks", + "Children": [ + { + "id": 35, + "Text": "GPU Core", + "Children": [], + "Min": "215 MHz", + "Value": "215 MHz", + "Max": "1683 MHz", + "ImageURL": "images/transparent.png" + }, + { + "id": 36, + "Text": "GPU Memory", + "Children": [], + "Min": "405 MHz", + "Value": "405 MHz", + "Max": "5006 MHz", + "ImageURL": "images/transparent.png" + }, + { + "id": 37, + "Text": "GPU Shader", + "Children": [], + "Min": "430 MHz", + "Value": "430 MHz", + "Max": "3366 MHz", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/clock.png" + }, + { + "id": 38, + "Text": "Temperatures", + "Children": [ + { + "id": 39, + "Text": "GPU Core", + "Children": [], + "Min": "38.0 °C", + "Value": "39.0 °C", + "Max": "42.0 °C", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/temperature.png" + }, + { + "id": 40, + "Text": "Load", + "Children": [ + { + "id": 41, + "Text": "GPU Core", + "Children": [], + "Min": "0.0 %", + "Value": "0.0 %", + "Max": "19.0 %", + "ImageURL": "images/transparent.png" + }, + { + "id": 42, + "Text": "GPU Memory Controller", + "Children": [], + "Min": "0.0 %", + "Value": "0.0 %", + "Max": "2.0 %", + "ImageURL": "images/transparent.png" + }, + { + "id": 43, + "Text": "GPU Video Engine", + "Children": [], + "Min": "0.0 %", + "Value": "0.0 %", + "Max": "0.0 %", + "ImageURL": "images/transparent.png" + }, + { + "id": 44, + "Text": "GPU Memory", + "Children": [], + "Min": "3.9 %", + "Value": "3.9 %", + "Max": "4.1 %", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/load.png" + }, + { + "id": 45, + "Text": "Fans", + "Children": [ + { + "id": 46, + "Text": "GPU", + "Children": [], + "Min": "0 RPM", + "Value": "0 RPM", + "Max": "0 RPM", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/fan.png" + }, + { + "id": 47, + "Text": "Controls", + "Children": [ + { + "id": 48, + "Text": "GPU Fan", + "Children": [], + "Min": "0.0 %", + "Value": "0.0 %", + "Max": "0.0 %", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/control.png" + }, + { + "id": 49, + "Text": "Data", + "Children": [ + { + "id": 50, + "Text": "GPU Memory Free", + "Children": [], + "Min": "7854.8 MB", + "Value": "7873.1 MB", + "Max": "7873.1 MB", + "ImageURL": "images/transparent.png" + }, + { + "id": 51, + "Text": "GPU Memory Used", + "Children": [], + "Min": "318.9 MB", + "Value": "318.9 MB", + "Max": "337.2 MB", + "ImageURL": "images/transparent.png" + }, + { + "id": 52, + "Text": "GPU Memory Total", + "Children": [], + "Min": "8192.0 MB", + "Value": "8192.0 MB", + "Max": "8192.0 MB", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/power.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/nvidia.png" + }, + { + "id": 53, + "Text": "Generic Hard Disk", + "Children": [ + { + "id": 54, + "Text": "Load", + "Children": [ + { + "id": 55, + "Text": "Used Space", + "Children": [], + "Min": "74.6 %", + "Value": "75.3 %", + "Max": "75.6 %", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/load.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/hdd.png" + }, + { + "id": 56, + "Text": "WDC WD30EZRZ-00Z5HB0", + "Children": [ + { + "id": 57, + "Text": "Temperatures", + "Children": [ + { + "id": 58, + "Text": "Temperature", + "Children": [], + "Min": "30.0 °C", + "Value": "30.0 °C", + "Max": "32.0 °C", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/temperature.png" + }, + { + "id": 59, + "Text": "Load", + "Children": [ + { + "id": 60, + "Text": "Used Space", + "Children": [], + "Min": "14.4 %", + "Value": "14.4 %", + "Max": "14.4 %", + "ImageURL": "images/transparent.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/load.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/hdd.png" + } + ], + "Min": "", + "Value": "", + "Max": "", + "ImageURL": "images_icon/computer.png" + } + ], + "Min": "Min", + "Value": "Value", + "Max": "Max", + "ImageURL": "" +} \ No newline at end of file diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index f68090358c7..11717c75e20 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -9,6 +9,7 @@ from datetime import timedelta import homeassistant.core as ha import homeassistant.loader as loader +from homeassistant.exceptions import PlatformNotReady from homeassistant.components import group from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.entity_component import ( @@ -21,7 +22,7 @@ import homeassistant.util.dt as dt_util from tests.common import ( get_test_home_assistant, MockPlatform, MockModule, fire_time_changed, - mock_coro) + mock_coro, async_fire_time_changed) _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" @@ -533,3 +534,47 @@ def test_extract_from_service_available_device(hass): assert ['test_domain.test_3'] == \ sorted(ent.entity_id for ent in component.async_extract_from_service(call_2)) + + +@asyncio.coroutine +def test_platform_not_ready(hass): + """Test that we retry when platform not ready.""" + platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, + None]) + loader.set_component('test_domain.mod1', MockPlatform(platform1_setup)) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + + yield from component.async_setup({ + DOMAIN: { + 'platform': 'mod1' + } + }) + + assert len(platform1_setup.mock_calls) == 1 + assert 'test_domain.mod1' not in hass.config.components + + utcnow = dt_util.utcnow() + + with patch('homeassistant.util.dt.utcnow', return_value=utcnow): + # Should not trigger attempt 2 + async_fire_time_changed(hass, utcnow + timedelta(seconds=29)) + yield from hass.async_block_till_done() + assert len(platform1_setup.mock_calls) == 1 + + # Should trigger attempt 2 + async_fire_time_changed(hass, utcnow + timedelta(seconds=30)) + yield from hass.async_block_till_done() + assert len(platform1_setup.mock_calls) == 2 + assert 'test_domain.mod1' not in hass.config.components + + # This should not trigger attempt 3 + async_fire_time_changed(hass, utcnow + timedelta(seconds=59)) + yield from hass.async_block_till_done() + assert len(platform1_setup.mock_calls) == 2 + + # Trigger attempt 3, which succeeds + async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) + yield from hass.async_block_till_done() + assert len(platform1_setup.mock_calls) == 3 + assert 'test_domain.mod1' in hass.config.components diff --git a/tests/test_config.py b/tests/test_config.py index 2686b597554..00b631a2f78 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -20,6 +20,8 @@ from homeassistant.util.async import run_coroutine_threadsafe from homeassistant.helpers.entity import Entity from homeassistant.components.config.group import ( CONFIG_PATH as GROUP_CONFIG_PATH) +from homeassistant.components.config.automation import ( + CONFIG_PATH as AUTOMATIONS_CONFIG_PATH) from tests.common import ( get_test_config_dir, get_test_home_assistant, mock_coro) @@ -28,6 +30,7 @@ CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) GROUP_PATH = os.path.join(CONFIG_DIR, GROUP_CONFIG_PATH) +AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, AUTOMATIONS_CONFIG_PATH) ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -59,6 +62,9 @@ class TestConfig(unittest.TestCase): if os.path.isfile(GROUP_PATH): os.remove(GROUP_PATH) + if os.path.isfile(AUTOMATIONS_PATH): + os.remove(AUTOMATIONS_PATH) + self.hass.stop() def test_create_default_config(self): @@ -68,6 +74,7 @@ class TestConfig(unittest.TestCase): assert os.path.isfile(YAML_PATH) assert os.path.isfile(VERSION_PATH) assert os.path.isfile(GROUP_PATH) + assert os.path.isfile(AUTOMATIONS_PATH) def test_find_config_file_yaml(self): """Test if it finds a YAML config file.""" @@ -363,6 +370,7 @@ class TestConfig(unittest.TestCase): 'name': 'Huis', CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, 'time_zone': 'America/New_York', + 'whitelist_external_dirs': '/tmp', }), self.hass.loop).result() assert self.hass.config.latitude == 60 @@ -371,6 +379,8 @@ class TestConfig(unittest.TestCase): assert self.hass.config.location_name == 'Huis' assert self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL assert self.hass.config.time_zone.zone == 'America/New_York' + assert len(self.hass.config.whitelist_external_dirs) == 2 + assert '/tmp' in self.hass.config.whitelist_external_dirs def test_loading_configuration_temperature_unit(self): """Test backward compatibility when loading core config.""" @@ -428,6 +438,7 @@ class TestConfig(unittest.TestCase): mock_elevation): """Test config remains unchanged if discovery fails.""" self.hass.config = Config() + self.hass.config.config_dir = "/test/config" run_coroutine_threadsafe( config_util.async_process_ha_core_config( @@ -441,6 +452,8 @@ class TestConfig(unittest.TestCase): assert self.hass.config.location_name == blankConfig.location_name assert self.hass.config.units == blankConfig.units assert self.hass.config.time_zone == blankConfig.time_zone + assert len(self.hass.config.whitelist_external_dirs) == 1 + assert "/test/config/www" in self.hass.config.whitelist_external_dirs @mock.patch('asyncio.create_subprocess_exec') def test_check_ha_config_file_correct(self, mock_create): diff --git a/tests/test_core.py b/tests/test_core.py index 89ae6c5f651..f173ad65c41 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,11 +1,13 @@ """Test to verify that Home Assistant core works.""" # pylint: disable=protected-access import asyncio +import logging +import os import unittest from unittest.mock import patch, MagicMock, sentinel from datetime import datetime, timedelta +from tempfile import TemporaryDirectory -import logging import pytz import pytest @@ -796,11 +798,41 @@ class TestConfig(unittest.TestCase): 'time_zone': 'UTC', 'components': set(), 'config_dir': '/tmp/ha-config', + 'whitelist_external_dirs': set(), 'version': __version__, } self.assertEqual(expected, self.config.as_dict()) + def test_is_allowed_path(self): + """Test is_allowed_path method.""" + with TemporaryDirectory() as tmp_dir: + self.config.whitelist_external_dirs = set(( + tmp_dir, + )) + + test_file = os.path.join(tmp_dir, "test.jpg") + with open(test_file, "w") as tmp_file: + tmp_file.write("test") + + valid = [ + test_file, + ] + for path in valid: + assert self.config.is_allowed_path(path) + + self.config.whitelist_external_dirs = set(('/home',)) + + unvalid = [ + "/hass/config/secure", + "/etc/passwd", + "/root/secure_file", + "/hass/config/test/../../../etc/passwd", + test_file, + ] + for path in unvalid: + assert not self.config.is_allowed_path(path) + @patch('homeassistant.core.monotonic') def test_create_timer(mock_monotonic, loop): diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 707e61fa37e..c92294ccd11 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -25,8 +25,12 @@ RUN virtualization/Docker/setup_docker_prereqs # Install hass component dependencies COPY requirements_all.txt requirements_all.txt + +# Uninstall enum34 because some depenndecies install it but breaks Python 3.4+. +# See PR #8103 for more info. RUN pip3 install --no-cache-dir -r requirements_all.txt && \ - pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet + pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet && \ + pip3 uninstall -y enum34 # BEGIN: Development additions diff --git a/virtualization/Docker/scripts/coap_client b/virtualization/Docker/scripts/coap_client index fa16d14c0cb..82606c5f14d 100755 --- a/virtualization/Docker/scripts/coap_client +++ b/virtualization/Docker/scripts/coap_client @@ -6,6 +6,9 @@ set -e apt-get install -y --no-install-recommends git autoconf automake libtool +cd /usr/src/app/ +mkdir -p build && cd build + git clone --depth 1 --recursive -b dtls https://github.com/home-assistant/libcoap.git cd libcoap ./autogen.sh diff --git a/virtualization/Docker/scripts/libcec b/virtualization/Docker/scripts/libcec index 44e90c40030..56cbaeb16d7 100755 --- a/virtualization/Docker/scripts/libcec +++ b/virtualization/Docker/scripts/libcec @@ -12,7 +12,7 @@ PYTHON_LDLIBRARY=$(python -c 'from distutils import sysconfig; print(sysconfig.g PYTHON_LIBRARY="${PYTHON_LIBDIR}/${PYTHON_LDLIBRARY}" PYTHON_INCLUDE_DIR=$(python -c 'from distutils import sysconfig; print(sysconfig.get_python_inc())') -cd "$(dirname "$0")/.." +cd /usr/src/app/ mkdir -p build && cd build if [ ! -d libcec ]; then diff --git a/virtualization/Docker/scripts/openalpr b/virtualization/Docker/scripts/openalpr index b8fe8d44338..5ee36ecd4ed 100755 --- a/virtualization/Docker/scripts/openalpr +++ b/virtualization/Docker/scripts/openalpr @@ -11,11 +11,14 @@ PACKAGES=( apt-get install -y --no-install-recommends ${PACKAGES[@]} +cd /usr/src/app/ +mkdir -p build && cd build + # Clone the latest code from GitHub -git clone --depth 1 https://github.com/openalpr/openalpr.git /usr/local/src/openalpr +git clone --depth 1 https://github.com/openalpr/openalpr.git openalpr # Setup the build directory -cd /usr/local/src/openalpr/src +cd openalpr/src/ mkdir -p build cd build diff --git a/virtualization/Docker/scripts/phantomjs b/virtualization/Docker/scripts/phantomjs index 7c1e1dd3536..7700b08f293 100755 --- a/virtualization/Docker/scripts/phantomjs +++ b/virtualization/Docker/scripts/phantomjs @@ -6,7 +6,7 @@ set -e PHANTOMJS_VERSION="2.1.1" -cd "$(dirname "$0")/.." +cd /usr/src/app/ mkdir -p build && cd build curl -LSO https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 diff --git a/virtualization/Docker/scripts/ssocr b/virtualization/Docker/scripts/ssocr index 137f20d70c2..7054951407a 100755 --- a/virtualization/Docker/scripts/ssocr +++ b/virtualization/Docker/scripts/ssocr @@ -10,20 +10,15 @@ PACKAGES=( apt-get install -y --no-install-recommends ${PACKAGES[@]} +cd /usr/src/app/ +mkdir -p build && cd build + # Clone the latest code from GitHub -git clone --depth 1 https://github.com/auerswal/ssocr.git /usr/local/src/ssocr +git clone --depth 1 https://github.com/auerswal/ssocr.git ssocr +cd ssocr/ -# Build ssocr -( - # Setup the build directory - cd /usr/local/src/ssocr +# Compile the library +make - # compile the library - make - - # Install the binaries/libraries to your local system (prefix is /usr/local) - make install - - # Cleanup - make clean -) +# Install the binaries/libraries to your local system (prefix is /usr/local) +make install \ No newline at end of file diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs index e3a2dd4acf5..91bb9888765 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -30,8 +30,8 @@ PACKAGES=( # Required debian packages for building dependencies PACKAGES_DEV=( - cmake git - # libcec + cmake + git swig ) @@ -73,4 +73,4 @@ apt-get -y --purge autoremove # Cleanup apt-get clean -rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* build/ +rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/src/app/build/