diff --git a/.coveragerc b/.coveragerc index 9596b29534d..4fa8e04e694 100644 --- a/.coveragerc +++ b/.coveragerc @@ -53,6 +53,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 @@ -170,6 +176,9 @@ omit = homeassistant/components/tellstick.py homeassistant/components/*/tellstick.py + homeassistant/components/tesla.py + homeassistant/components/*/tesla.py + homeassistant/components/*/thinkingcleaner.py homeassistant/components/tradfri.py @@ -205,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 @@ -244,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 @@ -280,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 @@ -328,6 +339,7 @@ omit = homeassistant/components/light/tplink.py homeassistant/components/light/tradfri.py homeassistant/components/light/x10.py + homeassistant/components/light/xiaomi_miio.py homeassistant/components/light/yeelight.py homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py @@ -380,6 +392,8 @@ omit = homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py homeassistant/components/media_player/yamaha.py + homeassistant/components/media_player/yamaha_musiccast.py + homeassistant/components/mycroft.py homeassistant/components/notify/aws_lambda.py homeassistant/components/notify/aws_sns.py homeassistant/components/notify/aws_sqs.py @@ -397,6 +411,7 @@ omit = homeassistant/components/notify/llamalab_automate.py homeassistant/components/notify/matrix.py homeassistant/components/notify/message_bird.py + homeassistant/components/notify/mycroft.py homeassistant/components/notify/nfandroidtv.py homeassistant/components/notify/nma.py homeassistant/components/notify/prowl.py @@ -420,6 +435,7 @@ omit = homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py + homeassistant/components/sensor/airvisual.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py @@ -445,6 +461,7 @@ omit = homeassistant/components/sensor/dovado.py homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/dublin_bus_transport.py + homeassistant/components/sensor/dwd_weather_warnings.py homeassistant/components/sensor/ebox.py homeassistant/components/sensor/eddystone_temperature.py homeassistant/components/sensor/eliqonline.py @@ -480,6 +497,7 @@ omit = homeassistant/components/sensor/metoffice.py homeassistant/components/sensor/miflora.py homeassistant/components/sensor/modem_callerid.py + homeassistant/components/sensor/mopar.py homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/mvglive.py homeassistant/components/sensor/netdata.py @@ -517,6 +535,7 @@ omit = homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/synologydsm.py homeassistant/components/sensor/systemmonitor.py + homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py homeassistant/components/sensor/time_date.py @@ -529,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 @@ -555,13 +575,13 @@ 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/* homeassistant/components/thingspeak.py homeassistant/components/tts/amazon_polly.py homeassistant/components/tts/picotts.py - homeassistant/components/upnp.py homeassistant/components/vacuum/roomba.py homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py @@ -571,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/README.rst b/README.rst index 039e8a922af..7f0d41b00ea 100644 --- a/README.rst +++ b/README.rst @@ -33,10 +33,6 @@ of a component, check the `Home Assistant help section 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/__init__.py b/homeassistant/components/__init__.py index 1d437d35da7..6db147a5f59 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -101,6 +101,12 @@ def reload_core_config(hass): hass.services.call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG) +@asyncio.coroutine +def async_reload_core_config(hass): + """Reload the core config.""" + yield from hass.services.async_call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG) + + @asyncio.coroutine def async_setup(hass, config): """Set up general services related to Home Assistant.""" diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 677fcab4f5d..fe35d7b1b8b 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -4,52 +4,140 @@ This component provides basic support for Abode Home Security system. For more details about this component, please refer to the documentation at 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.const import CONF_USERNAME, CONF_PASSWORD, CONF_NAME +from homeassistant.helpers.entity import Entity +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.7.1'] +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 = 'data_abode' -DEFAULT_ENTITY_NAMESPACE = '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', + '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.""" + 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: - data = AbodeData(username, password) - hass.data[DATA_ABODE] = data - - for component in ['binary_sensor', 'alarm_control_panel']: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - 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.' @@ -58,18 +146,209 @@ 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 -class AbodeData: - """Shared Abode data.""" +def setup_hass_services(hass): + """Home assistant services.""" + from abodepy.exceptions import AbodeException - def __init__(self, username, password): - """Initialize Abode oject.""" - import abodepy + def change_setting(call): + """Change an Abode system setting.""" + setting = call.data.get(ATTR_SETTING) + value = call.data.get(ATTR_VALUE) - self.abode = abodepy.Abode(username, password) - self.devices = self.abode.get_devices() + try: + hass.data[DOMAIN].abode.set_setting(setting, value) + except AbodeException as ex: + _LOGGER.warning(ex) - _LOGGER.debug("Abode Security set up with %s devices", - len(self.devices)) + 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.""" + 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 setup_abode_events(hass): + """Event callbacks.""" + import abodepy.helpers.timeline as TIMELINE + + 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, data, device): + """Initialize a sensor for Abode device.""" + self._data = data + self._device = device + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + self.hass.async_add_job( + self._data.abode.events.add_device_callback, + self._device.device_id, self._update_callback + ) + + @property + def should_poll(self): + """Return the polling state.""" + return self._data.polling + + def update(self): + """Update automation state.""" + self._device.refresh() + + @property + def name(self): + """Return the name of the sensor.""" + return self._device.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_low': self._device.battery_low, + '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 7d7ce931c20..aa4e86a2318 100644 --- a/homeassistant/components/alarm_control_panel/abode.py +++ b/homeassistant/components/alarm_control_panel/abode.py @@ -6,10 +6,12 @@ https://home-assistant.io/components/alarm_control_panel.abode/ """ import logging -from homeassistant.components.abode import (DATA_ABODE, DEFAULT_NAME) -from homeassistant.const import (STATE_ALARM_ARMED_AWAY, +from homeassistant.components.abode import ( + 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) -import homeassistant.components.alarm_control_panel as alarm + DEPENDENCIES = ['abode'] @@ -20,29 +22,22 @@ ICON = 'mdi:security' def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - data = hass.data.get(DATA_ABODE) + data = hass.data[ABODE_DOMAIN] - add_devices([AbodeAlarm(hass, data, data.abode.get_alarm())]) + alarm_devices = [AbodeAlarm(data, data.abode.get_alarm(), data.name)] + + data.devices.extend(alarm_devices) + + add_devices(alarm_devices) -class AbodeAlarm(alarm.AlarmControlPanel): +class AbodeAlarm(AbodeDevice, AlarmControlPanel): """An alarm_control_panel implementation for Abode.""" - def __init__(self, hass, data, device): + def __init__(self, data, device, name): """Initialize the alarm control panel.""" - super(AbodeAlarm, self).__init__() - self._device = device - self._name = "{0}".format(DEFAULT_NAME) - - @property - def should_poll(self): - """Return the polling state.""" - return True - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + super().__init__(data, device) + self._name = name @property def icon(self): @@ -52,11 +47,11 @@ class AbodeAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._device.mode == "standby": + if self._device.is_standby: state = STATE_ALARM_DISARMED - elif self._device.mode == "away": + elif self._device.is_away: state = STATE_ALARM_ARMED_AWAY - elif self._device.mode == "home": + elif self._device.is_home: state = STATE_ALARM_ARMED_HOME else: state = None @@ -65,18 +60,26 @@ class AbodeAlarm(alarm.AlarmControlPanel): def alarm_disarm(self, code=None): """Send disarm command.""" self._device.set_standby() - self.schedule_update_ha_state() def alarm_arm_home(self, code=None): """Send arm home command.""" self._device.set_home() - self.schedule_update_ha_state() def alarm_arm_away(self, code=None): """Send arm away command.""" self._device.set_away() - self.schedule_update_ha_state() - def update(self): - """Update the device state.""" - self._device.refresh() + @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.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_backup': self._device.battery, + 'cellular_backup': self._device.is_cellular + } 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/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index df815424ee9..291d4bc80b5 100755 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -107,7 +107,7 @@ class Concord232Alarm(alarm.AlarmControlPanel): newstate = STATE_ALARM_ARMED_AWAY if not newstate == self._state: - _LOGGER.info("State Chnage from %s to %s", self._state, newstate) + _LOGGER.info("State Change from %s to %s", self._state, newstate) self._state = newstate return self._state 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/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index fe7db95651b..fbafe061334 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) -REQUIREMENTS = ['pythonegardia==1.0.18'] +REQUIREMENTS = ['pythonegardia==1.0.20'] _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,7 @@ CONF_REPORT_SERVER_PORT = 'report_server_port' DEFAULT_NAME = 'Egardia' DEFAULT_PORT = 80 DEFAULT_REPORT_SERVER_ENABLED = False -DEFAULT_REPORT_SERVER_PORT = 85 +DEFAULT_REPORT_SERVER_PORT = 52010 DOMAIN = 'egardia' NOTIFICATION_ID = 'egardia_notification' @@ -154,8 +154,9 @@ class EgardiaAlarm(alarm.AlarmControlPanel): def update(self): """Update the alarm status.""" - status = self._egardiasystem.getstate() - self.parsestatus(status) + if not self._rs_enabled: + status = self._egardiasystem.getstate() + self.parsestatus(status) def alarm_disarm(self, code=None): """Send disarm command.""" 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 97820ab4b2b..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,7 +25,28 @@ DEFAULT_PENDING_TIME = 60 DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False -PLATFORM_SCHEMA = vol.Schema({ +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' + + +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, @@ -34,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__) @@ -47,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 )]) @@ -61,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.""" @@ -87,24 +118,27 @@ 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 - return self._pre_trigger_state + else: + 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.""" @@ -124,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.""" @@ -183,3 +206,13 @@ class ManualAlarm(alarm.AlarmControlPanel): if not check: _LOGGER.warning("Invalid code given for %s", state) return check + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index b554a667b2a..44247616b59 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.manual_mqtt/ """ import asyncio +import copy import datetime import logging @@ -13,9 +14,9 @@ import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm import homeassistant.util.dt as dt_util from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM, - CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, + CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) import homeassistant.components.mqtt as mqtt @@ -28,6 +29,7 @@ from homeassistant.helpers.event import track_point_in_time CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' +CONF_PAYLOAD_ARM_NIGHT = 'payload_arm_night' DEFAULT_ALARM_NAME = 'HA Alarm' DEFAULT_PENDING_TIME = 60 @@ -35,11 +37,32 @@ DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False DEFAULT_ARM_AWAY = 'ARM_AWAY' DEFAULT_ARM_HOME = 'ARM_HOME' +DEFAULT_ARM_NIGHT = 'ARM_NIGHT' DEFAULT_DISARM = 'DISARM' +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' + + +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)) +}) + DEPENDENCIES = ['mqtt'] -PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA = vol.Schema(vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): 'manual_mqtt', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, vol.Optional(CONF_CODE): cv.string, @@ -49,12 +72,17 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ 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, vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, + vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, -}) +}), _state_validator)) _LOGGER = logging.getLogger(__name__) @@ -73,7 +101,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(mqtt.CONF_QOS), config.get(CONF_PAYLOAD_DISARM), config.get(CONF_PAYLOAD_ARM_HOME), - config.get(CONF_PAYLOAD_ARM_AWAY))]) + config.get(CONF_PAYLOAD_ARM_AWAY), + config.get(CONF_PAYLOAD_ARM_NIGHT), + config)]) class ManualMQTTAlarm(alarm.AlarmControlPanel): @@ -89,7 +119,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): def __init__(self, hass, name, code, pending_time, trigger_time, disarm_after_trigger, state_topic, command_topic, qos, - payload_disarm, payload_arm_home, payload_arm_away): + payload_disarm, payload_arm_home, payload_arm_away, + payload_arm_night, config): """Init the manual MQTT alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass @@ -101,12 +132,18 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): 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]) + self._state_topic = state_topic self._command_topic = command_topic self._qos = qos self._payload_disarm = payload_disarm self._payload_arm_home = payload_arm_home self._payload_arm_away = payload_arm_away + self._payload_arm_night = payload_arm_night @property def should_poll(self): @@ -121,23 +158,27 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._state in (STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY) 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 - return self._pre_trigger_state + else: + 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.""" @@ -157,44 +198,47 @@ class ManualMQTTAlarm(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() + self._update_state(STATE_ALARM_ARMED_AWAY) - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + def alarm_arm_night(self, code=None): + """Send arm night command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + return + + 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.""" @@ -203,6 +247,16 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): _LOGGER.warning("Invalid code given for %s", state) return check + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr + def async_added_to_hass(self): """Subscribe mqtt events. @@ -221,6 +275,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): self.async_alarm_arm_home(self._code) elif payload == self._payload_arm_away: self.async_alarm_arm_away(self._code) + elif payload == self._payload_arm_night: + self.async_alarm_arm_night(self._code) else: _LOGGER.warning("Received unexpected payload: %s", payload) return 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..ae1ecb87f60 --- /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 incoming 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 formatted 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 formatted error response. + + Async friendly. + """ + return api_message(exc, request[ATTR_HEADER][ATTR_NAMESPACE]) + + +@asyncio.coroutine +def async_api_discovery(hass, request): + """Create a API formatted 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..5e02f80f229 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 @@ -17,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.4'] +REQUIREMENTS = ['pyatv==0.3.5'] _LOGGER = logging.getLogger(__name__) @@ -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/arlo.py b/homeassistant/components/arlo.py index 1ba2acb4fe0..0ab629cfbd4 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -12,7 +12,7 @@ from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -REQUIREMENTS = ['pyarlo==0.0.4'] +REQUIREMENTS = ['pyarlo==0.0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 3657724f679..51b2ea89f0f 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -12,16 +12,18 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID, - CONF_BELOW, CONF_ABOVE) -from homeassistant.helpers.event import async_track_state_change + CONF_BELOW, CONF_ABOVE, CONF_FOR) +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) from homeassistant.helpers import condition, config_validation as cv TRIGGER_SCHEMA = vol.All(vol.Schema({ vol.Required(CONF_PLATFORM): 'numeric_state', vol.Required(CONF_ENTITY_ID): cv.entity_ids, - CONF_BELOW: vol.Coerce(float), - CONF_ABOVE: vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Optional(CONF_ABOVE): vol.Coerce(float), vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), }), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE)) _LOGGER = logging.getLogger(__name__) @@ -33,15 +35,18 @@ def async_trigger(hass, config, action): entity_id = config.get(CONF_ENTITY_ID) below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) + time_delta = config.get(CONF_FOR) value_template = config.get(CONF_VALUE_TEMPLATE) + async_remove_track_same = None + if value_template is not None: value_template.hass = hass @callback - def state_automation_listener(entity, from_s, to_s): - """Listen for state changes and calls action.""" + def check_numeric_state(entity, from_s, to_s): + """Return True if they should trigger.""" if to_s is None: - return + return False variables = { 'trigger': { @@ -55,17 +60,56 @@ def async_trigger(hass, config, action): # If new one doesn't match, nothing to do if not condition.async_numeric_state( hass, to_s, below, above, value_template, variables): + return False + + return True + + @callback + def state_automation_listener(entity, from_s, to_s): + """Listen for state changes and calls action.""" + nonlocal async_remove_track_same + + if not check_numeric_state(entity, from_s, to_s): return + variables = { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': entity, + 'below': below, + 'above': above, + 'from_state': from_s, + 'to_state': to_s, + } + } + # Only match if old didn't exist or existed but didn't match # Written as: skip if old one did exist and matched if from_s is not None and condition.async_numeric_state( hass, from_s, below, above, value_template, variables): return - variables['trigger']['from_state'] = from_s - variables['trigger']['to_state'] = to_s + @callback + def call_action(): + """Call action with right context.""" + hass.async_run_job(action, variables) - hass.async_run_job(action, variables) + if not time_delta: + call_action() + return - return async_track_state_change(hass, entity_id, state_automation_listener) + async_remove_track_same = async_track_same_state( + hass, True, time_delta, call_action, entity_ids=entity_id, + async_check_func=check_numeric_state) + + unsub = async_track_state_change( + hass, entity_id, state_automation_listener) + + @callback + def async_remove(): + """Remove state listeners async.""" + unsub() + if async_remove_track_same: + async_remove_track_same() # pylint: disable=not-callable + + return async_remove diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 8ad5c40bb80..e7a01cb7115 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -8,28 +8,23 @@ import asyncio import voluptuous as vol from homeassistant.core import callback -import homeassistant.util.dt as dt_util -from homeassistant.const import MATCH_ALL, CONF_PLATFORM +from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR from homeassistant.helpers.event import ( - async_track_state_change, async_track_point_in_utc_time) + async_track_state_change, async_track_same_state) import homeassistant.helpers.config_validation as cv CONF_ENTITY_ID = 'entity_id' CONF_FROM = 'from' CONF_TO = 'to' -CONF_FOR = 'for' -TRIGGER_SCHEMA = vol.All( - vol.Schema({ - vol.Required(CONF_PLATFORM): 'state', - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - # These are str on purpose. Want to catch YAML conversions - CONF_FROM: str, - CONF_TO: str, - CONF_FOR: vol.All(cv.time_period, cv.positive_timedelta), - }), - cv.key_dependency(CONF_FOR, CONF_TO), -) +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'state', + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + # These are str on purpose. Want to catch YAML conversions + vol.Optional(CONF_FROM): str, + vol.Optional(CONF_TO): str, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), +}), cv.key_dependency(CONF_FOR, CONF_TO)) @asyncio.coroutine @@ -39,28 +34,15 @@ def async_trigger(hass, config, action): from_state = config.get(CONF_FROM, MATCH_ALL) to_state = config.get(CONF_TO, MATCH_ALL) time_delta = config.get(CONF_FOR) - async_remove_state_for_cancel = None - async_remove_state_for_listener = None match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) - - @callback - def clear_listener(): - """Clear all unsub listener.""" - nonlocal async_remove_state_for_cancel, async_remove_state_for_listener - - # pylint: disable=not-callable - if async_remove_state_for_listener is not None: - async_remove_state_for_listener() - async_remove_state_for_listener = None - if async_remove_state_for_cancel is not None: - async_remove_state_for_cancel() - async_remove_state_for_cancel = None + async_remove_track_same = None @callback def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - nonlocal async_remove_state_for_cancel, async_remove_state_for_listener + nonlocal async_remove_track_same + @callback def call_action(): """Call action with right context.""" hass.async_run_job(action, { @@ -78,33 +60,12 @@ def async_trigger(hass, config, action): from_s.last_changed == to_s.last_changed): return - if time_delta is None: + if not time_delta: call_action() return - @callback - def state_for_listener(now): - """Fire on state changes after a delay and calls action.""" - nonlocal async_remove_state_for_listener - async_remove_state_for_listener = None - clear_listener() - call_action() - - @callback - def state_for_cancel_listener(entity, inner_from_s, inner_to_s): - """Fire on changes and cancel for listener if changed.""" - if inner_to_s.state == to_s.state: - return - clear_listener() - - # cleanup previous listener - clear_listener() - - async_remove_state_for_listener = async_track_point_in_utc_time( - hass, state_for_listener, dt_util.utcnow() + time_delta) - - async_remove_state_for_cancel = async_track_state_change( - hass, entity, state_for_cancel_listener) + async_remove_track_same = async_track_same_state( + hass, to_s.state, time_delta, call_action, entity_ids=entity_id) unsub = async_track_state_change( hass, entity_id, state_automation_listener, from_state, to_state) @@ -113,6 +74,7 @@ def async_trigger(hass, config, action): def async_remove(): """Remove state listeners async.""" unsub() - clear_listener() + if async_remove_track_same: + async_remove_track_same() # pylint: disable=not-callable return async_remove 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 9abff53026d..8ad40158958 100644 --- a/homeassistant/components/binary_sensor/abode.py +++ b/homeassistant/components/binary_sensor/abode.py @@ -6,76 +6,69 @@ https://home-assistant.io/components/binary_sensor.abode/ """ import logging -from homeassistant.components.abode import (CONF_ATTRIBUTION, DATA_ABODE) -from homeassistant.const import (ATTR_ATTRIBUTION) -from homeassistant.components.binary_sensor import (BinarySensorDevice) +from homeassistant.components.abode import (AbodeDevice, AbodeAutomation, + DOMAIN as ABODE_DOMAIN) +from homeassistant.components.binary_sensor import BinarySensorDevice + DEPENDENCIES = ['abode'] _LOGGER = logging.getLogger(__name__) -# Sensor types: Name, device_class -SENSOR_TYPES = { - 'Door Contact': 'opening', - 'Motion Camera': 'motion', -} - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - data = hass.data.get(DATA_ABODE) + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE - sensors = [] - for sensor in data.devices: - _LOGGER.debug('Sensor type %s', sensor.type) - if sensor.type in ['Door Contact', 'Motion Camera']: - sensors.append(AbodeBinarySensor(hass, data, sensor)) + data = hass.data[ABODE_DOMAIN] - _LOGGER.debug('Adding %d sensors', len(sensors)) - add_devices(sensors) + 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(BinarySensorDevice): +class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): """A binary sensor implementation for Abode device.""" - def __init__(self, hass, data, device): - """Initialize a sensor for Abode device.""" - super(AbodeBinarySensor, self).__init__() - self._device = device - - @property - def should_poll(self): - """Return the polling state.""" - return True - - @property - def name(self): - """Return the name of the sensor.""" - return "{0} {1}".format(self._device.type, self._device.name) - @property def is_on(self): """Return True if the binary sensor is on.""" - if self._device.type == 'Door Contact': - return self._device.status != 'Closed' - elif self._device.type == 'Motion Camera': - return self._device.get_value('motion_event') == '1' + return self._device.is_on @property def device_class(self): """Return the class of the binary sensor.""" - return SENSOR_TYPES.get(self._device.type) + 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 device_state_attributes(self): - """Return the state attributes.""" - attrs = {} - attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION - attrs['device_id'] = self._device.device_id - attrs['battery_low'] = self._device.battery_low - - return attrs - - def update(self): - """Update the device state.""" - self._device.refresh() + 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 new file mode 100644 index 00000000000..13908fb5472 --- /dev/null +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -0,0 +1,217 @@ +""" +Use Bayesian Inference to trigger a binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.bayesian/ +""" +import asyncio +import logging +from collections import OrderedDict + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_ABOVE, CONF_BELOW, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + CONF_PLATFORM, CONF_STATE, STATE_UNKNOWN) +from homeassistant.core import callback +from homeassistant.helpers import condition +from homeassistant.helpers.event import async_track_state_change + +_LOGGER = logging.getLogger(__name__) + +CONF_OBSERVATIONS = 'observations' +CONF_PRIOR = 'prior' +CONF_PROBABILITY_THRESHOLD = 'probability_threshold' +CONF_P_GIVEN_F = 'prob_given_false' +CONF_P_GIVEN_T = 'prob_given_true' +CONF_TO_STATE = 'to_state' + +DEFAULT_NAME = 'BayesianBinary' + +NUMERIC_STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: 'numeric_state', + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: CONF_STATE, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TO_STATE): cv.string, + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): + cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Required(CONF_OBSERVATIONS): vol.Schema( + vol.All(cv.ensure_list, [vol.Any(NUMERIC_STATE_SCHEMA, + STATE_SCHEMA)]) + ), + vol.Required(CONF_PRIOR): vol.Coerce(float), + vol.Optional(CONF_PROBABILITY_THRESHOLD): + vol.Coerce(float), +}) + + +def update_probability(prior, prob_true, prob_false): + """Update probability using Bayes' rule.""" + numerator = prob_true * prior + denominator = numerator + prob_false * (1 - prior) + + probability = numerator / denominator + return probability + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Threshold sensor.""" + name = config.get(CONF_NAME) + observations = config.get(CONF_OBSERVATIONS) + prior = config.get(CONF_PRIOR) + probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD, 0.5) + device_class = config.get(CONF_DEVICE_CLASS) + + async_add_devices([ + BayesianBinarySensor(name, prior, observations, probability_threshold, + device_class) + ], True) + + +class BayesianBinarySensor(BinarySensorDevice): + """Representation of a Bayesian sensor.""" + + def __init__(self, name, prior, observations, probability_threshold, + device_class): + """Initialize the Bayesian sensor.""" + self._name = name + self._observations = observations + self._probability_threshold = probability_threshold + self._device_class = device_class + self._deviation = False + self.prior = prior + self.probability = prior + + self.current_obs = OrderedDict({}) + + 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, + 'state': self._process_state + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + @callback + # pylint: disable=invalid-name + def async_threshold_sensor_state_listener(entity, old_state, + new_state): + """Handle sensor state changes.""" + if new_state.state == STATE_UNKNOWN: + return + + entity_obs_list = self.entity_obs[entity] + + for entity_obs in entity_obs_list: + platform = entity_obs['platform'] + + self.watchers[platform](entity_obs) + + prior = self.prior + 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) + + entities = [obs['entity_id'] for obs in self._observations] + async_track_state_change( + self.hass, entities, async_threshold_sensor_state_listener) + + def _update_current_obs(self, entity_observation, should_trigger): + """Update current observation.""" + 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[obs_id] = { + 'prob_true': prob_true, + 'prob_false': prob_false + } + + else: + 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.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.async_numeric_state( + self.hass, entity, + entity_observation.get('below'), + entity_observation.get('above'), None, entity_observation) + + self._update_current_obs(entity_observation, should_trigger) + + def _process_state(self, entity_observation): + """Add entity to current observations if state conditions are met.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.state( + self.hass, entity, entity_observation.get('to_state')) + + self._update_current_obs(entity_observation, should_trigger) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._deviation + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + 'observations': [val for val in self.current_obs.values()], + 'probability': round(self.probability, 2), + 'probability_threshold': self._probability_threshold + } + + @asyncio.coroutine + def async_update(self): + """Get the latest data and update the states.""" + self._deviation = bool(self.probability > 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/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index 7f2127fcad5..df488cc0ed6 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) -REQUIREMENTS = ['pyhik==0.1.3'] +REQUIREMENTS = ['pyhik==0.1.4'] _LOGGER = logging.getLogger(__name__) CONF_IGNORED = 'ignored' @@ -47,6 +47,7 @@ DEVICE_CLASS_MAP = { 'PIR Alarm': 'motion', 'Face Detection': 'motion', 'Scene Change Detection': 'motion', + 'I/O': None, } CUSTOMIZE_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/binary_sensor/homematic.py b/homeassistant/components/binary_sensor/homematic.py index a82431a5ab8..2f464bc73cc 100644 --- a/homeassistant/components/binary_sensor/homematic.py +++ b/homeassistant/components/binary_sensor/homematic.py @@ -35,8 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMBinarySensor(hass, conf) - new_device.link_homematic() + new_device = HMBinarySensor(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/binary_sensor/insteon_plm.py b/homeassistant/components/binary_sensor/insteon_plm.py index 448ceae8636..0702ce8bb9e 100644 --- a/homeassistant/components/binary_sensor/insteon_plm.py +++ b/homeassistant/components/binary_sensor/insteon_plm.py @@ -55,12 +55,12 @@ class InsteonPLMBinarySensorDevice(BinarySensorDevice): @property def address(self): - """Return the the address of the node.""" + """Return the address of the node.""" return self._address @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self._name @property diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 87f8a30d78c..406f60f99bb 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -1,21 +1,145 @@ """ -Contains functionality to use a KNX group address as a binary. +Support for KNX/IP binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.knx/ """ -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +import asyncio +import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES, \ + KNXAutomation +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, \ + BinarySensorDevice +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +CONF_DEVICE_CLASS = 'device_class' +CONF_SIGNIFICANT_BIT = 'significant_bit' +CONF_DEFAULT_SIGNIFICANT_BIT = 1 +CONF_AUTOMATION = 'automation' +CONF_HOOK = 'hook' +CONF_DEFAULT_HOOK = 'on' +CONF_COUNTER = 'counter' +CONF_DEFAULT_COUNTER = 1 +CONF_ACTION = 'action' + +CONF__ACTION = 'turn_off_action' + +DEFAULT_NAME = 'KNX Binary Sensor' DEPENDENCIES = ['knx'] +AUTOMATION_SCHEMA = vol.Schema({ + vol.Optional(CONF_HOOK, default=CONF_DEFAULT_HOOK): cv.string, + vol.Optional(CONF_COUNTER, default=CONF_DEFAULT_COUNTER): cv.port, + vol.Required(CONF_ACTION, default=None): cv.SCRIPT_SCHEMA +}) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX binary sensor platform.""" - add_devices([KNXSwitch(hass, KNXConfig(config))]) +AUTOMATIONS_SCHEMA = vol.All( + cv.ensure_list, + [AUTOMATION_SCHEMA] +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): + cv.positive_int, + vol.Optional(CONF_AUTOMATION, default=None): AUTOMATIONS_SCHEMA, +}) -class KNXSwitch(KNXGroupAddress, BinarySensorDevice): - """Representation of a KNX binary sensor device.""" +@asyncio.coroutine +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 \ + or not hass.data[DATA_KNX].initialized: + return False - pass + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + return True + + +@callback +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)) + async_add_devices(entities) + + +@callback +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 + binary_sensor = xknx.devices.BinarySensor( + hass.data[DATA_KNX].xknx, + name=name, + group_address=config.get(CONF_ADDRESS), + device_class=config.get(CONF_DEVICE_CLASS), + significant_bit=config.get(CONF_SIGNIFICANT_BIT)) + hass.data[DATA_KNX].xknx.devices.add(binary_sensor) + + entity = KNXBinarySensor(hass, binary_sensor) + automations = config.get(CONF_AUTOMATION) + if automations is not None: + for automation in automations: + counter = automation.get(CONF_COUNTER) + hook = automation.get(CONF_HOOK) + action = automation.get(CONF_ACTION) + entity.automations.append(KNXAutomation( + hass=hass, device=binary_sensor, hook=hook, + action=action, counter=counter)) + async_add_devices([entity]) + + +class KNXBinarySensor(BinarySensorDevice): + """Representation of a KNX binary sensor.""" + + def __init__(self, hass, device): + """Initialization of KNXBinarySensor.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + self.automations = [] + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def device_class(self): + """Return the class of this sensor.""" + return self.device.device_class + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.device.is_on() 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/mysensors.py b/homeassistant/components/binary_sensor/mysensors.py index 767ed858ec7..4b83f0c8f2d 100644 --- a/homeassistant/components/binary_sensor/mysensors.py +++ b/homeassistant/components/binary_sensor/mysensors.py @@ -4,62 +4,27 @@ Support for MySensors binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.mysensors/ """ -import logging - from homeassistant.components import mysensors -from homeassistant.components.binary_sensor import (DEVICE_CLASSES, +from homeassistant.components.binary_sensor import (DEVICE_CLASSES, DOMAIN, BinarySensorDevice) from homeassistant.const import STATE_ON -_LOGGER = logging.getLogger(__name__) -DEPENDENCIES = [] - def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for sensors.""" - # Only act if loaded via mysensors by discovery event. - # Otherwise gateway is not setup. - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_DOOR: [set_req.V_TRIPPED], - pres.S_MOTION: [set_req.V_TRIPPED], - pres.S_SMOKE: [set_req.V_TRIPPED], - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_SPRINKLER: [set_req.V_TRIPPED], - pres.S_WATER_LEAK: [set_req.V_TRIPPED], - pres.S_SOUND: [set_req.V_TRIPPED], - pres.S_VIBRATION: [set_req.V_TRIPPED], - pres.S_MOISTURE: [set_req.V_TRIPPED], - }) - - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsBinarySensor, add_devices)) + """Setup the mysensors platform for binary sensors.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsBinarySensor, + add_devices=add_devices) class MySensorsBinarySensor( - mysensors.MySensorsDeviceEntity, BinarySensorDevice): + mysensors.MySensorsEntity, BinarySensorDevice): """Represent the value of a MySensors Binary Sensor child node.""" @property def is_on(self): """Return True if the binary sensor is on.""" - if self.value_type in self._values: - return self._values[self.value_type] == STATE_ON - return False + return self._values.get(self.value_type) == STATE_ON @property def device_class(self): 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/ring.py b/homeassistant/components/binary_sensor/ring.py index 429e92afa7f..5c9a644f6b7 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -103,7 +103,8 @@ class RingBinarySensor(BinarySensorDevice): self._data.check_alerts() if self._data.alert: - self._state = (self._sensor_type == - self._data.alert.get('kind')) + if self._sensor_type == self._data.alert.get('kind') and \ + self._data.account_id == self._data.alert.get('doorbot_id'): + self._state = True else: self._state = False 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 330e8eaea9d..84afd01303f 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -19,16 +19,24 @@ from homeassistant.const import ( from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) +CONF_DELAY_ON = 'delay_on' +CONF_DELAY_OFF = 'delay_off' + SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_DELAY_ON): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_DELAY_OFF): + vol.All(cv.time_period, cv.positive_timedelta), }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -47,6 +55,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): value_template.extract_entities()) friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) device_class = device_config.get(CONF_DEVICE_CLASS) + delay_on = device_config.get(CONF_DELAY_ON) + delay_off = device_config.get(CONF_DELAY_OFF) if value_template is not None: value_template.hass = hass @@ -54,13 +64,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): sensors.append( BinarySensorTemplate( hass, device, friendly_name, device_class, value_template, - entity_ids) + entity_ids, delay_on, delay_off) ) if not sensors: _LOGGER.error("No sensors added") return False - async_add_devices(sensors, True) + async_add_devices(sensors) return True @@ -68,7 +78,7 @@ class BinarySensorTemplate(BinarySensorDevice): """A virtual binary sensor that triggers from another sensor.""" def __init__(self, hass, device, friendly_name, device_class, - value_template, entity_ids): + value_template, entity_ids, delay_on, delay_off): """Initialize the Template binary sensor.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -78,6 +88,8 @@ class BinarySensorTemplate(BinarySensorDevice): self._template = value_template self._state = None self._entities = entity_ids + self._delay_on = delay_on + self._delay_off = delay_off @asyncio.coroutine def async_added_to_hass(self): @@ -89,7 +101,7 @@ class BinarySensorTemplate(BinarySensorDevice): @callback def template_bsensor_state_listener(entity, old_state, new_state): """Handle the target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_check_state() @callback def template_bsensor_startup(event): @@ -97,7 +109,7 @@ class BinarySensorTemplate(BinarySensorDevice): async_track_state_change( self.hass, self._entities, template_bsensor_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.hass.async_add_job(self.async_check_state) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_bsensor_startup) @@ -122,11 +134,11 @@ class BinarySensorTemplate(BinarySensorDevice): """No polling needed.""" return False - @asyncio.coroutine - def async_update(self): - """Update the state from the template.""" + @callback + def _async_render(self, *args): + """Get the state of template.""" try: - self._state = self._template.async_render().lower() == 'true' + return self._template.async_render().lower() == 'true' except TemplateError as ex: if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): @@ -135,4 +147,29 @@ class BinarySensorTemplate(BinarySensorDevice): "the state is unknown", self._name) return _LOGGER.error("Could not render template %s: %s", self._name, ex) - self._state = False + + @callback + def async_check_state(self): + """Update the state from the template.""" + state = self._async_render() + + # return if the state don't change or is invalid + if state is None or state == self.state: + return + + @callback + def set_state(): + """Set state of template binary sensor.""" + self._state = state + self.async_schedule_update_ha_state() + + # state without delay + if (state and not self._delay_on) or \ + (not state and not self._delay_off): + set_state() + return + + period = self._delay_on if state else self._delay_off + async_track_same_state( + self.hass, state, period, set_state, entity_ids=self._entities, + async_check_func=self._async_render) diff --git a/homeassistant/components/binary_sensor/tesla.py b/homeassistant/components/binary_sensor/tesla.py new file mode 100644 index 00000000000..af7e394b50e --- /dev/null +++ b/homeassistant/components/binary_sensor/tesla.py @@ -0,0 +1,57 @@ +""" +Support for Tesla binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tesla/ +""" +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT) +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla binary sensor.""" + devices = [ + TeslaBinarySensor( + device, hass.data[TESLA_DOMAIN]['controller'], 'connectivity') + for device in hass.data[TESLA_DOMAIN]['devices']['binary_sensor']] + add_devices(devices, True) + + +class TeslaBinarySensor(TeslaDevice, BinarySensorDevice): + """Implement an Tesla binary sensor for parking and charger.""" + + def __init__(self, tesla_device, controller, sensor_type): + """Initialisation of binary sensor.""" + super().__init__(tesla_device, controller) + self._name = self.tesla_device.name + self._state = False + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._sensor_type = sensor_type + + @property + def device_class(self): + """Return the class of this binary sensor.""" + return self._sensor_type + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._state + + def update(self): + """Update the state of the device.""" + _LOGGER.debug("Updating sensor: %s", self._name) + self.tesla_device.update() + self._state = self.tesla_device.get_value() diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py index b4910687da7..05de0b51aa8 100644 --- a/homeassistant/components/binary_sensor/wink.py +++ b/homeassistant/components/binary_sensor/wink.py @@ -136,8 +136,9 @@ class WinkHub(WinkBinarySensorDevice): def device_state_attributes(self): """Return the state attributes.""" return { - 'update needed': self.wink.update_needed(), - 'firmware version': self.wink.firmware_version() + 'update_needed': self.wink.update_needed(), + 'firmware_version': self.wink.firmware_version(), + 'pairing_mode': self.wink.pairing_mode() } diff --git a/homeassistant/components/binary_sensor/xiaomi.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py similarity index 89% rename from homeassistant/components/binary_sensor/xiaomi.py rename to homeassistant/components/binary_sensor/xiaomi_aqara.py index fafdc098c5d..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__) @@ -31,6 +32,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices.append(XiaomiDoorSensor(device, gateway)) elif model == 'sensor_magnet.aq2': devices.append(XiaomiDoorSensor(device, gateway)) + elif model == 'sensor_wleak.aq1': + devices.append(XiaomiWaterLeakSensor(device, gateway)) elif model == 'smoke': devices.append(XiaomiSmokeSensor(device, gateway)) elif model == 'natgas': @@ -214,6 +217,35 @@ class XiaomiDoorSensor(XiaomiBinarySensor): return False +class XiaomiWaterLeakSensor(XiaomiBinarySensor): + """Representation of a XiaomiWaterLeakSensor.""" + + def __init__(self, device, xiaomi_hub): + """Initialize the XiaomiWaterLeakSensor.""" + XiaomiBinarySensor.__init__(self, device, 'Water Leak Sensor', + xiaomi_hub, 'status', 'moisture') + + def parse_data(self, data): + """Parse data sent by gateway.""" + self._should_poll = False + + value = data.get(self._data_key) + if value is None: + return False + + if value == 'leak': + self._should_poll = True + if self._state: + return False + self._state = True + return True + elif value == 'no_leak': + if self._state: + self._state = False + return True + return False + + class XiaomiSmokeSensor(XiaomiBinarySensor): """Representation of a XiaomiSmokeSensor.""" 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..eb9f0a2677e --- /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 analogous 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/amcrest.py b/homeassistant/components/camera/amcrest.py index 51b8ff13906..aba1bb08c93 100644 --- a/homeassistant/components/camera/amcrest.py +++ b/homeassistant/components/camera/amcrest.py @@ -62,7 +62,7 @@ class AmcrestCam(Camera): self._token = self._auth = authentication def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data response = self._camera.snapshot(channel=self._resolution) return response.data diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index 80833e34b20..d473fa42d9d 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -14,15 +14,31 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.components.arlo import DEFAULT_BRAND, DATA_ARLO from homeassistant.components.camera import Camera, PLATFORM_SCHEMA from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import ATTR_BATTERY_LEVEL DEPENDENCIES = ['arlo', 'ffmpeg'] _LOGGER = logging.getLogger(__name__) +ATTR_BRIGHTNESS = 'brightness' +ATTR_FLIPPED = 'flipped' +ATTR_MIRRORED = 'mirrored' +ATTR_MOTION_SENSITIVITY = 'motion_detection_sensitivity' +ATTR_POWER_SAVE_MODE = 'power_save_mode' +ATTR_SIGNAL_STRENGTH = 'signal_strength' +ATTR_UNSEEN_VIDEOS = 'unseen_videos' + CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + ARLO_MODE_ARMED = 'armed' ARLO_MODE_DISARMED = 'disarmed' +POWERSAVE_MODE_MAPPING = { + 1: 'best_battery_life', + 2: 'optimized', + 3: 'best_video' +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, }) @@ -80,6 +96,28 @@ class ArloCam(Camera): """Return the name of this camera.""" return self._name + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_BATTERY_LEVEL: + self._camera.get_battery_level, + ATTR_BRIGHTNESS: + self._camera.get_brightness, + ATTR_FLIPPED: + self._camera.get_flip_state, + ATTR_MIRRORED: + self._camera.get_mirror_state, + ATTR_MOTION_SENSITIVITY: + self._camera.get_motion_detection_sensitivity, + ATTR_POWER_SAVE_MODE: + POWERSAVE_MODE_MAPPING[self._camera.get_powersave_mode], + ATTR_SIGNAL_STRENGTH: + self._camera.get_signal_strength, + ATTR_UNSEEN_VIDEOS: + self._camera.unseen_videos + } + @property def model(self): """Camera model.""" 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/blink.py b/homeassistant/components/camera/blink.py index bca4fafec4f..4b708817cfd 100644 --- a/homeassistant/components/camera/blink.py +++ b/homeassistant/components/camera/blink.py @@ -76,6 +76,6 @@ class BlinkCamera(Camera): return self.data.camera_thumbs[self._name] def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" self.request_image() return self.response.content 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/foscam.py b/homeassistant/components/camera/foscam.py index 15138e2c253..3cc391eae33 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -15,7 +15,7 @@ from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyfoscam==1.2'] +REQUIREMENTS = ['libpyfoscam==1.0'] CONF_IP = 'ip' @@ -53,13 +53,13 @@ class FoscamCam(Camera): self._name = device_info.get(CONF_NAME) self._motion_status = False - from foscam import FoscamCamera + from libpyfoscam import FoscamCamera self._foscam_session = FoscamCamera(ip_address, port, self._username, self._password, verbose=False) def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data # Handle exception if host is not reachable or url failed result, response = self._foscam_session.snap_picture_2() 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/__init__.py b/homeassistant/components/climate/__init__.py index 1f919301254..53e60380a38 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -44,6 +44,12 @@ STATE_IDLE = 'idle' STATE_AUTO = 'auto' STATE_DRY = 'dry' STATE_FAN_ONLY = 'fan_only' +STATE_ECO = 'eco' +STATE_ELECTRIC = 'electric' +STATE_PERFORMANCE = 'performance' +STATE_HIGH_DEMAND = 'high_demand' +STATE_HEAT_PUMP = 'heat_pump' +STATE_GAS = 'gas' ATTR_CURRENT_TEMPERATURE = 'current_temperature' ATTR_MAX_TEMP = 'max_temp' @@ -147,7 +153,7 @@ def set_hold_mode(hass, hold_mode, entity_id=None): @bind_hass def set_aux_heat(hass, aux_heat, entity_id=None): - """Turn all or specified climate devices auxillary heater on.""" + """Turn all or specified climate devices auxiliary heater on.""" data = { ATTR_AUX_HEAT: aux_heat } @@ -661,22 +667,22 @@ class ClimateDevice(Entity): return self.hass.async_add_job(self.set_hold_mode, hold_mode) def turn_aux_heat_on(self): - """Turn auxillary heater on.""" + """Turn auxiliary heater on.""" raise NotImplementedError() def async_turn_aux_heat_on(self): - """Turn auxillary heater on. + """Turn auxiliary heater on. This method must be run in the event loop and returns a coroutine. """ return self.hass.async_add_job(self.turn_aux_heat_on) def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" raise NotImplementedError() def async_turn_aux_heat_off(self): - """Turn auxillary heater off. + """Turn auxiliary heater off. This method must be run in the event loop and returns a coroutine. """ diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 24b40af7eb1..377985aaa12 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -114,7 +114,7 @@ class DemoClimate(ClimateDevice): @property def is_aux_heat_on(self): - """Return true if away mode is on.""" + """Return true if aux heat is on.""" return self._aux @property @@ -183,11 +183,11 @@ class DemoClimate(ClimateDevice): self.schedule_update_ha_state() def turn_aux_heat_on(self): - """Turn away auxillary heater on.""" + """Turn auxillary heater on.""" self._aux = True self.schedule_update_ha_state() def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" self._aux = False self.schedule_update_ha_state() 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/homematic.py b/homeassistant/components/climate/homematic.py index 60cda24eef9..ce6e9580e54 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -47,8 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMThermostat(hass, conf) - new_device.link_homematic() + new_device = HMThermostat(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 4ff87aa67ab..0b2df903e17 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -196,6 +196,11 @@ class RoundThermostat(ClimateDevice): if val['id'] == self._id: data = val + except KeyError: + _LOGGER.error("Update failed from Honeywell server") + self.client.user_data = None + return + except StopIteration: _LOGGER.error("Did not receive any temperature data from the " "evohomeclient API") diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index e399e2f3dca..9bf44c9b9ab 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -1,68 +1,136 @@ """ -Support for KNX thermostats. +Support for KNX/IP climate devices. -For more details about this platform, please refer to the documentation +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.knx/ """ -import logging - +import asyncio import voluptuous as vol -from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.const import CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_ADDRESS = 'address' CONF_SETPOINT_ADDRESS = 'setpoint_address' CONF_TEMPERATURE_ADDRESS = 'temperature_address' +CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' +CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' +CONF_OPERATION_MODE_STATE_ADDRESS = 'operation_mode_state_address' +CONF_CONTROLLER_STATUS_ADDRESS = 'controller_status_address' +CONF_CONTROLLER_STATUS_STATE_ADDRESS = 'controller_status_state_address' +CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS = \ + 'operation_mode_frost_protection_address' +CONF_OPERATION_MODE_NIGHT_ADDRESS = 'operation_mode_night_address' +CONF_OPERATION_MODE_COMFORT_ADDRESS = 'operation_mode_comfort_address' -DEFAULT_NAME = 'KNX Thermostat' +DEFAULT_NAME = 'KNX Climate' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_SETPOINT_ADDRESS): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_STATE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_NIGHT_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_COMFORT_ADDRESS): cv.string, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXThermostat(hass, KNXConfig(config))]) +@asyncio.coroutine +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 \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + return True -class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): - """Representation of a KNX thermostat. +@callback +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)) + async_add_devices(entities) - A KNX thermostat will has the following parameters: - - temperature (current temperature) - - setpoint (target temperature in HASS terms) - - operation mode selection (comfort/night/frost protection) - This version supports only polling. Messages from the KNX bus do not - automatically update the state of the thermostat (to be implemented - in future releases) - """ +@callback +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( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_temperature=config.get( + CONF_TEMPERATURE_ADDRESS), + group_address_target_temperature=config.get( + CONF_TARGET_TEMPERATURE_ADDRESS), + group_address_setpoint=config.get( + CONF_SETPOINT_ADDRESS), + group_address_operation_mode=config.get( + CONF_OPERATION_MODE_ADDRESS), + group_address_operation_mode_state=config.get( + CONF_OPERATION_MODE_STATE_ADDRESS), + group_address_controller_status=config.get( + CONF_CONTROLLER_STATUS_ADDRESS), + group_address_controller_status_state=config.get( + CONF_CONTROLLER_STATUS_STATE_ADDRESS), + group_address_operation_mode_protection=config.get( + CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS), + group_address_operation_mode_night=config.get( + CONF_OPERATION_MODE_NIGHT_ADDRESS), + group_address_operation_mode_comfort=config.get( + CONF_OPERATION_MODE_COMFORT_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(climate) + async_add_devices([KNXClimate(hass, climate)]) - def __init__(self, hass, config): - """Initialize the thermostat based on the given configuration.""" - KNXMultiAddressDevice.__init__( - self, hass, config, ['temperature', 'setpoint'], ['mode']) - self._unit_of_measurement = TEMP_CELSIUS # KNX always used celsius +class KNXClimate(ClimateDevice): + """Representation of a KNX climate.""" + + def __init__(self, hass, device): + """Initialization of KNXClimate.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + self._unit_of_measurement = TEMP_CELSIUS self._away = False # not yet supported self._is_fan_on = False # not yet supported - self._current_temp = None - self._target_temp = None + + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Return the polling state, is needed for the KNX thermostat.""" - return True + """No polling needed within KNX.""" + return False @property def temperature_unit(self): @@ -72,32 +140,42 @@ class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self._current_temp + return self.device.temperature @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temp + if self.device.supports_target_temperature: + return self.device.target_temperature + return None - def set_temperature(self, **kwargs): + @asyncio.coroutine + def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - from knxip.conversion import float_to_knx2 + if self.device.supports_target_temperature: + yield from self.device.set_target_temperature(temperature) - self.set_value('setpoint', float_to_knx2(temperature)) - _LOGGER.debug("Set target temperature to %s", temperature) + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + if self.device.supports_operation_mode: + return self.device.operation_mode.value + return None - def set_operation_mode(self, operation_mode): + @property + def operation_list(self): + """Return the list of available operation modes.""" + return [operation_mode.value for + operation_mode in + self.device.get_supported_operation_modes()] + + @asyncio.coroutine + def async_set_operation_mode(self, operation_mode): """Set operation mode.""" - raise NotImplementedError() - - def update(self): - """Update KNX climate.""" - from knxip.conversion import knx2_to_float - - super().update() - - self._current_temp = knx2_to_float(self.value('temperature')) - self._target_temp = knx2_to_float(self.value('setpoint')) + if self.device.supports_operation_mode: + from xknx.knx import HVACOperationMode + knx_operation_mode = HVACOperationMode(operation_mode) + yield from self.device.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py index 82ed8a94e2b..d4316c2cfba 100755 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -4,15 +4,11 @@ MySensors platform that offers a Climate (MySensors-HVAC) component. For more details about this platform, please refer to the documentation https://home-assistant.io/components/climate.mysensors/ """ -import logging - from homeassistant.components import mysensors from homeassistant.components.climate import ( - STATE_COOL, STATE_HEAT, STATE_OFF, STATE_AUTO, ClimateDevice, - ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW) -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE - -_LOGGER = logging.getLogger(__name__) + ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN, STATE_AUTO, + STATE_COOL, STATE_HEAT, STATE_OFF, ClimateDevice) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT DICT_HA_TO_MYS = { STATE_AUTO: 'AutoChangeOver', @@ -29,28 +25,12 @@ DICT_MYS_TO_HA = { def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the mysensors climate.""" - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - if float(gateway.protocol_version) < 1.5: - continue - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_HVAC: [set_req.V_HVAC_FLOW_STATE], - } - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsHVAC, add_devices)) + """Setup the mysensors climate.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsHVAC, add_devices=add_devices) -class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): +class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice): """Representation of a MySensors HVAC.""" @property @@ -84,26 +64,28 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) if temp is None: temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) - return float(temp) + return float(temp) if temp is not None else None @property def target_temperature_high(self): """Return the highbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq if set_req.V_HVAC_SETPOINT_HEAT in self._values: - return float(self._values.get(set_req.V_HVAC_SETPOINT_COOL)) + temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) + return float(temp) if temp is not None else None @property def target_temperature_low(self): """Return the lowbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq if set_req.V_HVAC_SETPOINT_COOL in self._values: - return float(self._values.get(set_req.V_HVAC_SETPOINT_HEAT)) + temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) + return float(temp) if temp is not None else None @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" - return self._values.get(self.gateway.const.SetReq.V_HVAC_FLOW_STATE) + return self._values.get(self.value_type) @property def operation_list(self): @@ -128,7 +110,7 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): high = kwargs.get(ATTR_TARGET_TEMP_HIGH) heat = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) cool = self._values.get(set_req.V_HVAC_SETPOINT_COOL) - updates = () + updates = [] if temp is not None: if heat is not None: # Set HEAT Target temperature @@ -146,7 +128,7 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): self.gateway.set_child_value( self.node_id, self.child_id, value_type, value) if self.gateway.optimistic: - # optimistically assume that switch has changed state + # optimistically assume that device has changed state self._values[value_type] = value self.schedule_update_ha_state() @@ -156,54 +138,22 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan) if self.gateway.optimistic: - # optimistically assume that switch has changed state + # optimistically assume that device has changed state self._values[set_req.V_HVAC_SPEED] = fan self.schedule_update_ha_state() def set_operation_mode(self, operation_mode): """Set new target temperature.""" - set_req = self.gateway.const.SetReq self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_HVAC_FLOW_STATE, + self.node_id, self.child_id, self.value_type, DICT_HA_TO_MYS[operation_mode]) if self.gateway.optimistic: - # optimistically assume that switch has changed state - self._values[set_req.V_HVAC_FLOW_STATE] = operation_mode + # optimistically assume that device has changed state + self._values[self.value_type] = operation_mode self.schedule_update_ha_state() def update(self): """Update the controller with the latest value from a sensor.""" - set_req = self.gateway.const.SetReq - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - for value_type, value in child.values.items(): - _LOGGER.debug( - "%s: value_type %s, value = %s", self._name, value_type, value) - if value_type == set_req.V_HVAC_FLOW_STATE: - self._values[value_type] = DICT_MYS_TO_HA[value] - else: - self._values[value_type] = value - - def set_humidity(self, humidity): - """Set new target humidity.""" - _LOGGER.error("Service Not Implemented yet") - - def set_swing_mode(self, swing_mode): - """Set new target swing operation.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_away_mode_on(self): - """Turn away mode on.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_away_mode_off(self): - """Turn away mode off.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_aux_heat_on(self): - """Turn auxillary heater on.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_aux_heat_off(self): - """Turn auxillary heater off.""" - _LOGGER.error("Service Not Implemented yet") + super().update() + self._values[self.value_type] = DICT_MYS_TO_HA[ + self._values[self.value_type]] diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 4aebb1c85c9..92d821ebbaf 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -1,5 +1,5 @@ set_aux_heat: - description: Turn auxillary heater on/off for climate device + description: Turn auxiliary heater on/off for climate device fields: entity_id: diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py new file mode 100644 index 00000000000..39d002e72d9 --- /dev/null +++ b/homeassistant/components/climate/tesla.py @@ -0,0 +1,93 @@ +""" +Support for Tesla HVAC system. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.tesla/ +""" +import logging + +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.climate import ClimateDevice, ENTITY_ID_FORMAT +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import ( + TEMP_FAHRENHEIT, TEMP_CELSIUS, ATTR_TEMPERATURE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + +OPERATION_LIST = [STATE_ON, STATE_OFF] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla climate platform.""" + devices = [TeslaThermostat(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['climate']] + add_devices(devices, True) + + +class TeslaThermostat(TeslaDevice, ClimateDevice): + """Representation of a Tesla climate.""" + + def __init__(self, tesla_device, controller): + """Initialize the Tesla device.""" + super().__init__(tesla_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._target_temperature = None + self._temperature = None + self._name = self.tesla_device.name + + @property + def current_operation(self): + """Return current operation ie. On or Off.""" + mode = self.tesla_device.is_hvac_enabled() + if mode: + return OPERATION_LIST[0] # On + else: + return OPERATION_LIST[1] # Off + + @property + def operation_list(self): + """List of available operation modes.""" + return OPERATION_LIST + + def update(self): + """Called by the Tesla device callback to update state.""" + _LOGGER.debug("Updating: %s", self._name) + self.tesla_device.update() + self._target_temperature = self.tesla_device.get_goal_temp() + self._temperature = self.tesla_device.get_current_temp() + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + tesla_temp_units = self.tesla_device.measurement + + if tesla_temp_units == 'F': + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + _LOGGER.debug("Setting temperature for: %s", self._name) + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature: + self.tesla_device.set_temperature(temperature) + + def set_operation_mode(self, operation_mode): + """Set HVAC mode (auto, cool, heat, off).""" + _LOGGER.debug("Setting mode for: %s", self._name) + if operation_mode == OPERATION_LIST[1]: # off + self.tesla_device.set_status(False) + elif operation_mode == OPERATION_LIST[0]: # heat + self.tesla_device.set_status(True) diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index f52340dc627..f72cefc0841 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -1,30 +1,45 @@ """ -Support for Wink thermostats. +Support for Wink thermostats, Air Conditioners, and Water Heaters. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.wink/ """ +import logging import asyncio from homeassistant.components.wink import WinkDevice, DOMAIN from homeassistant.components.climate import ( STATE_AUTO, STATE_COOL, STATE_HEAT, ClimateDevice, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, - ATTR_CURRENT_HUMIDITY) + ATTR_TEMPERATURE, STATE_FAN_ONLY, + ATTR_CURRENT_HUMIDITY, STATE_ECO, STATE_ELECTRIC, + STATE_PERFORMANCE, STATE_HIGH_DEMAND, + STATE_HEAT_PUMP, STATE_GAS) from homeassistant.const import ( TEMP_CELSIUS, STATE_ON, STATE_OFF, STATE_UNKNOWN) +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ['wink'] -STATE_AUX = 'aux' -STATE_ECO = 'eco' -STATE_FAN = 'fan' SPEED_LOW = 'low' SPEED_MEDIUM = 'medium' SPEED_HIGH = 'high' +HA_STATE_TO_WINK = {STATE_AUTO: 'auto', + STATE_ECO: 'eco', + STATE_FAN_ONLY: 'fan_only', + STATE_HEAT: 'heat_only', + STATE_COOL: 'cool_only', + STATE_PERFORMANCE: 'performance', + STATE_HIGH_DEMAND: 'high_demand', + STATE_HEAT_PUMP: 'heat_pump', + STATE_ELECTRIC: 'electric_only', + STATE_GAS: 'gas', + STATE_OFF: 'off'} +WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()} + ATTR_EXTERNAL_TEMPERATURE = "external_temperature" ATTR_SMART_TEMPERATURE = "smart_temperature" ATTR_ECO_TARGET = "eco_target" @@ -32,28 +47,26 @@ ATTR_OCCUPIED = "occupied" def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Wink thermostat.""" + """Set up the Wink climate devices.""" import pywink - temp_unit = hass.config.units.temperature_unit for climate in pywink.get_thermostats(): _id = climate.object_id() + climate.name() if _id not in hass.data[DOMAIN]['unique_ids']: - add_devices([WinkThermostat(climate, hass, temp_unit)]) + add_devices([WinkThermostat(climate, hass)]) for climate in pywink.get_air_conditioners(): _id = climate.object_id() + climate.name() if _id not in hass.data[DOMAIN]['unique_ids']: - add_devices([WinkAC(climate, hass, temp_unit)]) + add_devices([WinkAC(climate, hass)]) + for water_heater in pywink.get_water_heaters(): + _id = water_heater.object_id() + water_heater.name() + if _id not in hass.data[DOMAIN]['unique_ids']: + add_devices([WinkWaterHeater(water_heater, hass)]) # pylint: disable=abstract-method class WinkThermostat(WinkDevice, ClimateDevice): """Representation of a Wink thermostat.""" - def __init__(self, wink, hass, temp_unit): - """Initialize the Wink device.""" - super().__init__(wink, hass) - self._config_temp_unit = temp_unit - @asyncio.coroutine def async_added_to_hass(self): """Callback when entity is added to hass.""" @@ -139,18 +152,12 @@ class WinkThermostat(WinkDevice, ClimateDevice): """Return current operation ie. heat, cool, idle.""" if not self.wink.is_on(): current_op = STATE_OFF - elif self.wink.current_hvac_mode() == 'cool_only': - current_op = STATE_COOL - elif self.wink.current_hvac_mode() == 'heat_only': - current_op = STATE_HEAT - elif self.wink.current_hvac_mode() == 'aux': - current_op = STATE_HEAT - elif self.wink.current_hvac_mode() == 'auto': - current_op = STATE_AUTO - elif self.wink.current_hvac_mode() == 'eco': - current_op = STATE_ECO else: - current_op = STATE_UNKNOWN + current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode()) + if current_op == 'aux': + return STATE_HEAT + if current_op is None: + current_op = STATE_UNKNOWN return current_op @property @@ -199,11 +206,12 @@ class WinkThermostat(WinkDevice, ClimateDevice): @property def is_aux_heat_on(self): """Return true if aux heater.""" - if self.wink.current_hvac_mode() == 'aux' and self.wink.is_on(): + if 'aux' not in self.wink.hvac_modes(): + return None + + if self.wink.current_hvac_mode() == 'aux': return True - elif self.wink.current_hvac_mode() == 'aux' and not self.wink.is_on(): - return False - return None + return False def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -223,32 +231,27 @@ class WinkThermostat(WinkDevice, ClimateDevice): def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_HEAT: - self.wink.set_operation_mode('heat_only') - elif operation_mode == STATE_COOL: - self.wink.set_operation_mode('cool_only') - elif operation_mode == STATE_AUTO: - self.wink.set_operation_mode('auto') - elif operation_mode == STATE_OFF: - self.wink.set_operation_mode('off') - elif operation_mode == STATE_AUX: - self.wink.set_operation_mode('aux') - elif operation_mode == STATE_ECO: - self.wink.set_operation_mode('eco') + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + # The only way to disable aux heat is with the toggle + if self.is_aux_heat_on and op_mode_to_set == STATE_HEAT: + return + self.wink.set_operation_mode(op_mode_to_set) @property def operation_list(self): """List of available operation modes.""" op_list = ['off'] modes = self.wink.hvac_modes() - if 'cool_only' in modes: - op_list.append(STATE_COOL) - if 'heat_only' in modes or 'aux' in modes: - op_list.append(STATE_HEAT) - if 'auto' in modes: - op_list.append(STATE_AUTO) - if 'eco' in modes: - op_list.append(STATE_ECO) + for mode in modes: + if mode == 'aux': + continue + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) return op_list def turn_away_mode_on(self): @@ -281,12 +284,12 @@ class WinkThermostat(WinkDevice, ClimateDevice): self.wink.set_fan_mode(fan.lower()) def turn_aux_heat_on(self): - """Turn auxillary heater on.""" - self.set_operation_mode(STATE_AUX) + """Turn auxiliary heater on.""" + self.wink.set_operation_mode('aux') def turn_aux_heat_off(self): - """Turn auxillary heater off.""" - self.set_operation_mode(STATE_AUTO) + """Turn auxiliary heater off.""" + self.set_operation_mode(STATE_HEAT) @property def min_temp(self): @@ -344,11 +347,6 @@ class WinkThermostat(WinkDevice, ClimateDevice): class WinkAC(WinkDevice, ClimateDevice): """Representation of a Wink air conditioner.""" - def __init__(self, wink, hass, temp_unit): - """Initialize the Wink device.""" - super().__init__(wink, hass) - self._config_temp_unit = temp_unit - @property def temperature_unit(self): """Return the unit of measurement.""" @@ -382,14 +380,10 @@ class WinkAC(WinkDevice, ClimateDevice): """Return current operation ie. heat, cool, idle.""" if not self.wink.is_on(): current_op = STATE_OFF - elif self.wink.current_mode() == 'cool_only': - current_op = STATE_COOL - elif self.wink.current_mode() == 'auto_eco': - current_op = STATE_ECO - elif self.wink.current_mode() == 'fan_only': - current_op = STATE_FAN else: - current_op = STATE_UNKNOWN + current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode()) + if current_op is None: + current_op = STATE_UNKNOWN return current_op @property @@ -397,12 +391,14 @@ class WinkAC(WinkDevice, ClimateDevice): """List of available operation modes.""" op_list = ['off'] modes = self.wink.modes() - if 'cool_only' in modes: - op_list.append(STATE_COOL) - if 'auto_eco' in modes: - op_list.append(STATE_ECO) - if 'fan_only' in modes: - op_list.append(STATE_FAN) + for mode in modes: + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) return op_list def set_temperature(self, **kwargs): @@ -412,30 +408,16 @@ class WinkAC(WinkDevice, ClimateDevice): def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_COOL: - self.wink.set_operation_mode('cool_only') - elif operation_mode == STATE_ECO: - self.wink.set_operation_mode('auto_eco') - elif operation_mode == STATE_OFF: - self.wink.set_operation_mode('off') - elif operation_mode == STATE_FAN: - self.wink.set_operation_mode('fan_only') + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + if op_mode_to_set == 'eco': + op_mode_to_set = 'auto_eco' + self.wink.set_operation_mode(op_mode_to_set) @property def target_temperature(self): """Return the temperature we try to reach.""" return self.wink.current_max_set_point() - @property - def target_temperature_low(self): - """Only supports cool.""" - return None - - @property - def target_temperature_high(self): - """Only supports cool.""" - return None - @property def current_fan_mode(self): """Return the current fan mode.""" @@ -453,12 +435,97 @@ class WinkAC(WinkDevice, ClimateDevice): """Return a list of available fan modes.""" return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def set_fan_mode(self, mode): + def set_fan_mode(self, fan): """Set fan speed.""" - if mode == SPEED_LOW: + if fan == SPEED_LOW: speed = 0.4 - elif mode == SPEED_MEDIUM: + elif fan == SPEED_MEDIUM: speed = 0.8 - elif mode == SPEED_HIGH: + elif fan == SPEED_HIGH: speed = 1.0 self.wink.set_ac_fan_speed(speed) + + +class WinkWaterHeater(WinkDevice, ClimateDevice): + """Representation of a Wink water heater.""" + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + # The Wink API always returns temp in Celsius + return TEMP_CELSIUS + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + data = {} + data["vacation_mode"] = self.wink.vacation_mode_enabled() + data["rheem_type"] = self.wink.rheem_type() + + return data + + @property + def current_operation(self): + """ + Return current operation one of the following. + + ["eco", "performance", "heat_pump", + "high_demand", "electric_only", "gas] + """ + if not self.wink.is_on(): + current_op = STATE_OFF + else: + current_op = WINK_STATE_TO_HA.get(self.wink.current_mode()) + if current_op is None: + current_op = STATE_UNKNOWN + return current_op + + @property + def operation_list(self): + """List of available operation modes.""" + op_list = ['off'] + modes = self.wink.modes() + for mode in modes: + if mode == 'aux': + continue + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) + return op_list + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + self.wink.set_temperature(target_temp) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + self.wink.set_operation_mode(op_mode_to_set) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.wink.current_set_point() + + def turn_away_mode_on(self): + """Turn away on.""" + self.wink.set_vacation_mode(True) + + def turn_away_mode_off(self): + """Turn away off.""" + self.wink.set_vacation_mode(False) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.wink.min_set_point() + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.wink.max_set_point() diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py new file mode 100644 index 00000000000..44796f97166 --- /dev/null +++ b/homeassistant/components/cloud/__init__.py @@ -0,0 +1,47 @@ +"""Component to integrate the Home Assistant cloud.""" +import asyncio +import logging + +import voluptuous as vol + +from . import http_api, auth_api +from .const import DOMAIN + + +REQUIREMENTS = ['warrant==0.2.0'] +DEPENDENCIES = ['http'] +CONF_MODE = 'mode' +MODE_DEV = 'development' +MODE_STAGING = 'staging' +MODE_PRODUCTION = 'production' +DEFAULT_MODE = MODE_DEV + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MODE, default=DEFAULT_MODE): + vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]), + }), +}, extra=vol.ALLOW_EXTRA) +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the Home Assistant cloud.""" + mode = MODE_PRODUCTION + + if DOMAIN in config: + mode = config[DOMAIN].get(CONF_MODE) + + if mode != 'development': + _LOGGER.error('Only development mode is currently allowed.') + return False + + data = hass.data[DOMAIN] = { + 'mode': mode + } + + 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/const.py b/homeassistant/components/cloud/const.py new file mode 100644 index 00000000000..81beab1891b --- /dev/null +++ b/homeassistant/components/cloud/const.py @@ -0,0 +1,14 @@ +"""Constants for the cloud component.""" +DOMAIN = 'cloud' +REQUEST_TIMEOUT = 10 +AUTH_FILE = '.cloud' + +SERVERS = { + 'development': { + '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 new file mode 100644 index 00000000000..941df7648a6 --- /dev/null +++ b/homeassistant/components/cloud/http_api.py @@ -0,0 +1,222 @@ +"""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, RequestDataValidator) + +from . import auth_api +from .const import REQUEST_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass): + """Initialize the HTTP api.""" + 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): + """Login to Home Assistant cloud.""" + + url = '/api/cloud/login' + name = 'api:cloud:login' + + @asyncio.coroutine + @_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'] + auth = hass.data['cloud']['auth'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job(auth.login, data['email'], + data['password']) + + return self.json(_auth_data(auth)) + + +class CloudLogoutView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/logout' + name = 'api:cloud:logout' + + @asyncio.coroutine + @_handle_cloud_errors + def post(self, request): + """Handle logout request.""" + hass = request.app['hass'] + auth = hass.data['cloud']['auth'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job(auth.logout) + + return self.json_message('ok') + + +class CloudAccountView(HomeAssistantView): + """View to retrieve account info.""" + + url = '/api/cloud/account' + name = 'api:cloud:account' + + @asyncio.coroutine + def get(self, request): + """Get account info.""" + hass = request.app['hass'] + auth = hass.data['cloud']['auth'] + + if not auth.is_logged_in: + return self.json_message('Not logged in', 400) + + 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/cloud/util.py b/homeassistant/components/cloud/util.py new file mode 100644 index 00000000000..ec5445f0638 --- /dev/null +++ b/homeassistant/components/cloud/util.py @@ -0,0 +1,10 @@ +"""Utilities for the cloud integration.""" +from .const import DOMAIN + + +def get_mode(hass): + """Return the current mode of the cloud component. + + Async friendly. + """ + return hass.data[DOMAIN]['mode'] diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 9e447c8936a..9ce7f30529b 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,7 +14,7 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] -SECTIONS = ('core', 'group', 'hassbian', 'automation', 'script') +SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script') ON_DEMAND = ('zwave') @@ -77,11 +77,11 @@ class BaseEditConfigView(HomeAssistantView): """Empty config if file not found.""" raise NotImplementedError - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" raise NotImplementedError - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" raise NotImplementedError @@ -90,7 +90,7 @@ class BaseEditConfigView(HomeAssistantView): """Fetch device specific config.""" hass = request.app['hass'] current = yield from self.read_config(hass) - value = self._get_value(current, config_key) + value = self._get_value(hass, current, config_key) if value is None: return self.json_message('Resource not found', 404) @@ -121,7 +121,7 @@ class BaseEditConfigView(HomeAssistantView): path = hass.config.path(self.path) current = yield from self.read_config(hass) - self._write_value(current, config_key, data) + self._write_value(hass, current, config_key, data) yield from hass.async_add_job(_write, path, current) @@ -149,11 +149,11 @@ class EditKeyBasedConfigView(BaseEditConfigView): """Return an empty config.""" return {} - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" return data.get(config_key, {}) - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" data.setdefault(config_key, {}).update(new_value) @@ -165,14 +165,14 @@ class EditIdBasedConfigView(BaseEditConfigView): """Return an empty config.""" return [] - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" return next( (val for val in data if val.get(CONF_ID) == config_key), None) - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" - value = self._get_value(data, config_key) + value = self._get_value(hass, data, config_key) if value is None: value = {CONF_ID: config_key} diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py new file mode 100644 index 00000000000..d25992ecc90 --- /dev/null +++ b/homeassistant/components/config/customize.py @@ -0,0 +1,39 @@ +"""Provide configuration end points for Customize.""" +import asyncio + +from homeassistant.components.config import EditKeyBasedConfigView +from homeassistant.components import async_reload_core_config +from homeassistant.config import DATA_CUSTOMIZE + +import homeassistant.helpers.config_validation as cv + +CONFIG_PATH = 'customize.yaml' + + +@asyncio.coroutine +def async_setup(hass): + """Set up the Customize config API.""" + hass.http.register_view(CustomizeConfigView( + 'customize', 'config', CONFIG_PATH, cv.entity_id, dict, + post_write_hook=async_reload_core_config + )) + + return True + + +class CustomizeConfigView(EditKeyBasedConfigView): + """Configure a list of entries.""" + + def _get_value(self, hass, data, config_key): + """Get value.""" + customize = hass.data.get(DATA_CUSTOMIZE, {}).get(config_key) or {} + return {'global': customize, 'local': data.get(config_key, {})} + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + data[config_key] = new_value + + state = hass.states.get(config_key) + state_attributes = dict(state.attributes) + state_attributes.update(new_value) + hass.states.async_set(config_key, state.state, state_attributes) 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/counter.py b/homeassistant/components/counter.py new file mode 100644 index 00000000000..64421306644 --- /dev/null +++ b/homeassistant/components/counter.py @@ -0,0 +1,220 @@ +""" +Component to count within automations. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/counter/ +""" +import asyncio +import logging +import os + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.loader import bind_hass + +_LOGGER = logging.getLogger(__name__) + +ATTR_INITIAL = 'initial' +ATTR_STEP = 'step' + +CONF_INITIAL = 'initial' +CONF_STEP = 'step' + +DEFAULT_INITIAL = 0 +DEFAULT_STEP = 1 +DOMAIN = 'counter' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +SERVICE_DECREMENT = 'decrement' +SERVICE_INCREMENT = 'increment' +SERVICE_RESET = 'reset' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.Any({ + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): + cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, + }, None) + }) +}, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def increment(hass, entity_id): + """Increment a counter.""" + hass.add_job(async_increment, hass, entity_id) + + +@callback +@bind_hass +def async_increment(hass, entity_id): + """Increment a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def decrement(hass, entity_id): + """Decrement a counter.""" + hass.add_job(async_decrement, hass, entity_id) + + +@callback +@bind_hass +def async_decrement(hass, entity_id): + """Decrement a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def reset(hass, entity_id): + """Reset a counter.""" + hass.add_job(async_reset, hass, entity_id) + + +@callback +@bind_hass +def async_reset(hass, entity_id): + """Reset a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_RESET, {ATTR_ENTITY_ID: entity_id})) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up a counter.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + initial = cfg.get(CONF_INITIAL) + step = cfg.get(CONF_STEP) + icon = cfg.get(CONF_ICON) + + entities.append(Counter(object_id, name, initial, step, icon)) + + if not entities: + return False + + @asyncio.coroutine + def async_handler_service(service): + """Handle a call to the counter services.""" + target_counters = component.async_extract_from_service(service) + + if service.service == SERVICE_INCREMENT: + attr = 'async_increment' + elif service.service == SERVICE_DECREMENT: + attr = 'async_decrement' + elif service.service == SERVICE_RESET: + attr = 'async_reset' + + tasks = [getattr(counter, attr)() for counter in target_counters] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml') + ) + + hass.services.async_register( + DOMAIN, SERVICE_INCREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_INCREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_DECREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_DECREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_RESET, async_handler_service, + descriptions[DOMAIN][SERVICE_RESET], SERVICE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class Counter(Entity): + """Representation of a counter.""" + + def __init__(self, object_id, name, initial, step, icon): + """Initialize a counter.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._step = step + self._state = self._initial = initial + self._icon = icon + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return name of the counter.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the current value of the counter.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_INITIAL: self._initial, + ATTR_STEP: self._step, + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + # If not None, we got an initial value. + if self._state is not None: + return + + state = yield from async_get_last_state(self.hass, self.entity_id) + self._state = state and state.state == state + + @asyncio.coroutine + def async_decrement(self): + """Decrement the counter.""" + self._state -= self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_increment(self): + """Increment a counter.""" + self._state += self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_reset(self): + """Reset a counter.""" + self._state = self._initial + yield from self.async_update_ha_state() diff --git a/homeassistant/components/cover/abode.py b/homeassistant/components/cover/abode.py new file mode 100644 index 00000000000..6eb0369aa3f --- /dev/null +++ b/homeassistant/components/cover/abode.py @@ -0,0 +1,50 @@ +""" +This component provides HA cover support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.cover import CoverDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode cover devices.""" + import abodepy.helpers.constants as CONST + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER): + if data.is_excluded(device): + continue + + devices.append(AbodeCover(data, device)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeCover(AbodeDevice, CoverDevice): + """Representation of an Abode cover.""" + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return not self._device.is_open + + def close_cover(self, **kwargs): + """Issue close command to cover.""" + self._device.close_cover() + + def open_cover(self, **kwargs): + """Issue open command to cover.""" + self._device.open_cover() diff --git a/homeassistant/components/cover/homematic.py b/homeassistant/components/cover/homematic.py index e8372b84ce4..9e3d675cabe 100644 --- a/homeassistant/components/cover/homematic.py +++ b/homeassistant/components/cover/homematic.py @@ -21,8 +21,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMCover(hass, conf) - new_device.link_homematic() + new_device = HMCover(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 4883cfe3648..b840c780645 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -1,185 +1,213 @@ """ -Support for KNX covers. +Support for KNX/IP covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.knx/ """ -import logging - +import asyncio import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.helpers.event import async_track_utc_time_change from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, ATTR_POSITION, DEVICE_CLASSES_SCHEMA, - SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, SUPPORT_STOP, - SUPPORT_SET_TILT_POSITION -) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, CONF_DEVICE_CLASS) + CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE, + SUPPORT_SET_POSITION, SUPPORT_STOP, SUPPORT_SET_TILT_POSITION, + ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.core import callback +from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_GETPOSITION_ADDRESS = 'getposition_address' -CONF_SETPOSITION_ADDRESS = 'setposition_address' -CONF_GETANGLE_ADDRESS = 'getangle_address' -CONF_SETANGLE_ADDRESS = 'setangle_address' -CONF_STOP = 'stop_address' -CONF_UPDOWN = 'updown_address' +CONF_MOVE_LONG_ADDRESS = 'move_long_address' +CONF_MOVE_SHORT_ADDRESS = 'move_short_address' +CONF_POSITION_ADDRESS = 'position_address' +CONF_POSITION_STATE_ADDRESS = 'position_state_address' +CONF_ANGLE_ADDRESS = 'angle_address' +CONF_ANGLE_STATE_ADDRESS = 'angle_state_address' +CONF_TRAVELLING_TIME_DOWN = 'travelling_time_down' +CONF_TRAVELLING_TIME_UP = 'travelling_time_up' CONF_INVERT_POSITION = 'invert_position' CONF_INVERT_ANGLE = 'invert_angle' +DEFAULT_TRAVEL_TIME = 25 DEFAULT_NAME = 'KNX Cover' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_UPDOWN): cv.string, - vol.Required(CONF_STOP): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_GETPOSITION_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SETPOSITION_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_LONG_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_SHORT_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_STATE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, + vol.Optional(CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, - vol.Inclusive(CONF_GETANGLE_ADDRESS, 'angle'): cv.string, - vol.Inclusive(CONF_SETANGLE_ADDRESS, 'angle'): cv.string, vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXCover(hass, KNXConfig(config))]) +@asyncio.coroutine +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 \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + return True -class KNXCover(KNXMultiAddressDevice, CoverDevice): - """Representation of a KNX cover. e.g. a rollershutter.""" +@callback +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)) + async_add_devices(entities) - def __init__(self, hass, config): + +@callback +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( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_long=config.get(CONF_MOVE_LONG_ADDRESS), + group_address_short=config.get(CONF_MOVE_SHORT_ADDRESS), + group_address_position_state=config.get( + CONF_POSITION_STATE_ADDRESS), + group_address_angle=config.get(CONF_ANGLE_ADDRESS), + 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), + invert_position=config.get(CONF_INVERT_POSITION), + invert_angle=config.get(CONF_INVERT_ANGLE)) + + hass.data[DATA_KNX].xknx.devices.add(cover) + async_add_devices([KNXCover(hass, cover)]) + + +class KNXCover(CoverDevice): + """Representation of a KNX cover.""" + + def __init__(self, hass, device): """Initialize the cover.""" - KNXMultiAddressDevice.__init__( - self, hass, config, - ['updown', 'stop'], # required - optional=['setposition', 'getposition', - 'getangle', 'setangle'] - ) - self._device_class = config.config.get(CONF_DEVICE_CLASS) - self._invert_position = config.config.get(CONF_INVERT_POSITION) - self._invert_angle = config.config.get(CONF_INVERT_ANGLE) - self._hass = hass - self._current_pos = None - self._target_pos = None - self._current_tilt = None - self._target_tilt = None - self._supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ - SUPPORT_SET_POSITION | SUPPORT_STOP + self.device = device + self.hass = hass + self.async_register_callbacks() - # Tilt is only supported, if there is a angle get and set address - if CONF_SETANGLE_ADDRESS in config.config: - _LOGGER.debug("%s: Tilt supported at addresses %s, %s", - self.name, config.config.get(CONF_SETANGLE_ADDRESS), - config.config.get(CONF_GETANGLE_ADDRESS)) - self._supported_features = self._supported_features | \ - SUPPORT_SET_TILT_POSITION + self._unsubscribe_auto_updater = None + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Polling is needed for the KNX cover.""" - return True + """No polling needed within KNX.""" + return False @property def supported_features(self): """Flag supported features.""" - return self._supported_features + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ + SUPPORT_SET_POSITION | SUPPORT_STOP + if self.device.supports_angle: + supported_features |= SUPPORT_SET_TILT_POSITION + return supported_features + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return self.device.current_position() @property def is_closed(self): """Return if the cover is closed.""" - if self.current_cover_position is not None: - if self.current_cover_position > 0: - return False - else: - return True + return self.device.is_closed() - @property - def current_cover_position(self): - """Return current position of cover. + @asyncio.coroutine + def async_close_cover(self, **kwargs): + """Close the cover.""" + if not self.device.is_closed(): + yield from self.device.set_down() + self.start_auto_updater() - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_pos + @asyncio.coroutine + def async_open_cover(self, **kwargs): + """Open the cover.""" + if not self.device.is_open(): + yield from self.device.set_up() + self.start_auto_updater() - @property - def target_position(self): - """Return the position we are trying to reach: 0 - 100.""" - return self._target_pos + @asyncio.coroutine + def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + yield from self.device.set_position(position) + self.start_auto_updater() + + @asyncio.coroutine + def async_stop_cover(self, **kwargs): + """Stop the cover.""" + yield from self.device.stop() + self.stop_auto_updater() @property def current_cover_tilt_position(self): - """Return current position of cover. + """Return current tilt position of cover.""" + if not self.device.supports_angle: + return None + return self.device.current_angle() - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_tilt + @asyncio.coroutine + def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + if ATTR_TILT_POSITION in kwargs: + tilt_position = kwargs[ATTR_TILT_POSITION] + yield from self.device.set_angle(tilt_position) - @property - def target_tilt(self): - """Return the tilt angle (in %) we are trying to reach: 0 - 100.""" - return self._target_tilt + def start_auto_updater(self): + """Start the autoupdater to update HASS while cover is moving.""" + if self._unsubscribe_auto_updater is None: + self._unsubscribe_auto_updater = async_track_utc_time_change( + self.hass, self.auto_updater_hook) - def set_cover_position(self, **kwargs): - """Set new target position.""" - position = kwargs.get(ATTR_POSITION) - if position is None: - return + def stop_auto_updater(self): + """Stop the autoupdater.""" + if self._unsubscribe_auto_updater is not None: + self._unsubscribe_auto_updater() + self._unsubscribe_auto_updater = None - if self._invert_position: - position = 100-position + @callback + def auto_updater_hook(self, now): + """Callback for autoupdater.""" + # pylint: disable=unused-argument + self.async_schedule_update_ha_state() + if self.device.position_reached(): + self.stop_auto_updater() - self._target_pos = position - self.set_percentage('setposition', position) - _LOGGER.debug("%s: Set target position to %d", self.name, position) - - def update(self): - """Update device state.""" - super().update() - value = self.get_percentage('getposition') - if value is not None: - self._current_pos = value - if self._invert_position: - self._current_pos = 100-value - _LOGGER.debug("%s: position = %d", self.name, value) - - if self._supported_features & SUPPORT_SET_TILT_POSITION: - value = self.get_percentage('getangle') - if value is not None: - self._current_tilt = value - if self._invert_angle: - self._current_tilt = 100-value - _LOGGER.debug("%s: tilt = %d", self.name, value) - - def open_cover(self, **kwargs): - """Open the cover.""" - _LOGGER.debug("%s: open: updown = 0", self.name) - self.set_int_value('updown', 0) - - def close_cover(self, **kwargs): - """Close the cover.""" - _LOGGER.debug("%s: open: updown = 1", self.name) - self.set_int_value('updown', 1) - - def stop_cover(self, **kwargs): - """Stop the cover movement.""" - _LOGGER.debug("%s: stop: stop = 1", self.name) - self.set_int_value('stop', 1) - - def set_cover_tilt_position(self, tilt_position, **kwargs): - """Move the cover til to a specific position.""" - if self._invert_angle: - tilt_position = 100-tilt_position - - self._target_tilt = round(tilt_position, -1) - self.set_percentage('setangle', tilt_position) - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._device_class + self.hass.add_job(self.device.auto_stop_if_necessary()) diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 648dba98ca6..31e4f1e3cf2 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -1,14 +1,14 @@ """ -Support for Lutron Caseta SerenaRollerShade. +Support for Lutron Caseta shades. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ import logging - from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION) + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, + ATTR_POSITION, DOMAIN) from homeassistant.components.lutron_caseta import ( LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) @@ -19,11 +19,10 @@ DEPENDENCIES = ['lutron_caseta'] # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Lutron Caseta Serena shades as a cover device.""" + """Set up the Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - cover_devices = bridge.get_devices_by_types(["SerenaRollerShade", - "SerenaHoneycombShade"]) + cover_devices = bridge.get_devices_by_domain(DOMAIN) for cover_device in cover_devices: dev = LutronCasetaCover(cover_device, bridge) devs.append(dev) @@ -32,7 +31,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class LutronCasetaCover(LutronCasetaDevice, CoverDevice): - """Representation of a Lutron Serena shade.""" + """Representation of a Lutron shade.""" @property def supported_features(self): @@ -42,24 +41,26 @@ class LutronCasetaCover(LutronCasetaDevice, CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - return self._state["current_state"] < 1 + return self._state['current_state'] < 1 @property def current_cover_position(self): """Return the current position of cover.""" - return self._state["current_state"] + return self._state['current_state'] - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - def set_cover_position(self, position, **kwargs): - """Move the roller shutter to a specific position.""" - self._smartbridge.set_value(self._device_id, position) + def set_cover_position(self, **kwargs): + """Move the shade to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + self._smartbridge.set_value(self._device_id, position) def update(self): """Call when forcing a refresh of the device.""" diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index eab64fd7abb..d10166a9469 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -21,8 +21,8 @@ from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, - valid_publish_topic, valid_subscribe_topic) + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, + CONF_QOS, CONF_RETAIN, valid_publish_topic, valid_subscribe_topic) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -37,6 +37,8 @@ CONF_SET_POSITION_TEMPLATE = 'set_position_template' CONF_PAYLOAD_OPEN = 'payload_open' CONF_PAYLOAD_CLOSE = 'payload_close' CONF_PAYLOAD_STOP = 'payload_stop' +CONF_PAYLOAD_AVAILABLE = 'payload_available' +CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' CONF_STATE_OPEN = 'state_open' CONF_STATE_CLOSED = 'state_closed' CONF_TILT_CLOSED_POSITION = 'tilt_closed_value' @@ -50,6 +52,8 @@ DEFAULT_NAME = 'MQTT Cover' DEFAULT_PAYLOAD_OPEN = 'OPEN' DEFAULT_PAYLOAD_CLOSE = 'CLOSE' DEFAULT_PAYLOAD_STOP = 'STOP' +DEFAULT_PAYLOAD_AVAILABLE = 'online' +DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' DEFAULT_OPTIMISTIC = False DEFAULT_RETAIN = False DEFAULT_TILT_CLOSED_POSITION = 0 @@ -69,11 +73,16 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SET_POSITION_TEMPLATE, default=None): cv.template, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_AVAILABILITY_TOPIC, default=None): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): cv.string, vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): cv.string, vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, + vol.Optional(CONF_PAYLOAD_AVAILABLE, + default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, + vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, @@ -106,6 +115,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), + config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_TILT_COMMAND_TOPIC), config.get(CONF_TILT_STATUS_TOPIC), config.get(CONF_QOS), @@ -115,6 +125,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_OPEN), config.get(CONF_PAYLOAD_CLOSE), config.get(CONF_PAYLOAD_STOP), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), config.get(CONF_OPTIMISTIC), value_template, config.get(CONF_TILT_OPEN_POSITION), @@ -131,9 +143,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttCover(CoverDevice): """Representation of a cover that can be controlled using MQTT.""" - def __init__(self, name, state_topic, command_topic, tilt_command_topic, - tilt_status_topic, qos, retain, state_open, state_closed, - payload_open, payload_close, payload_stop, + def __init__(self, name, state_topic, command_topic, availability_topic, + tilt_command_topic, tilt_status_topic, qos, retain, + state_open, state_closed, payload_open, payload_close, + payload_stop, payload_available, payload_not_available, optimistic, value_template, tilt_open_position, tilt_closed_position, tilt_min, tilt_max, tilt_optimistic, tilt_invert, position_topic, set_position_template): @@ -143,12 +156,16 @@ class MqttCover(CoverDevice): self._name = name self._state_topic = state_topic self._command_topic = command_topic + self._availability_topic = availability_topic + self._available = True if availability_topic is None else False self._tilt_command_topic = tilt_command_topic self._tilt_status_topic = tilt_status_topic self._qos = qos self._payload_open = payload_open self._payload_close = payload_close self._payload_stop = payload_stop + self._payload_available = payload_available + self._payload_not_available = payload_not_available self._state_open = state_open self._state_closed = state_closed self._retain = retain @@ -178,11 +195,11 @@ 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): - """Handle new MQTT message.""" + def state_message_received(topic, payload, qos): + """Handle new MQTT state messages.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) @@ -203,14 +220,30 @@ class MqttCover(CoverDevice): payload) return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() + + @callback + def availability_message_received(topic, payload, qos): + """Handle new MQTT availability messages.""" + 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._state_topic is None: # Force into optimistic mode. self._optimistic = True else: yield from mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + self.hass, self._state_topic, + state_message_received, self._qos) + + if self._availability_topic is not None: + yield from mqtt.async_subscribe( + self.hass, self._availability_topic, + availability_message_received, self._qos) if self._tilt_status_topic is None: self._tilt_optimistic = True @@ -230,6 +263,11 @@ class MqttCover(CoverDevice): """Return the name of the cover.""" return self._name + @property + def available(self) -> bool: + """Return if cover is available.""" + return self._available + @property def is_closed(self): """Return if the cover is closed.""" @@ -275,7 +313,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 +327,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 +347,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 +357,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/mysensors.py b/homeassistant/components/cover/mysensors.py index f48a2110eca..cd4ff62b3e9 100644 --- a/homeassistant/components/cover/mysensors.py +++ b/homeassistant/components/cover/mysensors.py @@ -4,42 +4,18 @@ Support for MySensors covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.mysensors/ """ -import logging - from homeassistant.components import mysensors -from homeassistant.components.cover import CoverDevice, ATTR_POSITION +from homeassistant.components.cover import CoverDevice, ATTR_POSITION, DOMAIN from homeassistant.const import STATE_ON, STATE_OFF -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = [] - def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for covers.""" - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_COVER: [set_req.V_DIMMER, set_req.V_LIGHT], - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_COVER: [set_req.V_PERCENTAGE, set_req.V_STATUS], - }) - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsCover, add_devices)) + """Setup the mysensors platform for covers.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsCover, add_devices=add_devices) -class MySensorsCover(mysensors.MySensorsDeviceEntity, CoverDevice): +class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): """Representation of the value of a MySensors Cover child node.""" @property diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py index f599ea3ede1..0e28d3ef701 100644 --- a/homeassistant/components/cover/rfxtrx.py +++ b/homeassistant/components/cover/rfxtrx.py @@ -16,7 +16,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the RFXtrx cover.""" import RFXtrx as rfxtrxmod - covers = rfxtrx.get_devices_from_config(config, RfxtrxCover, hass) + covers = rfxtrx.get_devices_from_config(config, RfxtrxCover) add_devices_callback(covers) def cover_update(event): @@ -26,7 +26,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): not event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxCover, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxCover) if new_device: add_devices_callback([new_device]) diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index 769c2fc4ed6..2e3ad7fff16 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_ENTITY_ID, EVENT_HOMEASSISTANT_START, MATCH_ALL, CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, - STATE_OPEN, STATE_CLOSED) + CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id @@ -39,6 +39,8 @@ CLOSE_ACTION = 'close_cover' STOP_ACTION = 'stop_cover' POSITION_ACTION = 'set_cover_position' TILT_ACTION = 'set_cover_tilt_position' +CONF_TILT_OPTIMISTIC = 'tilt_optimistic' + CONF_VALUE_OR_POSITION_TEMPLATE = 'value_or_position' CONF_OPEN_OR_CLOSE = 'open_or_close' @@ -56,6 +58,8 @@ COVER_SCHEMA = vol.Schema({ vol.Optional(CONF_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_TEMPLATE): cv.template, vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_FRIENDLY_NAME, default=None): cv.string, @@ -83,11 +87,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): stop_action = device_config.get(STOP_ACTION) position_action = device_config.get(POSITION_ACTION) tilt_action = device_config.get(TILT_ACTION) - - if position_template is None and state_template is None: - _LOGGER.error('Must specify either %s' or '%s', - CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE) - continue + optimistic = device_config.get(CONF_OPTIMISTIC) + tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC) if position_action is None and open_action is None: _LOGGER.error('Must specify at least one of %s' or '%s', @@ -125,7 +126,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device, friendly_name, state_template, position_template, tilt_template, icon_template, open_action, close_action, stop_action, - position_action, tilt_action, entity_ids + position_action, tilt_action, + optimistic, tilt_optimistic, entity_ids ) ) if not covers: @@ -142,7 +144,8 @@ class CoverTemplate(CoverDevice): def __init__(self, hass, device_id, friendly_name, state_template, position_template, tilt_template, icon_template, open_action, close_action, stop_action, - position_action, tilt_action, entity_ids): + position_action, tilt_action, + optimistic, tilt_optimistic, entity_ids): """Initialize the Template cover.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -167,6 +170,9 @@ class CoverTemplate(CoverDevice): self._tilt_script = None if tilt_action is not None: self._tilt_script = Script(hass, tilt_action) + self._optimistic = (optimistic or + (not state_template and not position_template)) + self._tilt_optimistic = tilt_optimistic or not tilt_template self._icon = None self._position = None self._tilt_value = None @@ -191,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): @@ -199,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) @@ -260,19 +266,23 @@ class CoverTemplate(CoverDevice): def async_open_cover(self, **kwargs): """Move the cover up.""" if self._open_script: - self.hass.async_add_job(self._open_script.async_run()) + yield from self._open_script.async_run() elif self._position_script: - self.hass.async_add_job(self._position_script.async_run( - {"position": 100})) + yield from self._position_script.async_run({"position": 100}) + if self._optimistic: + self._position = 100 + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover(self, **kwargs): """Move the cover down.""" if self._close_script: - self.hass.async_add_job(self._close_script.async_run()) + yield from self._close_script.async_run() elif self._position_script: - self.hass.async_add_job(self._position_script.async_run( - {"position": 0})) + yield from self._position_script.async_run({"position": 0}) + if self._optimistic: + self._position = 0 + self.async_schedule_update_ha_state() @asyncio.coroutine def async_stop_cover(self, **kwargs): @@ -284,29 +294,35 @@ class CoverTemplate(CoverDevice): def async_set_cover_position(self, **kwargs): """Set cover position.""" self._position = kwargs[ATTR_POSITION] - self.hass.async_add_job(self._position_script.async_run( - {"position": self._position})) + yield from self._position_script.async_run( + {"position": self._position}) + if self._optimistic: + self.async_schedule_update_ha_state() @asyncio.coroutine def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" self._tilt_value = 100 - self.hass.async_add_job(self._tilt_script.async_run( - {"tilt": self._tilt_value})) + yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + if self._tilt_optimistic: + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" self._tilt_value = 0 - self.hass.async_add_job(self._tilt_script.async_run( - {"tilt": self._tilt_value})) + yield from self._tilt_script.async_run( + {"tilt": self._tilt_value}) + if self._tilt_optimistic: + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] - self.hass.async_add_job(self._tilt_script.async_run( - {"tilt": self._tilt_value})) + yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + if self._tilt_optimistic: + 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 77% rename from homeassistant/components/cover/xiaomi.py rename to homeassistant/components/cover/xiaomi_aqara.py index 7e3b0b7044d..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__) @@ -24,10 +25,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class XiaomiGenericCover(XiaomiDevice, CoverDevice): - """Representation of a XiaomiPlug.""" + """Representation of a XiaomiGenericCover.""" def __init__(self, device, name, data_key, xiaomi_hub): - """Initialize the XiaomiPlug.""" + """Initialize the XiaomiGenericCover.""" self._data_key = data_key self._pos = 0 XiaomiDevice.__init__(self, device, name, xiaomi_hub) @@ -44,19 +45,19 @@ class XiaomiGenericCover(XiaomiDevice, CoverDevice): def close_cover(self, **kwargs): """Close the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'close') + self._write_to_hub(self._sid, **{self._data_key['status']: 'close'}) def open_cover(self, **kwargs): """Open the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'open') + self._write_to_hub(self._sid, **{self._data_key['status']: 'open'}) def stop_cover(self, **kwargs): """Stop the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'stop') + self._write_to_hub(self._sid, **{self._data_key['status']: 'stop'}) def set_cover_position(self, position, **kwargs): """Move the cover to a specific position.""" - self._write_to_hub(self._sid, self._data_key['pos'], str(position)) + self._write_to_hub(self._sid, **{self._data_key['pos']: str(position)}) def parse_data(self, data): """Parse data sent by gateway.""" 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 071edf42642..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.0'] +REQUIREMENTS = ['aioautomatic==0.6.3'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -205,6 +205,7 @@ class AutomaticData(object): self.hass = hass self.devices = devices self.vehicle_info = {} + self.vehicle_seen = {} self.client = client self.session = session self.async_see = async_see @@ -236,6 +237,14 @@ class AutomaticData(object): return yield from self.get_vehicle_info(vehicle) + if event.created_at < self.vehicle_seen[event.vehicle.id]: + # Skip events received out of order + _LOGGER.debug("Skipping out of order event. Event Created %s. " + "Last seen event: %s.", event.created_at, + self.vehicle_seen[event.vehicle.id]) + return + self.vehicle_seen[event.vehicle.id] = event.created_at + kwargs = self.vehicle_info[event.vehicle.id] if kwargs is None: # Ignored device @@ -323,15 +332,17 @@ class AutomaticData(object): if self.devices is not None and name not in self.devices: self.vehicle_info[vehicle.id] = None return - else: - self.vehicle_info[vehicle.id] = kwargs = { - ATTR_DEV_ID: vehicle.id, - ATTR_HOST_NAME: name, - ATTR_MAC: vehicle.id, - ATTR_ATTRIBUTES: { - ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, - } + + self.vehicle_info[vehicle.id] = kwargs = { + ATTR_DEV_ID: vehicle.id, + ATTR_HOST_NAME: name, + ATTR_MAC: vehicle.id, + ATTR_ATTRIBUTES: { + ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, } + } + self.vehicle_seen[vehicle.id] = \ + vehicle.updated_at or vehicle.created_at if vehicle.latest_location is not None: location = vehicle.latest_location @@ -352,4 +363,7 @@ class AutomaticData(object): kwargs[ATTR_GPS] = (location.lat, location.lon) kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m + if trips[0].ended_at >= self.vehicle_seen[vehicle.id]: + self.vehicle_seen[vehicle.id] = trips[0].ended_at + return kwargs diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py new file mode 100755 index 00000000000..d4e576bad74 --- /dev/null +++ b/homeassistant/components/device_tracker/geofency.py @@ -0,0 +1,127 @@ +""" +Support for the Geofency platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.geofency/ +""" +import asyncio +from functools import partial +import logging + +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + ATTR_LATITUDE, ATTR_LONGITUDE, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + +BEACON_DEV_PREFIX = 'beacon' +CONF_MOBILE_BEACONS = 'mobile_beacons' + +LOCATION_ENTRY = '1' +LOCATION_EXIT = '0' + +URL = '/api/geofency' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MOBILE_BEACONS): vol.All( + cv.ensure_list, [cv.string]), +}) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up an endpoint for the Geofency application.""" + mobile_beacons = config.get(CONF_MOBILE_BEACONS) or [] + + hass.http.register_view(GeofencyView(see, mobile_beacons)) + + return True + + +class GeofencyView(HomeAssistantView): + """View to handle Geofency requests.""" + + url = URL + name = 'api:geofency' + + def __init__(self, see, mobile_beacons): + """Initialize Geofency url endpoints.""" + self.see = see + self.mobile_beacons = [slugify(beacon) for beacon in mobile_beacons] + + @asyncio.coroutine + def post(self, request): + """Handle Geofency requests.""" + data = yield from request.post() + hass = request.app['hass'] + + data = self._validate_data(data) + if not data: + return ("Invalid data", HTTP_UNPROCESSABLE_ENTITY) + + if self._is_mobile_beacon(data): + return (yield from self._set_location(hass, data, None)) + else: + if data['entry'] == LOCATION_ENTRY: + location_name = data['name'] + else: + location_name = STATE_NOT_HOME + + return (yield from self._set_location(hass, data, location_name)) + + @staticmethod + def _validate_data(data): + """Validate POST payload.""" + data = data.copy() + + required_attributes = ['address', 'device', 'entry', + 'latitude', 'longitude', 'name'] + + valid = True + for attribute in required_attributes: + if attribute not in data: + valid = False + _LOGGER.error("'%s' not specified in message", attribute) + + if not valid: + return False + + data['address'] = data['address'].replace('\n', ' ') + data['device'] = slugify(data['device']) + data['name'] = slugify(data['name']) + + data[ATTR_LATITUDE] = float(data[ATTR_LATITUDE]) + data[ATTR_LONGITUDE] = float(data[ATTR_LONGITUDE]) + + return data + + def _is_mobile_beacon(self, data): + """Check if we have a mobile beacon.""" + return 'beaconUUID' in data and data['name'] in self.mobile_beacons + + @staticmethod + def _device_name(data): + """Return name of device tracker.""" + if 'beaconUUID' in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) + else: + return data['device'] + + @asyncio.coroutine + def _set_location(self, hass, data, location_name): + """Fire HA event to set location.""" + device = self._device_name(data) + + yield from hass.async_add_job( + partial(self.see, dev_id=device, + gps=(data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name=location_name, + attributes=data)) + + return "Setting location for {}".format(device) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index f20dad1fceb..472b48fef6e 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -248,7 +248,7 @@ class Icloud(DeviceScanner): self._trusted_device, self._verification_code): raise PyiCloudException('Unknown failure') except PyiCloudException as error: - # Reset to the inital 2FA state to allow the user to retry + # Reset to the initial 2FA state to allow the user to retry _LOGGER.error("Failed to verify verification code: %s", error) self._trusted_device = None self._verification_code = None @@ -307,12 +307,15 @@ class Icloud(DeviceScanner): self.api.authenticate() currentminutes = dt_util.now().hour * 60 + dt_util.now().minute - for devicename in self.devices: - interval = self._intervals.get(devicename, 1) - if ((currentminutes % interval == 0) or - (interval > 10 and - currentminutes % interval in [2, 4])): - self.update_device(devicename) + try: + for devicename in self.devices: + interval = self._intervals.get(devicename, 1) + if ((currentminutes % interval == 0) or + (interval > 10 and + currentminutes % interval in [2, 4])): + self.update_device(devicename) + except ValueError: + _LOGGER.debug("iCloud API returned an error") def determine_interval(self, devicename, latitude, longitude, battery): """Calculate new interval.""" @@ -397,7 +400,7 @@ class Icloud(DeviceScanner): self.see(**kwargs) self.seen_devices[devicename] = True except PyiCloudNoDevicesException: - _LOGGER.error('No iCloud Devices found!') + _LOGGER.error("No iCloud Devices found") def lost_iphone(self, devicename): """Call the lost iPhone function if the device is found.""" 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/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index 4503c4d1b26..f68eb361ca0 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -4,61 +4,51 @@ Support for tracking MySensors devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.mysensors/ """ -import logging - from homeassistant.components import mysensors +from homeassistant.components.device_tracker import DOMAIN +from homeassistant.helpers.dispatcher import dispatcher_connect from homeassistant.util import slugify -DEPENDENCIES = ['mysensors'] - -_LOGGER = logging.getLogger(__name__) - def setup_scanner(hass, config, see, discovery_info=None): - """Set up the MySensors tracker.""" - def mysensors_callback(gateway, msg): - """Set up callback for mysensors platform.""" - node = gateway.sensors[msg.node_id] - if node.sketch_name is None: - _LOGGER.debug("No sketch_name: node %s", msg.node_id) - return + """Set up the MySensors device scanner.""" + new_devices = mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsDeviceScanner, + device_args=(see, )) + if not new_devices: + return False - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - - child = node.children.get(msg.child_id) - if child is None: - return - position = child.values.get(set_req.V_POSITION) - if child.type != pres.S_GPS or position is None: - return - try: - latitude, longitude, _ = position.split(',') - except ValueError: - _LOGGER.error("Payload for V_POSITION %s is not of format " - "latitude, longitude, altitude", position) - return - name = '{} {} {}'.format( - node.sketch_name, msg.node_id, child.id) - attr = { - mysensors.ATTR_CHILD_ID: child.id, - mysensors.ATTR_DESCRIPTION: child.description, - mysensors.ATTR_DEVICE: gateway.device, - mysensors.ATTR_NODE_ID: msg.node_id, - } - see( - dev_id=slugify(name), - host_name=name, - gps=(latitude, longitude), - battery=node.battery_level, - attributes=attr - ) - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - - for gateway in gateways: - if float(gateway.protocol_version) < 2.0: - continue - gateway.platform_callbacks.append(mysensors_callback) + for device in new_devices: + dev_id = ( + id(device.gateway), device.node_id, device.child_id, + device.value_type) + dispatcher_connect( + hass, mysensors.SIGNAL_CALLBACK.format(*dev_id), + device.update_callback) return True + + +class MySensorsDeviceScanner(mysensors.MySensorsDevice): + """Represent a MySensors scanner.""" + + def __init__(self, see, *args): + """Set up instance.""" + super().__init__(*args) + self.see = see + + def update_callback(self): + """Update the device.""" + self.update() + node = self.gateway.sensors[self.node_id] + child = node.children[self.child_id] + position = child.values[self.value_type] + latitude, longitude, _ = position.split(',') + + self.see( + dev_id=slugify(self.name), + host_name=self.name, + gps=(latitude, longitude), + battery=node.battery_level, + attributes=self.device_state_attributes + ) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index b23008336ac..1c773f97692 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -16,7 +16,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.components.mqtt as mqtt from homeassistant.const import STATE_HOME -from homeassistant.util import convert, slugify +from homeassistant.util import slugify, decorator from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import PLATFORM_SCHEMA @@ -25,6 +25,8 @@ REQUIREMENTS = ['libnacl==1.5.2'] _LOGGER = logging.getLogger(__name__) +HANDLERS = decorator.Registry() + BEACON_DEV_ID = 'beacon' CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' @@ -32,17 +34,7 @@ CONF_SECRET = 'secret' CONF_WAYPOINT_IMPORT = 'waypoints' CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' -EVENT_TOPIC = 'owntracks/+/+/event' - -LOCATION_TOPIC = 'owntracks/+/+' - -VALIDATE_LOCATION = 'location' -VALIDATE_TRANSITION = 'transition' -VALIDATE_WAYPOINTS = 'waypoints' - -WAYPOINT_LAT_KEY = 'lat' -WAYPOINT_LON_KEY = 'lon' -WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint' +OWNTRACKS_TOPIC = 'owntracks/#' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), @@ -77,295 +69,61 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) secret = config.get(CONF_SECRET) - mobile_beacons_active = defaultdict(list) - regions_entered = defaultdict(list) + context = OwnTracksContext(async_see, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist) - def decrypt_payload(topic, ciphertext): - """Decrypt encrypted payload.""" + @asyncio.coroutine + def async_handle_mqtt_message(topic, payload, qos): + """Handle incoming OwnTracks message.""" try: - keylen, decrypt = get_cipher() - except OSError: - _LOGGER.warning( - "Ignoring encrypted payload because libsodium not installed") - return None - - if isinstance(secret, dict): - key = secret.get(topic) - else: - key = secret - - if key is None: - _LOGGER.warning( - "Ignoring encrypted payload because no decryption key known " - "for topic %s", topic) - return None - - key = key.encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') - - try: - ciphertext = base64.b64decode(ciphertext) - message = decrypt(ciphertext, key) - message = message.decode("utf-8") - _LOGGER.debug("Decrypted payload: %s", message) - return message - except ValueError: - _LOGGER.warning( - "Ignoring encrypted payload because unable to decrypt using " - "key for topic %s", topic) - return None - - def validate_payload(topic, payload, data_type): - """Validate the OwnTracks payload.""" - try: - data = json.loads(payload) + message = json.loads(payload) except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", payload) - return None - if isinstance(data, dict) and \ - data.get('_type') == 'encrypted' and \ - 'data' in data: - plaintext_payload = decrypt_payload(topic, data['data']) - if plaintext_payload is None: - return None - return validate_payload(topic, plaintext_payload, data_type) + message['topic'] = topic - if not isinstance(data, dict) or data.get('_type') != data_type: - _LOGGER.debug("Skipping %s update for following data " - "because of missing or malformatted data: %s", - data_type, data) - return None - if data_type == VALIDATE_TRANSITION or data_type == VALIDATE_WAYPOINTS: - return data - if max_gps_accuracy is not None and \ - convert(data.get('acc'), float, 0.0) > max_gps_accuracy: - _LOGGER.info("Ignoring %s update because expected GPS " - "accuracy %s is not met: %s", - data_type, max_gps_accuracy, payload) - return None - if convert(data.get('acc'), float, 1.0) == 0.0: - _LOGGER.warning( - "Ignoring %s update because GPS accuracy is zero: %s", - data_type, payload) - return None - - return data - - @callback - def async_owntracks_location_update(topic, payload, qos): - """MQTT message received.""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typelocation - data = validate_payload(topic, payload, VALIDATE_LOCATION) - if not data: - return - - dev_id, kwargs = _parse_see_args(topic, data) - - if regions_entered[dev_id]: - _LOGGER.debug( - "Location update ignored, inside region %s", - regions_entered[-1]) - return - - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - @callback - def async_owntracks_event_update(topic, payload, qos): - """Handle MQTT event (geofences).""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typetransition - data = validate_payload(topic, payload, VALIDATE_TRANSITION) - if not data: - return - - if data.get('desc') is None: - _LOGGER.error( - "Location missing from `Entering/Leaving` message - " - "please turn `Share` on in OwnTracks app") - return - # OwnTracks uses - at the start of a beacon zone - # to switch on 'hold mode' - ignore this - location = data['desc'].lstrip("-") - if location.lower() == 'home': - location = STATE_HOME - - dev_id, kwargs = _parse_see_args(topic, data) - - def enter_event(): - """Execute enter event.""" - zone = hass.states.get("zone.{}".format(slugify(location))) - if zone is None and data.get('t') == 'b': - # Not a HA zone, and a beacon so assume mobile - beacons = mobile_beacons_active[dev_id] - if location not in beacons: - beacons.append(location) - _LOGGER.info("Added beacon %s", location) - else: - # Normal region - regions = regions_entered[dev_id] - if location not in regions: - regions.append(location) - _LOGGER.info("Enter region %s", location) - _set_gps_from_zone(kwargs, location, zone) - - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - def leave_event(): - """Execute leave event.""" - regions = regions_entered[dev_id] - if location in regions: - regions.remove(location) - new_region = regions[-1] if regions else None - - if new_region: - # Exit to previous region - zone = hass.states.get( - "zone.{}".format(slugify(new_region))) - _set_gps_from_zone(kwargs, new_region, zone) - _LOGGER.info("Exit to %s", new_region) - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - else: - _LOGGER.info("Exit to GPS") - # Check for GPS accuracy - valid_gps = True - if 'acc' in data: - if data['acc'] == 0.0: - valid_gps = False - _LOGGER.warning( - "Ignoring GPS in region exit because accuracy" - "is zero: %s", payload) - if (max_gps_accuracy is not None and - data['acc'] > max_gps_accuracy): - valid_gps = False - _LOGGER.info( - "Ignoring GPS in region exit because expected " - "GPS accuracy %s is not met: %s", - max_gps_accuracy, payload) - if valid_gps: - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - beacons = mobile_beacons_active[dev_id] - if location in beacons: - beacons.remove(location) - _LOGGER.info("Remove beacon %s", location) - - if data['event'] == 'enter': - enter_event() - elif data['event'] == 'leave': - leave_event() - else: - _LOGGER.error( - "Misformatted mqtt msgs, _type=transition, event=%s", - data['event']) - return - - @callback - def async_owntracks_waypoint_update(topic, payload, qos): - """List of waypoints published by a user.""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typewaypoints - data = validate_payload(topic, payload, VALIDATE_WAYPOINTS) - if not data: - return - - wayps = data['waypoints'] - _LOGGER.info("Got %d waypoints from %s", len(wayps), topic) - for wayp in wayps: - name = wayp['desc'] - pretty_name = parse_topic(topic, True)[1] + ' - ' + name - lat = wayp[WAYPOINT_LAT_KEY] - lon = wayp[WAYPOINT_LON_KEY] - rad = wayp['rad'] - - # check zone exists - entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) - - # Check if state already exists - if hass.states.get(entity_id) is not None: - continue - - zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, - zone_comp.ICON_IMPORT, False) - zone.entity_id = entity_id - hass.async_add_job(zone.async_update_ha_state()) - - @callback - def async_see_beacons(dev_id, kwargs_param): - """Set active beacons to the current location.""" - kwargs = kwargs_param.copy() - # the battery state applies to the tracking device, not the beacon - kwargs.pop('battery', None) - for beacon in mobile_beacons_active[dev_id]: - kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) - kwargs['host_name'] = beacon - hass.async_add_job(async_see(**kwargs)) + yield from async_handle_message(hass, context, message) yield from mqtt.async_subscribe( - hass, LOCATION_TOPIC, async_owntracks_location_update, 1) - yield from mqtt.async_subscribe( - hass, EVENT_TOPIC, async_owntracks_event_update, 1) - - if waypoint_import: - if waypoint_whitelist is None: - yield from mqtt.async_subscribe( - hass, WAYPOINT_TOPIC.format('+', '+'), - async_owntracks_waypoint_update, 1) - else: - for whitelist_user in waypoint_whitelist: - yield from mqtt.async_subscribe( - hass, WAYPOINT_TOPIC.format(whitelist_user, '+'), - async_owntracks_waypoint_update, 1) + hass, OWNTRACKS_TOPIC, async_handle_mqtt_message, 1) return True -def parse_topic(topic, pretty=False): +def _parse_topic(topic): """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple. Async friendly. """ - parts = topic.split('/') - dev_id_format = '' - if pretty: - dev_id_format = '{} {}' - else: - dev_id_format = '{}_{}' - dev_id = slugify(dev_id_format.format(parts[1], parts[2])) - host_name = parts[1] - return (host_name, dev_id) + _, user, device, *_ = topic.split('/', 3) + + return user, device -def _parse_see_args(topic, data): +def _parse_see_args(message): """Parse the OwnTracks location parameters, into the format see expects. Async friendly. """ - (host_name, dev_id) = parse_topic(topic, False) + user, device = _parse_topic(message['topic']) + dev_id = slugify('{}_{}'.format(user, device)) kwargs = { 'dev_id': dev_id, - 'host_name': host_name, - 'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY]), + 'host_name': user, + 'gps': (message['lat'], message['lon']), 'attributes': {} } - if 'acc' in data: - kwargs['gps_accuracy'] = data['acc'] - if 'batt' in data: - kwargs['battery'] = data['batt'] - if 'vel' in data: - kwargs['attributes']['velocity'] = data['vel'] - if 'tid' in data: - kwargs['attributes']['tid'] = data['tid'] - if 'addr' in data: - kwargs['attributes']['address'] = data['addr'] + if 'acc' in message: + kwargs['gps_accuracy'] = message['acc'] + if 'batt' in message: + kwargs['battery'] = message['batt'] + if 'vel' in message: + kwargs['attributes']['velocity'] = message['vel'] + if 'tid' in message: + kwargs['attributes']['tid'] = message['tid'] + if 'addr' in message: + kwargs['attributes']['address'] = message['addr'] return dev_id, kwargs @@ -382,3 +140,269 @@ def _set_gps_from_zone(kwargs, location, zone): kwargs['gps_accuracy'] = zone.attributes['radius'] kwargs['location_name'] = location return kwargs + + +def _decrypt_payload(secret, topic, ciphertext): + """Decrypt encrypted payload.""" + try: + keylen, decrypt = get_cipher() + except OSError: + _LOGGER.warning( + "Ignoring encrypted payload because libsodium not installed") + return None + + if isinstance(secret, dict): + key = secret.get(topic) + else: + key = secret + + if key is None: + _LOGGER.warning( + "Ignoring encrypted payload because no decryption key known " + "for topic %s", topic) + return None + + key = key.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + try: + ciphertext = base64.b64decode(ciphertext) + message = decrypt(ciphertext, key) + message = message.decode("utf-8") + _LOGGER.debug("Decrypted payload: %s", message) + return message + except ValueError: + _LOGGER.warning( + "Ignoring encrypted payload because unable to decrypt using " + "key for topic %s", topic) + return None + + +class OwnTracksContext: + """Hold the current OwnTracks context.""" + + def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, + waypoint_whitelist): + """Initialize an OwnTracks context.""" + self.async_see = async_see + self.secret = secret + self.max_gps_accuracy = max_gps_accuracy + self.mobile_beacons_active = defaultdict(list) + self.regions_entered = defaultdict(list) + self.import_waypoints = import_waypoints + self.waypoint_whitelist = waypoint_whitelist + + @callback + def async_valid_accuracy(self, message): + """Check if we should ignore this message.""" + acc = message.get('acc') + + if acc is None: + return False + + try: + acc = float(acc) + except ValueError: + return False + + if acc == 0: + _LOGGER.warning( + "Ignoring %s update because GPS accuracy is zero: %s", + message['_type'], message) + return False + + if self.max_gps_accuracy is not None and \ + acc > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + message['_type'], self.max_gps_accuracy, + message) + return False + + return True + + @asyncio.coroutine + def async_see_beacons(self, dev_id, kwargs_param): + """Set active beacons to the current location.""" + kwargs = kwargs_param.copy() + # the battery state applies to the tracking device, not the beacon + kwargs.pop('battery', None) + for beacon in self.mobile_beacons_active[dev_id]: + kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) + kwargs['host_name'] = beacon + yield from self.async_see(**kwargs) + + +@HANDLERS.register('location') +@asyncio.coroutine +def async_handle_location_message(hass, context, message): + """Handle a location message.""" + if not context.async_valid_accuracy(message): + return + + dev_id, kwargs = _parse_see_args(message) + + if context.regions_entered[dev_id]: + _LOGGER.debug( + "Location update ignored, inside region %s", + context.regions_entered[-1]) + return + + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + +@asyncio.coroutine +def _async_transition_message_enter(hass, context, message, location): + """Execute enter event.""" + zone = hass.states.get("zone.{}".format(slugify(location))) + dev_id, kwargs = _parse_see_args(message) + + if zone is None and message.get('t') == 'b': + # Not a HA zone, and a beacon so assume mobile + beacons = context.mobile_beacons_active[dev_id] + if location not in beacons: + beacons.append(location) + _LOGGER.info("Added beacon %s", location) + else: + # Normal region + regions = context.regions_entered[dev_id] + if location not in regions: + regions.append(location) + _LOGGER.info("Enter region %s", location) + _set_gps_from_zone(kwargs, location, zone) + + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + +@asyncio.coroutine +def _async_transition_message_leave(hass, context, message, location): + """Execute leave event.""" + dev_id, kwargs = _parse_see_args(message) + regions = context.regions_entered[dev_id] + + if location in regions: + regions.remove(location) + + new_region = regions[-1] if regions else None + + if new_region: + # Exit to previous region + zone = hass.states.get( + "zone.{}".format(slugify(new_region))) + _set_gps_from_zone(kwargs, new_region, zone) + _LOGGER.info("Exit to %s", new_region) + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + return + + else: + _LOGGER.info("Exit to GPS") + + # Check for GPS accuracy + if context.async_valid_accuracy(message): + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + beacons = context.mobile_beacons_active[dev_id] + if location in beacons: + beacons.remove(location) + _LOGGER.info("Remove beacon %s", location) + + +@HANDLERS.register('transition') +@asyncio.coroutine +def async_handle_transition_message(hass, context, message): + """Handle a transition message.""" + if message.get('desc') is None: + _LOGGER.error( + "Location missing from `Entering/Leaving` message - " + "please turn `Share` on in OwnTracks app") + return + # OwnTracks uses - at the start of a beacon zone + # to switch on 'hold mode' - ignore this + location = message['desc'].lstrip("-") + if location.lower() == 'home': + location = STATE_HOME + + if message['event'] == 'enter': + yield from _async_transition_message_enter( + hass, context, message, location) + elif message['event'] == 'leave': + yield from _async_transition_message_leave( + hass, context, message, location) + else: + _LOGGER.error( + "Misformatted mqtt msgs, _type=transition, event=%s", + message['event']) + + +@HANDLERS.register('waypoints') +@asyncio.coroutine +def async_handle_waypoints_message(hass, context, message): + """Handle a waypoints message.""" + if not context.import_waypoints: + return + + if context.waypoint_whitelist is not None: + user = _parse_topic(message['topic'])[0] + + if user not in context.waypoint_whitelist: + return + + wayps = message['waypoints'] + + _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) + + name_base = ' '.join(_parse_topic(message['topic'])) + + for wayp in wayps: + name = wayp['desc'] + pretty_name = '{} - {}'.format(name_base, name) + lat = wayp['lat'] + lon = wayp['lon'] + rad = wayp['rad'] + + # check zone exists + entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) + + # Check if state already exists + if hass.states.get(entity_id) is not None: + continue + + zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, + zone_comp.ICON_IMPORT, False) + zone.entity_id = entity_id + yield from zone.async_update_ha_state() + + +@HANDLERS.register('encrypted') +@asyncio.coroutine +def async_handle_encrypted_message(hass, context, message): + """Handle an encrypted message.""" + plaintext_payload = _decrypt_payload(context.secret, message['topic'], + message['data']) + + if plaintext_payload is None: + return + + decrypted = json.loads(plaintext_payload) + decrypted['topic'] = message['topic'] + + yield from async_handle_message(hass, context, decrypted) + + +@asyncio.coroutine +def async_handle_message(hass, context, message): + """Handle an OwnTracks message.""" + msgtype = message.get('_type') + + handler = HANDLERS.get(msgtype) + + if handler is None: + error = 'Received unsupported message type: {}.'.format(msgtype) + _LOGGER.warning(error) + + yield from handler(hass, context, message) diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 3efae2b9ce2..25176cd82d0 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -75,7 +75,7 @@ class SnmpScanner(DeviceScanner): return [client['mac'] for client in self.last_results if client.get('mac')] - # Supressing no-self-use warning + # Suppressing no-self-use warning # pylint: disable=R0201 def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" diff --git a/homeassistant/components/device_tracker/tesla.py b/homeassistant/components/device_tracker/tesla.py new file mode 100644 index 00000000000..4945e98a94d --- /dev/null +++ b/homeassistant/components/device_tracker/tesla.py @@ -0,0 +1,57 @@ +""" +Support for the Tesla platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.tesla/ +""" +import logging + +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the Tesla tracker.""" + TeslaDeviceTracker( + hass, config, see, + hass.data[TESLA_DOMAIN]['devices']['devices_tracker']) + return True + + +class TeslaDeviceTracker(object): + """A class representing a Tesla device.""" + + def __init__(self, hass, config, see, tesla_devices): + """Initialize the Tesla device scanner.""" + self.hass = hass + self.see = see + self.devices = tesla_devices + self._update_info() + + track_utc_time_change( + self.hass, self._update_info, second=range(0, 60, 30)) + + def _update_info(self, now=None): + """Update the device info.""" + for device in self.devices: + device.update() + name = device.name + _LOGGER.debug("Updating device position: %s", name) + dev_id = slugify(device.uniq_name) + location = device.get_location() + lat = location['latitude'] + lon = location['longitude'] + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': name + } + self.see( + dev_id=dev_id, host_name=name, + gps=(lat, lon), attributes=attrs + ) diff --git a/homeassistant/components/device_tracker/volvooncall.py b/homeassistant/components/device_tracker/volvooncall.py index 4312c5dd54a..7872f8f1f1c 100644 --- a/homeassistant/components/device_tracker/volvooncall.py +++ b/homeassistant/components/device_tracker/volvooncall.py @@ -20,11 +20,12 @@ def setup_scanner(hass, config, see, discovery_info=None): return vin, _ = discovery_info - vehicle = hass.data[DATA_KEY].vehicles[vin] + voc = hass.data[DATA_KEY] + vehicle = voc.vehicles[vin] def see_vehicle(vehicle): """Handle the reporting of the vehicle position.""" - host_name = vehicle.registration_number + host_name = voc.vehicle_name(vehicle) dev_id = 'volvo_{}'.format(slugify(host_name)) see(dev_id=dev_id, host_name=host_name, diff --git a/homeassistant/components/device_tracker/xiaomi.py b/homeassistant/components/device_tracker/xiaomi.py index 8b8db3da2d8..12e64b724dd 100644 --- a/homeassistant/components/device_tracker/xiaomi.py +++ b/homeassistant/components/device_tracker/xiaomi.py @@ -69,7 +69,7 @@ class XiaomiDeviceScanner(DeviceScanner): return self.mac2name.get(device.upper(), None) def _update_info(self): - """Ensure the informations from the router are up to date. + """Ensure the information from the router are up to date. Returns true if scanning successful. """ diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 06e6f0b989a..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'), @@ -100,6 +102,7 @@ def async_setup(hass, config): # We do not know how to handle this service. if not comp_plat: + logger.info("Unknown service discovered: %s %s", service, info) return discovery_hash = json.dumps([service, info], sort_keys=True) 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/downloader.py b/homeassistant/components/downloader.py index 2e26b306673..0450ba175ee 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -122,7 +122,7 @@ def setup(hass, config): _LOGGER.info("Downloading of %s done", url) except requests.exceptions.ConnectionError: - _LOGGER.exception("ConnectionError occured for %s", url) + _LOGGER.exception("ConnectionError occurred for %s", url) # Remove file if we started downloading but failed if final_path and os.path.isfile(final_path): diff --git a/homeassistant/components/duckdns.py b/homeassistant/components/duckdns.py new file mode 100644 index 00000000000..0045b9421a2 --- /dev/null +++ b/homeassistant/components/duckdns.py @@ -0,0 +1,102 @@ +"""Integrate with DuckDNS.""" +import asyncio +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN +from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +DOMAIN = 'duckdns' +UPDATE_URL = 'https://www.duckdns.org/update' +INTERVAL = timedelta(minutes=5) +_LOGGER = logging.getLogger(__name__) +SERVICE_SET_TXT = 'set_txt' +ATTR_TXT = 'txt' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_TXT_SCHEMA = vol.Schema({ + vol.Required(ATTR_TXT): vol.Any(None, cv.string) +}) + + +@bind_hass +@asyncio.coroutine +def async_set_txt(hass, txt): + """Set the txt record. Pass in None to remove it.""" + yield from hass.services.async_call(DOMAIN, SERVICE_SET_TXT, { + ATTR_TXT: txt + }, blocking=True) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the DuckDNS component.""" + domain = config[DOMAIN][CONF_DOMAIN] + token = config[DOMAIN][CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) + + result = yield from _update_duckdns(session, domain, token) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the DuckDNS entry.""" + yield from _update_duckdns(session, domain, token) + + @asyncio.coroutine + def update_domain_service(call): + """Update the DuckDNS entry.""" + yield from _update_duckdns(session, domain, token, + txt=call.data[ATTR_TXT]) + + async_track_time_interval(hass, update_domain_interval, INTERVAL) + hass.services.async_register( + DOMAIN, SERVICE_SET_TXT, update_domain_service, + schema=SERVICE_TXT_SCHEMA) + + return result + + +_SENTINEL = object() + + +@asyncio.coroutine +def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False): + """Update DuckDNS.""" + params = { + 'domains': domain, + 'token': token, + } + + if txt is not _SENTINEL: + if txt is None: + # Pass in empty txt value to indicate it's clearing txt record + params['txt'] = '' + clear = True + else: + params['txt'] = txt + + if clear: + params['clear'] = 'true' + + resp = yield from session.get(UPDATE_URL, params=params) + body = yield from resp.text() + + if body != 'OK': + _LOGGER.warning('Updating DuckDNS domain %s failed', domain) + return False + + return True diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 9e74299e6bc..c4b0f2e9546 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -15,7 +15,7 @@ from homeassistant.helpers import discovery from homeassistant.const import CONF_API_KEY from homeassistant.util import Throttle -REQUIREMENTS = ['python-ecobee-api==0.0.8'] +REQUIREMENTS = ['python-ecobee-api==0.0.9'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) 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..a83f5337cae 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ) from homeassistant.components.http import REQUIREMENTS # NOQA from homeassistant.components.http import HomeAssistantWSGI +from homeassistant.helpers.deprecation import get_deprecated import homeassistant.helpers.config_validation as cv from .hue_api import ( HueUsernameView, HueAllLightsStateView, HueOneLightStateView, @@ -66,6 +67,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) ATTR_EMULATED_HUE = 'emulated_hue' +ATTR_EMULATED_HUE_HIDDEN = 'emulated_hue_hidden' def setup(hass, yaml_config): @@ -129,7 +131,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) @@ -148,7 +150,7 @@ class Config(object): self.listen_port) if self.type == TYPE_GOOGLE and self.listen_port != 80: - _LOGGER.warning("When targetting Google Home, listening port has " + _LOGGER.warning("When targeting Google Home, listening port has " "to be port 80") # Get whether or not UPNP binds to multicast address (239.255.255.250) @@ -223,7 +225,15 @@ class Config(object): domain = entity.domain.lower() explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None) - + explicit_hidden = entity.attributes.get(ATTR_EMULATED_HUE_HIDDEN, None) + if explicit_expose is True or explicit_hidden is False: + expose = True + elif explicit_expose is False or explicit_hidden is True: + expose = False + else: + expose = None + get_deprecated(entity.attributes, ATTR_EMULATED_HUE_HIDDEN, + ATTR_EMULATED_HUE, None) domain_exposed_by_default = \ self.expose_by_default and domain in self.exposed_domains @@ -231,9 +241,9 @@ class Config(object): # the configuration doesn't explicitly exclude it from being # exposed, or if the entity is explicitly exposed is_default_exposed = \ - domain_exposed_by_default and explicit_expose is not False + domain_exposed_by_default and expose is not False - return is_default_exposed or explicit_expose + return is_default_exposed or expose def _load_numbers_json(self): """Set up helper method to load numbers json.""" diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index f8d41424064..548b6f3d771 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -1,4 +1,4 @@ -"""Provides a UPNP discovery method that mimicks Hue hubs.""" +"""Provides a UPNP discovery method that mimics Hue hubs.""" import threading import socket import logging @@ -123,20 +123,20 @@ USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1 if ssdp_socket in read: data, addr = ssdp_socket.recvfrom(1024) else: - # most likely the timeout, so check for interupt + # most likely the timeout, so check for interrupt continue except socket.error as ex: if self._interrupted: clean_socket_close(ssdp_socket) return - _LOGGER.error("UPNP Responder socket exception occured: %s", + _LOGGER.error("UPNP Responder socket exception occurred: %s", ex.__str__) # without the following continue, a second exception occurs # 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/insteon_local.py b/homeassistant/components/fan/insteon_local.py index 5bdfec08427..e12e3476c3a 100644 --- a/homeassistant/components/fan/insteon_local.py +++ b/homeassistant/components/fan/insteon_local.py @@ -137,7 +137,7 @@ class InsteonLocalFanDevice(FanEntity): @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self.node.deviceName @property 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/__init__.py b/homeassistant/components/frontend/__init__.py index 29f6ef577e5..112c93403b0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -28,6 +28,7 @@ URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html' STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static/') ATTR_THEMES = 'themes' +ATTR_EXTRA_HTML_URL = 'extra_html_url' DEFAULT_THEME_COLOR = '#03A9F4' MANIFEST_JSON = { 'background_color': '#FFFFFF', @@ -50,6 +51,7 @@ for size in (192, 384, 512, 1024): }) DATA_PANELS = 'frontend_panels' +DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' DATA_INDEX_VIEW = 'frontend_index_view' DATA_THEMES = 'frontend_themes' DATA_DEFAULT_THEME = 'frontend_default_theme' @@ -66,6 +68,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(ATTR_THEMES): vol.Schema({ cv.string: {cv.string: cv.string} }), + vol.Optional(ATTR_EXTRA_HTML_URL): + vol.All(cv.ensure_list, [cv.string]), }), }, extra=vol.ALLOW_EXTRA) @@ -105,14 +109,13 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, component_name: name of the web component path: path to the HTML of the web component + (required unless url is provided) md5: the md5 hash of the web component (for versioning, optional) sidebar_title: title to show in the sidebar (optional) sidebar_icon: icon to show next to title in sidebar (optional) url_path: name to use in the url (defaults to component_name) - url: for the web component (for dev environment, optional) + url: for the web component (optional) config: config to be passed into the web component - - Warning: this API will probably change. Use at own risk. """ panels = hass.data.get(DATA_PANELS) if panels is None: @@ -123,14 +126,16 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, if url_path in panels: _LOGGER.warning("Overwriting component %s", url_path) - if not os.path.isfile(path): - _LOGGER.error( - "Panel %s component does not exist: %s", component_name, path) - return - if md5 is None: - with open(path) as fil: - md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() + if url is None: + if not os.path.isfile(path): + _LOGGER.error( + "Panel %s component does not exist: %s", component_name, path) + return + + if md5 is None: + with open(path) as fil: + md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() data = { 'url_path': url_path, @@ -169,6 +174,15 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, 'get', '/{}/{{extra:.+}}'.format(url_path), index_view.get) +@bind_hass +def add_extra_html_url(hass, url): + """Register extra html url to load.""" + url_set = hass.data.get(DATA_EXTRA_HTML_URL) + if url_set is None: + url_set = hass.data[DATA_EXTRA_HTML_URL] = set() + url_set.add(url) + + def add_manifest_json_key(key, val): """Add a keyval to the manifest.json.""" MANIFEST_JSON[key] = val @@ -208,6 +222,9 @@ def setup(hass, config): else: hass.data[DATA_PANELS] = {} + if DATA_EXTRA_HTML_URL not in hass.data: + hass.data[DATA_EXTRA_HTML_URL] = set() + register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location') for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', @@ -217,6 +234,9 @@ def setup(hass, config): themes = config.get(DOMAIN, {}).get(ATTR_THEMES) setup_themes(hass, themes) + for url in config.get(DOMAIN, {}).get(ATTR_EXTRA_HTML_URL, []): + add_extra_html_url(hass, url) + return True @@ -362,7 +382,9 @@ class IndexView(HomeAssistantView): compatibility_url=compatibility_url, no_auth=no_auth, icons_url=icons_url, icons=FINGERPRINTS['mdi.html'], panel_url=panel_url, panels=hass.data[DATA_PANELS], - dev_mode=request.app[KEY_DEVELOPMENT]) + dev_mode=request.app[KEY_DEVELOPMENT], + theme_color=MANIFEST_JSON['theme_color'], + extra_urls=hass.data[DATA_EXTRA_HTML_URL]) return web.Response(text=resp, content_type='text/html') diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 6420bb79739..70e7e777510 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -21,7 +21,7 @@ - + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 9c8b72d6bcc..439e965e0c9 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html index d55e008d907..53638dd582b 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html @@ -1,2 +1,2 @@ \ No newline at end of file + clear: both;white-space:pre-wrap;}.rendered.error{color:red;}
Templates

Templates are rendered using the Jinja2 template engine with some Home Assistant specific extensions.

[[processed]]
\ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz index 9d8f4d9f5eb..24fd95f17a7 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html index e2a93ae1cea..5f34f7bc28a 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz index 801450f1bd8..d9dd4c687fb 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz differ diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index a5544a8b165..ce24b20ac92 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -37,7 +37,7 @@ /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 'use strict'; -var precacheConfig = [["/","535d629ec4d3936dba0ca4ca84dabeb2"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-f47b6910d8e4880e22cc508ca452f9b6.html","9aa0675e01373c6bc2737438bb84a9ec"],["/frontend/panels/map-c2544fff3eedb487d44105cf94b335ec.html","113c5bf9a68a74c62e50cd354034e78b"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-6c8192a4393c9e83516dc8177b75c23d.html","56d5bfe9e11a8b81a686f20aeae3c359"],["/static/mdi-e91f61a039ed0a9936e7ee5360da3870.html","5e587bc82719b740a4f0798722a83aee"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; +var precacheConfig = [["/","da6c72cc82251a0456b2e678ebb6795c"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-7e13ce36d3141182a62a5b061e87e77a.html","73f53a9b597e1e69e0b3e56f4fc8f020"],["/static/mdi-89074face5529f5fe6fbae49ecb3e88b.html","97754e463f9e56a95c813d4d8e792347"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; var cacheName = 'sw-precache-v3--' + (self.registration ? self.registration.scope : ''); diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz index 97665c7f31e..2f4bd27e551 100644 Binary files a/homeassistant/components/frontend/www_static/service_worker.js.gz and b/homeassistant/components/frontend/www_static/service_worker.js.gz differ diff --git a/homeassistant/components/frontend/www_static/webcomponents-lite.js b/homeassistant/components/frontend/www_static/webcomponents-lite.js index 7f2e16c7c29..8c1d0f7d3da 100644 --- a/homeassistant/components/frontend/www_static/webcomponents-lite.js +++ b/homeassistant/components/frontend/www_static/webcomponents-lite.js @@ -28,164 +28,166 @@ The complete set of contributors may be found at http://polymer.github.io/CONTRI Code distributed by Google as part of the polymer project is also subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt */ -'use strict';var nb="undefined"!=typeof window&&window===this?this:"undefined"!=typeof global&&null!=global?global:this; -(function(){function k(){var a=this;this.s={};this.g=document.documentElement;var b=new za;b.rules=[];this.h=t.set(this.g,new t(b));this.i=!1;this.b=this.a=null;ob(function(){a.c()})}function H(){this.customStyles=[];this.enqueued=!1}function pb(){}function ha(a){this.cache={};this.c=void 0===a?100:a}function p(){}function t(a,b,c,d,e){this.G=a||null;this.b=b||null;this.sa=c||[];this.P=null;this.Y=e||"";this.a=this.B=this.K=null}function r(){}function za(){this.end=this.start=0;this.rules=this.parent= -this.previous=null;this.cssText=this.parsedCssText="";this.atRule=!1;this.type=0;this.parsedSelector=this.selector=this.keyframesName=""}function $c(a){function b(b,c){Object.defineProperty(b,"innerHTML",{enumerable:c.enumerable,configurable:!0,get:c.get,set:function(b){var d=this,e=void 0;m(this)&&(e=[],J(this,function(a){a!==d&&e.push(a)}));c.set.call(this,b);if(e)for(var f=0;f":return">";case '"':return""";case "\u00a0":return" "}}function Kb(a){for(var b={},c=0;c";break a;case Node.TEXT_NODE:h=h.data;h=n&&vd[n.localName]?h:h.replace(wd,Jb);break a;case Node.COMMENT_NODE:h="\x3c!--"+h.data+"--\x3e";break a;default:throw window.console.error(h),Error("not implemented");}}c+=h}return c}function U(a){F.currentNode=a;return F.parentNode()}function Ha(a){F.currentNode= -a;return F.firstChild()}function Ia(a){F.currentNode=a;return F.lastChild()}function Lb(a){F.currentNode=a;return F.previousSibling()}function Mb(a){F.currentNode=a;return F.nextSibling()}function S(a){var b=[];F.currentNode=a;for(a=F.firstChild();a;)b.push(a),a=F.nextSibling();return b}function Nb(a){x.currentNode=a;return x.parentNode()}function Ob(a){x.currentNode=a;return x.firstChild()}function Pb(a){x.currentNode=a;return x.lastChild()}function Qb(a){x.currentNode=a;return x.previousSibling()} -function Rb(a){x.currentNode=a;return x.nextSibling()}function Sb(a){var b=[];x.currentNode=a;for(a=x.firstChild();a;)b.push(a),a=x.nextSibling();return b}function Tb(a){return Oa(a,function(a){return S(a)})}function Ub(a){switch(a.nodeType){case Node.ELEMENT_NODE:case Node.DOCUMENT_FRAGMENT_NODE:a=document.createTreeWalker(a,NodeFilter.SHOW_TEXT,null,!1);for(var b="",c;c=a.nextNode();)b+=c.nodeValue;return b;default:return a.nodeValue}}function M(a,b,c){for(var d in b){var e=Object.getOwnPropertyDescriptor(a, -d);e&&e.configurable||!e&&c?Object.defineProperty(a,d,b[d]):c&&console.warn("Could not define",d,"on",a)}}function N(a){M(a,Vb);M(a,Pa);M(a,Qa)}function Wb(a,b,c){Fb(a);c=c||null;a.__shady=a.__shady||{};b.__shady=b.__shady||{};c&&(c.__shady=c.__shady||{});a.__shady.previousSibling=c?c.__shady.previousSibling:b.lastChild;var d=a.__shady.previousSibling;d&&d.__shady&&(d.__shady.nextSibling=a);(d=a.__shady.nextSibling=c)&&d.__shady&&(d.__shady.previousSibling=a);a.__shady.parentNode=b;c?c===b.__shady.firstChild&& -(b.__shady.firstChild=a):(b.__shady.lastChild=a,b.__shady.firstChild||(b.__shady.firstChild=a));b.__shady.childNodes=null}function Ra(a,b,c){if(b===a)throw Error("Failed to execute 'appendChild' on 'Node': The new child element contains the parent.");if(c){var d=c.__shady&&c.__shady.parentNode;if(void 0!==d&&d!==a||void 0===d&&U(c)!==a)throw Error("Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.");}if(c===b)return b;b.parentNode&& -Sa(b.parentNode,b);d=Z(a);var e;if(e=d)a:{if(!b.__noInsertionPoint){var f;"slot"===b.localName?f=[b]:b.querySelectorAll&&(f=b.querySelectorAll("slot"));if(f&&f.length){e=f;break a}}e=void 0}f=e;d&&("slot"===a.localName||f)&&d.M();if(T(a)){e=c;Eb(a);a.__shady=a.__shady||{};void 0!==a.__shady.firstChild&&(a.__shady.childNodes=null);if(b.nodeType===Node.DOCUMENT_FRAGMENT_NODE){for(var g=b.childNodes,h=0;h":return">";case "\u00a0":return" "}},h=function(b){Object.defineProperty(b,"innerHTML",{get:function(){for(var a="",b=this.content.firstChild;b;b=b.nextSibling)a+=b.outerHTML||b.data.replace(t,g);return a},set:function(b){k.body.innerHTML=b;for(a.b(k);this.content.firstChild;)this.content.removeChild(this.content.firstChild);for(;k.body.firstChild;)this.content.appendChild(k.body.firstChild)},configurable:!0})},k=document.implementation.createHTMLDocument("template"), -l=!0,m=document.createElement("style");m.textContent="template{display:none;}";var p=document.head;p.insertBefore(m,p.firstElementChild);a.prototype=Object.create(HTMLElement.prototype);var r=!document.createElement("div").hasOwnProperty("innerHTML");a.O=function(b){if(!b.content){b.content=k.createDocumentFragment();for(var c;c=b.firstChild;)b.content.appendChild(c);if(r)b.__proto__=a.prototype;else if(b.cloneNode=function(b){return a.a(this,b)},l)try{h(b)}catch(y){l=!1}a.b(b.content)}};h(a.prototype); -a.b=function(b){b=b.querySelectorAll("template");for(var c=0,d=b.length,e;c]/g}if(b||f)a.a=function(a,b){var d=c.call(a,!1);this.O&&this.O(d);b&&(d.content.appendChild(c.call(a.content,!0)),this.ra(d.content,a.content));return d},a.prototype.cloneNode=function(b){return a.a(this, -b)},a.ra=function(a,b){if(b.querySelectorAll){b=b.querySelectorAll("template");a=a.querySelectorAll("template");for(var c=0,d=a.length,e,f;c]*)(rel=['|"]?stylesheet['|"]?[^>]*>)/g,y={nb:function(a,b){a.href&&a.setAttribute("href",y.ua(a.getAttribute("href"),b));a.src&&a.setAttribute("src",y.ua(a.getAttribute("src"),b)); -if("style"===a.localName){var c=y.Ma(a.textContent,b,r);a.textContent=y.Ma(c,b,t)}},Ma:function(a,b,c){return a.replace(c,function(a,c,d,e){a=d.replace(/["']/g,"");b&&(a=y.Na(a,b));return c+"'"+a+"'"+e})},ua:function(a,b){return a&&q.test(a)?a:y.Na(a,b)},Na:function(a,b){if(void 0===y.ma){y.ma=!1;try{var c=new URL("b","http://a");c.pathname="c%20d";y.ma="http://a/c%20d"===c.href}catch(Lc){}}if(y.ma)return(new URL(a,b)).href;c=y.Za;c||(c=document.implementation.createHTMLDocument("temp"),y.Za=c,c.xa= -c.createElement("base"),c.head.appendChild(c.xa),c.wa=c.createElement("a"));c.xa.href=b;c.wa.href=a;return c.wa.href||a}},w={async:!0,load:function(a,b,c){if(a)if(a.match(/^data:/)){a=a.split(",");var d=a[1];d=-1e.status?b(d,a):c(d)};e.send()}else c("error: href must be specified")}},v=/Trident/.test(navigator.userAgent)||/Edge\/\d./i.test(navigator.userAgent);k.prototype.c=function(a){var b=this;a=a.querySelectorAll("link[rel=import]");l(a,function(a){return b.h(a)})};k.prototype.h=function(a){var b=this,c=a.href;if(void 0!==this.a[c]){var d=this.a[c];d&&d.__loaded&&(a.import=d,this.g(a))}else this.b++,this.a[c]="pending",w.load(c,function(a,d){a=b.s(a,d||c); -b.a[c]=a;b.b--;b.c(a);b.i()},function(){b.a[c]=null;b.b--;b.i()})};k.prototype.s=function(a,b){if(!a)return document.createDocumentFragment();v&&(a=a.replace(x,function(a,b,c){return-1===a.indexOf("type=")?b+" type=import-disable "+c:a}));var c=document.createElement("template");c.innerHTML=a;if(c.content)a=c.content;else for(a=document.createDocumentFragment();c.firstChild;)a.appendChild(c.firstChild);if(c=a.querySelector("base"))b=y.ua(c.getAttribute("href"),b),c.removeAttribute("href");c=a.querySelectorAll('link[rel=import], link[rel=stylesheet][href][type=import-disable],\n style:not([type]), link[rel=stylesheet][href]:not([type]),\n script:not([type]), script[type="application/javascript"],\n script[type="text/javascript"]'); -var d=0;l(c,function(a){g(a);y.nb(a,b);a.setAttribute("import-dependency","");"script"===a.localName&&!a.src&&a.textContent&&(a.setAttribute("src","data:text/javascript;charset=utf-8,"+encodeURIComponent(a.textContent+("\n//# sourceURL="+b+(d?"-"+d:"")+".js\n"))),a.textContent="",d++)});return a};k.prototype.i=function(){var a=this;if(!this.b){this.f.disconnect();this.flatten(document);var b=!1,c=!1,d=function(){c&&b&&(a.c(document),a.b||(a.f.observe(document.head,{childList:!0,subtree:!0}),a.j()))}; -this.v(function(){c=!0;d()});this.u(function(){b=!0;d()})}};k.prototype.flatten=function(a){var b=this;a=a.querySelectorAll("link[rel=import]");l(a,function(a){var c=b.a[a.href];(a.import=c)&&c.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&(b.a[a.href]=a,a.readyState="loading",a.import=a,b.flatten(c),a.appendChild(c))})};k.prototype.u=function(a){function b(e){if(e]/g,ud=Kb("area base br col command embed hr img input keygen link meta param source track wbr".split(" ")),vd=Kb("style script xmp iframe noembed noframes plaintext noscript".split(" ")),F=document.createTreeWalker(document, -NodeFilter.SHOW_ALL,null,!1),x=document.createTreeWalker(document,NodeFilter.SHOW_ELEMENT,null,!1),Rd=Object.freeze({parentNode:U,firstChild:Ha,lastChild:Ia,previousSibling:Lb,nextSibling:Mb,childNodes:S,parentElement:Nb,firstElementChild:Ob,lastElementChild:Pb,previousElementSibling:Qb,nextElementSibling:Rb,children:Sb,innerHTML:Tb,textContent:Ub}),hb=Object.getOwnPropertyDescriptor(Element.prototype,"innerHTML")||Object.getOwnPropertyDescriptor(HTMLElement.prototype,"innerHTML"),ta=document.implementation.createHTMLDocument("inert").createElement("div"), -ib=Object.getOwnPropertyDescriptor(Document.prototype,"activeElement"),Vb={parentElement:{get:function(){var a=this.__shady&&this.__shady.parentNode;a&&a.nodeType!==Node.ELEMENT_NODE&&(a=null);return void 0!==a?a:Nb(this)},configurable:!0},parentNode:{get:function(){var a=this.__shady&&this.__shady.parentNode;return void 0!==a?a:U(this)},configurable:!0},nextSibling:{get:function(){var a=this.__shady&&this.__shady.nextSibling;return void 0!==a?a:Mb(this)},configurable:!0},previousSibling:{get:function(){var a= -this.__shady&&this.__shady.previousSibling;return void 0!==a?a:Lb(this)},configurable:!0},className:{get:function(){return this.getAttribute("class")||""},set:function(a){this.setAttribute("class",a)},configurable:!0},nextElementSibling:{get:function(){if(this.__shady&&void 0!==this.__shady.nextSibling){for(var a=this.nextSibling;a&&a.nodeType!==Node.ELEMENT_NODE;)a=a.nextSibling;return a}return Rb(this)},configurable:!0},previousElementSibling:{get:function(){if(this.__shady&&void 0!==this.__shady.previousSibling){for(var a= -this.previousSibling;a&&a.nodeType!==Node.ELEMENT_NODE;)a=a.previousSibling;return a}return Qb(this)},configurable:!0}},Pa={childNodes:{get:function(){if(T(this)){if(!this.__shady.childNodes){this.__shady.childNodes=[];for(var a=this.firstChild;a;a=a.nextSibling)this.__shady.childNodes.push(a)}var b=this.__shady.childNodes}else b=S(this);b.item=function(a){return b[a]};return b},configurable:!0},childElementCount:{get:function(){return this.children.length},configurable:!0},firstChild:{get:function(){var a= -this.__shady&&this.__shady.firstChild;return void 0!==a?a:Ha(this)},configurable:!0},lastChild:{get:function(){var a=this.__shady&&this.__shady.lastChild;return void 0!==a?a:Ia(this)},configurable:!0},textContent:{get:function(){if(T(this)){for(var a=[],b=0,c=this.childNodes,d;d=c[b];b++)d.nodeType!==Node.COMMENT_NODE&&a.push(d.textContent);return a.join("")}return Ub(this)},set:function(a){switch(this.nodeType){case Node.ELEMENT_NODE:case Node.DOCUMENT_FRAGMENT_NODE:for(;this.firstChild;)this.removeChild(this.firstChild); -this.appendChild(document.createTextNode(a));break;default:this.nodeValue=a}},configurable:!0},firstElementChild:{get:function(){if(this.__shady&&void 0!==this.__shady.firstChild){for(var a=this.firstChild;a&&a.nodeType!==Node.ELEMENT_NODE;)a=a.nextSibling;return a}return Ob(this)},configurable:!0},lastElementChild:{get:function(){if(this.__shady&&void 0!==this.__shady.lastChild){for(var a=this.lastChild;a&&a.nodeType!==Node.ELEMENT_NODE;)a=a.previousSibling;return a}return Pb(this)},configurable:!0}, -children:{get:function(){var a;T(this)?a=Array.prototype.filter.call(this.childNodes,function(a){return a.nodeType===Node.ELEMENT_NODE}):a=Sb(this);a.item=function(b){return a[b]};return a},configurable:!0},innerHTML:{get:function(){var a="template"===this.localName?this.content:this;return T(this)?Oa(a):Tb(a)},set:function(a){for(var b="template"===this.localName?this.content:this;b.firstChild;)b.removeChild(b.firstChild);for(hb&&hb.set?hb.set.call(ta,a):ta.innerHTML=a;ta.firstChild;)b.appendChild(ta.firstChild)}, -configurable:!0}},Oc={shadowRoot:{get:function(){return this.__shady&&this.__shady.tb||null},configurable:!0}},Qa={activeElement:{get:function(){var a=ib&&ib.get?ib.get.call(document):C.V?void 0:document.activeElement;if(a&&a.nodeType){var b=!!L(this);if(this===document||b&&this.host!==a&&this.host.contains(a)){for(b=Z(a);b&&b!==this;)a=b.host,b=Z(a);a=this===document?b?null:a:b===this?a:null}else a=null}else a=null;return a},set:function(){},configurable:!0}},Fb=C.V?function(){}:function(a){a.__shady&& -a.__shady.Xa||(a.__shady=a.__shady||{},a.__shady.Xa=!0,M(a,Vb,!0))},Eb=C.V?function(){}:function(a){a.__shady&&a.__shady.Va||(a.__shady=a.__shady||{},a.__shady.Va=!0,M(a,Pa,!0),M(a,Oc,!0))},pa=null,Sd={blur:!0,focus:!0,focusin:!0,focusout:!0,click:!0,dblclick:!0,mousedown:!0,mouseenter:!0,mouseleave:!0,mousemove:!0,mouseout:!0,mouseover:!0,mouseup:!0,wheel:!0,beforeinput:!0,input:!0,keydown:!0,keyup:!0,compositionstart:!0,compositionupdate:!0,compositionend:!0,touchstart:!0,touchend:!0,touchmove:!0, -touchcancel:!0,pointerover:!0,pointerenter:!0,pointerdown:!0,pointermove:!0,pointerup:!0,pointercancel:!0,pointerout:!0,pointerleave:!0,gotpointercapture:!0,lostpointercapture:!0,dragstart:!0,drag:!0,dragenter:!0,dragleave:!0,dragover:!0,drop:!0,dragend:!0,DOMActivate:!0,DOMFocusIn:!0,DOMFocusOut:!0,keypress:!0},rc={get composed(){!1!==this.isTrusted&&void 0===this.ja&&(this.ja=Sd[this.type]);return this.ja||!1},composedPath:function(){this.ya||(this.ya=Wa(this.__target,this.composed));return this.ya}, -get target(){return hc(this.currentTarget,this.composedPath())},get relatedTarget(){if(!this.za)return null;this.Aa||(this.Aa=Wa(this.za,!0));return hc(this.currentTarget,this.Aa)},stopPropagation:function(){Event.prototype.stopPropagation.call(this);this.ka=!0},stopImmediatePropagation:function(){Event.prototype.stopImmediatePropagation.call(this);this.ka=this.Ua=!0}},Ya={focus:!0,blur:!0},Td=Xa(window.Event),Ud=Xa(window.CustomEvent),Vd=Xa(window.MouseEvent),Db={};l.prototype=Object.create(DocumentFragment.prototype); -l.prototype.D=function(a,b){this.Wa="ShadyRoot";la(a);la(this);this.host=a;this.L=b&&b.mode;a.__shady=a.__shady||{};a.__shady.root=this;a.__shady.tb="closed"!==this.L?this:null;this.S=!1;this.b=[];this.a=null;b=S(a);for(var c=0,d=b.length;cb.__shady.assignedNodes.length&&(b.__shady.qa=!0)}b.__shady.qa&&(b.__shady.qa=!1,this.g(b))}};l.prototype.f=function(a,b){a.__shady=a.__shady||{};var c=a.__shady.na;a.__shady.na=null;b||(b=(b=this.a[a.slot||"__catchall"])&&b[0]);b?(b.__shady.assignedNodes.push(a),a.__shady.assignedSlot=b):a.__shady.assignedSlot=void 0;c!==a.__shady.assignedSlot&& -a.__shady.assignedSlot&&(a.__shady.assignedSlot.__shady.qa=!0)};l.prototype.l=function(a){var b=a.__shady.assignedNodes;a.__shady.assignedNodes=[];a.__shady.U=[];if(a.__shady.Da=b)for(var c=0;cb.indexOf(d))||b.push(d)}for(a=0;a "+b}))}a=a.replace(ge,function(a,b,c){return'[dir="'+c+'"] '+b+", "+b+'[dir="'+c+'"]'});return{value:a,lb:b,stop:f}};r.prototype.s=function(a,b){a=a.split(Qc);a[0]+=b;return a.join(Qc)};r.prototype.L=function(a,b){var c=a.match(Rc);return(c=c&&c[2].trim()||"")?c[0].match(Sc)?a.replace(Rc,function(a,c,f){return b+ -f}):c.split(Sc)[0]===b?c:he:a.replace(jb,b)};r.prototype.I=function(a){a.selector=a.parsedSelector;this.u(a);this.j(a,this.D)};r.prototype.u=function(a){a.selector===ie&&(a.selector="html")};r.prototype.D=function(a){return a.match(kb)?this.g(a,Tc):this.s(a.trim(),Tc)};nb.Object.defineProperties(r.prototype,{c:{configurable:!0,enumerable:!0,get:function(){return"style-scope"}}});var ce=/:(nth[-\w]+)\(([^)]+)\)/,Tc=":not(.style-scope)",Pc=",",ee=/(^|[\s>+~]+)((?:\[.+?\]|[^\s>+~=[])+)/g,Sc=/[[.:#*]/, -jb=":host",ie=":root",kb="::slotted",de=new RegExp("^("+kb+")"),Rc=/(:host)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/,fe=/(?:::slotted)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/,ge=/(.*):dir\((?:(ltr|rtl))\)/,be=".",Qc=":",ae="class",he="should_not_match",v=new r;t.get=function(a){return a?a.__styleInfo:null};t.set=function(a,b){return a.__styleInfo=b};t.prototype.c=function(){return this.G};t.prototype._getStyleRules=t.prototype.c;var Uc=function(a){return a.matches||a.matchesSelector||a.mozMatchesSelector||a.msMatchesSelector|| -a.oMatchesSelector||a.webkitMatchesSelector}(window.Element.prototype),je=navigator.userAgent.match("Trident");p.prototype.R=function(a){var b=this,c={},d=[],e=0;W(a,function(a){b.c(a);a.index=e++;b.I(a.w.cssText,c)},function(a){d.push(a)});a.b=d;a=[];for(var f in c)a.push(f);return a};p.prototype.c=function(a){if(!a.w){var b={},c={};this.b(a,c)&&(b.F=c,a.rules=null);b.cssText=this.H(a);a.w=b}};p.prototype.b=function(a,b){var c=a.w;if(c){if(c.F)return Object.assign(b,c.F),!0}else{c=a.parsedCssText; -for(var d;a=va.exec(c);){d=(a[2]||a[3]).trim();if("inherit"!==d||"unset"!==d)b[a[1].trim()]=d;d=!0}return d}};p.prototype.H=function(a){return this.L(a.parsedCssText)};p.prototype.L=function(a){return a.replace($d,"").replace(va,"")};p.prototype.I=function(a,b){for(var c;c=Yd.exec(a);){var d=c[1];":"!==c[2]&&(b[d]=!0)}};p.prototype.fa=function(a){for(var b=Object.getOwnPropertyNames(a),c=0,d;c *"===f||"html"===f,h=0===f.indexOf(":host")&&!g;"shady"===c&&(g=f===e+" > *."+e||-1!==f.indexOf("html"),h=!g&&0===f.indexOf(e));"shadow"===c&&(g=":host > *"===f||"html"===f,h=h&&!g);if(g||h)c=e,h&&(w&&!b.A&&(b.A=v.l(b,v.g,v.h(a),e)),c=b.A||e),d({xb:c,qb:h,Gb:g})}};p.prototype.da=function(a,b){var c={},d= -{},e=this,f=b&&b.__cssBuild;W(b,function(b){e.ha(a,b,f,function(f){Uc.call(a.Db||a,f.xb)&&(f.qb?e.b(b,c):e.b(b,d))})},null,!0);return{vb:d,pb:c}};p.prototype.ga=function(a,b,c){var d=this,e=Q(a),f=v.f(e.is,e.Y),g=new RegExp("(?:^|[^.#[:])"+(a.extends?"\\"+f.slice(0,-1)+"\\]":f)+"($|[.:[\\s>+~])");e=t.get(a).G;var h=this.h(e,c);return v.b(a,e,function(a){d.D(a,b);w||Bc(a)||!a.cssText||(d.C(a,h),d.l(a,g,f,c))})};p.prototype.h=function(a,b){a=a.b;var c={};if(!w&&a)for(var d=0,e=a[d];d=f._useCount&&f.parentNode&&f.parentNode.removeChild(f));w?e.a?(e.a.textContent=b,d=e.a):b&&(d=bb(b,c,a.shadowRoot,e.b)):d?d.parentNode||(je&&-1this.c&&e.shift();this.cache[a]=e};ha.prototype.fetch=function(a,b,c){if(a=this.cache[a])for(var d=a.length-1;0<=d;d--){var e=a[d];if(this.a(e,b,c))return e}};if(!w){var Vc=new MutationObserver(Ec),Wc=function(a){Vc.observe(a, -{childList:!0,subtree:!0})};if(window.customElements&&!window.customElements.polyfillWrapFlushCallback)Wc(document);else{var mb=function(){Wc(document.body)};window.HTMLImports?window.HTMLImports.whenReady(mb):requestAnimationFrame(function(){if("loading"===document.readyState){var a=function(){mb();document.removeEventListener("readystatechange",a)};document.addEventListener("readystatechange",a)}else mb()})}pb=function(){Ec(Vc.takeRecords())}}var sa={},Ld=Promise.resolve(),cb=null,Gc=window.HTMLImports&& -window.HTMLImports.whenReady||null,db,ya=null,fa=null;H.prototype.Ga=function(){!this.enqueued&&fa&&(this.enqueued=!0,ob(fa))};H.prototype.b=function(a){a.__seenByShadyCSS||(a.__seenByShadyCSS=!0,this.customStyles.push(a),this.Ga())};H.prototype.a=function(a){return a.__shadyCSSCachedStyle?a.__shadyCSSCachedStyle:a.getStyle?a.getStyle():a};H.prototype.c=function(){for(var a=this.customStyles,b=0;b":return">";case '"':return""";case "\u00a0":return" "}} +function nc(a){for(var b={},c=0;c";break a;case Node.TEXT_NODE:h=h.data;h=m&&oe[m.localName]?h:h.replace(pe,mc);break a;case Node.COMMENT_NODE:h= +"\x3c!--"+h.data+"--\x3e";break a;default:throw window.console.error(h),Error("not implemented");}}c+=h}return c}function aa(a){B.currentNode=a;return B.parentNode()}function Sa(a){B.currentNode=a;return B.firstChild()}function Ta(a){B.currentNode=a;return B.lastChild()}function oc(a){B.currentNode=a;return B.previousSibling()}function pc(a){B.currentNode=a;return B.nextSibling()}function U(a){var b=[];B.currentNode=a;for(a=B.firstChild();a;)b.push(a),a=B.nextSibling();return b}function qc(a){C.currentNode= +a;return C.parentNode()}function rc(a){C.currentNode=a;return C.firstChild()}function sc(a){C.currentNode=a;return C.lastChild()}function tc(a){C.currentNode=a;return C.previousSibling()}function uc(a){C.currentNode=a;return C.nextSibling()}function vc(a){var b=[];C.currentNode=a;for(a=C.firstChild();a;)b.push(a),a=C.nextSibling();return b}function wc(a){return gb(a,function(a){return U(a)})}function xc(a){switch(a.nodeType){case Node.ELEMENT_NODE:case Node.DOCUMENT_FRAGMENT_NODE:a=document.createTreeWalker(a, +NodeFilter.SHOW_TEXT,null,!1);for(var b="",c;c=a.nextNode();)b+=c.nodeValue;return b;default:return a.nodeValue}}function M(a,b,c){for(var d in b){var e=Object.getOwnPropertyDescriptor(a,d);e&&e.configurable||!e&&c?Object.defineProperty(a,d,b[d]):c&&console.warn("Could not define",d,"on",a)}}function R(a){M(a,yc);M(a,hb);M(a,ib)}function zc(a,b,c){ac(a);c=c||null;a.__shady=a.__shady||{};b.__shady=b.__shady||{};c&&(c.__shady=c.__shady||{});a.__shady.previousSibling=c?c.__shady.previousSibling:b.lastChild; +var d=a.__shady.previousSibling;d&&d.__shady&&(d.__shady.nextSibling=a);(d=a.__shady.nextSibling=c)&&d.__shady&&(d.__shady.previousSibling=a);a.__shady.parentNode=b;c?c===b.__shady.firstChild&&(b.__shady.firstChild=a):(b.__shady.lastChild=a,b.__shady.firstChild||(b.__shady.firstChild=a));b.__shady.childNodes=null}function jb(a,b,c){if(b===a)throw Error("Failed to execute 'appendChild' on 'Node': The new child element contains the parent.");if(c){var d=c.__shady&&c.__shady.parentNode;if(void 0!==d&& +d!==a||void 0===d&&aa(c)!==a)throw Error("Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.");}if(c===b)return b;b.parentNode&&kb(b.parentNode,b);d=ia(a);var e;if(e=d)a:{if(!b.__noInsertionPoint){var f;"slot"===b.localName?f=[b]:b.querySelectorAll&&(f=b.querySelectorAll("slot"));if(f&&f.length){e=f;break a}}e=void 0}f=e;d&&("slot"===a.localName||f)&&d.M();if(Z(a)){e=c;$b(a);a.__shady=a.__shady||{};void 0!==a.__shady.firstChild&& +(a.__shady.childNodes=null);if(b.nodeType===Node.DOCUMENT_FRAGMENT_NODE){for(var g=b.childNodes,h=0;h":return">";case "\u00a0":return" "}},h=function(b){Object.defineProperty(b,"innerHTML",{get:function(){for(var a="",b=this.content.firstChild;b;b=b.nextSibling)a+=b.outerHTML||b.data.replace(r, +g);return a},set:function(b){m.body.innerHTML=b;for(a.b(m);this.content.firstChild;)this.content.removeChild(this.content.firstChild);for(;m.body.firstChild;)this.content.appendChild(m.body.firstChild)},configurable:!0})},m=document.implementation.createHTMLDocument("template"),k=!0,l=document.createElement("style");l.textContent="template{display:none;}";var n=document.head;n.insertBefore(l,n.firstElementChild);a.prototype=Object.create(HTMLElement.prototype);var p=!document.createElement("div").hasOwnProperty("innerHTML"); +a.O=function(b){if(!b.content){b.content=m.createDocumentFragment();for(var c;c=b.firstChild;)b.content.appendChild(c);if(p)b.__proto__=a.prototype;else if(b.cloneNode=function(b){return a.a(this,b)},k)try{h(b)}catch(df){k=!1}a.b(b.content)}};h(a.prototype);a.b=function(b){b=b.querySelectorAll("template");for(var c=0,d=b.length,e;c]/g}if(b||f)a.a=function(a,b){var d=c.call(a,!1);this.O&&this.O(d);b&&(d.content.appendChild(c.call(a.content,!0)),this.qa(d.content,a.content));return d},a.prototype.cloneNode=function(b){return a.a(this,b)},a.qa=function(a,b){if(b.querySelectorAll){b=b.querySelectorAll("template");a=a.querySelectorAll("template");for(var c=0,d=a.length,e,f;c]*)(rel=['|"]?stylesheet['|"]?[^>]*>)/g,q={nb:function(a,b){a.href&&a.setAttribute("href",q.ta(a.getAttribute("href"),b));a.src&&a.setAttribute("src",q.ta(a.getAttribute("src"),b));if("style"===a.localName){var c=q.La(a.textContent,b,r);a.textContent=q.La(c,b,t)}},La:function(a,b,c){return a.replace(c, +function(a,c,d,e){a=d.replace(/["']/g,"");b&&(a=q.Ma(a,b));return c+"'"+a+"'"+e})},ta:function(a,b){return a&&p.test(a)?a:q.Ma(a,b)},Ma:function(a,b){if(void 0===q.la){q.la=!1;try{var c=new URL("b","http://a");c.pathname="c%20d";q.la="http://a/c%20d"===c.href}catch(ef){}}if(q.la)return(new URL(a,b)).href;c=q.Za;c||(c=document.implementation.createHTMLDocument("temp"),q.Za=c,c.wa=c.createElement("base"),c.head.appendChild(c.wa),c.va=c.createElement("a"));c.wa.href=b;c.va.href=a;return c.va.href||a}}, +y={async:!0,load:function(a,b,c){if(a)if(a.match(/^data:/)){a=a.split(",");var d=a[1];d=-1e.status?b(d,a):c(d)};e.send()}else c("error: href must be specified")}}, +x=/Trident/.test(navigator.userAgent)||/Edge\/\d./i.test(navigator.userAgent);m.prototype.c=function(a){var b=this;a=a.querySelectorAll("link[rel=import]");k(a,function(a){return b.h(a)})};m.prototype.h=function(a){var b=this,c=a.href;if(void 0!==this.a[c]){var d=this.a[c];d&&d.__loaded&&(a.import=d,this.g(a))}else this.b++,this.a[c]="pending",y.load(c,function(a,d){a=b.s(a,d||c);b.a[c]=a;b.b--;b.c(a);b.i()},function(){b.a[c]=null;b.b--;b.i()})};m.prototype.s=function(a,b){if(!a)return document.createDocumentFragment(); +x&&(a=a.replace(w,function(a,b,c){return-1===a.indexOf("type=")?b+" type=import-disable "+c:a}));var c=document.createElement("template");c.innerHTML=a;if(c.content)a=c.content;else for(a=document.createDocumentFragment();c.firstChild;)a.appendChild(c.firstChild);if(c=a.querySelector("base"))b=q.ta(c.getAttribute("href"),b),c.removeAttribute("href");c=a.querySelectorAll('link[rel=import], link[rel=stylesheet][href][type=import-disable],\n style:not([type]), link[rel=stylesheet][href]:not([type]),\n script:not([type]), script[type="application/javascript"],\n script[type="text/javascript"]'); +var d=0;k(c,function(a){g(a);q.nb(a,b);a.setAttribute("import-dependency","");"script"===a.localName&&!a.src&&a.textContent&&(a.setAttribute("src","data:text/javascript;charset=utf-8,"+encodeURIComponent(a.textContent+("\n//# sourceURL="+b+(d?"-"+d:"")+".js\n"))),a.textContent="",d++)});return a};m.prototype.i=function(){var a=this;if(!this.b){this.f.disconnect();this.flatten(document);var b=!1,c=!1,d=function(){c&&b&&(a.c(document),a.b||(a.f.observe(document.head,{childList:!0,subtree:!0}),a.j()))}; +this.v(function(){c=!0;d()});this.u(function(){b=!0;d()})}};m.prototype.flatten=function(a){var b=this;a=a.querySelectorAll("link[rel=import]");k(a,function(a){var c=b.a[a.href];(a.import=c)&&c.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&(b.a[a.href]=a,a.readyState="loading",a.import=a,b.flatten(c),a.appendChild(c))})};m.prototype.u=function(a){function b(e){if(e]/g,ne=nc("area base br col command embed hr img input keygen link meta param source track wbr".split(" ")), +oe=nc("style script xmp iframe noembed noframes plaintext noscript".split(" ")),B=document.createTreeWalker(document,NodeFilter.SHOW_ALL,null,!1),C=document.createTreeWalker(document,NodeFilter.SHOW_ELEMENT,null,!1),Je=Object.freeze({parentNode:aa,firstChild:Sa,lastChild:Ta,previousSibling:oc,nextSibling:pc,childNodes:U,parentElement:qc,firstElementChild:rc,lastElementChild:sc,previousElementSibling:tc,nextElementSibling:uc,children:vc,innerHTML:wc,textContent:xc}),Bb=Object.getOwnPropertyDescriptor(Element.prototype, +"innerHTML")||Object.getOwnPropertyDescriptor(HTMLElement.prototype,"innerHTML"),Ca=document.implementation.createHTMLDocument("inert").createElement("div"),Cb=Object.getOwnPropertyDescriptor(Document.prototype,"activeElement"),yc={parentElement:{get:function(){var a=this.__shady&&this.__shady.parentNode;a&&a.nodeType!==Node.ELEMENT_NODE&&(a=null);return void 0!==a?a:qc(this)},configurable:!0},parentNode:{get:function(){var a=this.__shady&&this.__shady.parentNode;return void 0!==a?a:aa(this)},configurable:!0}, +nextSibling:{get:function(){var a=this.__shady&&this.__shady.nextSibling;return void 0!==a?a:pc(this)},configurable:!0},previousSibling:{get:function(){var a=this.__shady&&this.__shady.previousSibling;return void 0!==a?a:oc(this)},configurable:!0},className:{get:function(){return this.getAttribute("class")||""},set:function(a){this.setAttribute("class",a)},configurable:!0},nextElementSibling:{get:function(){if(this.__shady&&void 0!==this.__shady.nextSibling){for(var a=this.nextSibling;a&&a.nodeType!== +Node.ELEMENT_NODE;)a=a.nextSibling;return a}return uc(this)},configurable:!0},previousElementSibling:{get:function(){if(this.__shady&&void 0!==this.__shady.previousSibling){for(var a=this.previousSibling;a&&a.nodeType!==Node.ELEMENT_NODE;)a=a.previousSibling;return a}return tc(this)},configurable:!0}},hb={childNodes:{get:function(){if(Z(this)){if(!this.__shady.childNodes){this.__shady.childNodes=[];for(var a=this.firstChild;a;a=a.nextSibling)this.__shady.childNodes.push(a)}var b=this.__shady.childNodes}else b= +U(this);b.item=function(a){return b[a]};return b},configurable:!0},childElementCount:{get:function(){return this.children.length},configurable:!0},firstChild:{get:function(){var a=this.__shady&&this.__shady.firstChild;return void 0!==a?a:Sa(this)},configurable:!0},lastChild:{get:function(){var a=this.__shady&&this.__shady.lastChild;return void 0!==a?a:Ta(this)},configurable:!0},textContent:{get:function(){if(Z(this)){for(var a=[],b=0,c=this.childNodes,d;d=c[b];b++)d.nodeType!==Node.COMMENT_NODE&& +a.push(d.textContent);return a.join("")}return xc(this)},set:function(a){switch(this.nodeType){case Node.ELEMENT_NODE:case Node.DOCUMENT_FRAGMENT_NODE:for(;this.firstChild;)this.removeChild(this.firstChild);(0b.__shady.assignedNodes.length&&(b.__shady.pa=!0)}b.__shady.pa&& +(b.__shady.pa=!1,this.g(b))}};l.prototype.f=function(a,b){a.__shady=a.__shady||{};var c=a.__shady.ma;a.__shady.ma=null;b||(b=(b=this.a[a.slot||"__catchall"])&&b[0]);b?(b.__shady.assignedNodes.push(a),a.__shady.assignedSlot=b):a.__shady.assignedSlot=void 0;c!==a.__shady.assignedSlot&&a.__shady.assignedSlot&&(a.__shady.assignedSlot.__shady.pa=!0)};l.prototype.l=function(a){var b=a.__shady.assignedNodes;a.__shady.assignedNodes=[];a.__shady.V=[];if(a.__shady.Ca=b)for(var c=0;cb.indexOf(d))||b.push(d)}for(a=0;a< +b.length;a++)c=b[a],this.I(c===this?this.host:c,this.u(c))};l.prototype.u=function(a){var b=[];a=a.childNodes;for(var c=0;c "+b}))}a=a.replace(Ye,function(a,b,c){return'[dir="'+c+'"] '+b+", "+b+'[dir="'+c+'"]'});return{value:a,lb:b,stop:f}};r.prototype.s=function(a,b){a=a.split(ud);a[0]+=b;return a.join(ud)};r.prototype.H=function(a,b){var c=a.match(vd);return(c=c&&c[2].trim()||"")?c[0].match(wd)?a.replace(vd,function(a,c,f){return b+f}):c.split(wd)[0]===b?c:Ze:a.replace(Eb,b)};r.prototype.R=function(a){a.selector=a.parsedSelector;this.v(a);this.j(a,this.L)};r.prototype.v=function(a){a.selector=== +$e&&(a.selector="html")};r.prototype.L=function(a){return a.match(Fb)?this.g(a,xd):this.s(a.trim(),xd)};Jb.Object.defineProperties(r.prototype,{c:{configurable:!0,enumerable:!0,get:function(){return"style-scope"}}});var Db=/:(nth[-\w]+)\(([^)]+)\)/,xd=":not(.style-scope)",td=",",We=/(^|[\s>+~]+)((?:\[.+?\]|[^\s>+~=[])+)/g,wd=/[[.:#*]/,Eb=":host",$e=":root",Fb="::slotted",Ve=new RegExp("^("+Fb+")"),vd=/(:host)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/,Xe=/(?:::slotted)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/,Ye= +/(.*):dir\((?:(ltr|rtl))\)/,Ue=".",ud=":",Te="class",Ze="should_not_match",w=new r;t.get=function(a){return a?a.__styleInfo:null};t.set=function(a,b){return a.__styleInfo=b};t.prototype.c=function(){return this.G};t.prototype._getStyleRules=t.prototype.c;var yd=function(a){return a.matches||a.matchesSelector||a.mozMatchesSelector||a.msMatchesSelector||a.oMatchesSelector||a.webkitMatchesSelector}(window.Element.prototype),af=navigator.userAgent.match("Trident");p.prototype.R=function(a){var b=this, +c={},d=[],e=0;ca(a,function(a){b.c(a);a.index=e++;b.I(a.w.cssText,c)},function(a){d.push(a)});a.b=d;a=[];for(var f in c)a.push(f);return a};p.prototype.c=function(a){if(!a.w){var b={},c={};this.b(a,c)&&(b.F=c,a.rules=null);b.cssText=this.H(a);a.w=b}};p.prototype.b=function(a,b){var c=a.w;if(c){if(c.F)return Object.assign(b,c.F),!0}else{c=a.parsedCssText;for(var d;a=Ea.exec(c);){d=(a[2]||a[3]).trim();if("inherit"!==d||"unset"!==d)b[a[1].trim()]=d;d=!0}return d}};p.prototype.H=function(a){return this.L(a.parsedCssText)}; +p.prototype.L=function(a){return a.replace(Se,"").replace(Ea,"")};p.prototype.I=function(a,b){for(var c;c=Qe.exec(a);){var d=c[1];":"!==c[2]&&(b[d]=!0)}};p.prototype.ea=function(a){for(var b=Object.getOwnPropertyNames(a),c=0,d;c *"===f||"html"===f,h=0===f.indexOf(":host")&&!g;"shady"===c&&(g=f===e+" > *."+e||-1!==f.indexOf("html"),h=!g&&0===f.indexOf(e));"shadow"===c&&(g=":host > *"===f||"html"===f,h=h&&!g);if(g||h)c=e,h&&(q&&!b.A&&(b.A=w.l(b,w.g,w.h(a),e)),c=b.A||e),d({wb:c,qb:h,Fb:g})}};p.prototype.S=function(a,b){var c={},d={},e=this,f=b&&b.__cssBuild;ca(b,function(b){e.ga(a,b,f,function(f){yd.call(a.Db||a,f.wb)&&(f.qb?e.b(b,c):e.b(b,d))})},null,!0);return{ub:d,pb:c}};p.prototype.fa= +function(a,b,c){var d=this,e=T(a),f=w.f(e.is,e.Z),g=new RegExp("(?:^|[^.#[:])"+(a.extends?"\\"+f.slice(0,-1)+"\\]":f)+"($|[.:[\\s>+~])");e=t.get(a).G;var h=this.h(e,c);return w.b(a,e,function(a){d.D(a,b);q||ed(a)||!a.cssText||(d.C(a,h),d.l(a,g,f,c))})};p.prototype.h=function(a,b){a=a.b;var c={};if(!q&&a)for(var d=0,e=a[d];d=f._useCount&&f.parentNode&&f.parentNode.removeChild(f));q?e.a?(e.a.textContent=b,d=e.a):b&&(d=ub(b,c,a.shadowRoot,e.b)):d?d.parentNode||(af&&-1this.c&&e.shift();this.cache[a]=e};pa.prototype.fetch=function(a,b,c){if(a=this.cache[a])for(var d=a.length-1;0<=d;d--){var e=a[d];if(this.a(e,b,c))return e}};if(!q){var zd=new MutationObserver(hd),Ad=function(a){zd.observe(a,{childList:!0,subtree:!0})};if(window.customElements&&!window.customElements.polyfillWrapFlushCallback)Ad(document);else{var Hb=function(){Ad(document.body)}; +window.HTMLImports?window.HTMLImports.whenReady(Hb):requestAnimationFrame(function(){if("loading"===document.readyState){var a=function(){Hb();document.removeEventListener("readystatechange",a)};document.addEventListener("readystatechange",a)}else Hb()})}Lb=function(){hd(zd.takeRecords())}}var Aa={},Ee=Promise.resolve(),vb=null,jd=window.HTMLImports&&window.HTMLImports.whenReady||null,wb,Ha=null,oa=null;F.prototype.Fa=function(){!this.enqueued&&oa&&(this.enqueued=!0,Kb(oa))};F.prototype.b=function(a){a.__seenByShadyCSS|| +(a.__seenByShadyCSS=!0,this.customStyles.push(a),this.Fa())};F.prototype.a=function(a){return a.__shadyCSSCachedStyle?a.__shadyCSSCachedStyle:a.getStyle?a.getStyle():a};F.prototype.c=function(){for(var a=this.customStyles,b=0;b maximum: + raise vol.Invalid('Max len ({}) is not greater than min len ({})' + .format(minimum, maximum)) + state = cfg.get(CONF_INITIAL) + if state is not None and (len(state) < minimum or len(state) > maximum): + raise vol.Invalid('Initial value {} length not in range {}-{}' + .format(state, minimum, maximum)) + return cfg + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.All({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MIN, default=0): vol.Coerce(int), + vol.Optional(CONF_MAX, default=100): vol.Coerce(int), + vol.Optional(CONF_INITIAL, ''): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(ATTR_PATTERN): cv.string, + }, _cv_input_text) + }) +}, required=True, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def set_value(hass, entity_id, value): + """Set input_text to value.""" + hass.services.call(DOMAIN, SERVICE_SET_VALUE, { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: value, + }) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up an input text box.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME) + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + initial = cfg.get(CONF_INITIAL) + icon = cfg.get(CONF_ICON) + unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) + pattern = cfg.get(ATTR_PATTERN) + + entities.append(InputText( + object_id, name, initial, minimum, maximum, icon, unit, + pattern)) + + if not entities: + return False + + @asyncio.coroutine + def async_set_value_service(call): + """Handle a calls to the input box services.""" + target_inputs = component.async_extract_from_service(call) + + tasks = [input_text.async_set_value(call.data[ATTR_VALUE]) + for input_text in target_inputs] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register( + DOMAIN, SERVICE_SET_VALUE, async_set_value_service, + schema=SERVICE_SET_VALUE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class InputText(Entity): + """Represent a text box.""" + + def __init__(self, object_id, name, initial, minimum, maximum, icon, + unit, pattern): + """Initialize a text input.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._current_value = initial + self._minimum = minimum + self._maximum = maximum + self._icon = icon + self._unit = unit + self._pattern = pattern + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the text input entity.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + return self._current_value + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_MIN: self._minimum, + ATTR_MAX: self._maximum, + ATTR_PATTERN: self._pattern, + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + if self._current_value is not None: + return + + state = yield from async_get_last_state(self.hass, self.entity_id) + value = state and state.state + + # Check against None because value can be 0 + if value is not None and self._minimum <= len(value) <= self._maximum: + self._current_value = value + + @asyncio.coroutine + def async_set_value(self, value): + """Select new value.""" + if len(value) < self._minimum or len(value) > self._maximum: + _LOGGER.warning("Invalid value: %s (length range %s - %s)", + value, self._minimum, self._maximum) + return + self._current_value = value + yield from self.async_update_ha_state() diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm.py index 92807bf9b1c..94b70e47cba 100644 --- a/homeassistant/components/insteon_plm.py +++ b/homeassistant/components/insteon_plm.py @@ -102,7 +102,7 @@ def common_attributes(entity): 'address': 'INSTEON Address', 'description': 'Description', 'model': 'Model', - 'cat': 'Cagegory', + 'cat': 'Category', 'subcat': 'Subcategory', 'firmware': 'Firmware', 'product_key': 'Product Key' diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios.py index 13ccee9df3e..e3c58425b27 100644 --- a/homeassistant/components/ios.py +++ b/homeassistant/components/ios.py @@ -121,7 +121,7 @@ CONFIG_SCHEMA = vol.Schema({ CONF_PUSH: { CONF_PUSH_CATEGORIES: vol.All(cv.ensure_list, [{ vol.Required(CONF_PUSH_CATEGORIES_NAME): cv.string, - vol.Required(CONF_PUSH_CATEGORIES_IDENTIFIER): vol.Upper, + vol.Required(CONF_PUSH_CATEGORIES_IDENTIFIER): vol.Lower, vol.Required(CONF_PUSH_CATEGORIES_ACTIONS): ACTION_SCHEMA_LIST }]) } diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index a834cc0a3e4..7686eb7dc7d 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -17,7 +17,7 @@ from homeassistant.helpers import discovery, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, Dict # noqa -REQUIREMENTS = ['PyISY==1.0.7'] +REQUIREMENTS = ['PyISY==1.0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 9530becb6ce..4b976e6ca3f 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -1,495 +1,255 @@ """ -Support for KNX components. -For more details about this component, please refer to the documentation at +Connects to KNX platform. + +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/knx/ + """ import logging -import os +import asyncio import voluptuous as vol +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT) -from homeassistant.helpers.entity import Entity -from homeassistant.config import load_yaml_config_file +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, \ + CONF_HOST, CONF_PORT +from homeassistant.helpers.script import Script -REQUIREMENTS = ['knxip==0.5'] +DOMAIN = "knx" +DATA_KNX = "data_knx" +CONF_KNX_CONFIG = "config_file" + +CONF_KNX_ROUTING = "routing" +CONF_KNX_TUNNELING = "tunneling" +CONF_KNX_LOCAL_IP = "local_ip" +CONF_KNX_FIRE_EVENT = "fire_event" +CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter" + +SERVICE_KNX_SEND = "send" +SERVICE_KNX_ATTR_ADDRESS = "address" +SERVICE_KNX_ATTR_PAYLOAD = "payload" + +ATTR_DISCOVER_DEVICES = 'devices' _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = '0.0.0.0' -DEFAULT_PORT = 3671 -DOMAIN = 'knx' +REQUIREMENTS = ['xknx==0.7.14'] -EVENT_KNX_FRAME_RECEIVED = 'knx_frame_received' -EVENT_KNX_FRAME_SEND = 'knx_frame_send' +TUNNELING_SCHEMA = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Required(CONF_KNX_LOCAL_IP): cv.string, +}) -KNXTUNNEL = None -KNX_ADDRESS = "address" -KNX_DATA = "data" -KNX_GROUP_WRITE = "group_write" -CONF_LISTEN = "listen" +ROUTING_SCHEMA = vol.Schema({ + vol.Required(CONF_KNX_LOCAL_IP): cv.string, +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_LISTEN, default=[]): - vol.All(cv.ensure_list, [cv.string]), - }), + vol.Optional(CONF_KNX_CONFIG): cv.string, + vol.Exclusive(CONF_KNX_ROUTING, 'connection_type'): ROUTING_SCHEMA, + vol.Exclusive(CONF_KNX_TUNNELING, 'connection_type'): + TUNNELING_SCHEMA, + vol.Inclusive(CONF_KNX_FIRE_EVENT, 'fire_ev'): + cv.boolean, + vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'): + vol.All( + cv.ensure_list, + [cv.string]) + }) }, extra=vol.ALLOW_EXTRA) -KNX_WRITE_SCHEMA = vol.Schema({ - vol.Required(KNX_ADDRESS): vol.All(cv.ensure_list, [cv.string]), - vol.Required(KNX_DATA): vol.All(cv.ensure_list, [cv.byte]) +SERVICE_KNX_SEND_SCHEMA = vol.Schema({ + vol.Required(SERVICE_KNX_ATTR_ADDRESS): cv.string, + vol.Required(SERVICE_KNX_ATTR_PAYLOAD): vol.Any( + cv.positive_int, [cv.positive_int]), }) -def setup(hass, config): - """Set up the connection to the KNX IP interface.""" - global KNXTUNNEL - - from knxip.ip import KNXIPTunnel - from knxip.core import KNXException, parse_group_address - - host = config[DOMAIN].get(CONF_HOST) - port = config[DOMAIN].get(CONF_PORT) - - if host == '0.0.0.0': - _LOGGER.debug("Will try to auto-detect KNX/IP gateway") - - KNXTUNNEL = KNXIPTunnel(host, port) +@asyncio.coroutine +def async_setup(hass, config): + """Set up knx component.""" + from xknx.exceptions import XKNXException try: - res = KNXTUNNEL.connect() - _LOGGER.debug("Res = %s", res) - if not res: - _LOGGER.error("Could not connect to KNX/IP interface %s", host) - return False + hass.data[DATA_KNX] = KNXModule(hass, config) + yield from hass.data[DATA_KNX].start() - except KNXException as ex: - _LOGGER.exception("Can't connect to KNX/IP interface: %s", ex) - KNXTUNNEL = None + except XKNXException as ex: + _LOGGER.exception("Can't connect to KNX interface: %s", ex) return False - _LOGGER.info("KNX IP tunnel to %s:%i established", host, port) + for component, discovery_type in ( + ('switch', 'Switch'), + ('climate', 'Climate'), + ('cover', 'Cover'), + ('light', 'Light'), + ('sensor', 'Sensor'), + ('binary_sensor', 'BinarySensor'), + ('notify', 'Notification')): + found_devices = _get_devices(hass, discovery_type) + hass.async_add_job( + discovery.async_load_platform(hass, component, DOMAIN, { + ATTR_DISCOVER_DEVICES: found_devices + }, config)) - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - - def received_knx_event(address, data): - """Process received KNX message.""" - if len(data) == 1: - data = data[0] - hass.bus.fire('knx_event', { - 'address': address, - 'data': data - }) - - for listen in config[DOMAIN].get(CONF_LISTEN): - _LOGGER.debug("Registering listener for %s", listen) - try: - KNXTUNNEL.register_listener(parse_group_address(listen), - received_knx_event) - except KNXException as knxexception: - _LOGGER.error("Can't register KNX listener for address %s (%s)", - listen, knxexception) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_tunnel) - - # Listen to KNX events and send them to the bus - def handle_group_write(call): - """Bridge knx_frame_send events to the KNX bus.""" - # parameters are pre-validated using KNX_WRITE_SCHEMA - addrlist = call.data.get("address") - knxdata = call.data.get("data") - - knxaddrlist = [] - for addr in addrlist: - try: - _LOGGER.debug("Found %s", addr) - knxaddr = int(addr) - except ValueError: - knxaddr = None - - if knxaddr is None: - try: - knxaddr = parse_group_address(addr) - except KNXException: - _LOGGER.error("KNX address format incorrect: %s", addr) - - knxaddrlist.append(knxaddr) - - for addr in knxaddrlist: - KNXTUNNEL.group_write(addr, knxdata) - - # Listen for when knx_frame_send event is fired - hass.services.register(DOMAIN, - KNX_GROUP_WRITE, - handle_group_write, - descriptions[DOMAIN][KNX_GROUP_WRITE], - schema=KNX_WRITE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_KNX_SEND, + hass.data[DATA_KNX].service_send_to_knx_bus, + schema=SERVICE_KNX_SEND_SCHEMA) return True -def close_tunnel(_data): - """Close the NKX tunnel connection on shutdown.""" - global KNXTUNNEL - - KNXTUNNEL.disconnect() - KNXTUNNEL = None +def _get_devices(hass, discovery_type): + return list( + map(lambda device: device.name, + filter( + lambda device: type(device).__name__ == discovery_type, + hass.data[DATA_KNX].xknx.devices))) -class KNXConfig(object): - """Handle the fetching of configuration from the config file.""" - - def __init__(self, config): - """Initialize the configuration.""" - from knxip.core import parse_group_address - - self.config = config - self.should_poll = config.get('poll', True) - if config.get('address'): - self._address = parse_group_address(config.get('address')) - else: - self._address = None - if self.config.get('state_address'): - self._state_address = parse_group_address( - self.config.get('state_address')) - else: - self._state_address = None - - @property - def name(self): - """Return the name given to the entity.""" - return self.config['name'] - - @property - def address(self): - """Return the address of the device as an integer value. - - 3 types of addresses are supported: - integer - 0-65535 - 2 level - a/b - 3 level - a/b/c - """ - return self._address - - @property - def state_address(self): - """Return the group address the device sends its current state to. - - Some KNX devices can send the current state to a seperate - group address. This makes send e.g. when an actuator can - be switched but also have a timer functionality. - """ - return self._state_address - - -class KNXGroupAddress(Entity): - """Representation of devices connected to a KNX group address.""" +class KNXModule(object): + """Representation of KNX Object.""" def __init__(self, hass, config): - """Initialize the device.""" - self._config = config - self._state = False - self._data = None - _LOGGER.debug( - "Initalizing KNX group address for %s (%s)", - self.name, self.address - ) + """Initialization of KNXModule.""" + self.hass = hass + self.config = config + self.initialized = False + self.init_xknx() + self.register_callbacks() - def handle_knx_message(addr, data): - """Handle an incoming KNX frame. + def init_xknx(self): + """Initialization of KNX object.""" + from xknx import XKNX + self.xknx = XKNX( + config=self.config_file(), + loop=self.hass.loop) - Handle an incoming frame and update our status if it contains - information relating to this device. - """ - if (addr == self.state_address) or (addr == self.address): - self._state = data[0] - self.schedule_update_ha_state() + @asyncio.coroutine + def start(self): + """Start KNX object. Connect to tunneling or Routing device.""" + connection_config = self.connection_config() + yield from self.xknx.start( + state_updater=True, + connection_config=connection_config) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) + self.initialized = True - KNXTUNNEL.register_listener(self.address, handle_knx_message) - if self.state_address: - KNXTUNNEL.register_listener(self.state_address, handle_knx_message) + @asyncio.coroutine + def stop(self, event): + """Stop KNX object. Disconnect from tunneling or Routing device.""" + yield from self.xknx.stop() - @property - def name(self): - """Return the entity's display name.""" - return self._config.name + def config_file(self): + """Resolve and return the full path of xknx.yaml if configured.""" + config_file = self.config[DOMAIN].get(CONF_KNX_CONFIG) + if not config_file: + return None + if not config_file.startswith("/"): + return self.hass.config.path(config_file) + return config_file - @property - def config(self): - """Return the entity's configuration.""" - return self._config + def connection_config(self): + """Return the connection_config.""" + if CONF_KNX_TUNNELING in self.config[DOMAIN]: + return self.connection_config_tunneling() + elif CONF_KNX_ROUTING in self.config[DOMAIN]: + return self.connection_config_routing() + return self.connection_config_auto() - @property - def should_poll(self): - """Return the state of the polling, if needed.""" - return self._config.should_poll + def connection_config_routing(self): + """Return the connection_config if routing is configured.""" + from xknx.io import ConnectionConfig, ConnectionType + local_ip = \ + self.config[DOMAIN][CONF_KNX_ROUTING].get(CONF_KNX_LOCAL_IP) + return ConnectionConfig( + connection_type=ConnectionType.ROUTING, + local_ip=local_ip) - @property - def is_on(self): - """Return True if the value is not 0 is on, else False.""" - return self._state != 0 + def connection_config_tunneling(self): + """Return the connection_config if tunneling is configured.""" + from xknx.io import ConnectionConfig, ConnectionType, \ + DEFAULT_MCAST_PORT + gateway_ip = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_HOST) + gateway_port = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_PORT) + local_ip = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_KNX_LOCAL_IP) + if gateway_port is None: + gateway_port = DEFAULT_MCAST_PORT + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING, + gateway_ip=gateway_ip, + gateway_port=gateway_port, + local_ip=local_ip) - @property - def address(self): - """Return the KNX group address.""" - return self._config.address + def connection_config_auto(self): + """Return the connection_config if auto is configured.""" + # pylint: disable=no-self-use + from xknx.io import ConnectionConfig + return ConnectionConfig() - @property - def state_address(self): - """Return the KNX group address.""" - return self._config.state_address + def register_callbacks(self): + """Register callbacks within XKNX object.""" + if CONF_KNX_FIRE_EVENT in self.config[DOMAIN] and \ + self.config[DOMAIN][CONF_KNX_FIRE_EVENT]: + from xknx.knx import AddressFilter + address_filters = list(map( + AddressFilter, + self.config[DOMAIN][CONF_KNX_FIRE_EVENT_FILTER])) + self.xknx.telegram_queue.register_telegram_received_cb( + self.telegram_received_cb, address_filters) - @property - def cache(self): - """Return the name given to the entity.""" - return self._config.config.get('cache', True) - - def group_write(self, value): - """Write to the group address.""" - KNXTUNNEL.group_write(self.address, [value]) - - def update(self): - """Get the state from KNX bus or cache.""" - from knxip.core import KNXException - - try: - if self.state_address: - res = KNXTUNNEL.group_read( - self.state_address, use_cache=self.cache) - else: - res = KNXTUNNEL.group_read(self.address, use_cache=self.cache) - - if res: - self._state = res[0] - self._data = res - else: - _LOGGER.debug( - "%s: unable to read from KNX address: %s (None)", - self.name, self.address - ) - - except KNXException: - _LOGGER.exception( - "%s: unable to read from KNX address: %s", - self.name, self.address - ) - return False - - -class KNXMultiAddressDevice(Entity): - """Representation of devices connected to a multiple KNX group address. - - This is needed for devices like dimmers or shutter actuators as they have - to be controlled by multiple group addresses. - """ - - def __init__(self, hass, config, required, optional=None): - """Initialize the device. - - The namelist argument lists the required addresses. E.g. for a dimming - actuators, the namelist might look like: - onoff_address: 0/0/1 - brightness_address: 0/0/2 - """ - from knxip.core import parse_group_address, KNXException - - self.names = {} - self.values = {} - - self._config = config - self._state = False - self._data = None - _LOGGER.debug( - "%s: initalizing KNX multi address device", - self.name - ) - - settings = self._config.config - if config.address: - _LOGGER.debug( - "%s: base address: address=%s", - self.name, settings.get('address') - ) - self.names[config.address] = 'base' - if config.state_address: - _LOGGER.debug( - "%s, state address: state_address=%s", - self.name, settings.get('state_address') - ) - self.names[config.state_address] = 'state' - - # parse required addresses - for name in required: - paramname = '{}{}'.format(name, '_address') - addr = settings.get(paramname) - if addr is None: - _LOGGER.error( - "%s: Required KNX group address %s missing", - self.name, paramname - ) - raise KNXException( - "%s: Group address for {} missing in " - "configuration for {}".format( - self.name, paramname - ) - ) - _LOGGER.debug( - "%s: (required parameter) %s=%s", - self.name, paramname, addr - ) - addr = parse_group_address(addr) - self.names[addr] = name - - # parse optional addresses - for name in optional: - paramname = '{}{}'.format(name, '_address') - addr = settings.get(paramname) - _LOGGER.debug( - "%s: (optional parameter) %s=%s", - self.name, paramname, addr - ) - if addr: - try: - addr = parse_group_address(addr) - except KNXException: - _LOGGER.exception( - "%s: cannot parse group address %s", - self.name, addr - ) - self.names[addr] = name - - @property - def name(self): - """Return the entity's display name.""" - return self._config.name - - @property - def config(self): - """Return the entity's configuration.""" - return self._config - - @property - def should_poll(self): - """Return the state of the polling, if needed.""" - return self._config.should_poll - - @property - def cache(self): - """Return the name given to the entity.""" - return self._config.config.get('cache', True) - - def has_attribute(self, name): - """Check if the attribute with the given name is defined. - - This is mostly important for optional addresses. - """ - for attributename in self.names.values(): - if attributename == name: - return True + @asyncio.coroutine + def telegram_received_cb(self, telegram): + """Callback invoked after a KNX telegram was received.""" + self.hass.bus.fire('knx_event', { + 'address': telegram.group_address.str(), + 'data': telegram.payload.value + }) + # False signals XKNX to proceed with processing telegrams. return False - def set_percentage(self, name, percentage): - """Set a percentage in knx for a given attribute. + @asyncio.coroutine + def service_send_to_knx_bus(self, call): + """Service for sending an arbitrary KNX message to the KNX bus.""" + from xknx.knx import Telegram, Address, DPTBinary, DPTArray + attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) + attr_address = call.data.get(SERVICE_KNX_ATTR_ADDRESS) - DPT_Scaling / DPT 5.001 is a single byte scaled percentage - """ - percentage = abs(percentage) # only accept positive values - scaled_value = percentage * 255 / 100 - value = min(255, scaled_value) - return self.set_int_value(name, value) + def calculate_payload(attr_payload): + """Calculate payload depending on type of attribute.""" + if isinstance(attr_payload, int): + return DPTBinary(attr_payload) + return DPTArray(attr_payload) + payload = calculate_payload(attr_payload) + address = Address(attr_address) - def get_percentage(self, name): - """Get a percentage from knx for a given attribute. + telegram = Telegram() + telegram.payload = payload + telegram.group_address = address + yield from self.xknx.telegrams.put(telegram) - DPT_Scaling / DPT 5.001 is a single byte scaled percentage - """ - value = self.get_int_value(name) - percentage = round(value * 100 / 255) - return percentage - def set_int_value(self, name, value, num_bytes=1): - """Set an integer value for a given attribute.""" - # KNX packets are big endian - value = round(value) # only accept integers - b_value = value.to_bytes(num_bytes, byteorder='big') - return self.set_value(name, list(b_value)) +class KNXAutomation(): + """Wrapper around xknx.devices.ActionCallback object..""" - def get_int_value(self, name): - """Get an integer value for a given attribute.""" - # KNX packets are big endian - summed_value = 0 - raw_value = self.value(name) - try: - # convert raw value in bytes - for val in raw_value: - summed_value *= 256 - summed_value += val - except TypeError: - # pknx returns a non-iterable type for unsuccessful reads - pass + def __init__(self, hass, device, hook, action, counter=1): + """Initialize Automation class.""" + self.hass = hass + self.device = device + script_name = "{} turn ON script".format(device.get_name()) + self.script = Script(hass, action, script_name) - return summed_value - - def value(self, name): - """Return the value to a given named attribute.""" - from knxip.core import KNXException - - addr = None - for attributeaddress, attributename in self.names.items(): - if attributename == name: - addr = attributeaddress - - if addr is None: - _LOGGER.error("%s: attribute '%s' undefined", - self.name, name) - _LOGGER.debug( - "%s: defined attributes: %s", - self.name, str(self.names) - ) - return False - - try: - res = KNXTUNNEL.group_read(addr, use_cache=self.cache) - except KNXException: - _LOGGER.exception( - "%s: unable to read from KNX address: %s", - self.name, addr - ) - return False - - return res - - def set_value(self, name, value): - """Set the value of a given named attribute.""" - from knxip.core import KNXException - - addr = None - for attributeaddress, attributename in self.names.items(): - if attributename == name: - addr = attributeaddress - - if addr is None: - _LOGGER.error("%s: attribute '%s' undefined", - self.name, name) - _LOGGER.debug( - "%s: defined attributes: %s", - self.name, str(self.names) - ) - return False - - try: - KNXTUNNEL.group_write(addr, value) - except KNXException: - _LOGGER.exception( - "%s: unable to write to KNX address: %s", - self.name, addr - ) - return False - - return True + import xknx + self.action = xknx.devices.ActionCallback( + hass.data[DATA_KNX].xknx, + self.script.async_run, + hook=hook, + counter=counter) + device.actions.append(self.action) diff --git a/homeassistant/components/light/abode.py b/homeassistant/components/light/abode.py new file mode 100644 index 00000000000..d3e79b38647 --- /dev/null +++ b/homeassistant/components/light/abode.py @@ -0,0 +1,84 @@ +""" +This component provides HA light support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, + SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, Light) + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode light devices.""" + import abodepy.helpers.constants as CONST + + data = hass.data[ABODE_DOMAIN] + + device_types = [CONST.TYPE_LIGHT, CONST.TYPE_SWITCH] + + devices = [] + + # Get all regular lights that are not excluded or switches marked as lights + for device in data.abode.get_devices(generic_type=device_types): + if data.is_excluded(device) or not data.is_light(device): + continue + + devices.append(AbodeLight(data, device)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeLight(AbodeDevice, Light): + """Representation of an Abode light.""" + + def turn_on(self, **kwargs): + """Turn on the light.""" + if (ATTR_RGB_COLOR in kwargs and + self._device.is_dimmable and self._device.has_color): + self._device.set_color(kwargs[ATTR_RGB_COLOR]) + elif ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable: + self._device.set_level(kwargs[ATTR_BRIGHTNESS]) + else: + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the light.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on + + @property + def brightness(self): + """Return the brightness of the light.""" + if self._device.is_dimmable and self._device.has_brightness: + return self._device.brightness + + @property + def rgb_color(self): + """Return the color of the light.""" + if self._device.is_dimmable and self._device.has_color: + return self._device.color + + @property + def supported_features(self): + """Flag supported features.""" + if self._device.is_dimmable and self._device.has_color: + return SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + elif self._device.is_dimmable: + return SUPPORT_BRIGHTNESS + + return 0 diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index 21012f81658..95f13cad860 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( SUPPORT_RGB_COLOR, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['flux_led==0.19'] +REQUIREMENTS = ['flux_led==0.20'] _LOGGER = logging.getLogger(__name__) @@ -122,7 +122,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if ipaddr in light_ips: continue device['name'] = '{} {}'.format(device['id'], ipaddr) - device[ATTR_MODE] = 'rgbw' + device[ATTR_MODE] = MODE_RGBW device[CONF_PROTOCOL] = None light = FluxLight(device) lights.append(light) @@ -216,9 +216,9 @@ class FluxLight(Light): elif rgb is not None: self._bulb.setRgb(*tuple(rgb)) elif brightness is not None: - if self._mode == 'rgbw': + if self._mode == MODE_RGBW: self._bulb.setWarmWhite255(brightness) - elif self._mode == 'rgb': + elif self._mode == MODE_RGB: (red, green, blue) = self._bulb.getRgb() self._bulb.setRgb(red, green, blue, brightness=brightness) elif effect == EFFECT_RANDOM: diff --git a/homeassistant/components/light/homematic.py b/homeassistant/components/light/homematic.py index 60865dd223e..807c19fffdb 100644 --- a/homeassistant/components/light/homematic.py +++ b/homeassistant/components/light/homematic.py @@ -24,8 +24,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMLight(hass, conf) - new_device.link_homematic() + new_device = HMLight(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 746c6489c9e..d4e650f2ba5 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -83,6 +83,7 @@ SCENE_SCHEMA = vol.Schema({ }) ATTR_IS_HUE_GROUP = "is_hue_group" +GROUP_NAME_ALL_HUE_LIGHTS = "All Hue Lights" def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): @@ -203,6 +204,21 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable, _LOGGER.error("Got unexpected result from Hue API") return + if not skip_groups: + # Group ID 0 is a special group in the hub for all lights, but it + # is not returned by get_api() so explicitly get it and include it. + # See https://developers.meethue.com/documentation/ + # groups-api#21_get_all_groups + _LOGGER.debug("Getting group 0 from bridge") + all_lights = bridge.get_group(0) + if not isinstance(all_lights, dict): + _LOGGER.error("Got unexpected result from Hue API for group 0") + return + # Hue hub returns name of group 0 as "Group 0", so rename + # for ease of use in HA. + all_lights['name'] = GROUP_NAME_ALL_HUE_LIGHTS + api_groups["0"] = all_lights + new_lights = [] api_name = api.get('config').get('name') diff --git a/homeassistant/components/light/insteon_local.py b/homeassistant/components/light/insteon_local.py index ebd6ab92d0f..8917a9e9ccf 100644 --- a/homeassistant/components/light/insteon_local.py +++ b/homeassistant/components/light/insteon_local.py @@ -134,7 +134,7 @@ class InsteonLocalDimmerDevice(Light): @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self.node.deviceName @property diff --git a/homeassistant/components/light/insteon_plm.py b/homeassistant/components/light/insteon_plm.py index 3b3dd43f496..51de9f03df5 100644 --- a/homeassistant/components/light/insteon_plm.py +++ b/homeassistant/components/light/insteon_plm.py @@ -60,12 +60,12 @@ class InsteonPLMDimmerDevice(Light): @property def address(self): - """Return the the address of the node.""" + """Return the address of the node.""" return self._address @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self._name @property diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index d89d45e99a7..3688cafdd25 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -1,17 +1,17 @@ """ -Support KNX Lighting actuators. +Support for KNX/IP lights. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/Light.knx/ +https://home-assistant.io/components/light.knx/ """ -import logging +import asyncio import voluptuous as vol -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.components.light import (Light, PLATFORM_SCHEMA, - SUPPORT_BRIGHTNESS, - ATTR_BRIGHTNESS) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.light import PLATFORM_SCHEMA, Light, \ + SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_ADDRESS = 'address' @@ -19,8 +19,6 @@ CONF_STATE_ADDRESS = 'state_address' CONF_BRIGHTNESS_ADDRESS = 'brightness_address' CONF_BRIGHTNESS_STATE_ADDRESS = 'brightness_state_address' -_LOGGER = logging.getLogger(__name__) - DEFAULT_NAME = 'KNX Light' DEPENDENCIES = ['knx'] @@ -33,84 +31,136 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX light platform.""" - add_devices([KNXLight(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up light(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + return True -class KNXLight(KNXMultiAddressDevice, Light): - """Representation of a KNX Light device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, async_add_devices): + """Set up lights 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(KNXLight(hass, device)) + async_add_devices(entities) - def __init__(self, hass, config): - """Initialize the cover.""" - KNXMultiAddressDevice.__init__( - self, hass, config, - [], # required - optional=['state', 'brightness', 'brightness_state'] - ) - self._hass = hass - self._supported_features = 0 - if CONF_BRIGHTNESS_ADDRESS in config.config: - _LOGGER.debug("%s is dimmable", self.name) - self._supported_features = self._supported_features | \ - SUPPORT_BRIGHTNESS - self._brightness = None +@callback +def async_add_devices_config(hass, config, async_add_devices): + """Set up light for KNX platform configured within plattform.""" + import xknx + light = xknx.devices.Light( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_switch=config.get(CONF_ADDRESS), + group_address_switch_state=config.get(CONF_STATE_ADDRESS), + group_address_brightness=config.get(CONF_BRIGHTNESS_ADDRESS), + group_address_brightness_state=config.get( + CONF_BRIGHTNESS_STATE_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(light) + async_add_devices([KNXLight(hass, light)]) - def turn_on(self, **kwargs): - """Turn the switch on. - This sends a value 1 to the group address of the device - """ - _LOGGER.debug("%s: turn on", self.name) - self.set_value('base', [1]) - self._state = 1 +class KNXLight(Light): + """Representation of a KNX light.""" - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - _LOGGER.debug("turn_on requested brightness for light: %s is: %s ", - self.name, self._brightness) - assert self._brightness <= 255 - self.set_value("brightness", [self._brightness]) + def __init__(self, hass, device): + """Initialization of KNXLight.""" + self.device = device + self.hass = hass + self.async_register_callbacks() - if not self.should_poll: - self.schedule_update_ha_state() + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) - def turn_off(self, **kwargs): - """Turn the switch off. + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name - This sends a value 1 to the group address of the device - """ - _LOGGER.debug("%s: turn off", self.name) - self.set_value('base', [0]) - self._state = 0 - if not self.should_poll: - self.schedule_update_ha_state() + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self.device.brightness \ + if self.device.supports_dimming else \ + None + + @property + def xy_color(self): + """Return the XY color value [float, float].""" + return None + + @property + def rgb_color(self): + """Return the RBG color value.""" + return None + + @property + def color_temp(self): + """Return the CT color temperature.""" + return None + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return None + + @property + def effect_list(self): + """Return the list of supported effects.""" + return None + + @property + def effect(self): + """Return the current effect.""" + return None @property def is_on(self): - """Return True if the value is not 0 is on, else False.""" - return self._state != 0 + """Return true if light is on.""" + return self.device.state @property def supported_features(self): """Flag supported features.""" - return self._supported_features + flags = 0 + if self.device.supports_dimming: + flags |= SUPPORT_BRIGHTNESS + return flags - def update(self): - """Update device state.""" - super().update() - if self.has_attribute('brightness_state'): - value = self.value('brightness_state') - if value is not None: - self._brightness = int.from_bytes(value, byteorder='little') - _LOGGER.debug("%s: brightness = %d", - self.name, self._brightness) + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming: + yield from self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + else: + yield from self.device.set_on() - if self.has_attribute('state'): - self._state = self.value("state")[0] - _LOGGER.debug("%s: state = %d", self.name, self._state) - - def should_poll(self): - """No polling needed for a KNX light.""" - return False + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + yield from self.device.set_off() diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 6b57a1c5146..ad2cf204463 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -33,7 +33,7 @@ import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.5.4', 'aiolifx_effects==0.1.1'] +REQUIREMENTS = ['aiolifx==0.6.0', 'aiolifx_effects==0.1.2'] UDP_BROADCAST_PORT = 56700 @@ -642,6 +642,18 @@ class LIFXStrip(LIFXColor): bulb = self.device num_zones = len(bulb.color_zones) + zones = kwargs.get(ATTR_ZONES) + if zones is None: + # Fast track: setting all zones to the same brightness and color + # can be treated as a single-zone bulb. + if hsbk[2] is not None and hsbk[3] is not None: + yield from super().set_color(ack, hsbk, kwargs, duration) + return + + zones = list(range(0, num_zones)) + else: + zones = list(filter(lambda x: x < num_zones, set(zones))) + # Zone brightness is not reported when powered off if not self.is_on and hsbk[2] is None: yield from self.set_power(ack, True) @@ -650,12 +662,6 @@ class LIFXStrip(LIFXColor): yield from self.set_power(ack, False) yield from asyncio.sleep(0.3) - zones = kwargs.get(ATTR_ZONES, None) - if zones is None: - zones = list(range(0, num_zones)) - else: - zones = list(filter(lambda x: x < num_zones, set(zones))) - # Send new color to each zone for index, zone in enumerate(zones): zone_hsbk = merge_hsbk(bulb.color_zones[zone], hsbk) @@ -684,8 +690,7 @@ class LIFXStrip(LIFXColor): # Each get_color_zones can update 8 zones at once resp = yield from AwaitAioLIFX().wait(partial( self.device.get_color_zones, - start_index=zone, - end_index=zone+7)) + start_index=zone)) if resp: zone += 8 top = resp.count diff --git a/homeassistant/components/light/lutron_caseta.py b/homeassistant/components/light/lutron_caseta.py index 8e4e9d7450e..c11b3da6f75 100644 --- a/homeassistant/components/light/lutron_caseta.py +++ b/homeassistant/components/light/lutron_caseta.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/light.lutron_caseta/ import logging from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, DOMAIN) from homeassistant.components.light.lutron import ( to_hass_level, to_lutron_level) from homeassistant.components.lutron_caseta import ( @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Lutron Caseta lights.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - light_devices = bridge.get_devices_by_types(["WallDimmer", "PlugInDimmer"]) + light_devices = bridge.get_devices_by_domain(DOMAIN) for light_device in light_devices: dev = LutronCasetaLight(light_device, bridge) devs.append(dev) diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index 038cacd300e..a66cecd3ef8 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -39,6 +39,7 @@ CONF_EFFECT_COMMAND_TOPIC = 'effect_command_topic' CONF_EFFECT_LIST = 'effect_list' CONF_EFFECT_STATE_TOPIC = 'effect_state_topic' CONF_EFFECT_VALUE_TEMPLATE = 'effect_value_template' +CONF_RGB_COMMAND_TEMPLATE = 'rgb_command_template' CONF_RGB_COMMAND_TOPIC = 'rgb_command_topic' CONF_RGB_STATE_TOPIC = 'rgb_state_topic' CONF_RGB_VALUE_TEMPLATE = 'rgb_value_template' @@ -75,6 +76,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, 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_RGB_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_RGB_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_RGB_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_RGB_VALUE_TEMPLATE): cv.template, @@ -125,6 +127,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): CONF_COLOR_TEMP: config.get(CONF_COLOR_TEMP_VALUE_TEMPLATE), CONF_EFFECT: config.get(CONF_EFFECT_VALUE_TEMPLATE), CONF_RGB: config.get(CONF_RGB_VALUE_TEMPLATE), + CONF_RGB_COMMAND_TEMPLATE: config.get(CONF_RGB_COMMAND_TEMPLATE), CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), CONF_WHITE_VALUE: config.get(CONF_WHITE_VALUE_TEMPLATE), CONF_XY: config.get(CONF_XY_VALUE_TEMPLATE), @@ -217,7 +220,7 @@ class MqttLight(Light): self._state = True elif payload == self._payload['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( @@ -230,7 +233,7 @@ class MqttLight(Light): device_value = float(templates[CONF_BRIGHTNESS](payload)) percent_bright = device_value / self._brightness_scale self._brightness = int(percent_bright * 255) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -247,7 +250,7 @@ class MqttLight(Light): """Handle new MQTT messages for RGB.""" self._rgb = [int(val) for val in templates[CONF_RGB](payload).split(',')] - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_RGB_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -263,7 +266,7 @@ class MqttLight(Light): def color_temp_received(topic, payload, qos): """Handle new MQTT messages for color temperature.""" self._color_temp = int(templates[CONF_COLOR_TEMP](payload)) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -279,7 +282,7 @@ class MqttLight(Light): def effect_received(topic, payload, qos): """Handle new MQTT messages for effect.""" self._effect = templates[CONF_EFFECT](payload) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_EFFECT_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -297,7 +300,7 @@ class MqttLight(Light): device_value = float(templates[CONF_WHITE_VALUE](payload)) percent_white = device_value / self._white_value_scale self._white_value = int(percent_white * 255) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_WHITE_VALUE_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -314,7 +317,7 @@ class MqttLight(Light): """Handle new MQTT messages for color.""" self._xy = [float(val) for val in templates[CONF_XY](payload).split(',')] - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_XY_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -397,10 +400,17 @@ class MqttLight(Light): if ATTR_RGB_COLOR in kwargs and \ self._topic[CONF_RGB_COMMAND_TOPIC] is not None: + tpl = self._templates[CONF_RGB_COMMAND_TEMPLATE] + if tpl: + colors = {'red', 'green', 'blue'} + variables = {key: val for key, val in + zip(colors, kwargs[ATTR_RGB_COLOR])} + rgb_color_str = tpl.async_render(variables) + else: + rgb_color_str = '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]) mqtt.async_publish( self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], - '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]), self._qos, - self._retain) + rgb_color_str, self._qos, self._retain) if self._optimistic_rgb: self._rgb = kwargs[ATTR_RGB_COLOR] @@ -473,7 +483,7 @@ class MqttLight(Light): should_update = True if should_update: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -488,4 +498,4 @@ class MqttLight(Light): if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py index 4fee1138909..5663e1fc50d 100755 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -226,7 +226,7 @@ class MqttJson(Light): except ValueError: _LOGGER.warning("Invalid XY color value received") - 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( @@ -373,7 +373,7 @@ class MqttJson(Light): should_update = True if should_update: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -393,4 +393,4 @@ class MqttJson(Light): if self._optimistic: # Optimistically assume that the light has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt_template.py index 07fd6d45d8c..6dabedbd444 100755 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt_template.py @@ -211,7 +211,7 @@ class MqttTemplate(Light): else: _LOGGER.warning("Unsupported effect value received") - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topics[CONF_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -323,7 +323,7 @@ class MqttTemplate(Light): ) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -345,7 +345,7 @@ class MqttTemplate(Light): ) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def supported_features(self): diff --git a/homeassistant/components/light/mysensors.py b/homeassistant/components/light/mysensors.py index 203119e5e51..c41f480c67e 100644 --- a/homeassistant/components/light/mysensors.py +++ b/homeassistant/components/light/mysensors.py @@ -4,64 +4,35 @@ Support for MySensors lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.mysensors/ """ -import logging - from homeassistant.components import mysensors from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_WHITE_VALUE, + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_WHITE_VALUE, DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_WHITE_VALUE, Light) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.util.color import rgb_hex_to_rgb_list -_LOGGER = logging.getLogger(__name__) -ATTR_VALUE = 'value' -ATTR_VALUE_TYPE = 'value_type' - SUPPORT_MYSENSORS = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR | SUPPORT_WHITE_VALUE) def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for lights.""" - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_DIMMER: [set_req.V_DIMMER], - } - device_class_map = { - pres.S_DIMMER: MySensorsLightDimmer, - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_RGB_LIGHT: [set_req.V_RGB], - pres.S_RGBW_LIGHT: [set_req.V_RGBW], - }) - map_sv_types[pres.S_DIMMER].append(set_req.V_PERCENTAGE) - device_class_map.update({ - pres.S_RGB_LIGHT: MySensorsLightRGB, - pres.S_RGBW_LIGHT: MySensorsLightRGBW, - }) - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, device_class_map, add_devices)) + """Setup the mysensors platform for lights.""" + device_class_map = { + 'S_DIMMER': MySensorsLightDimmer, + 'S_RGB_LIGHT': MySensorsLightRGB, + 'S_RGBW_LIGHT': MySensorsLightRGBW, + } + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, device_class_map, + add_devices=add_devices) -class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): +class MySensorsLight(mysensors.MySensorsEntity, Light): """Representation of a MySensors Light child node.""" def __init__(self, *args): """Initialize a MySensors Light.""" - mysensors.MySensorsDeviceEntity.__init__(self, *args) + super().__init__(*args) self._state = None self._brightness = None self._rgb = None @@ -101,7 +72,7 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): """Turn on light child device.""" set_req = self.gateway.const.SetReq - if self._state or set_req.V_LIGHT not in self._values: + if self._state: return self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_LIGHT, 1) @@ -110,7 +81,6 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): # optimistically assume that light has changed state self._state = True self._values[set_req.V_LIGHT] = STATE_ON - self.schedule_update_ha_state() def _turn_on_dimmer(self, **kwargs): """Turn on dimmer child device.""" @@ -130,7 +100,6 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): # optimistically assume that light has changed state self._brightness = brightness self._values[set_req.V_DIMMER] = percent - self.schedule_update_ha_state() def _turn_on_rgb_and_w(self, hex_template, **kwargs): """Turn on RGB or RGBW child device.""" @@ -144,16 +113,11 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): return if new_rgb is not None: rgb = list(new_rgb) - if rgb is None: - return if hex_template == '%02x%02x%02x%02x': if new_white is not None: rgb.append(new_white) - elif white is not None: - rgb.append(white) else: - _LOGGER.error("White value is not updated for RGBW light") - return + rgb.append(white) hex_color = hex_template % tuple(rgb) if len(rgb) > 3: white = rgb.pop() @@ -164,104 +128,40 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): # optimistically assume that light has changed state self._rgb = rgb self._white = white - if hex_color: - self._values[self.value_type] = hex_color - self.schedule_update_ha_state() + self._values[self.value_type] = hex_color - def _turn_off_light(self, value_type=None, value=None): - """Turn off light child device.""" - set_req = self.gateway.const.SetReq - value_type = ( - set_req.V_LIGHT - if set_req.V_LIGHT in self._values else value_type) - value = 0 if set_req.V_LIGHT in self._values else value - return {ATTR_VALUE_TYPE: value_type, ATTR_VALUE: value} - - def _turn_off_dimmer(self, value_type=None, value=None): - """Turn off dimmer child device.""" - set_req = self.gateway.const.SetReq - value_type = ( - set_req.V_DIMMER - if set_req.V_DIMMER in self._values else value_type) - value = 0 if set_req.V_DIMMER in self._values else value - return {ATTR_VALUE_TYPE: value_type, ATTR_VALUE: value} - - def _turn_off_rgb_or_w(self, value_type=None, value=None): - """Turn off RGB or RGBW child device.""" - if float(self.gateway.protocol_version) >= 1.5: - set_req = self.gateway.const.SetReq - if self.value_type == set_req.V_RGB: - value = '000000' - elif self.value_type == set_req.V_RGBW: - value = '00000000' - return {ATTR_VALUE_TYPE: self.value_type, ATTR_VALUE: value} - - def _turn_off_main(self, value_type=None, value=None): + def turn_off(self): """Turn the device off.""" - set_req = self.gateway.const.SetReq - if value_type is None or value is None: - _LOGGER.warning( - "%s: value_type %s, value = %s, None is not valid argument " - "when setting child value", self._name, value_type, value) - return + value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value( - self.node_id, self.child_id, value_type, value) + self.node_id, self.child_id, value_type, 0) if self.gateway.optimistic: # optimistically assume that light has changed state self._state = False - self._values[value_type] = ( - STATE_OFF if set_req.V_LIGHT in self._values else value) + self._values[value_type] = STATE_OFF self.schedule_update_ha_state() def _update_light(self): """Update the controller with values from light child.""" value_type = self.gateway.const.SetReq.V_LIGHT - if value_type in self._values: - self._values[value_type] = ( - STATE_ON if int(self._values[value_type]) == 1 else STATE_OFF) - self._state = self._values[value_type] == STATE_ON + self._state = self._values[value_type] == STATE_ON def _update_dimmer(self): """Update the controller with values from dimmer child.""" - set_req = self.gateway.const.SetReq - value_type = set_req.V_DIMMER + value_type = self.gateway.const.SetReq.V_DIMMER if value_type in self._values: self._brightness = round(255 * int(self._values[value_type]) / 100) if self._brightness == 0: self._state = False - if set_req.V_LIGHT not in self._values: - self._state = self._brightness > 0 def _update_rgb_or_w(self): """Update the controller with values from RGB or RGBW child.""" - set_req = self.gateway.const.SetReq value = self._values[self.value_type] - if len(value) != 6 and len(value) != 8: - _LOGGER.error( - "Wrong value %s for %s", value, set_req(self.value_type).name) - return color_list = rgb_hex_to_rgb_list(value) - if set_req.V_LIGHT not in self._values and \ - set_req.V_DIMMER not in self._values: - self._state = max(color_list) > 0 if len(color_list) > 3: - if set_req.V_RGBW != self.value_type: - _LOGGER.error( - "Wrong value %s for %s", - value, set_req(self.value_type).name) - return self._white = color_list.pop() self._rgb = color_list - def _update_main(self): - """Update the controller with the latest value from a sensor.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - for value_type, value in child.values.items(): - _LOGGER.debug( - "%s: value_type %s, value = %s", self._name, value_type, value) - self._values[value_type] = value - class MySensorsLightDimmer(MySensorsLight): """Dimmer child class to MySensorsLight.""" @@ -270,18 +170,12 @@ class MySensorsLightDimmer(MySensorsLight): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - - def turn_off(self, **kwargs): - """Turn the device off.""" - ret = self._turn_off_dimmer() - ret = self._turn_off_light( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) - self._turn_off_main( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) + if self.gateway.optimistic: + self.schedule_update_ha_state() def update(self): """Update the controller with the latest value from a sensor.""" - self._update_main() + super().update() self._update_light() self._update_dimmer() @@ -294,20 +188,12 @@ class MySensorsLightRGB(MySensorsLight): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w('%02x%02x%02x', **kwargs) - - def turn_off(self, **kwargs): - """Turn the device off.""" - ret = self._turn_off_rgb_or_w() - ret = self._turn_off_dimmer( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) - ret = self._turn_off_light( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) - self._turn_off_main( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) + if self.gateway.optimistic: + self.schedule_update_ha_state() def update(self): """Update the controller with the latest value from a sensor.""" - self._update_main() + super().update() self._update_light() self._update_dimmer() self._update_rgb_or_w() @@ -316,8 +202,12 @@ class MySensorsLightRGB(MySensorsLight): class MySensorsLightRGBW(MySensorsLightRGB): """RGBW child class to MySensorsLightRGB.""" + # pylint: disable=too-many-ancestors + def turn_on(self, **kwargs): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w('%02x%02x%02x%02x', **kwargs) + if self.gateway.optimistic: + self.schedule_update_ha_state() diff --git a/homeassistant/components/light/rflink.py b/homeassistant/components/light/rflink.py index 0b56f1de0ac..a05822ed8d1 100644 --- a/homeassistant/components/light/rflink.py +++ b/homeassistant/components/light/rflink.py @@ -48,7 +48,7 @@ PLATFORM_SCHEMA = vol.Schema({ vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_NOGROUP_ALIASES, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_FIRE_EVENT): cv.boolean, vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), vol.Optional(CONF_GROUP, default=True): cv.boolean, # deprecated config options @@ -123,7 +123,7 @@ def devices_from_config(domain_config, hass=None): _LOGGER.warning( "Hybrid type for %s not compatible with signal " "repetitions. Please set 'dimmable' or 'switchable' " - "type explicity in configuration", device_id) + "type explicitly in configuration", device_id) device = entity_class(device_id, hass, **device_config) devices.append(device) diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py index f831d6c04ce..9248b0131f1 100644 --- a/homeassistant/components/light/rfxtrx.py +++ b/homeassistant/components/light/rfxtrx.py @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the RFXtrx platform.""" import RFXtrx as rfxtrxmod - lights = rfxtrx.get_devices_from_config(config, RfxtrxLight, hass) + lights = rfxtrx.get_devices_from_config(config, RfxtrxLight) add_devices(lights) def light_update(event): @@ -32,7 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): not event.device.known_to_be_dimmable: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxLight, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxLight) if new_device: add_devices([new_device]) diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py index 98af61ffb7d..598cd22c986 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -56,7 +56,7 @@ class TellstickLight(TellstickDevice, Light): return kwargs.get(ATTR_BRIGHTNESS) def _parse_tellcore_data(self, tellcore_data): - """Turn the value recieved from tellcore into something useful.""" + """Turn the value received from tellcore into something useful.""" if tellcore_data is not None: brightness = int(tellcore_data) return brightness diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index 07703d6c067..26ae0517955 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -127,6 +127,11 @@ class LightTemplate(Light): """Return the brightness of the light.""" return self._brightness + @property + def name(self): + """Return the display name of this light.""" + return self._name + @property def supported_features(self): """Flag supported features.""" @@ -155,7 +160,7 @@ class LightTemplate(Light): @callback def template_light_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_light_startup(event): @@ -165,7 +170,7 @@ class LightTemplate(Light): async_track_state_change( self.hass, self._entities, template_light_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_light_startup) @@ -192,7 +197,7 @@ class LightTemplate(Light): self.hass.async_add_job(self._on_script.async_run()) if optimistic_set: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -200,7 +205,7 @@ class LightTemplate(Light): self.hass.async_add_job(self._off_script.async_run()) if self._template is None: self._state = False - 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/light/tradfri.py b/homeassistant/components/light/tradfri.py index b04640d7a8a..0f56982dae5 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -9,9 +9,10 @@ import logging from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) -from homeassistant.components.light import \ - PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA -from homeassistant.components.tradfri import KEY_GATEWAY, KEY_TRADFRI_GROUPS +from homeassistant.components.light import ( + PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA) +from homeassistant.components.tradfri import ( + KEY_GATEWAY, KEY_TRADFRI_GROUPS, KEY_API) from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) @@ -19,9 +20,7 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['tradfri'] PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA IKEA = 'IKEA of Sweden' -ALLOWED_TEMPERATURES = { - IKEA: {2200: 'efd275', 2700: 'f1e0b5', 4000: 'f5faf6'} -} +ALLOWED_TEMPERATURES = {IKEA} def setup_platform(hass, config, add_devices, discovery_info=None): @@ -30,24 +29,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return gateway_id = discovery_info['gateway'] + api = hass.data[KEY_API][gateway_id] gateway = hass.data[KEY_GATEWAY][gateway_id] - devices = gateway.get_devices() - lights = [dev for dev in devices if dev.has_light_control] - add_devices(Tradfri(light) for light in lights) + devices = api(gateway.get_devices()) + lights = [dev for dev in devices if api(dev).has_light_control] + add_devices(Tradfri(light, api) for light in lights) allow_tradfri_groups = hass.data[KEY_TRADFRI_GROUPS][gateway_id] if allow_tradfri_groups: - groups = gateway.get_groups() - add_devices(TradfriGroup(group) for group in groups) + groups = api(gateway.get_groups()) + add_devices(TradfriGroup(group, api) for group in groups) class TradfriGroup(Light): """The platform class required by hass.""" - def __init__(self, light): + def __init__(self, light, api): """Initialize a Group.""" - self._group = light - self._name = light.name + self._group = api(light) + self._api = api + self._name = self._group.name @property def supported_features(self): @@ -71,20 +72,20 @@ class TradfriGroup(Light): def turn_off(self, **kwargs): """Instruct the group lights to turn off.""" - self._group.set_state(0) + self._api(self._group.set_state(0)) def turn_on(self, **kwargs): """Instruct the group lights to turn on, or dim.""" if ATTR_BRIGHTNESS in kwargs: - self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS]) + self._api(self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS])) else: - self._group.set_state(1) + self._api(self._group.set_state(1)) def update(self): """Fetch new state data for this group.""" from pytradfri import RequestTimeout try: - self._group.update() + self._api(self._group.update()) except RequestTimeout: _LOGGER.warning("Tradfri update request timed out") @@ -92,14 +93,15 @@ class TradfriGroup(Light): class Tradfri(Light): """The platform class required by Home Asisstant.""" - def __init__(self, light): + def __init__(self, light, api): """Initialize a Light.""" - self._light = light + self._light = api(light) + self._api = api # Caching of LightControl and light object - self._light_control = light.light_control - self._light_data = light.light_control.lights[0] - self._name = light.name + self._light_control = self._light.light_control + self._light_data = self._light_control.lights[0] + self._name = self._light.name self._rgb_color = None self._features = SUPPORT_BRIGHTNESS @@ -109,8 +111,20 @@ class Tradfri(Light): else: self._features |= SUPPORT_RGB_COLOR - self._ok_temps = ALLOWED_TEMPERATURES.get( - self._light.device_info.manufacturer) + self._ok_temps = \ + self._light.device_info.manufacturer in ALLOWED_TEMPERATURES + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + from pytradfri.color import MAX_KELVIN_WS + return color_util.color_temperature_kelvin_to_mired(MAX_KELVIN_WS) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + from pytradfri.color import MIN_KELVIN_WS + return color_util.color_temperature_kelvin_to_mired(MIN_KELVIN_WS) @property def supported_features(self): @@ -135,20 +149,13 @@ class Tradfri(Light): @property def color_temp(self): """Return the CT color value in mireds.""" - if (self._light_data.hex_color is None or + if (self._light_data.kelvin_color is None or self.supported_features & SUPPORT_COLOR_TEMP == 0 or not self._ok_temps): return None - - kelvin = next(( - kelvin for kelvin, hex_color in self._ok_temps.items() - if hex_color == self._light_data.hex_color), None) - if kelvin is None: - _LOGGER.error( - "Unexpected color temperature found for %s: %s", - self.name, self._light_data.hex_color) - return - return color_util.color_temperature_kelvin_to_mired(kelvin) + return color_util.color_temperature_kelvin_to_mired( + self._light_data.kelvin_color + ) @property def rgb_color(self): @@ -157,7 +164,7 @@ class Tradfri(Light): def turn_off(self, **kwargs): """Instruct the light to turn off.""" - self._light_control.set_state(False) + self._api(self._light_control.set_state(False)) def turn_on(self, **kwargs): """ @@ -167,29 +174,27 @@ class Tradfri(Light): for ATTR_RGB_COLOR, this also supports Philips Hue bulbs. """ if ATTR_BRIGHTNESS in kwargs: - self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS]) + self._api(self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS])) else: - self._light_control.set_state(True) + self._api(self._light_control.set_state(True)) if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - self._light.light_control.set_hex_color( - color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR])) + self._api(self._light.light_control.set_rgb_color( + *kwargs[ATTR_RGB_COLOR])) elif ATTR_COLOR_TEMP in kwargs and \ self._light_data.hex_color is not None and self._ok_temps: kelvin = color_util.color_temperature_mired_to_kelvin( kwargs[ATTR_COLOR_TEMP]) - # find closest allowed kelvin temp from user input - kelvin = min(self._ok_temps.keys(), key=lambda x: abs(x - kelvin)) - self._light_control.set_hex_color(self._ok_temps[kelvin]) + self._api(self._light_control.set_kelvin_color(kelvin)) def update(self): """Fetch new state data for this light.""" from pytradfri import RequestTimeout try: - self._light.update() - except RequestTimeout: - _LOGGER.warning("Tradfri update request timed out") + self._api(self._light.update()) + except RequestTimeout as exception: + _LOGGER.warning("Tradfri update request timed out: %s", exception) # Handle Hue lights paired with the gateway # hex_color is 0 when bulb is unreachable diff --git a/homeassistant/components/light/xiaomi.py b/homeassistant/components/light/xiaomi_aqara.py similarity index 95% rename from homeassistant/components/light/xiaomi.py rename to homeassistant/components/light/xiaomi_aqara.py index d8a70b726f4..63770fbf9b7 100755 --- a/homeassistant/components/light/xiaomi.py +++ b/homeassistant/components/light/xiaomi_aqara.py @@ -2,7 +2,8 @@ import logging import struct import binascii -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, Light) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py new file mode 100644 index 00000000000..cebd1670c4a --- /dev/null +++ b/homeassistant/components/light/xiaomi_miio.py @@ -0,0 +1,227 @@ +""" +Support for Xiaomi Philips Lights (LED Ball & Ceil). + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/light.xiaomi_philipslight/ +""" +import asyncio +from functools import partial +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.light import ( + PLATFORM_SCHEMA, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, + ATTR_COLOR_TEMP, SUPPORT_COLOR_TEMP, Light, ) + +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ) +from homeassistant.exceptions import PlatformNotReady + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Xiaomi Philips Light' +PLATFORM = 'xiaomi_miio' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +REQUIREMENTS = ['python-mirobo==0.2.0'] + +# The light does not accept cct values < 1 +CCT_MIN = 1 +CCT_MAX = 100 + +SUCCESS = ['ok'] +ATTR_MODEL = 'model' + + +# pylint: disable=unused-argument +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the light from config.""" + from mirobo import Ceil, DeviceException + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {} + + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + + try: + light = Ceil(host, token) + device_info = light.info() + _LOGGER.info("%s %s %s initialized", + device_info.raw['model'], + device_info.raw['fw_ver'], + device_info.raw['hw_ver']) + + philips_light = XiaomiPhilipsLight(name, light, device_info) + hass.data[PLATFORM][host] = philips_light + except DeviceException: + raise PlatformNotReady + + async_add_devices([philips_light], update_before_add=True) + + +class XiaomiPhilipsLight(Light): + """Representation of a Xiaomi Philips Light.""" + + def __init__(self, name, light, device_info): + """Initialize the light device.""" + self._name = name + self._device_info = device_info + + self._brightness = None + self._color_temp = None + + self._light = light + self._state = None + self._state_attrs = { + ATTR_MODEL: self._device_info.raw['model'], + } + + @property + def should_poll(self): + """Poll the light.""" + return True + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def available(self): + """Return true when state is known.""" + return self._state is not None + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def color_temp(self): + """Return the color temperature.""" + return self._color_temp + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 175 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 333 + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + + @asyncio.coroutine + def _try_command(self, mask_error, func, *args, **kwargs): + """Call a light command handling error messages.""" + from mirobo import DeviceException + try: + result = yield from self.hass.async_add_job( + partial(func, *args, **kwargs)) + + _LOGGER.debug("Response received from light: %s", result) + + return result == SUCCESS + except DeviceException as exc: + _LOGGER.error(mask_error, exc) + return False + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = int(100 * brightness / 255) + + _LOGGER.debug( + "Setting brightness: %s %s%%", + self.brightness, percent_brightness) + + result = yield from self._try_command( + "Setting brightness failed: %s", + self._light.set_brightness, percent_brightness) + + if result: + self._brightness = brightness + + if ATTR_COLOR_TEMP in kwargs: + color_temp = kwargs[ATTR_COLOR_TEMP] + percent_color_temp = self.translate( + color_temp, self.max_mireds, + self.min_mireds, CCT_MIN, CCT_MAX) + + _LOGGER.debug( + "Setting color temperature: " + "%s mireds, %s%% cct", + color_temp, percent_color_temp) + + result = yield from self._try_command( + "Setting color temperature failed: %s cct", + self._light.set_color_temperature, percent_color_temp) + + if result: + self._color_temp = color_temp + + result = yield from self._try_command( + "Turning the light on failed.", self._light.on) + + if result: + self._state = True + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + result = yield from self._try_command( + "Turning the light off failed.", self._light.off) + + if result: + self._state = True + + @asyncio.coroutine + def async_update(self): + """Fetch state from the device.""" + from mirobo import DeviceException + try: + state = yield from self.hass.async_add_job(self._light.status) + _LOGGER.debug("Got new state: %s", state) + + self._state = state.is_on + self._brightness = int(255 * 0.01 * state.brightness) + self._color_temp = self.translate(state.color_temperature, + CCT_MIN, CCT_MAX, + self.max_mireds, self.min_mireds) + + except DeviceException as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) + + @staticmethod + def translate(value, left_min, left_max, right_min, right_max): + """Map a value from left span to right span.""" + left_span = left_max - left_min + right_span = right_max - right_min + value_scaled = float(value - left_min) / float(left_span) + return int(right_min + (value_scaled * right_span)) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 1f7ee2ba5f9..82436334072 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -23,7 +23,7 @@ from homeassistant.components.light import ( Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['yeelight==0.3.2'] +REQUIREMENTS = ['yeelight==0.3.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 2a3ce18d74e..a18fdc9dec6 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -27,8 +27,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): endpoint = discovery_info['endpoint'] try: - primaries = yield from endpoint.light_color['num_primaries'] - discovery_info['num_primaries'] = primaries + discovery_info['color_capabilities'] \ + = yield from endpoint.light_color['color_capabilities'] except (AttributeError, KeyError): pass @@ -54,11 +54,11 @@ class Light(zha.Entity, light.Light): self._supported_features |= light.SUPPORT_TRANSITION self._brightness = 0 if zcl_clusters.lighting.Color.cluster_id in self._in_clusters: - # Not sure all color lights necessarily support this directly - # Should we emulate it? - self._supported_features |= light.SUPPORT_COLOR_TEMP - # Silly heuristic, not sure if it works widely - if kwargs.get('num_primaries', 1) >= 3: + color_capabilities = kwargs.get('color_capabilities', 0x10) + if color_capabilities & 0x10: + self._supported_features |= light.SUPPORT_COLOR_TEMP + + if color_capabilities & 0x08: self._supported_features |= light.SUPPORT_XY_COLOR self._supported_features |= light.SUPPORT_RGB_COLOR self._xy_color = (1.0, 1.0) @@ -105,19 +105,19 @@ class Light(zha.Entity, light.Light): duration ) self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() return yield from self._endpoint.on_off.on() self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn the entity off.""" yield from self._endpoint.on_off.off() self._state = 0 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def brightness(self): diff --git a/homeassistant/components/lock/abode.py b/homeassistant/components/lock/abode.py new file mode 100644 index 00000000000..2d342326636 --- /dev/null +++ b/homeassistant/components/lock/abode.py @@ -0,0 +1,50 @@ +""" +This component provides HA lock support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.lock import LockDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode lock devices.""" + import abodepy.helpers.constants as CONST + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_LOCK): + if data.is_excluded(device): + continue + + devices.append(AbodeLock(data, device)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeLock(AbodeDevice, LockDevice): + """Representation of an Abode lock.""" + + def lock(self, **kwargs): + """Lock the device.""" + self._device.lock() + + def unlock(self, **kwargs): + """Unlock the device.""" + self._device.unlock() + + @property + def is_locked(self): + """Return true if device is on.""" + return self._device.is_locked diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index de14d21a09b..b2533145a20 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -93,7 +93,7 @@ class MqttLock(LockDevice): elif payload == self._payload_unlock: self._state = False - 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. @@ -134,7 +134,7 @@ class MqttLock(LockDevice): if self._optimistic: # Optimistically assume that switch 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_unlock(self, **kwargs): @@ -148,4 +148,4 @@ class MqttLock(LockDevice): if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/lock/nello.py b/homeassistant/components/lock/nello.py index 47a8e3146aa..04030c92425 100644 --- a/homeassistant/components/lock/nello.py +++ b/homeassistant/components/lock/nello.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME) -REQUIREMENTS = ['pynello==1.5'] +REQUIREMENTS = ['pynello==1.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index 04e9f458f9c..810ef5a2e5b 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -17,7 +17,7 @@ get_usercode: description: Node id of the lock example: 18 code_slot: - description: Code slot to retrive a code from + description: Code slot to retrieve a code from example: 1 nuki_lock_n_go: @@ -83,7 +83,7 @@ wink_set_lock_vacation_mode: description: Name of lock to unlock example: 'lock.front_door' enabled: - description: enable or disable. true or false. + description: enable or disable. true or false. example: true wink_set_lock_alarm_mode: @@ -94,7 +94,7 @@ wink_set_lock_alarm_mode: description: Name of lock to unlock example: 'lock.front_door' mode: - description: One of tamper, activity, or forced_entry + description: One of tamper, activity, or forced_entry example: tamper wink_set_lock_alarm_sensitivity: @@ -105,7 +105,7 @@ wink_set_lock_alarm_sensitivity: description: Name of lock to unlock example: 'lock.front_door' sensitivity: - description: One of low, medium_low, medium, medium_high, high + description: One of low, medium_low, medium, medium_high, high example: medium wink_set_lock_alarm_state: @@ -116,7 +116,7 @@ wink_set_lock_alarm_state: description: Name of lock to unlock example: 'lock.front_door' enabled: - description: enable or disable. true or false. + description: enable or disable. true or false. example: true wink_set_lock_beeper_state: @@ -127,6 +127,19 @@ wink_set_lock_beeper_state: description: Name of lock to unlock example: 'lock.front_door' enabled: - description: enable or disable. true or false. + description: enable or disable. true or false. example: true +wink_add_new_lock_key_code: + description: Add a new user key code. + + fields: + entity_id: + description: Name of lock to unlock + example: 'lock.front_door' + name: + description: name of the new key code. + example: Bob + code: + description: new key code, length must match length of other codes. Default length is 4. + example: 1234 diff --git a/homeassistant/components/lock/tesla.py b/homeassistant/components/lock/tesla.py new file mode 100644 index 00000000000..3e93e4787a0 --- /dev/null +++ b/homeassistant/components/lock/tesla.py @@ -0,0 +1,57 @@ +""" +Support for Tesla door locks. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.tesla/ +""" +import logging + +from homeassistant.components.lock import ENTITY_ID_FORMAT, LockDevice +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla lock platform.""" + devices = [TeslaLock(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['lock']] + add_devices(devices, True) + + +class TeslaLock(TeslaDevice, LockDevice): + """Representation of a Tesla door lock.""" + + def __init__(self, tesla_device, controller): + """Initialisation of the lock.""" + self._state = None + super().__init__(tesla_device, controller) + self._name = self.tesla_device.name + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + def lock(self, **kwargs): + """Send the lock command.""" + _LOGGER.debug("Locking doors for: %s", self._name) + self.tesla_device.lock() + self._state = STATE_LOCKED + + def unlock(self, **kwargs): + """Send the unlock command.""" + _LOGGER.debug("Unlocking doors for: %s", self._name) + self.tesla_device.unlock() + self._state = STATE_UNLOCKED + + @property + def is_locked(self): + """Get whether the lock is in locked state.""" + return self._state == STATE_LOCKED + + def update(self): + """Updating state of the lock.""" + _LOGGER.debug("Updating state for: %s", self._name) + self.tesla_device.update() + self._state = STATE_LOCKED if self.tesla_device.is_locked() \ + else STATE_UNLOCKED diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py index 020fc00ab9a..502592ac6f3 100644 --- a/homeassistant/components/lock/wink.py +++ b/homeassistant/components/lock/wink.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.lock import LockDevice from homeassistant.components.wink import WinkDevice, DOMAIN import homeassistant.helpers.config_validation as cv -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, ATTR_CODE from homeassistant.config import load_yaml_config_file DEPENDENCIES = ['wink'] @@ -25,10 +25,12 @@ SERVICE_SET_ALARM_MODE = 'wink_set_lock_alarm_mode' SERVICE_SET_ALARM_SENSITIVITY = 'wink_set_lock_alarm_sensitivity' SERVICE_SET_ALARM_STATE = 'wink_set_lock_alarm_state' SERVICE_SET_BEEPER_STATE = 'wink_set_lock_beeper_state' +SERVICE_ADD_KEY = 'wink_add_new_lock_key_code' ATTR_ENABLED = 'enabled' ATTR_SENSITIVITY = 'sensitivity' ATTR_MODE = 'mode' +ATTR_NAME = 'name' ALARM_SENSITIVITY_MAP = {"low": 0.2, "medium_low": 0.4, "medium": 0.6, "medium_high": 0.8, @@ -53,6 +55,12 @@ SET_ALARM_MODES_SCHEMA = vol.Schema({ vol.Required(ATTR_MODE): vol.In(ALARM_MODES_MAP) }) +ADD_KEY_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_CODE): cv.positive_int, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Wink platform.""" @@ -86,6 +94,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): lock.set_alarm_mode(service.data.get(ATTR_MODE)) elif service.service == SERVICE_SET_ALARM_SENSITIVITY: lock.set_alarm_sensitivity(service.data.get(ATTR_SENSITIVITY)) + elif service.service == SERVICE_ADD_KEY: + name = service.data.get(ATTR_NAME) + code = service.data.get(ATTR_CODE) + lock.add_new_key(code, name) descriptions = load_yaml_config_file( path.join(path.dirname(__file__), 'services.yaml')) @@ -115,6 +127,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): descriptions.get(SERVICE_SET_ALARM_SENSITIVITY), schema=SET_SENSITIVITY_SCHEMA) + hass.services.register(DOMAIN, SERVICE_ADD_KEY, + service_handle, + descriptions.get(SERVICE_ADD_KEY), + schema=ADD_KEY_SCHEMA) + class WinkLockDevice(WinkDevice, LockDevice): """Representation of a Wink lock.""" @@ -149,6 +166,10 @@ class WinkLockDevice(WinkDevice, LockDevice): """Set lock's beeper mode.""" self.wink.set_beeper_mode(enabled) + def add_new_key(self, code, name): + """Add a new user key code.""" + self.wink.add_new_key(code, name) + def set_alarm_sensitivity(self, sensitivity): """ Set lock's alarm sensitivity. @@ -176,14 +197,14 @@ class WinkLockDevice(WinkDevice, LockDevice): super_attrs = super().device_state_attributes sensitivity = dict_value_to_key(ALARM_SENSITIVITY_MAP, self.wink.alarm_sensitivity()) - super_attrs['alarm sensitivity'] = sensitivity - super_attrs['vacation mode'] = self.wink.vacation_mode_enabled() - super_attrs['beeper mode'] = self.wink.beeper_enabled() - super_attrs['auto lock'] = self.wink.auto_lock_enabled() + super_attrs['alarm_sensitivity'] = sensitivity + super_attrs['vacation_mode'] = self.wink.vacation_mode_enabled() + super_attrs['beeper_mode'] = self.wink.beeper_enabled() + super_attrs['auto_lock'] = self.wink.auto_lock_enabled() alarm_mode = dict_value_to_key(ALARM_MODES_MAP, self.wink.alarm_mode()) - super_attrs['alarm mode'] = alarm_mode - super_attrs['alarm enabled'] = self.wink.alarm_enabled() + super_attrs['alarm_mode'] = alarm_mode + super_attrs['alarm_enabled'] = self.wink.alarm_enabled() return super_attrs diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index dcb3347e919..8660546c910 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylutron-caseta==0.2.7'] +REQUIREMENTS = ['pylutron-caseta==0.2.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 9d731016035..21b2dc7279f 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -111,7 +111,7 @@ class MailboxEntity(Entity): @callback def _mailbox_updated(event): - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) hass.bus.async_listen(EVENT, _mailbox_updated) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index f1d6139ffb1..188330de1c6 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.8.18'] +REQUIREMENTS = ['youtube_dl==2017.9.24'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 870252cc55e..2ff957186ba 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -637,11 +637,11 @@ class MediaPlayerDevice(Entity): return self.hass.async_add_job(self.set_volume_level, volume) def media_play(self): - """Send play commmand.""" + """Send play command.""" raise NotImplementedError() def async_media_play(self): - """Send play commmand. + """Send play command. This method must be run in the event loop and returns a coroutine. """ diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index 3ecb1c0922e..c2c70984734 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -93,10 +93,11 @@ class AppleTvDevice(MediaPlayerDevice): if not self._power.turned_on: return STATE_OFF - if self._playing is not None: + if self._playing: from pyatv import const state = self._playing.play_state - if state == const.PLAY_STATE_NO_MEDIA or \ + if state == const.PLAY_STATE_IDLE or \ + state == const.PLAY_STATE_NO_MEDIA or \ state == const.PLAY_STATE_LOADING: return STATE_IDLE elif state == const.PLAY_STATE_PLAYING: @@ -112,7 +113,7 @@ class AppleTvDevice(MediaPlayerDevice): def playstatus_update(self, updater, playing): """Print what is currently playing when it changes.""" self._playing = playing - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def playstatus_error(self, updater, exception): @@ -126,12 +127,12 @@ class AppleTvDevice(MediaPlayerDevice): # implemented here later. updater.start(initial_delay=10) self._playing = None - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def media_content_type(self): """Content type of current playing media.""" - if self._playing is not None: + if self._playing: from pyatv import const media_type = self._playing.media_type if media_type == const.MEDIA_TYPE_VIDEO: @@ -144,13 +145,13 @@ class AppleTvDevice(MediaPlayerDevice): @property def media_duration(self): """Duration of current playing media in seconds.""" - if self._playing is not None: + if self._playing: return self._playing.total_time @property def media_position(self): """Position of current playing media in seconds.""" - if self._playing is not None: + if self._playing: return self._playing.position @property @@ -168,18 +169,23 @@ class AppleTvDevice(MediaPlayerDevice): @property def media_image_hash(self): """Hash value for media image.""" - if self._playing is not None and self.state != STATE_IDLE: + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: return self._playing.hash @asyncio.coroutine def async_get_media_image(self): """Fetch media image of current playing image.""" - return (yield from self.atv.metadata.artwork()), 'image/png' + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: + return (yield from self.atv.metadata.artwork()), 'image/png' + + return None, None @property def media_title(self): """Title of current playing media.""" - if self._playing is not None: + if self._playing: if self.state == STATE_IDLE: return 'Nothing playing' title = self._playing.title @@ -215,7 +221,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: state = self.state if state == STATE_PAUSED: return self.atv.remote_control.play() @@ -227,7 +233,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.play() def async_media_stop(self): @@ -235,7 +241,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.stop() def async_media_pause(self): @@ -243,7 +249,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.pause() def async_media_next_track(self): @@ -251,7 +257,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.next() def async_media_previous_track(self): @@ -259,7 +265,7 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.previous() def async_media_seek(self, position): @@ -267,5 +273,5 @@ class AppleTvDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.set_position(position) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 780bd0e31ad..2aebbac5043 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -287,7 +287,7 @@ class CastDevice(MediaPlayerDevice): self.cast.set_volume(volume) def media_play(self): - """Send play commmand.""" + """Send play command.""" self.cast.media_controller.play() def media_pause(self): diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 06f95a7d3a7..94339514712 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -17,15 +17,16 @@ from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_VOLUME_SET, SUPPORT_PLAY) from homeassistant.const import ( CONF_HOST, STATE_OFF, STATE_PLAYING, STATE_PAUSED, - CONF_NAME, STATE_ON, CONF_ZONE) + CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.5.2'] +REQUIREMENTS = ['denonavr==0.5.3'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = None DEFAULT_SHOW_SOURCES = False +DEFAULT_TIMEOUT = 2 CONF_SHOW_ALL_SOURCES = 'show_all_sources' CONF_ZONES = 'zones' CONF_VALID_ZONES = ['Zone2', 'Zone3'] @@ -51,7 +52,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES): cv.boolean, vol.Optional(CONF_ZONES): - vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]) + vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, }) NewHost = namedtuple('NewHost', ['host', 'name']) @@ -69,8 +71,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if cache is None: cache = hass.data[KEY_DENON_CACHE] = set() - # Get config option for show_all_sources + # Get config option for show_all_sources and timeout show_all_sources = config.get(CONF_SHOW_ALL_SOURCES) + timeout = config.get(CONF_TIMEOUT) # Get config option for additional zones zones = config.get(CONF_ZONES) @@ -103,14 +106,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for d_receiver in d_receivers: host = d_receiver["host"] name = d_receiver["friendlyName"] - new_hosts.append(NewHost(host=host, name=name)) + new_hosts.append( + NewHost(host=host, name=name)) for entry in new_hosts: # Check if host not in cache, append it and save for later # starting if entry.host not in cache: new_device = denonavr.DenonAVR( - entry.host, entry.name, show_all_sources, add_zones) + host=entry.host, name=entry.name, + show_all_inputs=show_all_sources, timeout=timeout, + add_zones=add_zones) for new_zone in new_device.zones.values(): receivers.append(DenonDevice(new_zone)) cache.add(host) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 599b8fbbd71..a10b5cd8a25 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -1,10 +1,11 @@ """ -Support for the DirecTV recievers. +Support for the DirecTV receivers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.directv/ """ import voluptuous as vol +import requests from homeassistant.components.media_player import ( MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, @@ -25,7 +26,7 @@ SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY -KNOWN_HOSTS = [] +DATA_DIRECTV = "data_directv" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -37,38 +38,51 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the DirecTV platform.""" + known_devices = hass.data.get(DATA_DIRECTV) + if not known_devices: + known_devices = [] hosts = [] - if discovery_info: - host = discovery_info.get('host') - - if host in KNOWN_HOSTS: - return - - hosts.append([ - 'DirecTV_' + discovery_info.get('serial', ''), - host, DEFAULT_PORT - ]) - - elif CONF_HOST in config: + if CONF_HOST in config: hosts.append([ config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT), config.get(CONF_DEVICE) ]) + elif discovery_info: + host = discovery_info.get('host') + name = 'DirecTV_' + discovery_info.get('serial', '') + + # attempt to discover additional RVU units + try: + resp = requests.get( + 'http://%s:%d/info/getLocations' % (host, DEFAULT_PORT)).json() + if "locations" in resp: + for loc in resp["locations"]: + if("locationName" in loc and "clientAddr" in loc + and loc["clientAddr"] not in known_devices): + hosts.append([str.title(loc["locationName"]), host, + DEFAULT_PORT, loc["clientAddr"]]) + + except requests.exceptions.RequestException: + # bail out and just go forward with uPnP data + if DEFAULT_DEVICE not in known_devices: + hosts.append([name, host, DEFAULT_PORT, DEFAULT_DEVICE]) + dtvs = [] for host in hosts: dtvs.append(DirecTvDevice(*host)) - KNOWN_HOSTS.append(host) + known_devices.append(host[-1]) add_devices(dtvs) + hass.data[DATA_DIRECTV] = known_devices return True class DirecTvDevice(MediaPlayerDevice): - """Representation of a DirecTV reciever on the network.""" + """Representation of a DirecTV receiver on the network.""" def __init__(self, name, host, port, device): """Initialize the device.""" diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index 8df6bc4fd1b..ebb8a670488 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -159,7 +159,7 @@ class EmbyDevice(MediaPlayerDevice): self.media_status_last_position = None self.media_status_received = None - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def hidden(self): diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index a51238e9aaf..00dd90938c8 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -325,7 +325,7 @@ class KodiDevice(MediaPlayerDevice): # If a new item is playing, force a complete refresh force_refresh = data['item']['id'] != self._item.get('id') - self.hass.async_add_job(self.async_update_ha_state(force_refresh)) + self.async_schedule_update_ha_state(force_refresh) @callback def async_on_stop(self, sender, data): @@ -337,14 +337,14 @@ class KodiDevice(MediaPlayerDevice): self._players = [] self._properties = {} self._item = {} - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def async_on_volume_changed(self, sender, data): """Handle the volume changes.""" self._app_properties['volume'] = data['volume'] self._app_properties['muted'] = data['muted'] - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def async_on_quit(self, sender, data): @@ -403,7 +403,7 @@ class KodiDevice(MediaPlayerDevice): # to reconnect on the next poll. pass # Update HA state after Kodi disconnects - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Create a task instead of adding a tracking job, since this task will # run until the websocket connection is closed. diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 55df1e367a4..44dd9a7ea29 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -11,12 +11,13 @@ import voluptuous as vol from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, - SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, - SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, SUPPORT_PLAY, MEDIA_TYPE_PLAYLIST, + SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_PLAY, + SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_PLAYLIST, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, SUPPORT_SHUFFLE_SET, - SUPPORT_SEEK, MediaPlayerDevice) + SUPPORT_SEEK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) from homeassistant.const import ( - STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING, + STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_PORT, CONF_PASSWORD, CONF_HOST, CONF_NAME) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -30,11 +31,11 @@ DEFAULT_PORT = 6600 PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=120) -SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ +SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_MUTE | \ SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE | \ SUPPORT_CLEAR_PLAYLIST | SUPPORT_SHUFFLE_SET | SUPPORT_SEEK | \ - SUPPORT_STOP + SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -74,6 +75,8 @@ class MpdDevice(MediaPlayerDevice): self._playlists = [] self._currentplaylist = None self._is_connected = False + self._muted = False + self._muted_volume = 0 # set up MPD client self._client = mpd.MPDClient() @@ -142,8 +145,15 @@ class MpdDevice(MediaPlayerDevice): return STATE_PLAYING elif self._status['state'] == 'pause': return STATE_PAUSED + elif self._status['state'] == 'stop': + return STATE_OFF - return STATE_ON + return STATE_OFF + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted @property def media_content_id(self): @@ -255,6 +265,15 @@ class MpdDevice(MediaPlayerDevice): """Service to send the MPD the command for previous track.""" self._client.previous() + def mute_volume(self, mute): + """Mute. Emulated with set_volume_level.""" + if mute is True: + self._muted_volume = self.volume_level + self.set_volume_level(0) + elif mute is False: + self.set_volume_level(self._muted_volume) + self._muted = mute + def play_media(self, media_type, media_id, **kwargs): """Send the media player the command for playing a playlist.""" _LOGGER.debug(str.format("Playing playlist: {0}", media_id)) @@ -282,6 +301,15 @@ class MpdDevice(MediaPlayerDevice): """Enable/disable shuffle mode.""" self._client.random(int(shuffle)) + def turn_off(self): + """Service to send the MPD the command to stop playing.""" + self._client.stop() + + def turn_on(self): + """Service to send the MPD the command to start playing.""" + self._client.play() + self._update_playlists(no_throttle=True) + def clear_playlist(self): """Clear players playlist.""" self._client.clear() diff --git a/homeassistant/components/media_player/openhome.py b/homeassistant/components/media_player/openhome.py index b2242bfecad..bca6f2ad770 100644 --- a/homeassistant/components/media_player/openhome.py +++ b/homeassistant/components/media_player/openhome.py @@ -124,7 +124,7 @@ class OpenhomeDevice(MediaPlayerDevice): self._device.Stop() def media_play(self): - """Send play commmand.""" + """Send play command.""" self._device.Play() def media_next_track(self): diff --git a/homeassistant/components/media_player/philips_js.py b/homeassistant/components/media_player/philips_js.py index da572896ee0..d8450d31ea4 100644 --- a/homeassistant/components/media_player/philips_js.py +++ b/homeassistant/components/media_player/philips_js.py @@ -151,11 +151,11 @@ class PhilipsTV(MediaPlayerDevice): self._state = STATE_OFF def media_previous_track(self): - """Send rewind commmand.""" + """Send rewind command.""" self._tv.sendKey('Previous') def media_next_track(self): - """Send fast forward commmand.""" + """Send fast forward command.""" self._tv.sendKey('Next') @property diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index a901cd1d569..54ec61b50f8 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -711,7 +711,7 @@ class PlexClient(MediaPlayerDevice): if ("127.0.0.1" in client.baseurl and client.machineIdentifier == self.device.machineIdentifier): # point controls to server since that's where the - # playback is occuring + # playback is occurring _LOGGER.debug( "Local client detected, redirecting controls to " "Plex server: %s", self.entity_id) diff --git a/homeassistant/components/media_player/russound_rio.py b/homeassistant/components/media_player/russound_rio.py index 743fc4e262d..31b04ceb3cd 100644 --- a/homeassistant/components/media_player/russound_rio.py +++ b/homeassistant/components/media_player/russound_rio.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_NAME, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['russound_rio==0.1.3'] +REQUIREMENTS = ['russound_rio==0.1.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/russound_rnet.py b/homeassistant/components/media_player/russound_rnet.py index 9ce3dcfc4f4..77a9939c36c 100644 --- a/homeassistant/components/media_player/russound_rnet.py +++ b/homeassistant/components/media_player/russound_rnet.py @@ -135,7 +135,7 @@ class RussoundRNETDevice(MediaPlayerDevice): def set_volume_level(self, volume): """Set volume level. Volume has a range (0..1). - Translate this to a range of (0..100) as expected expected + Translate this to a range of (0..100) as expected by _russ.set_volume() """ self._russ.set_volume('1', self._zone_id, volume * 100) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 2cf3617cc61..993863ea725 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -140,7 +140,7 @@ select_source: fields: entity_id: - description: Name(s) of entites to change source on + description: Name(s) of entities to change source on example: 'media_player.media_player.txnr535_0009b0d81f82' source: description: Name of the source to switch to. Platform dependent. @@ -151,7 +151,7 @@ clear_playlist: fields: entity_id: - description: Name(s) of entites to change source on + description: Name(s) of entities to change source on example: 'media_player.living_room_chromecast' shuffle_set: @@ -170,7 +170,7 @@ snapcast_snapshot: fields: entity_id: - description: Name(s) of entites that will be snapshotted. Platform dependent. + description: Name(s) of entities that will be snapshotted. Platform dependent. example: 'media_player.living_room' snapcast_restore: @@ -178,7 +178,7 @@ snapcast_restore: fields: entity_id: - description: Name(s) of entites that will be restored. Platform dependent. + description: Name(s) of entities that will be restored. Platform dependent. example: 'media_player.living_room' sonos_join: @@ -190,7 +190,7 @@ sonos_join: example: 'media_player.living_room_sonos' entity_id: - description: Name(s) of entites that will coordinate the grouping. Platform dependent. + description: Name(s) of entities that will coordinate the grouping. Platform dependent. example: 'media_player.living_room_sonos' sonos_unjoin: @@ -198,7 +198,7 @@ sonos_unjoin: fields: entity_id: - description: Name(s) of entites that will be unjoined from their group. Platform dependent. + description: Name(s) of entities that will be unjoined from their group. Platform dependent. example: 'media_player.living_room_sonos' sonos_snapshot: @@ -206,7 +206,7 @@ sonos_snapshot: fields: entity_id: - description: Name(s) of entites that will be snapshot. Platform dependent. + description: Name(s) of entities that will be snapshot. Platform dependent. example: 'media_player.living_room_sonos' with_group: @@ -218,7 +218,7 @@ sonos_restore: fields: entity_id: - description: Name(s) of entites that will be restored. Platform dependent. + description: Name(s) of entities that will be restored. Platform dependent. example: 'media_player.living_room_sonos' with_group: @@ -230,7 +230,7 @@ sonos_set_sleep_timer: fields: entity_id: - description: Name(s) of entites that will have a timer set. + description: Name(s) of entities that will have a timer set. example: 'media_player.living_room_sonos' sleep_time: description: Number of seconds to set the timer @@ -241,7 +241,7 @@ sonos_clear_sleep_timer: fields: entity_id: - description: Name(s) of entites that will have the timer cleared. + description: Name(s) of entities that will have the timer cleared. example: 'media_player.living_room_sonos' diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index 1715f0f1829..3f1607831e5 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -159,19 +159,19 @@ class SnapcastGroupDevice(MediaPlayerDevice): streams = self._group.streams_by_name() if source in streams: yield from self._group.set_stream(streams[source].identifier) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_mute_volume(self, mute): """Send the mute command.""" yield from self._group.set_muted(mute) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_volume_level(self, volume): """Set the volume level.""" yield from self._group.set_volume(round(volume * 100)) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() def snapshot(self): """Snapshot the group state.""" @@ -235,13 +235,13 @@ class SnapcastClientDevice(MediaPlayerDevice): def async_mute_volume(self, mute): """Send the mute command.""" yield from self._client.set_muted(mute) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_volume_level(self, volume): """Set the volume level.""" yield from self._client.set_volume(round(volume * 100)) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() def snapshot(self): """Snapshot the client state.""" diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 63d27299aa7..410728dafaa 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -322,6 +322,7 @@ class SonosDevice(MediaPlayerDevice): self._media_title = None self._media_radio_show = None self._media_next_title = None + self._available = True self._support_previous_track = False self._support_next_track = False self._support_play = False @@ -386,6 +387,11 @@ class SonosDevice(MediaPlayerDevice): """Return coordinator of this player.""" return self._coordinator + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + def _is_available(self): try: sock = socket.create_connection( @@ -416,11 +422,11 @@ class SonosDevice(MediaPlayerDevice): self._player.get_sonos_favorites()['favorites'] if self._last_avtransport_event: - is_available = True + self._available = True else: - is_available = self._is_available() + self._available = self._is_available() - if not is_available: + if not self._available: self._player_volume = None self._player_volume_muted = None self._status = 'OFF' @@ -897,7 +903,8 @@ class SonosDevice(MediaPlayerDevice): src = fav.pop() self._source_name = src['title'] - if 'object.container.playlistContainer' in src['meta']: + if ('object.container.playlistContainer' in src['meta'] or + 'object.container.album.musicAlbum' in src['meta']): self._replace_queue_with_playlist(src) self._player.play_from_queue(0) else: @@ -908,8 +915,8 @@ class SonosDevice(MediaPlayerDevice): """Replace queue with playlist represented by src. Playlists can't be played directly with the self._player.play_uri - API as they are actually composed of mulitple URLs. Until soco has - suppport for playing a playlist, we'll need to parse the playlist item + API as they are actually composed of multiple URLs. Until soco has + support for playing a playlist, we'll need to parse the playlist item and replace the current queue in order to play it. """ import soco @@ -1109,7 +1116,7 @@ class SonosDevice(MediaPlayerDevice): return ## - # old is allready master, rejoin + # old is already master, rejoin if old.coordinator.group.coordinator == old.coordinator: self._player.join(old.coordinator) return diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 239b13a6292..734285d918a 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -148,6 +148,10 @@ class SpotifyMediaPlayer(MediaPlayerDevice): new_token = \ self._oauth.refresh_access_token( self._token_info['refresh_token']) + # skip when refresh failed + if new_token is None: + return + self._token_info = new_token token_refreshed = True if self._player is None or token_refreshed: @@ -158,6 +162,12 @@ class SpotifyMediaPlayer(MediaPlayerDevice): def update(self): """Update state and attributes.""" self.refresh_spotify_instance() + + # Don't true update when token is expired + if self._oauth.is_token_expired(self._token_info): + _LOGGER.warning("Spotify failed to update, token expired.") + return + # Available devices player_devices = self._player.devices() if player_devices is not None: diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index daf874a31dd..9647f04f5c3 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -148,7 +148,7 @@ class UniversalMediaPlayer(MediaPlayerDevice): @callback def async_on_dependency_update(*_): """Update ha state when dependencies update.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) depend = copy(children) for entity in attributes.values(): @@ -422,12 +422,12 @@ class UniversalMediaPlayer(MediaPlayerDevice): """ return self._async_call_service(SERVICE_TURN_OFF, allow_override=True) - def async_mute_volume(self, is_volume_muted): + def async_mute_volume(self, mute): """Mute the volume. This method must be run in the event loop and returns a coroutine. """ - data = {ATTR_MEDIA_VOLUME_MUTED: is_volume_muted} + data = {ATTR_MEDIA_VOLUME_MUTED: mute} return self._async_call_service( SERVICE_VOLUME_MUTE, data, allow_override=True) @@ -441,7 +441,7 @@ class UniversalMediaPlayer(MediaPlayerDevice): SERVICE_VOLUME_SET, data, allow_override=True) def async_media_play(self): - """Send play commmand. + """Send play command. This method must be run in the event loop and returns a coroutine. """ diff --git a/homeassistant/components/media_player/vlc.py b/homeassistant/components/media_player/vlc.py index f77b06054e1..d3346495015 100644 --- a/homeassistant/components/media_player/vlc.py +++ b/homeassistant/components/media_player/vlc.py @@ -137,7 +137,7 @@ class VlcDevice(MediaPlayerDevice): self._volume = volume def media_play(self): - """Send play commmand.""" + """Send play command.""" self._vlc.play() self._state = STATE_PLAYING diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 65a999528c3..8df8ceb0a8e 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -19,10 +19,11 @@ from homeassistant.components.media_player import ( SUPPORT_SELECT_SOURCE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_HOST, CONF_MAC, CONF_CUSTOMIZE, CONF_TIMEOUT, STATE_OFF, + CONF_HOST, CONF_CUSTOMIZE, CONF_TIMEOUT, STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN, CONF_NAME, CONF_FILENAME) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.script import Script REQUIREMENTS = ['pylgtv==0.1.7', 'websockets==3.2', @@ -32,6 +33,7 @@ _CONFIGURING = {} # type: Dict[str, str] _LOGGER = logging.getLogger(__name__) CONF_SOURCES = 'sources' +CONF_ON_ACTION = 'turn_on_action' DEFAULT_NAME = 'LG webOS Smart TV' @@ -53,10 +55,10 @@ CUSTOMIZE_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_MAC): cv.string, vol.Optional(CONF_CUSTOMIZE, default={}): CUSTOMIZE_SCHEMA, vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string, vol.Optional(CONF_TIMEOUT, default=10): cv.positive_int, + vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, }) @@ -76,15 +78,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if host in _CONFIGURING: return - mac = config.get(CONF_MAC) name = config.get(CONF_NAME) customize = config.get(CONF_CUSTOMIZE) timeout = config.get(CONF_TIMEOUT) + turn_on_action = config.get(CONF_ON_ACTION) + config = hass.config.path(config.get(CONF_FILENAME)) - setup_tv(host, mac, name, customize, config, timeout, hass, add_devices) + + setup_tv(host, name, customize, config, timeout, hass, + add_devices, turn_on_action) -def setup_tv(host, mac, name, customize, config, timeout, hass, add_devices): +def setup_tv(host, name, customize, config, timeout, hass, + add_devices, turn_on_action): """Set up a LG WebOS TV based on host parameter.""" from pylgtv import WebOsClient from pylgtv import PyLGTVPairException @@ -108,7 +114,8 @@ def setup_tv(host, mac, name, customize, config, timeout, hass, add_devices): # Not registered, request configuration. _LOGGER.warning("LG webOS TV %s needs to be paired", host) request_configuration( - host, mac, name, customize, config, timeout, hass, add_devices) + host, name, customize, config, timeout, hass, + add_devices, turn_on_action) return # If we came here and configuring this host, mark as done. @@ -117,12 +124,13 @@ def setup_tv(host, mac, name, customize, config, timeout, hass, add_devices): configurator = hass.components.configurator configurator.request_done(request_id) - add_devices([LgWebOSDevice(host, mac, name, customize, config, timeout)], - True) + add_devices([LgWebOSDevice(host, name, customize, config, timeout, + hass, turn_on_action)], True) def request_configuration( - host, mac, name, customize, config, timeout, hass, add_devices): + host, name, customize, config, timeout, hass, + add_devices, turn_on_action): """Request configuration steps from the user.""" configurator = hass.components.configurator @@ -135,8 +143,8 @@ def request_configuration( # pylint: disable=unused-argument def lgtv_configuration_callback(data): """The actions to do when our configuration callback is called.""" - setup_tv(host, mac, name, customize, config, timeout, hass, - add_devices) + setup_tv(host, name, customize, config, timeout, hass, + add_devices, turn_on_action) _CONFIGURING[host] = configurator.request_config( name, lgtv_configuration_callback, @@ -149,13 +157,12 @@ def request_configuration( class LgWebOSDevice(MediaPlayerDevice): """Representation of a LG WebOS TV.""" - def __init__(self, host, mac, name, customize, config, timeout): + def __init__(self, host, name, customize, config, timeout, + hass, on_action): """Initialize the webos device.""" from pylgtv import WebOsClient - from wakeonlan import wol self._client = WebOsClient(host, config, timeout) - self._wol = wol - self._mac = mac + self._on_script = Script(hass, on_action) if on_action else None self._customize = customize self._name = name @@ -273,7 +280,7 @@ class LgWebOSDevice(MediaPlayerDevice): @property def supported_features(self): """Flag media player features that are supported.""" - if self._mac: + if self._on_script: return SUPPORT_WEBOSTV | SUPPORT_TURN_ON return SUPPORT_WEBOSTV @@ -289,8 +296,8 @@ class LgWebOSDevice(MediaPlayerDevice): def turn_on(self): """Turn on the media player.""" - if self._mac: - self._wol.send_magic_packet(self._mac) + if self._on_script: + self._on_script.run() def volume_up(self): """Volume up the media player.""" diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py index f2e64b1fb25..c413bfd3357 100644 --- a/homeassistant/components/media_player/yamaha.py +++ b/homeassistant/components/media_player/yamaha.py @@ -214,7 +214,7 @@ class YamahaDevice(MediaPlayerDevice): self._volume = (self._receiver.volume / 100) + 1 def media_play(self): - """Send play commmand.""" + """Send play command.""" self._call_playback_function(self._receiver.play, "play") def media_pause(self): diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py new file mode 100644 index 00000000000..88d17b4d627 --- /dev/null +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -0,0 +1,233 @@ +"""Example for configuration.yaml. + +media_player: + - platform: yamaha_musiccast + name: "Living Room" + host: 192.168.xxx.xx + port: 5005 + +""" + +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, + STATE_UNKNOWN, STATE_ON +) +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PLAY, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, + SUPPORT_SELECT_SOURCE, SUPPORT_STOP +) +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_FEATURES = ( + SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | + SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | + SUPPORT_SELECT_SOURCE +) + +REQUIREMENTS = ['pymusiccast==0.1.0'] + +DEFAULT_NAME = "Yamaha Receiver" +DEFAULT_PORT = 5005 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Yamaha MusicCast platform.""" + import pymusiccast + + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + + receiver = pymusiccast.McDevice(host, udp_port=port) + _LOGGER.debug("receiver: %s / Port: %d", receiver, port) + + add_devices([YamahaDevice(receiver, name)], True) + + +class YamahaDevice(MediaPlayerDevice): + """Representation of a Yamaha MusicCast device.""" + + def __init__(self, receiver, name): + """Initialize the Yamaha MusicCast device.""" + self._receiver = receiver + self._name = name + self.power = STATE_UNKNOWN + self.volume = 0 + self.volume_max = 0 + self.mute = False + self._source = None + self._source_list = [] + self.status = STATE_UNKNOWN + self.media_status = None + self._receiver.set_yamaha_device(self) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self.power == STATE_ON and self.status is not STATE_UNKNOWN: + return self.status + return self.power + + @property + def should_poll(self): + """Push an update after each command.""" + return True + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.mute + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self.volume + + @property + def supported_features(self): + """Flag of features that are supported.""" + return SUPPORTED_FEATURES + + @property + def source(self): + """Return the current input source.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @source_list.setter + def source_list(self, value): + """Set source_list attribute.""" + self._source_list = value + + @property + def media_content_type(self): + """Return the media content type.""" + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self.media_status.media_duration \ + if self.media_status else None + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self.media_status.media_image_url \ + if self.media_status else None + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self.media_status.media_artist if self.media_status else None + + @property + def media_album(self): + """Album of current playing media, music track only.""" + return self.media_status.media_album if self.media_status else None + + @property + def media_track(self): + """Track number of current playing media, music track only.""" + return self.media_status.media_track if self.media_status else None + + @property + def media_title(self): + """Title of current playing media.""" + return self.media_status.media_title if self.media_status else None + + def update(self): + """Get the latest details from the device.""" + _LOGGER.debug("update: %s", self.entity_id) + + # call from constructor setup_platform() + if not self.entity_id: + _LOGGER.debug("First run") + self._receiver.update_status(push=False) + # call from regular polling + else: + # update_status_timer was set before + if self._receiver.update_status_timer: + _LOGGER.debug( + "is_alive: %s", + self._receiver.update_status_timer.is_alive()) + # e.g. computer was suspended, while hass was running + if not self._receiver.update_status_timer.is_alive(): + _LOGGER.debug("Reinitializing") + self._receiver.update_status() + + def turn_on(self): + """Turn on specified media player or all.""" + _LOGGER.debug("Turn device: on") + self._receiver.set_power(True) + + def turn_off(self): + """Turn off specified media player or all.""" + _LOGGER.debug("Turn device: off") + self._receiver.set_power(False) + + def media_play(self): + """Send the media player the command for play/pause.""" + _LOGGER.debug("Play") + self._receiver.set_playback("play") + + def media_pause(self): + """Send the media player the command for pause.""" + _LOGGER.debug("Pause") + self._receiver.set_playback("pause") + + def media_stop(self): + """Send the media player the stop command.""" + _LOGGER.debug("Stop") + self._receiver.set_playback("stop") + + def media_previous_track(self): + """Send the media player the command for prev track.""" + _LOGGER.debug("Previous") + self._receiver.set_playback("previous") + + def media_next_track(self): + """Send the media player the command for next track.""" + _LOGGER.debug("Next") + self._receiver.set_playback("next") + + def mute_volume(self, mute): + """Send mute command.""" + _LOGGER.debug("Mute volume: %s", mute) + self._receiver.set_mute(mute) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + _LOGGER.debug("Volume level: %.2f / %d", + volume, volume * self.volume_max) + self._receiver.set_volume(volume * self.volume_max) + + def select_source(self, source): + """Send the media player the command to select input source.""" + _LOGGER.debug("select_source: %s", source) + self.status = STATE_UNKNOWN + self._receiver.set_input(source) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index f76c4e9d527..7140423633e 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -20,10 +20,11 @@ TOPIC_MATCHER = re.compile( r'(?P\w+)/(?P\w+)/' r'(?:(?P[a-zA-Z0-9_-]+)/)?(?P[a-zA-Z0-9_-]+)/config') -SUPPORTED_COMPONENTS = ['binary_sensor', 'light', 'sensor', 'switch'] +SUPPORTED_COMPONENTS = ['binary_sensor', 'fan', 'light', 'sensor', 'switch'] ALLOWED_PLATFORMS = { 'binary_sensor': ['mqtt'], + 'fan': ['mqtt'], 'light': ['mqtt', 'mqtt_json', 'mqtt_template'], 'sensor': ['mqtt'], 'switch': ['mqtt'], diff --git a/homeassistant/components/mqtt_statestream.py b/homeassistant/components/mqtt_statestream.py new file mode 100644 index 00000000000..2b68394b160 --- /dev/null +++ b/homeassistant/components/mqtt_statestream.py @@ -0,0 +1,45 @@ +""" +Publish simple item state changes via MQTT. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mqtt_statestream/ +""" +import asyncio + +import voluptuous as vol + +from homeassistant.const import MATCH_ALL +from homeassistant.core import callback +from homeassistant.components.mqtt import valid_publish_topic +from homeassistant.helpers.event import async_track_state_change + +CONF_BASE_TOPIC = 'base_topic' +DEPENDENCIES = ['mqtt'] +DOMAIN = 'mqtt_statestream' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_BASE_TOPIC): valid_publish_topic + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the MQTT state feed.""" + conf = config.get(DOMAIN, {}) + base_topic = conf.get(CONF_BASE_TOPIC) + if not base_topic.endswith('/'): + base_topic = base_topic + '/' + + @callback + def _state_publisher(entity_id, old_state, new_state): + if new_state is None: + return + payload = new_state.state + + topic = base_topic + entity_id.replace('.', '/') + '/state' + hass.components.mqtt.async_publish(topic, payload, 1, True) + + async_track_state_change(hass, MATCH_ALL, _state_publisher) + return True diff --git a/homeassistant/components/mycroft.py b/homeassistant/components/mycroft.py new file mode 100644 index 00000000000..834572bc551 --- /dev/null +++ b/homeassistant/components/mycroft.py @@ -0,0 +1,35 @@ +""" +Support for Mycroft AI. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mycroft +""" + +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['mycroftapi==2.0'] + +_LOGGER = logging.getLogger(__name__) + + +DOMAIN = 'mycroft' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Mycroft component.""" + hass.data[DOMAIN] = config[DOMAIN][CONF_HOST] + discovery.load_platform(hass, 'notify', DOMAIN, {}, config) + return True diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index ef863bfb34f..71be416c59c 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -4,30 +4,37 @@ Connect to a MySensors gateway via pymysensors API. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.mysensors/ """ +import asyncio +from collections import defaultdict import logging import os import socket import sys +from timeit import default_timer as timer import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.setup import setup_component from homeassistant.components.mqtt import ( valid_publish_topic, valid_subscribe_topic) from homeassistant.const import ( ATTR_BATTERY_LEVEL, CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON) from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component +from homeassistant.setup import setup_component -REQUIREMENTS = ['pymysensors==0.10.0'] +REQUIREMENTS = ['pymysensors==0.11.1'] _LOGGER = logging.getLogger(__name__) ATTR_CHILD_ID = 'child_id' ATTR_DESCRIPTION = 'description' ATTR_DEVICE = 'device' +ATTR_DEVICES = 'devices' ATTR_NODE_ID = 'node_id' CONF_BAUD_RATE = 'baud_rate' @@ -42,13 +49,21 @@ CONF_TOPIC_IN_PREFIX = 'topic_in_prefix' CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix' CONF_VERSION = 'version' +CONF_NODES = 'nodes' +CONF_NODE_NAME = 'name' + DEFAULT_BAUD_RATE = 115200 DEFAULT_TCP_PORT = 5003 -DEFAULT_VERSION = 1.4 +DEFAULT_VERSION = '1.4' DOMAIN = 'mysensors' MQTT_COMPONENT = 'mqtt' MYSENSORS_GATEWAYS = 'mysensors_gateways' +MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}' +PLATFORM = 'platform' +SCHEMA = 'schema' +SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}' +TYPE = 'type' def is_socket_address(value): @@ -120,6 +135,12 @@ def deprecated(key): return validator +NODE_SCHEMA = vol.Schema({ + cv.positive_int: { + vol.Required(CONF_NODE_NAME): cv.string + } +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema(vol.All(deprecated(CONF_DEBUG), { vol.Required(CONF_GATEWAYS): vol.All( @@ -139,16 +160,133 @@ CONFIG_SCHEMA = vol.Schema({ CONF_TOPIC_IN_PREFIX, default=''): valid_subscribe_topic, vol.Optional( CONF_TOPIC_OUT_PREFIX, default=''): valid_publish_topic, + vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, }] ), vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, vol.Optional(CONF_RETAIN, default=True): cv.boolean, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.Coerce(float), + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, })) }, extra=vol.ALLOW_EXTRA) +# mysensors const schemas +BINARY_SENSOR_SCHEMA = {PLATFORM: 'binary_sensor', TYPE: 'V_TRIPPED'} +CLIMATE_SCHEMA = {PLATFORM: 'climate', TYPE: 'V_HVAC_FLOW_STATE'} +LIGHT_DIMMER_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_DIMMER', + SCHEMA: {'V_DIMMER': cv.string, 'V_LIGHT': cv.string}} +LIGHT_PERCENTAGE_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_PERCENTAGE', + SCHEMA: {'V_PERCENTAGE': cv.string, 'V_STATUS': cv.string}} +LIGHT_RGB_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_RGB', SCHEMA: { + 'V_RGB': cv.string, 'V_STATUS': cv.string}} +LIGHT_RGBW_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_RGBW', SCHEMA: { + 'V_RGBW': cv.string, 'V_STATUS': cv.string}} +NOTIFY_SCHEMA = {PLATFORM: 'notify', TYPE: 'V_TEXT'} +DEVICE_TRACKER_SCHEMA = {PLATFORM: 'device_tracker', TYPE: 'V_POSITION'} +DUST_SCHEMA = [ + {PLATFORM: 'sensor', TYPE: 'V_DUST_LEVEL'}, + {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}] +SWITCH_LIGHT_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_LIGHT'} +SWITCH_STATUS_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_STATUS'} +MYSENSORS_CONST_SCHEMA = { + 'S_DOOR': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_MOTION': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SMOKE': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SPRINKLER': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_STATUS'}], + 'S_WATER_LEAK': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SOUND': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_VIBRATION': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_MOISTURE': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_HVAC': [CLIMATE_SCHEMA], + 'S_COVER': [ + {PLATFORM: 'cover', TYPE: 'V_DIMMER'}, + {PLATFORM: 'cover', TYPE: 'V_PERCENTAGE'}, + {PLATFORM: 'cover', TYPE: 'V_LIGHT'}, + {PLATFORM: 'cover', TYPE: 'V_STATUS'}], + 'S_DIMMER': [LIGHT_DIMMER_SCHEMA, LIGHT_PERCENTAGE_SCHEMA], + 'S_RGB_LIGHT': [LIGHT_RGB_SCHEMA], + 'S_RGBW_LIGHT': [LIGHT_RGBW_SCHEMA], + 'S_INFO': [NOTIFY_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_TEXT'}], + 'S_GPS': [ + DEVICE_TRACKER_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_POSITION'}], + 'S_TEMP': [{PLATFORM: 'sensor', TYPE: 'V_TEMP'}], + 'S_HUM': [{PLATFORM: 'sensor', TYPE: 'V_HUM'}], + 'S_BARO': [ + {PLATFORM: 'sensor', TYPE: 'V_PRESSURE'}, + {PLATFORM: 'sensor', TYPE: 'V_FORECAST'}], + 'S_WIND': [ + {PLATFORM: 'sensor', TYPE: 'V_WIND'}, + {PLATFORM: 'sensor', TYPE: 'V_GUST'}, + {PLATFORM: 'sensor', TYPE: 'V_DIRECTION'}], + 'S_RAIN': [ + {PLATFORM: 'sensor', TYPE: 'V_RAIN'}, + {PLATFORM: 'sensor', TYPE: 'V_RAINRATE'}], + 'S_UV': [{PLATFORM: 'sensor', TYPE: 'V_UV'}], + 'S_WEIGHT': [ + {PLATFORM: 'sensor', TYPE: 'V_WEIGHT'}, + {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], + 'S_POWER': [ + {PLATFORM: 'sensor', TYPE: 'V_WATT'}, + {PLATFORM: 'sensor', TYPE: 'V_KWH'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR'}, + {PLATFORM: 'sensor', TYPE: 'V_VA'}, + {PLATFORM: 'sensor', TYPE: 'V_POWER_FACTOR'}], + 'S_DISTANCE': [{PLATFORM: 'sensor', TYPE: 'V_DISTANCE'}], + 'S_LIGHT_LEVEL': [ + {PLATFORM: 'sensor', TYPE: 'V_LIGHT_LEVEL'}, + {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}], + 'S_IR': [ + {PLATFORM: 'sensor', TYPE: 'V_IR_RECEIVE'}, + {PLATFORM: 'switch', TYPE: 'V_IR_SEND', + SCHEMA: {'V_IR_SEND': cv.string, 'V_LIGHT': cv.string}}], + 'S_WATER': [ + {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, + {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], + 'S_CUSTOM': [ + {PLATFORM: 'sensor', TYPE: 'V_VAR1'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR2'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR3'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR4'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR5'}, + {PLATFORM: 'sensor', TYPE: 'V_CUSTOM'}], + 'S_SCENE_CONTROLLER': [ + {PLATFORM: 'sensor', TYPE: 'V_SCENE_ON'}, + {PLATFORM: 'sensor', TYPE: 'V_SCENE_OFF'}], + 'S_COLOR_SENSOR': [{PLATFORM: 'sensor', TYPE: 'V_RGB'}], + 'S_MULTIMETER': [ + {PLATFORM: 'sensor', TYPE: 'V_VOLTAGE'}, + {PLATFORM: 'sensor', TYPE: 'V_CURRENT'}, + {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], + 'S_GAS': [ + {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, + {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], + 'S_WATER_QUALITY': [ + {PLATFORM: 'sensor', TYPE: 'V_TEMP'}, + {PLATFORM: 'sensor', TYPE: 'V_PH'}, + {PLATFORM: 'sensor', TYPE: 'V_ORP'}, + {PLATFORM: 'sensor', TYPE: 'V_EC'}, + {PLATFORM: 'switch', TYPE: 'V_STATUS'}], + 'S_AIR_QUALITY': DUST_SCHEMA, + 'S_DUST': DUST_SCHEMA, + 'S_LIGHT': [SWITCH_LIGHT_SCHEMA], + 'S_BINARY': [SWITCH_STATUS_SCHEMA], + 'S_LOCK': [{PLATFORM: 'switch', TYPE: 'V_LOCK_STATUS'}], +} + + def setup(hass, config): """Set up the MySensors component.""" import mysensors.mysensors as mysensors @@ -197,20 +335,14 @@ def setup(hass, config): # invalid ip address return gateway.metric = hass.config.units.is_metric - optimistic = config[DOMAIN].get(CONF_OPTIMISTIC) - gateway = GatewayWrapper(gateway, optimistic, device) - # pylint: disable=attribute-defined-outside-init - gateway.event_callback = gateway.callback_factory() + gateway.optimistic = config[DOMAIN].get(CONF_OPTIMISTIC) + gateway.device = device + gateway.event_callback = gw_callback_factory(hass) def gw_start(event): """Trigger to start of the gateway and any persistence.""" if persistence: - for node_id in gateway.sensors: - node = gateway.sensors[node_id] - for child_id in node.children: - msg = mysensors.Message().modify( - node_id=node_id, child_id=child_id) - gateway.event_callback(msg) + discover_persistent_devices(hass, gateway) gateway.start() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, lambda event: gateway.stop()) @@ -219,15 +351,8 @@ def setup(hass, config): return gateway - gateways = hass.data.get(MYSENSORS_GATEWAYS) - if gateways is not None: - _LOGGER.error( - "%s already exists in %s, will not setup %s component", - MYSENSORS_GATEWAYS, hass.data, DOMAIN) - return False - # Setup all devices from config - gateways = [] + gateways = {} conf_gateways = config[DOMAIN][CONF_GATEWAYS] for index, gway in enumerate(conf_gateways): @@ -243,7 +368,8 @@ def setup(hass, config): device, persistence_file, baud_rate, tcp_port, in_prefix, out_prefix) if ready_gateway is not None: - gateways.append(ready_gateway) + ready_gateway.nodes_config = gway.get(CONF_NODES) + gateways[id(ready_gateway)] = ready_gateway if not gateways: _LOGGER.error( @@ -252,115 +378,194 @@ def setup(hass, config): hass.data[MYSENSORS_GATEWAYS] = gateways - for component in ['sensor', 'switch', 'light', 'binary_sensor', 'climate', - 'cover']: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - discovery.load_platform( - hass, 'device_tracker', DOMAIN, {}, config) - - discovery.load_platform( - hass, 'notify', DOMAIN, {CONF_NAME: DOMAIN}, config) - return True -def pf_callback_factory(map_sv_types, devices, entity_class, add_devices=None): - """Return a new callback for the platform.""" - def mysensors_callback(gateway, msg): - """Run when a message from the gateway arrives.""" - if gateway.sensors[msg.node_id].sketch_name is None: - _LOGGER.debug("No sketch_name: node %s", msg.node_id) - return - child = gateway.sensors[msg.node_id].children.get(msg.child_id) +def validate_child(gateway, node_id, child): + """Validate that a child has the correct values according to schema. + + Return a dict of platform with a list of device ids for validated devices. + """ + validated = defaultdict(list) + + if not child.values: + _LOGGER.debug( + "No child values for node %s child %s", node_id, child.id) + return validated + if gateway.sensors[node_id].sketch_name is None: + _LOGGER.debug("Node %s is missing sketch name", node_id) + return validated + pres = gateway.const.Presentation + set_req = gateway.const.SetReq + s_name = next( + (member.name for member in pres if member.value == child.type), None) + if s_name not in MYSENSORS_CONST_SCHEMA: + _LOGGER.warning("Child type %s is not supported", s_name) + return validated + child_schemas = MYSENSORS_CONST_SCHEMA[s_name] + + def msg(name): + """Return a message for an invalid schema.""" + return "{} requires value_type {}".format( + pres(child.type).name, set_req[name].name) + + for schema in child_schemas: + platform = schema[PLATFORM] + v_name = schema[TYPE] + value_type = next( + (member.value for member in set_req if member.name == v_name), + None) + if value_type is None: + continue + _child_schema = child.get_schema(gateway.protocol_version) + vol_schema = _child_schema.extend( + {vol.Required(set_req[key].value, msg=msg(key)): + _child_schema.schema.get(set_req[key].value, val) + for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, + extra=vol.ALLOW_EXTRA) + try: + vol_schema(child.values) + except vol.Invalid as exc: + level = (logging.WARNING if value_type in child.values + else logging.DEBUG) + _LOGGER.log( + level, + "Invalid values: %s: %s platform: node %s child %s: %s", + child.values, platform, node_id, child.id, exc) + continue + dev_id = id(gateway), node_id, child.id, value_type + validated[platform].append(dev_id) + return validated + + +def discover_mysensors_platform(hass, platform, new_devices): + """Discover a mysensors platform.""" + discovery.load_platform( + hass, platform, DOMAIN, {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}) + + +def discover_persistent_devices(hass, gateway): + """Discover platforms for devices loaded via persistence file.""" + new_devices = defaultdict(list) + for node_id in gateway.sensors: + node = gateway.sensors[node_id] + for child in node.children.values(): + validated = validate_child(gateway, node_id, child) + for platform, dev_ids in validated.items(): + new_devices[platform].extend(dev_ids) + for platform, dev_ids in new_devices.items(): + discover_mysensors_platform(hass, platform, dev_ids) + + +def get_mysensors_devices(hass, domain): + """Return mysensors devices for a platform.""" + if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data: + hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} + return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] + + +def gw_callback_factory(hass): + """Return a new callback for the gateway.""" + def mysensors_callback(msg): + """Default callback for a mysensors gateway.""" + start = timer() + _LOGGER.debug( + "Node update: node %s child %s", msg.node_id, msg.child_id) + + child = msg.gateway.sensors[msg.node_id].children.get(msg.child_id) if child is None: + _LOGGER.debug( + "Not a child update for node %s", msg.node_id) return - for value_type in child.values: - key = msg.node_id, child.id, value_type - if child.type not in map_sv_types or \ - value_type not in map_sv_types[child.type]: - continue - if key in devices: - if add_devices: - devices[key].schedule_update_ha_state(True) + + signals = [] + + # Update all platforms for the device via dispatcher. + # Add/update entity if schema validates to true. + validated = validate_child(msg.gateway, msg.node_id, child) + for platform, dev_ids in validated.items(): + devices = get_mysensors_devices(hass, platform) + new_dev_ids = [] + for dev_id in dev_ids: + if dev_id in devices: + signals.append(SIGNAL_CALLBACK.format(*dev_id)) else: - devices[key].update() - continue - name = '{} {} {}'.format( - gateway.sensors[msg.node_id].sketch_name, msg.node_id, - child.id) - if isinstance(entity_class, dict): - device_class = entity_class[child.type] - else: - device_class = entity_class - devices[key] = device_class( - gateway, msg.node_id, child.id, name, value_type) - if add_devices: - _LOGGER.info("Adding new devices: %s", [devices[key]]) - add_devices([devices[key]], True) - else: - devices[key].update() + new_dev_ids.append(dev_id) + if new_dev_ids: + discover_mysensors_platform(hass, platform, new_dev_ids) + for signal in set(signals): + # Only one signal per device is needed. + # A device can have multiple platforms, ie multiple schemas. + # FOR LATER: Add timer to not signal if another update comes in. + dispatcher_send(hass, signal) + end = timer() + if end - start > 0.1: + _LOGGER.debug( + "Callback for node %s child %s took %.3f seconds", + msg.node_id, msg.child_id, end - start) return mysensors_callback -class GatewayWrapper(object): - """Gateway wrapper class.""" - - def __init__(self, gateway, optimistic, device): - """Set up the class attributes on instantiation. - - Args: - gateway (mysensors.SerialGateway): Gateway to wrap. - optimistic (bool): Send values to actuators without feedback state. - device (str): Path to serial port, ip adress or mqtt. - - Attributes: - _wrapped_gateway (mysensors.SerialGateway): Wrapped gateway. - platform_callbacks (list): Callback functions, one per platform. - optimistic (bool): Send values to actuators without feedback state. - device (str): Device configured as gateway. - __initialised (bool): True if GatewayWrapper is initialised. - - """ - self._wrapped_gateway = gateway - self.platform_callbacks = [] - self.optimistic = optimistic - self.device = device - self.__initialised = True - - def __getattr__(self, name): - """See if this object has attribute name.""" - # Do not use hasattr, it goes into infinite recurrsion - if name in self.__dict__: - # This object has the attribute. - return getattr(self, name) - # The wrapped object has the attribute. - return getattr(self._wrapped_gateway, name) - - def __setattr__(self, name, value): - """See if this object has attribute name then set to value.""" - if '_GatewayWrapper__initialised' not in self.__dict__: - return object.__setattr__(self, name, value) - elif name in self.__dict__: - object.__setattr__(self, name, value) - else: - object.__setattr__(self._wrapped_gateway, name, value) - - def callback_factory(self): - """Return a new callback function.""" - def node_update(msg): - """Handle node updates from the MySensors gateway.""" - _LOGGER.debug( - "Update: node %s, child %s sub_type %s", - msg.node_id, msg.child_id, msg.sub_type) - for callback in self.platform_callbacks: - callback(self, msg) - - return node_update +def get_mysensors_name(gateway, node_id, child_id): + """Return a name for a node child.""" + node_name = '{} {}'.format( + gateway.sensors[node_id].sketch_name, node_id) + node_name = next( + (node[CONF_NODE_NAME] for conf_id, node in gateway.nodes_config.items() + if node.get(CONF_NODE_NAME) is not None and conf_id == node_id), + node_name) + return '{} {}'.format(node_name, child_id) -class MySensorsDeviceEntity(object): - """Representation of a MySensors entity.""" +def get_mysensors_gateway(hass, gateway_id): + """Return gateway.""" + if MYSENSORS_GATEWAYS not in hass.data: + hass.data[MYSENSORS_GATEWAYS] = {} + gateways = hass.data.get(MYSENSORS_GATEWAYS) + return gateways.get(gateway_id) + + +def setup_mysensors_platform( + hass, domain, discovery_info, device_class, device_args=None, + add_devices=None): + """Set up a mysensors platform.""" + # Only act if called via mysensors by discovery event. + # Otherwise gateway is not setup. + if not discovery_info: + return + if device_args is None: + device_args = () + new_devices = [] + new_dev_ids = discovery_info[ATTR_DEVICES] + for dev_id in new_dev_ids: + devices = get_mysensors_devices(hass, domain) + if dev_id in devices: + continue + gateway_id, node_id, child_id, value_type = dev_id + gateway = get_mysensors_gateway(hass, gateway_id) + if not gateway: + continue + device_class_copy = device_class + if isinstance(device_class, dict): + child = gateway.sensors[node_id].children[child_id] + s_type = gateway.const.Presentation(child.type).name + device_class_copy = device_class[s_type] + name = get_mysensors_name(gateway, node_id, child_id) + + # python 3.4 cannot unpack inside tuple, but combining tuples works + args_copy = device_args + ( + gateway, node_id, child_id, name, value_type) + devices[dev_id] = device_class_copy(*args_copy) + new_devices.append(devices[dev_id]) + if new_devices: + _LOGGER.info("Adding new devices: %s", new_devices) + if add_devices is not None: + add_devices(new_devices, True) + return new_devices + + +class MySensorsDevice(object): + """Representation of a MySensors device.""" def __init__(self, gateway, node_id, child_id, name, value_type): """Set up the MySensors device.""" @@ -373,11 +578,6 @@ class MySensorsDeviceEntity(object): self.child_type = child.type self._values = {} - @property - def should_poll(self): - """Mysensor gateway pushes its state to HA.""" - return False - @property def name(self): """Return the name of this entity.""" @@ -399,18 +599,9 @@ class MySensorsDeviceEntity(object): set_req = self.gateway.const.SetReq for value_type, value in self._values.items(): - try: - attr[set_req(value_type).name] = value - except ValueError: - _LOGGER.error("Value_type %s is not valid for mysensors " - "version %s", value_type, - self.gateway.protocol_version) - return attr + attr[set_req(value_type).name] = value - @property - def available(self): - """Return true if entity is available.""" - return self.value_type in self._values + return attr def update(self): """Update the controller with the latest value from a sensor.""" @@ -419,7 +610,8 @@ class MySensorsDeviceEntity(object): set_req = self.gateway.const.SetReq for value_type, value in child.values.items(): _LOGGER.debug( - "%s: value_type %s, value = %s", self._name, value_type, value) + "Entity update: %s: value_type %s, value = %s", + self._name, value_type, value) if value_type in (set_req.V_ARMED, set_req.V_LIGHT, set_req.V_LOCK_STATUS, set_req.V_TRIPPED): self._values[value_type] = ( @@ -428,3 +620,29 @@ class MySensorsDeviceEntity(object): self._values[value_type] = int(value) else: self._values[value_type] = value + + +class MySensorsEntity(MySensorsDevice, Entity): + """Representation of a MySensors entity.""" + + @property + def should_poll(self): + """Mysensor gateway pushes its state to HA.""" + return False + + @property + def available(self): + """Return true if entity is available.""" + return self.value_type in self._values + + def _async_update_callback(self): + """Update the entity.""" + self.async_schedule_update_ha_state(True) + + @asyncio.coroutine + def async_added_to_hass(self): + """Register update callback.""" + dev_id = id(self.gateway), self.node_id, self.child_id, self.value_type + async_dispatcher_connect( + self.hass, SIGNAL_CALLBACK.format(*dev_id), + self._async_update_callback) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 1c17d1a795a..9496ff1d596 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -82,8 +82,6 @@ def async_setup(hass, config): """Set up a notify platform.""" if p_config is None: p_config = {} - if discovery_info is None: - discovery_info = {} platform = yield from async_prepare_setup_platform( hass, config, DOMAIN, p_type) @@ -105,8 +103,12 @@ def async_setup(hass, config): raise HomeAssistantError("Invalid notify platform.") if notify_service is None: - _LOGGER.error( - "Failed to initialize notification service %s", p_type) + # Platforms can decide not to create a service based + # on discovery data. + if discovery_info is None: + _LOGGER.error( + "Failed to initialize notification service %s", + p_type) return except Exception: # pylint: disable=broad-except @@ -115,6 +117,9 @@ def async_setup(hass, config): notify_service.hass = hass + if discovery_info is None: + discovery_info = {} + @asyncio.coroutine def async_notify_message(service): """Handle sending notification message service calls.""" diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index 136d5300183..250ef5c50c8 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -111,7 +111,7 @@ class ApnsDevice(object): return self.device_disabled def disable(self): - """Disable the device from recieving notifications.""" + """Disable the device from receiving notifications.""" self.device_disabled = True def __eq__(self, other): diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index a4ce304167f..90212bca025 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['discord.py==0.16.10'] +REQUIREMENTS = ['discord.py==0.16.11'] CONF_TOKEN = 'token' diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 7151b418248..6b1cdf814fa 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -25,7 +25,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.frontend import add_manifest_json_key from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['pywebpush==1.0.6', 'PyJWT==1.5.2'] +REQUIREMENTS = ['pywebpush==1.1.0', 'PyJWT==1.5.3'] DEPENDENCIES = ['frontend'] diff --git a/homeassistant/components/notify/knx.py b/homeassistant/components/notify/knx.py new file mode 100644 index 00000000000..c5dbcb0d4ad --- /dev/null +++ b/homeassistant/components/notify/knx.py @@ -0,0 +1,99 @@ +""" +KNX/IP notification service. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/notify.knx/ +""" +import asyncio +import voluptuous as vol + +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.notify import PLATFORM_SCHEMA, \ + BaseNotificationService +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +DEFAULT_NAME = 'KNX Notify' +DEPENDENCIES = ['knx'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +@asyncio.coroutine +def async_get_service(hass, config, discovery_info=None): + """Get the KNX notification service.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + return async_get_service_discovery(hass, discovery_info) \ + if discovery_info is not None else \ + async_get_service_config(hass, config) + + +@callback +def async_get_service_discovery(hass, discovery_info): + """Set up notifications for KNX platform configured via xknx.yaml.""" + notification_devices = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + notification_devices.append(device) + return \ + KNXNotificationService(hass, notification_devices) \ + if notification_devices else \ + None + + +@callback +def async_get_service_config(hass, config): + """Set up notification for KNX platform configured within plattform.""" + import xknx + notification = xknx.devices.Notification( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(notification) + return KNXNotificationService(hass, [notification, ]) + + +class KNXNotificationService(BaseNotificationService): + """Implement demo notification service.""" + + def __init__(self, hass, devices): + """Initialize the service.""" + self.hass = hass + self.devices = devices + + @property + def targets(self): + """Return a dictionary of registered targets.""" + ret = {} + for device in self.devices: + ret[device.name] = device.name + return ret + + @asyncio.coroutine + def async_send_message(self, message="", **kwargs): + """Send a notification to knx bus.""" + if "target" in kwargs: + yield from self._async_send_to_device(message, kwargs["target"]) + else: + yield from self._async_send_to_all_devices(message) + + @asyncio.coroutine + def _async_send_to_all_devices(self, message): + """Send a notification to knx bus to all connected devices.""" + for device in self.devices: + yield from device.set(message) + + @asyncio.coroutine + def _async_send_to_device(self, message, names): + """Send a notification to knx bus to device with given names.""" + for device in self.devices: + if device.name in names: + yield from device.set(message) diff --git a/homeassistant/components/notify/kodi.py b/homeassistant/components/notify/kodi.py index eda01c13086..05f4c5d17f3 100644 --- a/homeassistant/components/notify/kodi.py +++ b/homeassistant/components/notify/kodi.py @@ -53,7 +53,7 @@ def async_get_service(hass, config, discovery_info=None): if host.startswith('http://') or host.startswith('https://'): host = host.lstrip('http://').lstrip('https://') _LOGGER.warning( - "Kodi host name should no longer conatin http:// See updated " + "Kodi host name should no longer contain http:// See updated " "definitions here: " "https://home-assistant.io/components/media_player.kodi/") diff --git a/homeassistant/components/notify/mycroft.py b/homeassistant/components/notify/mycroft.py new file mode 100644 index 00000000000..1fd22c5c42b --- /dev/null +++ b/homeassistant/components/notify/mycroft.py @@ -0,0 +1,40 @@ +""" +Mycroft AI notification platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.mycroft/ +""" +import logging + + +from homeassistant.components.notify import BaseNotificationService + +DEPENDENCIES = ['mycroft'] + + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config, discovery_info=None): + """Get the Mycroft notification service.""" + return MycroftNotificationService( + hass.data['mycroft']) + + +class MycroftNotificationService(BaseNotificationService): + """The Mycroft Notification Service.""" + + def __init__(self, mycroft_ip): + """Initialize the service.""" + self.mycroft_ip = mycroft_ip + + def send_message(self, message="", **kwargs): + """Send a message mycroft to speak on instance.""" + from mycroftapi import MycroftAPI + + text = message + mycroft = MycroftAPI(self.mycroft_ip) + if mycroft is not None: + mycroft.speak_text(text) + else: + _LOGGER.log("Could not reach this instance of mycroft") diff --git a/homeassistant/components/notify/mysensors.py b/homeassistant/components/notify/mysensors.py index d9576767f25..8ae697048f5 100644 --- a/homeassistant/components/notify/mysensors.py +++ b/homeassistant/components/notify/mysensors.py @@ -6,35 +6,19 @@ https://home-assistant.io/components/notify.mysensors/ """ from homeassistant.components import mysensors from homeassistant.components.notify import ( - ATTR_TARGET, BaseNotificationService) + ATTR_TARGET, DOMAIN, BaseNotificationService) def get_service(hass, config, discovery_info=None): """Get the MySensors notification service.""" - if discovery_info is None: + new_devices = mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsNotificationDevice) + if not new_devices: return - platform_devices = [] - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - if float(gateway.protocol_version) < 2.0: - continue - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_INFO: [set_req.V_TEXT], - } - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsNotificationDevice)) - platform_devices.append(devices) - - return MySensorsNotificationService(platform_devices) + return MySensorsNotificationService(hass) -class MySensorsNotificationDevice(mysensors.MySensorsDeviceEntity): +class MySensorsNotificationDevice(mysensors.MySensorsDevice): """Represent a MySensors Notification device.""" def send_msg(self, msg): @@ -44,24 +28,25 @@ class MySensorsNotificationDevice(mysensors.MySensorsDeviceEntity): self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, sub_msg) + def __repr__(self): + """Return the representation.""" + return "".format(self.name) + class MySensorsNotificationService(BaseNotificationService): - """Implement MySensors notification service.""" + """Implement a MySensors notification service.""" # pylint: disable=too-few-public-methods - def __init__(self, platform_devices): + def __init__(self, hass): """Initialize the service.""" - self.platform_devices = platform_devices + self.devices = mysensors.get_mysensors_devices(hass, DOMAIN) def send_message(self, message="", **kwargs): """Send a message to a user.""" target_devices = kwargs.get(ATTR_TARGET) - devices = [] - for gw_devs in self.platform_devices: - for device in gw_devs.values(): - if target_devices is None or device.name in target_devices: - devices.append(device) + devices = [device for device in self.devices.values() + if target_devices is None or device.name in target_devices] for device in devices: device.send_msg(message) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index bbc7c18ffab..d8b67413528 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.pushbullet/ """ import logging +import mimetypes import voluptuous as vol @@ -20,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_URL = 'url' ATTR_FILE = 'file' +ATTR_FILE_URL = 'file_url' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, @@ -80,24 +82,11 @@ class PushBulletNotificationService(BaseNotificationService): targets = kwargs.get(ATTR_TARGET) title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) - url = None - filepath = None - if data: - url = data.get(ATTR_URL, None) - filepath = data.get(ATTR_FILE, None) refreshed = False if not targets: # Backward compatibility, notify all devices in own account - if url: - self.pushbullet.push_link(title, url, body=message) - elif filepath and self.hass.config.is_allowed_path(filepath): - with open(filepath, "rb") as fileh: - filedata = self.pushbullet.upload_file(fileh, filepath) - self.pushbullet.push_file(title=title, body=message, - **filedata) - else: - self.pushbullet.push_note(title, message) + self._push_data(message, title, data, self.pushbullet) _LOGGER.info("Sent notification to self") return @@ -112,16 +101,7 @@ class PushBulletNotificationService(BaseNotificationService): # Target is email, send directly, don't use a target object # This also seems works to send to all devices in own account if ttype == 'email': - if url: - self.pushbullet.push_link( - title, url, body=message, email=tname) - if filepath and self.hass.config.is_allowed_path(filepath): - with open(filepath, "rb") as fileh: - filedata = self.pushbullet.upload_file(fileh, filepath) - self.pushbullet.push_file(title=title, body=message, - **filedata) - else: - self.pushbullet.push_note(title, message, email=tname) + self._push_data(message, title, data, self.pushbullet, tname) _LOGGER.info("Sent notification to email %s", tname) continue @@ -140,15 +120,47 @@ class PushBulletNotificationService(BaseNotificationService): # Attempt push_note on a dict value. Keys are types & target # name. Dict pbtargets has all *actual* targets. try: - if url: - self.pbtargets[ttype][tname].push_link( - title, url, body=message) - else: - self.pbtargets[ttype][tname].push_note(title, message) + self._push_data(message, title, data, + self.pbtargets[ttype][tname]) _LOGGER.info("Sent notification to %s/%s", ttype, tname) except KeyError: _LOGGER.error("No such target: %s/%s", ttype, tname) continue - except self.pushbullet.errors.PushError: - _LOGGER.error("Notify failed to: %s/%s", ttype, tname) - continue + + def _push_data(self, message, title, data, pusher, tname=None): + from pushbullet import PushError + if data is None: + data = {} + url = data.get(ATTR_URL) + filepath = data.get(ATTR_FILE) + file_url = data.get(ATTR_FILE_URL) + try: + if url: + if tname: + pusher.push_link(title, url, body=message, email=tname) + else: + pusher.push_link(title, url, body=message) + elif filepath: + if not self.hass.config.is_allowed_path(filepath): + _LOGGER.error("Filepath is not valid or allowed.") + return + with open(filepath, "rb") as fileh: + filedata = self.pushbullet.upload_file(fileh, filepath) + if filedata.get('file_type') == 'application/x-empty': + _LOGGER.error("Can not send an empty file.") + return + pusher.push_file(title=title, body=message, **filedata) + elif file_url: + if not file_url.startswith('http'): + _LOGGER.error("Url should start with http or https.") + return + pusher.push_file(title=title, body=message, file_name=file_url, + file_url=file_url, + file_type=mimetypes.guess_type(file_url)[0]) + else: + if tname: + pusher.push_note(title, message, email=tname) + else: + pusher.push_note(title, message) + except PushError as err: + _LOGGER.error("Notify failed: %s", err) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index f67eae6c611..b7f192ff983 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==5.0.0'] +REQUIREMENTS = ['sendgrid==5.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/simplepush.py b/homeassistant/components/notify/simplepush.py index cda6a6952e0..b4c65d116c4 100644 --- a/homeassistant/components/notify/simplepush.py +++ b/homeassistant/components/notify/simplepush.py @@ -6,49 +6,54 @@ https://home-assistant.io/components/notify.simplepush/ """ import logging -import requests import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_PASSWORD + +REQUIREMENTS = ['simplepush==1.1.3'] _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'https://api.simplepush.io/send' + +ATTR_ENCRYPTED = 'encrypted' CONF_DEVICE_KEY = 'device_key' - -DEFAULT_TIMEOUT = 10 +CONF_EVENT = 'event' +CONF_SALT = 'salt' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DEVICE_KEY): cv.string, + vol.Optional(CONF_EVENT): cv.string, + vol.Inclusive(CONF_PASSWORD, ATTR_ENCRYPTED): cv.string, + vol.Inclusive(CONF_SALT, ATTR_ENCRYPTED): cv.string, }) def get_service(hass, config, discovery_info=None): """Get the Simplepush notification service.""" - return SimplePushNotificationService(config.get(CONF_DEVICE_KEY)) + return SimplePushNotificationService(config) class SimplePushNotificationService(BaseNotificationService): - """Implementation of the notification service for SimplePush.""" + """Implementation of the notification service for Simplepush.""" - def __init__(self, device_key): - """Initialize the service.""" - self._device_key = device_key + def __init__(self, config): + """Initialize the Simplepush notification service.""" + self._device_key = config.get(CONF_DEVICE_KEY) + self._event = config.get(CONF_EVENT) + self._password = config.get(CONF_PASSWORD) + self._salt = config.get(CONF_SALT) def send_message(self, message='', **kwargs): - """Send a message to a user.""" + """Send a message to a Simplepush user.""" + from simplepush import send, send_encrypted + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - # Upstream bug will be fixed soon, but no dead-line available. - # payload = 'key={}&title={}&msg={}'.format( - # self._device_key, title, message).replace(' ', '%') - # response = requests.get( - # _RESOURCE, data=payload, timeout=DEFAULT_TIMEOUT) - response = requests.get( - '{}/{}/{}/{}'.format(_RESOURCE, self._device_key, title, message), - timeout=DEFAULT_TIMEOUT) - - if response.json()['status'] != 'OK': - _LOGGER.error("Not possible to send notification") + if self._password: + send_encrypted(self._device_key, self._password, self._salt, title, + message, event=self._event) + else: + send(self._device_key, title, message, event=self._event) diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 9d2a8c07932..6cb98e45274 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -8,6 +8,8 @@ import json import logging import mimetypes import os +from datetime import timedelta, datetime +from functools import partial import voluptuous as vol @@ -15,6 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME +from homeassistant.helpers.event import async_track_point_in_time REQUIREMENTS = ['TwitterAPI==2.4.6'] @@ -68,49 +71,70 @@ class TwitterNotificationService(BaseNotificationService): _LOGGER.warning("'%s' is not a whitelisted directory", media) return - media_id = self.upload_media(media) + callback = partial(self.send_message_callback, message) + self.upload_media_then_callback(callback, media) + + def send_message_callback(self, message, media_id=None): + """Tweet a message, optionally with media.""" if self.user: resp = self.api.request('direct_messages/new', - {'text': message, 'user': self.user, + {'user': self.user, + 'text': message, 'media_ids': media_id}) else: resp = self.api.request('statuses/update', - {'status': message, 'media_ids': media_id}) + {'status': message, + 'media_ids': media_id}) if resp.status_code != 200: self.log_error_resp(resp) + else: + _LOGGER.debug("Message posted: %s", resp.json()) - def upload_media(self, media_path=None): + def upload_media_then_callback(self, callback, media_path=None): """Upload media.""" if not media_path: - return None + return callback() + with open(media_path, 'rb') as file: + total_bytes = os.path.getsize(media_path) + (media_category, media_type) = self.media_info(media_path) + resp = self.upload_media_init( + media_type, media_category, total_bytes + ) + + if 199 > resp.status_code < 300: + self.log_error_resp(resp) + return None + + media_id = resp.json()['media_id'] + media_id = self.upload_media_chunked(file, total_bytes, media_id) + + resp = self.upload_media_finalize(media_id) + if 199 > resp.status_code < 300: + self.log_error_resp(resp) + return None + + if resp.json().get('processing_info') is None: + return callback(media_id) + + self.check_status_until_done(media_id, callback) + + def media_info(self, media_path): + """Determine mime type and Twitter media category for given media.""" (media_type, _) = mimetypes.guess_type(media_path) - total_bytes = os.path.getsize(media_path) + media_category = self.media_category_for_type(media_type) + _LOGGER.debug("media %s is mime type %s and translates to %s", + media_path, media_type, media_category) + return media_category, media_type - file = open(media_path, 'rb') - resp = self.upload_media_init(media_type, total_bytes) - - if 199 > resp.status_code < 300: - self.log_error_resp(resp) - return None - - media_id = resp.json()['media_id'] - media_id = self.upload_media_chunked(file, total_bytes, media_id) - - resp = self.upload_media_finalize(media_id) - if 199 > resp.status_code < 300: - self.log_error_resp(resp) - - return media_id - - def upload_media_init(self, media_type, total_bytes): + def upload_media_init(self, media_type, media_category, total_bytes): """Upload media, INIT phase.""" - resp = self.api.request('media/upload', + return self.api.request('media/upload', {'command': 'INIT', 'media_type': media_type, + 'media_category': media_category, 'total_bytes': total_bytes}) - return resp def upload_media_chunked(self, file, total_bytes, media_id): """Upload media, chunked append.""" @@ -128,17 +152,55 @@ class TwitterNotificationService(BaseNotificationService): return media_id def upload_media_append(self, chunk, media_id, segment_id): - """Upload media, append phase.""" + """Upload media, APPEND phase.""" return self.api.request('media/upload', {'command': 'APPEND', 'media_id': media_id, 'segment_index': segment_id}, {'media': chunk}) def upload_media_finalize(self, media_id): - """Upload media, finalize phase.""" + """Upload media, FINALIZE phase.""" return self.api.request('media/upload', {'command': 'FINALIZE', 'media_id': media_id}) + def check_status_until_done(self, media_id, callback, *args): + """Upload media, STATUS phase.""" + resp = self.api.request('media/upload', + {'command': 'STATUS', 'media_id': media_id}, + method_override='GET') + if resp.status_code != 200: + _LOGGER.error("media processing error: %s", resp.json()) + processing_info = resp.json()['processing_info'] + + _LOGGER.debug("media processing %s status: %s", media_id, + processing_info) + + if processing_info['state'] in {u'succeeded', u'failed'}: + return callback(media_id) + + check_after_secs = processing_info['check_after_secs'] + _LOGGER.debug("media processing waiting %s seconds to check status", + str(check_after_secs)) + + when = datetime.now() + timedelta(seconds=check_after_secs) + myself = partial(self.check_status_until_done, media_id, callback) + async_track_point_in_time(self.hass, myself, when) + + @staticmethod + def media_category_for_type(media_type): + """Determine Twitter media category by mime type.""" + if media_type is None: + return None + + if media_type.startswith('image/gif'): + return 'tweet_gif' + elif media_type.startswith('video/'): + return 'tweet_video' + elif media_type.startswith('image/'): + return 'tweet_image' + + return None + @staticmethod def log_bytes_sent(bytes_sent, total_bytes): """Log upload progress.""" diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index d04eb91b6c4..fe19da49cb2 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -15,18 +15,20 @@ from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT REQUIREMENTS = ['sleekxmpp==1.3.2', 'dnspython3==1.15.0', - 'pyasn1==0.3.2', - 'pyasn1-modules==0.0.11'] + 'pyasn1==0.3.6', + 'pyasn1-modules==0.1.4'] _LOGGER = logging.getLogger(__name__) CONF_TLS = 'tls' +CONF_VERIFY = 'verify' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENDER): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_RECIPIENT): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, + vol.Optional(CONF_VERIFY, default=True): cv.boolean, }) @@ -34,18 +36,20 @@ def get_service(hass, config, discovery_info=None): """Get the Jabber (XMPP) notification service.""" return XmppNotificationService( config.get(CONF_SENDER), config.get(CONF_PASSWORD), - config.get(CONF_RECIPIENT), config.get(CONF_TLS)) + config.get(CONF_RECIPIENT), config.get(CONF_TLS), + config.get(CONF_VERIFY)) class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__(self, sender, password, recipient, tls): + def __init__(self, sender, password, recipient, tls, verify): """Initialize the service.""" self._sender = sender self._password = password self._recipient = recipient self._tls = tls + self._verify = verify def send_message(self, message="", **kwargs): """Send a message to a user.""" @@ -53,10 +57,11 @@ class XmppNotificationService(BaseNotificationService): data = '{}: {}'.format(title, message) if title else message send_message('{}/home-assistant'.format(self._sender), self._password, - self._recipient, self._tls, data) + self._recipient, self._tls, self._verify, data) -def send_message(sender, password, recipient, use_tls, message): +def send_message(sender, password, recipient, use_tls, + verify_certificate, message): """Send a message over XMPP.""" import sleekxmpp @@ -73,6 +78,10 @@ def send_message(sender, password, recipient, use_tls, message): self.use_ipv6 = False self.add_event_handler('failed_auth', self.check_credentials) self.add_event_handler('session_start', self.start) + if not verify_certificate: + self.add_event_handler('ssl_invalid_cert', + self.discard_ssl_invalid_cert) + self.connect(use_tls=self.use_tls, use_ssl=False) self.process() @@ -87,4 +96,10 @@ def send_message(sender, password, recipient, use_tls, message): """Disconnect from the server if credentials are invalid.""" self.disconnect() + @staticmethod + def discard_ssl_invalid_cert(event): + """Do nothing if ssl certificate is invalid.""" + _LOGGER.info('Ignoring invalid ssl certificate as requested.') + return + SendNotificationBot() diff --git a/homeassistant/components/plant.py b/homeassistant/components/plant.py index 9b9e11e0fbb..3a6876e3e12 100644 --- a/homeassistant/components/plant.py +++ b/homeassistant/components/plant.py @@ -212,7 +212,7 @@ class Plant(Entity): self._icon = 'mdi:thumb-up' self._problems = PROBLEM_NONE _LOGGER.debug("New data processed") - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def should_poll(self): diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index f244bcdd740..0396cafd4ff 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -12,16 +12,18 @@ from aiohttp import web from homeassistant.components.http import HomeAssistantView from homeassistant.components import recorder -from homeassistant.const import (CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, - CONF_INCLUDE, EVENT_STATE_CHANGED, - TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.const import ( + CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, TEMP_CELSIUS, + EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN) from homeassistant import core as hacore from homeassistant.helpers import state as state_helper from homeassistant.util.temperature import fahrenheit_to_celsius +REQUIREMENTS = ['prometheus_client==0.0.19'] + _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['prometheus_client==0.0.19'] +API_ENDPOINT = '/api/prometheus' DOMAIN = 'prometheus' DEPENDENCIES = ['http'] @@ -30,8 +32,6 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: recorder.FILTER_SCHEMA, }, extra=vol.ALLOW_EXTRA) -API_ENDPOINT = '/api/prometheus' - def setup(hass, config): """Activate Prometheus component.""" @@ -45,11 +45,10 @@ def setup(hass, config): metrics = Metrics(prometheus_client, exclude, include) hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_event) - return True -class Metrics: +class Metrics(object): """Model all of the metrics which should be exposed to Prometheus.""" def __init__(self, prometheus_client, exclude, include): @@ -81,7 +80,7 @@ class Metrics: entity_id not in self.include_entities): return - handler = '_handle_' + domain + handler = '_handle_{}'.format(domain) if hasattr(self, handler): getattr(self, handler)(state) @@ -233,8 +232,8 @@ class PrometheusView(HomeAssistantView): @asyncio.coroutine def get(self, request): """Handle request for Prometheus metrics.""" - _LOGGER.debug('Received Prometheus metrics request') + _LOGGER.debug("Received Prometheus metrics request") return web.Response( body=self.prometheus_client.generate_latest(), - content_type="text/plain") + content_type=CONTENT_TYPE_TEXT_PLAIN) diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index 386abba59ae..b33766d84db 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -2,9 +2,11 @@ import glob import os import logging +import datetime import voluptuous as vol +from homeassistant.const import SERVICE_RELOAD from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.util import sanitize_filename @@ -35,6 +37,24 @@ def setup(hass, config): """Initialize the python_script component.""" path = hass.config.path(FOLDER) + if not os.path.isdir(path): + _LOGGER.warning('Folder %s not found in config folder', FOLDER) + return False + + discover_scripts(hass) + + def reload_scripts_handler(call): + """Handle reload service calls.""" + discover_scripts(hass) + hass.services.register(DOMAIN, SERVICE_RELOAD, reload_scripts_handler) + + return True + + +def discover_scripts(hass): + """Discover python scripts in folder.""" + path = hass.config.path(FOLDER) + if not os.path.isdir(path): _LOGGER.warning('Folder %s not found in config folder', FOLDER) return False @@ -43,12 +63,16 @@ def setup(hass, config): """Handle python script service calls.""" execute_script(hass, call.service, call.data) + existing = hass.services.services.get(DOMAIN, {}).keys() + for existing_service in existing: + if existing_service == SERVICE_RELOAD: + continue + hass.services.remove(DOMAIN, existing_service) + for fil in glob.iglob(os.path.join(path, '*.py')): name = os.path.splitext(os.path.basename(fil))[0] hass.services.register(DOMAIN, name, python_script_service_handler) - return True - @bind_hass def execute_script(hass, name, data=None): @@ -63,7 +87,8 @@ def execute_script(hass, name, data=None): def execute(hass, filename, source, data=None): """Execute Python source.""" from RestrictedPython import compile_restricted_exec - from RestrictedPython.Guards import safe_builtins, full_write_guard + from RestrictedPython.Guards import safe_builtins, full_write_guard, \ + guarded_iter_unpack_sequence, guarded_unpack_sequence from RestrictedPython.Utilities import utility_builtins from RestrictedPython.Eval import default_guarded_getitem @@ -94,13 +119,16 @@ def execute(hass, filename, source, data=None): builtins = safe_builtins.copy() builtins.update(utility_builtins) + builtins['datetime'] = datetime restricted_globals = { '__builtins__': builtins, '_print_': StubPrinter, '_getattr_': protected_getattr, '_write_': full_write_guard, '_getiter_': iter, - '_getitem_': default_guarded_getitem + '_getitem_': default_guarded_getitem, + '_iter_unpack_sequence_': guarded_iter_unpack_sequence, + '_unpack_sequence_': guarded_unpack_sequence, } logger = logging.getLogger('{}.{}'.format(__name__, filename)) local = { diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 6aac0b7fafd..5d3ca270399 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -33,7 +33,7 @@ from . import purge, migration from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.1.13'] +REQUIREMENTS = ['sqlalchemy==1.1.14'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 5a68fe43fe0..325267b857e 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -47,7 +47,7 @@ def _create_index(engine, table_name, index_name): table = Table(table_name, models.Base.metadata) _LOGGER.debug("Looking up index for table %s", table_name) - # Look up the index object by name from the table is the the models + # Look up the index object by name from the table is the models index = next(idx for idx in table.indexes if idx.name == index_name) _LOGGER.debug("Creating %s index", index_name) _LOGGER.info("Adding index `%s` to database. Note: this can take several " @@ -151,7 +151,7 @@ def _apply_update(engine, new_version, old_version): def _inspect_schema_version(engine, session): """Determine the schema version by inspecting the db structure. - When the schema verison is not present in the db, either db was just + When the schema version is not present in the db, either db was just created with the correct schema, or this is a db created before schema versions were tracked. For now, we'll test if the changes for schema version 1 are present to make the determination. Eventually this logic diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 26ddefedf7d..90a69f8f2a1 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -26,6 +26,7 @@ def purge_old_data(instance, purge_days): _LOGGER.debug("Deleted %s events", deleted_rows) # Execute sqlite vacuum command to free up space on disk - if instance.engine.driver == 'sqlite': + _LOGGER.debug("DB engine driver: %s", instance.engine.driver) + if instance.engine.driver == 'pysqlite': _LOGGER.info("Vacuuming SQLite to free space") instance.engine.execute("VACUUM") diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index fe3e954c571..74e533d70ec 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -272,7 +272,7 @@ class RflinkDevice(Entity): self._handle_event(event) # Propagate changes through ha - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Put command onto bus for user to subscribe to if self._should_fire_event and identify_event_type( diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index e3ffc2f24a8..0c5acd3f7fa 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -4,6 +4,7 @@ Support for RFXtrx components. For more details about this component, please refer to the documentation at https://home-assistant.io/components/rfxtrx/ """ + import logging from collections import OrderedDict import voluptuous as vol @@ -11,13 +12,14 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ATTR_ENTITY_ID, TEMP_CELSIUS, CONF_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF ) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyRFXtrx==0.19.0'] +REQUIREMENTS = ['pyRFXtrx==0.20.1'] DOMAIN = 'rfxtrx' @@ -54,7 +56,7 @@ DATA_TYPES = OrderedDict([ RECEIVED_EVT_SUBSCRIBERS = [] RFX_DEVICES = {} _LOGGER = logging.getLogger(__name__) -RFXOBJECT = None +RFXOBJECT = 'rfxobject' def _valid_device(value, device_type): @@ -77,10 +79,6 @@ def _valid_device(value, device_type): if not len(key) % 2 == 0: key = '0' + key - if get_rfx_object(key) is None: - raise vol.Invalid('Rfxtrx device {} is invalid: ' - 'Invalid device id for {}'.format(key, value)) - if device_type == 'sensor': config[key] = DEVICE_SCHEMA_SENSOR(device) elif device_type == 'binary_sensor': @@ -171,24 +169,24 @@ def setup(hass, config): # Try to load the RFXtrx module. import RFXtrx as rfxtrxmod - # Init the rfxtrx module. - global RFXOBJECT - device = config[DOMAIN][ATTR_DEVICE] debug = config[DOMAIN][ATTR_DEBUG] dummy_connection = config[DOMAIN][ATTR_DUMMY] if dummy_connection: - RFXOBJECT =\ - rfxtrxmod.Connect(device, handle_receive, debug=debug, + hass.data[RFXOBJECT] =\ + rfxtrxmod.Connect(device, None, debug=debug, transport_protocol=rfxtrxmod.DummyTransport2) else: - RFXOBJECT = rfxtrxmod.Connect(device, handle_receive, debug=debug) + hass.data[RFXOBJECT] = rfxtrxmod.Connect(device, None, debug=debug) + + def _start_rfxtrx(event): + hass.data[RFXOBJECT].event_callback = handle_receive + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx) def _shutdown_rfxtrx(event): """Close connection with RFXtrx.""" - RFXOBJECT.close_connection() - + hass.data[RFXOBJECT].close_connection() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) return True @@ -285,13 +283,16 @@ def find_possible_pt2262_device(device_id): return None -def get_devices_from_config(config, device, hass): +def get_devices_from_config(config, device): """Read rfxtrx configuration.""" signal_repetitions = config[CONF_SIGNAL_REPETITIONS] devices = [] for packet_id, entity_info in config[CONF_DEVICES].items(): event = get_rfx_object(packet_id) + if event is None: + _LOGGER.error("Invalid device: %s", packet_id) + continue device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: continue @@ -303,13 +304,12 @@ def get_devices_from_config(config, device, hass): new_device = device(entity_info[ATTR_NAME], event, datas, signal_repetitions) - new_device.hass = hass RFX_DEVICES[device_id] = new_device devices.append(new_device) return devices -def get_new_device(event, config, device, hass): +def get_new_device(event, config, device): """Add entity if not exist and the automatic_add is True.""" device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: @@ -330,7 +330,6 @@ def get_new_device(event, config, device, hass): signal_repetitions = config[CONF_SIGNAL_REPETITIONS] new_device = device(pkt_id, event, datas, signal_repetitions) - new_device.hass = hass RFX_DEVICES[device_id] = new_device return new_device @@ -438,31 +437,36 @@ class RfxtrxDevice(Entity): if command == "turn_on": for _ in range(self.signal_repetitions): - self._event.device.send_on(RFXOBJECT.transport) + self._event.device.send_on(self.hass.data[RFXOBJECT] + .transport) self._state = True elif command == "dim": for _ in range(self.signal_repetitions): - self._event.device.send_dim(RFXOBJECT.transport, - brightness) + self._event.device.send_dim(self.hass.data[RFXOBJECT] + .transport, brightness) self._state = True elif command == 'turn_off': for _ in range(self.signal_repetitions): - self._event.device.send_off(RFXOBJECT.transport) + self._event.device.send_off(self.hass.data[RFXOBJECT] + .transport) self._state = False self._brightness = 0 elif command == "roll_up": for _ in range(self.signal_repetitions): - self._event.device.send_open(RFXOBJECT.transport) + self._event.device.send_open(self.hass.data[RFXOBJECT] + .transport) elif command == "roll_down": for _ in range(self.signal_repetitions): - self._event.device.send_close(RFXOBJECT.transport) + self._event.device.send_close(self.hass.data[RFXOBJECT] + .transport) elif command == "stop_roll": for _ in range(self.signal_repetitions): - self._event.device.send_stop(RFXOBJECT.transport) + self._event.device.send_stop(self.hass.data[RFXOBJECT] + .transport) self.schedule_update_ha_state() diff --git a/homeassistant/components/satel_integra.py b/homeassistant/components/satel_integra.py new file mode 100644 index 00000000000..4b61ff15c08 --- /dev/null +++ b/homeassistant/components/satel_integra.py @@ -0,0 +1,152 @@ +""" +Support for Satel Integra devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/satel_integra/ +""" +# pylint: disable=invalid-name + +import asyncio +import logging + + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send + +REQUIREMENTS = ['satel_integra==0.1.0'] + +DEFAULT_ALARM_NAME = 'satel_integra' +DEFAULT_PORT = 7094 +DEFAULT_CONF_ARM_HOME_MODE = 1 +DEFAULT_DEVICE_PARTITION = 1 +DEFAULT_ZONE_TYPE = 'motion' + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'satel_integra' + +DATA_SATEL = 'satel_integra' + +CONF_DEVICE_HOST = 'host' +CONF_DEVICE_PORT = 'port' +CONF_DEVICE_PARTITION = 'partition' +CONF_ARM_HOME_MODE = 'arm_home_mode' +CONF_ZONE_NAME = 'name' +CONF_ZONE_TYPE = 'type' +CONF_ZONES = 'zones' + +ZONES = 'zones' + +SIGNAL_PANEL_MESSAGE = 'satel_integra.panel_message' +SIGNAL_PANEL_ARM_AWAY = 'satel_integra.panel_arm_away' +SIGNAL_PANEL_ARM_HOME = 'satel_integra.panel_arm_home' +SIGNAL_PANEL_DISARM = 'satel_integra.panel_disarm' + +SIGNAL_ZONES_UPDATED = 'satel_integra.zones_updated' + +ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_ZONE_NAME): cv.string, + vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE_HOST): cv.string, + vol.Optional(CONF_DEVICE_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_DEVICE_PARTITION, + default=DEFAULT_DEVICE_PARTITION): cv.positive_int, + vol.Optional(CONF_ARM_HOME_MODE, + default=DEFAULT_CONF_ARM_HOME_MODE): vol.In([1, 2, 3]), + vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA}, + }), +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the Satel Integra component.""" + conf = config.get(DOMAIN) + + zones = conf.get(CONF_ZONES) + host = conf.get(CONF_DEVICE_HOST) + port = conf.get(CONF_DEVICE_PORT) + partition = conf.get(CONF_DEVICE_PARTITION) + + from satel_integra.satel_integra import AsyncSatel, AlarmState + + controller = AsyncSatel(host, port, zones, hass.loop, partition) + + hass.data[DATA_SATEL] = controller + + result = yield from controller.connect() + + if not result: + return False + + @asyncio.coroutine + def _close(): + controller.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close()) + + _LOGGER.debug("Arm home config: %s, mode: %s ", + conf, + conf.get(CONF_ARM_HOME_MODE)) + + task_control_panel = hass.async_add_job( + async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config)) + + task_zones = hass.async_add_job( + async_load_platform(hass, 'binary_sensor', DOMAIN, + {CONF_ZONES: zones}, config)) + + yield from asyncio.wait([task_control_panel, task_zones], loop=hass.loop) + + @callback + def alarm_status_update_callback(status): + """Send status update received from alarm to home assistant.""" + _LOGGER.debug("Alarm status callback, status: %s", status) + hass_alarm_status = STATE_ALARM_DISARMED + + if status == AlarmState.ARMED_MODE0: + hass_alarm_status = STATE_ALARM_ARMED_AWAY + + elif status in [ + AlarmState.ARMED_MODE0, + AlarmState.ARMED_MODE1, + AlarmState.ARMED_MODE2, + AlarmState.ARMED_MODE3 + ]: + hass_alarm_status = STATE_ALARM_ARMED_HOME + + elif status in [AlarmState.TRIGGERED, AlarmState.TRIGGERED_FIRE]: + hass_alarm_status = STATE_ALARM_TRIGGERED + + elif status == AlarmState.DISARMED: + hass_alarm_status = STATE_ALARM_DISARMED + + _LOGGER.debug("Sending hass_alarm_status: %s...", hass_alarm_status) + async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE, hass_alarm_status) + + @callback + def zones_update_callback(status): + """Update zone objects as per notification from the alarm.""" + _LOGGER.debug("Zones callback , status: %s", status) + async_dispatcher_send(hass, SIGNAL_ZONES_UPDATED, status[ZONES]) + + # Create a task instead of adding a tracking job, since this task will + # run until the connection to satel_integra is closed. + hass.loop.create_task(controller.keep_alive()) + hass.loop.create_task( + controller.monitor_status( + alarm_status_update_callback, + zones_update_callback) + ) + + return True diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py new file mode 100644 index 00000000000..7e14ec6eff4 --- /dev/null +++ b/homeassistant/components/sensor/airvisual.py @@ -0,0 +1,318 @@ +""" +Support for AirVisual air quality sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.airvisual/ +""" + +import asyncio +from logging import getLogger +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY, + CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_STATE) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = getLogger(__name__) +REQUIREMENTS = ['pyairvisual==1.0.0'] + +ATTR_CITY = 'city' +ATTR_COUNTRY = 'country' +ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' +ATTR_POLLUTANT_UNIT = 'pollutant_unit' +ATTR_REGION = 'region' +ATTR_TIMESTAMP = 'timestamp' + +CONF_CITY = 'city' +CONF_COUNTRY = 'country' +CONF_RADIUS = 'radius' + +MASS_PARTS_PER_MILLION = 'ppm' +MASS_PARTS_PER_BILLION = 'ppb' +VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) + +POLLUTANT_LEVEL_MAPPING = [{ + 'label': 'Good', + 'minimum': 0, + 'maximum': 50 +}, { + 'label': 'Moderate', + 'minimum': 51, + 'maximum': 100 +}, { + 'label': 'Unhealthy for Sensitive Groups', + 'minimum': 101, + 'maximum': 150 +}, { + 'label': 'Unhealthy', + 'minimum': 151, + 'maximum': 200 +}, { + 'label': 'Very Unhealthy', + 'minimum': 201, + 'maximum': 300 +}, { + 'label': 'Hazardous', + 'minimum': 301, + 'maximum': 10000 +}] +POLLUTANT_MAPPING = { + 'co': { + 'label': 'Carbon Monoxide', + 'unit': MASS_PARTS_PER_MILLION + }, + 'n2': { + 'label': 'Nitrogen Dioxide', + 'unit': MASS_PARTS_PER_BILLION + }, + 'o3': { + 'label': 'Ozone', + 'unit': MASS_PARTS_PER_BILLION + }, + 'p1': { + 'label': 'PM10', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 'p2': { + 'label': 'PM2.5', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 's2': { + 'label': 'Sulfur Dioxide', + 'unit': MASS_PARTS_PER_BILLION + } +} + +SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} +SENSOR_TYPES = [ + ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), + ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), + ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), + vol.Optional(CONF_LATITUDE): + cv.latitude, + vol.Optional(CONF_LONGITUDE): + cv.longitude, + vol.Optional(CONF_RADIUS, default=1000): + cv.positive_int, + vol.Optional(CONF_CITY): + cv.string, + vol.Optional(CONF_STATE): + cv.string, + vol.Optional(CONF_COUNTRY): + cv.string +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Configure the platform and add the sensors.""" + import pyairvisual as pav + + _LOGGER.debug('Received configuration: %s', config) + + api_key = config.get(CONF_API_KEY) + monitored_locales = config.get(CONF_MONITORED_CONDITIONS) + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + radius = config.get(CONF_RADIUS) + city = config.get(CONF_CITY) + state = config.get(CONF_STATE) + country = config.get(CONF_COUNTRY) + + if city and state and country: + _LOGGER.debug('Using city, state, and country: %s, %s, %s', city, + state, country) + data = AirVisualData( + pav.Client(api_key), city=city, state=state, country=country) + else: + _LOGGER.debug('Using latitude and longitude: %s, %s', latitude, + longitude) + data = AirVisualData( + pav.Client(api_key), + latitude=latitude, + longitude=longitude, + radius=radius) + + sensors = [] + for locale in monitored_locales: + for sensor_class, name, icon in SENSOR_TYPES: + sensors.append(globals()[sensor_class](data, name, icon, locale)) + + async_add_devices(sensors, True) + + +def merge_two_dicts(dict1, dict2): + """Merge two dicts into a new dict as a shallow copy.""" + final = dict1.copy() + final.update(dict2) + return final + + +class AirVisualBaseSensor(Entity): + """Define a base class for all of our sensors.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + self._data = data + self._icon = icon + self._locale = locale + self._name = name + self._state = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: 'AirVisual©', + ATTR_CITY: self._data.city, + ATTR_COUNTRY: self._data.country, + ATTR_REGION: self._data.state, + ATTR_LATITUDE: self._data.latitude, + ATTR_LONGITUDE: self._data.longitude, + ATTR_TIMESTAMP: self._data.pollution_info.get('ts') + } + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return '{0} {1}'.format(SENSOR_LOCALES[self._locale], self._name) + + @property + def state(self): + """Return the state.""" + return self._state + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + _LOGGER.debug('Updating sensor: %s', self._name) + self._data.update() + + +class AirPollutionLevelSensor(AirVisualBaseSensor): + """Define a sensor to measure air pollution level.""" + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + aqi = self._data.pollution_info.get('aqi{0}'.format(self._locale)) + try: + [level] = [ + i for i in POLLUTANT_LEVEL_MAPPING + if i['minimum'] <= aqi <= i['maximum'] + ] + self._state = level.get('label') + except TypeError: + self._state = None + except ValueError: + self._state = None + + +class AirQualityIndexSensor(AirVisualBaseSensor): + """Define a sensor to measure AQI.""" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return 'PSI' + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + + self._state = self._data.pollution_info.get( + 'aqi{0}'.format(self._locale)) + + +class MainPollutantSensor(AirVisualBaseSensor): + """Define a sensor to the main pollutant of an area.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + super().__init__(data, name, icon, locale) + self._symbol = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return merge_two_dicts(super().device_state_attributes, { + ATTR_POLLUTANT_SYMBOL: self._symbol, + ATTR_POLLUTANT_UNIT: self._unit + }) + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + symbol = self._data.pollution_info.get('main{0}'.format(self._locale)) + pollution_info = POLLUTANT_MAPPING.get(symbol, {}) + self._state = pollution_info.get('label') + self._unit = pollution_info.get('unit') + self._symbol = symbol + + +class AirVisualData(object): + """Define an object to hold sensor data.""" + + def __init__(self, client, **kwargs): + """Initialize.""" + self._client = client + self.pollution_info = None + + self.city = kwargs.get(CONF_CITY) + self.state = kwargs.get(CONF_STATE) + self.country = kwargs.get(CONF_COUNTRY) + + self.latitude = kwargs.get(CONF_LATITUDE) + self.longitude = kwargs.get(CONF_LONGITUDE) + self._radius = kwargs.get(CONF_RADIUS) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update with new AirVisual data.""" + import pyairvisual.exceptions as exceptions + + try: + if self.city and self.state and self.country: + resp = self._client.city(self.city, self.state, + self.country).get('data') + self.longitude, self.latitude = resp.get('location').get( + 'coordinates') + else: + resp = self._client.nearest_city(self.latitude, self.longitude, + self._radius).get('data') + _LOGGER.debug('New data retrieved: %s', resp) + + self.city = resp.get('city') + self.state = resp.get('state') + self.country = resp.get('country') + self.pollution_info = resp.get('current', {}).get('pollution', {}) + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to retrieve data on this location: %s', + self.__dict__) + _LOGGER.debug(exc_info) + self.pollution_info = {} diff --git a/homeassistant/components/sensor/alarmdecoder.py b/homeassistant/components/sensor/alarmdecoder.py index dba1697f026..6b026298db0 100644 --- a/homeassistant/components/sensor/alarmdecoder.py +++ b/homeassistant/components/sensor/alarmdecoder.py @@ -50,7 +50,7 @@ class AlarmDecoderSensor(Entity): def _message_callback(self, message): if self._display != message.text: self._display = message.text - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def icon(self): diff --git a/homeassistant/components/sensor/arwn.py b/homeassistant/components/sensor/arwn.py index 4aa8e20cb75..6fd09874651 100644 --- a/homeassistant/components/sensor/arwn.py +++ b/homeassistant/components/sensor/arwn.py @@ -123,7 +123,7 @@ class ArwnSensor(Entity): """Update the sensor with the most recent event.""" self.event = {} self.event.update(event) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def state(self): diff --git a/homeassistant/components/sensor/bitcoin.py b/homeassistant/components/sensor/bitcoin.py index 4c5cbc248dd..31c6c1809b3 100644 --- a/homeassistant/components/sensor/bitcoin.py +++ b/homeassistant/components/sensor/bitcoin.py @@ -15,7 +15,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['blockchain==1.3.3'] +REQUIREMENTS = ['blockchain==1.4.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py index 8961fa1dc74..1b5cfc4b491 100755 --- a/homeassistant/components/sensor/buienradar.py +++ b/homeassistant/components/sensor/buienradar.py @@ -220,7 +220,12 @@ class BrSensor(Entity): # update all other sensors if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION): - condition = data.get(FORECAST)[fcday].get(CONDITION) + try: + condition = data.get(FORECAST)[fcday].get(CONDITION) + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False + if condition: new_state = condition.get(CONDITION, None) if self.type.startswith(SYMBOL): @@ -240,7 +245,11 @@ class BrSensor(Entity): return True return False else: - new_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + try: + new_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False if new_state != self._state: self._state = new_state diff --git a/homeassistant/components/sensor/citybikes.py b/homeassistant/components/sensor/citybikes.py index 32b82b15631..a7bf7533e32 100644 --- a/homeassistant/components/sensor/citybikes.py +++ b/homeassistant/components/sensor/citybikes.py @@ -129,7 +129,7 @@ def async_citybikes_request(hass, uri, schema): # pylint: disable=unused-argument @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 CityBikes platform.""" if DOMAIN not in hass.data: @@ -159,7 +159,7 @@ def async_setup_platform(hass, config, async_add_entities, yield from network.ready.wait() - entities = [] + devices = [] for station in network.stations: dist = location.distance(latitude, longitude, station[ATTR_LATITUDE], @@ -169,9 +169,9 @@ def async_setup_platform(hass, config, async_add_entities, if radius > dist or stations_list.intersection((station_id, station_uid)): - entities.append(CityBikesStation(network, station_id, name)) + devices.append(CityBikesStation(network, station_id, name)) - async_add_entities(entities, True) + async_add_devices(devices, True) class CityBikesNetwork: diff --git a/homeassistant/components/sensor/coinmarketcap.py b/homeassistant/components/sensor/coinmarketcap.py index 332cfe7ba15..616b30abf2b 100644 --- a/homeassistant/components/sensor/coinmarketcap.py +++ b/homeassistant/components/sensor/coinmarketcap.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_CURRENCY from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['coinmarketcap==3.0.1'] +REQUIREMENTS = ['coinmarketcap==4.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/comed_hourly_pricing.py b/homeassistant/components/sensor/comed_hourly_pricing.py index c6a4a38c3b2..01e9f443e0e 100644 --- a/homeassistant/components/sensor/comed_hourly_pricing.py +++ b/homeassistant/components/sensor/comed_hourly_pricing.py @@ -6,14 +6,17 @@ https://home-assistant.io/components/sensor.comed_hourly_pricing/ """ from datetime import timedelta import logging +import asyncio +import json +import async_timeout +import aiohttp import voluptuous as vol -from requests import RequestException, get - import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN from homeassistant.helpers.entity import Entity +from homeassistant.helpers.aiohttp_client import async_get_clientsession _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://hourlypricing.comed.com/api' @@ -46,22 +49,27 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the ComEd Hourly Pricing sensor.""" + websession = async_get_clientsession(hass) dev = [] + for variable in config[CONF_MONITORED_FEEDS]: dev.append(ComedHourlyPricingSensor( - variable[CONF_SENSOR_TYPE], variable[CONF_OFFSET], - variable.get(CONF_NAME))) + hass.loop, websession, variable[CONF_SENSOR_TYPE], + variable[CONF_OFFSET], variable.get(CONF_NAME))) - add_devices(dev, True) + async_add_devices(dev, True) class ComedHourlyPricingSensor(Entity): """Implementation of a ComEd Hourly Pricing sensor.""" - def __init__(self, sensor_type, offset, name): + def __init__(self, loop, websession, sensor_type, offset, name): """Initialize the sensor.""" + self.loop = loop + self.websession = websession if name: self._name = name else: @@ -92,20 +100,30 @@ class ComedHourlyPricingSensor(Entity): attrs = {ATTR_ATTRIBUTION: CONF_ATTRIBUTION} return attrs - def update(self): + @asyncio.coroutine + def async_update(self): """Get the ComEd Hourly Pricing data from the web service.""" try: - if self.type == CONF_FIVE_MINUTE: - url_string = _RESOURCE + '?type=5minutefeed' - response = get(url_string, timeout=10) - self._state = round( - float(response.json()[0]['price']) + self.offset, 2) - elif self.type == CONF_CURRENT_HOUR_AVERAGE: - url_string = _RESOURCE + '?type=currenthouraverage' - response = get(url_string, timeout=10) - self._state = round( - float(response.json()[0]['price']) + self.offset, 2) + if self.type == CONF_FIVE_MINUTE or \ + self.type == CONF_CURRENT_HOUR_AVERAGE: + url_string = _RESOURCE + if self.type == CONF_FIVE_MINUTE: + url_string += '?type=5minutefeed' + else: + url_string += '?type=currenthouraverage' + + with async_timeout.timeout(60, loop=self.loop): + response = yield from self.websession.get(url_string) + # The API responds with MIME type 'text/html' + text = yield from response.text() + data = json.loads(text) + self._state = round( + float(data[0]['price']) + self.offset, 2) + else: self._state = STATE_UNKNOWN - except (RequestException, ValueError, KeyError): + + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.error("Could not get data from ComEd API: %s", err) + except (ValueError, KeyError): _LOGGER.warning("Could not update status for %s", self.name) diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index 8fa34d50137..cbf06783dc7 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -127,7 +127,7 @@ class DHTSensor(Entity): humidity_offset = self.humidity_offset data = self.dht_client.data - if self.type == SENSOR_TEMPERATURE: + if self.type == SENSOR_TEMPERATURE and SENSOR_TEMPERATURE in data: temperature = data[SENSOR_TEMPERATURE] _LOGGER.debug("Temperature %.1f \u00b0C + offset %.1f", temperature, temperature_offset) @@ -135,7 +135,7 @@ class DHTSensor(Entity): self._state = round(temperature + temperature_offset, 1) if self.temp_unit == TEMP_FAHRENHEIT: self._state = round(celsius_to_fahrenheit(temperature), 1) - elif self.type == SENSOR_HUMIDITY: + elif self.type == SENSOR_HUMIDITY and SENSOR_HUMIDITY in data: humidity = data[SENSOR_HUMIDITY] _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 76fde35410d..2b303ac3c71 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -1,29 +1,8 @@ """ -Support for Dutch Smart Meter Requirements. - -Also known as: Smartmeter or P1 port. +Support for Dutch Smart Meter (also known as Smartmeter or P1 port). For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.dsmr/ - -Technical overview: - -DSMR is a standard to which Dutch smartmeters must comply. It specifies that -the smartmeter must send out a 'telegram' every 10 seconds over a serial port. - -The contents of this telegram differ between version but they generally consist -of lines with 'obis' (Object Identification System, a numerical ID for a value) -followed with the value and unit. - -This module sets up a asynchronous reading loop using the `dsmr_parser` module -which waits for a complete telegram, parser it and puts it on an async queue as -a dictionary of `obis`/object mapping. The numeric value and unit of each value -can be read from the objects attributes. Because the `obis` are know for each -DSMR version the Entities for this component are create during bootstrap. - -Another loop (DSMR class) is setup which reads the telegram queue, -stores/caches the latest telegram and notifies the Entities that the telegram -has been updated. """ import asyncio from datetime import timedelta @@ -40,7 +19,7 @@ import voluptuous as vol _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['dsmr_parser==0.8'] +REQUIREMENTS = ['dsmr_parser==0.11'] CONF_DSMR_VERSION = 'dsmr_version' CONF_RECONNECT_INTERVAL = 'reconnect_interval' @@ -54,6 +33,7 @@ ICON_POWER = 'mdi:flash' # Smart meter sends telegram every 10 seconds MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + RECONNECT_INTERVAL = 5 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -98,7 +78,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): else: gas_obis = obis_ref.GAS_METER_READING - # add gas meter reading and derivative for usage + # Add gas meter reading and derivative for usage devices += [ DSMREntity('Gas Consumption', gas_obis), DerivativeDSMREntity('Hourly Gas Consumption', gas_obis), @@ -107,7 +87,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): async_add_devices(devices) def update_entities_telegram(telegram): - """Update entities with latests telegram & trigger state update.""" + """Update entities with latests telegram and trigger state update.""" # Make all device entities aware of new telegram for device in devices: device.telegram = telegram @@ -127,7 +107,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @asyncio.coroutine def connect_and_reconnect(): - """Connect to DSMR and keep reconnecting until HA stops.""" + """Connect to DSMR and keep reconnecting until Home Assistant stops.""" while hass.state != CoreState.stopping: # Start DSMR asyncio.Protocol reader try: @@ -135,34 +115,34 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): reader_factory()) except (serial.serialutil.SerialException, ConnectionRefusedError, TimeoutError): - # log any error while establishing connection and drop to retry + # Log any error while establishing connection and drop to retry # connection wait _LOGGER.exception("Error connecting to DSMR") transport = None if transport: - # register listener to close transport on HA shutdown + # Register listener to close transport on HA shutdown stop_listerer = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, transport.close) - # wait for reader to close + # Wait for reader to close yield from protocol.wait_closed() if hass.state != CoreState.stopping: - # unexpected disconnect + # Unexpected disconnect if transport: # remove listerer stop_listerer() - # reflect disconnect state in devices state by setting an - # empty telegram resulting in `unkown` states + # Reflect disconnect state in devices state by setting an + # empty telegram resulting in `unknown` states update_entities_telegram({}) # throttle reconnect attempts yield from asyncio.sleep(config[CONF_RECONNECT_INTERVAL], loop=hass.loop) - # Cannot be hass.async_add_job because job runs forever + # Can't be hass.async_add_job because job runs forever hass.loop.create_task(connect_and_reconnect()) @@ -181,7 +161,7 @@ class DSMREntity(Entity): if self._obis not in self.telegram: return None - # get the attibute value if the object has it + # Get the attribute value if the object has it dsmr_object = self.telegram[self._obis] return getattr(dsmr_object, attribute, None) @@ -237,7 +217,6 @@ class DerivativeDSMREntity(DSMREntity): Gas readings are only reported per hour and don't offer a rate only the current meter reading. This entity converts subsequents readings into a hourly rate. - """ _previous_reading = None @@ -265,11 +244,11 @@ class DerivativeDSMREntity(DSMREntity): current_reading = self.get_dsmr_object_attr('value') if self._previous_reading is None: - # can't calculate rate without previous datapoint + # Can't calculate rate without previous datapoint # just store current point pass else: - # recalculate the rate + # Recalculate the rate diff = current_reading - self._previous_reading self._state = diff diff --git a/homeassistant/components/sensor/dte_energy_bridge.py b/homeassistant/components/sensor/dte_energy_bridge.py index ee80c4f76fe..c1687b6025b 100644 --- a/homeassistant/components/sensor/dte_energy_bridge.py +++ b/homeassistant/components/sensor/dte_energy_bridge.py @@ -16,14 +16,18 @@ from homeassistant.const import CONF_NAME _LOGGER = logging.getLogger(__name__) CONF_IP_ADDRESS = 'ip' +CONF_VERSION = 'version' DEFAULT_NAME = 'Current Energy Usage' +DEFAULT_VERSION = 1 ICON = 'mdi:flash' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_IP_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): + vol.All(vol.Coerce(int), vol.Any(1, 2)) }) @@ -31,16 +35,25 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the DTE energy bridge sensor.""" name = config.get(CONF_NAME) ip_address = config.get(CONF_IP_ADDRESS) + version = config.get(CONF_VERSION, 1) - add_devices([DteEnergyBridgeSensor(ip_address, name)], True) + add_devices([DteEnergyBridgeSensor(ip_address, name, version)], True) class DteEnergyBridgeSensor(Entity): - """Implementation of a DTE Energy Bridge sensor.""" + """Implementation of the DTE Energy Bridge sensors.""" - def __init__(self, ip_address, name): + def __init__(self, ip_address, name, version): """Initialize the sensor.""" - self._url = "http://{}/instantaneousdemand".format(ip_address) + self._version = version + + if self._version == 1: + url_template = "http://{}/instantaneousdemand" + elif self._version == 2: + url_template = "http://{}:8888/zigbee/se/instantaneousdemand" + + self._url = url_template.format(ip_address) + self._name = name self._unit_of_measurement = "kW" self._state = None @@ -91,4 +104,9 @@ class DteEnergyBridgeSensor(Entity): response.text, self._name) return - self._state = float(response_split[0]) + val = float(response_split[0]) + + # A workaround for a bug in the DTE energy bridge. + # The returned value can randomly be in W or kW. Checking for a + # a decimal seems to be a reliable way to determine the units. + self._state = val if '.' in response_split[0] else val / 1000 diff --git a/homeassistant/components/sensor/dwd_weather_warnings.py b/homeassistant/components/sensor/dwd_weather_warnings.py new file mode 100644 index 00000000000..0eeaa9424e8 --- /dev/null +++ b/homeassistant/components/sensor/dwd_weather_warnings.py @@ -0,0 +1,243 @@ +""" +Support for getting statistical data from a DWD Weather Warnings. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dwd_weather_warnings/ + +Data is fetched from DWD: +https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html + +Warnungen vor extremem Unwetter (Stufe 4) +Unwetterwarnungen (Stufe 3) +Warnungen vor markantem Wetter (Stufe 2) +Wetterwarnungen (Stufe 1) +""" +import logging +import json +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_NAME, CONF_MONITORED_CONDITIONS) +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util +from homeassistant.components.sensor.rest import RestData + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by DWD" + +DEFAULT_NAME = 'DWD-Weather-Warnings' + +CONF_REGION_NAME = 'region_name' + +SCAN_INTERVAL = timedelta(minutes=15) + +MONITORED_CONDITIONS = { + 'current_warning_level': ['Current Warning Level', + None, 'mdi:close-octagon-outline'], + 'advance_warning_level': ['Advance Warning Level', + None, 'mdi:close-octagon-outline'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION_NAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the DWD-Weather-Warnings sensor.""" + name = config.get(CONF_NAME) + region_name = config.get(CONF_REGION_NAME) + + api = DwdWeatherWarningsAPI(region_name) + + sensors = [DwdWeatherWarningsSensor(api, name, condition) + for condition in config[CONF_MONITORED_CONDITIONS]] + + add_devices(sensors, True) + + +class DwdWeatherWarningsSensor(Entity): + """Representation of a DWD-Weather-Warnings sensor.""" + + def __init__(self, api, name, variable): + """Initialize a DWD-Weather-Warnings sensor.""" + self._api = api + self._name = name + self._var_id = variable + + variable_info = MONITORED_CONDITIONS[variable] + self._var_name = variable_info[0] + self._var_units = variable_info[1] + self._var_icon = variable_info[2] + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format(self._name, self._var_name) + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._var_icon + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._var_units + + # pylint: disable=no-member + @property + def state(self): + """Return the state of the device.""" + try: + return round(self._api.data[self._var_id], 2) + except TypeError: + return self._api.data[self._var_id] + + # pylint: disable=no-member + @property + def device_state_attributes(self): + """Return the state attributes of the DWD-Weather-Warnings.""" + data = { + ATTR_ATTRIBUTION: ATTRIBUTION, + 'region_name': self._api.region_name + } + + if self._api.region_id is not None: + data['region_id'] = self._api.region_id + + if self._api.region_state is not None: + data['region_state'] = self._api.region_state + + if self._api.data['time'] is not None: + data['last_update'] = dt_util.as_local( + dt_util.utc_from_timestamp(self._api.data['time'] / 1000)) + + if self._var_id == 'current_warning_level': + prefix = 'current' + elif self._var_id == 'advance_warning_level': + prefix = 'advance' + else: + raise Exception('Unknown warning type') + + data['warning_count'] = self._api.data[prefix + '_warning_count'] + i = 0 + for event in self._api.data[prefix + '_warnings']: + i = i + 1 + + data['warning_{}_name'.format(i)] = event['event'] + data['warning_{}_level'.format(i)] = event['level'] + data['warning_{}_type'.format(i)] = event['type'] + if len(event['headline']) > 0: + data['warning_{}_headline'.format(i)] = event['headline'] + if len(event['description']) > 0: + data['warning_{}_description'.format(i)] = event['description'] + if len(event['instruction']) > 0: + data['warning_{}_instruction'.format(i)] = event['instruction'] + + if event['start'] is not None: + data['warning_{}_start'.format(i)] = dt_util.as_local( + dt_util.utc_from_timestamp(event['start'] / 1000)) + + if event['end'] is not None: + data['warning_{}_end'.format(i)] = dt_util.as_local( + dt_util.utc_from_timestamp(event['end'] / 1000)) + + return data + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._api.available + + def update(self): + """Get the latest data from the DWD-Weather-Warnings API.""" + self._api.update() + + +class DwdWeatherWarningsAPI(object): + """Get the latest data and update the states.""" + + def __init__(self, region_name): + """Initialize the data object.""" + resource = "{}{}{}?{}".format( + 'https://', + 'www.dwd.de', + '/DWD/warnungen/warnapp_landkreise/json/warnings.json', + 'jsonp=loadWarnings' + ) + + self._rest = RestData('GET', resource, None, None, None, True) + self.region_name = region_name + self.region_id = None + self.region_state = None + self.data = None + self.available = True + self.update() + + @Throttle(SCAN_INTERVAL) + def update(self): + """Get the latest data from the DWD-Weather-Warnings.""" + try: + self._rest.update() + + json_string = self._rest.data[24:len(self._rest.data) - 2] + json_obj = json.loads(json_string) + + data = {'time': json_obj['time']} + + for mykey, myvalue in { + 'current': 'warnings', + 'advance': 'vorabInformation' + }.items(): + + _LOGGER.debug("Found %d %s global DWD warnings", + len(json_obj[myvalue]), mykey) + + data['{}_warning_level'.format(mykey)] = 0 + my_warnings = [] + + if self.region_id is not None: + # get a specific region_id + if self.region_id in json_obj[myvalue]: + my_warnings = json_obj[myvalue][self.region_id] + + else: + # loop through all items to find warnings, region_id + # and region_state for region_name + for key in json_obj[myvalue]: + my_region = json_obj[myvalue][key][0]['regionName'] + if my_region != self.region_name: + continue + my_warnings = json_obj[myvalue][key] + my_state = json_obj[myvalue][key][0]['stateShort'] + self.region_id = key + self.region_state = my_state + break + + # Get max warning level + maxlevel = data['{}_warning_level'.format(mykey)] + for event in my_warnings: + if event['level'] >= maxlevel: + data['{}_warning_level'.format(mykey)] = event['level'] + + data['{}_warning_count'.format(mykey)] = len(my_warnings) + data['{}_warnings'.format(mykey)] = my_warnings + + _LOGGER.debug("Found %d %s local DWD warnings", + len(my_warnings), mykey) + + self.data = data + self.available = True + except TypeError: + _LOGGER.error("Unable to fetch data from DWD-Weather-Warnings") + self.available = False diff --git a/homeassistant/components/sensor/envirophat.py b/homeassistant/components/sensor/envirophat.py index f2db833954f..ce5e2a81939 100644 --- a/homeassistant/components/sensor/envirophat.py +++ b/homeassistant/components/sensor/envirophat.py @@ -171,7 +171,7 @@ class EnvirophatData(object): self.light = self.envirophat.light.light() if self.use_leds: self.envirophat.leds.on() - # the three color values scaled agains the overall light, 0-255 + # the three color values scaled against the overall light, 0-255 self.light_red, self.light_green, self.light_blue = \ self.envirophat.light.rgb() if self.use_leds: diff --git a/homeassistant/components/sensor/envisalink.py b/homeassistant/components/sensor/envisalink.py index 7f1ee5c0d41..24cb224570c 100644 --- a/homeassistant/components/sensor/envisalink.py +++ b/homeassistant/components/sensor/envisalink.py @@ -77,4 +77,4 @@ class EnvisalinkSensor(EnvisalinkDevice, Entity): def _update_callback(self, partition): """Update the partition state in HA, 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() diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 605805c028d..1bb6383ecbb 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -37,8 +37,8 @@ CONF_ATTRIBUTION = 'Data provided by Fitbit.com' DEPENDENCIES = ['http'] -FITBIT_AUTH_CALLBACK_PATH = '/auth/fitbit/callback' -FITBIT_AUTH_START = '/auth/fitbit' +FITBIT_AUTH_CALLBACK_PATH = '/api/fitbit/callback' +FITBIT_AUTH_START = '/api/fitbit' FITBIT_CONFIG_FILE = 'fitbit.conf' FITBIT_DEFAULT_RESOURCES = ['activities/steps'] @@ -260,13 +260,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): access_token = config_file.get(ATTR_ACCESS_TOKEN) refresh_token = config_file.get(ATTR_REFRESH_TOKEN) + expires_at = config_file.get(ATTR_LAST_SAVED_AT) if None not in (access_token, refresh_token): authd_client = fitbit.Fitbit(config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_CLIENT_SECRET), access_token=access_token, - refresh_token=refresh_token) + refresh_token=refresh_token, + expires_at=expires_at, + refresh_cb=lambda x: None) - if int(time.time()) - config_file.get(ATTR_LAST_SAVED_AT, 0) > 3600: + if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() authd_client.system = authd_client.user_profile_get()["user"]["locale"] @@ -317,8 +320,8 @@ class FitbitAuthCallbackView(HomeAssistantView): """Handle OAuth finish callback requests.""" requires_auth = False - url = '/auth/fitbit/callback' - name = 'auth:fitbit:callback' + url = FITBIT_AUTH_CALLBACK_PATH + name = 'api:fitbit:callback' def __init__(self, config, add_devices, oauth): """Initialize the OAuth callback view.""" @@ -338,12 +341,14 @@ class FitbitAuthCallbackView(HomeAssistantView): response_message = """Fitbit has been successfully authorized! You can close this window now!""" + result = None if data.get('code') is not None: redirect_uri = '{}{}'.format( hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH) try: - self.oauth.fetch_access_token(data.get('code'), redirect_uri) + result = self.oauth.fetch_access_token(data.get('code'), + redirect_uri) except MissingTokenError as error: _LOGGER.error("Missing token: %s", error) response_message = """Something went wrong when @@ -361,15 +366,24 @@ class FitbitAuthCallbackView(HomeAssistantView): An unknown error occurred. Please try again! """ + if result is None: + _LOGGER.error("Unknown error when authing") + response_message = """Something went wrong when + attempting authenticating with Fitbit. + An unknown error occurred. Please try again! + """ + html_response = """Fitbit Auth

{}

""".format(response_message) - config_contents = { - ATTR_ACCESS_TOKEN: self.oauth.token['access_token'], - ATTR_REFRESH_TOKEN: self.oauth.token['refresh_token'], - ATTR_CLIENT_ID: self.oauth.client_id, - ATTR_CLIENT_SECRET: self.oauth.client_secret - } + if result: + config_contents = { + ATTR_ACCESS_TOKEN: result.get('access_token'), + ATTR_REFRESH_TOKEN: result.get('refresh_token'), + ATTR_CLIENT_ID: self.oauth.client_id, + ATTR_CLIENT_SECRET: self.oauth.client_secret, + ATTR_LAST_SAVED_AT: int(time.time()) + } if not config_from_file(hass.config.path(FITBIT_CONFIG_FILE), config_contents): _LOGGER.error("Failed to save config file") @@ -490,9 +504,11 @@ class FitbitSensor(Entity): if self.resource_type == 'activities/heart': self._state = response[container][-1]. \ get('value').get('restingHeartRate') + + token = self.client.client.session.token config_contents = { - ATTR_ACCESS_TOKEN: self.client.client.token['access_token'], - ATTR_REFRESH_TOKEN: self.client.client.token['refresh_token'], + ATTR_ACCESS_TOKEN: token.get('access_token'), + ATTR_REFRESH_TOKEN: token.get('refresh_token'), ATTR_CLIENT_ID: self.client.client.client_id, ATTR_CLIENT_SECRET: self.client.client.client_secret, ATTR_LAST_SAVED_AT: int(time.time()) diff --git a/homeassistant/components/sensor/geo_rss_events.py b/homeassistant/components/sensor/geo_rss_events.py new file mode 100644 index 00000000000..484dd67e0e4 --- /dev/null +++ b/homeassistant/components/sensor/geo_rss_events.py @@ -0,0 +1,243 @@ +""" +Generic GeoRSS events service. + +Retrieves current events (typically incidents or alerts) in GeoRSS format, and +shows information on events filtered by distance to the HA instance's location +and grouped by category. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.geo_rss_events/ +""" + +import logging +from collections import namedtuple +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT, + CONF_NAME) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['feedparser==5.2.1', 'haversine==0.4.5'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_CATEGORY = 'category' +ATTR_DISTANCE = 'distance' +ATTR_TITLE = 'title' + +CONF_CATEGORIES = 'categories' +CONF_RADIUS = 'radius' +CONF_URL = 'url' + +DEFAULT_ICON = 'mdi:alert' +DEFAULT_NAME = "Event Service" +DEFAULT_RADIUS_IN_KM = 20.0 +DEFAULT_UNIT_OF_MEASUREMENT = 'Events' + +DOMAIN = 'geo_rss_events' + +# Minimum time between updates from the source. +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + +SCAN_INTERVAL = timedelta(minutes=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CATEGORIES, default=[]): vol.All(cv.ensure_list, + [cv.string]), + vol.Optional(CONF_UNIT_OF_MEASUREMENT, + default=DEFAULT_UNIT_OF_MEASUREMENT): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the GeoRSS component.""" + # Grab location from config + home_latitude = hass.config.latitude + home_longitude = hass.config.longitude + url = config.get(CONF_URL) + radius_in_km = config.get(CONF_RADIUS) + name = config.get(CONF_NAME) + categories = config.get(CONF_CATEGORIES) + unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + + _LOGGER.debug("latitude=%s, longitude=%s, url=%s, radius=%s", + home_latitude, home_longitude, url, radius_in_km) + + # Initialise update service. + data = GeoRssServiceData(home_latitude, home_longitude, url, radius_in_km) + data.update() + + # Create all sensors based on categories. + devices = [] + if not categories: + device = GeoRssServiceSensor(None, data, name, unit_of_measurement) + devices.append(device) + else: + for category in categories: + device = GeoRssServiceSensor(category, data, name, + unit_of_measurement) + devices.append(device) + add_devices(devices, True) + + +class GeoRssServiceSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, category, data, service_name, unit_of_measurement): + """Initialize the sensor.""" + self._category = category + self._data = data + self._service_name = service_name + self._state = STATE_UNKNOWN + self._state_attributes = None + self._unit_of_measurement = unit_of_measurement + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self._service_name, + 'Any' if self._category is None + else self._category) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the default icon to use in the frontend.""" + return DEFAULT_ICON + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._state_attributes + + def update(self): + """Update this sensor from the GeoRSS service.""" + _LOGGER.debug("About to update sensor %s", self.entity_id) + self._data.update() + # If no events were found due to an error then just set state to zero. + if self._data.events is None: + self._state = 0 + else: + if self._category is None: + # Add all events regardless of category. + my_events = self._data.events + else: + # Only keep events that belong to sensor's category. + my_events = [event for event in self._data.events if + event[ATTR_CATEGORY] == self._category] + _LOGGER.debug("Adding events to sensor %s: %s", self.entity_id, + my_events) + self._state = len(my_events) + # And now compute the attributes from the filtered events. + matrix = {} + for event in my_events: + matrix[event[ATTR_TITLE]] = '{:.0f}km'.format( + event[ATTR_DISTANCE]) + self._state_attributes = matrix + + +class GeoRssServiceData(object): + """Provides access to GeoRSS feed and stores the latest data.""" + + def __init__(self, home_latitude, home_longitude, url, radius_in_km): + """Initialize the update service.""" + self._home_coordinates = [home_latitude, home_longitude] + self._url = url + self._radius_in_km = radius_in_km + self.events = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Retrieve data from GeoRSS feed and store events.""" + import feedparser + feed_data = feedparser.parse(self._url) + if not feed_data: + _LOGGER.error("Error fetching feed data from %s", self._url) + else: + events = self.filter_entries(feed_data) + self.events = events + + def filter_entries(self, feed_data): + """Filter entries by distance from home coordinates.""" + events = [] + _LOGGER.debug("%s entri(es) available in feed %s", + len(feed_data.entries), self._url) + for entry in feed_data.entries: + geometry = None + if hasattr(entry, 'where'): + geometry = entry.where + elif hasattr(entry, 'geo_lat') and hasattr(entry, 'geo_long'): + coordinates = (float(entry.geo_long), float(entry.geo_lat)) + point = namedtuple('Point', ['type', 'coordinates']) + geometry = point('Point', coordinates) + if geometry: + distance = self.calculate_distance_to_geometry(geometry) + if distance <= self._radius_in_km: + event = { + ATTR_CATEGORY: None if not hasattr( + entry, 'category') else entry.category, + ATTR_TITLE: None if not hasattr( + entry, 'title') else entry.title, + ATTR_DISTANCE: distance + } + events.append(event) + _LOGGER.debug("%s events found nearby", len(events)) + return events + + def calculate_distance_to_geometry(self, geometry): + """Calculate the distance between HA and provided geometry.""" + distance = float("inf") + if geometry.type == 'Point': + distance = self.calculate_distance_to_point(geometry) + elif geometry.type == 'Polygon': + distance = self.calculate_distance_to_polygon( + geometry.coordinates[0]) + else: + _LOGGER.warning("Not yet implemented: %s", geometry.type) + return distance + + def calculate_distance_to_point(self, point): + """Calculate the distance between HA and the provided point.""" + # Swap coordinates to match: (lat, lon). + coordinates = (point.coordinates[1], point.coordinates[0]) + return self.calculate_distance_to_coords(coordinates) + + def calculate_distance_to_coords(self, coordinates): + """Calculate the distance between HA and the provided coordinates.""" + # Expecting coordinates in format: (lat, lon). + from haversine import haversine + distance = haversine(coordinates, self._home_coordinates) + _LOGGER.debug("Distance from %s to %s: %s km", self._home_coordinates, + coordinates, distance) + return distance + + def calculate_distance_to_polygon(self, polygon): + """Calculate the distance between HA and the provided polygon.""" + distance = float("inf") + # Calculate distance from polygon by calculating the distance + # to each point of the polygon but not to each edge of the + # polygon; should be good enough + for polygon_point in polygon: + coordinates = (polygon_point[1], polygon_point[0]) + distance = min(distance, + self.calculate_distance_to_coords(coordinates)) + _LOGGER.debug("Distance from %s to %s: %s km", self._home_coordinates, + polygon, distance) + return distance diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index 771b4a94bd4..2edfe6648f3 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -16,6 +16,7 @@ HM_STATE_HA_CAST = { 'RotaryHandleSensor': {0: 'closed', 1: 'tilted', 2: 'open'}, 'WaterSensor': {0: 'dry', 1: 'wet', 2: 'water'}, 'CO2Sensor': {0: 'normal', 1: 'added', 2: 'strong'}, + 'IPSmoke': {0: 'off', 1: 'primary', 2: 'intrusion', 3: 'secondary'} } HM_UNIT_HA_CAST = { @@ -57,8 +58,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSensor(hass, conf) - new_device.link_homematic() + new_device = HMSensor(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/sensor/imap.py b/homeassistant/components/sensor/imap.py index 849f3fd8100..9d66537079f 100644 --- a/homeassistant/components/sensor/imap.py +++ b/homeassistant/components/sensor/imap.py @@ -5,20 +5,27 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.imap/ """ import logging +import asyncio +import async_timeout import voluptuous as vol from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_PORT, CONF_USERNAME, CONF_PASSWORD) + CONF_NAME, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_SERVER = "server" +REQUIREMENTS = ['aioimaplib==0.7.12'] + +CONF_SERVER = 'server' DEFAULT_PORT = 993 + ICON = 'mdi:email-outline' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -30,17 +37,20 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the IMAP platform.""" - sensor = ImapSensor( - config.get(CONF_NAME, None), config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), config.get(CONF_SERVER), - config.get(CONF_PORT)) +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup the IMAP platform.""" + sensor = ImapSensor(config.get(CONF_NAME), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + config.get(CONF_SERVER), + config.get(CONF_PORT)) - if sensor.connection: - add_devices([sensor], True) - else: - return False + if not (yield from sensor.connection()): + raise PlatformNotReady + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, sensor.shutdown()) + async_add_devices([sensor], True) class ImapSensor(Entity): @@ -54,45 +64,110 @@ class ImapSensor(Entity): self._server = server self._port = port self._unread_count = 0 - self.connection = self._login() + self._connection = None + self._does_push = None + self._idle_loop_task = None - def _login(self): - """Login and return an IMAP connection.""" - import imaplib - try: - connection = imaplib.IMAP4_SSL(self._server, self._port) - connection.login(self._user, self._password) - return connection - except imaplib.IMAP4.error: - _LOGGER.error("Failed to login to %s.", self._server) - return False + @asyncio.coroutine + def async_added_to_hass(self): + """Handle when an entity is about to be added to Home Assistant.""" + if not self.should_poll: + self._idle_loop_task = self.hass.loop.create_task(self.idle_loop()) @property def name(self): """Return the name of the sensor.""" return self._name + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON + @property def state(self): """Return the number of unread emails.""" return self._unread_count - def update(self): - """Check the number of unread emails.""" - import imaplib - try: - self.connection.select() - self._unread_count = len(self.connection.search( - None, 'UnSeen UnDeleted')[1][0].split()) - except imaplib.IMAP4.error: - _LOGGER.info("Connection to %s lost, attempting to reconnect", - self._server) - try: - self.connection = self._login() - except imaplib.IMAP4.error: - _LOGGER.error("Failed to reconnect.") + @property + def available(self): + """Return the availability of the device.""" + return self._connection is not None @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON + def should_poll(self): + """Return if polling is needed.""" + return not self._does_push + + @asyncio.coroutine + def connection(self): + """Return a connection to the server, establishing it if necessary.""" + import aioimaplib + + if self._connection is None: + try: + self._connection = aioimaplib.IMAP4_SSL( + self._server, self._port) + yield from self._connection.wait_hello_from_server() + yield from self._connection.login(self._user, self._password) + yield from self._connection.select() + self._does_push = self._connection.has_capability('IDLE') + except (aioimaplib.AioImapException, asyncio.TimeoutError): + self._connection = None + + return self._connection + + @asyncio.coroutine + def idle_loop(self): + """Wait for data pushed from server.""" + import aioimaplib + + while True: + try: + if (yield from self.connection()): + yield from self.refresh_unread_count() + yield from self.async_update_ha_state() + + idle = yield from self._connection.idle_start() + yield from self._connection.wait_server_push() + self._connection.idle_done() + with async_timeout.timeout(10): + yield from idle + else: + yield from self.async_update_ha_state() + except (aioimaplib.AioImapException, asyncio.TimeoutError): + self.disconnected() + + @asyncio.coroutine + def async_update(self): + """Periodic polling of state.""" + import aioimaplib + + try: + if (yield from self.connection()): + yield from self.refresh_unread_count() + except (aioimaplib.AioImapException, asyncio.TimeoutError): + self.disconnected() + + @asyncio.coroutine + def refresh_unread_count(self): + """Check the number of unread emails.""" + if self._connection: + yield from self._connection.noop() + _, lines = yield from self._connection.search('UnSeen UnDeleted') + self._unread_count = len(lines[0].split()) + + def disconnected(self): + """Forget the connection after it was lost.""" + _LOGGER.warning("Lost %s (will attempt to reconnect)", self._server) + self._connection = None + + @asyncio.coroutine + def shutdown(self): + """Close resources.""" + if self._connection: + if self._connection.has_pending_idle(): + self._connection.idle_done() + yield from self._connection.logout() + if self._idle_loop_task: + self._idle_loop_task.cancel() diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 80a88ca925a..7abc986bdd7 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -1,184 +1,111 @@ """ -Sensors of a KNX Device. +Support for KNX/IP sensors. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/knx/ +https://home-assistant.io/components/sensor.knx/ """ -from enum import Enum - -import logging +import asyncio import voluptuous as vol -from homeassistant.const import ( - CONF_NAME, CONF_MAXIMUM, CONF_MINIMUM, - CONF_TYPE, TEMP_CELSIUS -) -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) +CONF_ADDRESS = 'address' +CONF_TYPE = 'type' +DEFAULT_NAME = 'KNX Sensor' DEPENDENCIES = ['knx'] -DEFAULT_NAME = "KNX sensor" - -CONF_TEMPERATURE = 'temperature' -CONF_ADDRESS = 'address' -CONF_ILLUMINANCE = 'illuminance' -CONF_PERCENTAGE = 'percentage' -CONF_SPEED_MS = 'speed_ms' - - -class KNXAddressType(Enum): - """Enum to indicate conversion type for the KNX address.""" - - FLOAT = 1 - PERCENT = 2 - - -# define the fixed settings required for each sensor type -FIXED_SETTINGS_MAP = { - # Temperature as defined in KNX Standard 3.10 - 9.001 DPT_Value_Temp - CONF_TEMPERATURE: { - 'unit': TEMP_CELSIUS, - 'default_minimum': -273, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Speed m/s as defined in KNX Standard 3.10 - 9.005 DPT_Value_Wsp - CONF_SPEED_MS: { - 'unit': 'm/s', - 'default_minimum': 0, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Luminance(LUX) as defined in KNX Standard 3.10 - 9.004 DPT_Value_Lux - CONF_ILLUMINANCE: { - 'unit': 'lx', - 'default_minimum': 0, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Percentage(%) as defined in KNX Standard 3.10 - 5.001 DPT_Scaling - CONF_PERCENTAGE: { - 'unit': '%', - 'default_minimum': 0, - 'default_maximum': 100, - 'address_type': KNXAddressType.PERCENT - } -} - -SENSOR_TYPES = set(FIXED_SETTINGS_MAP.keys()) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), vol.Required(CONF_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MINIMUM): vol.Coerce(float), - vol.Optional(CONF_MAXIMUM): vol.Coerce(float) + vol.Optional(CONF_TYPE): cv.string, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX Sensor platform.""" - add_devices([KNXSensor(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up sensor(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + return True -class KNXSensor(KNXGroupAddress): - """Representation of a KNX Sensor device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, async_add_devices): + """Set up 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(KNXSensor(hass, device)) + async_add_devices(entities) - def __init__(self, hass, config): - """Initialize a KNX Float Sensor.""" - # set up the KNX Group address - KNXGroupAddress.__init__(self, hass, config) - device_type = config.config.get(CONF_TYPE) - sensor_config = FIXED_SETTINGS_MAP.get(device_type) +@callback +def async_add_devices_config(hass, config, async_add_devices): + """Set up sensor for KNX platform configured within plattform.""" + import xknx + sensor = xknx.devices.Sensor( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + value_type=config.get(CONF_TYPE)) + hass.data[DATA_KNX].xknx.devices.add(sensor) + async_add_devices([KNXSensor(hass, sensor)]) - if not sensor_config: - raise NotImplementedError() - # set up the conversion function based on the address type - address_type = sensor_config.get('address_type') - if address_type == KNXAddressType.FLOAT: - self.convert = convert_float - elif address_type == KNXAddressType.PERCENT: - self.convert = convert_percent - else: - raise NotImplementedError() +class KNXSensor(Entity): + """Representation of a KNX sensor.""" - # other settings - self._unit_of_measurement = sensor_config.get('unit') - default_min = float(sensor_config.get('default_minimum')) - default_max = float(sensor_config.get('default_maximum')) - self._minimum_value = config.config.get(CONF_MINIMUM, default_min) - self._maximum_value = config.config.get(CONF_MAXIMUM, default_max) - _LOGGER.debug( - "%s: configured additional settings: unit=%s, " - "min=%f, max=%f, type=%s", - self.name, self._unit_of_measurement, - self._minimum_value, self._maximum_value, str(address_type) - ) + def __init__(self, hass, device): + """Initialization of KNXSensor.""" + self.device = device + self.hass = hass + self.async_register_callbacks() - self._value = None + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False @property def state(self): - """Return the Value of the KNX Sensor.""" - return self._value + """Return the state of the sensor.""" + return self.device.resolve_state() @property def unit_of_measurement(self): - """Return the defined Unit of Measurement for the KNX Sensor.""" - return self._unit_of_measurement - - def update(self): - """Update KNX sensor.""" - super().update() - - self._value = None - - if self._data: - if self._data == 0: - value = 0 - else: - value = self.convert(self._data) - if self._minimum_value <= value <= self._maximum_value: - self._value = value + """Return the unit this state is expressed in.""" + return self.device.unit_of_measurement() @property - def cache(self): - """We don't want to cache any Sensor Value.""" - return False - - -def convert_float(raw_value): - """Conversion for 2 byte floating point values. - - 2byte Floating Point KNX Telegram. - Defined in KNX 3.7.2 - 3.10 - """ - from knxip.conversion import knx2_to_float - from knxip.core import KNXException - - try: - return knx2_to_float(raw_value) - except KNXException as exception: - _LOGGER.error("Can't convert %s to float (%s)", raw_value, exception) - - -def convert_percent(raw_value): - """Conversion for scaled byte values. - - 1byte percentage scaled KNX Telegram. - Defined in KNX 3.7.2 - 3.10. - """ - value = 0 - try: - value = raw_value[0] - except (IndexError, ValueError): - # pknx returns a non-iterable type for unsuccessful reads - _LOGGER.error("Can't convert %s to percent value", raw_value) - - return round(value * 100 / 255) + def device_state_attributes(self): + """Return the state attributes.""" + return None diff --git a/homeassistant/components/sensor/lyft.py b/homeassistant/components/sensor/lyft.py index 11ca07f7fb8..0efc4063dc2 100644 --- a/homeassistant/components/sensor/lyft.py +++ b/homeassistant/components/sensor/lyft.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['lyft_rides==0.1.0b0'] +REQUIREMENTS = ['lyft_rides==0.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/modbus.py b/homeassistant/components/sensor/modbus.py index 9453daea413..0b2198bd396 100644 --- a/homeassistant/components/sensor/modbus.py +++ b/homeassistant/components/sensor/modbus.py @@ -71,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class ModbusRegisterSensor(Entity): - """Modbus resgister sensor.""" + """Modbus register sensor.""" def __init__(self, name, slave, register, register_type, unit_of_measurement, count, scale, offset, data_type, diff --git a/homeassistant/components/sensor/mopar.py b/homeassistant/components/sensor/mopar.py new file mode 100644 index 00000000000..66eea20ec70 --- /dev/null +++ b/homeassistant/components/sensor/mopar.py @@ -0,0 +1,166 @@ +""" +Sensor for Mopar vehicles. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mopar/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PIN, + ATTR_ATTRIBUTION, ATTR_COMMAND, + LENGTH_KILOMETERS) +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + + +REQUIREMENTS = ['motorparts==1.0.2'] + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(days=7) +DOMAIN = 'mopar' +ATTR_VEHICLE_INDEX = 'vehicle_index' +SERVICE_REMOTE_COMMAND = 'remote_command' +COOKIE_FILE = 'mopar_cookies.pickle' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PIN): cv.positive_int +}) + +REMOTE_COMMAND_SCHEMA = vol.Schema({ + vol.Required(ATTR_COMMAND): cv.string, + vol.Required(ATTR_VEHICLE_INDEX): cv.positive_int +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Mopar platform.""" + import motorparts + cookie = hass.config.path(COOKIE_FILE) + try: + session = motorparts.get_session(config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + config.get(CONF_PIN), + cookie_path=cookie) + except motorparts.MoparError: + _LOGGER.error("failed to login") + return False + + def _handle_service(service): + """Handle service call.""" + index = service.data.get(ATTR_VEHICLE_INDEX) + command = service.data.get(ATTR_COMMAND) + try: + motorparts.remote_command(session, command, index) + except motorparts.MoparError as error: + _LOGGER.error(str(error)) + + hass.services.register(DOMAIN, SERVICE_REMOTE_COMMAND, _handle_service, + schema=REMOTE_COMMAND_SCHEMA) + + data = MoparData(session) + add_devices([MoparSensor(data, index) + for index, _ in enumerate(data.vehicles)], + True) + return True + + +# pylint: disable=too-few-public-methods +class MoparData(object): + """Container for Mopar vehicle data. + + Prevents session expiry re-login race condition. + """ + + def __init__(self, session): + """Initialize data.""" + self._session = session + self.vehicles = [] + self.vhrs = {} + self.tow_guides = {} + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Update data.""" + import motorparts + _LOGGER.info("updating vehicle data") + try: + self.vehicles = motorparts.get_summary(self._session)['vehicles'] + except motorparts.MoparError: + _LOGGER.exception("failed to get summary") + return + for index, _ in enumerate(self.vehicles): + try: + self.vhrs[index] = motorparts.get_report(self._session, index) + self.tow_guides[index] = motorparts.get_tow_guide( + self._session, index) + except motorparts.MoparError: + _LOGGER.warning("failed to update for vehicle index %s", index) + + +class MoparSensor(Entity): + """Mopar vehicle sensor.""" + + def __init__(self, data, index): + """Initialize the sensor.""" + self._index = index + self._vehicle = {} + self._vhr = {} + self._tow_guide = {} + self._odometer = None + self._data = data + + def update(self): + """Update device state.""" + self._data.update() + self._vehicle = self._data.vehicles[self._index] + self._vhr = self._data.vhrs.get(self._index, {}) + self._tow_guide = self._data.tow_guides.get(self._index, {}) + if 'odometer' in self._vhr: + odo = float(self._vhr['odometer']) + self._odometer = int(self.hass.config.units.length( + odo, LENGTH_KILOMETERS)) + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {} {}'.format(self._vehicle['year'], + self._vehicle['make'], + self._vehicle['model']) + + @property + def state(self): + """Return the state of the sensor.""" + return self._odometer + + @property + def device_state_attributes(self): + """Return the state attributes.""" + import motorparts + attributes = { + ATTR_VEHICLE_INDEX: self._index, + ATTR_ATTRIBUTION: motorparts.ATTRIBUTION + } + attributes.update(self._vehicle) + attributes.update(self._vhr) + attributes.update(self._tow_guide) + return attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.hass.config.units.length_unit + + @property + def icon(self): + """Return the icon.""" + return 'mdi:car' diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 63b015b3dfd..70b1294c13f 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -100,7 +100,7 @@ class MqttSensor(Entity): payload = self._template.async_render_with_possible_json_value( payload, self._state) 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) @@ -110,7 +110,7 @@ class MqttSensor(Entity): """Triggered when value is expired.""" self._expiration_trigger = None self._state = STATE_UNKNOWN - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def should_poll(self): diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index 3d0dbd68afa..e14922a1579 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -96,7 +96,7 @@ class MQTTRoomSensor(Entity): self._distance = distance self._updated = dt.utcnow() - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def message_received(topic, payload, qos): diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py index d46680c7b66..a8daf212e57 100644 --- a/homeassistant/components/sensor/mysensors.py +++ b/homeassistant/components/sensor/mysensors.py @@ -4,89 +4,18 @@ Support for MySensors sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.mysensors/ """ -import logging - from homeassistant.components import mysensors +from homeassistant.components.sensor import DOMAIN from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.helpers.entity import Entity - -_LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the MySensors platform for sensors.""" - # Only act if loaded via mysensors by discovery event. - # Otherwise gateway is not setup. - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_TEMP: [set_req.V_TEMP], - pres.S_HUM: [set_req.V_HUM], - pres.S_BARO: [set_req.V_PRESSURE, set_req.V_FORECAST], - pres.S_WIND: [set_req.V_WIND, set_req.V_GUST, set_req.V_DIRECTION], - pres.S_RAIN: [set_req.V_RAIN, set_req.V_RAINRATE], - pres.S_UV: [set_req.V_UV], - pres.S_WEIGHT: [set_req.V_WEIGHT, set_req.V_IMPEDANCE], - pres.S_POWER: [set_req.V_WATT, set_req.V_KWH], - pres.S_DISTANCE: [set_req.V_DISTANCE], - pres.S_LIGHT_LEVEL: [set_req.V_LIGHT_LEVEL], - pres.S_IR: [set_req.V_IR_RECEIVE], - pres.S_WATER: [set_req.V_FLOW, set_req.V_VOLUME], - pres.S_CUSTOM: [set_req.V_VAR1, - set_req.V_VAR2, - set_req.V_VAR3, - set_req.V_VAR4, - set_req.V_VAR5], - pres.S_SCENE_CONTROLLER: [set_req.V_SCENE_ON, - set_req.V_SCENE_OFF], - } - if float(gateway.protocol_version) < 1.5: - map_sv_types.update({ - pres.S_AIR_QUALITY: [set_req.V_DUST_LEVEL], - pres.S_DUST: [set_req.V_DUST_LEVEL], - }) - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_COLOR_SENSOR: [set_req.V_RGB], - pres.S_MULTIMETER: [set_req.V_VOLTAGE, - set_req.V_CURRENT, - set_req.V_IMPEDANCE], - pres.S_SOUND: [set_req.V_LEVEL], - pres.S_VIBRATION: [set_req.V_LEVEL], - pres.S_MOISTURE: [set_req.V_LEVEL], - pres.S_AIR_QUALITY: [set_req.V_LEVEL], - pres.S_DUST: [set_req.V_LEVEL], - }) - map_sv_types[pres.S_LIGHT_LEVEL].append(set_req.V_LEVEL) - - if float(gateway.protocol_version) >= 2.0: - map_sv_types.update({ - pres.S_INFO: [set_req.V_TEXT], - pres.S_GAS: [set_req.V_FLOW, set_req.V_VOLUME], - pres.S_GPS: [set_req.V_POSITION], - pres.S_WATER_QUALITY: [set_req.V_TEMP, set_req.V_PH, - set_req.V_ORP, set_req.V_EC] - }) - map_sv_types[pres.S_CUSTOM].append(set_req.V_CUSTOM) - map_sv_types[pres.S_POWER].extend( - [set_req.V_VAR, set_req.V_VA, set_req.V_POWER_FACTOR]) - - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsSensor, add_devices)) + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsSensor, add_devices=add_devices) -class MySensorsSensor(mysensors.MySensorsDeviceEntity, Entity): +class MySensorsSensor(mysensors.MySensorsEntity): """Representation of a MySensors Sensor child node.""" @property diff --git a/homeassistant/components/sensor/onewire.py b/homeassistant/components/sensor/onewire.py index 5cbbe6ed0aa..b36e7bdf267 100644 --- a/homeassistant/components/sensor/onewire.py +++ b/homeassistant/components/sensor/onewire.py @@ -1,5 +1,5 @@ """ -Support for 1-Wire temperature sensors. +Support for 1-Wire environment sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.onewire/ @@ -22,7 +22,22 @@ CONF_MOUNT_DIR = 'mount_dir' CONF_NAMES = 'names' DEFAULT_MOUNT_DIR = '/sys/bus/w1/devices/' -DEVICE_FAMILIES = ('10', '22', '28', '3B', '42') +DEVICE_SENSORS = {'10': {'temperature': 'temperature'}, + '12': {'temperature': 'TAI8570/temperature', + 'pressure': 'TAI8570/pressure'}, + '22': {'temperature': 'temperature'}, + '26': {'temperature': 'temperature', + 'humidity': 'humidity', + 'pressure': 'B1-R1-A/pressure'}, + '28': {'temperature': 'temperature'}, + '3B': {'temperature': 'temperature'}, + '42': {'temperature': 'temperature'}} + +SENSOR_TYPES = { + 'temperature': ['temperature', TEMP_CELSIUS], + 'humidity': ['humidity', '%'], + 'pressure': ['pressure', 'mb'], +} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAMES): {cv.string: cv.string}, @@ -34,63 +49,54 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the one wire Sensors.""" base_dir = config.get(CONF_MOUNT_DIR) - sensor_ids = [] - device_files = [] + devs = [] + device_names = {} + if 'names' in config: + if isinstance(config['names'], dict): + device_names = config['names'] + if base_dir == DEFAULT_MOUNT_DIR: - for device_family in DEVICE_FAMILIES: + for device_family in DEVICE_SENSORS: for device_folder in glob(os.path.join(base_dir, device_family + '[.-]*')): - sensor_ids.append(os.path.split(device_folder)[1]) - device_files.append(os.path.join(device_folder, 'w1_slave')) + sensor_id = os.path.split(device_folder)[1] + device_file = os.path.join(device_folder, 'w1_slave') + devs.append(OneWire(device_names.get(sensor_id, sensor_id), + device_file, 'temperature')) else: for family_file_path in glob(os.path.join(base_dir, '*', 'family')): family_file = open(family_file_path, "r") family = family_file.read() - if family in DEVICE_FAMILIES: - sensor_id = os.path.split( - os.path.split(family_file_path)[0])[1] - sensor_ids.append(sensor_id) - device_files.append(os.path.join( - os.path.split(family_file_path)[0], 'temperature')) + if family in DEVICE_SENSORS: + for sensor_key, sensor_value in DEVICE_SENSORS[family].items(): + sensor_id = os.path.split( + os.path.split(family_file_path)[0])[1] + device_file = os.path.join( + os.path.split(family_file_path)[0], sensor_value) + devs.append(OneWire(device_names.get(sensor_id, sensor_id), + device_file, sensor_key)) - if device_files == []: + if devs == []: _LOGGER.error("No onewire sensor found. Check if dtoverlay=w1-gpio " "is in your /boot/config.txt. " "Check the mount_dir parameter if it's defined") return - devs = [] - names = sensor_ids - - for key in config.keys(): - if key == 'names': - # Only one name given - if isinstance(config['names'], str): - names = [config['names']] - # Map names and sensors in given order - elif isinstance(config['names'], list): - names = config['names'] - # Map names to ids. - elif isinstance(config['names'], dict): - names = [] - for sensor_id in sensor_ids: - names.append(config['names'].get(sensor_id, sensor_id)) - for device_file, name in zip(device_files, names): - devs.append(OneWire(name, device_file)) add_devices(devs, True) class OneWire(Entity): """Implementation of an One wire Sensor.""" - def __init__(self, name, device_file): + def __init__(self, name, device_file, sensor_type): """Initialize the sensor.""" - self._name = name + self._name = name+' '+sensor_type.capitalize() self._device_file = device_file + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._state = None - def _read_temp_raw(self): - """Read the temperature as it is returned by the sensor.""" + def _read_value_raw(self): + """Read the value as it is returned by the sensor.""" ds_device_file = open(self._device_file, 'r') lines = ds_device_file.readlines() ds_device_file.close() @@ -109,34 +115,32 @@ class OneWire(Entity): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return TEMP_CELSIUS + return self._unit_of_measurement def update(self): """Get the latest data from the device.""" - temp = -99 + value = None if self._device_file.startswith(DEFAULT_MOUNT_DIR): - lines = self._read_temp_raw() + lines = self._read_value_raw() while lines[0].strip()[-3:] != 'YES': time.sleep(0.2) - lines = self._read_temp_raw() + lines = self._read_value_raw() equals_pos = lines[1].find('t=') if equals_pos != -1: - temp_string = lines[1][equals_pos+2:] - temp = round(float(temp_string) / 1000.0, 1) + value_string = lines[1][equals_pos+2:] + value = round(float(value_string) / 1000.0, 1) else: try: ds_device_file = open(self._device_file, 'r') - temp_read = ds_device_file.readlines() + value_read = ds_device_file.readlines() ds_device_file.close() - if len(temp_read) == 1: - temp = round(float(temp_read[0]), 1) + if len(value_read) == 1: + value = round(float(value_read[0]), 1) except ValueError: - _LOGGER.warning("Invalid temperature value read from %s", + _LOGGER.warning("Invalid value read from %s", self._device_file) except FileNotFoundError: _LOGGER.warning( "Cannot read from sensor: %s", self._device_file) - if temp < -55 or temp > 125: - return - self._state = temp + self._state = value diff --git a/homeassistant/components/sensor/otp.py b/homeassistant/components/sensor/otp.py index 5d7808ea4c7..6ceed11a6b9 100644 --- a/homeassistant/components/sensor/otp.py +++ b/homeassistant/components/sensor/otp.py @@ -62,7 +62,7 @@ class TOTPSensor(Entity): @callback def _call_loop(self): self._state = self._otp.now() - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Update must occur at even TIME_STEP, e.g. 12:00:00, 12:00:30, # 12:01:00, etc. in order to have synced time (see RFC6238) diff --git a/homeassistant/components/sensor/radarr.py b/homeassistant/components/sensor/radarr.py index 03fbce3e79a..33a09a51aef 100644 --- a/homeassistant/components/sensor/radarr.py +++ b/homeassistant/components/sensor/radarr.py @@ -162,7 +162,7 @@ class RadarrSensor(Entity): res = requests.get( ENDPOINTS[self.type].format( self.ssl, self.host, self.port, self.urlbase, start, end), - headers={'X-Api-Key': self.apikey}, timeout=5) + headers={'X-Api-Key': self.apikey}, timeout=10) except OSError: _LOGGER.error("Host %s is not available", self.host) self._available = False diff --git a/homeassistant/components/sensor/season.py b/homeassistant/components/sensor/season.py new file mode 100644 index 00000000000..e02f3cac2b0 --- /dev/null +++ b/homeassistant/components/sensor/season.py @@ -0,0 +1,122 @@ +""" +Support for tracking which astronomical or meteorological season it is. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor/season/ +""" +import logging +from datetime import datetime + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_TYPE +from homeassistant.helpers.entity import Entity +import homeassistant.util as util + +REQUIREMENTS = ['ephem==3.7.6.0'] + +_LOGGER = logging.getLogger(__name__) + +NORTHERN = 'northern' +SOUTHERN = 'southern' +EQUATOR = 'equator' +STATE_SPRING = 'Spring' +STATE_SUMMER = 'Summer' +STATE_AUTUMN = 'Autumn' +STATE_WINTER = 'Winter' +TYPE_ASTRONOMICAL = 'astronomical' +TYPE_METEOROLOGICAL = 'meteorological' +VALID_TYPES = [TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL] + +HEMISPHERE_SEASON_SWAP = {STATE_WINTER: STATE_SUMMER, + STATE_SPRING: STATE_AUTUMN, + STATE_AUTUMN: STATE_SPRING, + STATE_SUMMER: STATE_WINTER} + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In(VALID_TYPES) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Display the current season.""" + if None in (hass.config.latitude, hass.config.longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return False + + latitude = util.convert(hass.config.latitude, float) + _type = config.get(CONF_TYPE) + + if latitude < 0: + hemisphere = SOUTHERN + elif latitude > 0: + hemisphere = NORTHERN + else: + hemisphere = EQUATOR + + _LOGGER.debug(_type) + add_devices([Season(hass, hemisphere, _type)]) + + return True + + +def get_season(date, hemisphere, season_tracking_type): + """Calculate the current season.""" + import ephem + + if hemisphere == 'equator': + return None + + if season_tracking_type == TYPE_ASTRONOMICAL: + spring_start = ephem.next_equinox(str(date.year)).datetime() + summer_start = ephem.next_solstice(str(date.year)).datetime() + autumn_start = ephem.next_equinox(spring_start).datetime() + winter_start = ephem.next_solstice(summer_start).datetime() + else: + spring_start = datetime(2017, 3, 1).replace(year=date.year) + summer_start = spring_start.replace(month=6) + autumn_start = spring_start.replace(month=9) + winter_start = spring_start.replace(month=12) + + if spring_start <= date < summer_start: + season = STATE_SPRING + elif summer_start <= date < autumn_start: + season = STATE_SUMMER + elif autumn_start <= date < winter_start: + season = STATE_AUTUMN + elif winter_start <= date or spring_start > date: + season = STATE_WINTER + + # If user is located in the southern hemisphere swap the season + if hemisphere == NORTHERN: + return season + return HEMISPHERE_SEASON_SWAP.get(season) + + +class Season(Entity): + """Representation of the current season.""" + + def __init__(self, hass, hemisphere, season_tracking_type): + """Initialize the season.""" + self.hass = hass + self.hemisphere = hemisphere + self.datetime = datetime.now() + self.type = season_tracking_type + self.season = get_season(self.datetime, self.hemisphere, self.type) + + @property + def name(self): + """Return the name.""" + return "Season" + + @property + def state(self): + """Return the current season.""" + return self.season + + def update(self): + """Update season.""" + self.datetime = datetime.now() + self.season = get_season(self.datetime, self.hemisphere, self.type) diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index c95d975ec47..3d86d940f4d 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.7.4'] +REQUIREMENTS = ['shodan==1.7.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/sonarr.py b/homeassistant/components/sensor/sonarr.py index 143fcee0a61..4be5582b8c4 100644 --- a/homeassistant/components/sensor/sonarr.py +++ b/homeassistant/components/sensor/sonarr.py @@ -36,17 +36,19 @@ SENSOR_TYPES = { 'upcoming': ['Upcoming', 'Episodes', 'mdi:television'], 'wanted': ['Wanted', 'Episodes', 'mdi:television'], 'series': ['Series', 'Shows', 'mdi:television'], - 'commands': ['Commands', 'Commands', 'mdi:code-braces'] + 'commands': ['Commands', 'Commands', 'mdi:code-braces'], + 'status': ['Status', 'Status', 'mdi:information'] } ENDPOINTS = { - 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace?apikey={4}', - 'queue': 'http{0}://{1}:{2}/{3}api/queue?apikey={4}', + 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace', + 'queue': 'http{0}://{1}:{2}/{3}api/queue', 'upcoming': - 'http{0}://{1}:{2}/{3}api/calendar?apikey={4}&start={5}&end={6}', - 'wanted': 'http{0}://{1}:{2}/{3}api/wanted/missing?apikey={4}', - 'series': 'http{0}://{1}:{2}/{3}api/series?apikey={4}', - 'commands': 'http{0}://{1}:{2}/{3}api/command?apikey={4}' + 'http{0}://{1}:{2}/{3}api/calendar?start={4}&end={5}', + 'wanted': 'http{0}://{1}:{2}/{3}api/wanted/missing', + 'series': 'http{0}://{1}:{2}/{3}api/series', + 'commands': 'http{0}://{1}:{2}/{3}api/command', + 'status': 'http{0}://{1}:{2}/{3}api/system/status' } # Support to Yottabytes for the future, why not @@ -156,6 +158,8 @@ class SonarrSensor(Entity): for show in self.data: attributes[show['title']] = '{}/{} Episodes'.format( show['episodeFileCount'], show['episodeCount']) + elif self.type == 'status': + attributes = self.data return attributes @property @@ -168,9 +172,12 @@ class SonarrSensor(Entity): start = get_date(self._tz) end = get_date(self._tz, self.days) try: - res = requests.get(ENDPOINTS[self.type].format( - self.ssl, self.host, self.port, self.urlbase, self.apikey, - start, end), timeout=5) + res = requests.get( + ENDPOINTS[self.type].format( + self.ssl, self.host, self.port, + self.urlbase, start, end), + headers={'X-Api-Key': self.apikey}, + timeout=10) except OSError: _LOGGER.error("Host %s is not available", self.host) self._available = False @@ -193,10 +200,13 @@ class SonarrSensor(Entity): self._state = len(self.data) elif self.type == 'wanted': data = res.json() - res = requests.get('{}&pageSize={}'.format( - ENDPOINTS[self.type].format( - self.ssl, self.host, self.port, self.urlbase, - self.apikey), data['totalRecords']), timeout=5) + res = requests.get( + '{}?pageSize={}'.format( + ENDPOINTS[self.type].format( + self.ssl, self.host, self.port, self.urlbase), + data['totalRecords']), + headers={'X-Api-Key': self.apikey}, + timeout=10) self.data = res.json()['records'] self._state = len(self.data) elif self.type == 'diskspace': @@ -217,6 +227,9 @@ class SonarrSensor(Entity): self._unit ) ) + elif self.type == 'status': + self.data = res.json() + self._state = self.data['version'] self._available = True diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 2d7b74e8791..34d3cabf26b 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -18,6 +18,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,8 @@ ATTR_SAMPLING_SIZE = 'sampling_size' ATTR_TOTAL = 'total' CONF_SAMPLING_SIZE = 'sampling_size' +CONF_MAX_AGE = 'max_age' + DEFAULT_NAME = 'Stats' DEFAULT_SIZE = 20 ICON = 'mdi:calculator' @@ -41,7 +44,9 @@ ICON = 'mdi:calculator' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE): cv.positive_int, + vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_MAX_AGE): cv.time_period }) @@ -51,16 +56,18 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) sampling_size = config.get(CONF_SAMPLING_SIZE) + max_age = config.get(CONF_MAX_AGE, None) async_add_devices( - [StatisticsSensor(hass, entity_id, name, sampling_size)], True) + [StatisticsSensor(hass, entity_id, name, sampling_size, max_age)], + True) return True class StatisticsSensor(Entity): """Representation of a Statistics sensor.""" - def __init__(self, hass, entity_id, name, sampling_size): + def __init__(self, hass, entity_id, name, sampling_size, max_age): """Initialize the Statistics sensor.""" self._hass = hass self._entity_id = entity_id @@ -71,11 +78,12 @@ class StatisticsSensor(Entity): else: self._name = '{} {}'.format(name, ATTR_COUNT) self._sampling_size = sampling_size + self._max_age = max_age self._unit_of_measurement = None - if self._sampling_size == 0: - self.states = deque() - else: - self.states = deque(maxlen=self._sampling_size) + self.states = deque(maxlen=self._sampling_size) + if self._max_age is not None: + self.ages = deque(maxlen=self._sampling_size) + self.median = self.mean = self.variance = self.stdev = 0 self.min = self.max = self.total = self.count = 0 self.average_change = self.change = 0 @@ -89,6 +97,9 @@ class StatisticsSensor(Entity): try: self.states.append(float(new_state.state)) + if self._max_age is not None: + now = dt_util.utcnow() + self.ages.append(now) self.count = self.count + 1 except ValueError: self.count = self.count + 1 @@ -128,8 +139,7 @@ class StatisticsSensor(Entity): ATTR_MAX_VALUE: self.max, ATTR_MEDIAN: self.median, ATTR_MIN_VALUE: self.min, - ATTR_SAMPLING_SIZE: 'unlimited' if self._sampling_size is - 0 else self._sampling_size, + ATTR_SAMPLING_SIZE: self._sampling_size, ATTR_STANDARD_DEVIATION: self.stdev, ATTR_TOTAL: self.total, ATTR_VARIANCE: self.variance, @@ -142,9 +152,20 @@ class StatisticsSensor(Entity): """Return the icon to use in the frontend, if any.""" return ICON + def _purge_old(self): + """Remove states which are older than self._max_age.""" + now = dt_util.utcnow() + + while (len(self.ages) > 0) and (now - self.ages[0]) > self._max_age: + self.ages.popleft() + self.states.popleft() + @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" + if self._max_age is not None: + self._purge_old() + if not self.is_binary: try: self.mean = round(statistics.mean(self.states), 2) diff --git a/homeassistant/components/sensor/swiss_public_transport.py b/homeassistant/components/sensor/swiss_public_transport.py index 0febd8c95bc..973eac0bdde 100644 --- a/homeassistant/components/sensor/swiss_public_transport.py +++ b/homeassistant/components/sensor/swiss_public_transport.py @@ -4,10 +4,10 @@ Support for transport.opendata.ch. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.swiss_public_transport/ """ +import asyncio import logging from datetime import timedelta -import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -15,15 +15,21 @@ import homeassistant.util.dt as dt_util from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION from homeassistant.helpers.entity import Entity +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +REQUIREMENTS = ['python_opendata_transport==0.0.2'] _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'http://transport.opendata.ch/v1/' ATTR_DEPARTURE_TIME1 = 'next_departure' ATTR_DEPARTURE_TIME2 = 'next_on_departure' +ATTR_DURATION = 'duration' +ATTR_PLATFORM = 'platform' ATTR_REMAINING_TIME = 'remaining_time' ATTR_START = 'start' ATTR_TARGET = 'destination' +ATTR_TRAIN_NUMBER = 'train_number' +ATTR_TRANSFERS = 'transfers' CONF_ATTRIBUTION = "Data provided by transport.opendata.ch" CONF_DESTINATION = 'to' @@ -33,9 +39,7 @@ DEFAULT_NAME = 'Next Departure' ICON = 'mdi:bus' -SCAN_INTERVAL = timedelta(minutes=1) - -TIME_STR_FORMAT = "%H:%M" +SCAN_INTERVAL = timedelta(seconds=90) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DESTINATION): cv.string, @@ -44,39 +48,39 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Swiss public transport sensor.""" name = config.get(CONF_NAME) - # journal contains [0] Station ID start, [1] Station ID destination - # [2] Station name start, and [3] Station name destination - journey = [config.get(CONF_START), config.get(CONF_DESTINATION)] - try: - for location in [config.get(CONF_START), config.get(CONF_DESTINATION)]: - # transport.opendata.ch doesn't play nice with requests.Session - result = requests.get( - '{}locations?query={}'.format(_RESOURCE, location), timeout=10) - journey.append(result.json()['stations'][0]['name']) - except KeyError: - _LOGGER.exception( - "Unable to determine stations. " - "Check your settings and/or the availability of opendata.ch") + start = config.get(CONF_START) + destination = config.get(CONF_DESTINATION) + + connection = SwissPublicTransportSensor(hass, start, destination, name) + yield from connection.async_update() + + if connection.state is None: + _LOGGER.error( + "Check at http://transport.opendata.ch/examples/stationboard.html " + "if your station names are valid") return False - data = PublicTransportData(journey) - add_devices([SwissPublicTransportSensor(data, journey, name)], True) + async_add_devices([connection]) class SwissPublicTransportSensor(Entity): """Implementation of an Swiss public transport sensor.""" - def __init__(self, data, journey, name): + def __init__(self, hass, start, destination, name): """Initialize the sensor.""" - self.data = data + from opendata_transport import OpendataTransport + + self.hass = hass self._name = name - self._state = None - self._times = None - self._from = journey[2] - self._to = journey[3] + self._from = start + self._to = destination + self._websession = async_get_clientsession(self.hass) + self._opendata = OpendataTransport( + self._from, self._to, self.hass.loop, self._websession) @property def name(self): @@ -86,70 +90,45 @@ class SwissPublicTransportSensor(Entity): @property def state(self): """Return the state of the sensor.""" - return self._state + return self._opendata.connections[0]['departure'] \ + if self._opendata is not None else None @property def device_state_attributes(self): """Return the state attributes.""" - if self._times is not None: - return { - ATTR_DEPARTURE_TIME1: self._times[0], - ATTR_DEPARTURE_TIME2: self._times[1], - ATTR_START: self._from, - ATTR_TARGET: self._to, - ATTR_REMAINING_TIME: '{}'.format( - ':'.join(str(self._times[2]).split(':')[:2])), - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - } + if self._opendata is None: + return + + remaining_time = dt_util.parse_datetime( + self._opendata.connections[0]['departure']) -\ + dt_util.as_local(dt_util.utcnow()) + + attr = { + ATTR_TRAIN_NUMBER: self._opendata.connections[0]['number'], + ATTR_PLATFORM: self._opendata.connections[0]['platform'], + ATTR_TRANSFERS: self._opendata.connections[0]['transfers'], + ATTR_DURATION: self._opendata.connections[0]['duration'], + ATTR_DEPARTURE_TIME1: self._opendata.connections[1]['departure'], + ATTR_DEPARTURE_TIME2: self._opendata.connections[2]['departure'], + ATTR_START: self._opendata.from_name, + ATTR_TARGET: self._opendata.to_name, + ATTR_REMAINING_TIME: '{}'.format(remaining_time), + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + } + return attr @property def icon(self): """Icon to use in the frontend, if any.""" return ICON - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest data from opendata.ch and update the states.""" - self.data.update() - self._times = self.data.times - try: - self._state = self._times[0] - except TypeError: - pass - - -class PublicTransportData(object): - """The Class for handling the data retrieval.""" - - def __init__(self, journey): - """Initialize the data object.""" - self.start = journey[0] - self.destination = journey[1] - self.times = {} - - def update(self): - """Get the latest data from opendata.ch.""" - response = requests.get( - _RESOURCE + - 'connections?' + - 'from=' + self.start + '&' + - 'to=' + self.destination + '&' + - 'fields[]=connections/from/departureTimestamp/&' + - 'fields[]=connections/', - timeout=10) - connections = response.json()['connections'][1:3] + from opendata_transport.exceptions import OpendataTransportError try: - self.times = [ - dt_util.as_local( - dt_util.utc_from_timestamp( - item['from']['departureTimestamp'])).strftime( - TIME_STR_FORMAT) - for item in connections - ] - self.times.append( - dt_util.as_local( - dt_util.utc_from_timestamp( - connections[0]['from']['departureTimestamp'])) - - dt_util.as_local(dt_util.utcnow())) - except KeyError: - self.times = ['n/a'] + yield from self._opendata.async_get_data() + except OpendataTransportError: + _LOGGER.error("Unable to retrieve data from transport.opendata.ch") + self._opendata = None diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 42229351fde..5fe1518a315 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,10 +16,12 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.2.2'] +REQUIREMENTS = ['psutil==5.3.1'] _LOGGER = logging.getLogger(__name__) +CONF_ARG = 'arg' + SENSOR_TYPES = { 'disk_free': ['Disk Free', 'GiB', 'mdi:harddisk'], 'disk_use': ['Disk Use', 'GiB', 'mdi:harddisk'], @@ -49,7 +51,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_RESOURCES, default=['disk_use']): vol.All(cv.ensure_list, [vol.Schema({ vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), - vol.Optional('arg'): cv.string, + vol.Optional(CONF_ARG): cv.string, })]) }) @@ -71,9 +73,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the system monitor sensors.""" dev = [] for resource in config[CONF_RESOURCES]: - if 'arg' not in resource: - resource['arg'] = '' - dev.append(SystemMonitorSensor(resource[CONF_TYPE], resource['arg'])) + if CONF_ARG not in resource: + resource[CONF_ARG] = '' + dev.append(SystemMonitorSensor( + resource[CONF_TYPE], resource[CONF_ARG])) add_devices(dev, True) @@ -137,10 +140,16 @@ class SystemMonitorSensor(Entity): elif self.type == 'processor_use': self._state = round(psutil.cpu_percent(interval=None)) elif self.type == 'process': - if any(self.argument in l.name() for l in psutil.process_iter()): - self._state = STATE_ON - else: - self._state = STATE_OFF + for proc in psutil.process_iter(): + try: + if self.argument == proc.name(): + self._state = STATE_ON + return + except psutil.NoSuchProcess as err: + _LOGGER.warning( + "Failed to load process with id: %s, old name: %s", + err.pid, err.name) + self._state = STATE_OFF elif self.type == 'network_out' or self.type == 'network_in': counters = psutil.net_io_counters(pernic=True) if self.argument in counters: diff --git a/homeassistant/components/sensor/tank_utility.py b/homeassistant/components/sensor/tank_utility.py new file mode 100644 index 00000000000..01ace415159 --- /dev/null +++ b/homeassistant/components/sensor/tank_utility.py @@ -0,0 +1,138 @@ +""" +Support for the Tank Utility propane monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tank_utility/ +""" + +import datetime +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, + STATE_UNKNOWN) +from homeassistant.helpers.entity import Entity + + +REQUIREMENTS = [ + "tank_utility==1.4.0" +] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(hours=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, vol.Length(min=1)) +}) + +SENSOR_TYPE = "tank" +SENSOR_ROUNDING_PRECISION = 1 +SENSOR_UNIT_OF_MEASUREMENT = "%" +SENSOR_ATTRS = [ + "name", + "address", + "capacity", + "fuelType", + "orientation", + "status", + "time", + "time_iso" +] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tank Utility sensor.""" + from tank_utility import auth + email = config.get(CONF_EMAIL) + password = config.get(CONF_PASSWORD) + devices = config.get(CONF_DEVICES) + + try: + token = auth.get_token(email, password) + except requests.exceptions.HTTPError as http_error: + if (http_error.response.status_code == + requests.codes.unauthorized): # pylint: disable=no-member + _LOGGER.error("Invalid credentials") + return + + all_sensors = [] + for device in devices: + sensor = TankUtilitySensor(email, password, token, device) + all_sensors.append(sensor) + add_devices(all_sensors, True) + + +class TankUtilitySensor(Entity): + """Representation of a Tank Utility sensor.""" + + def __init__(self, email, password, token, device): + """Initialize the sensor.""" + self._email = email + self._password = password + self._token = token + self._device = device + self._state = STATE_UNKNOWN + self._name = "Tank Utility " + self.device + self._unit_of_measurement = SENSOR_UNIT_OF_MEASUREMENT + self._attributes = {} + + @property + def device(self): + """Return the device identifier.""" + return self._device + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the device.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the attributes of the device.""" + return self._attributes + + def get_data(self): + """Get data from the device. + + Flatten dictionary to map device to map of device data. + + """ + from tank_utility import auth, device + data = {} + try: + data = device.get_device_data(self._token, self.device) + except requests.exceptions.HTTPError as http_error: + if (http_error.response.status_code == + requests.codes.unauthorized): # pylint: disable=no-member + _LOGGER.info("Getting new token") + self._token = auth.get_token(self._email, self._password, + force=True) + data = device.get_device_data(self._token, self.device) + else: + raise http_error + data.update(data.pop("device", {})) + data.update(data.pop("lastReading", {})) + return data + + def update(self): + """Set the device state and attributes.""" + data = self.get_data() + self._state = round(data[SENSOR_TYPE], SENSOR_ROUNDING_PRECISION) + self._attributes = {k: v for k, v in data.items() if k in SENSOR_ATTRS} diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index fdd0ef9c2ad..e59864dea2b 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -100,7 +100,7 @@ class SensorTemplate(Entity): @callback def template_sensor_state_listener(entity, old_state, new_state): """Handle device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @callback def template_sensor_startup(event): @@ -108,7 +108,7 @@ class SensorTemplate(Entity): async_track_state_change( self.hass, self._entities, template_sensor_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_sensor_startup) diff --git a/homeassistant/components/sensor/tesla.py b/homeassistant/components/sensor/tesla.py new file mode 100644 index 00000000000..fc31a5543e2 --- /dev/null +++ b/homeassistant/components/sensor/tesla.py @@ -0,0 +1,82 @@ +""" +Sensors for the Tesla sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tesla/ +""" +import logging +from datetime import timedelta + +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + +SCAN_INTERVAL = timedelta(minutes=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla sensor platform.""" + controller = hass.data[TESLA_DOMAIN]['devices']['controller'] + devices = [] + + for device in hass.data[TESLA_DOMAIN]['devices']['sensor']: + if device.bin_type == 0x4: + devices.append(TeslaSensor(device, controller, 'inside')) + devices.append(TeslaSensor(device, controller, 'outside')) + else: + devices.append(TeslaSensor(device, controller)) + add_devices(devices, True) + + +class TeslaSensor(TeslaDevice, Entity): + """Representation of Tesla sensors.""" + + def __init__(self, tesla_device, controller, sensor_type=None): + """Initialisation of the sensor.""" + self.current_value = None + self._temperature_units = None + self.last_changed_time = None + self.type = sensor_type + super().__init__(tesla_device, controller) + + if self.type: + self._name = '{} ({})'.format(self.tesla_device.name, self.type) + self.entity_id = ENTITY_ID_FORMAT.format( + '{}_{}'.format(self.tesla_id, self.type)) + else: + self._name = self.tesla_device.name + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + @property + def state(self): + """Return the state of the sensor.""" + return self.current_value + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + return self._temperature_units + + def update(self): + """Update the state from the sensor.""" + _LOGGER.debug("Updating sensor: %s", self._name) + self.tesla_device.update() + if self.tesla_device.bin_type == 0x4: + if self.type == 'outside': + self.current_value = self.tesla_device.get_outside_temp() + else: + self.current_value = self.tesla_device.get_inside_temp() + + tesla_temp_units = self.tesla_device.measurement + + if tesla_temp_units == 'F': + self._temperature_units = TEMP_FAHRENHEIT + else: + self._temperature_units = TEMP_CELSIUS + else: + self.current_value = self.tesla_device.battery_level() diff --git a/homeassistant/components/sensor/time_date.py b/homeassistant/components/sensor/time_date.py index a59ee01bac2..69723aea19a 100644 --- a/homeassistant/components/sensor/time_date.py +++ b/homeassistant/components/sensor/time_date.py @@ -129,6 +129,6 @@ class TimeDateSensor(Entity): def point_in_time_listener(self, time_date): """Get the latest data and update state.""" self._update_internal_state(time_date) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() async_track_point_in_utc_time( self.hass, self.point_in_time_listener, self.get_next_interval()) diff --git a/homeassistant/components/sensor/torque.py b/homeassistant/components/sensor/torque.py index 3ce277f794b..98fad475d52 100644 --- a/homeassistant/components/sensor/torque.py +++ b/homeassistant/components/sensor/torque.py @@ -141,4 +141,4 @@ class TorqueSensor(Entity): 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/sensor/uber.py b/homeassistant/components/sensor/uber.py index 5d8ff49cd5b..e80fe7d2d82 100644 --- a/homeassistant/components/sensor/uber.py +++ b/homeassistant/components/sensor/uber.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['uber_rides==0.5.1'] +REQUIREMENTS = ['uber_rides==0.6.0'] _LOGGER = logging.getLogger(__name__) @@ -87,11 +87,14 @@ class UberSensor(Entity): if self._product.get('price_details') is not None: price_details = self._product['price_details'] self._unit_of_measurement = price_details.get('currency_code') - if price_details.get('low_estimate') is not None: - statekey = 'minimum' - else: - statekey = 'low_estimate' - self._state = int(price_details.get(statekey, 0)) + try: + if price_details.get('low_estimate') is not None: + statekey = 'minimum' + else: + statekey = 'low_estimate' + self._state = int(price_details.get(statekey)) + except TypeError: + self._state = 0 else: self._state = 0 diff --git a/homeassistant/components/sensor/usps.py b/homeassistant/components/sensor/usps.py index 322c27e2f37..a789f566896 100644 --- a/homeassistant/components/sensor/usps.py +++ b/homeassistant/components/sensor/usps.py @@ -11,7 +11,7 @@ from homeassistant.components.usps import DATA_USPS from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DATE from homeassistant.helpers.entity import Entity from homeassistant.util import slugify -from homeassistant.util.dt import now, parse_datetime +from homeassistant.util.dt import now _LOGGER = logging.getLogger(__name__) @@ -57,7 +57,7 @@ class USPSPackageSensor(Entity): for package in self._usps.packages: status = slugify(package['primary_status']) if status == STATUS_DELIVERED and \ - parse_datetime(package['date']).date() < now().date(): + package['delivery_date'] < now().date(): continue status_counts[status] += 1 self._attributes = { @@ -116,7 +116,7 @@ class USPSMailSensor(Entity): attr = {} attr[ATTR_ATTRIBUTION] = self._usps.attribution try: - attr[ATTR_DATE] = self._usps.mail[0]['date'] + attr[ATTR_DATE] = str(self._usps.mail[0]['date']) except IndexError: pass return attr diff --git a/homeassistant/components/sensor/worldtidesinfo.py b/homeassistant/components/sensor/worldtidesinfo.py index c9a42f3cb11..f23d244cf3a 100644 --- a/homeassistant/components/sensor/worldtidesinfo.py +++ b/homeassistant/components/sensor/worldtidesinfo.py @@ -88,7 +88,7 @@ class WorldTidesInfoSensor(Entity): return "High tide at %s" % (tidetime) elif "Low" in str(self.data['extremes'][0]['type']): tidetime = time.strftime('%I:%M %p', time.localtime( - self.data['extremes'][1]['dt'])) + self.data['extremes'][0]['dt'])) return "Low tide at %s" % (tidetime) else: return STATE_UNKNOWN diff --git a/homeassistant/components/sensor/worxlandroid.py b/homeassistant/components/sensor/worxlandroid.py new file mode 100644 index 00000000000..ddf506bf4eb --- /dev/null +++ b/homeassistant/components/sensor/worxlandroid.py @@ -0,0 +1,163 @@ +""" +Support for Worx Landroid mower. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.worxlandroid/ +""" +import logging +import asyncio + +import aiohttp +import async_timeout + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +from homeassistant.helpers.entity import Entity +from homeassistant.components.switch import (PLATFORM_SCHEMA) +from homeassistant.const import (CONF_HOST, CONF_PIN, CONF_TIMEOUT) +from homeassistant.helpers.aiohttp_client import (async_get_clientsession) + +_LOGGER = logging.getLogger(__name__) + +CONF_ALLOW_UNREACHABLE = 'allow_unreachable' + +DEFAULT_TIMEOUT = 5 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PIN): + vol.All(vol.Coerce(int), vol.Range(min=1000, max=9999)), + vol.Optional(CONF_ALLOW_UNREACHABLE, default=True): cv.boolean, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, +}) + +ERROR_STATE = [ + 'blade-blocked', + 'repositioning-error', + 'wire-bounced', + 'blade-blocked', + 'outside-wire', + 'mower-lifted', + 'alarm-6', + 'upside-down', + 'alarm-8', + 'collision-sensor-blocked', + 'mower-tilted', + 'charge-error', + 'battery-error' +] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Worx Landroid sensors.""" + for typ in ('battery', 'state'): + async_add_devices([WorxLandroidSensor(typ, config)]) + + +class WorxLandroidSensor(Entity): + """Implementation of a Worx Landroid sensor.""" + + def __init__(self, sensor, config): + """Initialize a Worx Landroid sensor.""" + self._state = None + self.sensor = sensor + self.host = config.get(CONF_HOST) + self.pin = config.get(CONF_PIN) + self.timeout = config.get(CONF_TIMEOUT) + self.allow_unreachable = config.get(CONF_ALLOW_UNREACHABLE) + self.url = 'http://{}/jsondata.cgi'.format(self.host) + + @property + def name(self): + """Return the name of the sensor.""" + return 'worxlandroid-{}'.format(self.sensor) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + if self.sensor == 'battery': + return '%' + return None + + @asyncio.coroutine + def async_update(self): + """Update the sensor data from the mower.""" + connection_error = False + + try: + session = async_get_clientsession(self.hass) + with async_timeout.timeout(self.timeout, loop=self.hass.loop): + auth = aiohttp.helpers.BasicAuth('admin', self.pin) + mower_response = yield from session.get(self.url, auth=auth) + except (asyncio.TimeoutError, aiohttp.ClientError): + if self.allow_unreachable is False: + _LOGGER.error("Error connecting to mower at %s", self.url) + + connection_error = True + + # connection error + if connection_error is True and self.allow_unreachable is False: + if self.sensor == 'error': + self._state = 'yes' + elif self.sensor == 'state': + self._state = 'connection-error' + + # connection success + elif connection_error is False: + # set the expected content type to be text/html + # since the mover incorrectly returns it... + data = yield from mower_response.json(content_type='text/html') + + # sensor battery + if self.sensor == 'battery': + self._state = data['perc_batt'] + + # sensor error + elif self.sensor == 'error': + self._state = 'no' if self.get_error(data) is None else 'yes' + + # sensor state + elif self.sensor == 'state': + self._state = self.get_state(data) + + else: + if self.sensor == 'error': + self._state = 'no' + + @staticmethod + def get_error(obj): + """Get the mower error.""" + for i, err in enumerate(obj['allarmi']): + if i != 2: # ignore wire bounce errors + if err == 1: + return ERROR_STATE[i] + + return None + + def get_state(self, obj): + """Get the state of the mower.""" + state = self.get_error(obj) + + if state is None: + state_obj = obj['settaggi'] + + if state_obj[14] == 1: + return 'manual-stop' + elif state_obj[5] == 1 and state_obj[13] == 0: + return 'charging' + elif state_obj[5] == 1 and state_obj[13] == 1: + return 'charging-complete' + elif state_obj[15] == 1: + return 'going-home' + return 'mowing' + + return state diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index 3a72432610c..b68ef67bf37 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -139,7 +139,7 @@ class WUDailySimpleForecastSensorConfig(WUSensorConfig): wu_unit (string): "fahrenheit", "celsius", "degrees" etc. see the example json at: https://www.wunderground.com/weather/api/d/docs?d=data/forecast&MR=1 - ha_unit (string): coresponding unit in home assistant + ha_unit (string): corresponding unit in home assistant title (string): friendly_name of the sensor """ super().__init__( @@ -708,7 +708,7 @@ class WUndergroundSensor(Entity): def entity_picture(self): """Return the entity picture.""" url = self._cfg_expand("entity_picture") - if url is not None: + if isinstance(url, str): return re.sub(r'^http://', 'https://', url, flags=re.IGNORECASE) @property diff --git a/homeassistant/components/sensor/xiaomi.py b/homeassistant/components/sensor/xiaomi_aqara.py similarity index 94% rename from homeassistant/components/sensor/xiaomi.py rename to homeassistant/components/sensor/xiaomi_aqara.py index 994a6789bbf..e439691fd63 100644 --- a/homeassistant/components/sensor/xiaomi.py +++ b/homeassistant/components/sensor/xiaomi_aqara.py @@ -1,7 +1,8 @@ -"""Support for Xiaomi sensors.""" +"""Support for Xiaomi aqara sensors.""" import logging -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) from homeassistant.const import TEMP_CELSIUS _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 7315b6dc2d2..69a5982caeb 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -1,3 +1,4 @@ + foursquare: checkin: description: Check a user into a Foursquare venue @@ -115,7 +116,7 @@ persistent_notification: notification_id: description: Target ID of the notification, will replace a notification with the same Id. [Optional] example: 1234 - + dismiss: description: Remove a notification from the frontend @@ -154,7 +155,7 @@ homematic: example: 'homematic.ccu2' name: - description: Name of the varaible to set + description: Name of the variable to set example: 'testvariable' value: @@ -162,7 +163,7 @@ homematic: example: 1 set_dev_value: - description: Set a device property on RPC XML inteface. + description: Set a device property on RPC XML interface. fields: address: @@ -333,7 +334,7 @@ hdmi_cec: description: Select HDMI device. fields: device: - description: Addres of device to select. Can be entity_id, physical address or alias from confuguration. + description: Address of device to select. Can be entity_id, physical address or alias from confuguration. example: '"switch.hdmi_1" or "1.1.0.0" or "01:10"' power_on: @@ -347,21 +348,21 @@ ffmpeg: description: Send a start command to a ffmpeg based sensor. fields: entity_id: - description: Name(s) of entites that will start. Platform dependent. + description: Name(s) of entities that will start. Platform dependent. example: 'binary_sensor.ffmpeg_noise' stop: description: Send a stop command to a ffmpeg based sensor. fields: entity_id: - description: Name(s) of entites that will stop. Platform dependent. + description: Name(s) of entities that will stop. Platform dependent. example: 'binary_sensor.ffmpeg_noise' restart: description: Send a restart command to a ffmpeg based sensor. fields: entity_id: - description: Name(s) of entites that will restart. Platform dependent. + description: Name(s) of entities that will restart. Platform dependent. example: 'binary_sensor.ffmpeg_noise' logger: @@ -546,3 +547,121 @@ rflink: command: description: The command to be sent example: 'on' + +counter: + decrement: + description: Decrement a counter. + + fields: + entity_id: + description: Entity id of the counter to decrement. + example: 'counter.count0' + + increment: + description: Increment a counter. + + fields: + entity_id: + description: Entity id of the counter to increment. + example: 'counter.count0' + + reset: + description: Reset a counter. + + fields: + entity_id: + description: Entity id of the counter to reset. + example: 'counter.count0' + +abode: + change_setting: + description: Change an Abode system setting. + + fields: + setting: + description: Setting to change. + example: 'beeper_mute' + + value: + description: Value of the setting. + example: '1' + + capture_image: + description: Request a new image capture from a camera device. + + fields: + entity_id: + description: Entity id of the camera to request an image. + example: 'camera.downstairs_motion_camera' + + trigger_quick_action: + description: Trigger an Abode quick action. + + fields: + entity_id: + description: Entity id of the quick action to trigger. + example: 'binary_sensor.home_quick_action' + +input_boolean: + toggle: + description: Toggles an input boolean + + fields: + entity_id: + description: Entity id of the input boolean to toggle + example: 'input_boolean.notify_alerts' + + turn_off: + description: Turns OFF an input boolean + + fields: + entity_id: + description: Entity id of the input boolean to turn off + example: 'input_boolean.notify_alerts' + + turn_on: + description: Turns ON an input boolean + + fields: + entity_id: + description: Entity id of the input boolean to turn on + example: 'input_boolean.notify_alerts' + +wink: + pair_new_device: + description: Pair a new device to a Wink Hub. + + fields: + hub_name: + description: The name of the hub to pair a new device to. + example: 'My hub' + pairing_mode: + description: One of ["zigbee", "zwave", "zwave_exclusion", "zwave_network_rediscovery", "lutron", "bluetooth", "kidde"] + example: 'zigbee' + kidde_radio_code: + description: A string of 8 1s and 0s one for each dip switch on the kidde device left --> right = 1 --> 8 + example: '10101010' + + rename_wink_device: + description: Rename the provided device. + + fields: + entity_id: + description: The entity_id of the device to rename. + example: binary_sensor.front_door_opened + name: + description: The name to change it to. + example: back_door + + delete_wink_device: + description: Remove/unpair device from Wink. + + fields: + entity_id: + description: The entity_id of the device to delete. + + pull_newly_added_devices_from_wink: + description: Pull newly pair devices from Wink. + + refresh_state_from_wink: + description: Pull the latest states for every device. diff --git a/homeassistant/components/sleepiq.py b/homeassistant/components/sleepiq.py index d9d81d3fee0..baf6d154c66 100644 --- a/homeassistant/components/sleepiq.py +++ b/homeassistant/components/sleepiq.py @@ -117,7 +117,7 @@ class SleepIQSensor(Entity): def update(self): """Get the latest data from SleepIQ and updates the states.""" # Call the API for new sleepiq data. Each sensor will re-trigger this - # same exact call, but thats fine. We cache results for a short period + # same exact call, but that's fine. We cache results for a short period # of time to prevent hitting API limits. self.sleepiq_data.update() diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index 6243de0b2d6..1f64f78e9c8 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -66,7 +66,7 @@ def async_setup(hass, config): yield from intent.async_handle( hass, DOMAIN, intent_type, slots, request['input']) except intent.IntentError: - _LOGGER.exception("Error while handling intent.") + _LOGGER.exception("Error while handling intent: %s.", intent_type) yield from hass.components.mqtt.async_subscribe( INTENT_TOPIC, message_received) diff --git a/homeassistant/components/sun.py b/homeassistant/components/sun.py index 9d3d82bd8fc..90c7f69e64a 100644 --- a/homeassistant/components/sun.py +++ b/homeassistant/components/sun.py @@ -126,7 +126,7 @@ class Sun(Entity): """Run when the state of the sun has changed.""" self.update_sun_position(now) self.update_as_of(now) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Schedule next update at next_change+1 second so sun state has changed async_track_point_in_utc_time( @@ -137,4 +137,4 @@ class Sun(Entity): def timer_update(self, time): """Needed to update solar elevation and azimuth.""" self.update_sun_position(time) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/switch/abode.py b/homeassistant/components/switch/abode.py new file mode 100644 index 00000000000..0ce1ddc59f8 --- /dev/null +++ b/homeassistant/components/switch/abode.py @@ -0,0 +1,80 @@ +""" +This component provides HA switch support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.abode/ +""" +import logging + +from homeassistant.components.abode import (AbodeDevice, AbodeAutomation, + DOMAIN as ABODE_DOMAIN) +from homeassistant.components.switch import SwitchDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode switch devices.""" + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE + + data = hass.data[ABODE_DOMAIN] + + devices = [] + + # Get all regular switches that are not excluded or marked as lights + for device in data.abode.get_devices(generic_type=CONST.TYPE_SWITCH): + if data.is_excluded(device) or data.is_light(device): + continue + + devices.append(AbodeSwitch(data, device)) + + # Get all Abode automations that can be enabled/disabled + for automation in data.abode.get_automations( + generic_type=CONST.TYPE_AUTOMATION): + if data.is_automation_excluded(automation): + continue + + devices.append(AbodeAutomationSwitch( + data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeSwitch(AbodeDevice, SwitchDevice): + """Representation of an Abode switch.""" + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on + + +class AbodeAutomationSwitch(AbodeAutomation, SwitchDevice): + """A switch implementation for Abode automations.""" + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._automation.set_active(True) + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._automation.set_active(False) + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._automation.is_active diff --git a/homeassistant/components/switch/acer_projector.py b/homeassistant/components/switch/acer_projector.py index f32829b0633..58361b2e8b2 100644 --- a/homeassistant/components/switch/acer_projector.py +++ b/homeassistant/components/switch/acer_projector.py @@ -109,7 +109,7 @@ class AcerSwitch(SwitchDevice): def _write_read_format(self, msg): """Write msg, obtain awnser and format output.""" - # awnsers are formated as ***\rawnser\r*** + # awnsers are formatted as ***\rawnser\r*** awns = self._write_read(msg) match = re.search(r'\r(.+)\r', awns) if match: diff --git a/homeassistant/components/switch/android_ip_webcam.py b/homeassistant/components/switch/android_ip_webcam.py index 8c8f04b6161..8de2ce593af 100644 --- a/homeassistant/components/switch/android_ip_webcam.py +++ b/homeassistant/components/switch/android_ip_webcam.py @@ -47,7 +47,7 @@ class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchDevice): @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self._name @asyncio.coroutine @@ -72,7 +72,7 @@ class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchDevice): else: yield from self._ipcam.change_setting(self._setting, True) self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -86,7 +86,7 @@ class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchDevice): else: yield from self._ipcam.change_setting(self._setting, False) self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def icon(self): diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 6ea738d82bc..c12d13860e2 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -14,9 +14,11 @@ import socket import voluptuous as vol from homeassistant.util.dt import utcnow +from homeassistant.util import Throttle from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_FRIENDLY_NAME, CONF_SWITCHES, CONF_COMMAND_OFF, CONF_COMMAND_ON, + CONF_FRIENDLY_NAME, CONF_SWITCHES, + CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_TIMEOUT, CONF_HOST, CONF_MAC, CONF_TYPE) import homeassistant.helpers.config_validation as cv @@ -24,20 +26,24 @@ REQUIREMENTS = ['broadlink==0.5'] _LOGGER = logging.getLogger(__name__) +TIME_BETWEEN_UPDATES = timedelta(seconds=5) + DOMAIN = 'broadlink' DEFAULT_NAME = 'Broadlink switch' DEFAULT_TIMEOUT = 10 DEFAULT_RETRY = 3 SERVICE_LEARN = 'learn_command' SERVICE_SEND = 'send_packet' +CONF_SLOTS = 'slots' RM_TYPES = ['rm', 'rm2', 'rm_mini', 'rm_pro_phicomm', 'rm2_home_plus', 'rm2_home_plus_gdt', 'rm2_pro_plus', 'rm2_pro_plus2', 'rm2_pro_plus_bl', 'rm_mini_shate'] SP1_TYPES = ['sp1'] SP2_TYPES = ['sp2', 'honeywell_sp2', 'sp3', 'spmini2', 'spminiplus'] +MP1_TYPES = ["mp1"] -SWITCH_TYPES = RM_TYPES + SP1_TYPES + SP2_TYPES +SWITCH_TYPES = RM_TYPES + SP1_TYPES + SP2_TYPES + MP1_TYPES SWITCH_SCHEMA = vol.Schema({ vol.Optional(CONF_COMMAND_OFF, default=None): cv.string, @@ -45,9 +51,17 @@ SWITCH_SCHEMA = vol.Schema({ vol.Optional(CONF_FRIENDLY_NAME): cv.string, }) +MP1_SWITCH_SLOT_SCHEMA = vol.Schema({ + vol.Optional('slot_1'): cv.string, + vol.Optional('slot_2'): cv.string, + vol.Optional('slot_3'): cv.string, + vol.Optional('slot_4'): cv.string +}) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SWITCHES, default={}): vol.Schema({cv.slug: SWITCH_SCHEMA}), + vol.Optional(CONF_SLOTS, default={}): MP1_SWITCH_SLOT_SCHEMA, vol.Required(CONF_HOST): cv.string, vol.Required(CONF_MAC): cv.string, vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string, @@ -59,7 +73,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Broadlink switches.""" import broadlink - devices = config.get(CONF_SWITCHES, {}) + devices = config.get(CONF_SWITCHES) + slots = config.get('slots', {}) ip_addr = config.get(CONF_HOST) friendly_name = config.get(CONF_FRIENDLY_NAME) mac_addr = binascii.unhexlify( @@ -114,6 +129,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if retry == DEFAULT_RETRY-1: _LOGGER.error("Failed to send packet to device") + def _get_mp1_slot_name(switch_friendly_name, slot): + if not slots['slot_{}'.format(slot)]: + return '{} slot {}'.format(switch_friendly_name, slot) + return slots['slot_{}'.format(slot)] + if switch_type in RM_TYPES: broadlink_device = broadlink.rm((ip_addr, 80), mac_addr) hass.services.register(DOMAIN, SERVICE_LEARN + '_' + @@ -136,6 +156,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): elif switch_type in SP2_TYPES: broadlink_device = broadlink.sp2((ip_addr, 80), mac_addr) switches = [BroadlinkSP2Switch(friendly_name, broadlink_device)] + elif switch_type in MP1_TYPES: + switches = [] + broadlink_device = broadlink.mp1((ip_addr, 80), mac_addr) + parent_device = BroadlinkMP1Switch(broadlink_device) + for i in range(1, 5): + slot = BroadlinkMP1Slot( + _get_mp1_slot_name(friendly_name, i), + broadlink_device, i, parent_device) + switches.append(slot) broadlink_device.timeout = config.get(CONF_TIMEOUT) try: @@ -268,3 +297,84 @@ class BroadlinkSP2Switch(BroadlinkSP1Switch): if state is None and retry > 0: return self._update(retry-1) self._state = state + + +class BroadlinkMP1Slot(BroadlinkRMSwitch): + """Representation of a slot of Broadlink switch.""" + + def __init__(self, friendly_name, device, slot, parent_device): + """Initialize the slot of switch.""" + super().__init__(friendly_name, device, None, None) + self._command_on = 1 + self._command_off = 0 + self._slot = slot + self._parent_device = parent_device + + @property + def assumed_state(self): + """Return true if unable to access real state of entity.""" + return False + + def _sendpacket(self, packet, retry=2): + """Send packet to device.""" + try: + self._device.set_power(self._slot, packet) + except (socket.timeout, ValueError) as error: + if retry < 1: + _LOGGER.error(error) + return False + if not self._auth(): + return False + return self._sendpacket(packet, max(0, retry-1)) + return True + + @property + def should_poll(self): + """Polling needed.""" + return True + + def update(self): + """Trigger update for all switches on the parent device.""" + self._parent_device.update() + self._state = self._parent_device.get_outlet_status(self._slot) + + +class BroadlinkMP1Switch(object): + """Representation of a Broadlink switch - To fetch states of all slots.""" + + def __init__(self, device): + """Initialize the switch.""" + self._device = device + self._states = None + + def get_outlet_status(self, slot): + """Get status of outlet from cached status list.""" + return self._states['s{}'.format(slot)] + + @Throttle(TIME_BETWEEN_UPDATES) + def update(self): + """Fetch new state data for this device.""" + self._update() + + def _update(self, retry=2): + try: + states = self._device.check_power() + except (socket.timeout, ValueError) as error: + if retry < 1: + _LOGGER.error(error) + return + if not self._auth(): + return + return self._update(max(0, retry-1)) + if states is None and retry > 0: + return self._update(max(0, retry-1)) + self._states = states + + def _auth(self, retry=2): + try: + auth = self._device.auth() + except socket.timeout: + auth = False + if not auth and retry > 0: + return self._auth(retry-1) + return auth diff --git a/homeassistant/components/switch/digitalloggers.py b/homeassistant/components/switch/digitalloggers.py index 26493122184..0625a42f765 100755 --- a/homeassistant/components/switch/digitalloggers.py +++ b/homeassistant/components/switch/digitalloggers.py @@ -46,7 +46,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import dlipower host = config.get(CONF_HOST) - controllername = config.get(CONF_NAME) + controller_name = config.get(CONF_NAME) user = config.get(CONF_USERNAME) pswd = config.get(CONF_PASSWORD) tout = config.get(CONF_TIMEOUT) @@ -61,37 +61,42 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Could not connect to DIN III Relay") return False - devices = [] + outlets = [] parent_device = DINRelayDevice(power_switch) - devices.extend( - DINRelay(controllername, device.outlet_number, parent_device) - for device in power_switch + outlets.extend( + DINRelay(controller_name, parent_device, outlet) + for outlet in power_switch[0:] ) - add_devices(devices, True) + add_devices(outlets) class DINRelay(SwitchDevice): """Representation of a individual DIN III relay port.""" - def __init__(self, name, outletnumber, parent_device): + def __init__(self, controller_name, parent_device, outlet): """Initialize the DIN III Relay switch.""" + self._controller_name = controller_name self._parent_device = parent_device - self.controllername = name - self.outletnumber = outletnumber - self._outletname = '' - self._is_on = False + self._outlet = outlet + + self._outlet_number = self._outlet.outlet_number + self._name = self._outlet.description + self._state = self._outlet.state == 'ON' @property def name(self): """Return the display name of this relay.""" - return self._outletname + return '{}_{}'.format( + self._controller_name, + self._name + ) @property def is_on(self): """Return true if relay is on.""" - return self._is_on + return self._state @property def should_poll(self): @@ -100,41 +105,36 @@ class DINRelay(SwitchDevice): def turn_on(self, **kwargs): """Instruct the relay to turn on.""" - self._parent_device.turn_on(outlet=self.outletnumber) + self._outlet.on() def turn_off(self, **kwargs): """Instruct the relay to turn off.""" - self._parent_device.turn_off(outlet=self.outletnumber) + self._outlet.off() def update(self): """Trigger update for all switches on the parent device.""" self._parent_device.update() - self._is_on = ( - self._parent_device.statuslocal[self.outletnumber - 1][2] == 'ON' - ) - self._outletname = '{}_{}'.format( - self.controllername, - self._parent_device.statuslocal[self.outletnumber - 1][1] - ) + + outlet_status = self._parent_device.get_outlet_status( + self._outlet_number) + + self._name = outlet_status[1] + self._state = outlet_status[2] == 'ON' class DINRelayDevice(object): """Device representation for per device throttling.""" - def __init__(self, device): + def __init__(self, power_switch): """Initialize the DINRelay device.""" - self._device = device - self.statuslocal = None + self._power_switch = power_switch + self._statuslist = None - def turn_on(self, **kwargs): - """Instruct the relay to turn on.""" - self._device.on(**kwargs) - - def turn_off(self, **kwargs): - """Instruct the relay to turn off.""" - self._device.off(**kwargs) + def get_outlet_status(self, outlet_number): + """Get status of outlet from cached status list.""" + return self._statuslist[outlet_number - 1] @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Fetch new state data for this device.""" - self.statuslocal = self._device.statuslist() + self._statuslist = self._power_switch.statuslist() diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index b24693da616..f6ed6dac018 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -14,7 +14,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN -REQUIREMENTS = ['pyW215==0.5.1'] +REQUIREMENTS = ['pyW215==0.6.0'] _LOGGER = logging.getLogger(__name__) @@ -23,9 +23,9 @@ DEFAULT_PASSWORD = '' DEFAULT_USERNAME = 'admin' CONF_USE_LEGACY_PROTOCOL = 'use_legacy_protocol' -ATTR_CURRENT_CONSUMPTION = 'Current Consumption' -ATTR_TOTAL_CONSUMPTION = 'Total Consumption' -ATTR_TEMPERATURE = 'Temperature' +ATTR_CURRENT_CONSUMPTION = 'power_consumption' +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_TEMPERATURE = 'temperature' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/switch/doorbird.py b/homeassistant/components/switch/doorbird.py new file mode 100644 index 00000000000..66c3bf73116 --- /dev/null +++ b/homeassistant/components/switch/doorbird.py @@ -0,0 +1,97 @@ +"""Support for powering relays in a DoorBird video doorbell.""" +import datetime +import logging +import voluptuous as vol + +from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN +from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA +from homeassistant.const import CONF_SWITCHES +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['doorbird'] + +_LOGGER = logging.getLogger(__name__) + +SWITCHES = { + "open_door": { + "name": "Open Door", + "icon": { + True: "lock-open", + False: "lock" + }, + "time": datetime.timedelta(seconds=3) + }, + "light_on": { + "name": "Light On", + "icon": { + True: "lightbulb-on", + False: "lightbulb" + }, + "time": datetime.timedelta(minutes=5) + } +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SWITCHES, default=[]): + vol.All(cv.ensure_list([vol.In(SWITCHES)])) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the DoorBird switch platform.""" + device = hass.data.get(DOORBIRD_DOMAIN) + + switches = [] + for switch in SWITCHES: + _LOGGER.debug("Adding DoorBird switch %s", SWITCHES[switch]["name"]) + switches.append(DoorBirdSwitch(device, switch)) + + add_devices(switches) + _LOGGER.info("Added DoorBird switches") + + +class DoorBirdSwitch(SwitchDevice): + """A relay in a DoorBird device.""" + + def __init__(self, device, switch): + """Initialize a relay in a DoorBird device.""" + self._device = device + self._switch = switch + self._state = False + self._assume_off = datetime.datetime.min + + @property + def name(self): + """Get the name of the switch.""" + return SWITCHES[self._switch]["name"] + + @property + def icon(self): + """Get an icon to display.""" + return "mdi:{}".format(SWITCHES[self._switch]["icon"][self._state]) + + @property + def is_on(self): + """Get the assumed state of the relay.""" + return self._state + + def turn_on(self, **kwargs): + """Power the relay.""" + if self._switch == "open_door": + self._state = self._device.open_door() + elif self._switch == "light_on": + self._state = self._device.turn_light_on() + + now = datetime.datetime.now() + self._assume_off = now + SWITCHES[self._switch]["time"] + + def turn_off(self, **kwargs): + """The relays are time-based.""" + raise NotImplementedError("DoorBird relays cannot be manually turned " + "off.") + + def update(self): + """Wait for the correct amount of assumed time to pass.""" + if self._state and self._assume_off <= datetime.datetime.now(): + self._state = False + self._assume_off = datetime.datetime.min diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 5613bcbb19e..e8bd592cee8 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -6,8 +6,9 @@ The idea was taken from https://github.com/KpaBap/hue-flux/ For more details about this component, please refer to the documentation at https://home-assistant.io/components/switch.flux/ """ -from datetime import time +import datetime import logging + import voluptuous as vol from homeassistant.components.light import is_on, turn_on @@ -46,7 +47,7 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_LIGHTS): cv.entity_ids, vol.Optional(CONF_NAME, default="Flux"): cv.string, vol.Optional(CONF_START_TIME): cv.time, - vol.Optional(CONF_STOP_TIME, default=time(22, 0)): cv.time, + vol.Optional(CONF_STOP_TIME, default=datetime.time(22, 0)): cv.time, vol.Optional(CONF_START_CT, default=4000): vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)), vol.Optional(CONF_SUNSET_CT, default=3000): @@ -171,12 +172,22 @@ class FluxSwitch(SwitchDevice): """Update all the lights using flux.""" if now is None: now = dt_now() + sunset = get_astral_event_date(self.hass, 'sunset', now.date()) start_time = self.find_start_time(now) stop_time = now.replace( hour=self._stop_time.hour, minute=self._stop_time.minute, second=0) + if stop_time <= start_time: + # stop_time does not happen in the same day as start_time + if start_time < now: + # stop time is tomorrow + stop_time += datetime.timedelta(days=1) + elif now < start_time: + # stop_time was yesterday since the new start_time is not reached + stop_time -= datetime.timedelta(days=1) + if start_time < now < sunset: # Daytime time_state = 'day' @@ -192,15 +203,24 @@ class FluxSwitch(SwitchDevice): else: # Nightime time_state = 'night' - if now < stop_time and now > start_time: - now_time = now + + if now < stop_time: + if stop_time < start_time and stop_time.day == sunset.day: + # we need to use yesterday's sunset time + sunset_time = sunset - datetime.timedelta(days=1) + else: + sunset_time = sunset + + # pylint: disable=no-member + night_length = int(stop_time.timestamp() - + sunset_time.timestamp()) + seconds_from_sunset = int(now.timestamp() - + sunset_time.timestamp()) + percentage_complete = seconds_from_sunset / night_length else: - now_time = stop_time + percentage_complete = 1 + temp_range = abs(self._sunset_colortemp - self._stop_colortemp) - night_length = int(stop_time.timestamp() - sunset.timestamp()) - seconds_from_sunset = int(now_time.timestamp() - - sunset.timestamp()) - percentage_complete = seconds_from_sunset / night_length temp_offset = temp_range * percentage_complete if self._sunset_colortemp > self._stop_colortemp: temp = self._sunset_colortemp - temp_offset diff --git a/homeassistant/components/switch/homematic.py b/homeassistant/components/switch/homematic.py index 566eff99828..487947598bb 100644 --- a/homeassistant/components/switch/homematic.py +++ b/homeassistant/components/switch/homematic.py @@ -21,8 +21,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSwitch(hass, conf) - new_device.link_homematic() + new_device = HMSwitch(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/switch/insteon_local.py b/homeassistant/components/switch/insteon_local.py index 94259b8bb80..674a20278b3 100644 --- a/homeassistant/components/switch/insteon_local.py +++ b/homeassistant/components/switch/insteon_local.py @@ -130,7 +130,7 @@ class InsteonLocalSwitchDevice(SwitchDevice): @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self.node.deviceName @property diff --git a/homeassistant/components/switch/insteon_plm.py b/homeassistant/components/switch/insteon_plm.py index ee192b82be4..ed7d0ffc479 100644 --- a/homeassistant/components/switch/insteon_plm.py +++ b/homeassistant/components/switch/insteon_plm.py @@ -55,12 +55,12 @@ class InsteonPLMSwitchDevice(SwitchDevice): @property def address(self): - """Return the the address of the node.""" + """Return the address of the node.""" return self._address @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self._name @property diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index d07df08ed5c..b340bf5f43a 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -1,14 +1,16 @@ """ -Support KNX switching actuators. +Support for KNX/IP switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.knx/ """ +import asyncio import voluptuous as vol -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_ADDRESS = 'address' @@ -24,30 +26,85 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX switch platform.""" - add_devices([KNXSwitch(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up switch(es) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + return True -class KNXSwitch(KNXGroupAddress, SwitchDevice): - """Representation of a KNX switch device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, async_add_devices): + """Set up switches 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(KNXSwitch(hass, device)) + async_add_devices(entities) - def turn_on(self, **kwargs): - """Turn the switch on. - This sends a value 0 to the group address of the device - """ - self.group_write(1) - self._state = [1] - if not self.should_poll: - self.schedule_update_ha_state() +@callback +def async_add_devices_config(hass, config, async_add_devices): + """Set up switch for KNX platform configured within plattform.""" + import xknx + switch = xknx.devices.Switch( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + group_address_state=config.get(CONF_STATE_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(switch) + async_add_devices([KNXSwitch(hass, switch)]) - def turn_off(self, **kwargs): - """Turn the switch off. - This sends a value 1 to the group address of the device - """ - self.group_write(0) - self._state = [0] - if not self.should_poll: - self.schedule_update_ha_state() +class KNXSwitch(SwitchDevice): + """Representation of a KNX switch.""" + + def __init__(self, hass, device): + """Initialization of KNXSwitch.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + return self.device.state + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the device on.""" + yield from self.device.set_on() + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the device off.""" + yield from self.device.set_off() diff --git a/homeassistant/components/switch/lutron_caseta.py b/homeassistant/components/switch/lutron_caseta.py index 585dc043315..daaba68dc5e 100644 --- a/homeassistant/components/switch/lutron_caseta.py +++ b/homeassistant/components/switch/lutron_caseta.py @@ -8,7 +8,7 @@ import logging from homeassistant.components.lutron_caseta import ( LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchDevice, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -20,7 +20,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Lutron switch.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - switch_devices = bridge.get_devices_by_type("WallSwitch") + switch_devices = bridge.get_devices_by_domain(DOMAIN) for switch_device in switch_devices: dev = LutronCasetaLight(switch_device, bridge) diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 308cce4de46..21820b4a015 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -111,7 +111,7 @@ class MqttSwitch(SwitchDevice): elif payload == self._payload_off: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def availability_message_received(topic, payload, qos): @@ -121,7 +121,7 @@ class MqttSwitch(SwitchDevice): elif payload == self._payload_not_available: self._available = False - 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. @@ -173,7 +173,7 @@ class MqttSwitch(SwitchDevice): if self._optimistic: # Optimistically assume that switch 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_turn_off(self, **kwargs): @@ -187,4 +187,4 @@ class MqttSwitch(SwitchDevice): if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/switch/mysensors.py b/homeassistant/components/switch/mysensors.py index 38f67ee3ee9..131ec58ae67 100644 --- a/homeassistant/components/switch/mysensors.py +++ b/homeassistant/components/switch/mysensors.py @@ -4,7 +4,6 @@ Support for MySensors switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.mysensors/ """ -import logging import os import voluptuous as vol @@ -15,9 +14,6 @@ from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.config import load_yaml_config_file from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON -_LOGGER = logging.getLogger(__name__) -DEPENDENCIES = [] - ATTR_IR_CODE = 'V_IR_SEND' SERVICE_SEND_IR_CODE = 'mysensors_send_ir_code' @@ -29,82 +25,37 @@ SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the mysensors platform for switches.""" - # Only act if loaded via mysensors by discovery event. - # Otherwise gateway is not setup. - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - platform_devices = [] - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_DOOR: [set_req.V_ARMED], - pres.S_MOTION: [set_req.V_ARMED], - pres.S_SMOKE: [set_req.V_ARMED], - pres.S_LIGHT: [set_req.V_LIGHT], - pres.S_LOCK: [set_req.V_LOCK_STATUS], - pres.S_IR: [set_req.V_IR_SEND], - } - device_class_map = { - pres.S_DOOR: MySensorsSwitch, - pres.S_MOTION: MySensorsSwitch, - pres.S_SMOKE: MySensorsSwitch, - pres.S_LIGHT: MySensorsSwitch, - pres.S_LOCK: MySensorsSwitch, - pres.S_IR: MySensorsIRSwitch, - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_BINARY: [set_req.V_STATUS, set_req.V_LIGHT], - pres.S_SPRINKLER: [set_req.V_STATUS], - pres.S_WATER_LEAK: [set_req.V_ARMED], - pres.S_SOUND: [set_req.V_ARMED], - pres.S_VIBRATION: [set_req.V_ARMED], - pres.S_MOISTURE: [set_req.V_ARMED], - }) - map_sv_types[pres.S_LIGHT].append(set_req.V_STATUS) - device_class_map.update({ - pres.S_BINARY: MySensorsSwitch, - pres.S_SPRINKLER: MySensorsSwitch, - pres.S_WATER_LEAK: MySensorsSwitch, - pres.S_SOUND: MySensorsSwitch, - pres.S_VIBRATION: MySensorsSwitch, - pres.S_MOISTURE: MySensorsSwitch, - }) - if float(gateway.protocol_version) >= 2.0: - map_sv_types.update({ - pres.S_WATER_QUALITY: [set_req.V_STATUS], - }) - device_class_map.update({ - pres.S_WATER_QUALITY: MySensorsSwitch, - }) - - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, device_class_map, add_devices)) - platform_devices.append(devices) + device_class_map = { + 'S_DOOR': MySensorsSwitch, + 'S_MOTION': MySensorsSwitch, + 'S_SMOKE': MySensorsSwitch, + 'S_LIGHT': MySensorsSwitch, + 'S_LOCK': MySensorsSwitch, + 'S_IR': MySensorsIRSwitch, + 'S_BINARY': MySensorsSwitch, + 'S_SPRINKLER': MySensorsSwitch, + 'S_WATER_LEAK': MySensorsSwitch, + 'S_SOUND': MySensorsSwitch, + 'S_VIBRATION': MySensorsSwitch, + 'S_MOISTURE': MySensorsSwitch, + 'S_WATER_QUALITY': MySensorsSwitch, + } + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, device_class_map, + add_devices=add_devices) def send_ir_code_service(service): """Set IR code as device state attribute.""" entity_ids = service.data.get(ATTR_ENTITY_ID) ir_code = service.data.get(ATTR_IR_CODE) + devices = mysensors.get_mysensors_devices(hass, DOMAIN) if entity_ids: - _devices = [device for gw_devs in platform_devices - for device in gw_devs.values() + _devices = [device for device in devices.values() if isinstance(device, MySensorsIRSwitch) and device.entity_id in entity_ids] else: - _devices = [device for gw_devs in platform_devices - for device in gw_devs.values() + _devices = [device for device in devices.values() if isinstance(device, MySensorsIRSwitch)] kwargs = {ATTR_IR_CODE: ir_code} @@ -120,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): schema=SEND_IR_CODE_SERVICE_SCHEMA) -class MySensorsSwitch(mysensors.MySensorsDeviceEntity, SwitchDevice): +class MySensorsSwitch(mysensors.MySensorsEntity, SwitchDevice): """Representation of the value of a MySensors Switch child node.""" @property @@ -131,9 +82,7 @@ class MySensorsSwitch(mysensors.MySensorsDeviceEntity, SwitchDevice): @property def is_on(self): """Return True if switch is on.""" - if self.value_type in self._values: - return self._values[self.value_type] == STATE_ON - return False + return self._values.get(self.value_type) == STATE_ON def turn_on(self, **kwargs): """Turn the switch on.""" @@ -159,24 +108,18 @@ class MySensorsIRSwitch(MySensorsSwitch): def __init__(self, *args): """Set up instance attributes.""" - MySensorsSwitch.__init__(self, *args) + super().__init__(*args) self._ir_code = None @property def is_on(self): """Return True if switch is on.""" set_req = self.gateway.const.SetReq - if set_req.V_LIGHT in self._values: - return self._values[set_req.V_LIGHT] == STATE_ON - return False + return self._values.get(set_req.V_LIGHT) == STATE_ON def turn_on(self, **kwargs): """Turn the IR switch on.""" set_req = self.gateway.const.SetReq - if set_req.V_LIGHT not in self._values: - _LOGGER.error('missing value_type: %s at node: %s, child: %s', - set_req.V_LIGHT.name, self.node_id, self.child_id) - return if ATTR_IR_CODE in kwargs: self._ir_code = kwargs[ATTR_IR_CODE] self.gateway.set_child_value( @@ -194,10 +137,6 @@ class MySensorsIRSwitch(MySensorsSwitch): def turn_off(self, **kwargs): """Turn the IR switch off.""" set_req = self.gateway.const.SetReq - if set_req.V_LIGHT not in self._values: - _LOGGER.error('missing value_type: %s at node: %s, child: %s', - set_req.V_LIGHT.name, self.node_id, self.child_id) - return self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_LIGHT, 0) if self.gateway.optimistic: @@ -207,6 +146,5 @@ class MySensorsIRSwitch(MySensorsSwitch): def update(self): """Update the controller with the latest value from a sensor.""" - MySensorsSwitch.update(self) - if self.value_type in self._values: - self._ir_code = self._values[self.value_type] + super().update() + self._ir_code = self._values.get(self.value_type) diff --git a/homeassistant/components/switch/rest.py b/homeassistant/components/switch/rest.py index 31d4f0f3e06..c0f75509425 100644 --- a/homeassistant/components/switch/rest.py +++ b/homeassistant/components/switch/rest.py @@ -13,7 +13,8 @@ import voluptuous as vol from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_NAME, CONF_RESOURCE, CONF_TIMEOUT, CONF_METHOD) + CONF_NAME, CONF_RESOURCE, CONF_TIMEOUT, CONF_METHOD, CONF_USERNAME, + CONF_PASSWORD) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.template import Template @@ -41,6 +42,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(vol.Lower, vol.In(SUPPORT_REST_METHODS)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, }) @@ -53,8 +56,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): is_on_template = config.get(CONF_IS_ON_TEMPLATE) method = config.get(CONF_METHOD) name = config.get(CONF_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) resource = config.get(CONF_RESOURCE) - websession = async_get_clientsession(hass) + + auth = None + if username: + auth = aiohttp.BasicAuth(username, password=password) if is_on_template is not None: is_on_template.hass = hass @@ -65,37 +73,32 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): timeout = config.get(CONF_TIMEOUT) try: - with async_timeout.timeout(timeout, loop=hass.loop): - req = yield from websession.get(resource) + switch = RestSwitch(name, resource, method, auth, body_on, body_off, + is_on_template, timeout) + req = yield from switch.get_device_state(hass) if req.status >= 400: _LOGGER.error("Got non-ok response from resource: %s", req.status) - return False - + else: + async_add_devices([switch]) except (TypeError, ValueError): _LOGGER.error("Missing resource or schema in configuration. " "Add http:// or https:// to your URL") - return False except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("No route to resource/endpoint: %s", resource) - return False - - async_add_devices( - [RestSwitch(hass, name, resource, method, body_on, body_off, - is_on_template, timeout)]) class RestSwitch(SwitchDevice): """Representation of a switch that can be toggled using REST.""" - def __init__(self, hass, name, resource, method, body_on, body_off, + def __init__(self, name, resource, method, auth, body_on, body_off, is_on_template, timeout): """Initialize the REST switch.""" self._state = None - self.hass = hass self._name = name self._resource = resource self._method = method + self._auth = auth self._body_on = body_on self._body_off = body_off self._is_on_template = is_on_template @@ -115,54 +118,61 @@ class RestSwitch(SwitchDevice): def async_turn_on(self, **kwargs): """Turn the device on.""" body_on_t = self._body_on.async_render() - websession = async_get_clientsession(self.hass) try: - with async_timeout.timeout(self._timeout, loop=self.hass.loop): - request = yield from getattr(websession, self._method)( - self._resource, data=bytes(body_on_t, 'utf-8')) + req = yield from self.set_device_state(body_on_t) + + if req.status == 200: + self._state = True + else: + _LOGGER.error( + "Can't turn on %s. Is resource/endpoint offline?", + self._resource) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Error while turn on %s", self._resource) - return - - if request.status == 200: - self._state = True - else: - _LOGGER.error("Can't turn on %s. Is resource/endpoint offline?", - self._resource) @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn the device off.""" body_off_t = self._body_off.async_render() - websession = async_get_clientsession(self.hass) try: - with async_timeout.timeout(self._timeout, loop=self.hass.loop): - request = yield from getattr(websession, self._method)( - self._resource, data=bytes(body_off_t, 'utf-8')) + req = yield from self.set_device_state(body_off_t) + if req.status == 200: + self._state = False + else: + _LOGGER.error( + "Can't turn off %s. Is resource/endpoint offline?", + self._resource) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Error while turn off %s", self._resource) - return - if request.status == 200: - self._state = False - else: - _LOGGER.error("Can't turn off %s. Is resource/endpoint offline?", - self._resource) + @asyncio.coroutine + def set_device_state(self, body): + """Send a state update to the device.""" + websession = async_get_clientsession(self.hass) + + with async_timeout.timeout(self._timeout, loop=self.hass.loop): + req = yield from getattr(websession, self._method)( + self._resource, auth=self._auth, data=bytes(body, 'utf-8')) + return req @asyncio.coroutine def async_update(self): - """Get the latest data from REST API and update the state.""" - websession = async_get_clientsession(self.hass) - + """Get the current state, catching errors.""" try: - with async_timeout.timeout(self._timeout, loop=self.hass.loop): - request = yield from websession.get(self._resource) - text = yield from request.text() + yield from self.get_device_state(self.hass) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.exception("Error while fetch data.") - return + + @asyncio.coroutine + def get_device_state(self, hass): + """Get the latest data from REST API and update the state.""" + websession = async_get_clientsession(hass) + + with async_timeout.timeout(self._timeout, loop=hass.loop): + req = yield from websession.get(self._resource, auth=self._auth) + text = yield from req.text() if self._is_on_template is not None: text = self._is_on_template.async_render_with_possible_json_value( @@ -181,3 +191,5 @@ class RestSwitch(SwitchDevice): self._state = False else: self._state = None + + return req diff --git a/homeassistant/components/switch/rflink.py b/homeassistant/components/switch/rflink.py index 29e93342f66..366cb397d5b 100644 --- a/homeassistant/components/switch/rflink.py +++ b/homeassistant/components/switch/rflink.py @@ -35,7 +35,7 @@ PLATFORM_SCHEMA = vol.Schema({ vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_NOGROUP_ALIASES, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_FIRE_EVENT): cv.boolean, vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), vol.Optional(CONF_GROUP, default=True): cv.boolean, # deprecated config options diff --git a/homeassistant/components/switch/rfxtrx.py b/homeassistant/components/switch/rfxtrx.py index 36044f5f168..1361d22de18 100644 --- a/homeassistant/components/switch/rfxtrx.py +++ b/homeassistant/components/switch/rfxtrx.py @@ -21,7 +21,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): import RFXtrx as rfxtrxmod # Add switch from config file - switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch, hass) + switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch) add_devices_callback(switches) def switch_update(event): @@ -31,7 +31,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch) if new_device: add_devices_callback([new_device]) diff --git a/homeassistant/components/switch/services.yaml b/homeassistant/components/switch/services.yaml index 00b2abb91a4..5fdd8142ffc 100644 --- a/homeassistant/components/switch/services.yaml +++ b/homeassistant/components/switch/services.yaml @@ -29,7 +29,7 @@ mysensors_send_ir_code: fields: entity_id: - description: Name(s) of entites that should have the IR code set and be turned on. Platform dependent. + description: Name(s) of entities that should have the IR code set and be turned on. Platform dependent. example: 'switch.living_room_1_1' V_IR_SEND: diff --git a/homeassistant/components/switch/tellstick.py b/homeassistant/components/switch/tellstick.py index c631eedc050..de7a3bf4545 100644 --- a/homeassistant/components/switch/tellstick.py +++ b/homeassistant/components/switch/tellstick.py @@ -39,7 +39,7 @@ class TellstickSwitch(TellstickDevice, ToggleEntity): return None def _parse_tellcore_data(self, tellcore_data): - """Turn the value recieved from tellcore into something useful.""" + """Turn the value received from tellcore into something useful.""" return None def _update_model(self, new_state, data): diff --git a/homeassistant/components/switch/telnet.py b/homeassistant/components/switch/telnet.py new file mode 100644 index 00000000000..4d3db97f56e --- /dev/null +++ b/homeassistant/components/switch/telnet.py @@ -0,0 +1,144 @@ +""" +Support for switch controlled using a telnet connection. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.telnet/ +""" +import logging +import telnetlib +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA, + ENTITY_ID_FORMAT) +from homeassistant.const import ( + CONF_RESOURCE, CONF_NAME, CONF_SWITCHES, CONF_VALUE_TEMPLATE, + CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_COMMAND_STATE, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 23 + +SWITCH_SCHEMA = vol.Schema({ + vol.Required(CONF_COMMAND_ON): cv.string, + vol.Required(CONF_COMMAND_OFF): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_RESOURCE): cv.string, + vol.Required(CONF_VALUE_TEMPLATE): cv.template, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SWITCHES): vol.Schema({cv.slug: SWITCH_SCHEMA}), +}) + +SCAN_INTERVAL = timedelta(seconds=10) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Find and return switches controlled by telnet commands.""" + devices = config.get(CONF_SWITCHES, {}) + switches = [] + + for object_id, device_config in devices.items(): + value_template = device_config.get(CONF_VALUE_TEMPLATE) + + if value_template is not None: + value_template.hass = hass + + switches.append( + TelnetSwitch( + hass, + object_id, + device_config.get(CONF_RESOURCE), + device_config.get(CONF_PORT), + device_config.get(CONF_NAME, object_id), + device_config.get(CONF_COMMAND_ON), + device_config.get(CONF_COMMAND_OFF), + device_config.get(CONF_COMMAND_STATE), + value_template + ) + ) + + if not switches: + _LOGGER.error("No switches added") + return False + + add_devices(switches) + + +class TelnetSwitch(SwitchDevice): + """Representation of a switch that can be toggled using telnet commands.""" + + def __init__(self, hass, object_id, resource, port, friendly_name, + command_on, command_off, command_state, value_template): + """Initialize the switch.""" + self._hass = hass + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._resource = resource + self._port = port + self._name = friendly_name + self._state = False + self._command_on = command_on + self._command_off = command_off + self._command_state = command_state + self._value_template = value_template + + def _telnet_command(self, command): + try: + telnet = telnetlib.Telnet(self._resource, self._port) + telnet.write(command.encode('ASCII') + b'\r') + response = telnet.read_until(b'\r', timeout=0.2) + return response.decode('ASCII').strip() + except IOError as error: + _LOGGER.error( + 'Command "%s" failed with exception: %s', + command, repr(error)) + return None + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def should_poll(self): + """Only poll if we have state command.""" + return self._command_state is not None + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def assumed_state(self): + """Default ist true if no state command is defined, false otherwise.""" + return self._command_state is None + + def update(self): + """Update device state.""" + response = self._telnet_command(self._command_state) + if response: + rendered = self._value_template \ + .render_with_possible_json_value(response) + self._state = rendered == "True" + else: + _LOGGER.warning( + "Empty response for command: %s", self._command_state) + + def turn_on(self, **kwargs): + """Turn the device on.""" + self._telnet_command(self._command_on) + if self.assumed_state: + self._state = True + + def turn_off(self, **kwargs): + """Turn the device off.""" + self._telnet_command(self._command_off) + if self.assumed_state: + self._state = False diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index fc076f32e88..9b73d668c8c 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -103,7 +103,7 @@ class SwitchTemplate(SwitchDevice): @callback def template_switch_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_switch_startup(event): @@ -111,7 +111,7 @@ class SwitchTemplate(SwitchDevice): async_track_state_change( self.hass, self._entities, template_switch_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_switch_startup) diff --git a/homeassistant/components/switch/xiaomi.py b/homeassistant/components/switch/xiaomi_aqara.py similarity index 96% rename from homeassistant/components/switch/xiaomi.py rename to homeassistant/components/switch/xiaomi_aqara.py index 767043a8bc9..67a56829bec 100644 --- a/homeassistant/components/switch/xiaomi.py +++ b/homeassistant/components/switch/xiaomi_aqara.py @@ -1,8 +1,9 @@ -"""Support for Xiaomi binary sensors.""" +"""Support for Xiaomi aqara binary sensors.""" import logging from homeassistant.components.switch import SwitchDevice -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/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 38669ff4ee6..de9c0f4ede3 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -24,7 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['python-telegram-bot==7.0.1'] +REQUIREMENTS = ['python-telegram-bot==8.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index 01ccb981cfa..1f2b3720062 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -53,7 +53,8 @@ def setup(hass, config): if not client.validate_session(): _LOGGER.error( "Authentication Error: Please make sure you have configured your " - "keys that can be aquired from https://api.telldus.com/keys/index") + "keys that can be acquired from " + "https://api.telldus.com/keys/index") return False hass.data[DOMAIN] = client @@ -173,7 +174,7 @@ class TelldusLiveEntity(Entity): @property def device(self): - """Return the representaion of the device.""" + """Return the representation of the device.""" return self._client.device(self.device_id) @property diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index 5d0ec78dfa7..6ae96b88da7 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -192,7 +192,7 @@ class TellstickDevice(Entity): raise NotImplementedError def _parse_tellcore_data(self, tellcore_data): - """Turn the value recieved from tellcore into something useful.""" + """Turn the value received from tellcore into something useful.""" raise NotImplementedError def _update_model(self, new_state, data): diff --git a/homeassistant/components/tesla.py b/homeassistant/components/tesla.py new file mode 100644 index 00000000000..08006310dc7 --- /dev/null +++ b/homeassistant/components/tesla.py @@ -0,0 +1,123 @@ +""" +Support for Tesla cars. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tesla/ +""" +from collections import defaultdict +import logging + +from urllib.error import HTTPError +import voluptuous as vol + +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL) +from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify + +REQUIREMENTS = ['teslajsonpy==0.0.11'] + +DOMAIN = 'tesla' + +_LOGGER = logging.getLogger(__name__) + +TESLA_ID_FORMAT = '{}_{}' +TESLA_ID_LIST_SCHEMA = vol.Schema([int]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=300): + vol.All(cv.positive_int, vol.Clamp(min=300)), + }), +}, extra=vol.ALLOW_EXTRA) + +NOTIFICATION_ID = 'tesla_integration_notification' +NOTIFICATION_TITLE = 'Tesla integration setup' + +TESLA_COMPONENTS = [ + 'sensor', 'lock', 'climate', 'binary_sensor', 'device_tracker' +] + + +def setup(hass, base_config): + """Set up of Tesla platform.""" + from teslajsonpy.controller import Controller as teslaApi + + config = base_config.get(DOMAIN) + + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + update_interval = config.get(CONF_SCAN_INTERVAL) + if hass.data.get(DOMAIN) is None: + try: + hass.data[DOMAIN] = { + 'controller': teslaApi(email, password, update_interval), + 'devices': defaultdict(list) + } + _LOGGER.debug("Connected to the Tesla API.") + except HTTPError as ex: + if ex.code == 401: + hass.components.persistent_notification.create( + "Error:
Please check username and password." + "You will need to restart Home Assistant after fixing.", + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + else: + hass.components.persistent_notification.create( + "Error:
Can't communicate with Tesla API.
" + "Error code: {} Reason: {}" + "You will need to restart Home Assistant after fixing." + "".format(ex.code, ex.reason), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + _LOGGER.error("Unable to communicate with Tesla API: %s", + ex.reason) + + return False + + all_devices = hass.data[DOMAIN]['controller'].list_vehicles() + + if not all_devices: + return False + + for device in all_devices: + hass.data[DOMAIN]['devices'][device.hass_type].append(device) + + for component in TESLA_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, base_config) + + return True + + +class TeslaDevice(Entity): + """Representation of a Tesla device.""" + + def __init__(self, tesla_device, controller): + """Initialisation of the Tesla device.""" + self.tesla_device = tesla_device + self.controller = controller + self._name = self.tesla_device.name + self.tesla_id = slugify(self.tesla_device.uniq_name) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Get polling requirement from tesla device.""" + return self.tesla_device.should_poll + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + + if self.tesla_device.has_battery(): + attr[ATTR_BATTERY_LEVEL] = self.tesla_device.battery_level() + return attr diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index 31938cd15ff..34422819743 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -16,12 +16,13 @@ from homeassistant.helpers import discovery from homeassistant.const import CONF_HOST, CONF_API_KEY from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI -REQUIREMENTS = ['pytradfri==1.1'] +REQUIREMENTS = ['pytradfri==2.2'] DOMAIN = 'tradfri' CONFIG_FILE = 'tradfri.conf' KEY_CONFIG = 'tradfri_configuring' KEY_GATEWAY = 'tradfri_gateway' +KEY_API = 'tradfri_api' KEY_TRADFRI_GROUPS = 'tradfri_allow_tradfri_groups' CONF_ALLOW_TRADFRI_GROUPS = 'allow_tradfri_groups' DEFAULT_ALLOW_TRADFRI_GROUPS = True @@ -109,17 +110,21 @@ def async_setup(hass, config): @asyncio.coroutine def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): """Create a gateway.""" - from pytradfri import cli_api_factory, Gateway, RequestError, retry_timeout + from pytradfri import Gateway, RequestError + from pytradfri.api.libcoap_api import api_factory try: - api = retry_timeout(cli_api_factory(host, key)) + api = api_factory(host, key) except RequestError: return False - gateway = Gateway(api) - gateway_id = gateway.get_gateway_info().id + gateway = Gateway() + # pylint: disable=no-member + gateway_id = api(gateway.get_gateway_info()).id + hass.data.setdefault(KEY_API, {}) hass.data.setdefault(KEY_GATEWAY, {}) gateways = hass.data[KEY_GATEWAY] + hass.data[KEY_API][gateway_id] = api hass.data.setdefault(KEY_TRADFRI_GROUPS, {}) tradfri_groups = hass.data[KEY_TRADFRI_GROUPS] diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py index 9e45def63db..87990495cf4 100644 --- a/homeassistant/components/upnp.py +++ b/homeassistant/components/upnp.py @@ -4,16 +4,18 @@ Will open a port in your router for Home Assistant and provide statistics. For more details about this component, please refer to the documentation at https://home-assistant.io/components/upnp/ """ +from ipaddress import ip_address import logging -from urllib.parse import urlsplit import voluptuous as vol from homeassistant.const import (EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery +from homeassistant.util import get_local_ip -REQUIREMENTS = ['miniupnpc==1.9'] +REQUIREMENTS = ['miniupnpc==2.0.2'] +DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -22,9 +24,11 @@ DOMAIN = 'upnp' DATA_UPNP = 'UPNP' +CONF_LOCAL_IP = 'local_ip' CONF_ENABLE_PORT_MAPPING = 'port_mapping' -CONF_EXTERNAL_PORT = 'external_port' +CONF_PORTS = 'ports' CONF_UNITS = 'unit' +CONF_HASS = 'hass' NOTIFICATION_ID = 'upnp_notification' NOTIFICATION_TITLE = 'UPnP Setup' @@ -39,8 +43,10 @@ UNITS = { CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_ENABLE_PORT_MAPPING, default=True): cv.boolean, - vol.Optional(CONF_EXTERNAL_PORT, default=0): cv.positive_int, vol.Optional(CONF_UNITS, default="MBytes"): vol.In(UNITS), + vol.Optional(CONF_LOCAL_IP): ip_address, + vol.Optional(CONF_PORTS): + vol.Schema({vol.Any(CONF_HASS, cv.positive_int): cv.positive_int}) }), }, extra=vol.ALLOW_EXTRA) @@ -48,6 +54,19 @@ CONFIG_SCHEMA = vol.Schema({ # pylint: disable=import-error, no-member, broad-except def setup(hass, config): """Register a port mapping for Home Assistant via UPnP.""" + config = config[DOMAIN] + host = config.get(CONF_LOCAL_IP) + + if host is not None: + host = str(host) + else: + host = get_local_ip() + + if host == '127.0.0.1': + _LOGGER.error( + 'Unable to determine local IP. Add it to your configuration.') + return False + import miniupnpc upnp = miniupnpc.UPnP() @@ -61,40 +80,44 @@ def setup(hass, config): _LOGGER.exception("Error when attempting to discover an UPnP IGD") return False - unit = config[DOMAIN].get(CONF_UNITS) + unit = config.get(CONF_UNITS) discovery.load_platform(hass, 'sensor', DOMAIN, {'unit': unit}, config) - port_mapping = config[DOMAIN].get(CONF_ENABLE_PORT_MAPPING) + port_mapping = config.get(CONF_ENABLE_PORT_MAPPING) if not port_mapping: return True - base_url = urlsplit(hass.config.api.base_url) - host = base_url.hostname - internal_port = base_url.port - external_port = int(config[DOMAIN].get(CONF_EXTERNAL_PORT)) + internal_port = hass.http.server_port - if external_port == 0: - external_port = internal_port + ports = config.get(CONF_PORTS) + if ports is None: + ports = {CONF_HASS: internal_port} - try: - upnp.addportmapping( - external_port, 'TCP', host, internal_port, 'Home Assistant', '') + registered = [] + for internal, external in ports.items(): + if internal == CONF_HASS: + internal = internal_port + try: + upnp.addportmapping( + external, 'TCP', host, internal, 'Home Assistant', '') + registered.append(external) + except Exception: + _LOGGER.exception("UPnP failed to configure port mapping for %s", + external) + hass.components.persistent_notification.create( + 'ERROR: tcp port {} is already mapped in your router.' + '
Please disable port_mapping in the upnp ' + 'configuration section.
' + 'You will need to restart hass after fixing.' + ''.format(external), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) - def deregister_port(event): - """De-register the UPnP port mapping.""" - upnp.deleteportmapping(external_port, 'TCP') + def deregister_port(event): + """De-register the UPnP port mapping.""" + for external in registered: + upnp.deleteportmapping(external, 'TCP') - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port) - except Exception as ex: - _LOGGER.error("UPnP failed to configure port mapping: %s", str(ex)) - hass.components.persistent_notification.create( - 'ERROR: tcp port {} is already mapped in your router.' - '
Please disable port_mapping in the upnp ' - 'configuration section.
' - 'You will need to restart hass after fixing.' - ''.format(external_port), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False return True diff --git a/homeassistant/components/usps.py b/homeassistant/components/usps.py index fdafbbc3587..0c4eba54e35 100644 --- a/homeassistant/components/usps.py +++ b/homeassistant/components/usps.py @@ -15,7 +15,7 @@ from homeassistant.helpers import (config_validation as cv, discovery) from homeassistant.util import Throttle from homeassistant.util.dt import now -REQUIREMENTS = ['myusps==1.1.3'] +REQUIREMENTS = ['myusps==1.2.2'] _LOGGER = logging.getLogger(__name__) @@ -23,6 +23,7 @@ DOMAIN = 'usps' DATA_USPS = 'data_usps' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) COOKIE = 'usps_cookies.pickle' +CACHE = 'usps_cache' USPS_TYPE = ['sensor', 'camera'] @@ -45,7 +46,9 @@ def setup(hass, config): import myusps try: cookie = hass.config.path(COOKIE) - session = myusps.get_session(username, password, cookie_path=cookie) + cache = hass.config.path(CACHE) + session = myusps.get_session(username, password, + cookie_path=cookie, cache_path=cache) except myusps.USPSError: _LOGGER.exception('Could not connect to My USPS') return False diff --git a/homeassistant/components/vacuum/demo.py b/homeassistant/components/vacuum/demo.py index 54415b59db0..668e3ca37e6 100644 --- a/homeassistant/components/vacuum/demo.py +++ b/homeassistant/components/vacuum/demo.py @@ -142,7 +142,7 @@ class DemoVacuum(VacuumDevice): self.schedule_update_ha_state() def stop(self, **kwargs): - """Turn the vacuum off.""" + """Stop the vacuum.""" if self.supported_features & SUPPORT_STOP == 0: return @@ -162,7 +162,7 @@ class DemoVacuum(VacuumDevice): self.schedule_update_ha_state() def locate(self, **kwargs): - """Turn the vacuum off.""" + """Locate the vacuum (usually by playing a song).""" if self.supported_features & SUPPORT_LOCATE == 0: return @@ -184,7 +184,7 @@ class DemoVacuum(VacuumDevice): self.schedule_update_ha_state() def set_fan_speed(self, fan_speed, **kwargs): - """Tell the vacuum to return to its dock.""" + """Set the vacuum's fan speed.""" if self.supported_features & SUPPORT_FAN_SPEED == 0: return diff --git a/homeassistant/components/vacuum/mqtt.py b/homeassistant/components/vacuum/mqtt.py new file mode 100644 index 00000000000..67ee6fb15c7 --- /dev/null +++ b/homeassistant/components/vacuum/mqtt.py @@ -0,0 +1,496 @@ +""" +Support for a generic MQTT vacuum. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/vacuum.mqtt/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.components.mqtt as mqtt +import homeassistant.helpers.config_validation as cv +from homeassistant.components.vacuum import ( + DEFAULT_ICON, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, + SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, + SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + VacuumDevice) +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME +from homeassistant.core import callback +from homeassistant.util.icon import icon_for_battery_level + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +SERVICE_TO_STRING = { + SUPPORT_TURN_ON: 'turn_on', + SUPPORT_TURN_OFF: 'turn_off', + SUPPORT_PAUSE: 'pause', + SUPPORT_STOP: 'stop', + SUPPORT_RETURN_HOME: 'return_home', + SUPPORT_FAN_SPEED: 'fan_speed', + SUPPORT_BATTERY: 'battery', + SUPPORT_STATUS: 'status', + SUPPORT_SEND_COMMAND: 'send_command', + SUPPORT_LOCATE: 'locate', + SUPPORT_CLEAN_SPOT: 'clean_spot', +} + +STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()} + + +def services_to_strings(services): + """Convert SUPPORT_* service bitmask to list of service strings.""" + strings = [] + for service in SERVICE_TO_STRING: + if service & services: + strings.append(SERVICE_TO_STRING[service]) + return strings + + +def strings_to_services(strings): + """Convert service strings to SUPPORT_* service bitmask.""" + services = 0 + for string in strings: + services |= STRING_TO_SERVICE[string] + return services + + +DEFAULT_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_STOP |\ + SUPPORT_RETURN_HOME | SUPPORT_STATUS | SUPPORT_BATTERY |\ + SUPPORT_CLEAN_SPOT +ALL_SERVICES = DEFAULT_SERVICES | SUPPORT_PAUSE | SUPPORT_LOCATE |\ + SUPPORT_FAN_SPEED | SUPPORT_SEND_COMMAND + +CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES +CONF_PAYLOAD_TURN_ON = 'payload_turn_on' +CONF_PAYLOAD_TURN_OFF = 'payload_turn_off' +CONF_PAYLOAD_RETURN_TO_BASE = 'payload_return_to_base' +CONF_PAYLOAD_STOP = 'payload_stop' +CONF_PAYLOAD_CLEAN_SPOT = 'payload_clean_spot' +CONF_PAYLOAD_LOCATE = 'payload_locate' +CONF_PAYLOAD_START_PAUSE = 'payload_start_pause' +CONF_BATTERY_LEVEL_TOPIC = 'battery_level_topic' +CONF_BATTERY_LEVEL_TEMPLATE = 'battery_level_template' +CONF_CHARGING_TOPIC = 'charging_topic' +CONF_CHARGING_TEMPLATE = 'charging_template' +CONF_CLEANING_TOPIC = 'cleaning_topic' +CONF_CLEANING_TEMPLATE = 'cleaning_template' +CONF_DOCKED_TOPIC = 'docked_topic' +CONF_DOCKED_TEMPLATE = 'docked_template' +CONF_STATE_TOPIC = 'state_topic' +CONF_STATE_TEMPLATE = 'state_template' +CONF_FAN_SPEED_TOPIC = 'fan_speed_topic' +CONF_FAN_SPEED_TEMPLATE = 'fan_speed_template' +CONF_SET_FAN_SPEED_TOPIC = 'set_fan_speed_topic' +CONF_FAN_SPEED_LIST = 'fan_speed_list' +CONF_SEND_COMMAND_TOPIC = 'send_command_topic' + +DEFAULT_NAME = 'MQTT Vacuum' +DEFAULT_RETAIN = False +DEFAULT_SERVICE_STRINGS = services_to_strings(DEFAULT_SERVICES) +DEFAULT_PAYLOAD_TURN_ON = 'turn_on' +DEFAULT_PAYLOAD_TURN_OFF = 'turn_off' +DEFAULT_PAYLOAD_RETURN_TO_BASE = 'return_to_base' +DEFAULT_PAYLOAD_STOP = 'stop' +DEFAULT_PAYLOAD_CLEAN_SPOT = 'clean_spot' +DEFAULT_PAYLOAD_LOCATE = 'locate' +DEFAULT_PAYLOAD_START_PAUSE = 'start_pause' + +PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS): + vol.All(cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())]), + vol.Optional(mqtt.CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_PAYLOAD_TURN_ON, + default=DEFAULT_PAYLOAD_TURN_ON): cv.string, + vol.Optional(CONF_PAYLOAD_TURN_OFF, + default=DEFAULT_PAYLOAD_TURN_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_RETURN_TO_BASE, + default=DEFAULT_PAYLOAD_RETURN_TO_BASE): cv.string, + vol.Optional(CONF_PAYLOAD_STOP, + default=DEFAULT_PAYLOAD_STOP): cv.string, + vol.Optional(CONF_PAYLOAD_CLEAN_SPOT, + default=DEFAULT_PAYLOAD_CLEAN_SPOT): cv.string, + vol.Optional(CONF_PAYLOAD_LOCATE, + default=DEFAULT_PAYLOAD_LOCATE): cv.string, + vol.Optional(CONF_PAYLOAD_START_PAUSE, + default=DEFAULT_PAYLOAD_START_PAUSE): cv.string, + vol.Optional(CONF_BATTERY_LEVEL_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_BATTERY_LEVEL_TEMPLATE): cv.template, + vol.Optional(CONF_CHARGING_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_CHARGING_TEMPLATE): cv.template, + vol.Optional(CONF_CLEANING_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_CLEANING_TEMPLATE): cv.template, + vol.Optional(CONF_DOCKED_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_DOCKED_TEMPLATE): cv.template, + vol.Optional(CONF_STATE_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_FAN_SPEED_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_FAN_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_SET_FAN_SPEED_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_SEND_COMMAND_TOPIC): mqtt.valid_publish_topic, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the vacuum.""" + name = config.get(CONF_NAME) + supported_feature_strings = config.get(CONF_SUPPORTED_FEATURES) + supported_features = strings_to_services(supported_feature_strings) + + qos = config.get(mqtt.CONF_QOS) + retain = config.get(mqtt.CONF_RETAIN) + + command_topic = config.get(mqtt.CONF_COMMAND_TOPIC) + payload_turn_on = config.get(CONF_PAYLOAD_TURN_ON) + payload_turn_off = config.get(CONF_PAYLOAD_TURN_OFF) + payload_return_to_base = config.get(CONF_PAYLOAD_RETURN_TO_BASE) + payload_stop = config.get(CONF_PAYLOAD_STOP) + payload_clean_spot = config.get(CONF_PAYLOAD_CLEAN_SPOT) + payload_locate = config.get(CONF_PAYLOAD_LOCATE) + payload_start_pause = config.get(CONF_PAYLOAD_START_PAUSE) + + battery_level_topic = config.get(CONF_BATTERY_LEVEL_TOPIC) + battery_level_template = config.get(CONF_BATTERY_LEVEL_TEMPLATE) + if battery_level_template: + battery_level_template.hass = hass + + charging_topic = config.get(CONF_CHARGING_TOPIC) + charging_template = config.get(CONF_CHARGING_TEMPLATE) + if charging_template: + charging_template.hass = hass + + cleaning_topic = config.get(CONF_CLEANING_TOPIC) + cleaning_template = config.get(CONF_CLEANING_TEMPLATE) + if cleaning_template: + cleaning_template.hass = hass + + docked_topic = config.get(CONF_DOCKED_TOPIC) + docked_template = config.get(CONF_DOCKED_TEMPLATE) + if docked_template: + docked_template.hass = hass + + fan_speed_topic = config.get(CONF_FAN_SPEED_TOPIC) + fan_speed_template = config.get(CONF_FAN_SPEED_TEMPLATE) + if fan_speed_template: + fan_speed_template.hass = hass + + set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) + fan_speed_list = config.get(CONF_FAN_SPEED_LIST) + + send_command_topic = config.get(CONF_SEND_COMMAND_TOPIC) + + async_add_devices([ + MqttVacuum( + name, supported_features, qos, retain, command_topic, + payload_turn_on, payload_turn_off, payload_return_to_base, + payload_stop, payload_clean_spot, payload_locate, + payload_start_pause, battery_level_topic, battery_level_template, + charging_topic, charging_template, cleaning_topic, + cleaning_template, docked_topic, docked_template, fan_speed_topic, + fan_speed_template, set_fan_speed_topic, fan_speed_list, + send_command_topic + ), + ]) + + +class MqttVacuum(VacuumDevice): + """Representation of a MQTT-controlled vacuum.""" + + # pylint: disable=no-self-use + def __init__( + self, name, supported_features, qos, retain, command_topic, + payload_turn_on, payload_turn_off, payload_return_to_base, + payload_stop, payload_clean_spot, payload_locate, + payload_start_pause, battery_level_topic, battery_level_template, + charging_topic, charging_template, cleaning_topic, + cleaning_template, docked_topic, docked_template, fan_speed_topic, + fan_speed_template, set_fan_speed_topic, fan_speed_list, + send_command_topic): + """Initialize the vacuum.""" + self._name = name + self._supported_features = supported_features + self._qos = qos + self._retain = retain + + self._command_topic = command_topic + self._payload_turn_on = payload_turn_on + self._payload_turn_off = payload_turn_off + self._payload_return_to_base = payload_return_to_base + self._payload_stop = payload_stop + self._payload_clean_spot = payload_clean_spot + self._payload_locate = payload_locate + self._payload_start_pause = payload_start_pause + + self._battery_level_topic = battery_level_topic + self._battery_level_template = battery_level_template + + self._charging_topic = charging_topic + self._charging_template = charging_template + + self._cleaning_topic = cleaning_topic + self._cleaning_template = cleaning_template + + self._docked_topic = docked_topic + self._docked_template = docked_template + + self._fan_speed_topic = fan_speed_topic + self._fan_speed_template = fan_speed_template + + self._set_fan_speed_topic = set_fan_speed_topic + self._fan_speed_list = fan_speed_list + self._send_command_topic = send_command_topic + + self._cleaning = False + self._charging = False + self._docked = False + self._status = 'Unknown' + self._battery_level = 0 + self._fan_speed = 'unknown' + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe MQTT events. + + This method is a coroutine. + """ + @callback + def message_received(topic, payload, qos): + """Handle new MQTT message.""" + if topic == self._battery_level_topic and \ + self._battery_level_template: + battery_level = self._battery_level_template\ + .async_render_with_possible_json_value( + payload, + error_value=None) + if battery_level is not None: + self._battery_level = int(battery_level) + + if topic == self._charging_topic and self._charging_template: + charging = self._charging_template\ + .async_render_with_possible_json_value( + payload, + error_value=None) + if charging is not None: + self._charging = cv.boolean(charging) + + if topic == self._cleaning_topic and self._cleaning_template: + cleaning = self._cleaning_template \ + .async_render_with_possible_json_value( + payload, + error_value=None) + if cleaning is not None: + self._cleaning = cv.boolean(cleaning) + + if topic == self._docked_topic and self._docked_template: + docked = self._docked_template \ + .async_render_with_possible_json_value( + payload, + error_value=None) + if docked is not None: + self._docked = cv.boolean(docked) + + if self._docked: + if self._charging: + self._status = "Docked & Charging" + else: + self._status = "Docked" + elif self._cleaning: + self._status = "Cleaning" + else: + self._status = "Stopped" + + if topic == self._fan_speed_topic and self._fan_speed_template: + fan_speed = self._fan_speed_template\ + .async_render_with_possible_json_value( + payload, + error_value=None) + if fan_speed is not None: + self._fan_speed = fan_speed + + self.async_schedule_update_ha_state() + + topics_list = [topic for topic in (self._battery_level_topic, + self._charging_topic, + self._cleaning_topic, + self._docked_topic, + self._fan_speed_topic) if topic] + for topic in set(topics_list): + yield from self.hass.components.mqtt.async_subscribe( + topic, message_received, self._qos) + + @property + def name(self): + """Return the name of the vacuum.""" + return self._name + + @property + def icon(self): + """Return the icon for the vacuum.""" + return DEFAULT_ICON + + @property + def should_poll(self): + """No polling needed for an MQTT vacuum.""" + return False + + @property + def is_on(self): + """Return true if vacuum is on.""" + return self._cleaning + + @property + def status(self): + """Return a status string for the vacuum.""" + if self.supported_features & SUPPORT_STATUS == 0: + return + + return self._status + + @property + def fan_speed(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + return self._fan_speed + + @property + def fan_speed_list(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return [] + return self._fan_speed_list + + @property + def battery_level(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_BATTERY == 0: + return + + return max(0, min(100, self._battery_level)) + + @property + def battery_icon(self): + """Return the battery icon for the vacuum cleaner.""" + if self.supported_features & SUPPORT_BATTERY == 0: + return + + return icon_for_battery_level( + battery_level=self.battery_level, charging=self._charging) + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the vacuum on.""" + if self.supported_features & SUPPORT_TURN_ON == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_turn_on, self._qos, self._retain) + self._status = 'Cleaning' + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the vacuum off.""" + if self.supported_features & SUPPORT_TURN_OFF == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_turn_off, self._qos, self._retain) + self._status = 'Turning Off' + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_stop(self, **kwargs): + """Stop the vacuum.""" + if self.supported_features & SUPPORT_STOP == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, self._payload_stop, + self._qos, self._retain) + self._status = 'Stopping the current task' + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + if self.supported_features & SUPPORT_CLEAN_SPOT == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_clean_spot, self._qos, self._retain) + self._status = "Cleaning spot" + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_locate(self, **kwargs): + """Locate the vacuum (usually by playing a song).""" + if self.supported_features & SUPPORT_LOCATE == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_locate, self._qos, self._retain) + self._status = "Hi, I'm over here!" + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_start_pause(self, **kwargs): + """Start, pause or resume the cleaning task.""" + if self.supported_features & SUPPORT_PAUSE == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_start_pause, self._qos, self._retain) + self._status = 'Pausing/Resuming cleaning...' + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_return_to_base(self, **kwargs): + """Tell the vacuum to return to its dock.""" + if self.supported_features & SUPPORT_RETURN_HOME == 0: + return + + mqtt.async_publish(self.hass, self._command_topic, + self._payload_return_to_base, self._qos, + self._retain) + self._status = 'Returning home...' + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + if not self._fan_speed_list or fan_speed not in self._fan_speed_list: + return + + mqtt.async_publish( + self.hass, self._set_fan_speed_topic, fan_speed, self._qos, + self._retain) + self._status = "Setting fan to {}...".format(fan_speed) + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner.""" + if self.supported_features & SUPPORT_SEND_COMMAND == 0: + return + + mqtt.async_publish( + self.hass, self._send_command_topic, command, self._qos, + self._retain) + self._status = "Sending command {}...".format(command) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index cf9ee064283..500b98420fc 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -98,7 +98,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class RoombaVacuum(VacuumDevice): - """Representation of a Xiaomi Vacuum cleaner robot.""" + """Representation of a Roomba Vacuum cleaner robot.""" def __init__(self, name, roomba): """Initialize the Roomba handler.""" @@ -310,7 +310,7 @@ class RoombaVacuum(VacuumDevice): if error_msg and error_msg != 'None': self._state_attrs[ATTR_ERROR] = error_msg - # Not all Roombas expose positon data + # Not all Roombas expose position data # https://github.com/koalazak/dorita980/issues/48 if self._capabilities[CAP_POSITION]: pos_state = state.get('pose', {}) diff --git a/homeassistant/components/vacuum/xiaomi.py b/homeassistant/components/vacuum/xiaomi_miio.py similarity index 88% rename from homeassistant/components/vacuum/xiaomi.py rename to homeassistant/components/vacuum/xiaomi_miio.py index 5e5081a2aa8..5747dd1dc9e 100644 --- a/homeassistant/components/vacuum/xiaomi.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -2,7 +2,7 @@ Support for the Xiaomi vacuum cleaner robot. For more details about this platform, please refer to the documentation -https://home-assistant.io/components/vacuum.xiaomi/ +https://home-assistant.io/components/vacuum.xiaomi_miio/ """ import asyncio from functools import partial @@ -21,13 +21,13 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-mirobo==0.1.2'] +REQUIREMENTS = ['python-mirobo==0.2.0'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Vacuum cleaner' ICON = 'mdi:google-circles-group' -PLATFORM = 'xiaomi' +PLATFORM = 'xiaomi_miio' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -48,6 +48,12 @@ FAN_SPEEDS = { ATTR_CLEANING_TIME = 'cleaning_time' ATTR_DO_NOT_DISTURB = 'do_not_disturb' +ATTR_MAIN_BRUSH_LEFT = 'main_brush_left' +ATTR_SIDE_BRUSH_LEFT = 'side_brush_left' +ATTR_FILTER_LEFT = 'filter_left' +ATTR_CLEANING_COUNT = 'cleaning_count' +ATTR_CLEANED_TOTAL_AREA = 'total_cleaned_area' +ATTR_CLEANING_TOTAL_TIME = 'total_cleaning_time' ATTR_ERROR = 'error' ATTR_RC_DURATION = 'duration' ATTR_RC_ROTATION = 'rotation' @@ -147,6 +153,9 @@ class MiroboVacuum(VacuumDevice): self._is_on = False self._available = False + self.consumable_state = None + self.clean_history = None + @property def name(self): """Return the name of the device.""" @@ -194,8 +203,24 @@ class MiroboVacuum(VacuumDevice): STATE_ON if self.vacuum_state.dnd else STATE_OFF, # Not working --> 'Cleaning mode': # STATE_ON if self.vacuum_state.in_cleaning else STATE_OFF, - ATTR_CLEANING_TIME: str(self.vacuum_state.clean_time), - ATTR_CLEANED_AREA: round(self.vacuum_state.clean_area, 2)}) + ATTR_CLEANING_TIME: int( + self.vacuum_state.clean_time.total_seconds() + / 60), + ATTR_CLEANED_AREA: int(self.vacuum_state.clean_area), + ATTR_CLEANING_COUNT: int(self.clean_history.count), + ATTR_CLEANED_TOTAL_AREA: int(self.clean_history.total_area), + ATTR_CLEANING_TOTAL_TIME: int( + self.clean_history.total_duration.total_seconds() + / 60), + ATTR_MAIN_BRUSH_LEFT: int( + self.consumable_state.main_brush_left.total_seconds() + / 3600), + ATTR_SIDE_BRUSH_LEFT: int( + self.consumable_state.side_brush_left.total_seconds() + / 3600), + ATTR_FILTER_LEFT: int( + self.consumable_state.filter_left.total_seconds() + / 3600)}) if self.vacuum_state.got_error: attrs[ATTR_ERROR] = self.vacuum_state.error @@ -346,6 +371,10 @@ class MiroboVacuum(VacuumDevice): state = yield from self.hass.async_add_job(self._vacuum.status) _LOGGER.debug("Got new state from the vacuum: %s", state.data) self.vacuum_state = state + self.consumable_state = yield from self.hass.async_add_job( + self._vacuum.consumable_status) + self.clean_history = yield from self.hass.async_add_job( + self._vacuum.clean_history) self._is_on = state.is_on self._available = True except OSError as exc: diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 2183e20188f..7a018a6502d 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.35'] +REQUIREMENTS = ['pyvera==0.2.37'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 5903bed1fc7..9c8366e7f7e 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -73,14 +73,7 @@ def setup(hass, config): interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) - class state: # pylint:disable=invalid-name - """Namespace to hold state for each vehicle.""" - - entities = {} - vehicles = {} - names = config[DOMAIN].get(CONF_NAME) - - hass.data[DATA_KEY] = state + state = hass.data[DATA_KEY] = VolvoData(config) def discover_vehicle(vehicle): """Load relevant platforms.""" @@ -120,6 +113,31 @@ def setup(hass, config): return update(utcnow()) +class VolvoData: + """Hold component state.""" + + def __init__(self, config): + """Initialize the component state.""" + self.entities = {} + self.vehicles = {} + self.names = config[DOMAIN].get(CONF_NAME) + + def vehicle_name(self, vehicle): + """Provide a friendly name for a vehicle.""" + if (vehicle.registration_number and + vehicle.registration_number.lower()) in self.names: + return self.names[vehicle.registration_number.lower()] + elif (vehicle.vin and + vehicle.vin.lower() in self.names): + return self.names[vehicle.vin.lower()] + elif vehicle.registration_number: + return vehicle.registration_number + elif vehicle.vin: + return vehicle.vin + else: + return '' + + class VolvoEntity(Entity): """Base class for all VOC entities.""" @@ -139,17 +157,14 @@ class VolvoEntity(Entity): """Return vehicle.""" return self._state.vehicles[self._vin] - @property - def _vehicle_name(self): - return (self._state.names.get(self._vin.lower()) or - self._state.names.get( - self.vehicle.registration_number.lower()) or - self.vehicle.registration_number) - @property def _entity_name(self): return RESOURCES[self._attribute][1] + @property + def _vehicle_name(self): + return self._state.vehicle_name(self.vehicle) + @property def name(self): """Return full name of the entity.""" diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 3d7226e3c8b..0592ad4c124 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.19'] +REQUIREMENTS = ['pywemo==0.4.20'] DOMAIN = 'wemo' diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 23eb90daa89..0b3a006a8d2 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -20,11 +20,12 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.const import ( ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, __version__) + EVENT_HOMEASSISTANT_STOP, __version__, ATTR_ENTITY_ID) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['python-wink==1.5.1', 'pubnubsub-handler==1.0.2'] +REQUIREMENTS = ['python-wink==1.6.0', 'pubnubsub-handler==1.0.2'] _LOGGER = logging.getLogger(__name__) @@ -45,6 +46,10 @@ ATTR_ACCESS_TOKEN = 'access_token' ATTR_REFRESH_TOKEN = 'refresh_token' ATTR_CLIENT_ID = 'client_id' ATTR_CLIENT_SECRET = 'client_secret' +ATTR_NAME = 'name' +ATTR_PAIRING_MODE = 'pairing_mode' +ATTR_KIDDE_RADIO_CODE = 'kidde_radio_code' +ATTR_HUB_NAME = 'hub_name' WINK_AUTH_CALLBACK_PATH = '/auth/wink/callback' WINK_AUTH_START = '/auth/wink' @@ -56,9 +61,12 @@ DEFAULT_CONFIG = { 'client_secret': 'CLIENT_SECRET_HERE' } -SERVICE_ADD_NEW_DEVICES = 'add_new_devices' +SERVICE_ADD_NEW_DEVICES = 'pull_newly_added_devices_from_wink' SERVICE_REFRESH_STATES = 'refresh_state_from_wink' -SERVICE_KEEP_ALIVE = 'keep_pubnub_updates_flowing' +SERVICE_RENAME_DEVICE = 'rename_wink_device' +SERVICE_DELETE_DEVICE = 'delete_wink_device' +SERVICE_SET_PAIRING_MODE = 'pair_new_device' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -74,11 +82,29 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) + +RENAME_DEVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_NAME): cv.string +}, extra=vol.ALLOW_EXTRA) + +DELETE_DEVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids +}, extra=vol.ALLOW_EXTRA) + +SET_PAIRING_MODE_SCHEMA = vol.Schema({ + vol.Required(ATTR_HUB_NAME): cv.string, + vol.Required(ATTR_PAIRING_MODE): cv.string, + vol.Optional(ATTR_KIDDE_RADIO_CODE): cv.string +}, extra=vol.ALLOW_EXTRA) + WINK_COMPONENTS = [ 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'cover', 'climate', 'fan', 'alarm_control_panel', 'scene' ] +WINK_HUBS = [] + def _write_config_file(file_path, config): try: @@ -177,6 +203,9 @@ def setup(hass, config): import pywink from pubnubsubhandler import PubNubSubscriptionHandler + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')).get(DOMAIN) + if hass.data.get(DOMAIN) is None: hass.data[DOMAIN] = { 'unique_ids': [], @@ -313,6 +342,7 @@ def setup(hass, config): def stop_subscription(event): """Stop the pubnub subscription.""" hass.data[DOMAIN]['pubnub'].unsubscribe() + hass.data[DOMAIN]['pubnub'] = None hass.bus.listen(EVENT_HOMEASSISTANT_STOP, stop_subscription) @@ -333,7 +363,9 @@ def setup(hass, config): for entity in entity_list: time.sleep(1) entity.schedule_update_ha_state(True) - hass.services.register(DOMAIN, SERVICE_REFRESH_STATES, force_update) + + hass.services.register(DOMAIN, SERVICE_REFRESH_STATES, force_update, + descriptions.get(SERVICE_REFRESH_STATES)) def pull_new_devices(call): """Pull new devices added to users Wink account since startup.""" @@ -341,12 +373,71 @@ def setup(hass, config): for _component in WINK_COMPONENTS: discovery.load_platform(hass, _component, DOMAIN, {}, config) - hass.services.register(DOMAIN, SERVICE_ADD_NEW_DEVICES, pull_new_devices) + hass.services.register(DOMAIN, SERVICE_ADD_NEW_DEVICES, pull_new_devices, + descriptions.get(SERVICE_ADD_NEW_DEVICES)) + + def set_pairing_mode(call): + """Put the hub in provided pairing mode.""" + hub_name = call.data.get('hub_name') + pairing_mode = call.data.get('pairing_mode') + kidde_code = call.data.get('kidde_radio_code') + for hub in WINK_HUBS: + if hub.name() == hub_name: + hub.pair_new_device(pairing_mode, + kidde_radio_code=kidde_code) + + def rename_device(call): + """Set specified device's name.""" + # This should only be called on one device at a time. + found_device = None + entity_id = call.data.get('entity_id')[0] + all_devices = [] + for list_of_devices in hass.data[DOMAIN]['entities'].values(): + all_devices += list_of_devices + for device in all_devices: + if device.entity_id == entity_id: + found_device = device + if found_device is not None: + name = call.data.get('name') + found_device.wink.set_name(name) + + hass.services.register(DOMAIN, SERVICE_RENAME_DEVICE, rename_device, + descriptions.get(SERVICE_RENAME_DEVICE), + schema=RENAME_DEVICE_SCHEMA) + + def delete_device(call): + """Delete specified device.""" + # This should only be called on one device at a time. + found_device = None + entity_id = call.data.get('entity_id')[0] + all_devices = [] + for list_of_devices in hass.data[DOMAIN]['entities'].values(): + all_devices += list_of_devices + for device in all_devices: + if device.entity_id == entity_id: + found_device = device + if found_device is not None: + found_device.wink.remove_device() + + hass.services.register(DOMAIN, SERVICE_DELETE_DEVICE, delete_device, + descriptions.get(SERVICE_DELETE_DEVICE), + schema=DELETE_DEVICE_SCHEMA) + + hubs = pywink.get_hubs() + for hub in hubs: + if hub.device_manufacturer() == 'wink': + WINK_HUBS.append(hub) + + if WINK_HUBS: + hass.services.register( + DOMAIN, SERVICE_SET_PAIRING_MODE, set_pairing_mode, + descriptions.get(SERVICE_SET_PAIRING_MODE), + schema=SET_PAIRING_MODE_SCHEMA) # Load components for the devices in Wink that we support - for component in WINK_COMPONENTS: - hass.data[DOMAIN]['entities'][component] = [] - discovery.load_platform(hass, component, DOMAIN, {}, config) + for wink_component in WINK_COMPONENTS: + hass.data[DOMAIN]['entities'][wink_component] = [] + discovery.load_platform(hass, wink_component, DOMAIN, {}, config) return True diff --git a/homeassistant/components/xiaomi.py b/homeassistant/components/xiaomi_aqara.py similarity index 82% rename from homeassistant/components/xiaomi.py rename to homeassistant/components/xiaomi_aqara.py index 1329c9cdcaf..f786faf853a 100644 --- a/homeassistant/components/xiaomi.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -4,12 +4,11 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity +from homeassistant.components.discovery import SERVICE_XIAOMI_GW from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, CONF_MAC) - -REQUIREMENTS = ['https://github.com/Danielhiversen/PyXiaomiGateway/archive/' - '0.3.zip#PyXiaomiGateway==0.3.0'] +REQUIREMENTS = ['PyXiaomiGateway==0.5.1'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' @@ -17,7 +16,7 @@ ATTR_RINGTONE_VOL = 'ringtone_vol' CONF_DISCOVERY_RETRY = 'discovery_retry' CONF_GATEWAYS = 'gateways' CONF_INTERFACE = 'interface' -DOMAIN = 'xiaomi' +DOMAIN = 'xiaomi_aqara' PY_XIAOMI_GATEWAY = "xiaomi_gw" @@ -39,6 +38,17 @@ def _validate_conf(config): raise vol.Invalid('Invalid key %s.' ' Key must be 16 characters', key) res_gw_conf['key'] = key + + host = gw_conf.get('host') + if host is not None: + res_gw_conf['host'] = host + res_gw_conf['port'] = gw_conf.get('port', 9898) + + _LOGGER.warning( + 'Static address (%s:%s) of the gateway provided. ' + 'Discovery of this host will be skipped.', + res_gw_conf['host'], res_gw_conf['port']) + res_config.append(res_gw_conf) return res_config @@ -57,17 +67,30 @@ _LOGGER = logging.getLogger(__name__) def setup(hass, config): """Set up the Xiaomi component.""" - gateways = config[DOMAIN][CONF_GATEWAYS] - interface = config[DOMAIN][CONF_INTERFACE] - discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY] + gateways = [] + interface = 'any' + discovery_retry = 3 + if DOMAIN in config: + gateways = config[DOMAIN][CONF_GATEWAYS] + interface = config[DOMAIN][CONF_INTERFACE] + discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY] + + def xiaomi_gw_discovered(service, discovery_info): + """Called when Xiaomi Gateway device(s) has been found.""" + # We don't need to do anything here, the purpose of HA's + # discovery service is to just trigger loading of this + # component, and then its own discovery process kicks in. + _LOGGER.info("Discovered: %s", discovery_info) + + discovery.listen(hass, SERVICE_XIAOMI_GW, xiaomi_gw_discovered) from PyXiaomiGateway import PyXiaomiGateway hass.data[PY_XIAOMI_GATEWAY] = PyXiaomiGateway(hass.add_job, gateways, interface) _LOGGER.debug("Expecting %s gateways", len(gateways)) - for _ in range(discovery_retry): - _LOGGER.info('Discovering Xiaomi Gateways (Try %s)', _ + 1) + for k in range(discovery_retry): + _LOGGER.info('Discovering Xiaomi Gateways (Try %s)', k + 1) hass.data[PY_XIAOMI_GATEWAY].discover_gateways() if len(hass.data[PY_XIAOMI_GATEWAY].gateways) >= len(gateways): break @@ -76,7 +99,7 @@ def setup(hass, config): _LOGGER.error("No gateway discovered") return False hass.data[PY_XIAOMI_GATEWAY].listen() - _LOGGER.debug("Listening for broadcast") + _LOGGER.debug("Gateways discovered. Listening for broadcasts") for component in ['binary_sensor', 'sensor', 'switch', 'light', 'cover']: discovery.load_platform(hass, component, DOMAIN, {}, config) @@ -152,8 +175,8 @@ class XiaomiDevice(Entity): self._name = '{}_{}'.format(name, self._sid) self._write_to_hub = xiaomi_hub.write_to_hub self._get_from_hub = xiaomi_hub.get_from_hub - xiaomi_hub.callbacks[self._sid].append(self.push_data) self._device_state_attributes = {} + xiaomi_hub.callbacks[self._sid].append(self.push_data) self.parse_data(device['data']) self.parse_voltage(device['data']) @@ -164,7 +187,7 @@ class XiaomiDevice(Entity): @property def should_poll(self): - """Poll update device status.""" + """No polling needed.""" return False @property diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 1b2d46ee72b..55fb0e41cb2 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -218,7 +218,7 @@ class ApplicationListener: class Entity(entity.Entity): """A base class for ZHA entities.""" - _domain = None # Must be overriden by subclasses + _domain = None # Must be overridden by subclasses def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, model, **kwargs): diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 853966279b6..0e6e41c63a5 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -35,7 +35,7 @@ from . import workaround from .discovery_schemas import DISCOVERY_SCHEMAS from .util import check_node_schema, check_value_schema, node_name -REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.0.31'] +REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.0.35'] _LOGGER = logging.getLogger(__name__) @@ -85,6 +85,12 @@ SET_CONFIG_PARAMETER_SCHEMA = vol.Schema({ vol.Optional(const.ATTR_CONFIG_SIZE, default=2): vol.Coerce(int) }) +SET_POLL_INTENSITY_SCHEMA = vol.Schema({ + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(const.ATTR_VALUE_ID): vol.Coerce(int), + vol.Required(const.ATTR_POLL_INTENSITY): vol.Coerce(int), +}) + PRINT_CONFIG_PARAMETER_SCHEMA = vol.Schema({ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Coerce(int), @@ -415,6 +421,29 @@ def setup(hass, config): "Renamed Z-Wave value (Node %d Value %d) to %s", node_id, value_id, name) + def set_poll_intensity(service): + """Set the polling intensity of a node value.""" + node_id = service.data.get(const.ATTR_NODE_ID) + value_id = service.data.get(const.ATTR_VALUE_ID) + node = network.nodes[node_id] + value = node.values[value_id] + intensity = service.data.get(const.ATTR_POLL_INTENSITY) + if intensity == 0: + if value.disable_poll(): + _LOGGER.info("Polling disabled (Node %d Value %d)", + node_id, value_id) + return + _LOGGER.info("Polling disabled failed (Node %d Value %d)", + node_id, value_id) + else: + if value.enable_poll(intensity): + _LOGGER.info( + "Set polling intensity (Node %d Value %d) to %s", + node_id, value_id, intensity) + return + _LOGGER.info("Set polling intensity failed (Node %d Value %d)", + node_id, value_id) + def remove_failed_node(service): """Remove failed node.""" node_id = service.data.get(const.ATTR_NODE_ID) @@ -651,6 +680,10 @@ def setup(hass, config): descriptions[ const.SERVICE_RESET_NODE_METERS], schema=RESET_NODE_METERS_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_SET_POLL_INTENSITY, + set_poll_intensity, + descriptions[const.SERVICE_SET_POLL_INTENSITY], + schema=SET_POLL_INTENSITY_SCHEMA) # Setup autoheal if autoheal: @@ -775,8 +808,6 @@ class ZWaveDeviceEntityValues(): node_config.get(CONF_POLLING_INTENSITY), int) if polling_intensity: self.primary.enable_poll(polling_intensity) - else: - self.primary.disable_poll() platform = get_platform(component, DOMAIN) device = platform.get_device( @@ -887,6 +918,7 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): const.ATTR_NODE_ID: self.node_id, const.ATTR_VALUE_INDEX: self.values.primary.index, const.ATTR_VALUE_INSTANCE: self.values.primary.instance, + const.ATTR_VALUE_ID: str(self.values.primary.value_id), 'old_entity_id': self.old_entity_id, 'new_entity_id': self.new_entity_id, } diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index b72d9eb0cff..dced1689dba 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -10,10 +10,12 @@ ATTR_VALUE_ID = "value_id" ATTR_OBJECT_ID = "object_id" ATTR_NAME = "name" ATTR_SCENE_ID = "scene_id" +ATTR_SCENE_DATA = "scene_data" ATTR_BASIC_LEVEL = "basic_level" ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_SIZE = "size" ATTR_CONFIG_VALUE = "value" +ATTR_POLL_INTENSITY = "poll_intensity" ATTR_VALUE_INDEX = "value_index" ATTR_VALUE_INSTANCE = "value_instance" NETWORK_READY_WAIT_SECS = 30 @@ -37,6 +39,7 @@ SERVICE_PRINT_CONFIG_PARAMETER = "print_config_parameter" SERVICE_PRINT_NODE = "print_node" SERVICE_REMOVE_FAILED_NODE = "remove_failed_node" SERVICE_REPLACE_FAILED_NODE = "replace_failed_node" +SERVICE_SET_POLL_INTENSITY = "set_poll_intensity" SERVICE_SET_WAKEUP = "set_wakeup" SERVICE_STOP_NETWORK = "stop_network" SERVICE_START_NETWORK = "start_network" diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 3a810d00d2d..44a30cdc529 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -7,8 +7,9 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from .const import ( - ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_BASIC_LEVEL, - EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, DOMAIN) + ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_SCENE_DATA, + ATTR_BASIC_LEVEL, EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, DOMAIN, + COMMAND_CLASS_CENTRAL_SCENE) from .util import node_name _LOGGER = logging.getLogger(__name__) @@ -107,13 +108,19 @@ class ZWaveNodeEntity(ZWaveBaseEntity): dispatcher.connect( self.network_scene_activated, ZWaveNetwork.SIGNAL_SCENE_EVENT) - def network_node_changed(self, node=None, args=None): + def network_node_changed(self, node=None, value=None, args=None): """Handle a changed node on the network.""" if node and node.node_id != self.node_id: return if args is not None and 'nodeId' in args and \ args['nodeId'] != self.node_id: return + + # Process central scene activation + if (value is not None and + value.command_class == COMMAND_CLASS_CENTRAL_SCENE): + self.central_scene_activated(value.index, value.data) + self.node_changed() def get_node_statistics(self): @@ -177,6 +184,18 @@ class ZWaveNodeEntity(ZWaveBaseEntity): ATTR_SCENE_ID: scene_id }) + def central_scene_activated(self, scene_id, scene_data): + """Handle an activated central scene for this node.""" + if self.hass is None: + return + + self.hass.bus.fire(EVENT_SCENE_ACTIVATED, { + ATTR_ENTITY_ID: self.entity_id, + ATTR_NODE_ID: self.node_id, + ATTR_SCENE_ID: scene_id, + ATTR_SCENE_DATA: scene_data + }) + @property def state(self): """Return the state.""" diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index ea8a6eaa036..92b5fa25d20 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -56,6 +56,20 @@ set_config_parameter: size: description: (Optional) Set the size of the parameter value. Only needed if no parameters are available. +set_poll_intensity: + description: Set the polling interval to a nodes value + fields: + node_id: + description: ID of the node to set polling to. + example: 10 + value_id: + description: ID of the value to set polling to. + example: 72037594255792737 + poll_intensity: + description: The intensity to poll, 0 = disabled, 1 = Every time through list, 2 = Every second time through list... + example: 2 + + print_config_parameter: description: Prints a Z-Wave node config parameter value to log. fields: diff --git a/homeassistant/config.py b/homeassistant/config.py index c90c4517397..ee48ece67ab 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -57,6 +57,7 @@ DEFAULT_CORE_CONFIG = ( CONF_UNIT_SYSTEM_IMPERIAL)), (CONF_TIME_ZONE, 'UTC', 'time_zone', 'Pick yours from here: http://en.wiki' 'pedia.org/wiki/List_of_tz_database_time_zones'), + (CONF_CUSTOMIZE, '!include customize.yaml', None, 'Customization file'), ) # type: Tuple[Tuple[str, Any, Any, str], ...] DEFAULT_CONFIG = """ # Show links to resources in log and frontend @@ -176,12 +177,15 @@ def create_default_config(config_dir, detect_location=True): CONFIG_PATH as AUTOMATION_CONFIG_PATH) from homeassistant.components.config.script import ( CONFIG_PATH as SCRIPT_CONFIG_PATH) + from homeassistant.components.config.customize import ( + CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) config_path = os.path.join(config_dir, YAML_CONFIG_FILE) version_path = os.path.join(config_dir, VERSION_FILE) group_yaml_path = os.path.join(config_dir, GROUP_CONFIG_PATH) automation_yaml_path = os.path.join(config_dir, AUTOMATION_CONFIG_PATH) script_yaml_path = os.path.join(config_dir, SCRIPT_CONFIG_PATH) + customize_yaml_path = os.path.join(config_dir, CUSTOMIZE_CONFIG_PATH) info = {attr: default for attr, default, _, _ in DEFAULT_CORE_CONFIG} @@ -229,6 +233,9 @@ def create_default_config(config_dir, detect_location=True): with open(script_yaml_path, 'wt'): pass + with open(customize_yaml_path, 'wt'): + pass + return config_path except IOError: diff --git a/homeassistant/const.py b/homeassistant/const.py index 19ce7e470c2..b6937e9a0a6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 52 +MINOR_VERSION = 55 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) @@ -101,6 +101,7 @@ CONF_EVENT = 'event' CONF_EXCLUDE = 'exclude' CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' +CONF_FOR = 'for' CONF_FRIENDLY_NAME = 'friendly_name' CONF_HEADERS = 'headers' CONF_HOST = 'host' diff --git a/homeassistant/core.py b/homeassistant/core.py index 187dfcf1b83..a8704869f21 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -218,7 +218,7 @@ class HomeAssistant(object): else: task = self.loop.run_in_executor(None, target, *args) - # If a task is sheduled + # If a task is scheduled if self._track_task and task is not None: self._pending_tasks.append(task) @@ -914,7 +914,7 @@ class ServiceRegistry(object): Waits a maximum of SERVICE_CALL_LIMIT. If blocking = True, will return boolean if service executed - succesfully within SERVICE_CALL_LIMIT. + successfully within SERVICE_CALL_LIMIT. This method will fire an event to call the service. This event will be picked up by this ServiceRegistry and any @@ -937,7 +937,7 @@ class ServiceRegistry(object): Waits a maximum of SERVICE_CALL_LIMIT. If blocking = True, will return boolean if service executed - succesfully within SERVICE_CALL_LIMIT. + successfully within SERVICE_CALL_LIMIT. This method will fire an event to call the service. This event will be picked up by this ServiceRegistry and any diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index a8b18351021..29e2a6260fd 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -90,8 +90,15 @@ def async_aiohttp_proxy_web(hass, request, web_coro, buffer_size=102400, # Something went wrong with the connection raise HTTPBadGateway() from err - yield from async_aiohttp_proxy_stream(hass, request, req.content, - req.headers.get(CONTENT_TYPE)) + try: + yield from async_aiohttp_proxy_stream( + hass, + request, + req.content, + req.headers.get(CONTENT_TYPE) + ) + finally: + req.close() @asyncio.coroutine diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 49f250c65fa..b2928e73070 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -71,7 +71,7 @@ class Entity(object): # If we reported if this entity was slow _slow_reported = False - # protect for multible updates + # protect for multiple updates _update_warn = None @property @@ -297,10 +297,14 @@ class Entity(object): def schedule_update_ha_state(self, force_refresh=False): """Schedule a update ha state change task. - That is only needed on executor to not block. + That avoid executor dead looks. """ self.hass.add_job(self.async_update_ha_state(force_refresh)) + def async_schedule_update_ha_state(self, force_refresh=False): + """Schedule a update ha state change task.""" + self.hass.async_add_job(self.async_update_ha_state(force_refresh)) + def remove(self) -> None: """Remove entity from HASS.""" run_coroutine_threadsafe( diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 9b64c08af18..5db4ece5ef5 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -113,6 +113,62 @@ def async_track_template(hass, template, action, variables=None): track_template = threaded_listener_factory(async_track_template) +@callback +def async_track_same_state(hass, orig_value, period, action, + async_check_func=None, entity_ids=MATCH_ALL): + """Track the state of entities for a period and run a action. + + If async_check_func is None it use the state of orig_value. + Without entity_ids we track all state changes. + """ + async_remove_state_for_cancel = None + async_remove_state_for_listener = None + + @callback + def clear_listener(): + """Clear all unsub listener.""" + nonlocal async_remove_state_for_cancel, async_remove_state_for_listener + + # pylint: disable=not-callable + if async_remove_state_for_listener is not None: + async_remove_state_for_listener() + async_remove_state_for_listener = None + if async_remove_state_for_cancel is not None: + async_remove_state_for_cancel() + async_remove_state_for_cancel = None + + @callback + def state_for_listener(now): + """Fire on state changes after a delay and calls action.""" + nonlocal async_remove_state_for_listener + async_remove_state_for_listener = None + clear_listener() + hass.async_run_job(action) + + @callback + def state_for_cancel_listener(entity, from_state, to_state): + """Fire on changes and cancel for listener if changed.""" + if async_check_func: + value = async_check_func(entity, from_state, to_state) + else: + value = to_state.state + + if orig_value == value: + return + clear_listener() + + async_remove_state_for_listener = async_track_point_in_utc_time( + hass, state_for_listener, dt_util.utcnow() + period) + + async_remove_state_for_cancel = async_track_state_change( + hass, entity_ids, state_for_cancel_listener) + + return clear_listener + + +track_same_state = threaded_listener_factory(async_track_same_state) + + @callback def async_track_point_in_time(hass, action, point_in_time): """Add a listener that fires once after a specific point in time.""" diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index b44905a3141..bafaf4d0fdb 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -117,7 +117,7 @@ class Script(): wait_template = action[CONF_WAIT_TEMPLATE] wait_template.hass = self.hass - # check if condition allready okay + # check if condition already okay if condition.async_template( self.hass, wait_template, variables): continue diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index aa6ca186a8e..d5dbcb77a32 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -187,6 +187,10 @@ class AllStates(object): sorted(self._hass.states.async_all(), key=lambda state: state.entity_id)) + def __len__(self): + """Return number of states.""" + return len(self._hass.states.async_entity_ids()) + def __call__(self, entity_id): """Return the states.""" state = self._hass.states.get(entity_id) @@ -209,10 +213,14 @@ class DomainStates(object): def __iter__(self): """Return the iteration over all the states.""" return iter(sorted( - (state for state in self._hass.states.async_all() + (_wrap_state(state) for state in self._hass.states.async_all() if state.domain == self._domain), key=lambda state: state.entity_id)) + def __len__(self): + """Return number of states.""" + return len(self._hass.states.async_entity_ids(self._domain)) + class TemplateState(State): """Class to represent a state object in a template.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bea6d6fbe40..ef34bd15319 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,11 +2,11 @@ requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 -jinja2>=2.9.5 +jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 -async_timeout==1.2.1 +async_timeout==1.4.0 chardet==3.0.4 astral==1.4 diff --git a/homeassistant/scripts/influxdb_migrator.py b/homeassistant/scripts/influxdb_migrator.py index 6f130d18757..cad8f878ca6 100644 --- a/homeassistant/scripts/influxdb_migrator.py +++ b/homeassistant/scripts/influxdb_migrator.py @@ -104,7 +104,7 @@ def run(script_args: List) -> int: for index, measurement in enumerate(measurements): client.query('''SELECT * INTO {}..:MEASUREMENT FROM ''' '"{}" GROUP BY *'.format(old_dbname, measurement)) - # Print progess + # Print progress print_progress(index + 1, nb_measurements) # Delete the database @@ -184,7 +184,7 @@ def run(script_args: List) -> int: else: # Increment offset offset += args.step - # Print progess + # Print progress print_progress(index + 1, nb_measurements) # Delete database if needed diff --git a/homeassistant/scripts/macos/launchd.plist b/homeassistant/scripts/macos/launchd.plist index b65cdac7439..920f45a0c0e 100644 --- a/homeassistant/scripts/macos/launchd.plist +++ b/homeassistant/scripts/macos/launchd.plist @@ -8,7 +8,9 @@ EnvironmentVariables PATH - /usr/local/bin/:/usr/bin:$PATH + /usr/local/bin/:/usr/bin:/usr/sbin:$PATH + LC_CTYPE + UTF-8 Program diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 824f3969b2c..646edcf1c35 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -268,7 +268,7 @@ class Throttle(object): # We want to be able to differentiate between function and unbound # methods (which are considered functions). - # All methods have the classname in their qualname seperated by a '.' + # All methods have the classname in their qualname separated by a '.' # Functions have a '.' in their qualname if defined inline, but will # be prefixed by '..' so we strip that out. is_func = (not hasattr(method, '__self__') and diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index d76816cfbb8..9616774c623 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -10,7 +10,7 @@ _LOGGER = logging.getLogger(__name__) # Official CSS3 colors from w3.org: # https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 # names do not have spaces in them so that we can compare against -# reuqests more easily (by removing spaces from the requests as well). +# requests more easily (by removing spaces from the requests as well). # This lets "dark seagreen" and "dark sea green" both match the same # color "darkseagreen". COLORS = { @@ -308,7 +308,7 @@ def color_rgbw_to_rgb(r, g, b, w): # Add the white channel back into the rgb channels. rgb = (r + w, g + w, b + w) - # Match the output maximum value to the input. This ensures the the + # Match the output maximum value to the input. This ensures the # output doesn't overflow. return _match_max_scale((r, g, b, w), rgb) diff --git a/homeassistant/util/decorator.py b/homeassistant/util/decorator.py new file mode 100644 index 00000000000..c26606d52cf --- /dev/null +++ b/homeassistant/util/decorator.py @@ -0,0 +1,14 @@ +"""Decorator utility functions.""" + + +class Registry(dict): + """Registry of items.""" + + def register(self, name): + """Return decorator to register item with a specific name.""" + def decorator(func): + """Register decorated function.""" + self[name] = func + return func + + return decorator diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index c7bc4205297..8b07a344148 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -146,7 +146,7 @@ def vincenty(point1: Tuple[float, float], point2: Tuple[float, float], (-3 + 4 * cos2SigmaM ** 2))) s = AXIS_B * A * (sigma - deltaSigma) - s /= 1000 # Converion of meters to kilometers + s /= 1000 # Conversion of meters to kilometers if miles: s *= MILES_PER_KILOMETER # kilometers to miles diff --git a/requirements_all.txt b/requirements_all.txt index f94ccb389ff..a830af15b9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3,11 +3,11 @@ requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 -jinja2>=2.9.5 +jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 -async_timeout==1.2.1 +async_timeout==1.4.0 chardet==3.0.4 astral==1.4 @@ -17,11 +17,14 @@ astral==1.4 # homeassistant.components.bbb_gpio # Adafruit_BBIO==1.0.0 +# homeassistant.components.doorbird +DoorBirdPy==0.0.4 + # homeassistant.components.isy994 -PyISY==1.0.7 +PyISY==1.0.8 # homeassistant.components.notify.html5 -PyJWT==1.5.2 +PyJWT==1.5.3 # homeassistant.components.sensor.mvglive PyMVGLive==1.1.4 @@ -29,6 +32,9 @@ PyMVGLive==1.1.4 # homeassistant.components.arduino PyMata==2.14 +# homeassistant.components.xiaomi_aqara +PyXiaomiGateway==0.5.1 + # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 @@ -39,10 +45,10 @@ SoCo==0.12 TwitterAPI==2.4.6 # homeassistant.components.abode -abodepy==0.7.1 +abodepy==0.11.8 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.0 +aioautomatic==0.6.3 # homeassistant.components.sensor.dnsip aiodns==1.1.1 @@ -51,11 +57,14 @@ aiodns==1.1.1 # homeassistant.components.http aiohttp_cors==0.5.3 -# homeassistant.components.light.lifx -aiolifx==0.5.4 +# homeassistant.components.sensor.imap +aioimaplib==0.7.12 # homeassistant.components.light.lifx -aiolifx_effects==0.1.1 +aiolifx==0.6.0 + +# homeassistant.components.light.lifx +aiolifx_effects==0.1.2 # homeassistant.components.scene.hunterdouglas_powerview aiopvapi==1.4 @@ -82,7 +91,7 @@ asterisk_mbox==0.4.0 # avion==0.7 # homeassistant.components.axis -axis==8 +axis==12 # homeassistant.components.sensor.modem_callerid basicmodem==0.7 @@ -111,7 +120,7 @@ blinkstick==1.1.8 # blinkt==0.1.0 # homeassistant.components.sensor.bitcoin -blockchain==1.3.3 +blockchain==1.4.0 # homeassistant.components.light.decora # bluepy==1.1.1 @@ -137,7 +146,7 @@ buienradar==0.9 ciscosparkapi==0.4.2 # homeassistant.components.sensor.coinmarketcap -coinmarketcap==3.0.1 +coinmarketcap==4.1.1 # homeassistant.scripts.check_config colorlog==3.0.1 @@ -166,13 +175,13 @@ datapoint==0.4.3 # decora_wifi==1.3 # homeassistant.components.media_player.denonavr -denonavr==0.5.2 +denonavr==0.5.3 # homeassistant.components.media_player.directv directpy==0.1 # homeassistant.components.notify.discord -discord.py==0.16.10 +discord.py==0.16.11 # homeassistant.components.updater distro==1.0.4 @@ -187,7 +196,7 @@ dnspython3==1.15.0 dovado==0.4.1 # homeassistant.components.sensor.dsmr -dsmr_parser==0.8 +dsmr_parser==0.11 # homeassistant.components.dweet # homeassistant.components.sensor.dweet @@ -202,6 +211,9 @@ enocean==0.31 # homeassistant.components.sensor.envirophat # envirophat==0.0.6 +# homeassistant.components.sensor.season +ephem==3.7.6.0 + # homeassistant.components.keyboard_remote # evdev==0.6.1 @@ -210,7 +222,7 @@ evohomeclient==0.2.5 # homeassistant.components.image_processing.dlib_face_detect # homeassistant.components.image_processing.dlib_face_identify -# face_recognition==0.2.2 +# face_recognition==1.0.0 # homeassistant.components.sensor.fastdotcom fastdotcom==0.0.1 @@ -219,6 +231,7 @@ fastdotcom==0.0.1 fedexdeliverymanager==1.0.4 # homeassistant.components.feedreader +# homeassistant.components.sensor.geo_rss_events feedparser==5.2.1 # homeassistant.components.sensor.fitbit @@ -228,7 +241,7 @@ fitbit==0.3.0 fixerio==0.1.1 # homeassistant.components.light.flux_led -flux_led==0.19 +flux_led==0.20 # homeassistant.components.notify.free_mobile freesms==0.1.1 @@ -277,6 +290,9 @@ ha-ffmpeg==1.7 # homeassistant.components.media_player.philips_js ha-philipsjs==0.0.1 +# homeassistant.components.sensor.geo_rss_events +haversine==0.4.5 + # homeassistant.components.mqtt.server hbmqtt==0.8 @@ -295,9 +311,6 @@ holidays==0.8.1 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a -# homeassistant.components.xiaomi -https://github.com/Danielhiversen/PyXiaomiGateway/archive/0.3.zip#PyXiaomiGateway==0.3.0 - # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 @@ -359,15 +372,15 @@ jsonrpc-websocket==0.5 # homeassistant.scripts.keyring keyring>=9.3,<10.0 -# homeassistant.components.knx -knxip==0.5 - # homeassistant.components.device_tracker.owntracks libnacl==1.5.2 # homeassistant.components.dyson libpurecoollink==0.4.2 +# homeassistant.components.camera.foscam +libpyfoscam==1.0 + # homeassistant.components.device_tracker.mikrotik librouteros==1.0.2 @@ -391,7 +404,7 @@ liveboxplaytv==1.4.9 lmnotify==0.0.4 # homeassistant.components.sensor.lyft -lyft_rides==0.1.0b0 +lyft_rides==0.2 # homeassistant.components.notify.matrix matrix-client==0.0.6 @@ -410,20 +423,26 @@ mficlient==0.3.0 miflora==0.1.16 # homeassistant.components.upnp -miniupnpc==1.9 +miniupnpc==2.0.2 + +# homeassistant.components.sensor.mopar +motorparts==1.0.2 # homeassistant.components.tts mutagen==1.38 +# homeassistant.components.mycroft +mycroftapi==2.0 + # homeassistant.components.usps -myusps==1.1.3 +myusps==1.2.2 # homeassistant.components.media_player.nad # homeassistant.components.media_player.nadtcp nad_receiver==0.0.6 # homeassistant.components.discovery -netdisco==1.1.0 +netdisco==1.2.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 @@ -501,7 +520,7 @@ proliphix==0.4.1 prometheus_client==0.0.19 # homeassistant.components.sensor.systemmonitor -psutil==5.2.2 +psutil==5.3.1 # homeassistant.components.wink pubnubsub-handler==1.0.2 @@ -527,25 +546,28 @@ pyCEC==0.4.13 pyHS100==0.2.4.2 # homeassistant.components.rfxtrx -pyRFXtrx==0.19.0 +pyRFXtrx==0.20.1 # homeassistant.components.switch.dlink -pyW215==0.5.1 +pyW215==0.6.0 + +# homeassistant.components.sensor.airvisual +pyairvisual==1.0.0 # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.0 # homeassistant.components.arlo -pyarlo==0.0.4 +pyarlo==0.0.6 # homeassistant.components.notify.xmpp -pyasn1-modules==0.0.11 +pyasn1-modules==0.1.4 # homeassistant.components.notify.xmpp -pyasn1==0.3.2 +pyasn1==0.3.6 # homeassistant.components.apple_tv -pyatv==0.3.4 +pyatv==0.3.5 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox @@ -590,9 +612,6 @@ pyfido==1.0.1 # homeassistant.components.climate.flexit pyflexit==0.3 -# homeassistant.components.camera.foscam -pyfoscam==1.2 - # homeassistant.components.ifttt pyfttt==0.3 @@ -600,10 +619,10 @@ pyfttt==0.3 pyharmony==1.0.16 # homeassistant.components.binary_sensor.hikvision -pyhik==0.1.3 +pyhik==0.1.4 # homeassistant.components.homematic -pyhomematic==0.1.30 +pyhomematic==0.1.32 # homeassistant.components.sensor.hydroquebec pyhydroquebec==1.2.0 @@ -637,7 +656,7 @@ pylitejet==0.1 pyloopenergy==0.0.17 # homeassistant.components.lutron_caseta -pylutron-caseta==0.2.7 +pylutron-caseta==0.2.8 # homeassistant.components.lutron pylutron==0.1.0 @@ -651,14 +670,17 @@ pymochad==0.1.1 # homeassistant.components.modbus pymodbus==1.3.1 +# homeassistant.components.media_player.yamaha_musiccast +pymusiccast==0.1.0 + # homeassistant.components.cover.myq pymyq==0.0.8 # homeassistant.components.mysensors -pymysensors==0.10.0 +pymysensors==0.11.1 # homeassistant.components.lock.nello -pynello==1.5 +pynello==1.5.1 # homeassistant.components.device_tracker.netgear pynetgear==0.3.3 @@ -719,7 +741,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.12 # homeassistant.components.ecobee -python-ecobee-api==0.0.8 +python-ecobee-api==0.0.9 # homeassistant.components.climate.eq3btsmart # python-eq3bt==0.1.5 @@ -743,8 +765,9 @@ python-juicenet==0.0.5 # homeassistant.components.lirc # python-lirc==1.2.3 -# homeassistant.components.vacuum.xiaomi -python-mirobo==0.1.2 +# homeassistant.components.light.xiaomi_miio +# homeassistant.components.vacuum.xiaomi_miio +python-mirobo==0.2.0 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 @@ -775,7 +798,7 @@ python-synology==0.1.0 python-tado==0.2.2 # homeassistant.components.telegram_bot -python-telegram-bot==7.0.1 +python-telegram-bot==8.0.0 # homeassistant.components.sensor.twitch python-twitch==1.3.0 @@ -787,19 +810,22 @@ python-velbus==2.0.11 python-vlc==1.1.2 # homeassistant.components.wink -python-wink==1.5.1 +python-wink==1.6.0 + +# homeassistant.components.sensor.swiss_public_transport +python_opendata_transport==0.0.2 # homeassistant.components.zwave -python_openzwave==0.4.0.31 +python_openzwave==0.4.0.35 # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.18 +pythonegardia==1.0.20 # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 # homeassistant.components.tradfri -pytradfri==1.1 +pytradfri==2.2 # homeassistant.components.device_tracker.unifi pyunifi==2.13 @@ -808,7 +834,7 @@ pyunifi==2.13 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.35 +pyvera==0.2.37 # homeassistant.components.media_player.vizio pyvizio==0.0.2 @@ -817,10 +843,10 @@ pyvizio==0.0.2 pyvlx==0.1.3 # homeassistant.components.notify.html5 -pywebpush==1.0.6 +pywebpush==1.1.0 # homeassistant.components.wemo -pywemo==0.4.19 +pywemo==0.4.20 # homeassistant.components.zabbix pyzabbix==0.7.4 @@ -859,7 +885,7 @@ roombapy==1.3.1 russound==0.1.7 # homeassistant.components.media_player.russound_rio -russound_rio==0.1.3 +russound_rio==0.1.4 # homeassistant.components.media_player.yamaha rxv==0.4.0 @@ -867,6 +893,9 @@ rxv==0.4.0 # homeassistant.components.media_player.samsungtv samsungctl==0.6.0 +# homeassistant.components.satel_integra +satel_integra==0.1.0 + # homeassistant.components.sensor.deutsche_bahn schiene==0.18 @@ -874,7 +903,7 @@ schiene==0.18 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==5.0.0 +sendgrid==5.2.0 # homeassistant.components.light.sensehat # homeassistant.components.sensor.sensehat @@ -884,7 +913,10 @@ sense-hat==2.2.0 sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.7.4 +shodan==1.7.5 + +# homeassistant.components.notify.simplepush +simplepush==1.1.3 # homeassistant.components.alarm_control_panel.simplisafe simplisafe-python==1.0.5 @@ -915,7 +947,7 @@ speedtest-cli==1.0.6 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.13 +sqlalchemy==1.1.14 # homeassistant.components.statsd statsd==3.2.1 @@ -926,6 +958,9 @@ steamodd==4.21 # homeassistant.components.camera.onvif suds-py3==1.3.3.0 +# homeassistant.components.sensor.tank_utility +tank_utility==1.4.0 + # homeassistant.components.binary_sensor.tapsaff tapsaff==0.1.3 @@ -939,12 +974,18 @@ tellduslive==0.3.4 # homeassistant.components.sensor.temper temperusb==1.5.3 +# homeassistant.components.tesla +teslajsonpy==0.0.11 + # homeassistant.components.thingspeak thingspeak==0.4.1 # homeassistant.components.light.tikteck tikteck==0.4 +# homeassistant.components.calendar.todoist +todoist-python==7.0.17 + # homeassistant.components.alarm_control_panel.totalconnect total_connect_client==0.11 @@ -956,13 +997,13 @@ transmissionrpc==0.11 twilio==5.7.0 # homeassistant.components.sensor.uber -uber_rides==0.5.1 +uber_rides==0.6.0 # homeassistant.components.sensor.ups upsmychoice==1.0.6 # homeassistant.components.camera.uvc -uvcclient==0.10.0 +uvcclient==0.10.1 # homeassistant.components.volvooncall volvooncall==0.3.3 @@ -983,6 +1024,9 @@ wakeonlan==0.2.2 # homeassistant.components.sensor.waqi waqiasync==1.0.0 +# homeassistant.components.cloud +warrant==0.2.0 + # homeassistant.components.media_player.gpmdp websocket-client==0.37.0 @@ -996,6 +1040,9 @@ xbee-helper==0.0.7 # homeassistant.components.sensor.xbox_live xboxapi==0.1.1 +# homeassistant.components.knx +xknx==0.7.14 + # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.ted5000 @@ -1010,13 +1057,13 @@ yahoo-finance==1.4.0 yahooweather==0.8 # homeassistant.components.light.yeelight -yeelight==0.3.2 +yeelight==0.3.3 # homeassistant.components.light.yeelightsunflower yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.8.18 +youtube_dl==2017.9.24 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_docs.txt b/requirements_docs.txt index 7a79d7bff6d..0d1f2a95fa2 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.6.3 -sphinx-autodoc-typehints==1.2.1 +Sphinx==1.6.4 +sphinx-autodoc-typehints==1.2.3 sphinx-autodoc-annotation==1.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74717aa7d7b..3a6cbacd6e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -21,13 +21,13 @@ freezegun>=0.3.8 # homeassistant.components.notify.html5 -PyJWT==1.5.2 +PyJWT==1.5.3 # homeassistant.components.media_player.sonos SoCo==0.12 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.0 +aioautomatic==0.6.3 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -37,11 +37,18 @@ aiohttp_cors==0.5.3 apns2==0.1.1 # homeassistant.components.sensor.dsmr -dsmr_parser==0.8 +dsmr_parser==0.11 + +# homeassistant.components.sensor.season +ephem==3.7.6.0 # homeassistant.components.climate.honeywell evohomeclient==0.2.5 +# homeassistant.components.feedreader +# homeassistant.components.sensor.geo_rss_events +feedparser==5.2.1 + # homeassistant.components.conversation fuzzywuzzy==0.15.1 @@ -51,6 +58,9 @@ gTTS-token==1.1.1 # homeassistant.components.ffmpeg ha-ffmpeg==1.7 +# homeassistant.components.sensor.geo_rss_events +haversine==0.4.5 + # homeassistant.components.mqtt.server hbmqtt==0.8 @@ -108,7 +118,7 @@ python-forecastio==1.3.5 pyunifi==2.13 # homeassistant.components.notify.html5 -pywebpush==1.0.6 +pywebpush==1.1.0 # homeassistant.components.python_script restrictedpython==4.0a3 @@ -130,13 +140,16 @@ somecomfort==0.4.1 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.13 +sqlalchemy==1.1.14 # homeassistant.components.statsd statsd==3.2.1 # homeassistant.components.camera.uvc -uvcclient==0.10.0 +uvcclient==0.10.1 + +# homeassistant.components.cloud +warrant==0.2.0 # homeassistant.components.sensor.yahoo_finance yahoo-finance==1.4.0 diff --git a/script/build_frontend b/script/build_frontend index 12a4fefca05..3eee66daf36 100755 --- a/script/build_frontend +++ b/script/build_frontend @@ -16,13 +16,13 @@ rm -rf homeassistant/components/frontend/www_static/core.js* \ cd homeassistant/components/frontend/www_static/home-assistant-polymer # Build frontend -BUILD_DEV=0 gulp +BUILD_DEV=0 ./node_modules/.bin/gulp cp bower_components/webcomponentsjs/webcomponents-lite.js .. cp bower_components/webcomponentsjs/custom-elements-es5-adapter.js .. cp build/*.js build/*.html .. mkdir ../panels cp build/panels/*.html ../panels -BUILD_DEV=0 gulp gen-service-worker +BUILD_DEV=0 ./node_modules/.bin/gulp gen-service-worker cp build/service_worker.js .. cd .. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ba7a49cc7c0..dd1602fba6f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -33,43 +33,47 @@ COMMENT_REQUIREMENTS = ( ) TEST_REQUIREMENTS = ( - 'pydispatch', - 'influxdb', - 'nx584', - 'uvcclient', - 'somecomfort', 'aioautomatic', - 'SoCo', - 'libsoundtouch', - 'libpurecoollink', - 'rxv', - 'apns2', - 'sqlalchemy', - 'forecastio', 'aiohttp_cors', - 'pilight', + 'apns2', + 'dsmr_parser', + 'ephem', + 'evohomeclient', + 'feedparser', + 'forecastio', 'fuzzywuzzy', + 'gTTS-token', + 'ha-ffmpeg', + 'haversine', + 'hbmqtt', + 'holidays', + 'influxdb', + 'libpurecoollink', + 'libsoundtouch', + 'mficlient', + 'nx584', + 'paho', + 'pexpect', + 'pilight', + 'pmsensor', + 'prometheus_client', + 'pydispatch', + 'PyJWT', + 'pylitejet', + 'pyunifi', + 'pywebpush', + 'restrictedpython', 'rflink', 'ring_doorbell', + 'rxv', 'sleepyq', + 'SoCo', + 'somecomfort', + 'sqlalchemy', 'statsd', - 'pylitejet', - 'holidays', - 'evohomeclient', - 'pexpect', - 'hbmqtt', - 'paho', - 'dsmr_parser', - 'mficlient', - 'pmsensor', + 'uvcclient', + 'warrant', 'yahoo-finance', - 'ha-ffmpeg', - 'gTTS-token', - 'pywebpush', - 'PyJWT', - 'restrictedpython', - 'pyunifi', - 'prometheus_client', ) IGNORE_PACKAGES = ( diff --git a/script/test_docker b/script/test_docker index 9f3bbb4be07..bbea52a3a0b 100755 --- a/script/test_docker +++ b/script/test_docker @@ -1,6 +1,6 @@ #!/bin/sh # Executes the tests with tox in a docker container. -# Every argment is passed to tox to allow running only a subset of tests. +# Every argument is passed to tox to allow running only a subset of tests. # The following example will only run media_player tests: # ./test_docker -- tests/components/media_player/ diff --git a/setup.py b/setup.py index 78a6a0bba71..ce5b49d4232 100755 --- a/setup.py +++ b/setup.py @@ -19,11 +19,11 @@ REQUIRES = [ 'pyyaml>=3.11,<4', 'pytz>=2017.02', 'pip>=8.0.3', - 'jinja2>=2.9.5', + 'jinja2>=2.9.6', 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.2.5', - 'async_timeout==1.2.1', + 'async_timeout==1.4.0', 'chardet==3.0.4', 'astral==1.4', ] diff --git a/tests/common.py b/tests/common.py index 5fdec2fc411..d7b603cca58 100644 --- a/tests/common.py +++ b/tests/common.py @@ -119,7 +119,7 @@ def async_test_home_assistant(loop): def async_add_job(target, *args): """Add a magic mock.""" if isinstance(target, Mock): - return mock_coro(target()) + return mock_coro(target(*args)) return orig_async_add_job(target, *args) hass.async_add_job = async_add_job @@ -477,7 +477,7 @@ def assert_setup_component(count, domain=None): - domain: The domain to count is optional. It can be automatically determined most of the time - Use as a context manager aroung setup.setup_component + Use as a context manager around setup.setup_component with assert_setup_component(0) as result_config: setup_component(hass, domain, start_config) # using result_config is optional diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index e5d819bc815..b5af01584d3 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -72,6 +72,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_HOME)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -150,6 +155,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_AWAY)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -228,6 +238,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_NIGHT)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -311,6 +326,142 @@ class TestAlarmControlPanelManual(unittest.TestCase): alarm_control_panel.alarm_trigger(self.hass) self.hass.block_till_done() + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_TRIGGERED)) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_armed_home_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_home': { + 'pending_time': 2 + } + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_home(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_HOME, + self.hass.states.get(entity_id).state) + + def test_armed_away_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_away': { + 'pending_time': 2 + } + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_away(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + def test_armed_night_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_night': { + 'pending_time': 2 + } + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_night(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + + def test_trigger_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'triggered': { + 'pending_time': 2 + }, + 'trigger_time': 3, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) @@ -364,6 +515,97 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_trigger_with_no_disarm_after_trigger(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + def test_back_to_back_trigger_with_no_disarm_after_trigger(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + def test_disarm_while_pending_trigger(self): """Test disarming while pending state.""" self.assertTrue(setup_component( diff --git a/tests/components/alarm_control_panel/test_manual_mqtt.py b/tests/components/alarm_control_panel/test_manual_mqtt.py index c4dcd57ca39..5210c616f9c 100644 --- a/tests/components/alarm_control_panel/test_manual_mqtt.py +++ b/tests/components/alarm_control_panel/test_manual_mqtt.py @@ -6,7 +6,7 @@ from unittest.mock import patch from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) from homeassistant.components import alarm_control_panel import homeassistant.util.dt as dt_util @@ -100,6 +100,11 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_HOME)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): @@ -184,6 +189,11 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_AWAY)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): @@ -218,6 +228,95 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_arm_night_no_pending(self): + """Test arm night method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_night(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + + def test_arm_night_with_pending(self): + """Test arm night method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_night(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_NIGHT)) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + + def test_arm_night_with_invalid_code(self): + """Attempt to arm night without a valid code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_night(self.hass, CODE + '2') + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_trigger_no_pending(self): """Test triggering when no pending submitted method.""" self.assertTrue(setup_component( @@ -276,6 +375,11 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_TRIGGERED)) + future = dt_util.utcnow() + timedelta(seconds=2) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): @@ -328,6 +432,61 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_back_to_back_trigger_with_no_disarm_after_trigger(self): + """Test no disarm after back to back trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + def test_disarm_while_pending_trigger(self): """Test disarming while pending state.""" self.assertTrue(setup_component( @@ -407,6 +566,160 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_TRIGGERED, self.hass.states.get(entity_id).state) + def test_armed_home_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 10, + 'armed_home': { + 'pending_time': 2 + }, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_home(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_HOME, + self.hass.states.get(entity_id).state) + + def test_armed_away_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 10, + 'armed_away': { + 'pending_time': 2 + }, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_away(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + def test_armed_night_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 10, + 'armed_night': { + 'pending_time': 2 + }, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_night(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + + def test_trigger_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 10, + 'triggered': { + 'pending_time': 2 + }, + 'trigger_time': 3, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_home(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=10) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_HOME, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_HOME, + self.hass.states.get(entity_id).state) + def test_arm_home_via_command_topic(self): """Test arming home via command topic.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { @@ -475,6 +788,40 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_ARMED_AWAY, self.hass.states.get(entity_id).state) + def test_arm_night_via_command_topic(self): + """Test arming night via command topic.""" + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 1, + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'payload_arm_night': 'ARM_NIGHT', + } + }) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + # Fire the arm command via MQTT; ensure state changes to pending + fire_mqtt_message(self.hass, 'alarm/command', 'ARM_NIGHT') + self.hass.block_till_done() + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + # Fast-forward a little bit + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + def test_disarm_pending_via_command_topic(self): """Test disarming pending alarm via command topic.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { @@ -552,6 +899,20 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(('alarm/state', STATE_ALARM_ARMED_AWAY, 0, True), self.mock_publish.mock_calls[-2][1]) + # Arm in night mode + alarm_control_panel.alarm_arm_night(self.hass) + self.hass.block_till_done() + self.assertEqual(('alarm/state', STATE_ALARM_PENDING, 0, True), + self.mock_publish.mock_calls[-2][1]) + # Fast-forward a little bit + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(('alarm/state', STATE_ALARM_ARMED_NIGHT, 0, True), + self.mock_publish.mock_calls[-2][1]) + # Disarm alarm_control_panel.alarm_disarm(self.hass) self.hass.block_till_done() diff --git a/tests/components/alarm_control_panel/test_spc.py b/tests/components/alarm_control_panel/test_spc.py index 9fcfbfd56d2..504b4e9237c 100644 --- a/tests/components/alarm_control_panel/test_spc.py +++ b/tests/components/alarm_control_panel/test_spc.py @@ -54,7 +54,7 @@ def test_setup_platform(hass): yield from spc.async_setup_platform(hass=hass, config={}, - async_add_entities=add_entities, + async_add_devices=add_entities, discovery_info=areas) assert len(added_entities) == 2 diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py new file mode 100644 index 00000000000..88ecc63d200 --- /dev/null +++ b/tests/components/alexa/__init__.py @@ -0,0 +1 @@ +"""Tests for the Alexa integration.""" diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py new file mode 100644 index 00000000000..d9f0c8e156d --- /dev/null +++ b/tests/components/alexa/test_flash_briefings.py @@ -0,0 +1,98 @@ +"""The tests for the Alexa component.""" +# pylint: disable=protected-access +import asyncio +import datetime + +import pytest + +from homeassistant.core import callback +from homeassistant.setup import async_setup_component +from homeassistant.components import alexa +from homeassistant.components.alexa import const + +SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" +APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" +REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000" + +# pylint: disable=invalid-name +calls = [] + +NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" + + +@pytest.fixture +def alexa_client(loop, hass, test_client): + """Initialize a Home Assistant server for testing this module.""" + @callback + def mock_service(call): + calls.append(call) + + hass.services.async_register("test", "alexa", mock_service) + + assert loop.run_until_complete(async_setup_component(hass, alexa.DOMAIN, { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": { + "flash_briefings": { + "weather": [ + {"title": "Weekly forecast", + "text": "This week it will be sunny."}, + {"title": "Current conditions", + "text": "Currently it is 80 degrees fahrenheit."} + ], + "news_audio": { + "title": "NPR", + "audio": NPR_NEWS_MP3_URL, + "display_url": "https://npr.org", + "uid": "uuid" + } + }, + } + })) + return loop.run_until_complete(test_client(hass.http.app)) + + +def _flash_briefing_req(client, briefing_id): + return client.get( + "/api/alexa/flash_briefings/{}".format(briefing_id)) + + +@asyncio.coroutine +def test_flash_briefing_invalid_id(alexa_client): + """Test an invalid Flash Briefing ID.""" + req = yield from _flash_briefing_req(alexa_client, 10000) + assert req.status == 404 + text = yield from req.text() + assert text == '' + + +@asyncio.coroutine +def test_flash_briefing_date_from_str(alexa_client): + """Test the response has a valid date parsed from string.""" + req = yield from _flash_briefing_req(alexa_client, "weather") + assert req.status == 200 + data = yield from req.json() + assert isinstance(datetime.datetime.strptime(data[0].get( + const.ATTR_UPDATE_DATE), const.DATE_FORMAT), datetime.datetime) + + +@asyncio.coroutine +def test_flash_briefing_valid(alexa_client): + """Test the response is valid.""" + data = [{ + "titleText": "NPR", + "redirectionURL": "https://npr.org", + "streamUrl": NPR_NEWS_MP3_URL, + "mainText": "", + "uid": "uuid", + "updateDate": '2016-10-10T19:51:42.0Z' + }] + + req = yield from _flash_briefing_req(alexa_client, "news_audio") + assert req.status == 200 + json = yield from req.json() + assert isinstance(datetime.datetime.strptime(json[0].get( + const.ATTR_UPDATE_DATE), const.DATE_FORMAT), datetime.datetime) + json[0].pop(const.ATTR_UPDATE_DATE) + data[0].pop(const.ATTR_UPDATE_DATE) + assert json == data diff --git a/tests/components/test_alexa.py b/tests/components/alexa/test_intent.py similarity index 87% rename from tests/components/test_alexa.py rename to tests/components/alexa/test_intent.py index 3789e7ab615..565ebec64aa 100644 --- a/tests/components/test_alexa.py +++ b/tests/components/alexa/test_intent.py @@ -2,13 +2,13 @@ # pylint: disable=protected-access import asyncio import json -import datetime import pytest from homeassistant.core import callback from homeassistant.setup import async_setup_component from homeassistant.components import alexa +from homeassistant.components.alexa import intent SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" @@ -32,22 +32,6 @@ def alexa_client(loop, hass, test_client): assert loop.run_until_complete(async_setup_component(hass, alexa.DOMAIN, { # Key is here to verify we allow other keys in config too "homeassistant": {}, - "alexa": { - "flash_briefings": { - "weather": [ - {"title": "Weekly forecast", - "text": "This week it will be sunny."}, - {"title": "Current conditions", - "text": "Currently it is 80 degrees fahrenheit."} - ], - "news_audio": { - "title": "NPR", - "audio": NPR_NEWS_MP3_URL, - "display_url": "https://npr.org", - "uid": "uuid" - } - }, - } })) assert loop.run_until_complete(async_setup_component( hass, 'intent_script', { @@ -113,15 +97,10 @@ def alexa_client(loop, hass, test_client): def _intent_req(client, data={}): - return client.post(alexa.INTENTS_API_ENDPOINT, data=json.dumps(data), + return client.post(intent.INTENTS_API_ENDPOINT, data=json.dumps(data), headers={'content-type': 'application/json'}) -def _flash_briefing_req(client, briefing_id): - return client.get( - "/api/alexa/flash_briefings/{}".format(briefing_id)) - - @asyncio.coroutine def test_intent_launch_request(alexa_client): """Test the launch of a request.""" @@ -467,44 +446,3 @@ def test_intent_from_built_in_intent_library(alexa_client): text = data.get("response", {}).get("outputSpeech", {}).get("text") assert text == "Playing the shins." - - -@asyncio.coroutine -def test_flash_briefing_invalid_id(alexa_client): - """Test an invalid Flash Briefing ID.""" - req = yield from _flash_briefing_req(alexa_client, 10000) - assert req.status == 404 - text = yield from req.text() - assert text == '' - - -@asyncio.coroutine -def test_flash_briefing_date_from_str(alexa_client): - """Test the response has a valid date parsed from string.""" - req = yield from _flash_briefing_req(alexa_client, "weather") - assert req.status == 200 - data = yield from req.json() - assert isinstance(datetime.datetime.strptime(data[0].get( - alexa.ATTR_UPDATE_DATE), alexa.DATE_FORMAT), datetime.datetime) - - -@asyncio.coroutine -def test_flash_briefing_valid(alexa_client): - """Test the response is valid.""" - data = [{ - "titleText": "NPR", - "redirectionURL": "https://npr.org", - "streamUrl": NPR_NEWS_MP3_URL, - "mainText": "", - "uid": "uuid", - "updateDate": '2016-10-10T19:51:42.0Z' - }] - - req = yield from _flash_briefing_req(alexa_client, "news_audio") - assert req.status == 200 - json = yield from req.json() - assert isinstance(datetime.datetime.strptime(json[0].get( - alexa.ATTR_UPDATE_DATE), alexa.DATE_FORMAT), datetime.datetime) - json[0].pop(alexa.ATTR_UPDATE_DATE) - data[0].pop(alexa.ATTR_UPDATE_DATE) - assert json == data diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py new file mode 100644 index 00000000000..0c2b133bdfb --- /dev/null +++ b/tests/components/alexa/test_smart_home.py @@ -0,0 +1,182 @@ +"""Test for smart home alexa support.""" +import asyncio + +import pytest + +from homeassistant.components.alexa import smart_home + +from tests.common import async_mock_service + + +def test_create_api_message(): + """Create a API message.""" + msg = smart_home.api_message('testName', 'testNameSpace') + + assert msg['header']['messageId'] is not None + assert msg['header']['name'] == 'testName' + assert msg['header']['namespace'] == 'testNameSpace' + assert msg['header']['payloadVersion'] == '2' + assert msg['payload'] == {} + + +def test_mapping_api_funct(): + """Test function ref from mapping function.""" + assert smart_home.mapping_api_function('notExists') is None + assert smart_home.mapping_api_function('DiscoverAppliancesRequest') == \ + smart_home.async_api_discovery + assert smart_home.mapping_api_function('TurnOnRequest') == \ + smart_home.async_api_turn_on + assert smart_home.mapping_api_function('TurnOffRequest') == \ + smart_home.async_api_turn_off + assert smart_home.mapping_api_function('SetPercentageRequest') == \ + smart_home.async_api_set_percentage + + +@asyncio.coroutine +def test_wrong_version(hass): + """Test with wrong version.""" + msg = smart_home.api_message('testName', 'testNameSpace') + msg['header']['payloadVersion'] = '3' + + with pytest.raises(AssertionError): + yield from smart_home.async_handle_message(hass, msg) + + +@asyncio.coroutine +def test_discovery_request(hass): + """Test alexa discovery request.""" + msg = smart_home.api_message( + 'DiscoverAppliancesRequest', 'Alexa.ConnectedHome.Discovery') + + # settup test devices + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + + hass.states.async_set( + 'light.test_1', 'on', {'friendly_name': "Test light 1"}) + hass.states.async_set( + 'light.test_2', 'on', { + 'friendly_name': "Test light 2", 'supported_features': 1 + }) + + resp = yield from smart_home.async_api_discovery(hass, msg) + + assert len(resp['payload']['discoveredAppliances']) == 3 + assert resp['header']['name'] == 'DiscoverAppliancesResponse' + assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Discovery' + + for i, appliance in enumerate(resp['payload']['discoveredAppliances']): + if appliance['applianceId'] == 'switch#test': + assert appliance['applianceTypes'][0] == "SWITCH" + assert appliance['friendlyName'] == "Test switch" + assert appliance['actions'] == ['turnOff', 'turnOn'] + continue + + if appliance['applianceId'] == 'light#test_1': + assert appliance['applianceTypes'][0] == "LIGHT" + assert appliance['friendlyName'] == "Test light 1" + assert appliance['actions'] == ['turnOff', 'turnOn'] + continue + + if appliance['applianceId'] == 'light#test_2': + assert appliance['applianceTypes'][0] == "LIGHT" + assert appliance['friendlyName'] == "Test light 2" + assert appliance['actions'] == \ + ['turnOff', 'turnOn', 'setPercentage'] + continue + + raise AssertionError("Unknown appliance!") + + +@asyncio.coroutine +def test_api_entity_not_exists(hass): + """Test api turn on process without entity.""" + msg_switch = smart_home.api_message( + 'TurnOnRequest', 'Alexa.ConnectedHome.Control', { + 'appliance': { + 'applianceId': 'switch#test' + } + }) + + call_switch = async_mock_service(hass, 'switch', 'turn_on') + + resp = yield from smart_home.async_api_turn_on(hass, msg_switch) + assert len(call_switch) == 0 + assert resp['header']['name'] == 'DriverInternalError' + assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Control' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['light', 'switch']) +def test_api_turn_on(hass, domain): + """Test api turn on process.""" + msg = smart_home.api_message( + 'TurnOnRequest', 'Alexa.ConnectedHome.Control', { + 'appliance': { + 'applianceId': '{}#test'.format(domain) + } + }) + + # settup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'turn_on') + + resp = yield from smart_home.async_api_turn_on(hass, msg) + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert resp['header']['name'] == 'TurnOnConfirmation' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['light', 'switch']) +def test_api_turn_off(hass, domain): + """Test api turn on process.""" + msg = smart_home.api_message( + 'TurnOffRequest', 'Alexa.ConnectedHome.Control', { + 'appliance': { + 'applianceId': '{}#test'.format(domain) + } + }) + + # settup test devices + hass.states.async_set( + '{}.test'.format(domain), 'on', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'turn_off') + + resp = yield from smart_home.async_api_turn_off(hass, msg) + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert resp['header']['name'] == 'TurnOffConfirmation' + + +@asyncio.coroutine +def test_api_set_percentage_light(hass): + """Test api set brightness process.""" + msg_light = smart_home.api_message( + 'SetPercentageRequest', 'Alexa.ConnectedHome.Control', { + 'appliance': { + 'applianceId': 'light#test' + }, + 'percentageState': { + 'value': '50' + } + }) + + # settup test devices + hass.states.async_set( + 'light.test', 'off', {'friendly_name': "Test light"}) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + resp = yield from smart_home.async_api_set_percentage(hass, msg_light) + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['brightness'] == '50' + assert resp['header']['name'] == 'SetPercentageConfirmation' diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 355e26abf9b..0a7db4a122d 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -1,11 +1,16 @@ """The tests for numeric state automation.""" +from datetime import timedelta import unittest +from unittest.mock import patch +import homeassistant.components.automation as automation from homeassistant.core import callback from homeassistant.setup import setup_component -import homeassistant.components.automation as automation +import homeassistant.util.dt as dt_util -from tests.common import get_test_home_assistant, mock_component +from tests.common import ( + get_test_home_assistant, mock_component, fire_time_changed, + assert_setup_component) # pylint: disable=invalid-name @@ -576,3 +581,126 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(2, len(self.calls)) + + def test_if_fails_setup_bad_for(self): + """Test for setup failure for bad for.""" + with assert_setup_component(0): + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'invalid': 5 + }, + }, + 'action': { + 'service': 'homeassistant.turn_on', + } + }}) + + def test_if_fails_setup_for_without_above_below(self): + """Test for setup failures for missing above or below.""" + with assert_setup_component(0): + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'homeassistant.turn_on', + } + }}) + + def test_if_not_fires_on_entity_change_with_for(self): + """Test for not firing on entity change with for.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + self.hass.states.set('test.entity', 15) + self.hass.block_till_done() + fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_entity_change_with_for_attribute_change(self): + """Test for firing on entity change with for and attribute change.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + utcnow = dt_util.utcnow() + with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow: + mock_utcnow.return_value = utcnow + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + mock_utcnow.return_value += timedelta(seconds=4) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.states.set('test.entity', 9, + attributes={"mock_attr": "attr_change"}) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + mock_utcnow.return_value += timedelta(seconds=4) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_with_for(self): + """Test for firing on entity change with for.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) diff --git a/tests/components/binary_sensor/test_aurora.py b/tests/components/binary_sensor/test_aurora.py index c18d07575ca..ed68d23905f 100644 --- a/tests/components/binary_sensor/test_aurora.py +++ b/tests/components/binary_sensor/test_aurora.py @@ -64,7 +64,7 @@ class TestAuroraSensorSetUp(unittest.TestCase): @requests_mock.Mocker() def test_custom_threshold_works(self, mock_req): - """Test that the the config can take a custom forecast threshold.""" + """Test that the config can take a custom forecast threshold.""" uri = re.compile( "http://services\.swpc\.noaa\.gov/text/aurora-nowcast-map\.txt" ) diff --git a/tests/components/binary_sensor/test_bayesian.py b/tests/components/binary_sensor/test_bayesian.py new file mode 100644 index 00000000000..3b403c3702f --- /dev/null +++ b/tests/components/binary_sensor/test_bayesian.py @@ -0,0 +1,240 @@ +"""The test for the bayesian sensor platform.""" +import unittest + +from homeassistant.setup import setup_component +from homeassistant.components.binary_sensor import bayesian + +from tests.common import get_test_home_assistant + + +class TestBayesianBinarySensor(unittest.TestCase): + """Test the threshold sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_sensor_numeric_state(self): + """Test sensor on numeric state platform observations.""" + config = { + 'binary_sensor': { + 'platform': + 'bayesian', + 'name': + 'Test_Binary', + 'observations': [{ + 'platform': 'numeric_state', + 'entity_id': 'sensor.test_monitored', + 'below': 10, + 'above': 5, + 'prob_given_true': 0.6 + }, { + 'platform': 'numeric_state', + 'entity_id': 'sensor.test_monitored1', + 'below': 7, + 'above': 5, + 'prob_given_true': 0.9, + 'prob_given_false': 0.1 + }], + 'prior': + 0.2, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + + self.assertEqual([], state.attributes.get('observations')) + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 6) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 6) + self.hass.states.set('sensor.test_monitored1', 6) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual([{ + 'prob_false': 0.4, + 'prob_true': 0.6 + }, { + 'prob_false': 0.1, + 'prob_true': 0.9 + }], state.attributes.get('observations')) + self.assertAlmostEqual(0.77, state.attributes.get('probability')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 6) + self.hass.states.set('sensor.test_monitored1', 0) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 15) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + + assert state.state == 'off' + + def test_sensor_state(self): + """Test sensor on state platform observations.""" + config = { + 'binary_sensor': { + 'name': + 'Test_Binary', + 'platform': + 'bayesian', + 'observations': [{ + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'off', + 'prob_given_true': 0.8, + 'prob_given_false': 0.4 + }], + 'prior': + 0.2, + 'probability_threshold': + 0.32, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 'on') + + state = self.hass.states.get('binary_sensor.test_binary') + + self.assertEqual([], state.attributes.get('observations')) + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'on') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual([{ + 'prob_true': 0.8, + 'prob_false': 0.4 + }], state.attributes.get('observations')) + self.assertAlmostEqual(0.33, state.attributes.get('probability')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'on') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertAlmostEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + def test_multiple_observations(self): + """Test sensor with multiple observations of same entity.""" + config = { + 'binary_sensor': { + 'name': + 'Test_Binary', + 'platform': + 'bayesian', + 'observations': [{ + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'blue', + 'prob_given_true': 0.8, + 'prob_given_false': 0.4 + }, { + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'red', + 'prob_given_true': 0.2, + 'prob_given_false': 0.4 + }], + 'prior': + 0.2, + 'probability_threshold': + 0.32, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 'off') + + state = self.hass.states.get('binary_sensor.test_binary') + + self.assertEqual([], state.attributes.get('observations')) + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 'blue') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'blue') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual([{ + 'prob_true': 0.8, + 'prob_false': 0.4 + }], state.attributes.get('observations')) + self.assertAlmostEqual(0.33, state.attributes.get('probability')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 'blue') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'red') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertAlmostEqual(0.11, state.attributes.get('probability')) + + assert state.state == 'off' + + def test_probability_updates(self): + """Test probability update function.""" + prob_true = [0.3, 0.6, 0.8] + prob_false = [0.7, 0.4, 0.2] + prior = 0.5 + + for pt, pf in zip(prob_true, prob_false): + prior = bayesian.update_probability(prior, pt, pf) + + self.assertAlmostEqual(0.720000, prior) + + prob_true = [0.8, 0.3, 0.9] + prob_false = [0.6, 0.4, 0.2] + prior = 0.7 + + for pt, pf in zip(prob_true, prob_false): + prior = bayesian.update_probability(prior, pt, pf) + + self.assertAlmostEqual(0.9130434782608695, prior) diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index 85e56fb44ea..396020561ac 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -3,7 +3,8 @@ import unittest from homeassistant.setup import setup_component import homeassistant.components.binary_sensor as binary_sensor -from homeassistant.const import (STATE_OFF, STATE_ON) +from homeassistant.const import (STATE_OFF, STATE_ON, + STATE_UNAVAILABLE) from tests.common import ( get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) @@ -73,3 +74,70 @@ class TestSensorMQTT(unittest.TestCase): state = self.hass.states.get('binary_sensor.test') self.assertIsNone(state) + + def test_availability_without_topic(self): + """Test availability without defined availability topic.""" + self.assertTrue(setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + } + })) + + state = self.hass.states.get('binary_sensor.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + def test_availability_by_defaults(self): + """Test availability by defaults with defined topic.""" + self.assertTrue(setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'availability_topic': 'availability-topic' + } + })) + + state = self.hass.states.get('binary_sensor.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'online') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'offline') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + def test_availability_by_custom_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('binary_sensor.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/binary_sensor/test_spc.py b/tests/components/binary_sensor/test_spc.py index 2acd093dc1f..5004ccd3210 100644 --- a/tests/components/binary_sensor/test_spc.py +++ b/tests/components/binary_sensor/test_spc.py @@ -54,7 +54,7 @@ def test_setup_platform(hass): yield from spc.async_setup_platform(hass=hass, config={}, - async_add_entities=add_entities, + async_add_devices=add_entities, discovery_info=zones) assert len(added_entities) == 3 diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index 4e829b42fe3..11163d42ab5 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -1,5 +1,6 @@ """The tests for the Template Binary sensor platform.""" import asyncio +from datetime import timedelta import unittest from unittest import mock @@ -10,10 +11,12 @@ from homeassistant.components.binary_sensor import template from homeassistant.exceptions import TemplateError from homeassistant.helpers import template as template_hlpr from homeassistant.util.async import run_callback_threadsafe +import homeassistant.util.dt as dt_util from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE from tests.common import ( - get_test_home_assistant, assert_setup_component, mock_component) + get_test_home_assistant, assert_setup_component, mock_component, + async_fire_time_changed) class TestBinarySensorTemplate(unittest.TestCase): @@ -103,19 +106,20 @@ class TestBinarySensorTemplate(unittest.TestCase): vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', - template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL + template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL, + None, None ).result() self.assertFalse(vs.should_poll) self.assertEqual('motion', vs.device_class) self.assertEqual('Parent', vs.name) - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() self.assertFalse(vs.is_on) # pylint: disable=protected-access vs._template = template_hlpr.Template("{{ 2 > 1 }}", self.hass) - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() self.assertTrue(vs.is_on) def test_event(self): @@ -155,13 +159,14 @@ class TestBinarySensorTemplate(unittest.TestCase): vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', - template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL + template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL, + None, None ).result() mock_render.side_effect = TemplateError('foo') - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() mock_render.side_effect = TemplateError( "UndefinedError: 'None' has no attribute") - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() @asyncio.coroutine @@ -197,3 +202,124 @@ def test_restore_state(hass): state = hass.states.get('binary_sensor.test') assert state.state == 'off' + + +@asyncio.coroutine +def test_template_delay_on(hass): + """Test binary sensor template delay on.""" + config = { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'friendly_name': 'virtual thingy', + 'value_template': + "{{ states.sensor.test_state.state == 'on' }}", + 'device_class': 'motion', + 'delay_on': 5 + }, + }, + }, + } + yield from setup.async_setup_component(hass, 'binary_sensor', config) + yield from hass.async_start() + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + # check with time changes + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + +@asyncio.coroutine +def test_template_delay_off(hass): + """Test binary sensor template delay off.""" + config = { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'friendly_name': 'virtual thingy', + 'value_template': + "{{ states.sensor.test_state.state == 'on' }}", + 'device_class': 'motion', + 'delay_off': 5 + }, + }, + }, + } + hass.states.async_set('sensor.test_state', 'on') + yield from setup.async_setup_component(hass, 'binary_sensor', config) + yield from hass.async_start() + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + # check with time changes + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 4b69116f010..97f6c0385df 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -27,7 +27,7 @@ class TestSetupCamera(object): self.hass.stop() def test_setup_component(self): - """Setup demo platfrom on camera component.""" + """Setup demo platform on camera component.""" config = { camera.DOMAIN: { 'platform': 'demo' diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py index 27d79b40aa8..d15249d61f3 100644 --- a/tests/components/climate/test_demo.py +++ b/tests/components/climate/test_demo.py @@ -230,7 +230,7 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual(None, state.attributes.get('hold_mode')) def test_set_aux_heat_bad_attr(self): - """Test setting the auxillary heater without required attribute.""" + """Test setting the auxiliary heater without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('off', state.attributes.get('aux_heat')) climate.set_aux_heat(self.hass, None, ENTITY_CLIMATE) @@ -245,7 +245,7 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual('on', state.attributes.get('aux_heat')) def test_set_aux_heat_off(self): - """Test setting the auxillary heater off/false.""" + """Test setting the auxiliary heater off/false.""" climate.set_aux_heat(self.hass, False, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py new file mode 100644 index 00000000000..707e49f670f --- /dev/null +++ b/tests/components/cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the cloud component.""" diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py new file mode 100644 index 00000000000..652829d2f32 --- /dev/null +++ b/tests/components/cloud/test_auth_api.py @@ -0,0 +1,271 @@ +"""Tests for the tools to communicate with the cloud.""" +from unittest.mock import MagicMock, patch + +from botocore.exceptions import ClientError +import pytest + +from homeassistant.components.cloud import DOMAIN, auth_api + + +MOCK_AUTH = { + "id_token": "fake_id_token", + "access_token": "fake_access_token", + "refresh_token": "fake_refresh_token", +} + + +@pytest.fixture +def cloud_hass(hass): + """Fixture to return a hass instance with cloud mode set.""" + hass.data[DOMAIN] = {'mode': 'development'} + return hass + + +@pytest.fixture +def mock_write(): + """Mock reading authentication.""" + with patch.object(auth_api, '_write_info') as mock: + yield mock + + +@pytest.fixture +def mock_read(): + """Mock writing authentication.""" + with patch.object(auth_api, '_read_info') as mock: + yield mock + + +@pytest.fixture +def mock_cognito(): + """Mock warrant.""" + with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog: + yield mock_cog() + + +@pytest.fixture +def mock_auth(): + """Mock warrant.""" + with patch('homeassistant.components.cloud.auth_api.Auth') as mock_auth: + yield mock_auth() + + +def aws_error(code, message='Unknown', operation_name='fake_operation_name'): + """Generate AWS error response.""" + response = { + 'Error': { + 'Code': code, + 'Message': message + } + } + return ClientError(response, operation_name) + + +def test_load_auth_with_no_stored_auth(cloud_hass, mock_read): + """Test loading authentication with no stored auth.""" + mock_read.return_value = None + auth = auth_api.load_auth(cloud_hass) + assert auth.cognito is None + + +def test_load_auth_with_invalid_auth(cloud_hass, mock_read, mock_cognito): + """Test calling load_auth when auth is no longer valid.""" + mock_cognito.get_user.side_effect = aws_error('SomeError') + auth = auth_api.load_auth(cloud_hass) + + assert auth.cognito is None + + +def test_load_auth_with_valid_auth(cloud_hass, mock_read, mock_cognito): + """Test calling load_auth when valid auth.""" + auth = auth_api.load_auth(cloud_hass) + + assert auth.cognito is not None + + +def test_auth_properties(): + """Test Auth class properties.""" + auth = auth_api.Auth(None, None) + assert not auth.is_logged_in + auth.account = {} + assert auth.is_logged_in + + +def test_auth_validate_auth_verification_fails(mock_cognito): + """Test validate authentication with verify request failing.""" + mock_cognito.get_user.side_effect = aws_error('UserNotFoundException') + + auth = auth_api.Auth(None, mock_cognito) + assert auth.validate_auth() is False + + +def test_auth_validate_auth_token_refresh_needed_fails(mock_cognito): + """Test validate authentication with refresh needed which gets 401.""" + mock_cognito.get_user.side_effect = aws_error('NotAuthorizedException') + mock_cognito.renew_access_token.side_effect = \ + aws_error('NotAuthorizedException') + + auth = auth_api.Auth(None, mock_cognito) + assert auth.validate_auth() is False + + +def test_auth_validate_auth_token_refresh_needed_succeeds(mock_write, + mock_cognito): + """Test validate authentication with refresh.""" + mock_cognito.get_user.side_effect = [ + aws_error('NotAuthorizedException'), + MagicMock(email='hello@home-assistant.io') + ] + + auth = auth_api.Auth(None, mock_cognito) + assert auth.validate_auth() is True + assert len(mock_write.mock_calls) == 1 + + +def test_auth_login_invalid_auth(mock_cognito, mock_write): + """Test trying to login with invalid credentials.""" + mock_cognito.authenticate.side_effect = aws_error('NotAuthorizedException') + auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.Unauthenticated): + auth.login('user', 'pass') + + assert not auth.is_logged_in + assert len(mock_cognito.get_user.mock_calls) == 0 + assert len(mock_write.mock_calls) == 0 + + +def test_auth_login_user_not_found(mock_cognito, mock_write): + """Test trying to login with invalid credentials.""" + mock_cognito.authenticate.side_effect = aws_error('UserNotFoundException') + auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.UserNotFound): + auth.login('user', 'pass') + + assert not auth.is_logged_in + assert len(mock_cognito.get_user.mock_calls) == 0 + assert len(mock_write.mock_calls) == 0 + + +def test_auth_login_user_not_confirmed(mock_cognito, mock_write): + """Test trying to login without confirming account.""" + mock_cognito.authenticate.side_effect = \ + aws_error('UserNotConfirmedException') + auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.UserNotConfirmed): + auth.login('user', 'pass') + + assert not auth.is_logged_in + assert len(mock_cognito.get_user.mock_calls) == 0 + assert len(mock_write.mock_calls) == 0 + + +def test_auth_login(cloud_hass, mock_cognito, mock_write): + """Test trying to login without confirming account.""" + mock_cognito.get_user.return_value = \ + MagicMock(email='hello@home-assistant.io') + auth = auth_api.Auth(cloud_hass, None) + auth.login('user', 'pass') + assert auth.is_logged_in + assert len(mock_cognito.authenticate.mock_calls) == 1 + assert len(mock_write.mock_calls) == 1 + result_hass, result_auth = mock_write.mock_calls[0][1] + assert result_hass is cloud_hass + assert result_auth is auth + + +def test_auth_renew_access_token(mock_write, mock_cognito): + """Test renewing an access token.""" + auth = auth_api.Auth(None, mock_cognito) + assert auth.renew_access_token() + assert len(mock_write.mock_calls) == 1 + + +def test_auth_renew_access_token_fails(mock_write, mock_cognito): + """Test failing to renew an access token.""" + mock_cognito.renew_access_token.side_effect = aws_error('SomeError') + auth = auth_api.Auth(None, mock_cognito) + assert not auth.renew_access_token() + assert len(mock_write.mock_calls) == 0 + + +def test_auth_logout(mock_write, mock_cognito): + """Test renewing an access token.""" + auth = auth_api.Auth(None, mock_cognito) + auth.account = MagicMock() + auth.logout() + assert auth.account is None + assert len(mock_write.mock_calls) == 1 + + +def test_auth_logout_fails(mock_write, mock_cognito): + """Test error while logging out.""" + mock_cognito.logout.side_effect = aws_error('SomeError') + auth = auth_api.Auth(None, mock_cognito) + auth.account = MagicMock() + with pytest.raises(auth_api.CloudError): + auth.logout() + assert auth.account is not None + assert len(mock_write.mock_calls) == 0 + + +def test_register(mock_cognito): + """Test registering an account.""" + auth_api.register(None, 'email@home-assistant.io', 'password') + assert len(mock_cognito.register.mock_calls) == 1 + result_email, result_password = mock_cognito.register.mock_calls[0][1] + assert result_email == 'email@home-assistant.io' + assert result_password == 'password' + + +def test_register_fails(mock_cognito): + """Test registering an account.""" + mock_cognito.register.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.register(None, 'email@home-assistant.io', 'password') + + +def test_confirm_register(mock_cognito): + """Test confirming a registration of an account.""" + auth_api.confirm_register(None, '123456', 'email@home-assistant.io') + assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 + result_code, result_email = mock_cognito.confirm_sign_up.mock_calls[0][1] + assert result_email == 'email@home-assistant.io' + assert result_code == '123456' + + +def test_confirm_register_fails(mock_cognito): + """Test an error during confirmation of an account.""" + mock_cognito.confirm_sign_up.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.confirm_register(None, '123456', 'email@home-assistant.io') + + +def test_forgot_password(mock_cognito): + """Test starting forgot password flow.""" + auth_api.forgot_password(None, 'email@home-assistant.io') + assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 + + +def test_forgot_password_fails(mock_cognito): + """Test failure when starting forgot password flow.""" + mock_cognito.initiate_forgot_password.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.forgot_password(None, 'email@home-assistant.io') + + +def test_confirm_forgot_password(mock_cognito): + """Test confirming forgot password.""" + auth_api.confirm_forgot_password( + None, '123456', 'email@home-assistant.io', 'new password') + assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1 + result_code, result_password = \ + mock_cognito.confirm_forgot_password.mock_calls[0][1] + assert result_code == '123456' + assert result_password == 'new password' + + +def test_confirm_forgot_password_fails(mock_cognito): + """Test failure when confirming forgot password.""" + mock_cognito.confirm_forgot_password.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.confirm_forgot_password( + None, '123456', 'email@home-assistant.io', 'new password') diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py new file mode 100644 index 00000000000..e79f23c0845 --- /dev/null +++ b/tests/components/cloud/test_http_api.py @@ -0,0 +1,340 @@ +"""Tests for the HTTP API for the cloud component.""" +import asyncio +from unittest.mock import patch, MagicMock + +import pytest + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.cloud import DOMAIN, auth_api + + +@pytest.fixture +def cloud_client(hass, test_client): + """Fixture that can fetch from the cloud client.""" + hass.loop.run_until_complete(async_setup_component(hass, 'cloud', { + 'cloud': { + 'mode': 'development' + } + })) + return hass.loop.run_until_complete(test_client(hass.http.app)) + + +@pytest.fixture +def mock_auth(cloud_client, hass): + """Fixture to mock authentication.""" + auth = hass.data[DOMAIN]['auth'] = MagicMock() + return auth + + +@pytest.fixture +def mock_cognito(): + """Mock warrant.""" + with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog: + yield mock_cog() + + +@asyncio.coroutine +def test_account_view_no_account(cloud_client): + """Test fetching account if no account available.""" + req = yield from cloud_client.get('/api/cloud/account') + assert req.status == 400 + + +@asyncio.coroutine +def test_account_view(mock_auth, cloud_client): + """Test fetching account if no account available.""" + mock_auth.account = MagicMock(email='hello@home-assistant.io') + req = yield from cloud_client.get('/api/cloud/account') + assert req.status == 200 + result = yield from req.json() + assert result == {'email': 'hello@home-assistant.io'} + + +@asyncio.coroutine +def test_login_view(mock_auth, cloud_client): + """Test logging in.""" + mock_auth.account = MagicMock(email='hello@home-assistant.io') + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 200 + result = yield from req.json() + assert result == {'email': 'hello@home-assistant.io'} + assert len(mock_auth.login.mock_calls) == 1 + result_user, result_pass = mock_auth.login.mock_calls[0][1] + assert result_user == 'my_username' + assert result_pass == 'my_password' + + +@asyncio.coroutine +def test_login_view_invalid_json(mock_auth, cloud_client): + """Try logging in with invalid JSON.""" + req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') + assert req.status == 400 + assert len(mock_auth.mock_calls) == 0 + + +@asyncio.coroutine +def test_login_view_invalid_schema(mock_auth, cloud_client): + """Try logging in with invalid schema.""" + req = yield from cloud_client.post('/api/cloud/login', json={ + 'invalid': 'schema' + }) + assert req.status == 400 + assert len(mock_auth.mock_calls) == 0 + + +@asyncio.coroutine +def test_login_view_request_timeout(mock_auth, cloud_client): + """Test request timeout while trying to log in.""" + mock_auth.login.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 502 + + +@asyncio.coroutine +def test_login_view_invalid_credentials(mock_auth, cloud_client): + """Test logging in with invalid credentials.""" + mock_auth.login.side_effect = auth_api.Unauthenticated + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 401 + + +@asyncio.coroutine +def test_login_view_unknown_error(mock_auth, cloud_client): + """Test unknown error while logging in.""" + mock_auth.login.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 502 + + +@asyncio.coroutine +def test_logout_view(mock_auth, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 200 + data = yield from req.json() + assert data == {'message': 'ok'} + assert len(mock_auth.logout.mock_calls) == 1 + + +@asyncio.coroutine +def test_logout_view_request_timeout(mock_auth, cloud_client): + """Test timeout while logging out.""" + mock_auth.logout.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + + +@asyncio.coroutine +def test_logout_view_unknown_error(mock_auth, cloud_client): + """Test unknown error while logging out.""" + mock_auth.logout.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + + +@asyncio.coroutine +def test_register_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'password': 'falcon42' + }) + assert req.status == 200 + assert len(mock_cognito.register.mock_calls) == 1 + result_email, result_pass = mock_cognito.register.mock_calls[0][1] + assert result_email == 'hello@bla.com' + assert result_pass == 'falcon42' + + +@asyncio.coroutine +def test_register_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'not_password': 'falcon' + }) + assert req.status == 400 + assert len(mock_cognito.logout.mock_calls) == 0 + + +@asyncio.coroutine +def test_register_view_request_timeout(mock_cognito, cloud_client): + """Test timeout while logging out.""" + mock_cognito.register.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'password': 'falcon42' + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_register_view_unknown_error(mock_cognito, cloud_client): + """Test unknown error while logging out.""" + mock_cognito.register.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'password': 'falcon42' + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_confirm_register_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456' + }) + assert req.status == 200 + assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 + result_code, result_email = mock_cognito.confirm_sign_up.mock_calls[0][1] + assert result_email == 'hello@bla.com' + assert result_code == '123456' + + +@asyncio.coroutine +def test_confirm_register_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'not_confirmation_code': '123456' + }) + assert req.status == 400 + assert len(mock_cognito.confirm_sign_up.mock_calls) == 0 + + +@asyncio.coroutine +def test_confirm_register_view_request_timeout(mock_cognito, cloud_client): + """Test timeout while logging out.""" + mock_cognito.confirm_sign_up.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456' + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_confirm_register_view_unknown_error(mock_cognito, cloud_client): + """Test unknown error while logging out.""" + mock_cognito.confirm_sign_up.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456' + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_forgot_password_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 200 + assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 + + +@asyncio.coroutine +def test_forgot_password_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'not_email': 'hello@bla.com', + }) + assert req.status == 400 + assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0 + + +@asyncio.coroutine +def test_forgot_password_view_request_timeout(mock_cognito, cloud_client): + """Test timeout while logging out.""" + mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_forgot_password_view_unknown_error(mock_cognito, cloud_client): + """Test unknown error while logging out.""" + mock_cognito.initiate_forgot_password.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_confirm_forgot_password_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456', + 'new_password': 'hello2', + }) + assert req.status == 200 + assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1 + result_code, result_new_password = \ + mock_cognito.confirm_forgot_password.mock_calls[0][1] + assert result_code == '123456' + assert result_new_password == 'hello2' + + +@asyncio.coroutine +def test_confirm_forgot_password_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'not_confirmation_code': '123456', + 'new_password': 'hello2', + }) + assert req.status == 400 + assert len(mock_cognito.confirm_forgot_password.mock_calls) == 0 + + +@asyncio.coroutine +def test_confirm_forgot_password_view_request_timeout(mock_cognito, + cloud_client): + """Test timeout while logging out.""" + mock_cognito.confirm_forgot_password.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456', + 'new_password': 'hello2', + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_confirm_forgot_password_view_unknown_error(mock_cognito, + cloud_client): + """Test unknown error while logging out.""" + mock_cognito.confirm_forgot_password.side_effect = auth_api.UnknownError + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456', + 'new_password': 'hello2', + }) + assert req.status == 502 diff --git a/tests/components/config/test_customize.py b/tests/components/config/test_customize.py new file mode 100644 index 00000000000..f12774c25d9 --- /dev/null +++ b/tests/components/config/test_customize.py @@ -0,0 +1,118 @@ +"""Test Customize config panel.""" +import asyncio +import json +from unittest.mock import patch + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components import config +from homeassistant.config import DATA_CUSTOMIZE + + +@asyncio.coroutine +def test_get_entity(hass, test_client): + """Test getting entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + def mock_read(path): + """Mock reading data.""" + return { + 'hello.beer': { + 'free': 'beer', + }, + 'other.entity': { + 'do': 'something', + }, + } + hass.data[DATA_CUSTOMIZE] = {'hello.beer': {'cold': 'beer'}} + with patch('homeassistant.components.config._read', mock_read): + resp = yield from client.get( + '/api/config/customize/config/hello.beer') + + assert resp.status == 200 + result = yield from resp.json() + + assert result == {'local': {'free': 'beer'}, 'global': {'cold': 'beer'}} + + +@asyncio.coroutine +def test_update_entity(hass, test_client): + """Test updating entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + orig_data = { + 'hello.beer': { + 'ignored': True, + }, + 'other.entity': { + 'polling_intensity': 2, + }, + } + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + hass.states.async_set('hello.world', 'state', {'a': 'b'}) + with patch('homeassistant.components.config._read', mock_read), \ + patch('homeassistant.components.config._write', mock_write): + resp = yield from client.post( + '/api/config/customize/config/hello.world', data=json.dumps({ + 'name': 'Beer', + 'entities': ['light.top', 'light.bottom'], + })) + + assert resp.status == 200 + result = yield from resp.json() + assert result == {'result': 'ok'} + + state = hass.states.get('hello.world') + assert state.state == 'state' + assert dict(state.attributes) == { + 'a': 'b', 'name': 'Beer', 'entities': ['light.top', 'light.bottom']} + + orig_data['hello.world']['name'] = 'Beer' + orig_data['hello.world']['entities'] = ['light.top', 'light.bottom'] + + assert written[0] == orig_data + + +@asyncio.coroutine +def test_update_entity_invalid_key(hass, test_client): + """Test updating entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + resp = yield from client.post( + '/api/config/customize/config/not_entity', data=json.dumps({ + 'name': 'YO', + })) + + assert resp.status == 400 + + +@asyncio.coroutine +def test_update_entity_invalid_json(hass, test_client): + """Test updating entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + resp = yield from client.post( + '/api/config/customize/config/hello.beer', data='not json') + + assert resp.status == 400 diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index ecf4d6ecb29..fc359dc7ff7 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -143,7 +143,7 @@ def test_get_values(hass, test_client): node = MockNode(node_id=1) value = MockValue(value_id=123456, node=node, label='Test Label', - instance=1, index=2) + instance=1, index=2, poll_intensity=4) values = MockEntityValues(primary=value) node2 = MockNode(node_id=2) value2 = MockValue(value_id=234567, node=node2, label='Test Label 2') @@ -162,6 +162,7 @@ def test_get_values(hass, test_client): 'label': 'Test Label', 'instance': 1, 'index': 2, + 'poll_intensity': 4, } } diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 8b6202acdff..0b49e21674e 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -2,7 +2,8 @@ import unittest from homeassistant.setup import setup_component -from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN +from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN,\ + STATE_UNAVAILABLE import homeassistant.components.cover as cover from homeassistant.components.cover.mqtt import MqttCover @@ -570,71 +571,149 @@ class TestCoverMQTT(unittest.TestCase): def test_find_percentage_in_range_defaults(self): """Test find percentage in range with default range.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 100, 0, 0, 100, False, False, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 100, 0, 0, 100, False, False, None, None) self.assertEqual(44, mqtt_cover.find_percentage_in_range(44)) def test_find_percentage_in_range_altered(self): """Test find percentage in range with altered range.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 180, 80, 80, 180, False, False, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 180, 80, 80, 180, False, False, None, None) self.assertEqual(40, mqtt_cover.find_percentage_in_range(120)) def test_find_percentage_in_range_defaults_inverted(self): """Test find percentage in range with default range but inverted.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 100, 0, 0, 100, False, True, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 100, 0, 0, 100, False, True, None, None) self.assertEqual(56, mqtt_cover.find_percentage_in_range(44)) def test_find_percentage_in_range_altered_inverted(self): """Test find percentage in range with altered range and inverted.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 180, 80, 80, 180, False, True, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 180, 80, 80, 180, False, True, None, None) self.assertEqual(60, mqtt_cover.find_percentage_in_range(120)) def test_find_in_range_defaults(self): """Test find in range with default range.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 100, 0, 0, 100, False, False, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 100, 0, 0, 100, False, False, None, None) self.assertEqual(44, mqtt_cover.find_in_range_from_percent(44)) def test_find_in_range_altered(self): """Test find in range with altered range.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 180, 80, 80, 180, False, False, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 180, 80, 80, 180, False, False, None, None) self.assertEqual(120, mqtt_cover.find_in_range_from_percent(40)) def test_find_in_range_defaults_inverted(self): """Test find in range with default range but inverted.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 100, 0, 0, 100, False, True, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 100, 0, 0, 100, False, True, None, None) self.assertEqual(44, mqtt_cover.find_in_range_from_percent(56)) def test_find_in_range_altered_inverted(self): """Test find in range with altered range and inverted.""" mqtt_cover = MqttCover( - 'cover.test', 'foo', 'bar', 'fooBar', "fooBarBaz", 0, False, - 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', False, None, - 180, 80, 80, 180, False, True, None, None) + 'cover.test', 'state-topic', 'command-topic', None, + 'tilt-command-topic', 'tilt-status-topic', 0, False, + 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, + False, None, 180, 80, 80, 180, False, True, None, None) self.assertEqual(120, mqtt_cover.find_in_range_from_percent(60)) + + def test_availability_without_topic(self): + """Test availability without defined availability topic.""" + self.assertTrue(setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic' + } + })) + + state = self.hass.states.get('cover.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + def test_availability_by_defaults(self): + """Test availability by defaults with defined topic.""" + self.assertTrue(setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability-topic' + } + })) + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'online') + self.hass.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'offline') + self.hass.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + def test_availability_by_custom_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/cover/test_template.py b/tests/components/cover/test_template.py index cd2120e71e6..3c574bbf497 100644 --- a/tests/components/cover/test_template.py +++ b/tests/components/cover/test_template.py @@ -21,7 +21,7 @@ class TestTemplateCover(unittest.TestCase): # pylint: disable=invalid-name def setup_method(self, method): - """Setup things to be run when tests are started.""" + """Initialize services when tests are started.""" self.hass = get_test_home_assistant() self.calls = [] @@ -254,32 +254,6 @@ class TestTemplateCover(unittest.TestCase): assert self.hass.states.all() == [] - def test_template_position_or_value(self): - """Test that at least one of value or position template is used.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - }, - } - } - }) - - self.hass.start() - self.hass.block_till_done() - - assert self.hass.states.all() == [] - def test_template_open_or_position(self): """Test that at least one of open_cover or set_position is used.""" with assert_setup_component(1, 'cover'): @@ -590,6 +564,85 @@ class TestTemplateCover(unittest.TestCase): assert len(self.calls) == 1 + def test_set_position_optimistic(self): + """Test optimistic position mode.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'set_cover_position': { + 'service': 'test.automation', + }, + } + } + } + }) + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') is None + + cover.set_cover_position(self.hass, 42, + 'cover.test_template_cover') + self.hass.block_till_done() + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 42.0 + + cover.close_cover(self.hass, 'cover.test_template_cover') + self.hass.block_till_done() + state = self.hass.states.get('cover.test_template_cover') + assert state.state == STATE_CLOSED + + cover.open_cover(self.hass, 'cover.test_template_cover') + self.hass.block_till_done() + state = self.hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + def test_set_tilt_position_optimistic(self): + """Test the optimistic tilt_position mode.""" + with assert_setup_component(1, 'cover'): + assert setup.setup_component(self.hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'set_cover_position': { + 'service': 'test.automation', + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, + } + } + } + }) + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') is None + + cover.set_cover_tilt_position(self.hass, 42, + 'cover.test_template_cover') + self.hass.block_till_done() + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 42.0 + + cover.close_cover_tilt(self.hass, 'cover.test_template_cover') + self.hass.block_till_done() + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 0.0 + + cover.open_cover_tilt(self.hass, 'cover.test_template_cover') + self.hass.block_till_done() + state = self.hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 100.0 + def test_icon_template(self): """Test icon template.""" with assert_setup_component(1, 'cover'): diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py index d572791168c..d40c1518ffa 100644 --- a/tests/components/device_tracker/test_automatic.py +++ b/tests/components/device_tracker/test_automatic.py @@ -1,5 +1,6 @@ """Test the automatic device tracker platform.""" import asyncio +from datetime import datetime import logging from unittest.mock import patch, MagicMock import aioautomatic @@ -71,10 +72,12 @@ def test_valid_credentials( vehicle.display_name = 'mock_display_name' vehicle.fuel_level_percent = 45.6 vehicle.latest_location = None + vehicle.updated_at = datetime(2017, 8, 13, 1, 2, 3) trip.end_location.lat = 45.567 trip.end_location.lon = 34.345 trip.end_location.accuracy_m = 5.6 + trip.ended_at = datetime(2017, 8, 13, 1, 2, 4) @asyncio.coroutine def get_session(*args, **kwargs): diff --git a/tests/components/device_tracker/test_geofency.py b/tests/components/device_tracker/test_geofency.py new file mode 100644 index 00000000000..e8aa44cb0e5 --- /dev/null +++ b/tests/components/device_tracker/test_geofency.py @@ -0,0 +1,230 @@ +"""The tests for the Geofency device tracker platform.""" +# pylint: disable=redefined-outer-name +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.components import zone +import homeassistant.components.device_tracker as device_tracker +from homeassistant.components.device_tracker.geofency import ( + CONF_MOBILE_BEACONS, URL) +from homeassistant.const import ( + CONF_PLATFORM, HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, + STATE_NOT_HOME) +from homeassistant.setup import async_setup_component +from homeassistant.util import slugify + +HOME_LATITUDE = 37.239622 +HOME_LONGITUDE = -115.815811 + +NOT_HOME_LATITUDE = 37.239394 +NOT_HOME_LONGITUDE = -115.763283 + +GPS_ENTER_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +GPS_EXIT_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + +BEACON_ENTER_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +BEACON_EXIT_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + +BEACON_ENTER_CAR = { + 'latitude': NOT_HOME_LATITUDE, + 'longitude': NOT_HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Car 1', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +BEACON_EXIT_CAR = { + 'latitude': NOT_HOME_LATITUDE, + 'longitude': NOT_HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Car 1', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + + +@pytest.fixture +def geofency_client(loop, hass, test_client): + """Geofency mock client.""" + assert loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'geofency', + CONF_MOBILE_BEACONS: ['Car 1'] + }})) + + with patch('homeassistant.components.device_tracker.update_config'): + yield loop.run_until_complete(test_client(hass.http.app)) + + +@pytest.fixture(autouse=True) +def setup_zones(loop, hass): + """Setup Zone config in HA.""" + assert loop.run_until_complete(async_setup_component( + hass, zone.DOMAIN, { + 'zone': { + 'name': 'Home', + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'radius': 100, + }})) + + +@asyncio.coroutine +def test_data_validation(geofency_client): + """Test data validation.""" + # No data + req = yield from geofency_client.post(URL) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + missing_attributes = ['address', 'device', + 'entry', 'latitude', 'longitude', 'name'] + + # missing attributes + for attribute in missing_attributes: + copy = GPS_ENTER_HOME.copy() + del copy[attribute] + req = yield from geofency_client.post(URL, data=copy) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + +@asyncio.coroutine +def test_gps_enter_and_exit_home(hass, geofency_client): + """Test GPS based zone enter and exit.""" + # Enter the Home zone + req = yield from geofency_client.post(URL, data=GPS_ENTER_HOME) + assert req.status == HTTP_OK + device_name = slugify(GPS_ENTER_HOME['device']) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Home zone + req = yield from geofency_client.post(URL, data=GPS_EXIT_HOME) + assert req.status == HTTP_OK + device_name = slugify(GPS_EXIT_HOME['device']) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + +@asyncio.coroutine +def test_beacon_enter_and_exit_home(hass, geofency_client): + """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" + # Enter the Home zone + req = yield from geofency_client.post(URL, data=BEACON_ENTER_HOME) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Home zone + req = yield from geofency_client.post(URL, data=BEACON_EXIT_HOME) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + +@asyncio.coroutine +def test_beacon_enter_and_exit_car(hass, geofency_client): + """Test use of mobile iBeacon.""" + # Enter the Car away from Home zone + req = yield from geofency_client.post(URL, data=BEACON_ENTER_CAR) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + # Exit the Car away from Home zone + req = yield from geofency_client.post(URL, data=BEACON_EXIT_CAR) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + # Enter the Car in the Home zone + data = BEACON_ENTER_CAR.copy() + data['latitude'] = HOME_LATITUDE + data['longitude'] = HOME_LONGITUDE + req = yield from geofency_client.post(URL, data=data) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(data['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Car in the Home zone + req = yield from geofency_client.post(URL, data=data) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(data['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index e4944035261..3a23fe61d41 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -1,13 +1,12 @@ """The tests for the Owntracks device tracker.""" import asyncio import json -import os -from collections import defaultdict import unittest from unittest.mock import patch -from tests.common import (assert_setup_component, fire_mqtt_message, - get_test_home_assistant, mock_mqtt_component) +from tests.common import (assert_setup_component, fire_mqtt_message, mock_coro, + get_test_home_assistant, mock_mqtt_component, + mock_component) import homeassistant.components.device_tracker.owntracks as owntracks from homeassistant.setup import setup_component @@ -20,9 +19,9 @@ DEVICE = 'phone' LOCATION_TOPIC = 'owntracks/{}/{}'.format(USER, DEVICE) EVENT_TOPIC = 'owntracks/{}/{}/event'.format(USER, DEVICE) -WAYPOINT_TOPIC = owntracks.WAYPOINT_TOPIC.format(USER, DEVICE) +WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoints'.format(USER, DEVICE) USER_BLACKLIST = 'ram' -WAYPOINT_TOPIC_BLOCKED = owntracks.WAYPOINT_TOPIC.format( +WAYPOINT_TOPIC_BLOCKED = 'owntracks/{}/{}/waypoints'.format( USER_BLACKLIST, DEVICE) DEVICE_TRACKER_STATE = 'device_tracker.{}_{}'.format(USER, DEVICE) @@ -252,7 +251,26 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() mock_mqtt_component(self.hass) - with assert_setup_component(1, device_tracker.DOMAIN): + mock_component(self.hass, 'group') + mock_component(self.hass, 'zone') + + patcher = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patcher.start() + self.addCleanup(patcher.stop) + + orig_context = owntracks.OwnTracksContext + + def store_context(*args): + self.context = orig_context(*args) + return self.context + + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])), \ + patch('homeassistant.components.device_tracker.' + 'load_yaml_config_file', return_value=mock_coro({})), \ + patch.object(owntracks, 'OwnTracksContext', store_context), \ + assert_setup_component(1, device_tracker.DOMAIN): assert setup_component(self.hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', @@ -290,18 +308,11 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): # Clear state between teste self.hass.states.set(DEVICE_TRACKER_STATE, None) - owntracks.REGIONS_ENTERED = defaultdict(list) - owntracks.MOBILE_BEACONS_ACTIVE = defaultdict(list) def teardown_method(self, _): """Stop everything that was started.""" self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - def assert_tracker_state(self, location): """Test the assertion of a tracker state.""" state = self.hass.states.get(REGION_TRACKER_STATE) @@ -372,7 +383,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.assert_location_state('outer') # Left clean zone state - self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + self.assertFalse(self.context.regions_entered[USER]) def test_event_with_spaces(self): """Test the entry event.""" @@ -386,7 +397,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.send_message(EVENT_TOPIC, message) # Left clean zone state - self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + self.assertFalse(self.context.regions_entered[USER]) def test_event_entry_exit_inaccurate(self): """Test the event for inaccurate exit.""" @@ -405,7 +416,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.assert_location_state('inner') # But does exit region correctly - self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + self.assertFalse(self.context.regions_entered[USER]) def test_event_entry_exit_zero_accuracy(self): """Test entry/exit events with accuracy zero.""" @@ -424,7 +435,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.assert_location_state('inner') # But does exit region correctly - self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + self.assertFalse(self.context.regions_entered[USER]) def test_event_exit_outside_zone_sets_away(self): """Test the event for exit zone.""" @@ -604,7 +615,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.hass.block_till_done() self.send_message(EVENT_TOPIC, exit_message) - self.assertEqual(owntracks.MOBILE_BEACONS_ACTIVE['greg_phone'], []) + self.assertEqual(self.context.mobile_beacons_active['greg_phone'], []) def test_mobile_multiple_enter_exit(self): """Test the multiple entering.""" @@ -618,7 +629,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.send_message(EVENT_TOPIC, enter_message) self.send_message(EVENT_TOPIC, exit_message) - self.assertEqual(owntracks.MOBILE_BEACONS_ACTIVE['greg_phone'], []) + self.assertEqual(self.context.mobile_beacons_active['greg_phone'], []) def test_waypoint_import_simple(self): """Test a simple import of list of waypoints.""" @@ -706,6 +717,19 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() mock_mqtt_component(self.hass) + mock_component(self.hass, 'group') + mock_component(self.hass, 'zone') + + patch_load = patch( + 'homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])) + patch_load.start() + self.addCleanup(patch_load.stop) + + patch_save = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patch_save.start() + self.addCleanup(patch_save.stop) def teardown_method(self, method): """Tear down resources.""" @@ -749,7 +773,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): # key missing }}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(None) + assert self.hass.states.get(DEVICE_TRACKER_STATE) is None @patch('homeassistant.components.device_tracker.owntracks.get_cipher', mock_cipher) @@ -762,7 +786,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): CONF_SECRET: 'wrong key', }}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(None) + assert self.hass.states.get(DEVICE_TRACKER_STATE) is None @patch('homeassistant.components.device_tracker.owntracks.get_cipher', mock_cipher) @@ -776,7 +800,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): LOCATION_TOPIC: 'wrong key' }}}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(None) + assert self.hass.states.get(DEVICE_TRACKER_STATE) is None @patch('homeassistant.components.device_tracker.owntracks.get_cipher', mock_cipher) @@ -790,7 +814,7 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' }}}) self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(None) + assert self.hass.states.get(DEVICE_TRACKER_STATE) is None try: import libnacl diff --git a/tests/components/device_tracker/test_upc_connect.py b/tests/components/device_tracker/test_upc_connect.py index 1ef3aefa6a4..396d2b88b19 100644 --- a/tests/components/device_tracker/test_upc_connect.py +++ b/tests/components/device_tracker/test_upc_connect.py @@ -1,11 +1,11 @@ """The tests for the UPC ConnextBox device tracker platform.""" import asyncio -import os from unittest.mock import patch import logging +import pytest + from homeassistant.setup import setup_component -from homeassistant.components import device_tracker from homeassistant.const import ( CONF_PLATFORM, CONF_HOST) from homeassistant.components.device_tracker import DOMAIN @@ -14,7 +14,7 @@ from homeassistant.util.async import run_coroutine_threadsafe from tests.common import ( get_test_home_assistant, assert_setup_component, load_fixture, - mock_component) + mock_component, mock_coro) _LOGGER = logging.getLogger(__name__) @@ -25,6 +25,14 @@ def async_scan_devices_mock(scanner): return [] +@pytest.fixture(autouse=True) +def mock_load_config(): + """Mock device tracker loading config.""" + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])): + yield + + class TestUPCConnect(object): """Tests for the Ddwrt device tracker platform.""" @@ -32,16 +40,12 @@ class TestUPCConnect(object): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() mock_component(self.hass, 'zone') + mock_component(self.hass, 'group') self.host = "127.0.0.1" def teardown_method(self): """Stop everything that was started.""" - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - self.hass.stop() @patch('homeassistant.components.device_tracker.upc_connect.' diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 0d2f0d24da0..cc03324a638 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -99,6 +99,14 @@ def hass_hue(loop, hass): kitchen_light_entity.entity_id, kitchen_light_entity.state, attributes=attrs) + # Ceiling Fan is explicitly excluded from being exposed + ceiling_fan_entity = hass.states.get('fan.ceiling_fan') + attrs = dict(ceiling_fan_entity.attributes) + attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = True + hass.states.async_set( + ceiling_fan_entity.entity_id, ceiling_fan_entity.state, + attributes=attrs) + # Expose the script script_entity = hass.states.get('script.set_kitchen_light') attrs = dict(script_entity.attributes) @@ -146,6 +154,7 @@ def test_discover_lights(hue_client): assert 'media_player.walkman' in devices assert 'media_player.lounge_room' in devices assert 'fan.living_room_fan' in devices + assert 'fan.ceiling_fan' not in devices @asyncio.coroutine diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 2dcb9ecbf21..b9ef09fe4a7 100755 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -125,4 +125,4 @@ def test_warning_config_google_home_listen_port(): assert mock_warn.called assert mock_warn.mock_calls[0][1][0] == \ - "When targetting Google Home, listening port has to be port 80" + "When targeting Google Home, listening port has to be port 80" diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 816976751a7..0594c436abd 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -24,7 +24,7 @@ class TestSetupImageProcessing(object): self.hass.stop() def test_setup_component(self): - """Setup demo platfrom on image_process component.""" + """Setup demo platform on image_process component.""" config = { ip.DOMAIN: { 'platform': 'demo' @@ -35,7 +35,7 @@ class TestSetupImageProcessing(object): setup_component(self.hass, ip.DOMAIN, config) def test_setup_component_with_service(self): - """Setup demo platfrom on image_process component test service.""" + """Setup demo platform on image_process component test service.""" config = { ip.DOMAIN: { 'platform': 'demo' diff --git a/tests/components/light/test_mochad.py b/tests/components/light/test_mochad.py index b1644effd57..e69ebdb4aef 100644 --- a/tests/components/light/test_mochad.py +++ b/tests/components/light/test_mochad.py @@ -32,7 +32,7 @@ class TestMochadSwitchSetup(unittest.TestCase): self.hass = get_test_home_assistant() def tearDown(self): - """Stop everyhing that was started.""" + """Stop everything that was started.""" self.hass.stop() @mock.patch('homeassistant.components.light.mochad.MochadLight') diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 97375aa6b13..e111fc3aa49 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -123,6 +123,20 @@ light: payload_on: "on" payload_off: "off" +config for RGB Version with RGB command template: + +light: + platform: mqtt + name: "Office Light RGB" + state_topic: "office/rgb1/light/status" + command_topic: "office/rgb1/light/switch" + rgb_state_topic: "office/rgb1/rgb/status" + rgb_command_topic: "office/rgb1/rgb/set" + rgb_command_template: "{{ '#%02x%02x%02x' | format(red, green, blue)}}" + qos: 0 + payload_on: "on" + payload_off: "off" + """ import unittest from unittest import mock @@ -512,6 +526,38 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual(80, state.attributes['white_value']) self.assertEqual((0.123, 0.123), state.attributes['xy_color']) + def test_sending_mqtt_rgb_command_with_template(self): + """Test the sending of RGB command with template.""" + config = {light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'rgb_command_topic': 'test_light_rgb/rgb/set', + 'rgb_command_template': '{{ "#%02x%02x%02x" | ' + 'format(red, green, blue)}}', + 'payload_on': 'on', + 'payload_off': 'off', + 'qos': 0 + }} + + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, config) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + light.turn_on(self.hass, 'light.test', rgb_color=[255, 255, 255]) + self.hass.block_till_done() + + self.mock_publish().async_publish.assert_has_calls([ + mock.call('test_light_rgb/set', 'on', 0, False), + mock.call('test_light_rgb/rgb/set', '#ffffff', 0, False), + ], any_order=True) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual((255, 255, 255), state.attributes['rgb_color']) + def test_show_brightness_if_only_command_topic(self): """Test the brightness if only a command topic is present.""" config = {light.DOMAIN: { diff --git a/tests/components/light/test_template.py b/tests/components/light/test_template.py index 6564d66299b..0e741cc7ee1 100644 --- a/tests/components/light/test_template.py +++ b/tests/components/light/test_template.py @@ -590,6 +590,44 @@ class TestTemplateLight: assert state.attributes.get('brightness') == '42' + def test_friendly_name(self): + """Test the accessibility of the friendly_name attribute.""" + with assert_setup_component(1, 'light'): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'friendly_name': 'Template light', + 'value_template': "{{ 1 == 1 }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state is not None + + assert state.attributes.get('friendly_name') == 'Template light' + @asyncio.coroutine def test_restore_state(hass): diff --git a/tests/components/media_player/test_yamaha.py b/tests/components/media_player/test_yamaha.py index 8cea5f7c63e..ad443fadebb 100644 --- a/tests/components/media_player/test_yamaha.py +++ b/tests/components/media_player/test_yamaha.py @@ -29,7 +29,7 @@ class FakeYamaha(rxv.rxv.RXV): @property def input(self): - """A fake input for the reciever.""" + """A fake input for the receiver.""" return self._fake_input @input.setter @@ -39,7 +39,7 @@ class FakeYamaha(rxv.rxv.RXV): self._fake_input = input_name def inputs(self): - """All inputs of the the fake receiver.""" + """All inputs of the fake receiver.""" return {'AUDIO1': None, 'AUDIO2': None, 'AV1': None, diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index e865b524f85..d0704aac227 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -77,6 +77,23 @@ def test_correct_config_discovery(hass, mqtt_mock, caplog): assert ('binary_sensor', 'bla') in hass.data[ALREADY_DISCOVERED] +@asyncio.coroutine +def test_discover_fan(hass, mqtt_mock, caplog): + """Test discovering an MQTT fan.""" + yield from async_start(hass, 'homeassistant', {}) + + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + ('{ "name": "Beer",' + ' "command_topic": "test_topic" }')) + yield from hass.async_block_till_done() + + state = hass.states.get('fan.beer') + + assert state is not None + assert state.name == 'Beer' + assert ('fan', 'bla') in hass.data[ALREADY_DISCOVERED] + + @asyncio.coroutine def test_discovery_incl_nodeid(hass, mqtt_mock, caplog): """Test sending in correct JSON with optional node_id included.""" diff --git a/tests/components/sensor/test_dte_energy_bridge.py b/tests/components/sensor/test_dte_energy_bridge.py new file mode 100644 index 00000000000..2341c3f8350 --- /dev/null +++ b/tests/components/sensor/test_dte_energy_bridge.py @@ -0,0 +1,68 @@ +"""The tests for the DTE Energy Bridge.""" + +import unittest + +import requests_mock + +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant + +DTE_ENERGY_BRIDGE_CONFIG = { + 'platform': 'dte_energy_bridge', + 'ip': '192.168.1.1', +} + + +class TestDteEnergyBridgeSetup(unittest.TestCase): + """Test the DTE Energy Bridge platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_with_config(self): + """Test the platform setup with configuration.""" + self.assertTrue( + setup_component(self.hass, 'sensor', + {'dte_energy_bridge': DTE_ENERGY_BRIDGE_CONFIG})) + + @requests_mock.Mocker() + def test_setup_correct_reading(self, mock_req): + """Test DTE Energy bridge returns a correct value.""" + mock_req.get("http://{}/instantaneousdemand" + .format(DTE_ENERGY_BRIDGE_CONFIG['ip']), + text='.411 kW') + assert setup_component(self.hass, 'sensor', { + 'sensor': DTE_ENERGY_BRIDGE_CONFIG}) + self.assertEqual('0.411', + self.hass.states + .get('sensor.current_energy_usage').state) + + @requests_mock.Mocker() + def test_setup_incorrect_units_reading(self, mock_req): + """Test DTE Energy bridge handles a value with incorrect units.""" + mock_req.get("http://{}/instantaneousdemand" + .format(DTE_ENERGY_BRIDGE_CONFIG['ip']), + text='411 kW') + assert setup_component(self.hass, 'sensor', { + 'sensor': DTE_ENERGY_BRIDGE_CONFIG}) + self.assertEqual('0.411', + self.hass.states + .get('sensor.current_energy_usage').state) + + @requests_mock.Mocker() + def test_setup_bad_format_reading(self, mock_req): + """Test DTE Energy bridge handles an invalid value.""" + mock_req.get("http://{}/instantaneousdemand" + .format(DTE_ENERGY_BRIDGE_CONFIG['ip']), + text='411') + assert setup_component(self.hass, 'sensor', { + 'sensor': DTE_ENERGY_BRIDGE_CONFIG}) + self.assertEqual('unknown', + self.hass.states + .get('sensor.current_energy_usage').state) diff --git a/tests/components/sensor/test_geo_rss_events.py b/tests/components/sensor/test_geo_rss_events.py new file mode 100644 index 00000000000..557def8225b --- /dev/null +++ b/tests/components/sensor/test_geo_rss_events.py @@ -0,0 +1,143 @@ +"""The test for the geo rss events sensor platform.""" +import unittest +from unittest import mock + +from homeassistant.setup import setup_component +from tests.common import load_fixture, get_test_home_assistant +import homeassistant.components.sensor.geo_rss_events as geo_rss_events + +URL = 'http://geo.rss.local/geo_rss_events.xml' +VALID_CONFIG_WITH_CATEGORIES = { + 'platform': 'geo_rss_events', + geo_rss_events.CONF_URL: URL, + geo_rss_events.CONF_CATEGORIES: [ + 'Category 1', + 'Category 2' + ] +} +VALID_CONFIG_WITHOUT_CATEGORIES = { + 'platform': 'geo_rss_events', + geo_rss_events.CONF_URL: URL +} + + +class TestGeoRssServiceUpdater(unittest.TestCase): + """Test the GeoRss service updater.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG_WITHOUT_CATEGORIES + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_with_categories(self): + """Test the general setup of this sensor.""" + self.config = VALID_CONFIG_WITH_CATEGORIES + self.assertTrue( + setup_component(self.hass, 'sensor', {'sensor': self.config})) + self.assertIsNotNone( + self.hass.states.get('sensor.event_service_category_1')) + self.assertIsNotNone( + self.hass.states.get('sensor.event_service_category_2')) + + def test_setup_without_categories(self): + """Test the general setup of this sensor.""" + self.assertTrue( + setup_component(self.hass, 'sensor', {'sensor': self.config})) + self.assertIsNotNone(self.hass.states.get('sensor.event_service_any')) + + def setup_data(self, url='url'): + """Set up data object for use by sensors.""" + home_latitude = -33.865 + home_longitude = 151.209444 + radius_in_km = 500 + data = geo_rss_events.GeoRssServiceData(home_latitude, + home_longitude, url, + radius_in_km) + return data + + def test_update_sensor_with_category(self): + """Test updating sensor object.""" + raw_data = load_fixture('geo_rss_events.xml') + # Loading raw data from fixture and plug in to data object as URL + # works since the third-party feedparser library accepts a URL + # as well as the actual data. + data = self.setup_data(raw_data) + category = "Category 1" + name = "Name 1" + unit_of_measurement = "Unit 1" + sensor = geo_rss_events.GeoRssServiceSensor(category, + data, name, + unit_of_measurement) + + sensor.update() + assert sensor.name == "Name 1 Category 1" + assert sensor.unit_of_measurement == "Unit 1" + assert sensor.icon == "mdi:alert" + assert len(sensor._data.events) == 4 + assert sensor.state == 1 + assert sensor.device_state_attributes == {'Title 1': "117km"} + # Check entries of first hit + assert sensor._data.events[0][geo_rss_events.ATTR_TITLE] == "Title 1" + assert sensor._data.events[0][ + geo_rss_events.ATTR_CATEGORY] == "Category 1" + self.assertAlmostEqual(sensor._data.events[0][ + geo_rss_events.ATTR_DISTANCE], 116.586, 0) + + def test_update_sensor_without_category(self): + """Test updating sensor object.""" + raw_data = load_fixture('geo_rss_events.xml') + data = self.setup_data(raw_data) + category = None + name = "Name 2" + unit_of_measurement = "Unit 2" + sensor = geo_rss_events.GeoRssServiceSensor(category, + data, name, + unit_of_measurement) + + sensor.update() + assert sensor.name == "Name 2 Any" + assert sensor.unit_of_measurement == "Unit 2" + assert sensor.icon == "mdi:alert" + assert len(sensor._data.events) == 4 + assert sensor.state == 4 + assert sensor.device_state_attributes == {'Title 1': "117km", + 'Title 2': "302km", + 'Title 3': "204km", + 'Title 6': "48km"} + + def test_update_sensor_without_data(self): + """Test updating sensor object.""" + data = self.setup_data() + category = None + name = "Name 3" + unit_of_measurement = "Unit 3" + sensor = geo_rss_events.GeoRssServiceSensor(category, + data, name, + unit_of_measurement) + + sensor.update() + assert sensor.name == "Name 3 Any" + assert sensor.unit_of_measurement == "Unit 3" + assert sensor.icon == "mdi:alert" + assert len(sensor._data.events) == 0 + assert sensor.state == 0 + + @mock.patch('feedparser.parse', return_value=None) + def test_update_sensor_with_none_result(self, parse_function): + """Test updating sensor object.""" + data = self.setup_data("http://invalid.url/") + category = None + name = "Name 4" + unit_of_measurement = "Unit 4" + sensor = geo_rss_events.GeoRssServiceSensor(category, + data, name, + unit_of_measurement) + + sensor.update() + assert sensor.name == "Name 4 Any" + assert sensor.unit_of_measurement == "Unit 4" + assert sensor.state == 0 diff --git a/tests/components/sensor/test_mfi.py b/tests/components/sensor/test_mfi.py index 8b037209cbc..ae967449ef2 100644 --- a/tests/components/sensor/test_mfi.py +++ b/tests/components/sensor/test_mfi.py @@ -61,7 +61,7 @@ class TestMfiSensorSetup(unittest.TestCase): @mock.patch('mficlient.client.MFiClient') def test_setup_failed_connect(self, mock_client): - """Test setup with conection failure.""" + """Test setup with connection failure.""" mock_client.side_effect = requests.exceptions.ConnectionError self.assertFalse( self.PLATFORM.setup_platform( diff --git a/tests/components/sensor/test_season.py b/tests/components/sensor/test_season.py new file mode 100644 index 00000000000..10e147bcff9 --- /dev/null +++ b/tests/components/sensor/test_season.py @@ -0,0 +1,183 @@ +"""The tests for the Season sensor platform.""" +# pylint: disable=protected-access +import unittest +from datetime import datetime + +import homeassistant.components.sensor.season as season + +from tests.common import get_test_home_assistant + + +# pylint: disable=invalid-name +class TestSeason(unittest.TestCase): + """Test the season platform.""" + + DEVICE = None + CONFIG_ASTRONOMICAL = {'type': 'astronomical'} + CONFIG_METEOROLOGICAL = {'type': 'meteorological'} + + def add_devices(self, devices): + """Mock add devices.""" + for device in devices: + self.DEVICE = device + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_season_should_be_summer_northern_astonomical(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(summer_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_summer_northern_meteorological(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 8, 13, 0, 0) + current_season = season.get_season(summer_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_autumn_northern_astonomical(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 9, 23, 0, 0) + current_season = season.get_season(autumn_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_autumn_northern_meteorological(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(autumn_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_winter_northern_astonomical(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 12, 25, 0, 0) + current_season = season.get_season(winter_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_winter_northern_meteorological(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 12, 3, 0, 0) + current_season = season.get_season(winter_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_spring_northern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 4, 1, 0, 0) + current_season = season.get_season(spring_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_spring_northern_meteorological(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 3, 3, 0, 0) + current_season = season.get_season(spring_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_winter_southern_astonomical(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(winter_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_winter_southern_meteorological(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 8, 13, 0, 0) + current_season = season.get_season(winter_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_spring_southern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 9, 23, 0, 0) + current_season = season.get_season(spring_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_spring_southern_meteorological(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(spring_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_summer_southern_astonomical(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 12, 25, 0, 0) + current_season = season.get_season(summer_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_summer_southern_meteorological(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 12, 3, 0, 0) + current_season = season.get_season(summer_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_autumn_southern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + autumn_day = datetime(2017, 4, 1, 0, 0) + current_season = season.get_season(autumn_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_autumn_southern_meteorological(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 3, 3, 0, 0) + current_season = season.get_season(autumn_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_on_equator_results_in_none(self): + """Test that season should be unknown.""" + # A known day in summer if astronomical and northern + summer_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(summer_day, + season.EQUATOR, + season.TYPE_ASTRONOMICAL) + self.assertEqual(None, current_season) diff --git a/tests/components/sensor/test_sonarr.py b/tests/components/sensor/test_sonarr.py index b71b96e1400..9e2050e850c 100644 --- a/tests/components/sensor/test_sonarr.py +++ b/tests/components/sensor/test_sonarr.py @@ -549,6 +549,25 @@ def mocked_requests_get(*args, **kwargs): "totalSpace": 499738734592 } ], 200) + elif 'api/system/status' in url: + return MockResponse({ + "version": "2.0.0.1121", + "buildTime": "2014-02-08T20:49:36.5560392Z", + "isDebug": "false", + "isProduction": "true", + "isAdmin": "true", + "isUserInteractive": "false", + "startupPath": "C:\\ProgramData\\NzbDrone\\bin", + "appData": "C:\\ProgramData\\NzbDrone", + "osVersion": "6.2.9200.0", + "isMono": "false", + "isLinux": "false", + "isWindows": "true", + "branch": "develop", + "authentication": "false", + "startOfWeek": 0, + "urlBase": "" + }, 200) else: return MockResponse({ "error": "Unauthorized" @@ -794,6 +813,31 @@ class TestSonarrSetup(unittest.TestCase): device.device_state_attributes["Bob's Burgers"] ) + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_system_status(self, req_mock): + """Test getting system status.""" + config = { + 'platform': 'sonarr', + 'api_key': 'foo', + 'days': '2', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'status' + ] + } + sonarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual('2.0.0.1121', device.state) + self.assertEqual('mdi:information', device.icon) + self.assertEqual('Sonarr Status', device.name) + self.assertEqual( + '6.2.9200.0', + device.device_state_attributes['osVersion']) + @pytest.mark.skip @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) def test_ssl(self, req_mock): diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 753b18f137f..ba71c6e3993 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -4,7 +4,10 @@ import statistics from homeassistant.setup import setup_component from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.util import dt as dt_util from tests.common import get_test_home_assistant +from unittest.mock import patch +from datetime import datetime, timedelta class TestStatisticsSensor(unittest.TestCase): @@ -100,3 +103,35 @@ class TestStatisticsSensor(unittest.TestCase): self.assertEqual(3.8, state.attributes.get('min_value')) self.assertEqual(14, state.attributes.get('max_value')) + + def test_max_age(self): + """Test value deprecation.""" + mock_data = { + 'return_time': datetime(2017, 8, 2, 12, 23, tzinfo=dt_util.UTC), + } + + def mock_now(): + return mock_data['return_time'] + + with patch('homeassistant.components.sensor.statistics.dt_util.utcnow', + new=mock_now): + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'statistics', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'max_age': {'minutes': 3} + } + }) + + for value in self.values: + self.hass.states.set('sensor.test_monitored', value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + # insert the next value one minute later + mock_data['return_time'] += timedelta(minutes=1) + + state = self.hass.states.get('sensor.test_mean') + + self.assertEqual(6, state.attributes.get('min_value')) + self.assertEqual(14, state.attributes.get('max_value')) diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py index d529e8c3f56..0d2a486cb4f 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -347,6 +347,261 @@ class TestSwitchFlux(unittest.TestCase): self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 154) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.494, 0.397]) + def test_flux_before_sunrise_stop_next_day(self): + """Test the flux switch before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + + # pylint: disable=invalid-name + def test_flux_after_sunrise_before_sunset_stop_next_day(self): + """ + Test the flux switch after sunrise and before sunset. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 180) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.431, 0.38]) + + # pylint: disable=invalid-name + def test_flux_after_sunset_before_midnight_stop_next_day(self): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=23, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 126) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.574, 0.401]) + + # pylint: disable=invalid-name + def test_flux_after_sunset_after_midnight_stop_next_day(self): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=00, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 122) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.586, 0.397]) + + # pylint: disable=invalid-name + def test_flux_after_stop_before_sunrise_stop_next_day(self): + """Test the flux switch after stop and before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + # pylint: disable=invalid-name def test_flux_with_custom_colortemps(self): """Test the flux with custom start and stop colortemps.""" diff --git a/tests/components/switch/test_mochad.py b/tests/components/switch/test_mochad.py index 0851bfbc324..8011d85860e 100644 --- a/tests/components/switch/test_mochad.py +++ b/tests/components/switch/test_mochad.py @@ -32,7 +32,7 @@ class TestMochadSwitchSetup(unittest.TestCase): self.hass = get_test_home_assistant() def tearDown(self): - """Stop everyhing that was started.""" + """Stop everything that was started.""" self.hass.stop() @mock.patch('homeassistant.components.switch.mochad.MochadSwitch') diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index cc97fe1c9c3..21ab1dd31f2 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -9,7 +9,7 @@ from tests.common import ( mock_mqtt_component, fire_mqtt_message, get_test_home_assistant) -class TestSensorMQTT(unittest.TestCase): +class TestSwitchMQTT(unittest.TestCase): """Test the MQTT switch.""" def setUp(self): # pylint: disable=invalid-name diff --git a/tests/components/switch/test_rest.py b/tests/components/switch/test_rest.py index 97911fccbfd..1b8215660bd 100644 --- a/tests/components/switch/test_rest.py +++ b/tests/components/switch/test_rest.py @@ -99,11 +99,13 @@ class TestRestSwitch: self.name = 'foo' self.method = 'post' self.resource = 'http://localhost/' + self.auth = None self.body_on = Template('on', self.hass) self.body_off = Template('off', self.hass) self.switch = rest.RestSwitch( - self.hass, self.name, self.resource, self.method, self.body_on, + self.name, self.resource, self.method, self.auth, self.body_on, self.body_off, None, 10) + self.switch.hass = self.hass def teardown_method(self): """Stop everything that was started.""" diff --git a/tests/components/switch/test_rflink.py b/tests/components/switch/test_rflink.py index b261d9c9b49..77a6b572e96 100644 --- a/tests/components/switch/test_rflink.py +++ b/tests/components/switch/test_rflink.py @@ -7,8 +7,10 @@ control of Rflink switch devices. import asyncio +from homeassistant.components.rflink import EVENT_BUTTON_PRESSED from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) +from homeassistant.core import callback from ..test_rflink import mock_rflink @@ -81,7 +83,7 @@ def test_default_setup(hass, monkeypatch): assert hass.states.get('switch.test').state == 'on' # The switch component does not support adding new devices for incoming - # events because every new unkown device is added as a light by default. + # events because every new unknown device is added as a light by default. # test changing state from HA propagates to Rflink hass.async_add_job( @@ -227,3 +229,84 @@ def test_nogroup_device_id(hass, monkeypatch): yield from hass.async_block_till_done() # should affect state assert hass.states.get(DOMAIN + '.test').state == 'on' + + +@asyncio.coroutine +def test_device_defaults(hass, monkeypatch): + """Event should fire if device_defaults config says so.""" + config = { + 'rflink': { + 'port': '/dev/ttyABC0', + }, + DOMAIN: { + 'platform': 'rflink', + 'device_defaults': { + 'fire_event': True, + }, + 'devices': { + 'protocol_0_0': { + 'name': 'test', + 'aliases': ['test_alias_0_0'], + }, + }, + }, + } + + # setup mocking rflink module + event_callback, _, _, _ = yield from mock_rflink( + hass, config, DOMAIN, monkeypatch) + + calls = [] + + @callback + def listener(event): + calls.append(event) + hass.bus.async_listen_once(EVENT_BUTTON_PRESSED, listener) + + # test event for new unconfigured sensor + event_callback({ + 'id': 'protocol_0_0', + 'command': 'off', + }) + yield from hass.async_block_till_done() + + assert calls[0].data == {'state': 'off', 'entity_id': DOMAIN + '.test'} + + +@asyncio.coroutine +def test_not_firing_default(hass, monkeypatch): + """By default no bus events should be fired.""" + config = { + 'rflink': { + 'port': '/dev/ttyABC0', + }, + DOMAIN: { + 'platform': 'rflink', + 'devices': { + 'protocol_0_0': { + 'name': 'test', + 'aliases': ['test_alias_0_0'], + }, + }, + }, + } + + # setup mocking rflink module + event_callback, _, _, _ = yield from mock_rflink( + hass, config, DOMAIN, monkeypatch) + + calls = [] + + @callback + def listener(event): + calls.append(event) + hass.bus.async_listen_once(EVENT_BUTTON_PRESSED, listener) + + # test event for new unconfigured sensor + event_callback({ + 'id': 'protocol_0_0', + 'command': 'off', + }) + yield from hass.async_block_till_done() + + assert not calls, 'an event has been fired' diff --git a/tests/components/test_counter.py b/tests/components/test_counter.py new file mode 100644 index 00000000000..8dc04f0e76a --- /dev/null +++ b/tests/components/test_counter.py @@ -0,0 +1,204 @@ +"""The tests for the counter component.""" +# pylint: disable=protected-access +import asyncio +import unittest +import logging + +from homeassistant.core import CoreState, State +from homeassistant.setup import setup_component, async_setup_component +from homeassistant.components.counter import ( + DOMAIN, decrement, increment, reset, CONF_INITIAL, CONF_STEP, CONF_NAME, + CONF_ICON) +from homeassistant.const import (ATTR_ICON, ATTR_FRIENDLY_NAME) + +from tests.common import (get_test_home_assistant, mock_restore_cache) + +_LOGGER = logging.getLogger(__name__) + + +class TestCounter(unittest.TestCase): + """Test the counter component.""" + + # pylint: disable=invalid-name + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_config(self): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] + + for cfg in invalid_configs: + self.assertFalse( + setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + + def test_methods(self): + """Test increment, decrement, and reset methods.""" + config = { + DOMAIN: { + 'test_1': {}, + } + } + + assert setup_component(self.hass, 'counter', config) + + entity_id = 'counter.test_1' + + state = self.hass.states.get(entity_id) + self.assertEqual(0, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(1, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(2, int(state.state)) + + decrement(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(1, int(state.state)) + + reset(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(0, int(state.state)) + + def test_methods_with_config(self): + """Test increment, decrement, and reset methods with configuration.""" + config = { + DOMAIN: { + 'test': { + CONF_NAME: 'Hello World', + CONF_INITIAL: 10, + CONF_STEP: 5, + } + } + } + + assert setup_component(self.hass, 'counter', config) + + entity_id = 'counter.test' + + state = self.hass.states.get(entity_id) + self.assertEqual(10, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(15, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(20, int(state.state)) + + decrement(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(15, int(state.state)) + + def test_config_options(self): + """Test configuration options.""" + count_start = len(self.hass.states.entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_INITIAL: 10, + CONF_STEP: 5, + } + } + } + + assert setup_component(self.hass, 'counter', config) + self.hass.block_till_done() + + _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) + + self.assertEqual(count_start + 2, len(self.hass.states.entity_ids())) + self.hass.block_till_done() + + state_1 = self.hass.states.get('counter.test_1') + state_2 = self.hass.states.get('counter.test_2') + + self.assertIsNotNone(state_1) + self.assertIsNotNone(state_2) + + self.assertEqual(0, int(state_1.state)) + self.assertNotIn(ATTR_ICON, state_1.attributes) + self.assertNotIn(ATTR_FRIENDLY_NAME, state_1.attributes) + + self.assertEqual(10, int(state_2.state)) + self.assertEqual('Hello World', + state_2.attributes.get(ATTR_FRIENDLY_NAME)) + self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON)) + + +@asyncio.coroutine +def test_initial_state_overrules_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('counter.test1', '11'), + State('counter.test2', '-22'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': {}, + 'test2': { + CONF_INITIAL: 10, + }, + }}) + + state = hass.states.get('counter.test1') + assert state + assert int(state.state) == 0 + + state = hass.states.get('counter.test2') + assert state + assert int(state.state) == 10 + + +@asyncio.coroutine +def test_no_initial_state_and_no_restore_state(hass): + """Ensure that entity is create without initial and restore feature.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': { + CONF_STEP: 5, + } + }}) + + state = hass.states.get('counter.test1') + assert state + assert int(state.state) == 0 diff --git a/tests/components/test_duckdns.py b/tests/components/test_duckdns.py new file mode 100644 index 00000000000..d64ffbca81f --- /dev/null +++ b/tests/components/test_duckdns.py @@ -0,0 +1,106 @@ +"""Test the DuckDNS component.""" +import asyncio +from datetime import timedelta + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import duckdns +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + +DOMAIN = 'bla' +TOKEN = 'abcdefgh' + + +@pytest.fixture +def setup_duckdns(hass, aioclient_mock): + """Fixture that sets up DuckDNS.""" + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN + }, text='OK') + + hass.loop.run_until_complete(async_setup_component( + hass, duckdns.DOMAIN, { + 'duckdns': { + 'domain': DOMAIN, + 'access_token': TOKEN + } + })) + + +@asyncio.coroutine +def test_setup(hass, aioclient_mock): + """Test setup works if update passes.""" + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN + }, text='OK') + + result = yield from async_setup_component(hass, duckdns.DOMAIN, { + 'duckdns': { + 'domain': DOMAIN, + 'access_token': TOKEN + } + }) + assert result + assert aioclient_mock.call_count == 1 + + async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) + yield from hass.async_block_till_done() + assert aioclient_mock.call_count == 2 + + +@asyncio.coroutine +def test_setup_fails_if_update_fails(hass, aioclient_mock): + """Test setup fails if first update fails.""" + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN + }, text='KO') + + result = yield from async_setup_component(hass, duckdns.DOMAIN, { + 'duckdns': { + 'domain': DOMAIN, + 'access_token': TOKEN + } + }) + assert not result + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_service_set_txt(hass, aioclient_mock, setup_duckdns): + """Test set txt service call.""" + # Empty the fixture mock requests + aioclient_mock.clear_requests() + + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN, + 'txt': 'some-txt', + }, text='OK') + + assert aioclient_mock.call_count == 0 + yield from hass.components.duckdns.async_set_txt('some-txt') + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_service_clear_txt(hass, aioclient_mock, setup_duckdns): + """Test clear txt service call.""" + # Empty the fixture mock requests + aioclient_mock.clear_requests() + + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN, + 'txt': '', + 'clear': 'true', + }, text='OK') + + assert aioclient_mock.call_count == 0 + yield from hass.components.duckdns.async_set_txt(None) + assert aioclient_mock.call_count == 1 diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index 3682e0a2c14..fdd33b99d2b 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -6,7 +6,8 @@ from unittest.mock import patch import pytest from homeassistant.setup import async_setup_component -from homeassistant.components.frontend import DOMAIN, ATTR_THEMES +from homeassistant.components.frontend import ( + DOMAIN, ATTR_THEMES, ATTR_EXTRA_HTML_URL, DATA_PANELS, register_panel) @pytest.fixture @@ -30,6 +31,16 @@ def mock_http_client_with_themes(hass, test_client): return hass.loop.run_until_complete(test_client(hass.http.app)) +@pytest.fixture +def mock_http_client_with_urls(hass, test_client): + """Start the Hass HTTP component.""" + hass.loop.run_until_complete(async_setup_component(hass, 'frontend', { + DOMAIN: { + ATTR_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"] + }})) + return hass.loop.run_until_complete(test_client(hass.http.app)) + + @asyncio.coroutine def test_frontend_and_static(mock_http_client): """Test if we can get the frontend.""" @@ -143,3 +154,29 @@ def test_missing_themes(mock_http_client): json = yield from resp.json() assert json['default_theme'] == 'default' assert json['themes'] == {} + + +@asyncio.coroutine +def test_extra_urls(mock_http_client_with_urls): + """Test that extra urls are loaded.""" + resp = yield from mock_http_client_with_urls.get('/states') + assert resp.status == 200 + text = yield from resp.text() + assert text.find('href=\'https://domain.com/my_extra_url.html\'') >= 0 + + +@asyncio.coroutine +def test_panel_without_path(hass): + """Test panel registration without file path.""" + register_panel(hass, 'test_component', 'nonexistant_file') + assert hass.data[DATA_PANELS] == {} + + +@asyncio.coroutine +def test_panel_with_url(hass): + """Test panel registration without file path.""" + register_panel(hass, 'test_component', None, url='some_url') + assert hass.data[DATA_PANELS] == { + 'test_component': {'component_name': 'test_component', + 'url': 'some_url', + 'url_path': 'test_component'}} diff --git a/tests/components/test_history.py b/tests/components/test_history.py index d2ea03b1873..8484e2c536f 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -145,6 +145,48 @@ class TestComponentHistory(unittest.TestCase): self.hass, zero, four, filters=history.Filters()) assert states == hist + def test_get_significant_states_with_initial(self): + """Test that only significant states are returned. + + We should get back every thermostat change that + includes an attribute change, but only the state updates for + media player (attribute changes are not significant and not returned). + """ + zero, four, states = self.record_states() + one = zero + timedelta(seconds=1) + one_and_half = zero + timedelta(seconds=1.5) + for entity_id in states: + if entity_id == 'media_player.test': + states[entity_id] = states[entity_id][1:] + for state in states[entity_id]: + if state.last_changed == one: + state.last_changed = one_and_half + + hist = history.get_significant_states( + self.hass, one_and_half, four, filters=history.Filters(), + include_start_time_state=True) + assert states == hist + + def test_get_significant_states_without_initial(self): + """Test that only significant states are returned. + + We should get back every thermostat change that + includes an attribute change, but only the state updates for + media player (attribute changes are not significant and not returned). + """ + zero, four, states = self.record_states() + one = zero + timedelta(seconds=1) + one_and_half = zero + timedelta(seconds=1.5) + for entity_id in states: + states[entity_id] = list(filter( + lambda s: s.last_changed != one, states[entity_id])) + del states['media_player.test2'] + + hist = history.get_significant_states( + self.hass, one_and_half, four, filters=history.Filters(), + include_start_time_state=False) + assert states == hist + def test_get_significant_states_entity_id(self): """Test that only significant states are returned for one entity.""" zero, four, states = self.record_states() @@ -154,7 +196,19 @@ class TestComponentHistory(unittest.TestCase): del states['script.can_cancel_this_one'] hist = history.get_significant_states( - self.hass, zero, four, 'media_player.test', + self.hass, zero, four, ['media_player.test'], + filters=history.Filters()) + assert states == hist + + def test_get_significant_states_multiple_entity_ids(self): + """Test that only significant states are returned for one entity.""" + zero, four, states = self.record_states() + del states['media_player.test2'] + del states['thermostat.test2'] + del states['script.can_cancel_this_one'] + + hist = history.get_significant_states( + self.hass, zero, four, ['media_player.test', 'thermostat.test'], filters=history.Filters()) assert states == hist diff --git a/tests/components/test_history_graph.py b/tests/components/test_history_graph.py new file mode 100644 index 00000000000..554f7f29dd7 --- /dev/null +++ b/tests/components/test_history_graph.py @@ -0,0 +1,46 @@ +"""The tests the Graph component.""" + +import unittest + +from homeassistant.setup import setup_component +from tests.common import init_recorder_component, get_test_home_assistant + + +class TestGraph(unittest.TestCase): + """Test the Google component.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_component(self): + """Test setup component.""" + self.init_recorder() + config = { + 'history': { + }, + 'history_graph': { + 'name_1': { + 'entities': 'test.test', + } + } + } + + self.assertTrue(setup_component(self.hass, 'history_graph', config)) + self.assertEqual( + dict(self.hass.states.get('history_graph.name_1').attributes), + { + 'entity_id': ['test.test'], + 'friendly_name': 'name_1', + 'hours_to_show': 24, + 'refresh': 0 + }) + + def init_recorder(self): + """Initialize the recorder.""" + init_recorder_component(self.hass) + self.hass.start() diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py index f117b62fddb..7c98dfcd540 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -578,7 +578,7 @@ class TestInfluxDB(unittest.TestCase): mock_client.return_value.write_points.reset_mock() def test_event_listener_component_override_measurement(self, mock_client): - """Test the event listener with overrided measurements.""" + """Test the event listener with overridden measurements.""" config = { 'influxdb': { 'host': 'host', diff --git a/tests/components/test_init.py b/tests/components/test_init.py index 222d25f644a..06ba8a57508 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -82,7 +82,7 @@ class TestComponentsCore(unittest.TestCase): # We can't test if our service call results in services being called # because by mocking out the call service method, we mock out all - # So we mimick how the service registry calls services + # So we mimic how the service registry calls services service_call = ha.ServiceCall('homeassistant', 'turn_on', { 'entity_id': ['light.test', 'sensor.bla', 'light.bla'] }) diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py new file mode 100755 index 00000000000..be22e1122ea --- /dev/null +++ b/tests/components/test_input_text.py @@ -0,0 +1,147 @@ +"""The tests for the Input text component.""" +# pylint: disable=protected-access +import asyncio +import unittest + +from homeassistant.core import CoreState, State +from homeassistant.setup import setup_component, async_setup_component +from homeassistant.components.input_text import (DOMAIN, set_value) + +from tests.common import get_test_home_assistant, mock_restore_cache + + +class TestInputText(unittest.TestCase): + """Test the input slider component.""" + + # pylint: disable=invalid-name + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_config(self): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + {'test_1': { + 'min': 50, + 'max': 50, + }}, + ] + for cfg in invalid_configs: + self.assertFalse( + setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + + def test_set_value(self): + """Test set_value method.""" + self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'initial': 'test', + 'min': 3, + 'max': 10, + }, + }})) + entity_id = 'input_text.test_1' + + state = self.hass.states.get(entity_id) + self.assertEqual('test', str(state.state)) + + set_value(self.hass, entity_id, 'testing') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual('testing', str(state.state)) + + set_value(self.hass, entity_id, 'testing too long') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual('testing', str(state.state)) + + +@asyncio.coroutine +def test_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('input_text.b1', 'test'), + State('input_text.b2', 'testing too long'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'min': 0, + 'max': 10, + }, + 'b2': { + 'min': 0, + 'max': 10, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'test' + + state = hass.states.get('input_text.b2') + assert state + assert str(state.state) == 'unknown' + + +@asyncio.coroutine +def test_initial_state_overrules_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('input_text.b1', 'testing'), + State('input_text.b2', 'testing too long'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'initial': 'test', + 'min': 0, + 'max': 10, + }, + 'b2': { + 'initial': 'test', + 'min': 0, + 'max': 10, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'test' + + state = hass.states.get('input_text.b2') + assert state + assert str(state.state) == 'test' + + +@asyncio.coroutine +def test_no_initial_state_and_no_restore_state(hass): + """Ensure that entity is create without initial and restore feature.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'min': 0, + 'max': 100, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'unknown' diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index aa4bc9fdf8c..07c89b5dcd1 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -415,7 +415,7 @@ class TestComponentLogbook(unittest.TestCase): def test_home_assistant_start_stop_grouped(self): """Test if HA start and stop events are grouped. - Events that are occuring in the same minute. + Events that are occurring in the same minute. """ entries = list(logbook.humanify(( ha.Event(EVENT_HOMEASSISTANT_STOP), diff --git a/tests/components/test_mqtt_statestream.py b/tests/components/test_mqtt_statestream.py new file mode 100644 index 00000000000..cbd7838effe --- /dev/null +++ b/tests/components/test_mqtt_statestream.py @@ -0,0 +1,65 @@ +"""The tests for the MQTT statestream component.""" +from unittest.mock import patch + +from homeassistant.setup import setup_component +import homeassistant.components.mqtt_statestream as statestream +from homeassistant.core import State + +from tests.common import ( + get_test_home_assistant, + mock_mqtt_component, + mock_state_change_event +) + + +class TestMqttStateStream(object): + """Test the MQTT statestream module.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_mqtt = mock_mqtt_component(self.hass) + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + def add_statestream(self, base_topic=None): + """Add a mqtt_statestream component.""" + config = {} + if base_topic: + config['base_topic'] = base_topic + return setup_component(self.hass, statestream.DOMAIN, { + statestream.DOMAIN: config}) + + def test_fails_with_no_base(self): + """Setup should fail if no base_topic is set.""" + assert self.add_statestream() is False + + def test_setup_succeeds(self): + """"Test the success of the setup with a valid base_topic.""" + assert self.add_statestream(base_topic='pub') + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub): + """"Test the sending of a new message if event changed.""" + e_id = 'fake.entity' + base_topic = 'pub' + + # Add the statestream component for publishing state updates + assert self.add_statestream(base_topic=base_topic) + self.hass.block_till_done() + + # Reset the mock because it will have already gotten calls for the + # mqtt_statestream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State(e_id, 'on')) + self.hass.block_till_done() + + # Make sure 'on' was published to pub/fake/entity/state + mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', + 'on', 1, True) + assert mock_pub.called diff --git a/tests/components/test_python_script.py b/tests/components/test_python_script.py index 62c1b67eba9..660ed3c1b18 100644 --- a/tests/components/test_python_script.py +++ b/tests/components/test_python_script.py @@ -180,3 +180,61 @@ for i in [1, 2]: assert hass.states.is_state('hello.1', 'world') assert hass.states.is_state('hello.2', 'world') + + +@asyncio.coroutine +def test_unpacking_sequence(hass, caplog): + """Test compile error logs error.""" + caplog.set_level(logging.ERROR) + source = """ +a,b = (1,2) +ab_list = [(a,b) for a,b in [(1, 2), (3, 4)]] +hass.states.set('hello.a', a) +hass.states.set('hello.b', b) +hass.states.set('hello.ab_list', '{}'.format(ab_list)) +""" + + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + + assert hass.states.is_state('hello.a', '1') + assert hass.states.is_state('hello.b', '2') + assert hass.states.is_state('hello.ab_list', '[(1, 2), (3, 4)]') + + # No errors logged = good + assert caplog.text == '' + + +@asyncio.coroutine +def test_reload(hass): + """Test we can re-discover scripts.""" + scripts = [ + '/some/config/dir/python_scripts/hello.py', + '/some/config/dir/python_scripts/world_beer.py' + ] + with patch('homeassistant.components.python_script.os.path.isdir', + return_value=True), \ + patch('homeassistant.components.python_script.glob.iglob', + return_value=scripts): + res = yield from async_setup_component(hass, 'python_script', {}) + + assert res + assert hass.services.has_service('python_script', 'hello') + assert hass.services.has_service('python_script', 'world_beer') + assert hass.services.has_service('python_script', 'reload') + + scripts = [ + '/some/config/dir/python_scripts/hello2.py', + '/some/config/dir/python_scripts/world_beer.py' + ] + with patch('homeassistant.components.python_script.os.path.isdir', + return_value=True), \ + patch('homeassistant.components.python_script.glob.iglob', + return_value=scripts): + yield from hass.services.async_call( + 'python_script', 'reload', {}, blocking=True) + + assert not hass.services.has_service('python_script', 'hello') + assert hass.services.has_service('python_script', 'hello2') + assert hass.services.has_service('python_script', 'world_beer') + assert hass.services.has_service('python_script', 'reload') diff --git a/tests/components/test_upnp.py b/tests/components/test_upnp.py new file mode 100644 index 00000000000..e2096d28e58 --- /dev/null +++ b/tests/components/test_upnp.py @@ -0,0 +1,142 @@ +"""Test the UPNP component.""" +import asyncio +from collections import OrderedDict +from unittest.mock import patch, MagicMock + +import pytest + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.setup import async_setup_component + + +@pytest.fixture +def mock_miniupnpc(): + """Mock miniupnpc.""" + mock = MagicMock() + + with patch.dict('sys.modules', {'miniupnpc': mock}): + yield mock.UPnP() + + +@pytest.fixture +def mock_local_ip(): + """Mock get_local_ip.""" + with patch('homeassistant.components.upnp.get_local_ip', + return_value='192.168.0.10'): + yield + + +@pytest.fixture(autouse=True) +def mock_discovery(): + """Mock discovery of upnp sensor.""" + with patch('homeassistant.components.upnp.discovery'): + yield + + +@asyncio.coroutine +def test_setup_fail_if_no_ip(hass): + """Test setup fails if we can't find a local IP.""" + with patch('homeassistant.components.upnp.get_local_ip', + return_value='127.0.0.1'): + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': {} + }) + + assert not result + + +@asyncio.coroutine +def test_setup_fail_if_cannot_select_igd(hass, mock_local_ip, mock_miniupnpc): + """Test setup fails if we can't find an UPnP IGD.""" + mock_miniupnpc.selectigd.side_effect = Exception + + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': {} + }) + + assert not result + + +@asyncio.coroutine +def test_setup_succeeds_if_specify_ip(hass, mock_miniupnpc): + """Test setup succeeds if we specify IP and can't find a local IP.""" + with patch('homeassistant.components.upnp.get_local_ip', + return_value='127.0.0.1'): + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': { + 'local_ip': '192.168.0.10' + } + }) + + assert result + + +@asyncio.coroutine +def test_no_config_maps_hass_local_to_remote_port(hass, mock_miniupnpc): + """Test by default we map local to remote port.""" + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': { + 'local_ip': '192.168.0.10' + } + }) + + assert result + assert len(mock_miniupnpc.addportmapping.mock_calls) == 1 + external, _, host, internal, _, _ = \ + mock_miniupnpc.addportmapping.mock_calls[0][1] + assert host == '192.168.0.10' + assert external == 8123 + assert internal == 8123 + + +@asyncio.coroutine +def test_map_hass_to_remote_port(hass, mock_miniupnpc): + """Test mapping hass to remote port.""" + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': { + 'local_ip': '192.168.0.10', + 'ports': { + 'hass': 1000 + } + } + }) + + assert result + assert len(mock_miniupnpc.addportmapping.mock_calls) == 1 + external, _, host, internal, _, _ = \ + mock_miniupnpc.addportmapping.mock_calls[0][1] + assert external == 1000 + assert internal == 8123 + + +@asyncio.coroutine +def test_map_internal_to_remote_ports(hass, mock_miniupnpc): + """Test mapping local to remote ports.""" + ports = OrderedDict() + ports['hass'] = 1000 + ports[1883] = 3883 + + result = yield from async_setup_component(hass, 'upnp', { + 'upnp': { + 'local_ip': '192.168.0.10', + 'ports': ports + } + }) + + assert result + assert len(mock_miniupnpc.addportmapping.mock_calls) == 2 + external, _, host, internal, _, _ = \ + mock_miniupnpc.addportmapping.mock_calls[0][1] + assert external == 1000 + assert internal == 8123 + + external, _, host, internal, _, _ = \ + mock_miniupnpc.addportmapping.mock_calls[1][1] + assert external == 3883 + assert internal == 1883 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + yield from hass.async_block_till_done() + assert len(mock_miniupnpc.deleteportmapping.mock_calls) == 2 + assert mock_miniupnpc.deleteportmapping.mock_calls[0][1][0] == 1000 + assert mock_miniupnpc.deleteportmapping.mock_calls[1][1][0] == 3883 diff --git a/tests/components/vacuum/test_mqtt.py b/tests/components/vacuum/test_mqtt.py new file mode 100644 index 00000000000..f4c63d63708 --- /dev/null +++ b/tests/components/vacuum/test_mqtt.py @@ -0,0 +1,199 @@ +"""The tests for the Demo vacuum platform.""" +import unittest + +from homeassistant.components import vacuum +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON, ATTR_STATUS, + ATTR_FAN_SPEED, mqtt) +from homeassistant.components.mqtt import CONF_COMMAND_TOPIC +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, CONF_NAME +from homeassistant.setup import setup_component +from tests.common import ( + fire_mqtt_message, get_test_home_assistant, mock_mqtt_component) + + +class TestVacuumMQTT(unittest.TestCase): + """MQTT vacuum component test class.""" + + def setUp(self): # pylint: disable=invalid-name + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + + self.default_config = { + CONF_PLATFORM: 'mqtt', + CONF_NAME: 'mqtttest', + CONF_COMMAND_TOPIC: 'vacuum/command', + mqtt.CONF_SEND_COMMAND_TOPIC: 'vacuum/send_command', + mqtt.CONF_BATTERY_LEVEL_TOPIC: 'vacuum/state', + mqtt.CONF_BATTERY_LEVEL_TEMPLATE: + '{{ value_json.battery_level }}', + mqtt.CONF_CHARGING_TOPIC: 'vacuum/state', + mqtt.CONF_CHARGING_TEMPLATE: '{{ value_json.charging }}', + mqtt.CONF_CLEANING_TOPIC: 'vacuum/state', + mqtt.CONF_CLEANING_TEMPLATE: '{{ value_json.cleaning }}', + mqtt.CONF_DOCKED_TOPIC: 'vacuum/state', + mqtt.CONF_DOCKED_TEMPLATE: '{{ value_json.docked }}', + mqtt.CONF_STATE_TOPIC: 'vacuum/state', + mqtt.CONF_STATE_TEMPLATE: '{{ value_json.state }}', + mqtt.CONF_FAN_SPEED_TOPIC: 'vacuum/state', + mqtt.CONF_FAN_SPEED_TEMPLATE: '{{ value_json.fan_speed }}', + mqtt.CONF_SET_FAN_SPEED_TOPIC: 'vacuum/set_fan_speed', + mqtt.CONF_FAN_SPEED_LIST: ['min', 'medium', 'high', 'max'], + } + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_default_supported_features(self): + """Test that the correct supported features.""" + self.assertTrue(setup_component(self.hass, vacuum.DOMAIN, { + vacuum.DOMAIN: self.default_config, + })) + entity = self.hass.states.get('vacuum.mqtttest') + entity_features = \ + entity.attributes.get(mqtt.CONF_SUPPORTED_FEATURES, 0) + self.assertListEqual(sorted(mqtt.services_to_strings(entity_features)), + sorted(['turn_on', 'turn_off', 'stop', + 'return_home', 'battery', 'status', + 'clean_spot'])) + + def test_all_commands(self): + """Test simple commands to the vacuum.""" + self.default_config[mqtt.CONF_SUPPORTED_FEATURES] = \ + mqtt.services_to_strings(mqtt.ALL_SERVICES) + + self.assertTrue(setup_component(self.hass, vacuum.DOMAIN, { + vacuum.DOMAIN: self.default_config, + })) + + vacuum.turn_on(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'turn_on', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.turn_off(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'turn_off', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.stop(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'stop', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.clean_spot(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'clean_spot', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.locate(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'locate', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.start_pause(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'start_pause', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.return_to_base(self.hass, 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual(('vacuum/command', 'return_to_base', 0, False), + self.mock_publish.mock_calls[-2][1]) + + vacuum.set_fan_speed(self.hass, 'high', 'vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual( + ('vacuum/set_fan_speed', 'high', 0, False), + self.mock_publish.mock_calls[-2][1] + ) + + vacuum.send_command(self.hass, '44 FE 93', entity_id='vacuum.mqtttest') + self.hass.block_till_done() + self.assertEqual( + ('vacuum/send_command', '44 FE 93', 0, False), + self.mock_publish.mock_calls[-2][1] + ) + + def test_status(self): + """Test status updates from the vacuum.""" + self.default_config[mqtt.CONF_SUPPORTED_FEATURES] = \ + mqtt.services_to_strings(mqtt.ALL_SERVICES) + + self.assertTrue(setup_component(self.hass, vacuum.DOMAIN, { + vacuum.DOMAIN: self.default_config, + })) + + message = """{ + "battery_level": 54, + "cleaning": true, + "docked": false, + "charging": false, + "fan_speed": "max" + }""" + fire_mqtt_message(self.hass, 'vacuum/state', message) + self.hass.block_till_done() + state = self.hass.states.get('vacuum.mqtttest') + self.assertEqual(STATE_ON, state.state) + self.assertEqual( + 'mdi:battery-50', + state.attributes.get(ATTR_BATTERY_ICON) + ) + self.assertEqual(54, state.attributes.get(ATTR_BATTERY_LEVEL)) + self.assertEqual('max', state.attributes.get(ATTR_FAN_SPEED)) + + message = """{ + "battery_level": 61, + "docked": true, + "cleaning": false, + "charging": true, + "fan_speed": "min" + }""" + + fire_mqtt_message(self.hass, 'vacuum/state', message) + self.hass.block_till_done() + state = self.hass.states.get('vacuum.mqtttest') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual( + 'mdi:battery-charging-60', + state.attributes.get(ATTR_BATTERY_ICON) + ) + self.assertEqual(61, state.attributes.get(ATTR_BATTERY_LEVEL)) + self.assertEqual('min', state.attributes.get(ATTR_FAN_SPEED)) + + def test_battery_template(self): + """Test that you can use non-default templates for battery_level.""" + self.default_config.update({ + mqtt.CONF_SUPPORTED_FEATURES: + mqtt.services_to_strings(mqtt.ALL_SERVICES), + mqtt.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level", + mqtt.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}" + }) + + self.assertTrue(setup_component(self.hass, vacuum.DOMAIN, { + vacuum.DOMAIN: self.default_config, + })) + + fire_mqtt_message(self.hass, 'retroroomba/battery_level', '54') + self.hass.block_till_done() + state = self.hass.states.get('vacuum.mqtttest') + self.assertEqual(54, state.attributes.get(ATTR_BATTERY_LEVEL)) + self.assertEqual(state.attributes.get(ATTR_BATTERY_ICON), + 'mdi:battery-50') + + def test_status_invalid_json(self): + """Test to make sure nothing breaks if the vacuum sends bad JSON.""" + self.default_config[mqtt.CONF_SUPPORTED_FEATURES] = \ + mqtt.services_to_strings(mqtt.ALL_SERVICES) + + self.assertTrue(setup_component(self.hass, vacuum.DOMAIN, { + vacuum.DOMAIN: self.default_config, + })) + + fire_mqtt_message(self.hass, 'vacuum/state', '{"asdfasas false}') + self.hass.block_till_done() + state = self.hass.states.get('vacuum.mqtttest') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual("Stopped", state.attributes.get(ATTR_STATUS)) diff --git a/tests/components/vacuum/test_xiaomi.py b/tests/components/vacuum/test_xiaomi_miio.py similarity index 54% rename from tests/components/vacuum/test_xiaomi.py rename to tests/components/vacuum/test_xiaomi_miio.py index 0045bbb3b24..bdb85abb057 100644 --- a/tests/components/vacuum/test_xiaomi.py +++ b/tests/components/vacuum/test_xiaomi_miio.py @@ -11,8 +11,10 @@ from homeassistant.components.vacuum import ( SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_RETURN_TO_BASE, SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, SERVICE_START_PAUSE, SERVICE_STOP, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON) -from homeassistant.components.vacuum.xiaomi import ( +from homeassistant.components.vacuum.xiaomi_miio import ( ATTR_CLEANED_AREA, ATTR_CLEANING_TIME, ATTR_DO_NOT_DISTURB, ATTR_ERROR, + ATTR_MAIN_BRUSH_LEFT, ATTR_SIDE_BRUSH_LEFT, ATTR_FILTER_LEFT, + ATTR_CLEANING_COUNT, ATTR_CLEANED_TOTAL_AREA, ATTR_CLEANING_TOTAL_TIME, CONF_HOST, CONF_NAME, CONF_TOKEN, PLATFORM, SERVICE_MOVE_REMOTE_CONTROL, SERVICE_MOVE_REMOTE_CONTROL_STEP, SERVICE_START_REMOTE_CONTROL, SERVICE_STOP_REMOTE_CONTROL) @@ -36,6 +38,16 @@ def mock_mirobo_is_off(): mock_vacuum.Vacuum().status().clean_area = 123.43218 mock_vacuum.Vacuum().status().clean_time = timedelta( hours=2, minutes=35, seconds=34) + mock_vacuum.Vacuum().consumable_status().main_brush_left = timedelta( + hours=12, minutes=35, seconds=34) + mock_vacuum.Vacuum().consumable_status().side_brush_left = timedelta( + hours=12, minutes=35, seconds=34) + mock_vacuum.Vacuum().consumable_status().filter_left = timedelta( + hours=12, minutes=35, seconds=34) + mock_vacuum.Vacuum().clean_history().count = '35' + mock_vacuum.Vacuum().clean_history().total_area = 123.43218 + mock_vacuum.Vacuum().clean_history().total_duration = timedelta( + hours=11, minutes=35, seconds=34) mock_vacuum.Vacuum().status().state = 'Test Xiaomi Charging' with mock.patch.dict('sys.modules', { @@ -57,6 +69,16 @@ def mock_mirobo_is_on(): mock_vacuum.Vacuum().status().clean_area = 133.43218 mock_vacuum.Vacuum().status().clean_time = timedelta( hours=2, minutes=55, seconds=34) + mock_vacuum.Vacuum().consumable_status().main_brush_left = timedelta( + hours=11, minutes=35, seconds=34) + mock_vacuum.Vacuum().consumable_status().side_brush_left = timedelta( + hours=11, minutes=35, seconds=34) + mock_vacuum.Vacuum().consumable_status().filter_left = timedelta( + hours=11, minutes=35, seconds=34) + mock_vacuum.Vacuum().clean_history().count = '41' + mock_vacuum.Vacuum().clean_history().total_area = 323.43218 + mock_vacuum.Vacuum().clean_history().total_duration = timedelta( + hours=11, minutes=15, seconds=34) mock_vacuum.Vacuum().status().state = 'Test Xiaomi Cleaning' with mock.patch.dict('sys.modules', { @@ -117,65 +139,111 @@ def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_off): assert state.attributes.get(ATTR_ERROR) == 'Error message' assert (state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-charging-80') - assert state.attributes.get(ATTR_CLEANING_TIME) == '2:35:34' - assert state.attributes.get(ATTR_CLEANED_AREA) == 123.43 + assert state.attributes.get(ATTR_CLEANING_TIME) == 155 + assert state.attributes.get(ATTR_CLEANED_AREA) == 123 assert state.attributes.get(ATTR_FAN_SPEED) == 'Quiet' assert (state.attributes.get(ATTR_FAN_SPEED_LIST) == ['Quiet', 'Balanced', 'Turbo', 'Max']) + assert state.attributes.get(ATTR_MAIN_BRUSH_LEFT) == 12 + assert state.attributes.get(ATTR_SIDE_BRUSH_LEFT) == 12 + assert state.attributes.get(ATTR_FILTER_LEFT) == 12 + assert state.attributes.get(ATTR_CLEANING_COUNT) == 35 + assert state.attributes.get(ATTR_CLEANED_TOTAL_AREA) == 123 + assert state.attributes.get(ATTR_CLEANING_TOTAL_TIME) == 695 # Call services yield from hass.services.async_call( DOMAIN, SERVICE_TURN_ON, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().start()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().start()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().home()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().home()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_TOGGLE, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().start()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().start()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_STOP, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().stop()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().stop()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_START_PAUSE, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().start()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().start()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_RETURN_TO_BASE, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().home()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().home()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_LOCATE, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().find()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().find()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_CLEAN_SPOT, {}, blocking=True) - assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().spot()' - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().spot()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') # Set speed service: yield from hass.services.async_call( DOMAIN, SERVICE_SET_FAN_SPEED, {"fan_speed": 60}, blocking=True) - assert (str(mock_mirobo_is_off.mock_calls[-2]) + assert (str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().set_fan_speed(60)') - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_SET_FAN_SPEED, {"fan_speed": "turbo"}, blocking=True) - assert (str(mock_mirobo_is_off.mock_calls[-2]) + assert (str(mock_mirobo_is_off.mock_calls[-4]) == 'call.Vacuum().set_fan_speed(77)') - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') assert 'ERROR' not in caplog.text yield from hass.services.async_call( @@ -185,16 +253,24 @@ def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_off): yield from hass.services.async_call( DOMAIN, SERVICE_SEND_COMMAND, {"command": "raw"}, blocking=True) - assert (str(mock_mirobo_is_off.mock_calls[-2]) + assert (str(mock_mirobo_is_off.mock_calls[-4]) == "call.Vacuum().raw_command('raw', None)") - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_SEND_COMMAND, {"command": "raw", "params": {"k1": 2}}, blocking=True) - assert (str(mock_mirobo_is_off.mock_calls[-2]) + assert (str(mock_mirobo_is_off.mock_calls[-4]) == "call.Vacuum().raw_command('raw', {'k1': 2})") - assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_off.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_off.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_off.mock_calls[-1]) + == 'call.Vacuum().clean_history()') @asyncio.coroutine @@ -220,48 +296,74 @@ def test_xiaomi_specific_services(hass, caplog, mock_mirobo_is_on): assert state.attributes.get(ATTR_ERROR) is None assert (state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-30') - assert state.attributes.get(ATTR_CLEANING_TIME) == '2:55:34' - assert state.attributes.get(ATTR_CLEANED_AREA) == 133.43 + assert state.attributes.get(ATTR_CLEANING_TIME) == 175 + assert state.attributes.get(ATTR_CLEANED_AREA) == 133 assert state.attributes.get(ATTR_FAN_SPEED) == 99 assert (state.attributes.get(ATTR_FAN_SPEED_LIST) == ['Quiet', 'Balanced', 'Turbo', 'Max']) + assert state.attributes.get(ATTR_MAIN_BRUSH_LEFT) == 11 + assert state.attributes.get(ATTR_SIDE_BRUSH_LEFT) == 11 + assert state.attributes.get(ATTR_FILTER_LEFT) == 11 + assert state.attributes.get(ATTR_CLEANING_COUNT) == 41 + assert state.attributes.get(ATTR_CLEANED_TOTAL_AREA) == 323 + assert state.attributes.get(ATTR_CLEANING_TOTAL_TIME) == 675 # Check setting pause yield from hass.services.async_call( DOMAIN, SERVICE_START_PAUSE, blocking=True) - assert str(mock_mirobo_is_on.mock_calls[-2]) == 'call.Vacuum().pause()' - assert str(mock_mirobo_is_on.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_on.mock_calls[-4]) == 'call.Vacuum().pause()' + assert str(mock_mirobo_is_on.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_on.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_on.mock_calls[-1]) + == 'call.Vacuum().clean_history()') # Xiaomi vacuum specific services: yield from hass.services.async_call( DOMAIN, SERVICE_START_REMOTE_CONTROL, {ATTR_ENTITY_ID: entity_id}, blocking=True) - assert (str(mock_mirobo_is_on.mock_calls[-2]) + assert (str(mock_mirobo_is_on.mock_calls[-4]) == "call.Vacuum().manual_start()") - assert str(mock_mirobo_is_on.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_on.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_on.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_on.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_MOVE_REMOTE_CONTROL, {"duration": 1000, "rotation": -40, "velocity": -0.1}, blocking=True) assert ('call.Vacuum().manual_control(' - in str(mock_mirobo_is_on.mock_calls[-2])) - assert 'duration=1000' in str(mock_mirobo_is_on.mock_calls[-2]) - assert 'rotation=-40' in str(mock_mirobo_is_on.mock_calls[-2]) - assert 'velocity=-0.1' in str(mock_mirobo_is_on.mock_calls[-2]) - assert str(mock_mirobo_is_on.mock_calls[-1]) == 'call.Vacuum().status()' + in str(mock_mirobo_is_on.mock_calls[-4])) + assert 'duration=1000' in str(mock_mirobo_is_on.mock_calls[-4]) + assert 'rotation=-40' in str(mock_mirobo_is_on.mock_calls[-4]) + assert 'velocity=-0.1' in str(mock_mirobo_is_on.mock_calls[-4]) + assert str(mock_mirobo_is_on.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_on.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_on.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_STOP_REMOTE_CONTROL, {}, blocking=True) - assert (str(mock_mirobo_is_on.mock_calls[-2]) + assert (str(mock_mirobo_is_on.mock_calls[-4]) == "call.Vacuum().manual_stop()") - assert str(mock_mirobo_is_on.mock_calls[-1]) == 'call.Vacuum().status()' + assert str(mock_mirobo_is_on.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_on.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_on.mock_calls[-1]) + == 'call.Vacuum().clean_history()') yield from hass.services.async_call( DOMAIN, SERVICE_MOVE_REMOTE_CONTROL_STEP, {"duration": 2000, "rotation": 120, "velocity": 0.1}, blocking=True) assert ('call.Vacuum().manual_control_once(' - in str(mock_mirobo_is_on.mock_calls[-2])) - assert 'duration=2000' in str(mock_mirobo_is_on.mock_calls[-2]) - assert 'rotation=120' in str(mock_mirobo_is_on.mock_calls[-2]) - assert 'velocity=0.1' in str(mock_mirobo_is_on.mock_calls[-2]) - assert str(mock_mirobo_is_on.mock_calls[-1]) == 'call.Vacuum().status()' + in str(mock_mirobo_is_on.mock_calls[-4])) + assert 'duration=2000' in str(mock_mirobo_is_on.mock_calls[-4]) + assert 'rotation=120' in str(mock_mirobo_is_on.mock_calls[-4]) + assert 'velocity=0.1' in str(mock_mirobo_is_on.mock_calls[-4]) + assert str(mock_mirobo_is_on.mock_calls[-3]) == 'call.Vacuum().status()' + assert (str(mock_mirobo_is_on.mock_calls[-2]) + == 'call.Vacuum().consumable_status()') + assert (str(mock_mirobo_is_on.mock_calls[-1]) + == 'call.Vacuum().clean_history()') diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 2fa4dd0b929..1e759949a46 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -576,7 +576,6 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): assert args[3] == {const.DISCOVERY_DEVICE: id(values)} assert args[4] == self.zwave_config assert not self.primary.enable_poll.called - assert self.primary.disable_poll.called @patch.object(zwave, 'get_platform') @patch.object(zwave, 'discovery') @@ -742,7 +741,6 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): assert self.primary.enable_poll.called assert len(self.primary.enable_poll.mock_calls) == 1 assert self.primary.enable_poll.mock_calls[0][1][0] == 123 - assert not self.primary.disable_poll.called class TestZwave(unittest.TestCase): @@ -887,6 +885,85 @@ class TestZWaveServices(unittest.TestCase): assert value.label == "New Label" + def test_set_poll_intensity_enable(self): + """Test zwave set_poll_intensity service, succsessful set.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=0) + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 0 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 4, + }) + self.hass.block_till_done() + + enable_poll = value.enable_poll + assert value.enable_poll.called + assert len(enable_poll.mock_calls) == 2 + assert enable_poll.mock_calls[0][1][0] == 4 + + def test_set_poll_intensity_enable_failed(self): + """Test zwave set_poll_intensity service, failed set.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=0) + value.enable_poll.return_value = False + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 0 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 4, + }) + self.hass.block_till_done() + + enable_poll = value.enable_poll + assert value.enable_poll.called + assert len(enable_poll.mock_calls) == 1 + + def test_set_poll_intensity_disable(self): + """Test zwave set_poll_intensity service, successful disable.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=4) + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 4 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 0, + }) + self.hass.block_till_done() + + disable_poll = value.disable_poll + assert value.disable_poll.called + assert len(disable_poll.mock_calls) == 2 + + def test_set_poll_intensity_disable_failed(self): + """Test zwave set_poll_intensity service, failed disable.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=4) + value.disable_poll.return_value = False + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 4 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 0, + }) + self.hass.block_till_done() + + disable_poll = value.disable_poll + assert value.disable_poll.called + assert len(disable_poll.mock_calls) == 1 + def test_remove_failed_node(self): """Test zwave remove_failed_node service.""" self.hass.services.call('zwave', 'remove_failed_node', { diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index b7148dd982e..32351234ad3 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -117,6 +117,60 @@ def test_scene_activated(hass, mock_openzwave): assert events[0].data[const.ATTR_SCENE_ID] == scene_id +@asyncio.coroutine +def test_central_scene_activated(hass, mock_openzwave): + """Test central scene activated event.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == mock_zwave.MockNetwork.SIGNAL_VALUE_CHANGED: + mock_receivers.append(receiver) + + node = mock_zwave.MockNode(node_id=11) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + entity = node_entity.ZWaveNodeEntity(node, mock_openzwave, True) + + assert len(mock_receivers) == 1 + + events = [] + + def listener(event): + events.append(event) + + hass.bus.async_listen(const.EVENT_SCENE_ACTIVATED, listener) + + # Test event before entity added to hass + scene_id = 1 + scene_data = 3 + value = mock_zwave.MockValue( + command_class=const.COMMAND_CLASS_CENTRAL_SCENE, + index=scene_id, + data=scene_data) + hass.async_add_job(mock_receivers[0], node, value) + yield from hass.async_block_till_done() + assert len(events) == 0 + + # Add entity to hass + entity.hass = hass + entity.entity_id = 'zwave.mock_node' + + scene_id = 1 + scene_data = 3 + value = mock_zwave.MockValue( + command_class=const.COMMAND_CLASS_CENTRAL_SCENE, + index=scene_id, + data=scene_data) + hass.async_add_job(mock_receivers[0], node, value) + yield from hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" + assert events[0].data[const.ATTR_NODE_ID] == 11 + assert events[0].data[const.ATTR_SCENE_ID] == scene_id + assert events[0].data[const.ATTR_SCENE_DATA] == scene_data + + @pytest.mark.usefixtures('mock_openzwave') class TestZWaveNodeEntity(unittest.TestCase): """Class to test ZWaveNodeEntity.""" diff --git a/tests/fixtures/geo_rss_events.xml b/tests/fixtures/geo_rss_events.xml new file mode 100644 index 00000000000..212994756d2 --- /dev/null +++ b/tests/fixtures/geo_rss_events.xml @@ -0,0 +1,76 @@ + + + + + + Title 1 + Description 1 + Category 1 + Sun, 30 Jul 2017 09:00:00 UTC + GUID 1 + -32.916667 151.75 + + + + Title 2 + Description 2 + Category 2 + Sun, 30 Jul 2017 09:05:00 GMT + GUID 2 + 148.601111 + -32.256944 + + + + Title 3 + Description 3 + Category 3 + Sun, 30 Jul 2017 09:05:00 GMT + GUID 3 + + -33.283333 149.1 + -33.2999997 149.1 + -33.2999997 149.1166663888889 + -33.283333 149.1166663888889 + -33.283333 149.1 + + + + + Title 4 + Description 4 + Category 4 + Sun, 30 Jul 2017 09:15:00 GMT + GUID 4 + 52.518611 13.408333 + + + + Title 5 + Description 5 + Category 5 + Sun, 30 Jul 2017 09:20:00 GMT + GUID 5 + + + + + Title 6 + Description 6 + Category 6 + 2017-07-30T09:25:00.000Z + Link 6 + -33.75801 150.70544 + + + + Title 1 + Description 1 + Category 1 + Sun, 30 Jul 2017 09:00:00 UTC + GUID 1 + 45.256 -110.45 46.46 -109.48 43.84 -109.86 + + + \ No newline at end of file diff --git a/tests/fixtures/ring_ding_active.json b/tests/fixtures/ring_ding_active.json index 6bbcc0ee3f9..7c9e0b07405 100644 --- a/tests/fixtures/ring_ding_active.json +++ b/tests/fixtures/ring_ding_active.json @@ -2,7 +2,7 @@ "audio_jitter_buffer_ms": 0, "device_kind": "lpd_v1", "doorbot_description": "Front Door", - "doorbot_id": 12345, + "doorbot_id": 987652, "expires_in": 180, "id": 123456789, "id_str": "123456789", diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 644c8894874..cf73e066072 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -191,3 +191,25 @@ def test_warn_slow_update_with_exception(hass): assert mock_call().cancel.called assert update_call + + +@asyncio.coroutine +def test_async_schedule_update_ha_state(hass): + """Warn we log when entity update takes a long time and trow exception.""" + update_call = False + + @asyncio.coroutine + def async_update(): + """Mock async update.""" + nonlocal update_call + update_call = True + + mock_entity = entity.Entity() + mock_entity.hass = hass + mock_entity.entity_id = 'comp_test.test_entity' + mock_entity.async_update = async_update + + mock_entity.async_schedule_update_ha_state(True) + yield from hass.async_block_till_done() + + assert update_call is True diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 11717c75e20..efa079a7e4a 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -183,7 +183,7 @@ class TestHelpersEntityComponent(unittest.TestCase): assert 2 == len(self.hass.states.entity_ids()) def test_update_state_adds_entities_with_update_befor_add_true(self): - """Test if call update befor add to state machine.""" + """Test if call update before add to state machine.""" component = EntityComponent(_LOGGER, DOMAIN, self.hass) ent = EntityTest() @@ -196,7 +196,7 @@ class TestHelpersEntityComponent(unittest.TestCase): assert ent.update.called def test_update_state_adds_entities_with_update_befor_add_false(self): - """Test if not call update befor add to state machine.""" + """Test if not call update before add to state machine.""" component = EntityComponent(_LOGGER, DOMAIN, self.hass) ent = EntityTest() @@ -209,7 +209,7 @@ class TestHelpersEntityComponent(unittest.TestCase): assert not ent.update.called def test_adds_entities_with_update_befor_add_true_deadlock_protect(self): - """Test if call update befor add to state machine. + """Test if call update before add to state machine. It need to run update inside executor and never call async_add_entities with True diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 37ff8ba297e..9c325df181e 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -17,6 +17,7 @@ from homeassistant.helpers.event import ( track_state_change, track_time_interval, track_template, + track_same_state, track_sunrise, track_sunset, ) @@ -24,7 +25,7 @@ from homeassistant.helpers.template import Template from homeassistant.components import sun import homeassistant.util.dt as dt_util -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, fire_time_changed from unittest.mock import patch @@ -262,6 +263,111 @@ class TestEventHelpers(unittest.TestCase): self.assertEqual(2, len(wildcard_runs)) self.assertEqual(2, len(wildercard_runs)) + def test_track_same_state_simple_trigger(self): + """Test track_same_change with trigger simple.""" + thread_runs = [] + callback_runs = [] + coroutine_runs = [] + period = timedelta(minutes=1) + + def thread_run_callback(): + thread_runs.append(1) + + track_same_state( + self.hass, 'on', period, thread_run_callback, + entity_ids='light.Bowl') + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl') + + @asyncio.coroutine + def coroutine_run_callback(): + coroutine_runs.append(1) + + track_same_state( + self.hass, 'on', period, coroutine_run_callback) + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(thread_runs)) + self.assertEqual(0, len(callback_runs)) + self.assertEqual(0, len(coroutine_runs)) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(1, len(thread_runs)) + self.assertEqual(1, len(callback_runs)) + self.assertEqual(1, len(coroutine_runs)) + + def test_track_same_state_simple_no_trigger(self): + """Test track_same_change with no trigger.""" + callback_runs = [] + period = timedelta(minutes=1) + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl') + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + # Change state on state machine + self.hass.states.set("light.Bowl", "off") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + def test_track_same_state_simple_trigger_check_funct(self): + """Test track_same_change with trigger and check funct.""" + callback_runs = [] + check_func = [] + period = timedelta(minutes=1) + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + @ha.callback + def async_check_func(entity, from_s, to_s): + check_func.append((entity, from_s, to_s)) + return 'on' + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl', async_check_func=async_check_func) + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + self.assertEqual('on', check_func[-1][2].state) + self.assertEqual('light.bowl', check_func[-1][0]) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(1, len(callback_runs)) + def test_track_time_interval(self): """Test tracking time interval.""" specific_runs = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 6a2a77f5c71..e668bd5b6cd 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -782,3 +782,17 @@ def test_state_with_unit(hass): hass) assert tpl.async_render() == '' + + +@asyncio.coroutine +def test_length_of_states(hass): + """Test fetching the length of states.""" + hass.states.async_set('sensor.test', '23') + hass.states.async_set('sensor.test2', 'wow') + hass.states.async_set('climate.test2', 'cooling') + + tpl = template.Template('{{ states | length }}', hass) + assert tpl.async_render() == '3' + + tpl = template.Template('{{ states.sensor | length }}', hass) + assert tpl.async_render() == '2' diff --git a/tests/test_config.py b/tests/test_config.py index 8c889979a82..1cb5e00bee9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,10 @@ from homeassistant.components.config.group import ( CONFIG_PATH as GROUP_CONFIG_PATH) from homeassistant.components.config.automation import ( CONFIG_PATH as AUTOMATIONS_CONFIG_PATH) +from homeassistant.components.config.script import ( + CONFIG_PATH as SCRIPTS_CONFIG_PATH) +from homeassistant.components.config.customize import ( + CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) from tests.common import ( get_test_config_dir, get_test_home_assistant, mock_coro) @@ -31,6 +35,8 @@ YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) GROUP_PATH = os.path.join(CONFIG_DIR, GROUP_CONFIG_PATH) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, AUTOMATIONS_CONFIG_PATH) +SCRIPTS_PATH = os.path.join(CONFIG_DIR, SCRIPTS_CONFIG_PATH) +CUSTOMIZE_PATH = os.path.join(CONFIG_DIR, CUSTOMIZE_CONFIG_PATH) ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -65,8 +71,15 @@ class TestConfig(unittest.TestCase): if os.path.isfile(AUTOMATIONS_PATH): os.remove(AUTOMATIONS_PATH) + if os.path.isfile(SCRIPTS_PATH): + os.remove(SCRIPTS_PATH) + + if os.path.isfile(CUSTOMIZE_PATH): + os.remove(CUSTOMIZE_PATH) + self.hass.stop() + # pylint: disable=no-self-use def test_create_default_config(self): """Test creation of default config.""" config_util.create_default_config(CONFIG_DIR, False) @@ -75,6 +88,7 @@ class TestConfig(unittest.TestCase): assert os.path.isfile(VERSION_PATH) assert os.path.isfile(GROUP_PATH) assert os.path.isfile(AUTOMATIONS_PATH) + assert os.path.isfile(CUSTOMIZE_PATH) def test_find_config_file_yaml(self): """Test if it finds a YAML config file.""" @@ -169,7 +183,8 @@ class TestConfig(unittest.TestCase): CONF_ELEVATION: 101, CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, CONF_NAME: 'Home', - CONF_TIME_ZONE: 'America/Los_Angeles' + CONF_TIME_ZONE: 'America/Los_Angeles', + CONF_CUSTOMIZE: OrderedDict(), } assert expected_values == ha_conf @@ -334,11 +349,12 @@ class TestConfig(unittest.TestCase): mock_open = mock.mock_open() - def mock_isfile(filename): + def _mock_isfile(filename): return True with mock.patch('homeassistant.config.open', mock_open, create=True), \ - mock.patch('homeassistant.config.os.path.isfile', mock_isfile): + mock.patch( + 'homeassistant.config.os.path.isfile', _mock_isfile): opened_file = mock_open.return_value # pylint: disable=no-member opened_file.readline.return_value = ha_version @@ -359,11 +375,12 @@ class TestConfig(unittest.TestCase): mock_open = mock.mock_open() - def mock_isfile(filename): + def _mock_isfile(filename): return False with mock.patch('homeassistant.config.open', mock_open, create=True), \ - mock.patch('homeassistant.config.os.path.isfile', mock_isfile): + mock.patch( + 'homeassistant.config.os.path.isfile', _mock_isfile): opened_file = mock_open.return_value # pylint: disable=no-member opened_file.readline.return_value = ha_version diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 0af5321c65f..ccd71e55d16 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -201,6 +201,7 @@ def mock_aiohttp_client(): with mock.patch('aiohttp.ClientSession') as mock_session: instance = mock_session() + instance.request = mocker.match_request for method in ('get', 'post', 'put', 'options', 'delete'): setattr(instance, method, diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 1b0b808b9c4..918a684f322 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -54,8 +54,8 @@ class TestYaml(unittest.TestCase): patch_yaml_files(files): yaml.load_yaml(YAML_CONFIG_FILE) - def test_enviroment_variable(self): - """Test config file with enviroment variable.""" + def test_environment_variable(self): + """Test config file with environment variable.""" os.environ["PASSWORD"] = "secret_password" conf = "password: !env_var PASSWORD" with io.StringIO(conf) as file: @@ -70,8 +70,8 @@ class TestYaml(unittest.TestCase): doc = yaml.yaml.safe_load(file) assert doc['password'] == "secret_password" - def test_invalid_enviroment_variable(self): - """Test config file with no enviroment variable sat.""" + def test_invalid_environment_variable(self): + """Test config file with no environment variable sat.""" conf = "password: !env_var PASSWORD" with self.assertRaises(HomeAssistantError): with io.StringIO(conf) as file: