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 -%}
-
-