diff --git a/.coveragerc b/.coveragerc index d5eb32e670c..60375fbb97e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -52,6 +52,9 @@ omit = homeassistant/components/digital_ocean.py homeassistant/components/*/digital_ocean.py + + homeassistant/components/doorbird.py + homeassistant/components/*/doorbird.py homeassistant/components/dweet.py homeassistant/components/*/dweet.py @@ -158,6 +161,9 @@ omit = homeassistant/components/rpi_pfio.py homeassistant/components/*/rpi_pfio.py + homeassistant/components/satel_integra.py + homeassistant/components/*/satel_integra.py + homeassistant/components/scsgate.py homeassistant/components/*/scsgate.py @@ -208,12 +214,12 @@ omit = homeassistant/components/wink.py homeassistant/components/*/wink.py - homeassistant/components/xiaomi.py - homeassistant/components/binary_sensor/xiaomi.py - homeassistant/components/cover/xiaomi.py - homeassistant/components/light/xiaomi.py - homeassistant/components/sensor/xiaomi.py - homeassistant/components/switch/xiaomi.py + homeassistant/components/xiaomi_aqara.py + homeassistant/components/binary_sensor/xiaomi_aqara.py + homeassistant/components/cover/xiaomi_aqara.py + homeassistant/components/light/xiaomi_aqara.py + homeassistant/components/sensor/xiaomi_aqara.py + homeassistant/components/switch/xiaomi_aqara.py homeassistant/components/zabbix.py homeassistant/components/*/zabbix.py @@ -247,6 +253,7 @@ omit = homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py homeassistant/components/browser.py + homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py @@ -283,6 +290,7 @@ omit = homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/huawei_router.py homeassistant/components/device_tracker/icloud.py + homeassistant/components/device_tracker/keenetic_ndms2.py homeassistant/components/device_tracker/linksys_ap.py homeassistant/components/device_tracker/linksys_smart.py homeassistant/components/device_tracker/luci.py @@ -331,7 +339,7 @@ omit = homeassistant/components/light/tplink.py homeassistant/components/light/tradfri.py homeassistant/components/light/x10.py - homeassistant/components/light/xiaomi_philipslight.py + homeassistant/components/light/xiaomi_miio.py homeassistant/components/light/yeelight.py homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py @@ -540,6 +548,7 @@ omit = homeassistant/components/sensor/vasttrafik.py homeassistant/components/sensor/waqi.py homeassistant/components/sensor/worldtidesinfo.py + homeassistant/components/sensor/worxlandroid.py homeassistant/components/sensor/xbox_live.py homeassistant/components/sensor/yweather.py homeassistant/components/sensor/zamg.py @@ -565,6 +574,7 @@ omit = homeassistant/components/switch/rest.py homeassistant/components/switch/rpi_rf.py homeassistant/components/switch/tplink.py + homeassistant/components/switch/telnet.py homeassistant/components/switch/transmission.py homeassistant/components/switch/wake_on_lan.py homeassistant/components/telegram_bot/* @@ -581,6 +591,7 @@ omit = homeassistant/components/weather/zamg.py homeassistant/components/zeroconf.py homeassistant/components/zwave/util.py + homeassistant/components/vacuum/mqtt.py [report] diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 2ce574ca15e..a8852b910c2 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -126,6 +126,12 @@ def get_arguments() -> argparse.Namespace: type=int, default=None, help='Enables daily log rotation and keeps up to the specified days') + parser.add_argument( + '--log-file', + type=str, + default=None, + help='Log file to write to. If not set, CONFIG/home-assistant.log ' + 'is used') parser.add_argument( '--runner', action='store_true', @@ -256,13 +262,14 @@ def setup_and_run_hass(config_dir: str, } hass = bootstrap.from_config_dict( config, config_dir=config_dir, verbose=args.verbose, - skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days) + skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, + log_file=args.log_file) else: config_file = ensure_config_file(config_dir) print('Config directory:', config_dir) hass = bootstrap.from_config_file( config_file, verbose=args.verbose, skip_pip=args.skip_pip, - log_rotate_days=args.log_rotate_days) + log_rotate_days=args.log_rotate_days, log_file=args.log_file) if hass is None: return None diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7831036ff59..3ff4d99fb98 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -27,6 +27,10 @@ from homeassistant.helpers.signal import async_register_signal_handling _LOGGER = logging.getLogger(__name__) ERROR_LOG_FILENAME = 'home-assistant.log' + +# hass.data key for logging information. +DATA_LOGGING = 'logging' + FIRST_INIT_COMPONENT = set(( 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', 'introduction', 'frontend', 'history')) @@ -38,7 +42,8 @@ def from_config_dict(config: Dict[str, Any], enable_log: bool=True, verbose: bool=False, skip_pip: bool=False, - log_rotate_days: Any=None) \ + log_rotate_days: Any=None, + log_file: Any=None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -56,7 +61,7 @@ def from_config_dict(config: Dict[str, Any], hass = hass.loop.run_until_complete( async_from_config_dict( config, hass, config_dir, enable_log, verbose, skip_pip, - log_rotate_days) + log_rotate_days, log_file) ) return hass @@ -69,7 +74,8 @@ def async_from_config_dict(config: Dict[str, Any], enable_log: bool=True, verbose: bool=False, skip_pip: bool=False, - log_rotate_days: Any=None) \ + log_rotate_days: Any=None, + log_file: Any=None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -88,7 +94,7 @@ def async_from_config_dict(config: Dict[str, Any], yield from hass.async_add_job(conf_util.process_ha_config_upgrade, hass) if enable_log: - async_enable_logging(hass, verbose, log_rotate_days) + async_enable_logging(hass, verbose, log_rotate_days, log_file) hass.config.skip_pip = skip_pip if skip_pip: @@ -153,7 +159,8 @@ def from_config_file(config_path: str, hass: Optional[core.HomeAssistant]=None, verbose: bool=False, skip_pip: bool=True, - log_rotate_days: Any=None): + log_rotate_days: Any=None, + log_file: Any=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -165,7 +172,7 @@ def from_config_file(config_path: str, # run task hass = hass.loop.run_until_complete( async_from_config_file( - config_path, hass, verbose, skip_pip, log_rotate_days) + config_path, hass, verbose, skip_pip, log_rotate_days, log_file) ) return hass @@ -176,7 +183,8 @@ def async_from_config_file(config_path: str, hass: core.HomeAssistant, verbose: bool=False, skip_pip: bool=True, - log_rotate_days: Any=None): + log_rotate_days: Any=None, + log_file: Any=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -187,7 +195,7 @@ def async_from_config_file(config_path: str, hass.config.config_dir = config_dir yield from async_mount_local_lib_path(config_dir, hass.loop) - async_enable_logging(hass, verbose, log_rotate_days) + async_enable_logging(hass, verbose, log_rotate_days, log_file) try: config_dict = yield from hass.async_add_job( @@ -205,7 +213,7 @@ def async_from_config_file(config_path: str, @core.callback def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, - log_rotate_days=None) -> None: + log_rotate_days=None, log_file=None) -> None: """Set up the logging. This method must be run in the event loop. @@ -239,13 +247,18 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, pass # Log errors to a file if we have write access to file or config dir - err_log_path = hass.config.path(ERROR_LOG_FILENAME) + if log_file is None: + err_log_path = hass.config.path(ERROR_LOG_FILENAME) + else: + err_log_path = os.path.abspath(log_file) + err_path_exists = os.path.isfile(err_log_path) + err_dir = os.path.dirname(err_log_path) # Check if we can write to the error log if it exists or that # we can create files in the containing directory if not. if (err_path_exists and os.access(err_log_path, os.W_OK)) or \ - (not err_path_exists and os.access(hass.config.config_dir, os.W_OK)): + (not err_path_exists and os.access(err_dir, os.W_OK)): if log_rotate_days: err_handler = logging.handlers.TimedRotatingFileHandler( @@ -272,6 +285,8 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, logger.addHandler(async_handler) logger.setLevel(logging.INFO) + # Save the log file location for access by other components. + hass.data[DATA_LOGGING] = err_log_path else: _LOGGER.error( "Unable to setup error log %s (access denied)", err_log_path) diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index f3283eff748..fe35d7b1b8b 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -6,57 +6,138 @@ https://home-assistant.io/components/abode/ """ import asyncio import logging +from functools import partial +from os import path import voluptuous as vol from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.const import (ATTR_ATTRIBUTION, - CONF_USERNAME, CONF_PASSWORD, - CONF_NAME, EVENT_HOMEASSISTANT_STOP, +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, + ATTR_ENTITY_ID, CONF_USERNAME, CONF_PASSWORD, + CONF_EXCLUDE, CONF_NAME, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['abodepy==0.9.0'] +REQUIREMENTS = ['abodepy==0.11.8'] _LOGGER = logging.getLogger(__name__) CONF_ATTRIBUTION = "Data provided by goabode.com" +CONF_LIGHTS = "lights" +CONF_POLLING = "polling" DOMAIN = 'abode' -DEFAULT_NAME = 'Abode' -DATA_ABODE = 'abode' NOTIFICATION_ID = 'abode_notification' NOTIFICATION_TITLE = 'Abode Security Setup' +EVENT_ABODE_ALARM = 'abode_alarm' +EVENT_ABODE_ALARM_END = 'abode_alarm_end' +EVENT_ABODE_AUTOMATION = 'abode_automation' +EVENT_ABODE_FAULT = 'abode_panel_fault' +EVENT_ABODE_RESTORE = 'abode_panel_restore' + +SERVICE_SETTINGS = 'change_setting' +SERVICE_CAPTURE_IMAGE = 'capture_image' +SERVICE_TRIGGER = 'trigger_quick_action' + +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_NAME = 'device_name' +ATTR_DEVICE_TYPE = 'device_type' +ATTR_EVENT_CODE = 'event_code' +ATTR_EVENT_NAME = 'event_name' +ATTR_EVENT_TYPE = 'event_type' +ATTR_EVENT_UTC = 'event_utc' +ATTR_SETTING = 'setting' +ATTR_USER_NAME = 'user_name' +ATTR_VALUE = 'value' + +ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str]) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POLLING, default=False): cv.boolean, + vol.Optional(CONF_EXCLUDE, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA, + vol.Optional(CONF_LIGHTS, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA }), }, extra=vol.ALLOW_EXTRA) +CHANGE_SETTING_SCHEMA = vol.Schema({ + vol.Required(ATTR_SETTING): cv.string, + vol.Required(ATTR_VALUE): cv.string +}) + +CAPTURE_IMAGE_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + +TRIGGER_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + ABODE_PLATFORMS = [ - 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover' + 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover', + 'camera', 'light' ] +class AbodeSystem(object): + """Abode System class.""" + + def __init__(self, username, password, name, polling, exclude, lights): + """Initialize the system.""" + import abodepy + self.abode = abodepy.Abode(username, password, + auto_login=True, + get_devices=True, + get_automations=True) + self.name = name + self.polling = polling + self.exclude = exclude + self.lights = lights + self.devices = [] + + def is_excluded(self, device): + """Check if a device is configured to be excluded.""" + return device.device_id in self.exclude + + def is_automation_excluded(self, automation): + """Check if an automation is configured to be excluded.""" + return automation.automation_id in self.exclude + + def is_light(self, device): + """Check if a switch device is configured as a light.""" + import abodepy.helpers.constants as CONST + + return (device.generic_type == CONST.TYPE_LIGHT or + (device.generic_type == CONST.TYPE_SWITCH and + device.device_id in self.lights)) + + def setup(hass, config): """Set up Abode component.""" - import abodepy + from abodepy.exceptions import AbodeException conf = config[DOMAIN] username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) + name = conf.get(CONF_NAME) + polling = conf.get(CONF_POLLING) + exclude = conf.get(CONF_EXCLUDE) + lights = conf.get(CONF_LIGHTS) try: - hass.data[DATA_ABODE] = abode = abodepy.Abode( - username, password, auto_login=True, get_devices=True) - - except (ConnectTimeout, HTTPError) as ex: + hass.data[DOMAIN] = AbodeSystem( + username, password, name, polling, exclude, lights) + except (AbodeException, ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Abode: %s", str(ex)) + hass.components.persistent_notification.create( 'Error: {}
' 'You will need to restart hass after fixing.' @@ -65,46 +146,144 @@ def setup(hass, config): notification_id=NOTIFICATION_ID) return False + setup_hass_services(hass) + setup_hass_events(hass) + setup_abode_events(hass) + for platform in ABODE_PLATFORMS: discovery.load_platform(hass, platform, DOMAIN, {}, config) + return True + + +def setup_hass_services(hass): + """Home assistant services.""" + from abodepy.exceptions import AbodeException + + def change_setting(call): + """Change an Abode system setting.""" + setting = call.data.get(ATTR_SETTING) + value = call.data.get(ATTR_VALUE) + + try: + hass.data[DOMAIN].abode.set_setting(setting, value) + except AbodeException as ex: + _LOGGER.warning(ex) + + def capture_image(call): + """Capture a new image.""" + entity_ids = call.data.get(ATTR_ENTITY_ID) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.capture() + + def trigger_quick_action(call): + """Trigger a quick action.""" + entity_ids = call.data.get(ATTR_ENTITY_ID, None) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.trigger() + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml'))[DOMAIN] + + hass.services.register( + DOMAIN, SERVICE_SETTINGS, change_setting, + descriptions.get(SERVICE_SETTINGS), + schema=CHANGE_SETTING_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, + descriptions.get(SERVICE_CAPTURE_IMAGE), + schema=CAPTURE_IMAGE_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_TRIGGER, trigger_quick_action, + descriptions.get(SERVICE_TRIGGER), + schema=TRIGGER_SCHEMA) + + +def setup_hass_events(hass): + """Home assistant start and stop callbacks.""" + def startup(event): + """Listen for push events.""" + hass.data[DOMAIN].abode.events.start() + def logout(event): """Logout of Abode.""" - abode.stop_listener() - abode.logout() + if not hass.data[DOMAIN].polling: + hass.data[DOMAIN].abode.events.stop() + + hass.data[DOMAIN].abode.logout() _LOGGER.info("Logged out of Abode") + if not hass.data[DOMAIN].polling: + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout) - def startup(event): - """Listen for push events.""" - abode.start_listener() - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) +def setup_abode_events(hass): + """Event callbacks.""" + import abodepy.helpers.timeline as TIMELINE - return True + def event_callback(event, event_json): + """Handle an event callback from Abode.""" + data = { + ATTR_DEVICE_ID: event_json.get(ATTR_DEVICE_ID, ''), + ATTR_DEVICE_NAME: event_json.get(ATTR_DEVICE_NAME, ''), + ATTR_DEVICE_TYPE: event_json.get(ATTR_DEVICE_TYPE, ''), + ATTR_EVENT_CODE: event_json.get(ATTR_EVENT_CODE, ''), + ATTR_EVENT_NAME: event_json.get(ATTR_EVENT_NAME, ''), + ATTR_EVENT_TYPE: event_json.get(ATTR_EVENT_TYPE, ''), + ATTR_EVENT_UTC: event_json.get(ATTR_EVENT_UTC, ''), + ATTR_USER_NAME: event_json.get(ATTR_USER_NAME, ''), + ATTR_DATE: event_json.get(ATTR_DATE, ''), + ATTR_TIME: event_json.get(ATTR_TIME, ''), + } + + hass.bus.fire(event, data) + + events = [TIMELINE.ALARM_GROUP, TIMELINE.ALARM_END_GROUP, + TIMELINE.PANEL_FAULT_GROUP, TIMELINE.PANEL_RESTORE_GROUP, + TIMELINE.AUTOMATION_GROUP] + + for event in events: + hass.data[DOMAIN].abode.events.add_event_callback( + event, + partial(event_callback, event)) class AbodeDevice(Entity): """Representation of an Abode device.""" - def __init__(self, controller, device): + def __init__(self, data, device): """Initialize a sensor for Abode device.""" - self._controller = controller + self._data = data self._device = device @asyncio.coroutine def async_added_to_hass(self): """Subscribe Abode events.""" self.hass.async_add_job( - self._controller.register, self._device, - self._update_callback + self._data.abode.events.add_device_callback, + self._device.device_id, self._update_callback ) @property def should_poll(self): """Return the polling state.""" - return False + return self._data.polling + + def update(self): + """Update automation state.""" + self._device.refresh() @property def name(self): @@ -118,9 +297,58 @@ class AbodeDevice(Entity): ATTR_ATTRIBUTION: CONF_ATTRIBUTION, 'device_id': self._device.device_id, 'battery_low': self._device.battery_low, - 'no_response': self._device.no_response + 'no_response': self._device.no_response, + 'device_type': self._device.type } def _update_callback(self, device): """Update the device state.""" self.schedule_update_ha_state() + + +class AbodeAutomation(Entity): + """Representation of an Abode automation.""" + + def __init__(self, data, automation, event=None): + """Initialize for Abode automation.""" + self._data = data + self._automation = automation + self._event = event + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + if self._event: + self.hass.async_add_job( + self._data.abode.events.add_event_callback, + self._event, self._update_callback + ) + + @property + def should_poll(self): + """Return the polling state.""" + return self._data.polling + + def update(self): + """Update automation state.""" + self._automation.refresh() + + @property + def name(self): + """Return the name of the sensor.""" + return self._automation.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'automation_id': self._automation.automation_id, + 'type': self._automation.type, + 'sub_type': self._automation.sub_type + } + + def _update_callback(self, device): + """Update the device state.""" + self._automation.refresh() + self.schedule_update_ha_state() diff --git a/homeassistant/components/alarm_control_panel/abode.py b/homeassistant/components/alarm_control_panel/abode.py index 7a615ffc7bf..aa4e86a2318 100644 --- a/homeassistant/components/alarm_control_panel/abode.py +++ b/homeassistant/components/alarm_control_panel/abode.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/alarm_control_panel.abode/ import logging from homeassistant.components.abode import ( - AbodeDevice, DATA_ABODE, DEFAULT_NAME, CONF_ATTRIBUTION) + AbodeDevice, DOMAIN as ABODE_DOMAIN, CONF_ATTRIBUTION) from homeassistant.components.alarm_control_panel import (AlarmControlPanel) from homeassistant.const import (ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) @@ -22,18 +22,22 @@ ICON = 'mdi:security' def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - abode = hass.data[DATA_ABODE] + data = hass.data[ABODE_DOMAIN] - add_devices([AbodeAlarm(abode, abode.get_alarm())]) + alarm_devices = [AbodeAlarm(data, data.abode.get_alarm(), data.name)] + + data.devices.extend(alarm_devices) + + add_devices(alarm_devices) class AbodeAlarm(AbodeDevice, AlarmControlPanel): """An alarm_control_panel implementation for Abode.""" - def __init__(self, controller, device): + def __init__(self, data, device, name): """Initialize the alarm control panel.""" - AbodeDevice.__init__(self, controller, device) - self._name = "{0}".format(DEFAULT_NAME) + super().__init__(data, device) + self._name = name @property def icon(self): @@ -65,6 +69,11 @@ class AbodeAlarm(AbodeDevice, AlarmControlPanel): """Send arm away command.""" self._device.set_away() + @property + def name(self): + """Return the name of the alarm.""" + return self._name or super().name + @property def device_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index f54774b8923..3b58eb0b71d 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -57,19 +57,19 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): if message.alarm_sounding or message.fire_alarm: if self._state != STATE_ALARM_TRIGGERED: self._state = STATE_ALARM_TRIGGERED - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() elif message.armed_away: if self._state != STATE_ALARM_ARMED_AWAY: self._state = STATE_ALARM_ARMED_AWAY - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() elif message.armed_home: if self._state != STATE_ALARM_ARMED_HOME: self._state = STATE_ALARM_ARMED_HOME - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() else: if self._state != STATE_ALARM_DISARMED: self._state = STATE_ALARM_DISARMED - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def name(self): diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py index 8ebf0a93c38..00dae5c2779 100644 --- a/homeassistant/components/alarm_control_panel/demo.py +++ b/homeassistant/components/alarm_control_panel/demo.py @@ -5,10 +5,26 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ import homeassistant.components.alarm_control_panel.manual as manual +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_TRIGGERED, CONF_PENDING_TIME) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo alarm control panel platform.""" add_devices([ - manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False), + manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False, { + STATE_ALARM_ARMED_AWAY: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_ARMED_HOME: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_ARMED_NIGHT: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_TRIGGERED: { + CONF_PENDING_TIME: 5 + }, + }), ]) diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index f6d388a6c5b..026d2324ed3 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -106,7 +106,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel): def _update_callback(self, partition): """Update Home Assistant state, if needed.""" if partition is None or int(partition) == self._partition_number: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def code_format(self): diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index f345ccc4dcd..237959ab10d 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -4,6 +4,7 @@ Support for manual alarms. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.manual/ """ +import copy import datetime import logging @@ -24,9 +25,28 @@ DEFAULT_PENDING_TIME = 60 DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False +SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED] + ATTR_POST_PENDING_STATE = 'post_pending_state' -PLATFORM_SCHEMA = vol.Schema({ + +def _state_validator(config): + config = copy.deepcopy(config) + for state in SUPPORTED_PENDING_STATES: + if CONF_PENDING_TIME not in config[state]: + config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + + return config + + +STATE_SETTING_SCHEMA = vol.Schema({ + vol.Optional(CONF_PENDING_TIME): + vol.All(vol.Coerce(int), vol.Range(min=0)) +}) + + +PLATFORM_SCHEMA = vol.Schema(vol.All({ vol.Required(CONF_PLATFORM): 'manual', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, vol.Optional(CONF_CODE): cv.string, @@ -36,7 +56,11 @@ PLATFORM_SCHEMA = vol.Schema({ vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, -}) + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA, +}, _state_validator)) _LOGGER = logging.getLogger(__name__) @@ -49,7 +73,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(CONF_CODE), config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME), config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), - config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER) + config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), + config )]) @@ -63,19 +88,23 @@ class ManualAlarm(alarm.AlarmControlPanel): or disarm if `disarm_after_trigger` is true. """ - def __init__(self, hass, name, code, pending_time, - trigger_time, disarm_after_trigger): + def __init__(self, hass, name, code, pending_time, trigger_time, + disarm_after_trigger, config): """Init the manual alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass self._name = name self._code = str(code) if code else None - self._pending_time = datetime.timedelta(seconds=pending_time) self._trigger_time = datetime.timedelta(seconds=trigger_time) self._disarm_after_trigger = disarm_after_trigger self._pre_trigger_state = self._state self._state_ts = None + self._pending_time_by_state = {} + for state in SUPPORTED_PENDING_STATES: + self._pending_time_by_state[state] = datetime.timedelta( + seconds=config[state][CONF_PENDING_TIME]) + @property def should_poll(self): """Return the plling state.""" @@ -89,17 +118,10 @@ class ManualAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._state in (STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_NIGHT) and \ - self._pending_time and self._state_ts + self._pending_time > \ - dt_util.utcnow(): - return STATE_ALARM_PENDING - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: - if self._state_ts + self._pending_time > dt_util.utcnow(): + if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time + + elif (self._state_ts + self._pending_time_by_state[self._state] + self._trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED @@ -107,8 +129,16 @@ class ManualAlarm(alarm.AlarmControlPanel): self._state = self._pre_trigger_state return self._state + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING + return self._state + def _within_pending_time(self, state): + pending_time = self._pending_time_by_state[state] + return self._state_ts + pending_time > dt_util.utcnow() + @property def code_format(self): """One or more characters.""" @@ -128,58 +158,47 @@ class ManualAlarm(alarm.AlarmControlPanel): if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return - self._state = STATE_ALARM_ARMED_HOME - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return - self._state = STATE_ALARM_ARMED_AWAY - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_AWAY) def alarm_arm_night(self, code=None): """Send arm night command.""" if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): return - self._state = STATE_ALARM_ARMED_NIGHT - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_trigger(self, code=None): """Send alarm trigger command. No code needed.""" self._pre_trigger_state = self._state - self._state = STATE_ALARM_TRIGGERED + + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - if self._trigger_time: + pending_time = self._pending_time_by_state[state] + + if state == STATE_ALARM_TRIGGERED and self._trigger_time: track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._state_ts + pending_time) track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time + self._trigger_time) + self._state_ts + self._trigger_time + pending_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) def _validate_code(self, code, state): """Validate given code.""" diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 33bfe464eea..fca935388c1 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -87,7 +87,7 @@ class MqttAlarm(alarm.AlarmControlPanel): _LOGGER.warning("Received unexpected payload: %s", payload) return self._state = payload - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() return mqtt.async_subscribe( self.hass, self._state_topic, message_received, self._qos) diff --git a/homeassistant/components/alarm_control_panel/satel_integra.py b/homeassistant/components/alarm_control_panel/satel_integra.py new file mode 100644 index 00000000000..6115311f873 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/satel_integra.py @@ -0,0 +1,94 @@ +""" +Support for Satel Integra alarm, using ETHM module: https://www.satel.pl/en/ . + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.satel_integra/ +""" +import asyncio +import logging + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.satel_integra import (CONF_ARM_HOME_MODE, + DATA_SATEL, + SIGNAL_PANEL_MESSAGE) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['satel_integra'] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up for AlarmDecoder alarm panels.""" + if not discovery_info: + return + + device = SatelIntegraAlarmPanel("Alarm Panel", + discovery_info.get(CONF_ARM_HOME_MODE)) + async_add_devices([device]) + + +class SatelIntegraAlarmPanel(alarm.AlarmControlPanel): + """Representation of an AlarmDecoder-based alarm panel.""" + + def __init__(self, name, arm_home_mode): + """Initialize the alarm panel.""" + self._name = name + self._state = None + self._arm_home_mode = arm_home_mode + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + + @callback + def _message_callback(self, message): + + if message != self._state: + self._state = message + self.async_schedule_update_ha_state() + else: + _LOGGER.warning("Ignoring alarm status message, same state") + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def code_format(self): + """Return the regex for code format or None if no code is required.""" + return '^\\d{4,6}$' + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @asyncio.coroutine + def async_alarm_disarm(self, code=None): + """Send disarm command.""" + if code: + yield from self.hass.data[DATA_SATEL].disarm(code) + + @asyncio.coroutine + def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + if code: + yield from self.hass.data[DATA_SATEL].arm(code) + + @asyncio.coroutine + def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + if code: + yield from self.hass.data[DATA_SATEL].arm(code, + self._arm_home_mode) diff --git a/homeassistant/components/alarm_control_panel/spc.py b/homeassistant/components/alarm_control_panel/spc.py index de4d5098b41..1682ef2ae02 100644 --- a/homeassistant/components/alarm_control_panel/spc.py +++ b/homeassistant/components/alarm_control_panel/spc.py @@ -27,20 +27,20 @@ def _get_alarm_state(spc_mode): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the SPC alarm control panel platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_AREAS] is None): return - entities = [SpcAlarm(hass=hass, - area_id=area['id'], - name=area['name'], - state=_get_alarm_state(area['mode'])) - for area in discovery_info[ATTR_DISCOVER_AREAS]] + devices = [SpcAlarm(hass=hass, + area_id=area['id'], + name=area['name'], + state=_get_alarm_state(area['mode'])) + for area in discovery_info[ATTR_DISCOVER_AREAS]] - async_add_entities(entities) + async_add_devices(devices) class SpcAlarm(alarm.AlarmControlPanel): diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py new file mode 100644 index 00000000000..65243aa83ce --- /dev/null +++ b/homeassistant/components/alexa/__init__.py @@ -0,0 +1,52 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from .const import ( + DOMAIN, CONF_UID, CONF_TITLE, CONF_AUDIO, CONF_TEXT, CONF_DISPLAY_URL) +from . import flash_briefings, intent + +_LOGGER = logging.getLogger(__name__) + + +DEPENDENCIES = ['http'] + +CONF_FLASH_BRIEFINGS = 'flash_briefings' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + CONF_FLASH_BRIEFINGS: { + cv.string: vol.All(cv.ensure_list, [{ + vol.Optional(CONF_UID): cv.string, + vol.Required(CONF_TITLE): cv.template, + vol.Optional(CONF_AUDIO): cv.template, + vol.Required(CONF_TEXT, default=""): cv.template, + vol.Optional(CONF_DISPLAY_URL): cv.template, + }]), + } + } +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Activate Alexa component.""" + config = config.get(DOMAIN, {}) + flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS) + + intent.async_setup(hass) + + if flash_briefings_config: + flash_briefings.async_setup(hass, flash_briefings_config) + + return True diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py new file mode 100644 index 00000000000..9550b6dbade --- /dev/null +++ b/homeassistant/components/alexa/const.py @@ -0,0 +1,18 @@ +"""Constants for the Alexa integration.""" +DOMAIN = 'alexa' + +# Flash briefing constants +CONF_UID = 'uid' +CONF_TITLE = 'title' +CONF_AUDIO = 'audio' +CONF_TEXT = 'text' +CONF_DISPLAY_URL = 'display_url' + +ATTR_UID = 'uid' +ATTR_UPDATE_DATE = 'updateDate' +ATTR_TITLE_TEXT = 'titleText' +ATTR_STREAM_URL = 'streamUrl' +ATTR_MAIN_TEXT = 'mainText' +ATTR_REDIRECTION_URL = 'redirectionURL' + +DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py new file mode 100644 index 00000000000..ec7e3521c0a --- /dev/null +++ b/homeassistant/components/alexa/flash_briefings.py @@ -0,0 +1,96 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import copy +import logging +from datetime import datetime +import uuid + +from homeassistant.core import callback +from homeassistant.helpers import template +from homeassistant.components import http + +from .const import ( + CONF_UID, CONF_TITLE, CONF_AUDIO, CONF_TEXT, CONF_DISPLAY_URL, ATTR_UID, + ATTR_UPDATE_DATE, ATTR_TITLE_TEXT, ATTR_STREAM_URL, ATTR_MAIN_TEXT, + ATTR_REDIRECTION_URL, DATE_FORMAT) + + +_LOGGER = logging.getLogger(__name__) + +FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' + + +@callback +def async_setup(hass, flash_briefing_config): + """Activate Alexa component.""" + hass.http.register_view( + AlexaFlashBriefingView(hass, flash_briefing_config)) + + +class AlexaFlashBriefingView(http.HomeAssistantView): + """Handle Alexa Flash Briefing skill requests.""" + + url = FLASH_BRIEFINGS_API_ENDPOINT + name = 'api:alexa:flash_briefings' + + def __init__(self, hass, flash_briefings): + """Initialize Alexa view.""" + super().__init__() + self.flash_briefings = copy.deepcopy(flash_briefings) + template.attach(hass, self.flash_briefings) + + @callback + def get(self, request, briefing_id): + """Handle Alexa Flash Briefing request.""" + _LOGGER.debug('Received Alexa flash briefing request for: %s', + briefing_id) + + if self.flash_briefings.get(briefing_id) is None: + err = 'No configured Alexa flash briefing was found for: %s' + _LOGGER.error(err, briefing_id) + return b'', 404 + + briefing = [] + + for item in self.flash_briefings.get(briefing_id, []): + output = {} + if item.get(CONF_TITLE) is not None: + if isinstance(item.get(CONF_TITLE), template.Template): + output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() + else: + output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) + + if item.get(CONF_TEXT) is not None: + if isinstance(item.get(CONF_TEXT), template.Template): + output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() + else: + output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) + + uid = item.get(CONF_UID) + if uid is None: + uid = str(uuid.uuid4()) + output[ATTR_UID] = uid + + if item.get(CONF_AUDIO) is not None: + if isinstance(item.get(CONF_AUDIO), template.Template): + output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() + else: + output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) + + if item.get(CONF_DISPLAY_URL) is not None: + if isinstance(item.get(CONF_DISPLAY_URL), + template.Template): + output[ATTR_REDIRECTION_URL] = \ + item[CONF_DISPLAY_URL].async_render() + else: + output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) + + output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) + + briefing.append(output) + + return self.json(briefing) diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa/intent.py similarity index 60% rename from homeassistant/components/alexa.py rename to homeassistant/components/alexa/intent.py index 25b6537e255..a0d0062414d 100644 --- a/homeassistant/components/alexa.py +++ b/homeassistant/components/alexa/intent.py @@ -5,52 +5,19 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/alexa/ """ import asyncio -import copy import enum import logging -import uuid -from datetime import datetime - -import voluptuous as vol from homeassistant.core import callback from homeassistant.const import HTTP_BAD_REQUEST -from homeassistant.helpers import intent, template, config_validation as cv +from homeassistant.helpers import intent from homeassistant.components import http -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN INTENTS_API_ENDPOINT = '/api/alexa' -FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' -CONF_ACTION = 'action' -CONF_CARD = 'card' -CONF_INTENTS = 'intents' -CONF_SPEECH = 'speech' - -CONF_TYPE = 'type' -CONF_TITLE = 'title' -CONF_CONTENT = 'content' -CONF_TEXT = 'text' - -CONF_FLASH_BRIEFINGS = 'flash_briefings' -CONF_UID = 'uid' -CONF_TITLE = 'title' -CONF_AUDIO = 'audio' -CONF_TEXT = 'text' -CONF_DISPLAY_URL = 'display_url' - -ATTR_UID = 'uid' -ATTR_UPDATE_DATE = 'updateDate' -ATTR_TITLE_TEXT = 'titleText' -ATTR_STREAM_URL = 'streamUrl' -ATTR_MAIN_TEXT = 'mainText' -ATTR_REDIRECTION_URL = 'redirectionURL' - -DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' - -DOMAIN = 'alexa' -DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) class SpeechType(enum.Enum): @@ -73,30 +40,10 @@ class CardType(enum.Enum): link_account = "LinkAccount" -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: { - CONF_FLASH_BRIEFINGS: { - cv.string: vol.All(cv.ensure_list, [{ - vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string, - vol.Required(CONF_TITLE): cv.template, - vol.Optional(CONF_AUDIO): cv.template, - vol.Required(CONF_TEXT, default=""): cv.template, - vol.Optional(CONF_DISPLAY_URL): cv.template, - }]), - } - } -}, extra=vol.ALLOW_EXTRA) - - -@asyncio.coroutine -def async_setup(hass, config): +@callback +def async_setup(hass): """Activate Alexa component.""" - flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {}) - hass.http.register_view(AlexaIntentsView) - hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings)) - - return True class AlexaIntentsView(http.HomeAssistantView): @@ -255,66 +202,3 @@ class AlexaResponse(object): 'sessionAttributes': self.session_attributes, 'response': response, } - - -class AlexaFlashBriefingView(http.HomeAssistantView): - """Handle Alexa Flash Briefing skill requests.""" - - url = FLASH_BRIEFINGS_API_ENDPOINT - name = 'api:alexa:flash_briefings' - - def __init__(self, hass, flash_briefings): - """Initialize Alexa view.""" - super().__init__() - self.flash_briefings = copy.deepcopy(flash_briefings) - template.attach(hass, self.flash_briefings) - - @callback - def get(self, request, briefing_id): - """Handle Alexa Flash Briefing request.""" - _LOGGER.debug('Received Alexa flash briefing request for: %s', - briefing_id) - - if self.flash_briefings.get(briefing_id) is None: - err = 'No configured Alexa flash briefing was found for: %s' - _LOGGER.error(err, briefing_id) - return b'', 404 - - briefing = [] - - for item in self.flash_briefings.get(briefing_id, []): - output = {} - if item.get(CONF_TITLE) is not None: - if isinstance(item.get(CONF_TITLE), template.Template): - output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() - else: - output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) - - if item.get(CONF_TEXT) is not None: - if isinstance(item.get(CONF_TEXT), template.Template): - output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() - else: - output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) - - if item.get(CONF_UID) is not None: - output[ATTR_UID] = item.get(CONF_UID) - - if item.get(CONF_AUDIO) is not None: - if isinstance(item.get(CONF_AUDIO), template.Template): - output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() - else: - output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) - - if item.get(CONF_DISPLAY_URL) is not None: - if isinstance(item.get(CONF_DISPLAY_URL), - template.Template): - output[ATTR_REDIRECTION_URL] = \ - item[CONF_DISPLAY_URL].async_render() - else: - output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) - - output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) - - briefing.append(output) - - return self.json(briefing) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py new file mode 100644 index 00000000000..aa4b1cbec70 --- /dev/null +++ b/homeassistant/components/alexa/smart_home.py @@ -0,0 +1,185 @@ +"""Support for alexa Smart Home Skill API.""" +import asyncio +import logging +from uuid import uuid4 + +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) +from homeassistant.components import switch, light + +_LOGGER = logging.getLogger(__name__) + +ATTR_HEADER = 'header' +ATTR_NAME = 'name' +ATTR_NAMESPACE = 'namespace' +ATTR_MESSAGE_ID = 'messageId' +ATTR_PAYLOAD = 'payload' +ATTR_PAYLOAD_VERSION = 'payloadVersion' + + +MAPPING_COMPONENT = { + switch.DOMAIN: ['SWITCH', ('turnOff', 'turnOn'), None], + light.DOMAIN: [ + 'LIGHT', ('turnOff', 'turnOn'), { + light.SUPPORT_BRIGHTNESS: 'setPercentage' + } + ], +} + + +def mapping_api_function(name): + """Return function pointer to api function for name. + + Async friendly. + """ + mapping = { + 'DiscoverAppliancesRequest': async_api_discovery, + 'TurnOnRequest': async_api_turn_on, + 'TurnOffRequest': async_api_turn_off, + 'SetPercentageRequest': async_api_set_percentage, + } + return mapping.get(name, None) + + +@asyncio.coroutine +def async_handle_message(hass, message): + """Handle incomming API messages.""" + assert int(message[ATTR_HEADER][ATTR_PAYLOAD_VERSION]) == 2 + + # Do we support this API request? + funct_ref = mapping_api_function(message[ATTR_HEADER][ATTR_NAME]) + if not funct_ref: + _LOGGER.warning( + "Unsupported API request %s", message[ATTR_HEADER][ATTR_NAME]) + return api_error(message) + + return (yield from funct_ref(hass, message)) + + +def api_message(name, namespace, payload=None): + """Create a API formated response message. + + Async friendly. + """ + payload = payload or {} + return { + ATTR_HEADER: { + ATTR_MESSAGE_ID: uuid4(), + ATTR_NAME: name, + ATTR_NAMESPACE: namespace, + ATTR_PAYLOAD_VERSION: '2', + }, + ATTR_PAYLOAD: payload, + } + + +def api_error(request, exc='DriverInternalError'): + """Create a API formated error response. + + Async friendly. + """ + return api_message(exc, request[ATTR_HEADER][ATTR_NAMESPACE]) + + +@asyncio.coroutine +def async_api_discovery(hass, request): + """Create a API formated discovery response. + + Async friendly. + """ + discovered_appliances = [] + + for entity in hass.states.async_all(): + class_data = MAPPING_COMPONENT.get(entity.domain) + + if not class_data: + continue + + appliance = { + 'actions': [], + 'applianceTypes': [class_data[0]], + 'additionalApplianceDetails': {}, + 'applianceId': entity.entity_id.replace('.', '#'), + 'friendlyDescription': '', + 'friendlyName': entity.name, + 'isReachable': True, + 'manufacturerName': 'Unknown', + 'modelName': 'Unknown', + 'version': 'Unknown', + } + + # static actions + if class_data[1]: + appliance['actions'].extend(list(class_data[1])) + + # dynamic actions + if class_data[2]: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + for feature, action_name in class_data[2].items(): + if feature & supported > 0: + appliance['actions'].append(action_name) + + discovered_appliances.append(appliance) + + return api_message( + 'DiscoverAppliancesResponse', 'Alexa.ConnectedHome.Discovery', + payload={'discoveredAppliances': discovered_appliances}) + + +def extract_entity(funct): + """Decorator for extract entity object from request.""" + @asyncio.coroutine + def async_api_entity_wrapper(hass, request): + """Process a turn on request.""" + entity_id = \ + request[ATTR_PAYLOAD]['appliance']['applianceId'].replace('#', '.') + + # extract state object + entity = hass.states.get(entity_id) + if not entity: + _LOGGER.error("Can't process %s for %s", + request[ATTR_HEADER][ATTR_NAME], entity_id) + return api_error(request) + + return (yield from funct(hass, request, entity)) + + return async_api_entity_wrapper + + +@extract_entity +@asyncio.coroutine +def async_api_turn_on(hass, request, entity): + """Process a turn on request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message('TurnOnConfirmation', 'Alexa.ConnectedHome.Control') + + +@extract_entity +@asyncio.coroutine +def async_api_turn_off(hass, request, entity): + """Process a turn off request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_OFF, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message('TurnOffConfirmation', 'Alexa.ConnectedHome.Control') + + +@extract_entity +@asyncio.coroutine +def async_api_set_percentage(hass, request, entity): + """Process a set percentage request.""" + if entity.domain == light.DOMAIN: + brightness = request[ATTR_PAYLOAD]['percentageState']['value'] + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS: brightness, + }, blocking=True) + else: + return api_error(request) + + return api_message( + 'SetPercentageConfirmation', 'Alexa.ConnectedHome.Control') diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py index 2fb039f0ab3..2883fca9ab6 100644 --- a/homeassistant/components/android_ip_webcam.py +++ b/homeassistant/components/android_ip_webcam.py @@ -263,7 +263,7 @@ class AndroidIPCamEntity(Entity): """Update callback.""" if self._host != host: return - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index c22683970bf..3b905ab0420 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -13,7 +13,7 @@ import async_timeout import homeassistant.core as ha import homeassistant.remote as rem -from homeassistant.bootstrap import ERROR_LOG_FILENAME +from homeassistant.bootstrap import DATA_LOGGING from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND, @@ -51,8 +51,9 @@ def setup(hass, config): hass.http.register_view(APIComponentsView) hass.http.register_view(APITemplateView) - hass.http.register_static_path( - URL_API_ERROR_LOG, hass.config.path(ERROR_LOG_FILENAME), False) + log_path = hass.data.get(DATA_LOGGING, None) + if log_path: + hass.http.register_static_path(URL_API_ERROR_LOG, log_path, False) return True diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 7a2ff7610f7..4fce508ba7e 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -10,6 +10,7 @@ import logging import voluptuous as vol +from typing import Union, TypeVar, Sequence from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_ENTITY_ID) from homeassistant.config import load_yaml_config_file from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -45,8 +46,19 @@ NOTIFICATION_AUTH_TITLE = 'Apple TV Authentication' NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification' NOTIFICATION_SCAN_TITLE = 'Apple TV Scan' +T = TypeVar('T') + + +# This version of ensure_list interprets an empty dict as no value +def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]: + """Wrap value in list if it is not one.""" + if value is None or (isinstance(value, dict) and not value): + return [] + return value if isinstance(value, list) else [value] + + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + DOMAIN: vol.All(ensure_list, [vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_LOGIN_ID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -133,6 +145,10 @@ def async_setup(hass, config): """Handler for service calls.""" entity_ids = service.data.get(ATTR_ENTITY_ID) + if service.service == SERVICE_SCAN: + hass.async_add_job(scan_for_apple_tvs, hass) + return + if entity_ids: devices = [device for device in hass.data[DATA_ENTITIES] if device.entity_id in entity_ids] @@ -140,16 +156,16 @@ def async_setup(hass, config): devices = hass.data[DATA_ENTITIES] for device in devices: + if service.service != SERVICE_AUTHENTICATE: + continue + atv = device.atv - if service.service == SERVICE_AUTHENTICATE: - credentials = yield from atv.airplay.generate_credentials() - yield from atv.airplay.load_credentials(credentials) - _LOGGER.debug('Generated new credentials: %s', credentials) - yield from atv.airplay.start_authentication() - hass.async_add_job(request_configuration, - hass, config, atv, credentials) - elif service.service == SERVICE_SCAN: - hass.async_add_job(scan_for_apple_tvs, hass) + credentials = yield from atv.airplay.generate_credentials() + yield from atv.airplay.load_credentials(credentials) + _LOGGER.debug('Generated new credentials: %s', credentials) + yield from atv.airplay.start_authentication() + hass.async_add_job(request_configuration, + hass, config, atv, credentials) @asyncio.coroutine def atv_discovered(service, info): diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index eaf85937658..aee8dbc415b 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -14,7 +14,7 @@ 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, + CONF_PASSWORD, CONF_PORT, CONF_TRIGGER_TIME, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) from homeassistant.components.discovery import SERVICE_AXIS from homeassistant.helpers import config_validation as cv @@ -23,7 +23,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['axis==8'] +REQUIREMENTS = ['axis==12'] _LOGGER = logging.getLogger(__name__) @@ -51,6 +51,7 @@ DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_TRIGGER_TIME, default=0): cv.positive_int, + vol.Optional(CONF_PORT, default=80): cv.positive_int, vol.Optional(ATTR_LOCATION, default=''): cv.string, }) @@ -76,7 +77,7 @@ SERVICE_SCHEMA = vol.Schema({ }) -def request_configuration(hass, name, host, serialnumber): +def request_configuration(hass, config, name, host, serialnumber): """Request configuration steps from the user.""" configurator = hass.components.configurator @@ -91,15 +92,15 @@ def request_configuration(hass, name, host, serialnumber): if CONF_NAME not in callback_data: callback_data[CONF_NAME] = name try: - config = DEVICE_SCHEMA(callback_data) + device_config = DEVICE_SCHEMA(callback_data) except vol.Invalid: configurator.notify_errors(request_id, "Bad input, please check spelling.") return False - if setup_device(hass, config): + if setup_device(hass, config, device_config): config_file = _read_config(hass) - config_file[serialnumber] = dict(config) + config_file[serialnumber] = dict(device_config) del config_file[serialnumber]['hass'] _write_config(hass, config_file) configurator.request_done(request_id) @@ -132,6 +133,9 @@ def request_configuration(hass, name, host, serialnumber): {'id': ATTR_LOCATION, 'name': "Physical location of device (optional)", 'type': 'text'}, + {'id': CONF_PORT, + 'name': "HTTP port (default=80)", + 'type': 'number'}, {'id': CONF_TRIGGER_TIME, 'name': "Sensor update interval (optional)", 'type': 'number'}, @@ -139,7 +143,7 @@ def request_configuration(hass, name, host, serialnumber): ) -def setup(hass, base_config): +def setup(hass, config): """Common setup for Axis devices.""" def _shutdown(call): # pylint: disable=unused-argument """Stop the metadatastream on shutdown.""" @@ -160,16 +164,17 @@ def setup(hass, base_config): if serialnumber in config_file: # Device config saved to file try: - config = DEVICE_SCHEMA(config_file[serialnumber]) - config[CONF_HOST] = host + device_config = DEVICE_SCHEMA(config_file[serialnumber]) + device_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[CONF_NAME]) + if not setup_device(hass, config, device_config): + _LOGGER.error("Couldn\'t set up %s", + device_config[CONF_NAME]) else: # New device, create configuration request for UI - request_configuration(hass, name, host, serialnumber) + request_configuration(hass, config, name, host, serialnumber) else: # Device already registered, but on a different IP device = AXIS_DEVICES[serialnumber] @@ -181,13 +186,13 @@ def setup(hass, base_config): # Register discovery service discovery.listen(hass, SERVICE_AXIS, axis_device_discovered) - if DOMAIN in base_config: - for device in base_config[DOMAIN]: - config = base_config[DOMAIN][device] - if CONF_NAME not in config: - config[CONF_NAME] = device - if not setup_device(hass, config): - _LOGGER.error("Couldn\'t set up %s", config[CONF_NAME]) + if DOMAIN in config: + for device in config[DOMAIN]: + device_config = config[DOMAIN][device] + if CONF_NAME not in device_config: + device_config[CONF_NAME] = device + if not setup_device(hass, config, device_config): + _LOGGER.error("Couldn\'t set up %s", device_config[CONF_NAME]) # Services to communicate with device. descriptions = load_yaml_config_file( @@ -215,20 +220,20 @@ def setup(hass, base_config): return True -def setup_device(hass, config): +def setup_device(hass, config, device_config): """Set up device.""" from axis import AxisDevice - config['hass'] = hass - device = AxisDevice(config) # Initialize device + device_config['hass'] = hass + device = AxisDevice(device_config) # Initialize device enable_metadatastream = False if device.serial_number is None: # If there is no serial number a connection could not be made - _LOGGER.error("Couldn\'t connect to %s", config[CONF_HOST]) + _LOGGER.error("Couldn\'t connect to %s", device_config[CONF_HOST]) return False - for component in config[CONF_INCLUDE]: + for component in device_config[CONF_INCLUDE]: if component in EVENT_TYPES: # Sensors are created by device calling event_initialized # when receiving initialize messages on metadatastream @@ -236,7 +241,18 @@ def setup_device(hass, config): if not enable_metadatastream: enable_metadatastream = True else: - discovery.load_platform(hass, component, DOMAIN, config) + camera_config = { + CONF_HOST: device_config[CONF_HOST], + CONF_NAME: device_config[CONF_NAME], + CONF_PORT: device_config[CONF_PORT], + CONF_USERNAME: device_config[CONF_USERNAME], + CONF_PASSWORD: device_config[CONF_PASSWORD] + } + discovery.load_platform(hass, + component, + DOMAIN, + camera_config, + config) if enable_metadatastream: device.initialize_new_event = event_initialized diff --git a/homeassistant/components/binary_sensor/abode.py b/homeassistant/components/binary_sensor/abode.py index d3b0d662a94..8ad40158958 100644 --- a/homeassistant/components/binary_sensor/abode.py +++ b/homeassistant/components/binary_sensor/abode.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/binary_sensor.abode/ """ import logging -from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.abode import (AbodeDevice, AbodeAutomation, + DOMAIN as ABODE_DOMAIN) from homeassistant.components.binary_sensor import BinarySensorDevice @@ -17,39 +18,38 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - abode = hass.data[DATA_ABODE] - - device_types = map_abode_device_class().keys() - - sensors = [] - for sensor in abode.get_devices(type_filter=device_types): - sensors.append(AbodeBinarySensor(abode, sensor)) - - add_devices(sensors) - - -def map_abode_device_class(): - """Map Abode device types to Home Assistant binary sensor class.""" import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE - return { - CONST.DEVICE_GLASS_BREAK: 'connectivity', - CONST.DEVICE_KEYPAD: 'connectivity', - CONST.DEVICE_DOOR_CONTACT: 'opening', - CONST.DEVICE_STATUS_DISPLAY: 'connectivity', - CONST.DEVICE_MOTION_CAMERA: 'connectivity', - CONST.DEVICE_WATER_SENSOR: 'moisture' - } + data = hass.data[ABODE_DOMAIN] + + device_types = [CONST.TYPE_CONNECTIVITY, CONST.TYPE_MOISTURE, + CONST.TYPE_MOTION, CONST.TYPE_OCCUPANCY, + CONST.TYPE_OPENING] + + devices = [] + for device in data.abode.get_devices(generic_type=device_types): + if data.is_excluded(device): + continue + + devices.append(AbodeBinarySensor(data, device)) + + for automation in data.abode.get_automations( + generic_type=CONST.TYPE_QUICK_ACTION): + if data.is_automation_excluded(automation): + continue + + devices.append(AbodeQuickActionBinarySensor( + data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)) + + data.devices.extend(devices) + + add_devices(devices) class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): """A binary sensor implementation for Abode device.""" - def __init__(self, controller, device): - """Initialize a sensor for Abode device.""" - AbodeDevice.__init__(self, controller, device) - self._device_class = map_abode_device_class().get(self._device.type) - @property def is_on(self): """Return True if the binary sensor is on.""" @@ -58,4 +58,17 @@ class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): @property def device_class(self): """Return the class of the binary sensor.""" - return self._device_class + return self._device.generic_type + + +class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice): + """A binary sensor implementation for Abode quick action automations.""" + + def trigger(self): + """Trigger a quick automation.""" + self._automation.trigger() + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._automation.is_active diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index 495feaf64ab..bc05e4d84d8 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -102,11 +102,11 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def _restore_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 0 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py index 4c62735a6f9..13908fb5472 100644 --- a/homeassistant/components/binary_sensor/bayesian.py +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -102,7 +102,13 @@ class BayesianBinarySensor(BinarySensorDevice): self.current_obs = OrderedDict({}) - self.entity_obs = {obs['entity_id']: obs for obs in self._observations} + to_observe = set(obs['entity_id'] for obs in self._observations) + + self.entity_obs = dict.fromkeys(to_observe, []) + + for ind, obs in enumerate(self._observations): + obs["id"] = ind + self.entity_obs[obs['entity_id']].append(obs) self.watchers = { 'numeric_state': self._process_numeric_state, @@ -120,17 +126,17 @@ class BayesianBinarySensor(BinarySensorDevice): if new_state.state == STATE_UNKNOWN: return - entity_obs = self.entity_obs[entity] - platform = entity_obs['platform'] + entity_obs_list = self.entity_obs[entity] - self.watchers[platform](entity_obs) + for entity_obs in entity_obs_list: + platform = entity_obs['platform'] + + self.watchers[platform](entity_obs) prior = self.prior - print(self.current_obs.values()) for obs in self.current_obs.values(): prior = update_probability(prior, obs['prob_true'], obs['prob_false']) - self.probability = prior self.hass.async_add_job(self.async_update_ha_state, True) @@ -141,20 +147,20 @@ class BayesianBinarySensor(BinarySensorDevice): def _update_current_obs(self, entity_observation, should_trigger): """Update current observation.""" - entity = entity_observation['entity_id'] + obs_id = entity_observation['id'] if should_trigger: prob_true = entity_observation['prob_given_true'] prob_false = entity_observation.get( 'prob_given_false', 1 - prob_true) - self.current_obs[entity] = { + self.current_obs[obs_id] = { 'prob_true': prob_true, 'prob_false': prob_false } else: - self.current_obs.pop(entity, None) + self.current_obs.pop(obs_id, None) def _process_numeric_state(self, entity_observation): """Add entity to current_obs if numeric state conditions are met.""" @@ -201,7 +207,7 @@ class BayesianBinarySensor(BinarySensorDevice): """Return the state attributes of the sensor.""" return { 'observations': [val for val in self.current_obs.values()], - 'probability': self.probability, + 'probability': round(self.probability, 2), 'probability_threshold': self._probability_threshold } diff --git a/homeassistant/components/binary_sensor/doorbird.py b/homeassistant/components/binary_sensor/doorbird.py new file mode 100644 index 00000000000..9a13687fc54 --- /dev/null +++ b/homeassistant/components/binary_sensor/doorbird.py @@ -0,0 +1,60 @@ +"""Support for reading binary states from a DoorBird video doorbell.""" +from datetime import timedelta +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN +from homeassistant.util import Throttle + +DEPENDENCIES = ['doorbird'] + +_LOGGER = logging.getLogger(__name__) +_MIN_UPDATE_INTERVAL = timedelta(milliseconds=250) + +SENSOR_TYPES = { + "doorbell": { + "name": "Doorbell Ringing", + "icon": { + True: "bell-ring", + False: "bell", + None: "bell-outline" + } + } +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the DoorBird binary sensor component.""" + device = hass.data.get(DOORBIRD_DOMAIN) + add_devices([DoorBirdBinarySensor(device, "doorbell")], True) + + +class DoorBirdBinarySensor(BinarySensorDevice): + """A binary sensor of a DoorBird device.""" + + def __init__(self, device, sensor_type): + """Initialize a binary sensor on a DoorBird device.""" + self._device = device + self._sensor_type = sensor_type + self._state = None + + @property + def name(self): + """Get the name of the sensor.""" + return SENSOR_TYPES[self._sensor_type]["name"] + + @property + def icon(self): + """Get an icon to display.""" + state_icon = SENSOR_TYPES[self._sensor_type]["icon"][self._state] + return "mdi:{}".format(state_icon) + + @property + def is_on(self): + """Get the state of the binary sensor.""" + return self._state + + @Throttle(_MIN_UPDATE_INTERVAL) + def update(self): + """Pull the latest value from the device.""" + self._state = self._device.doorbell_state() diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 5fbc1eb90a1..7d35c0c9e94 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -80,4 +80,4 @@ class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): def _update_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/ffmpeg_motion.py b/homeassistant/components/binary_sensor/ffmpeg_motion.py index 1bbf39dd6e0..47b1be988bf 100644 --- a/homeassistant/components/binary_sensor/ffmpeg_motion.py +++ b/homeassistant/components/binary_sensor/ffmpeg_motion.py @@ -73,7 +73,7 @@ class FFmpegBinarySensor(FFmpegBase, BinarySensorDevice): def _async_callback(self, state): """HA-FFmpeg callback for noise detection.""" self._state = state - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def is_on(self): diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 2b11c3fe172..406f60f99bb 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -53,7 +53,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up binary sensor(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -61,25 +61,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up binary sensors for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXBinarySensor(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up binary senor for KNX platform configured within plattform.""" name = config.get(CONF_NAME) import xknx @@ -101,7 +101,7 @@ def async_add_devices_config(hass, config, add_devices): entity.automations.append(KNXAutomation( hass=hass, device=binary_sensor, hook=hook, action=action, counter=counter)) - add_devices([entity]) + async_add_devices([entity]) class KNXBinarySensor(BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index 3702b32d586..c5fba72bde0 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -16,14 +16,21 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS) -from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS) +from homeassistant.components.mqtt import ( + CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_QOS, valid_subscribe_topic) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_PAYLOAD_AVAILABLE = 'payload_available' +CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' + DEFAULT_NAME = 'MQTT Binary sensor' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_PAYLOAD_ON = 'ON' +DEFAULT_PAYLOAD_AVAILABLE = 'online' +DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' + DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ @@ -31,6 +38,11 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_AVAILABILITY_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD_AVAILABLE, + default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, + vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, }) @@ -47,10 +59,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): async_add_devices([MqttBinarySensor( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), + config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_DEVICE_CLASS), config.get(CONF_QOS), config.get(CONF_PAYLOAD_ON), config.get(CONF_PAYLOAD_OFF), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), value_template )]) @@ -58,15 +73,20 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttBinarySensor(BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" - def __init__(self, name, state_topic, device_class, qos, payload_on, - payload_off, value_template): + def __init__(self, name, state_topic, availability_topic, device_class, + qos, payload_on, payload_off, payload_available, + payload_not_available, value_template): """Initialize the MQTT binary sensor.""" self._name = name - self._state = False + self._state = None self._state_topic = state_topic + self._availability_topic = availability_topic + self._available = True if availability_topic is None else False self._device_class = device_class self._payload_on = payload_on self._payload_off = payload_off + self._payload_available = payload_available + self._payload_not_available = payload_not_available self._qos = qos self._template = value_template @@ -76,8 +96,8 @@ class MqttBinarySensor(BinarySensorDevice): This method must be run in the event loop and returns a coroutine. """ @callback - def message_received(topic, payload, qos): - """Handle a new received MQTT message.""" + def state_message_received(topic, payload, qos): + """Handle a new received MQTT state message.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) @@ -86,10 +106,25 @@ class MqttBinarySensor(BinarySensorDevice): elif payload == self._payload_off: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() - return mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + yield from mqtt.async_subscribe( + self.hass, self._state_topic, state_message_received, self._qos) + + @callback + def availability_message_received(topic, payload, qos): + """Handle a new received MQTT availability message.""" + if payload == self._payload_available: + self._available = True + elif payload == self._payload_not_available: + self._available = False + + self.async_schedule_update_ha_state() + + if self._availability_topic is not None: + yield from mqtt.async_subscribe( + self.hass, self._availability_topic, + availability_message_received, self._qos) @property def should_poll(self): @@ -101,6 +136,11 @@ class MqttBinarySensor(BinarySensorDevice): """Return the name of the binary sensor.""" return self._name + @property + def available(self) -> bool: + """Return if the binary sensor is available.""" + return self._available + @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/binary_sensor/mystrom.py b/homeassistant/components/binary_sensor/mystrom.py index 08ab1f4a8b7..2afaa032745 100644 --- a/homeassistant/components/binary_sensor/mystrom.py +++ b/homeassistant/components/binary_sensor/mystrom.py @@ -92,4 +92,4 @@ class MyStromBinarySensor(BinarySensorDevice): def async_on_update(self, value): """Receive an update.""" self._state = value - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/satel_integra.py b/homeassistant/components/binary_sensor/satel_integra.py new file mode 100644 index 00000000000..f373809f7c0 --- /dev/null +++ b/homeassistant/components/binary_sensor/satel_integra.py @@ -0,0 +1,90 @@ +""" +Support for Satel Integra zone states- represented as binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.satel_integra/ +""" +import asyncio +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.satel_integra import (CONF_ZONES, + CONF_ZONE_NAME, + CONF_ZONE_TYPE, + SIGNAL_ZONES_UPDATED) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['satel_integra'] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Satel Integra binary sensor devices.""" + if not discovery_info: + return + + configured_zones = discovery_info[CONF_ZONES] + + devices = [] + + for zone_num, device_config_data in configured_zones.items(): + zone_type = device_config_data[CONF_ZONE_TYPE] + zone_name = device_config_data[CONF_ZONE_NAME] + device = SatelIntegraBinarySensor(zone_num, zone_name, zone_type) + devices.append(device) + + async_add_devices(devices) + + +class SatelIntegraBinarySensor(BinarySensorDevice): + """Representation of an Satel Integra binary sensor.""" + + def __init__(self, zone_number, zone_name, zone_type): + """Initialize the binary_sensor.""" + self._zone_number = zone_number + self._name = zone_name + self._zone_type = zone_type + self._state = 0 + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_ZONES_UPDATED, self._zones_updated) + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def icon(self): + """Icon for device by its type.""" + if self._zone_type == 'smoke': + return "mdi:fire" + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state == 1 + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return self._zone_type + + @callback + def _zones_updated(self, zones): + """Update the zone's state, if needed.""" + if self._zone_number in zones \ + and self._state != zones[self._zone_number]: + self._state = zones[self._zone_number] + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/spc.py b/homeassistant/components/binary_sensor/spc.py index 8023e1cf4b3..af3669c2b15 100644 --- a/homeassistant/components/binary_sensor/spc.py +++ b/homeassistant/components/binary_sensor/spc.py @@ -41,14 +41,14 @@ def _create_sensor(hass, zone): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Initialize the platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_DEVICES] is None): return - async_add_entities( + async_add_devices( _create_sensor(hass, zone) for zone in discovery_info[ATTR_DISCOVER_DEVICES] if _get_device_class(zone['type'])) diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 413804f0856..84afd01303f 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -161,7 +161,7 @@ class BinarySensorTemplate(BinarySensorDevice): def set_state(): """Set state of template binary sensor.""" self._state = state - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # state without delay if (state and not self._delay_on) or \ diff --git a/homeassistant/components/binary_sensor/xiaomi.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py similarity index 98% rename from homeassistant/components/binary_sensor/xiaomi.py rename to homeassistant/components/binary_sensor/xiaomi_aqara.py index c5f0a7b3dce..d60d265b849 100644 --- a/homeassistant/components/binary_sensor/xiaomi.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -1,8 +1,9 @@ -"""Support for Xiaomi binary sensors.""" +"""Support for Xiaomi aqara binary sensors.""" import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 4e088c8a640..5198381b976 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -12,6 +12,7 @@ import re from homeassistant.components.google import ( CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME) from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.config_validation import time_period_str from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml new file mode 100644 index 00000000000..952e2302091 --- /dev/null +++ b/homeassistant/components/calendar/services.yaml @@ -0,0 +1,19 @@ +todoist: + new_task: + description: Create a new task and add it to a project. + fields: + content: + description: The name of the task. [Required] + example: Pick up the mail + project: + description: The name of the project this task should belong to. Defaults to Inbox. [Optional] + example: Errands + labels: + description: Any labels that you want to apply to this task, separated by a comma. [Optional] + example: Chores,Deliveries + priority: + description: The priority of this task, from 1 (normal) to 4 (urgent). [Optional] + example: 2 + due_date: + description: The day this task is due, in format YYYY-MM-DD. [Optional] + example: "2018-04-01" diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py new file mode 100644 index 00000000000..ae9a1c9afa8 --- /dev/null +++ b/homeassistant/components/calendar/todoist.py @@ -0,0 +1,544 @@ +""" +Support for Todoist task management (https://todoist.com). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.todoist/ +""" + + +from datetime import datetime +from datetime import timedelta +import logging +import os + +import voluptuous as vol + +from homeassistant.components.calendar import ( + CalendarEventDevice, PLATFORM_SCHEMA) +from homeassistant.components.google import ( + CONF_DEVICE_ID) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + CONF_ID, CONF_NAME, CONF_TOKEN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import dt +from homeassistant.util import Throttle + +REQUIREMENTS = ['todoist-python==7.0.17'] + +_LOGGER = logging.getLogger(__name__) +DOMAIN = 'todoist' + +# Calendar Platform: Does this calendar event last all day? +ALL_DAY = 'all_day' +# Attribute: All tasks in this project +ALL_TASKS = 'all_tasks' +# Todoist API: "Completed" flag -- 1 if complete, else 0 +CHECKED = 'checked' +# Attribute: Is this task complete? +COMPLETED = 'completed' +# Todoist API: What is this task about? +# Service Call: What is this task about? +CONTENT = 'content' +# Calendar Platform: Get a calendar event's description +DESCRIPTION = 'description' +# Calendar Platform: Used in the '_get_date()' method +DATETIME = 'dateTime' +# Attribute: When is this task due? +# Service Call: When is this task due? +DUE_DATE = 'due_date' +# Todoist API: Look up a task's due date +DUE_DATE_UTC = 'due_date_utc' +# Attribute: Is this task due today? +DUE_TODAY = 'due_today' +# Calendar Platform: When a calendar event ends +END = 'end' +# Todoist API: Look up a Project/Label/Task ID +ID = 'id' +# Todoist API: Fetch all labels +# Service Call: What are the labels attached to this task? +LABELS = 'labels' +# Todoist API: "Name" value +NAME = 'name' +# Attribute: Is this task overdue? +OVERDUE = 'overdue' +# Attribute: What is this task's priority? +# Todoist API: Get a task's priority +# Service Call: What is this task's priority? +PRIORITY = 'priority' +# Todoist API: Look up the Project ID a Task belongs to +PROJECT_ID = 'project_id' +# Service Call: What Project do you want a Task added to? +PROJECT_NAME = 'project' +# Todoist API: Fetch all Projects +PROJECTS = 'projects' +# Calendar Platform: When does a calendar event start? +START = 'start' +# Calendar Platform: What is the next calendar event about? +SUMMARY = 'summary' +# Todoist API: Fetch all Tasks +TASKS = 'items' + +SERVICE_NEW_TASK = 'new_task' +NEW_TASK_SERVICE_SCHEMA = vol.Schema({ + vol.Required(CONTENT): cv.string, + vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower), + vol.Optional(LABELS): cv.ensure_list_csv, + vol.Optional(PRIORITY): vol.All(vol.Coerce(int), + vol.Range(min=1, max=4)), + vol.Optional(DUE_DATE): cv.string +}) + +CONF_EXTRA_PROJECTS = 'custom_projects' +CONF_PROJECT_DUE_DATE = 'due_date_days' +CONF_PROJECT_WHITELIST = 'include_projects' +CONF_PROJECT_LABEL_WHITELIST = 'labels' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_EXTRA_PROJECTS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_PROJECT_DUE_DATE): vol.Coerce(int), + vol.Optional(CONF_PROJECT_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]), + vol.Optional(CONF_PROJECT_LABEL_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]) + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Todoist platform.""" + # Check token: + token = config.get(CONF_TOKEN) + + # Look up IDs based on (lowercase) names. + project_id_lookup = {} + label_id_lookup = {} + + from todoist.api import TodoistAPI + api = TodoistAPI(token) + api.sync() + + # Setup devices: + # Grab all projects. + projects = api.state[PROJECTS] + + # Grab all labels + labels = api.state[LABELS] + + # Add all Todoist-defined projects. + project_devices = [] + for project in projects: + # Project is an object, not a dict! + # Because of that, we convert what we need to a dict. + project_data = { + CONF_NAME: project[NAME], + CONF_ID: project[ID] + } + project_devices.append( + TodoistProjectDevice(hass, project_data, labels, api) + ) + # Cache the names so we can easily look up name->ID. + project_id_lookup[project[NAME].lower()] = project[ID] + + # Cache all label names + for label in labels: + label_id_lookup[label[NAME].lower()] = label[ID] + + # Check config for more projects. + extra_projects = config.get(CONF_EXTRA_PROJECTS) + for project in extra_projects: + # Special filter: By date + project_due_date = project.get(CONF_PROJECT_DUE_DATE) + + # Special filter: By label + project_label_filter = project.get(CONF_PROJECT_LABEL_WHITELIST) + + # Special filter: By name + # Names must be converted into IDs. + project_name_filter = project.get(CONF_PROJECT_WHITELIST) + project_id_filter = [ + project_id_lookup[project_name.lower()] + for project_name in project_name_filter] + + # Create the custom project and add it to the devices array. + project_devices.append( + TodoistProjectDevice( + hass, project, labels, api, project_due_date, + project_label_filter, project_id_filter + ) + ) + + add_devices(project_devices) + + # Services: + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def handle_new_task(call): + """Called when a user creates a new Todoist Task from HASS.""" + project_name = call.data[PROJECT_NAME] + project_id = project_id_lookup[project_name] + + # Create the task + item = api.items.add(call.data[CONTENT], project_id) + + if LABELS in call.data: + task_labels = call.data[LABELS] + label_ids = [ + label_id_lookup[label.lower()] + for label in task_labels] + item.update(labels=label_ids) + + if PRIORITY in call.data: + item.update(priority=call.data[PRIORITY]) + + if DUE_DATE in call.data: + due_date = dt.parse_datetime(call.data[DUE_DATE]) + if due_date is None: + due = dt.parse_date(call.data[DUE_DATE]) + due_date = datetime(due.year, due.month, due.day) + # Format it in the manner Todoist expects + due_date = dt.as_utc(due_date) + date_format = '%Y-%m-%dT%H:%M' + due_date = datetime.strftime(due_date, date_format) + item.update(due_date_utc=due_date) + # Commit changes + api.commit() + _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) + + hass.services.register(DOMAIN, SERVICE_NEW_TASK, handle_new_task, + descriptions[DOMAIN][SERVICE_NEW_TASK], + schema=NEW_TASK_SERVICE_SCHEMA) + + +class TodoistProjectDevice(CalendarEventDevice): + """A device for getting the next Task from a Todoist Project.""" + + def __init__(self, hass, data, labels, token, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Create the Todoist Calendar Event Device.""" + self.data = TodoistProjectData( + data, labels, token, latest_task_due_date, + whitelisted_labels, whitelisted_projects + ) + + # Set up the calendar side of things + calendar_format = { + CONF_NAME: data[CONF_NAME], + # Set Entity ID to use the name so we can identify calendars + CONF_DEVICE_ID: data[CONF_NAME] + } + + super().__init__(hass, calendar_format) + + def update(self): + """Update all Todoist Calendars.""" + # Set basic calendar data + super().update() + + # Set Todoist-specific data that can't easily be grabbed + self._cal_data[ALL_TASKS] = [ + task[SUMMARY] for task in self.data.all_project_tasks] + + def cleanup(self): + """Clean up all calendar data.""" + super().cleanup() + self._cal_data[ALL_TASKS] = [] + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + + # Add additional attributes. + attributes[DUE_TODAY] = self.data.event[DUE_TODAY] + attributes[OVERDUE] = self.data.event[OVERDUE] + attributes[ALL_TASKS] = self._cal_data[ALL_TASKS] + attributes[PRIORITY] = self.data.event[PRIORITY] + attributes[LABELS] = self.data.event[LABELS] + + return attributes + + +class TodoistProjectData(object): + """ + Class used by the Task Device service object to hold all Todoist Tasks. + + This is analagous to the GoogleCalendarData found in the Google Calendar + component. + + Takes an object with a 'name' field and optionally an 'id' field (either + user-defined or from the Todoist API), a Todoist API token, and an optional + integer specifying the latest number of days from now a task can be due (7 + means everything due in the next week, 0 means today, etc.). + + This object has an exposed 'event' property (used by the Calendar platform + to determine the next calendar event) and an exposed 'update' method (used + by the Calendar platform to poll for new calendar events). + + The 'event' is a representation of a Todoist Task, with defined parameters + of 'due_today' (is the task due today?), 'all_day' (does the task have a + due date?), 'task_labels' (all labels assigned to the task), 'message' + (the content of the task, e.g. 'Fetch Mail'), 'description' (a URL pointing + to the task on the Todoist website), 'end_time' (what time the event is + due), 'start_time' (what time this event was last updated), 'overdue' (is + the task past its due date?), 'priority' (1-4, how important the task is, + with 4 being the most important), and 'all_tasks' (all tasks in this + project, sorted by how important they are). + + 'offset_reached', 'location', and 'friendly_name' are defined by the + platform itself, but are not used by this component at all. + + The 'update' method polls the Todoist API for new projects/tasks, as well + as any updates to current projects/tasks. This is throttled to every + MIN_TIME_BETWEEN_UPDATES minutes. + """ + + def __init__(self, project_data, labels, api, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Initialize a Todoist Project.""" + self.event = None + + self._api = api + self._name = project_data.get(CONF_NAME) + # If no ID is defined, fetch all tasks. + self._id = project_data.get(CONF_ID) + + # All labels the user has defined, for easy lookup. + self._labels = labels + # Not tracked: order, indent, comment_count. + + self.all_project_tasks = [] + + # The latest date a task can be due (for making lists of everything + # due today, or everything due in the next week, for example). + if latest_task_due_date is not None: + self._latest_due_date = dt.utcnow() + timedelta( + days=latest_task_due_date) + else: + self._latest_due_date = None + + # Only tasks with one of these labels will be included. + if whitelisted_labels is not None: + self._label_whitelist = whitelisted_labels + else: + self._label_whitelist = [] + + # This project includes only projects with these names. + if whitelisted_projects is not None: + self._project_id_whitelist = whitelisted_projects + else: + self._project_id_whitelist = [] + + def create_todoist_task(self, data): + """ + Create a dictionary based on a Task passed from the Todoist API. + + Will return 'None' if the task is to be filtered out. + """ + task = {} + # Fields are required to be in all returned task objects. + task[SUMMARY] = data[CONTENT] + task[COMPLETED] = data[CHECKED] == 1 + task[PRIORITY] = data[PRIORITY] + task[DESCRIPTION] = 'https://todoist.com/showTask?id={}'.format( + data[ID]) + + # All task Labels (optional parameter). + task[LABELS] = [ + label[NAME].lower() for label in self._labels + if label[ID] in data[LABELS]] + + if self._label_whitelist and ( + not any(label in task[LABELS] + for label in self._label_whitelist)): + # We're not on the whitelist, return invalid task. + return None + + # Due dates (optional parameter). + # The due date is the END date -- the task cannot be completed + # past this time. + # That means that the START date is the earliest time one can + # complete the task. + # Generally speaking, that means right now. + task[START] = dt.utcnow() + if data[DUE_DATE_UTC] is not None: + due_date = data[DUE_DATE_UTC] + + # Due dates are represented in RFC3339 format, in UTC. + # Home Assistant exclusively uses UTC, so it'll + # handle the conversion. + time_format = '%a %d %b %Y %H:%M:%S %z' + # HASS' built-in parse time function doesn't like + # Todoist's time format; strptime has to be used. + task[END] = datetime.strptime(due_date, time_format) + + if self._latest_due_date is not None and ( + task[END] > self._latest_due_date): + # This task is out of range of our due date; + # it shouldn't be counted. + return None + + task[DUE_TODAY] = task[END].date() == datetime.today().date() + + # Special case: Task is overdue. + if task[END] <= task[START]: + task[OVERDUE] = True + # Set end time to the current time plus 1 hour. + # We're pretty much guaranteed to update within that 1 hour, + # so it should be fine. + task[END] = task[START] + timedelta(hours=1) + else: + task[OVERDUE] = False + else: + # If we ask for everything due before a certain date, don't count + # things which have no due dates. + if self._latest_due_date is not None: + return None + + # Define values for tasks without due dates + task[END] = None + task[ALL_DAY] = True + task[DUE_TODAY] = False + task[OVERDUE] = False + + # Not tracked: id, comments, project_id order, indent, recurring. + return task + + @staticmethod + def select_best_task(project_tasks): + """ + Search through a list of events for the "best" event to select. + + The "best" event is determined by the following criteria: + * A proposed event must not be completed + * A proposed event must have a end date (otherwise we go with + the event at index 0, selected above) + * A proposed event must be on the same day or earlier as our + current event + * If a proposed event is an earlier day than what we have so + far, select it + * If a proposed event is on the same day as our current event + and the proposed event has a higher priority than our current + event, select it + * If a proposed event is on the same day as our current event, + has the same priority as our current event, but is due earlier + in the day, select it + """ + # Start at the end of the list, so if tasks don't have a due date + # the newest ones are the most important. + + event = project_tasks[-1] + + for proposed_event in project_tasks: + if event == proposed_event: + continue + if proposed_event[COMPLETED]: + # Event is complete! + continue + if proposed_event[END] is None: + # No end time: + if event[END] is None and ( + proposed_event[PRIORITY] < event[PRIORITY]): + # They also have no end time, + # but we have a higher priority. + event = proposed_event + continue + else: + continue + elif event[END] is None: + # We have an end time, they do not. + event = proposed_event + continue + if proposed_event[END].date() > event[END].date(): + # Event is too late. + continue + elif proposed_event[END].date() < event[END].date(): + # Event is earlier than current, select it. + event = proposed_event + continue + else: + if proposed_event[PRIORITY] > event[PRIORITY]: + # Proposed event has a higher priority. + event = proposed_event + continue + elif proposed_event[PRIORITY] == event[PRIORITY] and ( + proposed_event[END] < event[END]): + event = proposed_event + continue + return event + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + if self._id is None: + project_task_data = [ + task for task in self._api.state[TASKS] + if not self._project_id_whitelist or + task[PROJECT_ID] in self._project_id_whitelist] + else: + project_task_data = self._api.projects.get_data(self._id)[TASKS] + + # If we have no data, we can just return right away. + if not project_task_data: + self.event = None + return True + + # Keep an updated list of all tasks in this project. + project_tasks = [] + + for task in project_task_data: + todoist_task = self.create_todoist_task(task) + if todoist_task is not None: + # A None task means it is invalid for this project + project_tasks.append(todoist_task) + + if not project_tasks: + # We had no valid tasks + return True + + # Organize the best tasks (so users can see all the tasks + # they have, organized) + while len(project_tasks) > 0: + best_task = self.select_best_task(project_tasks) + _LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY]) + project_tasks.remove(best_task) + self.all_project_tasks.append(best_task) + + self.event = self.all_project_tasks[0] + + # Convert datetime to a string again + if self.event is not None: + if self.event[START] is not None: + self.event[START] = { + DATETIME: self.event[START].strftime(DATE_STR_FORMAT) + } + if self.event[END] is not None: + self.event[END] = { + DATETIME: self.event[END].strftime(DATE_STR_FORMAT) + } + else: + # HASS gets cranky if a calendar event never ends + # Let's set our "due date" to tomorrow + self.event[END] = { + DATETIME: ( + datetime.utcnow() + + timedelta(days=1) + ).strftime(DATE_STR_FORMAT) + } + _LOGGER.debug("Updated %s", self._name) + return True diff --git a/homeassistant/components/camera/abode.py b/homeassistant/components/camera/abode.py new file mode 100644 index 00000000000..3c0c0a54e0e --- /dev/null +++ b/homeassistant/components/camera/abode.py @@ -0,0 +1,101 @@ +""" +This component provides HA camera support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.abode/ +""" +import asyncio +import logging + +from datetime import timedelta +import requests + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.camera import Camera +from homeassistant.util import Throttle + + +DEPENDENCIES = ['abode'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discoveryy_info=None): + """Set up Abode camera devices.""" + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA): + if data.is_excluded(device): + continue + + devices.append(AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeCamera(AbodeDevice, Camera): + """Representation of an Abode camera.""" + + def __init__(self, data, device, event): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, data, device) + Camera.__init__(self) + self._event = event + self._response = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + yield from super().async_added_to_hass() + + self.hass.async_add_job( + self._data.abode.events.add_timeline_callback, + self._event, self._capture_callback + ) + + def capture(self): + """Request a new image capture.""" + return self._device.capture() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def refresh_image(self): + """Find a new image on the timeline.""" + if self._device.refresh_image(): + self.get_image() + + def get_image(self): + """Attempt to download the most recent capture.""" + if self._device.image_url: + try: + self._response = requests.get( + self._device.image_url, stream=True) + + self._response.raise_for_status() + except requests.HTTPError as err: + _LOGGER.warning("Failed to get camera image: %s", err) + self._response = None + else: + self._response = None + + def camera_image(self): + """Get a camera image.""" + self.refresh_image() + + if self._response: + return self._response.content + + return None + + def _capture_callback(self, capture): + """Update the image with the device then refresh device.""" + self._device.update_image_location(capture) + self.get_image() + self.schedule_update_ha_state() diff --git a/homeassistant/components/camera/axis.py b/homeassistant/components/camera/axis.py index b0295b9ee34..ee8ccce1a9c 100644 --- a/homeassistant/components/camera/axis.py +++ b/homeassistant/components/camera/axis.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/camera.axis/ import logging from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) from homeassistant.components.camera.mjpeg import ( CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) @@ -19,38 +19,44 @@ DOMAIN = 'axis' DEPENDENCIES = [DOMAIN] -def _get_image_url(host, mode): +def _get_image_url(host, port, mode): if mode == 'mjpeg': - return 'http://{}/axis-cgi/mjpg/video.cgi'.format(host) + return 'http://{}:{}/axis-cgi/mjpg/video.cgi'.format(host, port) elif mode == 'single': - return 'http://{}/axis-cgi/jpg/image.cgi'.format(host) + return 'http://{}:{}/axis-cgi/jpg/image.cgi'.format(host, port) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Axis camera.""" - config = { + camera_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_MJPEG_URL: _get_image_url(discovery_info[CONF_HOST], + str(discovery_info[CONF_PORT]), + 'mjpeg'), CONF_STILL_IMAGE_URL: _get_image_url(discovery_info[CONF_HOST], + str(discovery_info[CONF_PORT]), 'single'), CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, } - add_devices([AxisCamera(hass, config)]) + add_devices([AxisCamera(hass, + camera_config, + str(discovery_info[CONF_PORT]))]) class AxisCamera(MjpegCamera): """AxisCamera class.""" - def __init__(self, hass, config): + def __init__(self, hass, config, port): """Initialize Axis Communications camera component.""" super().__init__(hass, config) + self.port = port async_dispatcher_connect(hass, DOMAIN + '_' + config[CONF_NAME] + '_new_ip', self._new_ip) 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') + self._mjpeg_url = _get_image_url(host, self.port, 'mjpeg') + self._still_image_url = _get_image_url(host, self.port, 'single') diff --git a/homeassistant/components/camera/doorbird.py b/homeassistant/components/camera/doorbird.py new file mode 100644 index 00000000000..cf6b6b2871f --- /dev/null +++ b/homeassistant/components/camera/doorbird.py @@ -0,0 +1,90 @@ +"""Support for viewing the camera feed from a DoorBird video doorbell.""" + +import asyncio +import datetime +import logging +import voluptuous as vol + +import aiohttp +import async_timeout + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +DEPENDENCIES = ['doorbird'] + +_CAMERA_LIVE = "DoorBird Live" +_CAMERA_LAST_VISITOR = "DoorBird Last Ring" +_LIVE_INTERVAL = datetime.timedelta(seconds=1) +_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1) +_LOGGER = logging.getLogger(__name__) +_TIMEOUT = 10 # seconds + +CONF_SHOW_LAST_VISITOR = 'last_visitor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_SHOW_LAST_VISITOR, default=False): cv.boolean +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the DoorBird camera platform.""" + device = hass.data.get(DOORBIRD_DOMAIN) + + _LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LIVE) + entities = [DoorBirdCamera(device.live_image_url, _CAMERA_LIVE, + _LIVE_INTERVAL)] + + if config.get(CONF_SHOW_LAST_VISITOR): + _LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LAST_VISITOR) + entities.append(DoorBirdCamera(device.history_image_url(1), + _CAMERA_LAST_VISITOR, + _LAST_VISITOR_INTERVAL)) + + async_add_devices(entities) + _LOGGER.info("Added DoorBird camera(s)") + + +class DoorBirdCamera(Camera): + """The camera on a DoorBird device.""" + + def __init__(self, url, name, interval=None): + """Initialize the camera on a DoorBird device.""" + self._url = url + self._name = name + self._last_image = None + self._interval = interval or datetime.timedelta + self._last_update = datetime.datetime.min + super().__init__() + + @property + def name(self): + """Get the name of the camera.""" + return self._name + + @asyncio.coroutine + def async_camera_image(self): + """Pull a still image from the camera.""" + now = datetime.datetime.now() + + if self._last_image and now - self._last_update < self._interval: + return self._last_image + + try: + websession = async_get_clientsession(self.hass) + + with async_timeout.timeout(_TIMEOUT, loop=self.hass.loop): + response = yield from websession.get(self._url) + + self._last_image = yield from response.read() + self._last_update = now + return self._last_image + except asyncio.TimeoutError: + _LOGGER.error("Camera image timed out") + return self._last_image + except aiohttp.ClientError as error: + _LOGGER.error("Error getting camera image: %s", error) + return self._last_image diff --git a/homeassistant/components/camera/usps.py b/homeassistant/components/camera/usps.py index 545ea9798de..6c76d0d66d8 100644 --- a/homeassistant/components/camera/usps.py +++ b/homeassistant/components/camera/usps.py @@ -77,7 +77,7 @@ class USPSCamera(Camera): def model(self): """Return date of mail as model.""" try: - return 'Date: {}'.format(self._usps.mail[0]['date']) + return 'Date: {}'.format(str(self._usps.mail[0]['date'])) except IndexError: return None diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index 3203a10b391..685b6d64364 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_PORT from homeassistant.components.camera import Camera, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['uvcclient==0.10.0'] +REQUIREMENTS = ['uvcclient==0.10.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 9442b7da194..6af06323fd0 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -211,7 +211,7 @@ class GenericThermostat(ClimateDevice): """Handle heater switch state changes.""" if new_state is None: return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def _async_keep_alive(self, time): diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 688ded5e7c4..9bf44c9b9ab 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -44,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up climate(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -52,25 +52,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up climates for KNX platform configured within plattform.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXClimate(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up climate for KNX platform configured within plattform.""" import xknx climate = xknx.devices.Climate( @@ -97,7 +97,7 @@ def async_add_devices_config(hass, config, add_devices): group_address_operation_mode_comfort=config.get( CONF_OPERATION_MODE_COMFORT_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(climate) - add_devices([KNXClimate(hass, climate)]) + async_add_devices([KNXClimate(hass, climate)]) class KNXClimate(ClimateDevice): diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 8804f6d113f..44796f97166 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -4,10 +4,11 @@ import logging import voluptuous as vol -from . import http_api, cloud_api +from . import http_api, auth_api from .const import DOMAIN +REQUIREMENTS = ['warrant==0.2.0'] DEPENDENCIES = ['http'] CONF_MODE = 'mode' MODE_DEV = 'development' @@ -40,10 +41,7 @@ def async_setup(hass, config): 'mode': mode } - cloud = yield from cloud_api.async_load_auth(hass) - - if cloud is not None: - data['cloud'] = cloud + data['auth'] = yield from hass.async_add_job(auth_api.load_auth, hass) yield from http_api.async_setup(hass) return True diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py new file mode 100644 index 00000000000..0baadeece46 --- /dev/null +++ b/homeassistant/components/cloud/auth_api.py @@ -0,0 +1,270 @@ +"""Package to offer tools to authenticate with the cloud.""" +import json +import logging +import os + +from .const import AUTH_FILE, SERVERS +from .util import get_mode + +_LOGGER = logging.getLogger(__name__) + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + +class Unauthenticated(CloudError): + """Raised when authentication failed.""" + + +class UserNotFound(CloudError): + """Raised when a user is not found.""" + + +class UserNotConfirmed(CloudError): + """Raised when a user has not confirmed email yet.""" + + +class ExpiredCode(CloudError): + """Raised when an expired code is encoutered.""" + + +class InvalidCode(CloudError): + """Raised when an invalid code is submitted.""" + + +class PasswordChangeRequired(CloudError): + """Raised when a password change is required.""" + + def __init__(self, message='Password change required.'): + """Initialize a password change required error.""" + super().__init__(message) + + +class UnknownError(CloudError): + """Raised when an unknown error occurrs.""" + + +AWS_EXCEPTIONS = { + 'UserNotFoundException': UserNotFound, + 'NotAuthorizedException': Unauthenticated, + 'ExpiredCodeException': ExpiredCode, + 'UserNotConfirmedException': UserNotConfirmed, + 'PasswordResetRequiredException': PasswordChangeRequired, + 'CodeMismatchException': InvalidCode, +} + + +def _map_aws_exception(err): + """Map AWS exception to our exceptions.""" + ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError) + return ex(err.response['Error']['Message']) + + +def load_auth(hass): + """Load authentication from disk and verify it.""" + info = _read_info(hass) + + if info is None: + return Auth(hass) + + auth = Auth(hass, _cognito( + hass, + id_token=info['id_token'], + access_token=info['access_token'], + refresh_token=info['refresh_token'], + )) + + if auth.validate_auth(): + return auth + + return Auth(hass) + + +def register(hass, email, password): + """Register a new account.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.register(email, password) + except ClientError as err: + raise _map_aws_exception(err) + + +def confirm_register(hass, confirmation_code, email): + """Confirm confirmation code after registration.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.confirm_sign_up(confirmation_code, email) + except ClientError as err: + raise _map_aws_exception(err) + + +def forgot_password(hass, email): + """Initiate forgotten password flow.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.initiate_forgot_password() + except ClientError as err: + raise _map_aws_exception(err) + + +def confirm_forgot_password(hass, confirmation_code, email, new_password): + """Confirm forgotten password code and change password.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.confirm_forgot_password(confirmation_code, new_password) + except ClientError as err: + raise _map_aws_exception(err) + + +class Auth(object): + """Class that holds Cloud authentication.""" + + def __init__(self, hass, cognito=None): + """Initialize Hass cloud info object.""" + self.hass = hass + self.cognito = cognito + self.account = None + + @property + def is_logged_in(self): + """Return if user is logged in.""" + return self.account is not None + + def validate_auth(self): + """Validate that the contained auth is valid.""" + from botocore.exceptions import ClientError + + try: + self._refresh_account_info() + except ClientError as err: + if err.response['Error']['Code'] != 'NotAuthorizedException': + _LOGGER.error('Unexpected error verifying auth: %s', err) + return False + + try: + self.renew_access_token() + self._refresh_account_info() + except ClientError: + _LOGGER.error('Unable to refresh auth token: %s', err) + return False + + return True + + def login(self, username, password): + """Login using a username and password.""" + from botocore.exceptions import ClientError + from warrant.exceptions import ForceChangePasswordException + + cognito = _cognito(self.hass, username=username) + + try: + cognito.authenticate(password=password) + self.cognito = cognito + self._refresh_account_info() + _write_info(self.hass, self) + + except ForceChangePasswordException as err: + raise PasswordChangeRequired + + except ClientError as err: + raise _map_aws_exception(err) + + def _refresh_account_info(self): + """Refresh the account info. + + Raises boto3 exceptions. + """ + self.account = self.cognito.get_user() + + def renew_access_token(self): + """Refresh token.""" + from botocore.exceptions import ClientError + + try: + self.cognito.renew_access_token() + _write_info(self.hass, self) + return True + except ClientError as err: + _LOGGER.error('Error refreshing token: %s', err) + return False + + def logout(self): + """Invalidate token.""" + from botocore.exceptions import ClientError + + try: + self.cognito.logout() + self.account = None + _write_info(self.hass, self) + except ClientError as err: + raise _map_aws_exception(err) + + +def _read_info(hass): + """Read auth file.""" + path = hass.config.path(AUTH_FILE) + + if not os.path.isfile(path): + return None + + with open(path) as file: + return json.load(file).get(get_mode(hass)) + + +def _write_info(hass, auth): + """Write auth info for specified mode. + + Pass in None for data to remove authentication for that mode. + """ + path = hass.config.path(AUTH_FILE) + mode = get_mode(hass) + + if os.path.isfile(path): + with open(path) as file: + content = json.load(file) + else: + content = {} + + if auth.is_logged_in: + content[mode] = { + 'id_token': auth.cognito.id_token, + 'access_token': auth.cognito.access_token, + 'refresh_token': auth.cognito.refresh_token, + } + else: + content.pop(mode, None) + + with open(path, 'wt') as file: + file.write(json.dumps(content, indent=4, sort_keys=True)) + + +def _cognito(hass, **kwargs): + """Get the client credentials.""" + from warrant import Cognito + + mode = get_mode(hass) + + info = SERVERS.get(mode) + + if info is None: + raise ValueError('Mode {} is not supported.'.format(mode)) + + cognito = Cognito( + user_pool_id=info['identity_pool_id'], + client_id=info['client_id'], + user_pool_region=info['region'], + access_key=info['access_key_id'], + secret_key=info['secret_access_key'], + **kwargs + ) + + return cognito diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py deleted file mode 100644 index 6429da14516..00000000000 --- a/homeassistant/components/cloud/cloud_api.py +++ /dev/null @@ -1,297 +0,0 @@ -"""Package to offer tools to communicate with the cloud.""" -import asyncio -from datetime import timedelta -import json -import logging -import os -from urllib.parse import urljoin - -import aiohttp -import async_timeout - -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util.dt import utcnow - -from .const import AUTH_FILE, REQUEST_TIMEOUT, SERVERS -from .util import get_mode - -_LOGGER = logging.getLogger(__name__) - - -URL_CREATE_TOKEN = 'o/token/' -URL_REVOKE_TOKEN = 'o/revoke_token/' -URL_ACCOUNT = 'account.json' - - -class CloudError(Exception): - """Base class for cloud related errors.""" - - def __init__(self, reason=None, status=None): - """Initialize a cloud error.""" - super().__init__(reason) - self.status = status - - -class Unauthenticated(CloudError): - """Raised when authentication failed.""" - - -class UnknownError(CloudError): - """Raised when an unknown error occurred.""" - - -@asyncio.coroutine -def async_load_auth(hass): - """Load authentication from disk and verify it.""" - auth = yield from hass.async_add_job(_read_auth, hass) - - if not auth: - return None - - cloud = Cloud(hass, auth) - - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - auth_check = yield from cloud.async_refresh_account_info() - - if not auth_check: - _LOGGER.error('Unable to validate credentials.') - return None - - return cloud - - except asyncio.TimeoutError: - _LOGGER.error('Unable to reach server to validate credentials.') - return None - - -@asyncio.coroutine -def async_login(hass, username, password, scope=None): - """Get a token using a username and password. - - Returns a coroutine. - """ - data = { - 'grant_type': 'password', - 'username': username, - 'password': password - } - if scope is not None: - data['scope'] = scope - - auth = yield from _async_get_token(hass, data) - - yield from hass.async_add_job(_write_auth, hass, auth) - - return Cloud(hass, auth) - - -@asyncio.coroutine -def _async_get_token(hass, data): - """Get a new token and return it as a dictionary. - - Raises exceptions when errors occur: - - Unauthenticated - - UnknownError - """ - session = async_get_clientsession(hass) - auth = aiohttp.BasicAuth(*_client_credentials(hass)) - - try: - req = yield from session.post( - _url(hass, URL_CREATE_TOKEN), - data=data, - auth=auth - ) - - if req.status == 401: - _LOGGER.error('Cloud login failed: %d', req.status) - raise Unauthenticated(status=req.status) - elif req.status != 200: - _LOGGER.error('Cloud login failed: %d', req.status) - raise UnknownError(status=req.status) - - response = yield from req.json() - response['expires_at'] = \ - (utcnow() + timedelta(seconds=response['expires_in'])).isoformat() - - return response - - except aiohttp.ClientError: - raise UnknownError() - - -class Cloud: - """Store Hass Cloud info.""" - - def __init__(self, hass, auth): - """Initialize Hass cloud info object.""" - self.hass = hass - self.auth = auth - self.account = None - - @property - def access_token(self): - """Return access token.""" - return self.auth['access_token'] - - @property - def refresh_token(self): - """Get refresh token.""" - return self.auth['refresh_token'] - - @asyncio.coroutine - def async_refresh_account_info(self): - """Refresh the account info.""" - req = yield from self.async_request('get', URL_ACCOUNT) - - if req.status != 200: - return False - - self.account = yield from req.json() - return True - - @asyncio.coroutine - def async_refresh_access_token(self): - """Get a token using a refresh token.""" - try: - self.auth = yield from _async_get_token(self.hass, { - 'grant_type': 'refresh_token', - 'refresh_token': self.refresh_token, - }) - - yield from self.hass.async_add_job( - _write_auth, self.hass, self.auth) - - return True - except CloudError: - return False - - @asyncio.coroutine - def async_revoke_access_token(self): - """Revoke active access token.""" - session = async_get_clientsession(self.hass) - client_id, client_secret = _client_credentials(self.hass) - data = { - 'token': self.access_token, - 'client_id': client_id, - 'client_secret': client_secret - } - try: - req = yield from session.post( - _url(self.hass, URL_REVOKE_TOKEN), - data=data, - ) - - if req.status != 200: - _LOGGER.error('Cloud logout failed: %d', req.status) - raise UnknownError(status=req.status) - - self.auth = None - yield from self.hass.async_add_job( - _write_auth, self.hass, None) - - except aiohttp.ClientError: - raise UnknownError() - - @asyncio.coroutine - def async_request(self, method, path, **kwargs): - """Make a request to Home Assistant cloud. - - Will refresh the token if necessary. - """ - session = async_get_clientsession(self.hass) - url = _url(self.hass, path) - - if 'headers' not in kwargs: - kwargs['headers'] = {} - - kwargs['headers']['authorization'] = \ - 'Bearer {}'.format(self.access_token) - - request = yield from session.request(method, url, **kwargs) - - if request.status != 403: - return request - - # Maybe token expired. Try refreshing it. - reauth = yield from self.async_refresh_access_token() - - if not reauth: - return request - - # Release old connection back to the pool. - yield from request.release() - - kwargs['headers']['authorization'] = \ - 'Bearer {}'.format(self.access_token) - - # If we are not already fetching the account info, - # refresh the account info. - - if path != URL_ACCOUNT: - yield from self.async_refresh_account_info() - - request = yield from session.request(method, url, **kwargs) - - return request - - -def _read_auth(hass): - """Read auth file.""" - path = hass.config.path(AUTH_FILE) - - if not os.path.isfile(path): - return None - - with open(path) as file: - return json.load(file).get(get_mode(hass)) - - -def _write_auth(hass, data): - """Write auth info for specified mode. - - Pass in None for data to remove authentication for that mode. - """ - path = hass.config.path(AUTH_FILE) - mode = get_mode(hass) - - if os.path.isfile(path): - with open(path) as file: - content = json.load(file) - else: - content = {} - - if data is None: - content.pop(mode, None) - else: - content[mode] = data - - with open(path, 'wt') as file: - file.write(json.dumps(content, indent=4, sort_keys=True)) - - -def _client_credentials(hass): - """Get the client credentials. - - Async friendly. - """ - mode = get_mode(hass) - - if mode not in SERVERS: - raise ValueError('Mode {} is not supported.'.format(mode)) - - return SERVERS[mode]['client_id'], SERVERS[mode]['client_secret'] - - -def _url(hass, path): - """Generate a url for the cloud. - - Async friendly. - """ - mode = get_mode(hass) - - if mode not in SERVERS: - raise ValueError('Mode {} is not supported.'.format(mode)) - - return urljoin(SERVERS[mode]['host'], path) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index f55a4be21a2..81beab1891b 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -5,10 +5,10 @@ AUTH_FILE = '.cloud' SERVERS = { 'development': { - 'host': 'http://localhost:8000', - 'client_id': 'HBhQxeV8H4aFBcs7jrZUeeDud0FjGEJJSZ9G6gNu', - 'client_secret': ('V1qw2NhB32cSAlP7DOezjgWNgn7ZKgq0jvVZoYSI0KCmg9rg7q4' - 'BSzoebnQnX6tuHCJiZjm2479mZmmtf2LOUdnSqOqkSpjc3js7Wu' - 'VBJrRyfgTVd43kbrEQtuOiaUpK') + 'client_id': '3k755iqfcgv8t12o4pl662mnos', + 'identity_pool_id': 'us-west-2_vDOfweDJo', + 'region': 'us-west-2', + 'access_key_id': 'AKIAJGRK7MILPRJTT2ZQ', + 'secret_access_key': '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' } } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 661cc8a7ba1..941df7648a6 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,14 +1,16 @@ """The HTTP api to control the cloud integration.""" import asyncio +from functools import wraps import logging import voluptuous as vol import async_timeout -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import ( + HomeAssistantView, RequestDataValidator) -from . import cloud_api -from .const import DOMAIN, REQUEST_TIMEOUT +from . import auth_api +from .const import REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -19,6 +21,42 @@ def async_setup(hass): hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) hass.http.register_view(CloudAccountView) + hass.http.register_view(CloudRegisterView) + hass.http.register_view(CloudConfirmRegisterView) + hass.http.register_view(CloudForgotPasswordView) + hass.http.register_view(CloudConfirmForgotPasswordView) + + +_CLOUD_ERRORS = { + auth_api.UserNotFound: (400, "User does not exist."), + auth_api.UserNotConfirmed: (400, 'Email not confirmed.'), + auth_api.Unauthenticated: (401, 'Authentication failed.'), + auth_api.PasswordChangeRequired: (400, 'Password change required.'), + auth_api.ExpiredCode: (400, 'Confirmation code has expired.'), + auth_api.InvalidCode: (400, 'Invalid confirmation code.'), + asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.') +} + + +def _handle_cloud_errors(handler): + """Helper method to handle auth errors.""" + @asyncio.coroutine + @wraps(handler) + def error_handler(view, request, *args, **kwargs): + """Handle exceptions that raise from the wrapped request handler.""" + try: + result = yield from handler(view, request, *args, **kwargs) + return result + + except (auth_api.CloudError, asyncio.TimeoutError) as err: + err_info = _CLOUD_ERRORS.get(err.__class__) + if err_info is None: + err_info = (502, 'Unexpected error: {}'.format(err)) + status, msg = err_info + return view.json_message(msg, status_code=status, + message_code=err.__class__.__name__) + + return error_handler class CloudLoginView(HomeAssistantView): @@ -26,52 +64,23 @@ class CloudLoginView(HomeAssistantView): url = '/api/cloud/login' name = 'api:cloud:login' - schema = vol.Schema({ - vol.Required('username'): str, - vol.Required('password'): str, - }) @asyncio.coroutine - def post(self, request): - """Validate config and return results.""" - try: - data = yield from request.json() - except ValueError: - _LOGGER.error('Login with invalid JSON') - return self.json_message('Invalid JSON.', 400) - - try: - self.schema(data) - except vol.Invalid as err: - _LOGGER.error('Login with invalid formatted data') - return self.json_message( - 'Message format incorrect: {}'.format(err), 400) - + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): str, + })) + def post(self, request, data): + """Handle login request.""" hass = request.app['hass'] - phase = 1 - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - cloud = yield from cloud_api.async_login( - hass, data['username'], data['password']) + auth = hass.data['cloud']['auth'] - phase += 1 + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job(auth.login, data['email'], + data['password']) - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from cloud.async_refresh_account_info() - - except cloud_api.Unauthenticated: - return self.json_message( - 'Authentication failed (phase {}).'.format(phase), 401) - except cloud_api.UnknownError: - return self.json_message( - 'Unknown error occurred (phase {}).'.format(phase), 500) - except asyncio.TimeoutError: - return self.json_message( - 'Unable to reach Home Assistant cloud ' - '(phase {}).'.format(phase), 502) - - hass.data[DOMAIN]['cloud'] = cloud - return self.json(cloud.account) + return self.json(_auth_data(auth)) class CloudLogoutView(HomeAssistantView): @@ -81,39 +90,133 @@ class CloudLogoutView(HomeAssistantView): name = 'api:cloud:logout' @asyncio.coroutine + @_handle_cloud_errors def post(self, request): - """Validate config and return results.""" + """Handle logout request.""" hass = request.app['hass'] - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from \ - hass.data[DOMAIN]['cloud'].async_revoke_access_token() + auth = hass.data['cloud']['auth'] - hass.data[DOMAIN].pop('cloud') + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job(auth.logout) - return self.json({ - 'result': 'ok', - }) - except asyncio.TimeoutError: - return self.json_message("Could not reach the server.", 502) - except cloud_api.UnknownError as err: - return self.json_message( - "Error communicating with the server ({}).".format(err.status), - 502) + return self.json_message('ok') class CloudAccountView(HomeAssistantView): - """Log out of the Home Assistant cloud.""" + """View to retrieve account info.""" url = '/api/cloud/account' name = 'api:cloud:account' @asyncio.coroutine def get(self, request): - """Validate config and return results.""" + """Get account info.""" hass = request.app['hass'] + auth = hass.data['cloud']['auth'] - if 'cloud' not in hass.data[DOMAIN]: + if not auth.is_logged_in: return self.json_message('Not logged in', 400) - return self.json(hass.data[DOMAIN]['cloud'].account) + return self.json(_auth_data(auth)) + + +class CloudRegisterView(HomeAssistantView): + """Register on the Home Assistant cloud.""" + + url = '/api/cloud/register' + name = 'api:cloud:register' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): vol.All(str, vol.Length(min=6)), + })) + def post(self, request, data): + """Handle registration request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.register, hass, data['email'], data['password']) + + return self.json_message('ok') + + +class CloudConfirmRegisterView(HomeAssistantView): + """Confirm registration on the Home Assistant cloud.""" + + url = '/api/cloud/confirm_register' + name = 'api:cloud:confirm_register' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('confirmation_code'): str, + vol.Required('email'): str, + })) + def post(self, request, data): + """Handle registration confirmation request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.confirm_register, hass, data['confirmation_code'], + data['email']) + + return self.json_message('ok') + + +class CloudForgotPasswordView(HomeAssistantView): + """View to start Forgot Password flow..""" + + url = '/api/cloud/forgot_password' + name = 'api:cloud:forgot_password' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + })) + def post(self, request, data): + """Handle forgot password request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.forgot_password, hass, data['email']) + + return self.json_message('ok') + + +class CloudConfirmForgotPasswordView(HomeAssistantView): + """View to finish Forgot Password flow..""" + + url = '/api/cloud/confirm_forgot_password' + name = 'api:cloud:confirm_forgot_password' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('confirmation_code'): str, + vol.Required('email'): str, + vol.Required('new_password'): vol.All(str, vol.Length(min=6)) + })) + def post(self, request, data): + """Handle forgot password confirm request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.confirm_forgot_password, hass, + data['confirmation_code'], data['email'], + data['new_password']) + + return self.json_message('ok') + + +def _auth_data(auth): + """Generate the auth data JSON response.""" + return { + 'email': auth.account.email + } diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index a40e1f64043..53fa200a1b1 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -55,6 +55,7 @@ class ZWaveNodeValueView(HomeAssistantView): 'label': entity_values.primary.label, 'index': entity_values.primary.index, 'instance': entity_values.primary.instance, + 'poll_intensity': entity_values.primary.poll_intensity, } return self.json(values_data) diff --git a/homeassistant/components/cover/abode.py b/homeassistant/components/cover/abode.py index b09c9e5e007..6eb0369aa3f 100644 --- a/homeassistant/components/cover/abode.py +++ b/homeassistant/components/cover/abode.py @@ -6,7 +6,7 @@ https://home-assistant.io/components/cover.abode/ """ import logging -from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN from homeassistant.components.cover import CoverDevice @@ -19,31 +19,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Abode cover devices.""" import abodepy.helpers.constants as CONST - abode = hass.data[DATA_ABODE] + data = hass.data[ABODE_DOMAIN] - sensors = [] - for sensor in abode.get_devices(type_filter=(CONST.DEVICE_SECURE_BARRIER)): - sensors.append(AbodeCover(abode, sensor)) + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER): + if data.is_excluded(device): + continue - add_devices(sensors) + devices.append(AbodeCover(data, device)) + + data.devices.extend(devices) + + add_devices(devices) class AbodeCover(AbodeDevice, CoverDevice): """Representation of an Abode cover.""" - def __init__(self, controller, device): - """Initialize the Abode device.""" - AbodeDevice.__init__(self, controller, device) - @property def is_closed(self): """Return true if cover is closed, else False.""" - return self._device.is_open is False + return not self._device.is_open - def close_cover(self): + def close_cover(self, **kwargs): """Issue close command to cover.""" self._device.close_cover() - def open_cover(self): + def open_cover(self, **kwargs): """Issue open command to cover.""" self._device.open_cover() diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index e4c2931983d..b840c780645 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -50,7 +50,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up cover(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -58,25 +58,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up covers for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXCover(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up cover for KNX platform configured within plattform.""" import xknx cover = xknx.devices.Cover( @@ -90,23 +90,20 @@ def async_add_devices_config(hass, config, add_devices): group_address_angle_state=config.get(CONF_ANGLE_STATE_ADDRESS), group_address_position=config.get(CONF_POSITION_ADDRESS), travel_time_down=config.get(CONF_TRAVELLING_TIME_DOWN), - travel_time_up=config.get(CONF_TRAVELLING_TIME_UP)) + travel_time_up=config.get(CONF_TRAVELLING_TIME_UP), + invert_position=config.get(CONF_INVERT_POSITION), + invert_angle=config.get(CONF_INVERT_ANGLE)) - invert_position = config.get(CONF_INVERT_POSITION) - invert_angle = config.get(CONF_INVERT_ANGLE) hass.data[DATA_KNX].xknx.devices.add(cover) - add_devices([KNXCover(hass, cover, invert_position, invert_angle)]) + async_add_devices([KNXCover(hass, cover)]) class KNXCover(CoverDevice): """Representation of a KNX cover.""" - def __init__(self, hass, device, invert_position=False, - invert_angle=False): + def __init__(self, hass, device): """Initialize the cover.""" self.device = device - self.invert_position = invert_position - self.invert_angle = invert_angle self.hass = hass self.async_register_callbacks() @@ -144,9 +141,7 @@ class KNXCover(CoverDevice): @property def current_cover_position(self): """Return the current position of the cover.""" - return int(self.from_knx_position( - self.device.current_position(), - self.invert_position)) + return self.device.current_position() @property def is_closed(self): @@ -172,8 +167,7 @@ class KNXCover(CoverDevice): """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] - knx_position = self.to_knx_position(position, self.invert_position) - yield from self.device.set_position(knx_position) + yield from self.device.set_position(position) self.start_auto_updater() @asyncio.coroutine @@ -187,17 +181,14 @@ class KNXCover(CoverDevice): """Return current tilt position of cover.""" if not self.device.supports_angle: return None - return int(self.from_knx_position( - self.device.angle, - self.invert_angle)) + return self.device.current_angle() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" if ATTR_TILT_POSITION in kwargs: - position = kwargs[ATTR_TILT_POSITION] - knx_position = self.to_knx_position(position, self.invert_angle) - yield from self.device.set_angle(knx_position) + tilt_position = kwargs[ATTR_TILT_POSITION] + yield from self.device.set_angle(tilt_position) def start_auto_updater(self): """Start the autoupdater to update HASS while cover is moving.""" @@ -215,25 +206,8 @@ class KNXCover(CoverDevice): def auto_updater_hook(self, now): """Callback for autoupdater.""" # pylint: disable=unused-argument - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self.device.position_reached(): self.stop_auto_updater() self.hass.add_job(self.device.auto_stop_if_necessary()) - - @staticmethod - def from_knx_position(raw, invert): - """Convert KNX position [0...255] to hass position [100...0].""" - position = round((raw/256)*100) - if not invert: - position = 100 - position - return position - - @staticmethod - def to_knx_position(value, invert): - """Convert hass position [100...0] to KNX position [0...255].""" - knx_position = round(value/100*255.4) - if not invert: - knx_position = 255-knx_position - print(value, " -> ", knx_position) - return knx_position diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index eab64fd7abb..8e197cc2e02 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -178,7 +178,7 @@ class MqttCover(CoverDevice): level = self.find_percentage_in_range(float(payload)) self._tilt_value = level - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def message_received(topic, payload, qos): @@ -203,7 +203,7 @@ class MqttCover(CoverDevice): payload) return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._state_topic is None: # Force into optimistic mode. @@ -275,7 +275,7 @@ class MqttCover(CoverDevice): if self._optimistic: # Optimistically assume that cover has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover(self, **kwargs): @@ -289,7 +289,7 @@ class MqttCover(CoverDevice): if self._optimistic: # Optimistically assume that cover has changed state. self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_stop_cover(self, **kwargs): @@ -309,7 +309,7 @@ class MqttCover(CoverDevice): self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_open_position - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): @@ -319,7 +319,7 @@ class MqttCover(CoverDevice): self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_closed_position - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index f9e059d3927..2e3ad7fff16 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -197,7 +197,7 @@ class CoverTemplate(CoverDevice): @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)) + self.async_schedule_update_ha_state(True) @callback def template_cover_startup(event): @@ -205,7 +205,7 @@ class CoverTemplate(CoverDevice): async_track_state_change( self.hass, self._entities, template_cover_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_cover_startup) @@ -271,7 +271,7 @@ class CoverTemplate(CoverDevice): yield from self._position_script.async_run({"position": 100}) if self._optimistic: self._position = 100 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover(self, **kwargs): @@ -282,7 +282,7 @@ class CoverTemplate(CoverDevice): yield from self._position_script.async_run({"position": 0}) if self._optimistic: self._position = 0 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_stop_cover(self, **kwargs): @@ -297,7 +297,7 @@ class CoverTemplate(CoverDevice): yield from self._position_script.async_run( {"position": self._position}) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_open_cover_tilt(self, **kwargs): @@ -305,7 +305,7 @@ class CoverTemplate(CoverDevice): self._tilt_value = 100 yield from self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): @@ -314,7 +314,7 @@ class CoverTemplate(CoverDevice): yield from self._tilt_script.async_run( {"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): @@ -322,7 +322,7 @@ class CoverTemplate(CoverDevice): self._tilt_value = kwargs[ATTR_TILT_POSITION] yield from self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_update(self): diff --git a/homeassistant/components/cover/xiaomi.py b/homeassistant/components/cover/xiaomi_aqara.py similarity index 94% rename from homeassistant/components/cover/xiaomi.py rename to homeassistant/components/cover/xiaomi_aqara.py index d0e7bfa6d7e..17d056a5010 100644 --- a/homeassistant/components/cover/xiaomi.py +++ b/homeassistant/components/cover/xiaomi_aqara.py @@ -2,7 +2,8 @@ import logging from homeassistant.components.cover import CoverDevice -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index cef5eabd901..79d8806fe22 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -19,9 +19,9 @@ _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pexpect==4.0.1'] _DEVICES_REGEX = re.compile( - r'(?P([^\s]+))\s+' + + r'(?P([^\s]+)?)\s+' + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + - r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+') + r'(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))\s+') PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 6ae038fd41c..05fe0b6997d 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.6.2'] +REQUIREMENTS = ['aioautomatic==0.6.3'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py new file mode 100644 index 00000000000..5a7db36e479 --- /dev/null +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -0,0 +1,121 @@ +""" +Support for Zyxel Keenetic NDMS2 based routers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.keenetic_ndms2/ +""" +import logging +from collections import namedtuple + +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, CONF_PASSWORD, CONF_USERNAME +) + +_LOGGER = logging.getLogger(__name__) + +# Interface name to track devices for. Most likely one will not need to +# change it from default 'Home'. This is needed not to track Guest WI-FI- +# clients and router itself +CONF_INTERFACE = 'interface' + +DEFAULT_INTERFACE = 'Home' + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, +}) + + +def get_scanner(_hass, config): + """Validate the configuration and return a Nmap scanner.""" + scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +Device = namedtuple('Device', ['mac', 'name']) + + +class KeeneticNDMS2DeviceScanner(DeviceScanner): + """This class scans for devices using keenetic NDMS2 web interface.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.last_results = [] + + self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST] + self._interface = config[CONF_INTERFACE] + + self._username = config.get(CONF_USERNAME) + self._password = config.get(CONF_PASSWORD) + + self.success_init = self._update_info() + _LOGGER.info("Scanner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.mac for device in self.last_results] + + def get_device_name(self, mac): + """Return the name of the given device or None if we don't know.""" + filter_named = [device.name for device in self.last_results + if device.mac == mac] + + if filter_named: + return filter_named[0] + return None + + def _update_info(self): + """Get ARP from keenetic router.""" + _LOGGER.info("Fetching...") + + last_results = [] + + # doing a request + try: + from requests.auth import HTTPDigestAuth + res = requests.get(self._url, timeout=10, auth=HTTPDigestAuth( + self._username, self._password + )) + except requests.exceptions.Timeout: + _LOGGER.error( + "Connection to the router timed out at URL %s", self._url) + return False + if res.status_code != 200: + _LOGGER.error( + "Connection failed with http code %s", res.status_code) + return False + try: + result = res.json() + except ValueError: + # If json decoder could not parse the response + _LOGGER.error("Failed to parse response from router") + return False + + # parsing response + for info in result: + if info.get('interface') != self._interface: + continue + mac = info.get('mac') + name = info.get('name') + # No address = no item :) + if mac is None: + continue + + last_results.append(Device(mac.upper(), name)) + + self.last_results = last_results + + _LOGGER.info("Request successful") + return True diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index b23008336ac..5c5c3c7c92e 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -42,7 +42,7 @@ VALIDATE_WAYPOINTS = 'waypoints' WAYPOINT_LAT_KEY = 'lat' WAYPOINT_LON_KEY = 'lon' -WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint' +WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoints' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index c757d9d1ce3..439b6258bcd 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.1.0'] +REQUIREMENTS = ['netdisco==1.2.0'] DOMAIN = 'discovery' @@ -34,6 +34,7 @@ SERVICE_HASSIO = 'hassio' SERVICE_AXIS = 'axis' SERVICE_APPLE_TV = 'apple_tv' SERVICE_WINK = 'wink' +SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -44,6 +45,7 @@ SERVICE_HANDLERS = { SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_WINK: ('wink', None), + SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), 'philips_hue': ('light', 'hue'), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), diff --git a/homeassistant/components/doorbird.py b/homeassistant/components/doorbird.py new file mode 100644 index 00000000000..421c85a0f94 --- /dev/null +++ b/homeassistant/components/doorbird.py @@ -0,0 +1,44 @@ +"""Support for a DoorBird video doorbell.""" + +import logging +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['DoorBirdPy==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'doorbird' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the DoorBird component.""" + device_ip = config[DOMAIN].get(CONF_HOST) + username = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + + from doorbirdpy import DoorBird + device = DoorBird(device_ip, username, password) + status = device.ready() + + if status[0]: + _LOGGER.info("Connected to DoorBird at %s as %s", device_ip, username) + hass.data[DOMAIN] = device + return True + elif status[1] == 401: + _LOGGER.error("Authorization rejected by DoorBird at %s", device_ip) + return False + else: + _LOGGER.error("Could not connect to DoorBird at %s: Error %s", + device_ip, str(status[1])) + return False diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py index 40a5d884aed..dda556ba6a4 100644 --- a/homeassistant/components/eight_sleep.py +++ b/homeassistant/components/eight_sleep.py @@ -209,7 +209,7 @@ class EightSleepUserEntity(Entity): @callback def async_eight_user_update(): """Update callback.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_USER, async_eight_user_update) @@ -233,7 +233,7 @@ class EightSleepHeatEntity(Entity): @callback def async_eight_heat_update(): """Update callback.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index ae0a26aaea4..ca056398d2b 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -129,7 +129,7 @@ class Config(object): if self.type == TYPE_ALEXA: _LOGGER.warning("Alexa type is deprecated and will be removed in a" - "future version") + " future version") # Get the IP address that will be passed to the Echo during discovery self.host_ip_addr = conf.get(CONF_HOST_IP) diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index f8d41424064..42a258cbf4b 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -136,7 +136,7 @@ USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1 # because the data object has not been initialized continue - if "M-SEARCH" in data.decode('utf-8'): + if "M-SEARCH" in data.decode('utf-8', errors='ignore'): # SSDP M-SEARCH method received, respond to it with our info resp_socket = socket.socket( socket.AF_INET, socket.SOCK_DGRAM) diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index bc732aa0aff..e76e11d4786 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -78,6 +78,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the MQTT fan platform.""" + if discovery_info is not None: + config = PLATFORM_SCHEMA(discovery_info) + async_add_devices([MqttFan( config.get(CONF_NAME), { @@ -160,7 +163,7 @@ class MqttFan(FanEntity): self._state = True elif payload == self._payload[STATE_OFF]: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -177,7 +180,7 @@ class MqttFan(FanEntity): self._speed = SPEED_MEDIUM elif payload == self._payload[SPEED_HIGH]: self._speed = SPEED_HIGH - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_SPEED_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -193,7 +196,7 @@ class MqttFan(FanEntity): self._oscillation = True elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]: self._oscillation = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -287,7 +290,7 @@ class MqttFan(FanEntity): if self._optimistic_speed: self._speed = speed - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_oscillate(self, oscillating: bool) -> None: @@ -309,4 +312,4 @@ class MqttFan(FanEntity): if self._optimistic_oscillation: self._oscillation = oscillating - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index 887d07e5855..f5efa1ef623 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -242,7 +242,7 @@ class FFmpegBase(Entity): def async_start_handle(event): """Start FFmpeg process.""" yield from self._async_start_ffmpeg(None) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, async_start_handle) diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 6d199a86a50..70e7e777510 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -92,6 +92,18 @@ {% if not dev_mode %} {% endif %} + {% if panel_url -%} @@ -100,19 +112,5 @@ {% for extra_url in extra_urls -%} {% endfor -%} - - diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 87ccbf55075..b5edb751d50 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,10 +3,10 @@ FINGERPRINTS = { "compatibility.js": "1686167ff210e001f063f5c606b2e74b", "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "6b0a95408d9ee869d0fe20c374077ed4", + "frontend.html": "7e13ce36d3141182a62a5b061e87e77a", "mdi.html": "89074face5529f5fe6fbae49ecb3e88b", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "0b985cbf668b16bca9f34727036c7139", + "panels/ha-panel-config.html": "61f65e75e39368e07441d7d6a4e36ae3", "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 2dc0bb5f156..60713690c44 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -70,7 +70,7 @@ /* Silent scrolling should not run CSS transitions */ :host([silent-scroll]) ::slotted(app-toolbar:first-of-type), :host([silent-scroll]) ::slotted([sticky]){transition:none !important;}