diff --git a/.coveragerc b/.coveragerc index 9410611536d..7ebab01d399 100644 --- a/.coveragerc +++ b/.coveragerc @@ -46,9 +46,11 @@ omit = homeassistant/components/light/limitlessled.py homeassistant/components/media_player/cast.py homeassistant/components/media_player/denon.py + homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py homeassistant/components/media_player/mpd.py homeassistant/components/media_player/squeezebox.py + homeassistant/components/media_player/sonos.py homeassistant/components/notify/file.py homeassistant/components/notify/instapush.py homeassistant/components/notify/nma.py @@ -60,9 +62,11 @@ omit = homeassistant/components/notify/xmpp.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/bitcoin.py + homeassistant/components/sensor/command_sensor.py homeassistant/components/sensor/dht.py homeassistant/components/sensor/efergy.py homeassistant/components/sensor/forecast.py + homeassistant/components/sensor/glances.py homeassistant/components/sensor/mysensors.py homeassistant/components/sensor/openweathermap.py homeassistant/components/sensor/rfxtrx.py @@ -73,6 +77,7 @@ omit = homeassistant/components/sensor/temper.py homeassistant/components/sensor/time_date.py homeassistant/components/sensor/transmission.py + homeassistant/components/switch/arest.py homeassistant/components/switch/command_switch.py homeassistant/components/switch/edimax.py homeassistant/components/switch/hikvisioncam.py diff --git a/.gitignore b/.gitignore index 6bda29ca6fc..b04c0ebc219 100644 --- a/.gitignore +++ b/.gitignore @@ -15,10 +15,6 @@ tests/config/home-assistant.log *.sublime-project *.sublime-workspace -# Hide code validator output -pep8.txt -pylint.txt - # Hide some OS X stuff .DS_Store .AppleDouble @@ -30,6 +26,9 @@ Icon .idea +# pytest +.cache + # GITHUB Proposed Python stuff: *.py[cod] diff --git a/.travis.yml b/.travis.yml index 339ed48d424..4a4dfbc2354 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,11 +3,6 @@ language: python python: - "3.4" install: - - pip install -r requirements_all.txt - - pip install flake8 pylint coveralls + - script/bootstrap_server script: - - flake8 homeassistant - - pylint homeassistant - - coverage run -m unittest discover tests -after_success: - - coveralls + - script/cibuild diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f2fd110a1d..f646766a231 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ For help on building your component, please see the [developer documentation](ht After you finish adding support for your device: - Update the supported devices in the `README.md` file. - - Add any new dependencies to `requirements.txt`. + - Add any new dependencies to `requirements_all.txt`. There is no ordering right now, so just add it to the end. - Update the `.coveragerc` file. - Provide some documentation for [home-assistant.io](https://home-assistant.io/). The documentation is handled in a separate [git repository](https://github.com/balloob/home-assistant.io). - Make sure all your code passes Pylint and flake8 (PEP8 and some more) validation. To generate reports, run `pylint homeassistant > pylint.txt` and `flake8 homeassistant --exclude bower_components,external > flake8.txt`. diff --git a/README.md b/README.md index 26bb0b998f5..6b1b1353392 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Examples of devices it can interface it: * Monitoring connected devices to a wireless router: [OpenWrt](https://openwrt.org/), [Tomato](http://www.polarcloud.com/tomato), [Netgear](http://netgear.com), [DD-WRT](http://www.dd-wrt.com/site/index), [TPLink](http://www.tp-link.us/), and [ASUSWRT](http://event.asus.com/2013/nw/ASUSWRT/) * [Philips Hue](http://meethue.com) lights, [WeMo](http://www.belkin.com/us/Products/home-automation/c/wemo-home-automation/) switches, [Edimax](http://www.edimax.com/) switches, [Efergy](https://efergy.com) energy monitoring, RFXtrx sensors, and [Tellstick](http://www.telldus.se/products/tellstick) devices and sensors - * [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast), [Music Player Daemon](http://www.musicpd.org/), [Logitech Squeezebox](https://en.wikipedia.org/wiki/Squeezebox_%28network_music_player%29), and [Kodi (XBMC)](http://kodi.tv/) + * [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast), [Music Player Daemon](http://www.musicpd.org/), [Logitech Squeezebox](https://en.wikipedia.org/wiki/Squeezebox_%28network_music_player%29), [Kodi (XBMC)](http://kodi.tv/), and iTunes (by way of [itunes-api](https://github.com/maddox/itunes-api)) * Support for [ISY994](https://www.universal-devices.com/residential/isy994i-series/) (Insteon and X10 devices), [Z-Wave](http://www.z-wave.com/), [Nest Thermostats](https://nest.com/), [Arduino](https://www.arduino.cc/), [Raspberry Pi](https://www.raspberrypi.org/), and [Modbus](http://www.modbus.org/) * Integrate data from the [Bitcoin](https://bitcoin.org) network, meteorological data from [OpenWeatherMap](http://openweathermap.org/) and [Forecast.io](https://forecast.io/), [Transmission](http://www.transmissionbt.com/), or [SABnzbd](http://sabnzbd.org). * [See full list of supported devices](https://home-assistant.io/components/) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 2641961f5c3..e97ed0c6386 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -95,6 +95,18 @@ def get_arguments(): type=int, default=None, help='Enables daily log rotation and keeps up to the specified days') + parser.add_argument( + '--install-osx', + action='store_true', + help='Installs as a service on OS X and loads on boot.') + parser.add_argument( + '--uninstall-osx', + action='store_true', + help='Uninstalls from OS X.') + parser.add_argument( + '--restart-osx', + action='store_true', + help='Restarts on OS X.') if os.name != "nt": parser.add_argument( '--daemon', @@ -152,6 +164,46 @@ def write_pid(pid_file): sys.exit(1) +def install_osx(): + """ Setup to run via launchd on OS X """ + with os.popen('which hass') as inp: + hass_path = inp.read().strip() + + with os.popen('whoami') as inp: + user = inp.read().strip() + + cwd = os.path.dirname(__file__) + template_path = os.path.join(cwd, 'startup', 'launchd.plist') + + with open(template_path, 'r', encoding='utf-8') as inp: + plist = inp.read() + + plist = plist.replace("$HASS_PATH$", hass_path) + plist = plist.replace("$USER$", user) + + path = os.path.expanduser("~/Library/LaunchAgents/org.homeassistant.plist") + + try: + with open(path, 'w', encoding='utf-8') as outp: + outp.write(plist) + except IOError as err: + print('Unable to write to ' + path, err) + return + + os.popen('launchctl load -w -F ' + path) + + print("Home Assistant has been installed. \ + Open it here: http://localhost:8123") + + +def uninstall_osx(): + """ Unload from launchd on OS X """ + path = os.path.expanduser("~/Library/LaunchAgents/org.homeassistant.plist") + os.popen('launchctl unload ' + path) + + print("Home Assistant has been uninstalled.") + + def main(): """ Starts Home Assistant. """ validate_python() @@ -161,6 +213,18 @@ def main(): config_dir = os.path.join(os.getcwd(), args.config) ensure_config_path(config_dir) + # os x launchd functions + if args.install_osx: + install_osx() + return + if args.uninstall_osx: + uninstall_osx() + return + if args.restart_osx: + uninstall_osx() + install_osx() + return + # daemon functions if args.pid_file: check_pid(args.pid_file) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index ca74f086632..b2e5fa51540 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -123,6 +123,7 @@ def prepare_setup_platform(hass, config, domain, platform_name): # Not found if platform is None: + _LOGGER.error('Unable to find platform %s', platform_path) return None # Already loaded @@ -296,11 +297,15 @@ def process_ha_core_config(hass, config): else: _LOGGER.error('Received invalid time zone %s', time_zone_str) - for key, attr in ((CONF_LATITUDE, 'latitude'), - (CONF_LONGITUDE, 'longitude'), - (CONF_NAME, 'location_name')): + for key, attr, typ in ((CONF_LATITUDE, 'latitude', float), + (CONF_LONGITUDE, 'longitude', float), + (CONF_NAME, 'location_name', str)): if key in config: - setattr(hac, attr, config[key]) + try: + setattr(hac, attr, typ(config[key])) + except ValueError: + _LOGGER.error('Received invalid %s value for %s: %s', + typ.__name__, key, attr) set_time_zone(config.get(CONF_TIME_ZONE)) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py new file mode 100644 index 00000000000..bf68e35ffe3 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -0,0 +1,108 @@ +""" +homeassistant.components.alarm_control_panel +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Component to interface with a alarm control panel. +""" +import logging +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.components import verisure +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY) + +DOMAIN = 'alarm_control_panel' +DEPENDENCIES = [] +SCAN_INTERVAL = 30 + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +# Maps discovered services to their platforms +DISCOVERY_PLATFORMS = { + verisure.DISCOVER_SENSORS: 'verisure' +} + +SERVICE_TO_METHOD = { + SERVICE_ALARM_DISARM: 'alarm_disarm', + SERVICE_ALARM_ARM_HOME: 'alarm_arm_home', + SERVICE_ALARM_ARM_AWAY: 'alarm_arm_away', +} + +ATTR_CODE = 'code' + +ATTR_TO_PROPERTY = [ + ATTR_CODE, +] + + +def setup(hass, config): + """ Track states and offer events for sensors. """ + component = EntityComponent( + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL, + DISCOVERY_PLATFORMS) + + component.setup(config) + + def alarm_service_handler(service): + """ Maps services to methods on Alarm. """ + target_alarms = component.extract_from_service(service) + + if ATTR_CODE not in service.data: + return + + code = service.data[ATTR_CODE] + + method = SERVICE_TO_METHOD[service.service] + + for alarm in target_alarms: + getattr(alarm, method)(code) + + for service in SERVICE_TO_METHOD: + hass.services.register(DOMAIN, service, alarm_service_handler) + + return True + + +def alarm_disarm(hass, code, entity_id=None): + """ Send the alarm the command for disarm. """ + data = {ATTR_CODE: code} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_ALARM_DISARM, data) + + +def alarm_arm_home(hass, code, entity_id=None): + """ Send the alarm the command for arm home. """ + data = {ATTR_CODE: code} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_ALARM_ARM_HOME, data) + + +def alarm_arm_away(hass, code, entity_id=None): + """ Send the alarm the command for arm away. """ + data = {ATTR_CODE: code} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data) + + +class AlarmControlPanel(Entity): + """ ABC for alarm control devices. """ + def alarm_disarm(self, code): + """ Send disarm command. """ + raise NotImplementedError() + + def alarm_arm_home(self, code): + """ Send arm home command. """ + raise NotImplementedError() + + def alarm_arm_away(self, code): + """ Send arm away command. """ + raise NotImplementedError() diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py new file mode 100644 index 00000000000..f19cdc102d2 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -0,0 +1,88 @@ +""" +homeassistant.components.alarm_control_panel.verisure +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Interfaces with Verisure alarm control panel. +""" +import logging + +import homeassistant.components.verisure as verisure +import homeassistant.components.alarm_control_panel as alarm + +from homeassistant.const import ( + STATE_UNKNOWN, + STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Verisure platform. """ + + if not verisure.MY_PAGES: + _LOGGER.error('A connection has not been made to Verisure mypages.') + return False + + alarms = [] + + alarms.extend([ + VerisureAlarm(value) + for value in verisure.get_alarm_status().values() + if verisure.SHOW_ALARM + ]) + + add_devices(alarms) + + +class VerisureAlarm(alarm.AlarmControlPanel): + """ represents a Verisure alarm status within home assistant. """ + + def __init__(self, alarm_status): + self._id = alarm_status.id + self._device = verisure.MY_PAGES.DEVICE_ALARM + self._state = STATE_UNKNOWN + + @property + def name(self): + """ Returns the name of the device. """ + return 'Alarm {}'.format(self._id) + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + def update(self): + ''' update alarm status ''' + verisure.update() + + if verisure.STATUS[self._device][self._id].status == 'unarmed': + self._state = STATE_ALARM_DISARMED + elif verisure.STATUS[self._device][self._id].status == 'armedhome': + self._state = STATE_ALARM_ARMED_HOME + elif verisure.STATUS[self._device][self._id].status == 'armedaway': + self._state = STATE_ALARM_ARMED_AWAY + elif verisure.STATUS[self._device][self._id].status != 'pending': + _LOGGER.error( + 'Unknown alarm state %s', + verisure.STATUS[self._device][self._id].status) + + def alarm_disarm(self, code): + """ Send disarm command. """ + verisure.MY_PAGES.set_alarm_status( + code, + verisure.MY_PAGES.ALARM_DISARMED) + _LOGGER.warning('disarming') + + def alarm_arm_home(self, code): + """ Send arm home command. """ + verisure.MY_PAGES.set_alarm_status( + code, + verisure.MY_PAGES.ALARM_ARMED_HOME) + _LOGGER.warning('arming home') + + def alarm_arm_away(self, code): + """ Send arm away command. """ + verisure.MY_PAGES.set_alarm_status( + code, + verisure.MY_PAGES.ALARM_ARMED_AWAY) + _LOGGER.warning('arming away') diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 8dcb158dea4..b734728e59b 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -7,68 +7,217 @@ Allows to setup simple automation rules via the config file. import logging from homeassistant.bootstrap import prepare_setup_platform -from homeassistant.helpers import config_per_platform from homeassistant.util import split_entity_id -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.components import logbook -DOMAIN = "automation" +DOMAIN = 'automation' -DEPENDENCIES = ["group"] +DEPENDENCIES = ['group'] -CONF_ALIAS = "alias" -CONF_SERVICE = "execute_service" -CONF_SERVICE_ENTITY_ID = "service_entity_id" -CONF_SERVICE_DATA = "service_data" +CONF_ALIAS = 'alias' +CONF_SERVICE = 'service' +CONF_SERVICE_ENTITY_ID = 'entity_id' +CONF_SERVICE_DATA = 'data' + +CONF_CONDITION = 'condition' +CONF_ACTION = 'action' +CONF_TRIGGER = 'trigger' +CONF_CONDITION_TYPE = 'condition_type' + +CONDITION_USE_TRIGGER_VALUES = 'use_trigger_values' +CONDITION_TYPE_AND = 'and' +CONDITION_TYPE_OR = 'or' + +DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND _LOGGER = logging.getLogger(__name__) def setup(hass, config): """ Sets up automation. """ - success = False + config_key = DOMAIN + found = 1 - for p_type, p_config in config_per_platform(config, DOMAIN, _LOGGER): - platform = prepare_setup_platform(hass, config, DOMAIN, p_type) + while config_key in config: + # check for one block syntax + if isinstance(config[config_key], dict): + config_block = _migrate_old_config(config[config_key]) + name = config_block.get(CONF_ALIAS, config_key) + _setup_automation(hass, config_block, name, config) - if platform is None: - _LOGGER.error("Unknown automation platform specified: %s", p_type) - continue + # check for multiple block syntax + elif isinstance(config[config_key], list): + for list_no, config_block in enumerate(config[config_key]): + name = config_block.get(CONF_ALIAS, + "{}, {}".format(config_key, list_no)) + _setup_automation(hass, config_block, name, config) - if platform.register(hass, p_config, _get_action(hass, p_config)): - _LOGGER.info( - "Initialized %s rule %s", p_type, p_config.get(CONF_ALIAS, "")) - success = True + # any scalar value is incorrect else: - _LOGGER.error( - "Error setting up rule %s", p_config.get(CONF_ALIAS, "")) + _LOGGER.error('Error in config in section %s.', config_key) - return success + found += 1 + config_key = "{} {}".format(DOMAIN, found) + + return True -def _get_action(hass, config): +def _setup_automation(hass, config_block, name, config): + """ Setup one instance of automation """ + + action = _get_action(hass, config_block.get(CONF_ACTION, {}), name) + + if action is None: + return False + + if CONF_CONDITION in config_block or CONF_CONDITION_TYPE in config_block: + action = _process_if(hass, config, config_block, action) + + if action is None: + return False + + _process_trigger(hass, config, config_block.get(CONF_TRIGGER, []), name, + action) + return True + + +def _get_action(hass, config, name): """ Return an action based on a config. """ + if CONF_SERVICE not in config: + _LOGGER.error('Error setting up %s, no action specified.', name) + return None + def action(): """ Action to be executed. """ - _LOGGER.info("Executing rule %s", config.get(CONF_ALIAS, "")) + _LOGGER.info('Executing %s', name) + logbook.log_entry(hass, name, 'has been triggered', DOMAIN) - if CONF_SERVICE in config: - domain, service = split_entity_id(config[CONF_SERVICE]) + domain, service = split_entity_id(config[CONF_SERVICE]) + service_data = config.get(CONF_SERVICE_DATA, {}) - service_data = config.get(CONF_SERVICE_DATA, {}) + if not isinstance(service_data, dict): + _LOGGER.error("%s should be a dictionary", CONF_SERVICE_DATA) + service_data = {} - if not isinstance(service_data, dict): - _LOGGER.error("%s should be a dictionary", CONF_SERVICE_DATA) - service_data = {} + if CONF_SERVICE_ENTITY_ID in config: + try: + service_data[ATTR_ENTITY_ID] = \ + config[CONF_SERVICE_ENTITY_ID].split(",") + except AttributeError: + service_data[ATTR_ENTITY_ID] = \ + config[CONF_SERVICE_ENTITY_ID] - if CONF_SERVICE_ENTITY_ID in config: - try: - service_data[ATTR_ENTITY_ID] = \ - config[CONF_SERVICE_ENTITY_ID].split(",") - except AttributeError: - service_data[ATTR_ENTITY_ID] = \ - config[CONF_SERVICE_ENTITY_ID] - - hass.services.call(domain, service, service_data) + hass.services.call(domain, service, service_data) return action + + +def _migrate_old_config(config): + """ Migrate old config to new. """ + if CONF_PLATFORM not in config: + return config + + _LOGGER.warning( + 'You are using an old configuration format. Please upgrade: ' + 'https://home-assistant.io/components/automation.html') + + new_conf = { + CONF_TRIGGER: dict(config), + CONF_CONDITION: config.get('if', []), + CONF_ACTION: dict(config), + } + + for cat, key, new_key in (('trigger', 'mqtt_topic', 'topic'), + ('trigger', 'mqtt_payload', 'payload'), + ('trigger', 'state_entity_id', 'entity_id'), + ('trigger', 'state_before', 'before'), + ('trigger', 'state_after', 'after'), + ('trigger', 'state_to', 'to'), + ('trigger', 'state_from', 'from'), + ('trigger', 'state_hours', 'hours'), + ('trigger', 'state_minutes', 'minutes'), + ('trigger', 'state_seconds', 'seconds'), + ('action', 'execute_service', 'service'), + ('action', 'service_entity_id', 'entity_id'), + ('action', 'service_data', 'data')): + if key in new_conf[cat]: + new_conf[cat][new_key] = new_conf[cat].pop(key) + + return new_conf + + +def _process_if(hass, config, p_config, action): + """ Processes if checks. """ + + cond_type = p_config.get(CONF_CONDITION_TYPE, + DEFAULT_CONDITION_TYPE).lower() + + if_configs = p_config.get(CONF_CONDITION) + use_trigger = if_configs == CONDITION_USE_TRIGGER_VALUES + + if use_trigger: + if_configs = p_config[CONF_TRIGGER] + + if isinstance(if_configs, dict): + if_configs = [if_configs] + + checks = [] + for if_config in if_configs: + platform = _resolve_platform('if_action', hass, config, + if_config.get(CONF_PLATFORM)) + if platform is None: + continue + + check = platform.if_action(hass, if_config) + + # Invalid conditions are allowed if we base it on trigger + if check is None and not use_trigger: + return None + + checks.append(check) + + if cond_type == CONDITION_TYPE_AND: + def if_action(): + """ AND all conditions. """ + if all(check() for check in checks): + action() + else: + def if_action(): + """ OR all conditions. """ + if any(check() for check in checks): + action() + + return if_action + + +def _process_trigger(hass, config, trigger_configs, name, action): + """ Setup triggers. """ + if isinstance(trigger_configs, dict): + trigger_configs = [trigger_configs] + + for conf in trigger_configs: + platform = _resolve_platform('trigger', hass, config, + conf.get(CONF_PLATFORM)) + if platform is None: + continue + + if platform.trigger(hass, conf, action): + _LOGGER.info("Initialized rule %s", name) + else: + _LOGGER.error("Error setting up rule %s", name) + + +def _resolve_platform(method, hass, config, platform): + """ Find automation platform. """ + if platform is None: + return None + platform = prepare_setup_platform(hass, config, DOMAIN, platform) + + if platform is None or not hasattr(platform, method): + _LOGGER.error("Unknown automation platform specified for %s: %s", + method, platform) + return None + + return platform diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 8a78f20d485..c5b0ee47923 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -12,7 +12,7 @@ CONF_EVENT_DATA = "event_data" _LOGGER = logging.getLogger(__name__) -def register(hass, config, action): +def trigger(hass, config, action): """ Listen for events based on config. """ event_type = config.get(CONF_EVENT_TYPE) @@ -20,11 +20,12 @@ def register(hass, config, action): _LOGGER.error("Missing configuration key %s", CONF_EVENT_TYPE) return False - event_data = config.get(CONF_EVENT_DATA, {}) + event_data = config.get(CONF_EVENT_DATA) def handle_event(event): """ Listens for events and calls the action when data matches. """ - if event_data == event.data: + if not event_data or all(val == event.data.get(key) for key, val + in event_data.items()): action() hass.bus.listen(event_type, handle_event) diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 6b4e6b1e039..3f85792f907 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -10,11 +10,11 @@ import homeassistant.components.mqtt as mqtt DEPENDENCIES = ['mqtt'] -CONF_TOPIC = 'mqtt_topic' -CONF_PAYLOAD = 'mqtt_payload' +CONF_TOPIC = 'topic' +CONF_PAYLOAD = 'payload' -def register(hass, config, action): +def trigger(hass, config, action): """ Listen for state changes based on `config`. """ topic = config.get(CONF_TOPIC) payload = config.get(CONF_PAYLOAD) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py new file mode 100644 index 00000000000..7e014213d62 --- /dev/null +++ b/homeassistant/components/automation/numeric_state.py @@ -0,0 +1,91 @@ +""" +homeassistant.components.automation.numeric_state +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Offers numeric state listening automation rules. +""" +import logging + +from homeassistant.helpers.event import track_state_change + + +CONF_ENTITY_ID = "entity_id" +CONF_BELOW = "below" +CONF_ABOVE = "above" + +_LOGGER = logging.getLogger(__name__) + + +def trigger(hass, config, action): + """ Listen for state changes based on `config`. """ + entity_id = config.get(CONF_ENTITY_ID) + + if entity_id is None: + _LOGGER.error("Missing configuration key %s", CONF_ENTITY_ID) + return False + + below = config.get(CONF_BELOW) + above = config.get(CONF_ABOVE) + + if below is None and above is None: + _LOGGER.error("Missing configuration key." + " One of %s or %s is required", + CONF_BELOW, CONF_ABOVE) + return False + + # pylint: disable=unused-argument + def state_automation_listener(entity, from_s, to_s): + """ Listens for state changes and calls action. """ + + # Fire action if we go from outside range into range + if _in_range(to_s.state, above, below) and \ + (from_s is None or not _in_range(from_s.state, above, below)): + action() + + track_state_change( + hass, entity_id, state_automation_listener) + + return True + + +def if_action(hass, config): + """ Wraps action method with state based condition. """ + + entity_id = config.get(CONF_ENTITY_ID) + + if entity_id is None: + _LOGGER.error("Missing configuration key %s", CONF_ENTITY_ID) + return None + + below = config.get(CONF_BELOW) + above = config.get(CONF_ABOVE) + + if below is None and above is None: + _LOGGER.error("Missing configuration key." + " One of %s or %s is required", + CONF_BELOW, CONF_ABOVE) + return None + + def if_numeric_state(): + """ Test numeric state condition. """ + state = hass.states.get(entity_id) + return state is not None and _in_range(state.state, above, below) + + return if_numeric_state + + +def _in_range(value, range_start, range_end): + """ Checks if value is inside the range """ + + try: + value = float(value) + except ValueError: + _LOGGER.warn("Missing value in numeric check") + return False + + if range_start is not None and range_end is not None: + return float(range_start) <= value < float(range_end) + elif range_end is not None: + return value < float(range_end) + else: + return float(range_start) <= value diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index ba96debf9ac..8baa0a01d46 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -10,22 +10,23 @@ from homeassistant.helpers.event import track_state_change from homeassistant.const import MATCH_ALL -CONF_ENTITY_ID = "state_entity_id" -CONF_FROM = "state_from" -CONF_TO = "state_to" +CONF_ENTITY_ID = "entity_id" +CONF_FROM = "from" +CONF_TO = "to" +CONF_STATE = "state" -def register(hass, config, action): +def trigger(hass, config, action): """ Listen for state changes based on `config`. """ entity_id = config.get(CONF_ENTITY_ID) if entity_id is None: logging.getLogger(__name__).error( - "Missing configuration key %s", CONF_ENTITY_ID) + "Missing trigger configuration key %s", CONF_ENTITY_ID) return False from_state = config.get(CONF_FROM, MATCH_ALL) - to_state = config.get(CONF_TO, MATCH_ALL) + to_state = config.get(CONF_TO) or config.get(CONF_STATE) or MATCH_ALL def state_automation_listener(entity, from_s, to_s): """ Listens for state changes and calls action. """ @@ -35,3 +36,23 @@ def register(hass, config, action): hass, entity_id, state_automation_listener, from_state, to_state) return True + + +def if_action(hass, config): + """ Wraps action method with state based condition. """ + entity_id = config.get(CONF_ENTITY_ID) + state = config.get(CONF_STATE) + + if entity_id is None or state is None: + logging.getLogger(__name__).error( + "Missing if-condition configuration key %s or %s", CONF_ENTITY_ID, + CONF_STATE) + return None + + state = str(state) + + def if_state(): + """ Test if condition. """ + return hass.states.is_state(entity_id, state) + + return if_state diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py new file mode 100644 index 00000000000..103df6c9b39 --- /dev/null +++ b/homeassistant/components/automation/sun.py @@ -0,0 +1,103 @@ +""" +homeassistant.components.automation.sun +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Offers sun based automation rules. +""" +import logging +from datetime import timedelta + +from homeassistant.components import sun +from homeassistant.helpers.event import track_point_in_utc_time +import homeassistant.util.dt as dt_util + +DEPENDENCIES = ['sun'] + +CONF_OFFSET = 'offset' +CONF_EVENT = 'event' + +EVENT_SUNSET = 'sunset' +EVENT_SUNRISE = 'sunrise' + +_LOGGER = logging.getLogger(__name__) + + +def trigger(hass, config, action): + """ Listen for events based on config. """ + event = config.get(CONF_EVENT) + + if event is None: + _LOGGER.error("Missing configuration key %s", CONF_EVENT) + return False + + event = event.lower() + if event not in (EVENT_SUNRISE, EVENT_SUNSET): + _LOGGER.error("Invalid value for %s: %s", CONF_EVENT, event) + return False + + if CONF_OFFSET in config: + raw_offset = config.get(CONF_OFFSET) + + negative_offset = False + if raw_offset.startswith('-'): + negative_offset = True + raw_offset = raw_offset[1:] + + try: + (hour, minute, second) = [int(x) for x in raw_offset.split(':')] + except ValueError: + _LOGGER.error('Could not parse offset %s', raw_offset) + return False + + offset = timedelta(hours=hour, minutes=minute, seconds=second) + + if negative_offset: + offset *= -1 + else: + offset = timedelta(0) + + # Do something to call action + if event == EVENT_SUNRISE: + trigger_sunrise(hass, action, offset) + else: + trigger_sunset(hass, action, offset) + + return True + + +def trigger_sunrise(hass, action, offset): + """ Trigger action at next sun rise. """ + def next_rise(): + """ Returns next sunrise. """ + next_time = sun.next_rising_utc(hass) + offset + + while next_time < dt_util.utcnow(): + next_time = next_time + timedelta(days=1) + + return next_time + + def sunrise_automation_listener(now): + """ Called when it's time for action. """ + track_point_in_utc_time(hass, sunrise_automation_listener, next_rise()) + action() + + track_point_in_utc_time(hass, sunrise_automation_listener, next_rise()) + + +def trigger_sunset(hass, action, offset): + """ Trigger action at next sun set. """ + def next_set(): + """ Returns next sunrise. """ + next_time = sun.next_setting_utc(hass) + offset + + while next_time < dt_util.utcnow(): + next_time = next_time + timedelta(days=1) + + return next_time + + def sunset_automation_listener(now): + """ Called when it's time for action. """ + track_point_in_utc_time(hass, sunset_automation_listener, next_set()) + action() + + track_point_in_utc_time(hass, sunset_automation_listener, next_set()) diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 77bd40a7a41..1d97ccc135d 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -4,19 +4,41 @@ homeassistant.components.automation.time Offers time listening automation rules. """ +import logging + from homeassistant.util import convert +import homeassistant.util.dt as dt_util from homeassistant.helpers.event import track_time_change -CONF_HOURS = "time_hours" -CONF_MINUTES = "time_minutes" -CONF_SECONDS = "time_seconds" +CONF_HOURS = "hours" +CONF_MINUTES = "minutes" +CONF_SECONDS = "seconds" +CONF_BEFORE = "before" +CONF_AFTER = "after" +CONF_WEEKDAY = "weekday" + +WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] + +_LOGGER = logging.getLogger(__name__) -def register(hass, config, action): +def trigger(hass, config, action): """ Listen for state changes based on `config`. """ - hours = convert(config.get(CONF_HOURS), int) - minutes = convert(config.get(CONF_MINUTES), int) - seconds = convert(config.get(CONF_SECONDS), int) + if CONF_AFTER in config: + after = dt_util.parse_time_str(config[CONF_AFTER]) + if after is None: + _error_time(config[CONF_AFTER], CONF_AFTER) + return False + hours, minutes, seconds = after.hour, after.minute, after.second + elif (CONF_HOURS in config or CONF_MINUTES in config + or CONF_SECONDS in config): + hours = convert(config.get(CONF_HOURS), int) + minutes = convert(config.get(CONF_MINUTES), int) + seconds = convert(config.get(CONF_SECONDS), int) + else: + _LOGGER.error('One of %s, %s, %s OR %s needs to be specified', + CONF_HOURS, CONF_MINUTES, CONF_SECONDS, CONF_AFTER) + return False def time_automation_listener(now): """ Listens for time changes and calls action. """ @@ -26,3 +48,58 @@ def register(hass, config, action): hour=hours, minute=minutes, second=seconds) return True + + +def if_action(hass, config): + """ Wraps action method with time based condition. """ + before = config.get(CONF_BEFORE) + after = config.get(CONF_AFTER) + weekday = config.get(CONF_WEEKDAY) + + if before is None and after is None and weekday is None: + logging.getLogger(__name__).error( + "Missing if-condition configuration key %s, %s or %s", + CONF_BEFORE, CONF_AFTER, CONF_WEEKDAY) + return None + + if before is not None: + before = dt_util.parse_time_str(before) + if before is None: + _error_time(before, CONF_BEFORE) + return None + + if after is not None: + after = dt_util.parse_time_str(after) + if after is None: + _error_time(after, CONF_AFTER) + return None + + def time_if(): + """ Validate time based if-condition """ + now = dt_util.now() + if before is not None and now > now.replace(hour=before.hour, + minute=before.minute): + return False + + if after is not None and now < now.replace(hour=after.hour, + minute=after.minute): + return False + + if weekday is not None: + now_weekday = WEEKDAYS[now.weekday()] + + if isinstance(weekday, str) and weekday != now_weekday or \ + now_weekday not in weekday: + return False + + return True + + return time_if + + +def _error_time(value, key): + """ Helper method to print error. """ + _LOGGER.error( + "Received invalid value for '%s': %s", key, value) + if isinstance(value, int): + _LOGGER.error('Make sure you wrap time values in quotes') diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py new file mode 100644 index 00000000000..78fd0f4d2e1 --- /dev/null +++ b/homeassistant/components/camera/foscam.py @@ -0,0 +1,105 @@ +""" +homeassistant.components.camera.foscam +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This component provides basic support for Foscam IP cameras. + +As part of the basic support the following features will be provided: +-MJPEG video streaming + +To use this component, add the following to your configuration.yaml file. + +camera: + platform: foscam + name: Door Camera + ip: 192.168.0.123 + port: 88 + username: YOUR_USERNAME + password: YOUR_PASSWORD + +Variables: + +ip +*Required +The IP address of your Foscam device. + +username +*Required +The username of a visitor or operator of your camera. Oddly admin accounts +don't seem to have access to take snapshots. + +password +*Required +The password for accessing your camera. + +name +*Optional +This parameter allows you to override the name of your camera in homeassistant. + +port +*Optional +The port that the camera is running on. The default is 88. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.foscam.html +""" +import logging +from homeassistant.helpers import validate_config +from homeassistant.components.camera import DOMAIN +from homeassistant.components.camera import Camera +import requests +import re + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Adds a Foscam IP Camera. """ + if not validate_config({DOMAIN: config}, + {DOMAIN: ['username', 'password', 'ip']}, _LOGGER): + return None + + add_devices_callback([FoscamCamera(config)]) + + +# pylint: disable=too-many-instance-attributes +class FoscamCamera(Camera): + """ An implementation of a Foscam IP camera. """ + + def __init__(self, device_info): + super(FoscamCamera, self).__init__() + + ip_address = device_info.get('ip') + port = device_info.get('port', 88) + + self._base_url = 'http://' + ip_address + ':' + str(port) + '/' + self._username = device_info.get('username') + self._password = device_info.get('password') + self._snap_picture_url = self._base_url \ + + 'cgi-bin/CGIProxy.fcgi?cmd=snapPicture&usr=' \ + + self._username + '&pwd=' + self._password + self._name = device_info.get('name', 'Foscam Camera') + + _LOGGER.info('Using the following URL for %s: %s', + self._name, self._snap_picture_url) + + def camera_image(self): + """ Return a still image reponse from the camera. """ + + # send the request to snap a picture + response = requests.get(self._snap_picture_url) + + # parse the response to find the image file name + + pattern = re.compile('src="[.][.]/(.*[.]jpg)"') + filename = pattern.search(response.content.decode("utf-8")).group(1) + + # send request for the image + response = requests.get(self._base_url + filename) + + return response.content + + @property + def name(self): + """ Return the name of this device. """ + return self._name diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 5dc91b28370..beb7a63b47c 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -17,7 +17,7 @@ DOMAIN = "demo" DEPENDENCIES = ['introduction', 'conversation'] COMPONENTS_WITH_DEMO_PLATFORM = [ - 'switch', 'light', 'thermostat', 'sensor', 'media_player', 'notify'] + 'switch', 'light', 'sensor', 'thermostat', 'media_player', 'notify'] def setup(hass, config): diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index fd706b3d73a..27e9417ab5b 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,52 +1,92 @@ """ -homeassistant.components.tracker -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +homeassistant.components.device_tracker +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides functionality to keep track of devices. + +device_tracker: + platform: netgear + + # Optional + + # How many seconds to wait after not seeing device to consider it not home + consider_home: 180 + + # Seconds between each scan + interval_seconds: 12 + + # New found devices auto found + track_new_devices: yes + + # Maximum distance from home we consider people home + range_home: 100 """ -import logging -import threading -import os +# pylint: disable=too-many-instance-attributes, too-many-arguments +# pylint: disable=too-many-locals import csv from datetime import timedelta +import logging +import os +import threading -from homeassistant.helpers import validate_config -from homeassistant.helpers.entity import _OVERWRITE +from homeassistant.bootstrap import prepare_setup_platform +from homeassistant.components import discovery, group +from homeassistant.config import load_yaml_config_file +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_per_platform +from homeassistant.helpers.entity import Entity import homeassistant.util as util import homeassistant.util.dt as dt_util -from homeassistant.bootstrap import prepare_setup_platform from homeassistant.helpers.event import track_utc_time_change from homeassistant.const import ( - STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, - CONF_PLATFORM, DEVICE_DEFAULT_NAME) -from homeassistant.components import group + ATTR_ENTITY_PICTURE, DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME) DOMAIN = "device_tracker" DEPENDENCIES = [] -SERVICE_DEVICE_TRACKER_RELOAD = "reload_devices_csv" - GROUP_NAME_ALL_DEVICES = 'all devices' ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format('all_devices') ENTITY_ID_FORMAT = DOMAIN + '.{}' -# After how much time do we consider a device not home if -# it does not show up on scans -TIME_DEVICE_NOT_FOUND = timedelta(minutes=3) +CSV_DEVICES = "known_devices.csv" +YAML_DEVICES = 'known_devices.yaml' -# Filename to save known devices to -KNOWN_DEVICES_FILE = "known_devices.csv" +CONF_TRACK_NEW = "track_new_devices" +DEFAULT_CONF_TRACK_NEW = True -CONF_SECONDS = "interval_seconds" +CONF_CONSIDER_HOME = 'consider_home' +DEFAULT_CONSIDER_HOME = 180 # seconds -DEFAULT_CONF_SECONDS = 12 +CONF_SCAN_INTERVAL = "interval_seconds" +DEFAULT_SCAN_INTERVAL = 12 -TRACK_NEW_DEVICES = "track_new_devices" +CONF_AWAY_HIDE = 'hide_if_away' +DEFAULT_AWAY_HIDE = False +CONF_HOME_RANGE = 'home_range' +DEFAULT_HOME_RANGE = 100 + +SERVICE_SEE = 'see' + +ATTR_LATITUDE = 'latitude' +ATTR_LONGITUDE = 'longitude' +ATTR_MAC = 'mac' +ATTR_DEV_ID = 'dev_id' +ATTR_HOST_NAME = 'host_name' +ATTR_LOCATION_NAME = 'location_name' +ATTR_GPS = 'gps' +ATTR_GPS_ACCURACY = 'gps_accuracy' +ATTR_BATTERY = 'battery' + +DISCOVERY_PLATFORMS = { + discovery.SERVICE_NETGEAR: 'netgear', +} _LOGGER = logging.getLogger(__name__) +# pylint: disable=too-many-arguments + def is_on(hass, entity_id=None): """ Returns if any or specified device is home. """ @@ -55,293 +95,343 @@ def is_on(hass, entity_id=None): return hass.states.is_state(entity, STATE_HOME) +def see(hass, mac=None, dev_id=None, host_name=None, location_name=None, + gps=None, gps_accuracy=None, battery=None): + """ Call service to notify you see device. """ + data = {key: value for key, value in + ((ATTR_MAC, mac), + (ATTR_DEV_ID, dev_id), + (ATTR_HOST_NAME, host_name), + (ATTR_LOCATION_NAME, location_name), + (ATTR_GPS, gps)) if value is not None} + hass.services.call(DOMAIN, SERVICE_SEE, data) + + def setup(hass, config): - """ Sets up the device tracker. """ + """ Setup device tracker """ + yaml_path = hass.config.path(YAML_DEVICES) + csv_path = hass.config.path(CSV_DEVICES) + if os.path.isfile(csv_path) and not os.path.isfile(yaml_path) and \ + convert_csv_config(csv_path, yaml_path): + os.remove(csv_path) - if not validate_config(config, {DOMAIN: [CONF_PLATFORM]}, _LOGGER): - return False + conf = config.get(DOMAIN, {}) + consider_home = timedelta( + seconds=util.convert(conf.get(CONF_CONSIDER_HOME), int, + DEFAULT_CONSIDER_HOME)) + track_new = util.convert(conf.get(CONF_TRACK_NEW), bool, + DEFAULT_CONF_TRACK_NEW) + home_range = util.convert(conf.get(CONF_HOME_RANGE), int, + DEFAULT_HOME_RANGE) - tracker_type = config[DOMAIN].get(CONF_PLATFORM) + devices = load_config(yaml_path, hass, consider_home, home_range) + tracker = DeviceTracker(hass, consider_home, track_new, home_range, + devices) - tracker_implementation = \ - prepare_setup_platform(hass, config, DOMAIN, tracker_type) - - if tracker_implementation is None: - _LOGGER.error("Unknown device_tracker type specified: %s.", - tracker_type) - - return False - - device_scanner = tracker_implementation.get_scanner(hass, config) - - if device_scanner is None: - _LOGGER.error("Failed to initialize device scanner: %s", - tracker_type) - - return False - - seconds = util.convert(config[DOMAIN].get(CONF_SECONDS), int, - DEFAULT_CONF_SECONDS) - - track_new_devices = config[DOMAIN].get(TRACK_NEW_DEVICES) or False - _LOGGER.info("Tracking new devices: %s", track_new_devices) - - tracker = DeviceTracker(hass, device_scanner, seconds, track_new_devices) - - # We only succeeded if we got to parse the known devices file - return not tracker.invalid_known_devices_file - - -class DeviceTracker(object): - """ Class that tracks which devices are home and which are not. """ - - def __init__(self, hass, device_scanner, seconds, track_new_devices): - self.hass = hass - - self.device_scanner = device_scanner - - self.lock = threading.Lock() - - # Do we track new devices by default? - self.track_new_devices = track_new_devices - - # Dictionary to keep track of known devices and devices we track - self.tracked = {} - self.untracked_devices = set() - - # Did we encounter an invalid known devices file - self.invalid_known_devices_file = False - - # Wrap it in a func instead of lambda so it can be identified in - # the bus by its __name__ attribute. - def update_device_state(now): - """ Triggers update of the device states. """ - self.update_devices(now) - - dev_group = group.Group( - hass, GROUP_NAME_ALL_DEVICES, user_defined=False) - - def reload_known_devices_service(service): - """ Reload known devices file. """ - self._read_known_devices_file() - - self.update_devices(dt_util.utcnow()) - - dev_group.update_tracked_entity_ids(self.device_entity_ids) - - reload_known_devices_service(None) - - if self.invalid_known_devices_file: - return - - seconds = range(0, 60, seconds) - - _LOGGER.info("Device tracker interval second=%s", seconds) - track_utc_time_change(hass, update_device_state, second=seconds) - - hass.services.register(DOMAIN, - SERVICE_DEVICE_TRACKER_RELOAD, - reload_known_devices_service) - - @property - def device_entity_ids(self): - """ Returns a set containing all device entity ids - that are being tracked. """ - return set(device['entity_id'] for device in self.tracked.values()) - - def _update_state(self, now, device, is_home): - """ Update the state of a device. """ - dev_info = self.tracked[device] - - if is_home: - # Update last seen if at home - dev_info['last_seen'] = now - else: - # State remains at home if it has been seen in the last - # TIME_DEVICE_NOT_FOUND - is_home = now - dev_info['last_seen'] < TIME_DEVICE_NOT_FOUND - - state = STATE_HOME if is_home else STATE_NOT_HOME - - # overwrite properties that have been set in the config file - attr = dict(dev_info['state_attr']) - attr.update(_OVERWRITE.get(dev_info['entity_id'], {})) - - self.hass.states.set( - dev_info['entity_id'], state, attr) - - def update_devices(self, now): - """ Update device states based on the found devices. """ - if not self.lock.acquire(False): + def setup_platform(p_type, p_config, disc_info=None): + """ Setup a device tracker platform. """ + platform = prepare_setup_platform(hass, config, DOMAIN, p_type) + if platform is None: return try: - found_devices = set(dev.upper() for dev in - self.device_scanner.scan_devices()) + if hasattr(platform, 'get_scanner'): + scanner = platform.get_scanner(hass, {DOMAIN: p_config}) - for device in self.tracked: - is_home = device in found_devices + if scanner is None: + _LOGGER.error('Error setting up platform %s', p_type) + return - self._update_state(now, device, is_home) + setup_scanner_platform(hass, p_config, scanner, tracker.see) + return - if is_home: - found_devices.remove(device) + if not platform.setup_scanner(hass, p_config, tracker.see): + _LOGGER.error('Error setting up platform %s', p_type) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error setting up platform %s', p_type) - # Did we find any devices that we didn't know about yet? - new_devices = found_devices - self.untracked_devices + for p_type, p_config in \ + config_per_platform(config, DOMAIN, _LOGGER): + setup_platform(p_type, p_config) - if new_devices: - if not self.track_new_devices: - self.untracked_devices.update(new_devices) + def device_tracker_discovered(service, info): + """ Called when a device tracker platform is discovered. """ + setup_platform(DISCOVERY_PLATFORMS[service], {}, info) - self._update_known_devices_file(new_devices) - finally: - self.lock.release() + discovery.listen(hass, DISCOVERY_PLATFORMS.keys(), + device_tracker_discovered) - # pylint: disable=too-many-branches - def _read_known_devices_file(self): - """ Parse and process the known devices file. """ - known_dev_path = self.hass.config.path(KNOWN_DEVICES_FILE) + def update_stale(now): + """ Clean up stale devices. """ + tracker.update_stale(now) + track_utc_time_change(hass, update_stale, second=range(0, 60, 5)) - # Return if no known devices file exists - if not os.path.isfile(known_dev_path): + tracker.setup_group() + + def see_service(call): + """ Service to see a device. """ + args = {key: value for key, value in call.data.items() if key in + (ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_LOCATION_NAME, + ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY)} + tracker.see(**args) + + hass.services.register(DOMAIN, SERVICE_SEE, see_service) + + return True + + +class DeviceTracker(object): + """ Track devices """ + def __init__(self, hass, consider_home, track_new, home_range, devices): + self.hass = hass + self.devices = {dev.dev_id: dev for dev in devices} + self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac} + self.consider_home = consider_home + self.track_new = track_new + self.home_range = home_range + self.lock = threading.Lock() + + for device in devices: + if device.track: + device.update_ha_state() + + self.group = None + + def see(self, mac=None, dev_id=None, host_name=None, location_name=None, + gps=None, gps_accuracy=None, battery=None): + """ Notify device tracker that you see a device. """ + with self.lock: + if mac is None and dev_id is None: + raise HomeAssistantError('Neither mac or device id passed in') + elif mac is not None: + mac = mac.upper() + device = self.mac_to_dev.get(mac) + if not device: + dev_id = util.slugify(host_name or '') or util.slugify(mac) + else: + dev_id = str(dev_id).lower() + device = self.devices.get(dev_id) + + if device: + device.seen(host_name, location_name, gps, gps_accuracy, + battery) + if device.track: + device.update_ha_state() + return + + # If no device can be found, create it + device = Device( + self.hass, self.consider_home, self.home_range, self.track_new, + dev_id, mac, (host_name or dev_id).replace('_', ' ')) + self.devices[dev_id] = device + if mac is not None: + self.mac_to_dev[mac] = device + + device.seen(host_name, location_name, gps, gps_accuracy, battery) + if device.track: + device.update_ha_state() + + # During init, we ignore the group + if self.group is not None: + self.group.update_tracked_entity_ids( + list(self.group.tracking) + [device.entity_id]) + update_config(self.hass.config.path(YAML_DEVICES), dev_id, device) + + def setup_group(self): + """ Initializes group for all tracked devices. """ + entity_ids = (dev.entity_id for dev in self.devices.values() + if dev.track) + self.group = group.setup_group( + self.hass, GROUP_NAME_ALL_DEVICES, entity_ids, False) + + def update_stale(self, now): + """ Update stale devices. """ + with self.lock: + for device in self.devices.values(): + if (device.track and device.last_update_home and + device.stale(now)): + device.update_ha_state(True) + + +class Device(Entity): + """ Tracked device. """ + + host_name = None + location_name = None + gps = None + gps_accuracy = 0 + last_seen = None + battery = None + + # Track if the last update of this device was HOME + last_update_home = False + _state = STATE_NOT_HOME + + def __init__(self, hass, consider_home, home_range, track, dev_id, mac, + name=None, picture=None, away_hide=False): + self.hass = hass + self.entity_id = ENTITY_ID_FORMAT.format(dev_id) + + # Timedelta object how long we consider a device home if it is not + # detected anymore. + self.consider_home = consider_home + + # Distance in meters + self.home_range = home_range + # Device ID + self.dev_id = dev_id + self.mac = mac + + # If we should track this device + self.track = track + + # Configured name + self.config_name = name + + # Configured picture + self.config_picture = picture + self.away_hide = away_hide + + @property + def gps_home(self): + """ Return if device is within range of home. """ + distance = max( + 0, self.hass.config.distance(*self.gps) - self.gps_accuracy) + return self.gps is not None and distance <= self.home_range + + @property + def name(self): + """ Returns the name of the entity. """ + return self.config_name or self.host_name or DEVICE_DEFAULT_NAME + + @property + def state(self): + """ State of the device. """ + return self._state + + @property + def state_attributes(self): + """ Device state attributes. """ + attr = {} + + if self.config_picture: + attr[ATTR_ENTITY_PICTURE] = self.config_picture + + if self.gps: + attr[ATTR_LATITUDE] = self.gps[0] + attr[ATTR_LONGITUDE] = self.gps[1] + attr[ATTR_GPS_ACCURACY] = self.gps_accuracy + + if self.battery: + attr[ATTR_BATTERY] = self.battery + + return attr + + @property + def hidden(self): + """ If device should be hidden. """ + return self.away_hide and self.state != STATE_HOME + + def seen(self, host_name=None, location_name=None, gps=None, + gps_accuracy=0, battery=None): + """ Mark the device as seen. """ + self.last_seen = dt_util.utcnow() + self.host_name = host_name + self.location_name = location_name + self.gps_accuracy = gps_accuracy + self.battery = battery + if gps is None: + self.gps = None + else: + try: + self.gps = tuple(float(val) for val in gps) + except ValueError: + _LOGGER.warning('Could not parse gps value for %s: %s', + self.dev_id, gps) + self.gps = None + self.update() + + def stale(self, now=None): + """ Return if device state is stale. """ + return self.last_seen and \ + (now or dt_util.utcnow()) - self.last_seen > self.consider_home + + def update(self): + """ Update state of entity. """ + if not self.last_seen: return + elif self.location_name: + self._state = self.location_name + elif self.gps is not None: + self._state = STATE_HOME if self.gps_home else STATE_NOT_HOME + elif self.stale(): + self._state = STATE_NOT_HOME + self.last_update_home = False + else: + self._state = STATE_HOME + self.last_update_home = True - self.lock.acquire() - self.untracked_devices.clear() +def convert_csv_config(csv_path, yaml_path): + """ Convert CSV config file format to YAML. """ + used_ids = set() + with open(csv_path) as inp: + for row in csv.DictReader(inp): + dev_id = util.ensure_unique_string( + (util.slugify(row['name']) or DEVICE_DEFAULT_NAME).lower(), + used_ids) + used_ids.add(dev_id) + device = Device(None, None, None, row['track'] == '1', dev_id, + row['device'], row['name'], row['picture']) + update_config(yaml_path, dev_id, device) + return True - with open(known_dev_path) as inp: - # To track which devices need an entity_id assigned - need_entity_id = [] +def load_config(path, hass, consider_home, home_range): + """ Load devices from YAML config file. """ + if not os.path.isfile(path): + return [] + return [ + Device(hass, consider_home, home_range, device.get('track', False), + str(dev_id).lower(), str(device.get('mac')).upper(), + device.get('name'), device.get('picture'), + device.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) + for dev_id, device in load_yaml_config_file(path).items()] - # All devices that are still in this set after we read the CSV file - # have been removed from the file and thus need to be cleaned up. - removed_devices = set(self.tracked.keys()) - try: - for row in csv.DictReader(inp): - device = row['device'].upper() +def setup_scanner_platform(hass, config, scanner, see_device): + """ Helper method to connect scanner-based platform to device tracker. """ + interval = util.convert(config.get(CONF_SCAN_INTERVAL), int, + DEFAULT_SCAN_INTERVAL) - if row['track'] == '1': - if device in self.tracked: - # Device exists - removed_devices.remove(device) - else: - # We found a new device - need_entity_id.append(device) + # Initial scan of each mac we also tell about host name for config + seen = set() - self._track_device(device, row['name']) + def device_tracker_scan(now): + """ Called when interval matches. """ + for mac in scanner.scan_devices(): + if mac in seen: + host_name = None + else: + host_name = scanner.get_device_name(mac) + seen.add(mac) + see_device(mac=mac, host_name=host_name) - # Update state_attr with latest from file - state_attr = { - ATTR_FRIENDLY_NAME: row['name'] - } + track_utc_time_change(hass, device_tracker_scan, second=range(0, 60, + interval)) - if row['picture']: - state_attr[ATTR_ENTITY_PICTURE] = row['picture'] + device_tracker_scan(None) - self.tracked[device]['state_attr'] = state_attr - else: - self.untracked_devices.add(device) +def update_config(path, dev_id, device): + """ Add device to YAML config file. """ + with open(path, 'a') as out: + out.write('\n') + out.write('{}:\n'.format(device.dev_id)) - # Remove existing devices that we no longer track - for device in removed_devices: - entity_id = self.tracked[device]['entity_id'] - - _LOGGER.info("Removing entity %s", entity_id) - - self.hass.states.remove(entity_id) - - self.tracked.pop(device) - - self._generate_entity_ids(need_entity_id) - - if not self.tracked: - _LOGGER.warning( - "No devices to track. Please update %s.", - known_dev_path) - - _LOGGER.info("Loaded devices from %s", known_dev_path) - - except KeyError: - self.invalid_known_devices_file = True - - _LOGGER.warning( - ("Invalid known devices file: %s. " - "We won't update it with new found devices."), - known_dev_path) - - finally: - self.lock.release() - - def _update_known_devices_file(self, new_devices): - """ Add new devices to known devices file. """ - if not self.invalid_known_devices_file: - known_dev_path = self.hass.config.path(KNOWN_DEVICES_FILE) - - try: - # If file does not exist we will write the header too - is_new_file = not os.path.isfile(known_dev_path) - - with open(known_dev_path, 'a') as outp: - _LOGGER.info("Found %d new devices, updating %s", - len(new_devices), known_dev_path) - - writer = csv.writer(outp) - - if is_new_file: - writer.writerow(("device", "name", "track", "picture")) - - for device in new_devices: - # See if the device scanner knows the name - # else defaults to unknown device - name = self.device_scanner.get_device_name(device) or \ - DEVICE_DEFAULT_NAME - - track = 0 - if self.track_new_devices: - self._track_device(device, name) - track = 1 - - writer.writerow((device, name, track, "")) - - if self.track_new_devices: - self._generate_entity_ids(new_devices) - - except IOError: - _LOGGER.exception("Error updating %s with %d new devices", - known_dev_path, len(new_devices)) - - def _track_device(self, device, name): - """ - Add a device to the list of tracked devices. - Does not generate the entity id yet. - """ - default_last_seen = dt_util.utcnow().replace(year=1990) - - self.tracked[device] = { - 'name': name, - 'last_seen': default_last_seen, - 'state_attr': {ATTR_FRIENDLY_NAME: name} - } - - def _generate_entity_ids(self, need_entity_id): - """ Generate entity ids for a list of devices. """ - # Setup entity_ids for the new devices - used_entity_ids = [info['entity_id'] for device, info - in self.tracked.items() - if device not in need_entity_id] - - for device in need_entity_id: - name = self.tracked[device]['name'] - - entity_id = util.ensure_unique_string( - ENTITY_ID_FORMAT.format(util.slugify(name)), - used_entity_ids) - - used_entity_ids.append(entity_id) - - self.tracked[device]['entity_id'] = entity_id + for key, value in (('name', device.name), ('mac', device.mac), + ('picture', device.config_picture), + ('track', 'yes' if device.track else 'no'), + (CONF_AWAY_HIDE, + 'yes' if device.away_hide else 'no')): + out.write(' {}: {}\n'.format(key, '' if value is None else value)) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index c0b29ab420f..1e3ac20b6f2 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -157,11 +157,19 @@ class AsusWrtDeviceScanner(object): devices = {} for lease in leases_result: match = _LEASES_REGEX.search(lease.decode('utf-8')) + + # For leases where the client doesn't set a hostname, ensure + # it is blank and not '*', which breaks the entity_id down + # the line + host = match.group('host') + if host == '*': + host = '' + devices[match.group('ip')] = { + 'host': host, + 'status': '', 'ip': match.group('ip'), 'mac': match.group('mac').upper(), - 'host': match.group('host'), - 'status': '' } for neighbor in neighbors: diff --git a/homeassistant/components/device_tracker/demo.py b/homeassistant/components/device_tracker/demo.py new file mode 100644 index 00000000000..e8cf906be9e --- /dev/null +++ b/homeassistant/components/device_tracker/demo.py @@ -0,0 +1,50 @@ +""" +homeassistant.components.device_tracker.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Demo platform for the device tracker. + +device_tracker: + platform: demo +""" +import random + +from homeassistant.components.device_tracker import DOMAIN + + +def setup_scanner(hass, config, see): + """ Set up a demo tracker. """ + + def offset(): + """ Return random offset. """ + return (random.randrange(500, 2000)) / 2e5 * random.choice((-1, 1)) + + def random_see(dev_id, name): + """ Randomize a sighting. """ + see( + dev_id=dev_id, + host_name=name, + gps=(hass.config.latitude + offset(), + hass.config.longitude + offset()), + gps_accuracy=random.randrange(50, 150), + battery=random.randrange(10, 90) + ) + + def observe(call=None): + """ Observe three entities. """ + random_see('demo_paulus', 'Paulus') + random_see('demo_anne_therese', 'Anne Therese') + + observe() + + see( + dev_id='demo_home_boy', + host_name='Home Boy', + gps=[hass.config.latitude - 0.00002, hass.config.longitude + 0.00002], + gps_accuracy=20, + battery=53 + ) + + hass.services.register(DOMAIN, 'demo', observe) + + return True diff --git a/homeassistant/components/device_tracker/mqtt.py b/homeassistant/components/device_tracker/mqtt.py new file mode 100644 index 00000000000..34cee8f6733 --- /dev/null +++ b/homeassistant/components/device_tracker/mqtt.py @@ -0,0 +1,48 @@ +""" +homeassistant.components.device_tracker.mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +MQTT platform for the device tracker. + +device_tracker: + platform: mqtt + qos: 1 + devices: + paulus_oneplus: /location/paulus + annetherese_n4: /location/annetherese +""" +import logging +from homeassistant import util +import homeassistant.components.mqtt as mqtt + +DEPENDENCIES = ['mqtt'] + +CONF_QOS = 'qos' +CONF_DEVICES = 'devices' + +DEFAULT_QOS = 0 + +_LOGGER = logging.getLogger(__name__) + + +def setup_scanner(hass, config, see): + """ Set up a MQTT tracker. """ + devices = config.get(CONF_DEVICES) + qos = util.convert(config.get(CONF_QOS), int, DEFAULT_QOS) + + if not isinstance(devices, dict): + _LOGGER.error('Expected %s to be a dict, found %s', CONF_DEVICES, + devices) + return False + + dev_id_lookup = {} + + def device_tracker_message_received(topic, payload, qos): + """ MQTT message received. """ + see(dev_id=dev_id_lookup[topic], location_name=payload) + + for dev_id, topic in devices.items(): + dev_id_lookup[topic] = dev_id + mqtt.subscribe(hass, topic, device_tracker_message_received, qos) + + return True diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index 88fd7aed78a..46c515dcb1f 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -70,7 +70,6 @@ class NetgearDeviceScanner(object): self.lock = threading.Lock() if host is None: - print("BIER") self._api = pynetgear.Netgear() elif username is None: self._api = pynetgear.Netgear(password, host) diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 5c619e001a3..8d9c2e72c20 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -44,7 +44,7 @@ _LOGGER = logging.getLogger(__name__) # interval in minutes to exclude devices from a scan while they are home CONF_HOME_INTERVAL = "home_interval" -REQUIREMENTS = ['python-nmap==0.4.1'] +REQUIREMENTS = ['python-nmap==0.4.3'] def get_scanner(hass, config): diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py new file mode 100644 index 00000000000..9ef227909e1 --- /dev/null +++ b/homeassistant/components/device_tracker/owntracks.py @@ -0,0 +1,54 @@ +""" +homeassistant.components.device_tracker.owntracks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +OwnTracks platform for the device tracker. + +device_tracker: + platform: owntracks +""" +import json +import logging + +import homeassistant.components.mqtt as mqtt + +DEPENDENCIES = ['mqtt'] + +LOCATION_TOPIC = 'owntracks/+/+' + + +def setup_scanner(hass, config, see): + """ Set up a OwnTracksks tracker. """ + + def owntracks_location_update(topic, payload, qos): + """ MQTT message received. """ + + # Docs on available data: + # http://owntracks.org/booklet/tech/json/#_typelocation + try: + data = json.loads(payload) + except ValueError: + # If invalid JSON + logging.getLogger(__name__).error( + 'Unable to parse payload as JSON: %s', payload) + return + + if data.get('_type') != 'location': + return + + parts = topic.split('/') + kwargs = { + 'dev_id': '{}_{}'.format(parts[1], parts[2]), + 'host_name': parts[1], + 'gps': (data['lat'], data['lon']), + } + if 'acc' in data: + kwargs['gps_accuracy'] = data['acc'] + if 'batt' in data: + kwargs['battery'] = data['batt'] + + see(**kwargs) + + mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1) + + return True diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index c21249fbc60..450019022e1 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -19,22 +19,22 @@ from homeassistant.const import ( DOMAIN = "discovery" DEPENDENCIES = [] -REQUIREMENTS = ['netdisco==0.3'] +REQUIREMENTS = ['netdisco==0.4.1'] SCAN_INTERVAL = 300 # seconds -# Next 3 lines for now a mirror from netdisco.const -# Should setup a mapping netdisco.const -> own constants SERVICE_WEMO = 'belkin_wemo' SERVICE_HUE = 'philips_hue' SERVICE_CAST = 'google_cast' SERVICE_NETGEAR = 'netgear_router' +SERVICE_SONOS = 'sonos' SERVICE_HANDLERS = { SERVICE_WEMO: "switch", SERVICE_CAST: "media_player", SERVICE_HUE: "light", SERVICE_NETGEAR: 'device_tracker', + SERVICE_SONOS: 'media_player', } @@ -79,13 +79,6 @@ def setup(hass, config): if not component: return - # Hack - fix when device_tracker supports discovery - if service == SERVICE_NETGEAR: - bootstrap.setup_component(hass, component, { - 'device_tracker': {'platform': 'netgear'} - }) - return - # This component cannot be setup. if not bootstrap.setup_component(hass, component, config): return diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 902b14e38b3..419e48d55b5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,8 @@ _LOGGER = logging.getLogger(__name__) FRONTEND_URLS = [ - URL_ROOT, '/logbook', '/history', '/devService', '/devState', '/devEvent'] + URL_ROOT, '/logbook', '/history', '/map', '/devService', '/devState', + '/devEvent'] STATES_URL = re.compile(r'/states(/([a-zA-Z\._\-0-9/]+)|)') diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 8c6b05726da..5f913eae674 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -1,2 +1,2 @@ """ DO NOT MODIFY. Auto-generated by build_frontend script """ -VERSION = "35ecb5457a9ff0f4142c2605b53eb843" +VERSION = "3a3ed81f9d66bf24e17f1d02b8403335" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 02d96975a0e..9277184b8a2 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -2277,7 +2277,490 @@ http://nicolasgallagher.com/micro-clearfix-hack/ .pika-table abbr { border-bottom: none; cursor: help; -}- \ No newline at end of file + } \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index b0b12e20e0f..6989009b2d5 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit b0b12e20e0f61df849c414c2dfbcf9923f784631 +Subproject commit 6989009b2d59e39fd39b3025ff5899877f618bd3 diff --git a/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png b/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png new file mode 100644 index 00000000000..a2cf7f9efef Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/layers.png b/homeassistant/components/frontend/www_static/images/leaflet/layers.png new file mode 100644 index 00000000000..bca0a0e4296 Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/leaflet/layers.png differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png new file mode 100644 index 00000000000..0015b6495fa Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png new file mode 100644 index 00000000000..e2e9f757f51 Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png new file mode 100644 index 00000000000..d1e773c715a Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png differ diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 1d2ccc9ab14..a723f9cbd71 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -147,8 +147,6 @@ def _api_history_period(handler, path_match, data): end_time = start_time + one_day - print("Fetchign", start_time, end_time) - entity_id = data.get('filter_entity_id') handler.write_json( diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index 0b4f6165bed..8b2e2a6252c 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -205,7 +205,7 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): self.serve_forever() def register_path(self, method, url, callback, require_auth=True): - """ Registers a path wit the server. """ + """ Registers a path with the server. """ self.paths.append((method, url, callback, require_auth)) def log_message(self, fmt, *args): @@ -487,7 +487,7 @@ class ServerSession: return self._expiry < date_util.utcnow() -class SessionStore: +class SessionStore(object): """ Responsible for storing and retrieving http sessions """ def __init__(self, enabled=True): """ Set up the session store """ diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py index 8068d20bb74..19ce1a06d4a 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -8,10 +8,11 @@ import logging from homeassistant.components.light import Light, ATTR_BRIGHTNESS from homeassistant.const import ATTR_FRIENDLY_NAME import tellcore.constants as tellcore_constants - +from tellcore.library import DirectCallbackDispatcher REQUIREMENTS = ['tellcore-py==1.0.4'] +# pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Find and return Tellstick lights. """ @@ -22,13 +23,28 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): "Failed to import tellcore") return [] - core = telldus.TelldusCore() + # pylint: disable=no-member + if telldus.TelldusCore.callback_dispatcher is None: + dispatcher = DirectCallbackDispatcher() + core = telldus.TelldusCore(callback_dispatcher=dispatcher) + else: + core = telldus.TelldusCore() + switches_and_lights = core.devices() lights = [] for switch in switches_and_lights: if switch.methods(tellcore_constants.TELLSTICK_DIM): lights.append(TellstickLight(switch)) + + def _device_event_callback(id_, method, data, cid): + """ Called from the TelldusCore library to update one device """ + for light_device in lights: + if light_device.tellstick_device.id == id_: + light_device.update_ha_state(True) + + core.register_device_event(_device_event_callback) + add_devices_callback(lights) @@ -40,15 +56,15 @@ class TellstickLight(Light): tellcore_constants.TELLSTICK_UP | tellcore_constants.TELLSTICK_DOWN) - def __init__(self, tellstick): - self.tellstick = tellstick - self.state_attr = {ATTR_FRIENDLY_NAME: tellstick.name} + def __init__(self, tellstick_device): + self.tellstick_device = tellstick_device + self.state_attr = {ATTR_FRIENDLY_NAME: tellstick_device.name} self._brightness = 0 @property def name(self): """ Returns the name of the switch if any. """ - return self.tellstick.name + return self.tellstick_device.name @property def is_on(self): @@ -62,8 +78,9 @@ class TellstickLight(Light): def turn_off(self, **kwargs): """ Turns the switch off. """ - self.tellstick.turn_off() + self.tellstick_device.turn_off() self._brightness = 0 + self.update_ha_state() def turn_on(self, **kwargs): """ Turns the switch on. """ @@ -74,11 +91,12 @@ class TellstickLight(Light): else: self._brightness = brightness - self.tellstick.dim(self._brightness) + self.tellstick_device.dim(self._brightness) + self.update_ha_state() def update(self): """ Update state of the light. """ - last_command = self.tellstick.last_sent_command( + last_command = self.tellstick_device.last_sent_command( self.last_sent_command_mask) if last_command == tellcore_constants.TELLSTICK_TURNON: @@ -88,6 +106,11 @@ class TellstickLight(Light): elif (last_command == tellcore_constants.TELLSTICK_DIM or last_command == tellcore_constants.TELLSTICK_UP or last_command == tellcore_constants.TELLSTICK_DOWN): - last_sent_value = self.tellstick.last_sent_value() + last_sent_value = self.tellstick_device.last_sent_value() if last_sent_value is not None: self._brightness = last_sent_value + + @property + def should_poll(self): + """ Tells Home Assistant not to poll this entity. """ + return False diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index c7a403f12ec..45ee7a2e319 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -12,9 +12,10 @@ from homeassistant.core import State, DOMAIN as HA_DOMAIN from homeassistant.const import ( EVENT_STATE_CHANGED, STATE_HOME, STATE_ON, STATE_OFF, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, HTTP_BAD_REQUEST) +from homeassistant import util import homeassistant.util.dt as dt_util -import homeassistant.components.recorder as recorder -import homeassistant.components.sun as sun +from homeassistant.components import recorder, sun + DOMAIN = "logbook" DEPENDENCIES = ['recorder', 'http'] @@ -25,8 +26,29 @@ QUERY_EVENTS_BETWEEN = """ SELECT * FROM events WHERE time_fired > ? AND time_fired < ? """ +EVENT_LOGBOOK_ENTRY = 'LOGBOOK_ENTRY' + GROUP_BY_MINUTES = 15 +ATTR_NAME = 'name' +ATTR_MESSAGE = 'message' +ATTR_DOMAIN = 'domain' +ATTR_ENTITY_ID = 'entity_id' + + +def log_entry(hass, name, message, domain=None, entity_id=None): + """ Adds an entry to the logbook. """ + data = { + ATTR_NAME: name, + ATTR_MESSAGE: message + } + + if domain is not None: + data[ATTR_DOMAIN] = domain + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + hass.bus.fire(EVENT_LOGBOOK_ENTRY, data) + def setup(hass, config): """ Listens for download events to download files. """ @@ -110,7 +132,10 @@ def humanify(events): # Process events for event in events_batch: if event.event_type == EVENT_STATE_CHANGED: - entity_id = event.data['entity_id'] + entity_id = event.data.get('entity_id') + + if entity_id is None: + continue if entity_id.startswith('sensor.'): last_sensor_event[entity_id] = event @@ -175,6 +200,20 @@ def humanify(events): event.time_fired, "Home Assistant", action, domain=HA_DOMAIN) + elif event.event_type == EVENT_LOGBOOK_ENTRY: + domain = event.data.get(ATTR_DOMAIN) + entity_id = event.data.get(ATTR_ENTITY_ID) + if domain is None and entity_id is not None: + try: + domain = util.split_entity_id(str(entity_id))[0] + except IndexError: + pass + + yield Entry( + event.time_fired, event.data.get(ATTR_NAME), + event.data.get(ATTR_MESSAGE), domain, + entity_id) + def _entry_message_from_state(domain, state): """ Convert a state to a message for the logbook. """ diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 29bcb731062..19ff0540c6b 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -19,12 +19,13 @@ from homeassistant.const import ( DOMAIN = 'media_player' DEPENDENCIES = [] -SCAN_INTERVAL = 30 +SCAN_INTERVAL = 10 ENTITY_ID_FORMAT = DOMAIN + '.{}' DISCOVERY_PLATFORMS = { discovery.SERVICE_CAST: 'cast', + discovery.SERVICE_SONOS: 'sonos', } SERVICE_YOUTUBE_VIDEO = 'play_youtube_video' @@ -483,7 +484,7 @@ class MediaPlayerDevice(Entity): else: state_attr = { attr: getattr(self, attr) for attr - in ATTR_TO_PROPERTY if getattr(self, attr) + in ATTR_TO_PROPERTY if getattr(self, attr) is not None } if self.media_image_url: diff --git a/homeassistant/components/media_player/itunes.py b/homeassistant/components/media_player/itunes.py new file mode 100644 index 00000000000..ecbb144e033 --- /dev/null +++ b/homeassistant/components/media_player/itunes.py @@ -0,0 +1,445 @@ +""" +homeassistant.components.media_player.itunes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Provides an interface to iTunes-API (https://github.com/maddox/itunes-api) + +The iTunes media player will allow you to control your iTunes instance. You +can play/pause/next/previous/mute, adjust volume, etc. + +In addition to controlling iTunes, your available AirPlay endpoints will be +added as media players as well. You can then individually address them append +turn them on, turn them off, or adjust their volume. + +Configuration: + +To use iTunes you will need to add something like the following to +your configuration.yaml file. + +media_player: + platform: itunes + name: iTunes + host: http://192.168.1.16 + port: 8181 + +Variables: + +name +*Optional +The name of the device. + +url +*Required +URL of your running version of iTunes-API. Example: http://192.168.1.50:8181 + +""" +import logging + +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_MUSIC, SUPPORT_PAUSE, SUPPORT_SEEK, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, + ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_MEDIA_COMMANDS) +from homeassistant.const import ( + STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF, STATE_ON) + +import requests + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_ITUNES = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK + +SUPPORT_AIRPLAY = SUPPORT_VOLUME_SET | SUPPORT_TURN_ON | SUPPORT_TURN_OFF + +DOMAIN = 'itunes' + + +class Itunes(object): + """ itunes-api client. """ + + def __init__(self, host, port): + self.host = host + self.port = port + + @property + def _base_url(self): + """ Returns the base url for endpoints. """ + return self.host + ":" + str(self.port) + + def _request(self, method, path, params=None): + """ Makes the actual request and returns the parsed response. """ + url = self._base_url + path + + try: + if method == 'GET': + response = requests.get(url) + elif method == "POST": + response = requests.put(url, params) + elif method == "PUT": + response = requests.put(url, params) + elif method == "DELETE": + response = requests.delete(url) + + return response.json() + except requests.exceptions.HTTPError: + return {'player_state': 'error'} + except requests.exceptions.RequestException: + return {'player_state': 'offline'} + + def _command(self, named_command): + """ Makes a request for a controlling command. """ + return self._request('PUT', '/' + named_command) + + def now_playing(self): + """ Returns the current state. """ + return self._request('GET', '/now_playing') + + def set_volume(self, level): + """ Sets the volume and returns the current state, level 0-100. """ + return self._request('PUT', '/volume', {'level': level}) + + def set_muted(self, muted): + """ Mutes and returns the current state, muted True or False. """ + return self._request('PUT', '/mute', {'muted': muted}) + + def play(self): + """ Sets playback to play and returns the current state. """ + return self._command('play') + + def pause(self): + """ Sets playback to paused and returns the current state. """ + return self._command('pause') + + def next(self): + """ Skips to the next track and returns the current state. """ + return self._command('next') + + def previous(self): + """ Skips back and returns the current state. """ + return self._command('previous') + + def artwork_url(self): + """ Returns a URL of the current track's album art. """ + return self._base_url + '/artwork' + + def airplay_devices(self): + """ Returns a list of AirPlay devices. """ + return self._request('GET', '/airplay_devices') + + def airplay_device(self, device_id): + """ Returns an AirPlay device. """ + return self._request('GET', '/airplay_devices/' + device_id) + + def toggle_airplay_device(self, device_id, toggle): + """ Toggles airplay device on or off, id, toggle True or False. """ + command = 'on' if toggle else 'off' + path = '/airplay_devices/' + device_id + '/' + command + return self._request('PUT', path) + + def set_volume_airplay_device(self, device_id, level): + """ Sets volume, returns current state of device, id,level 0-100. """ + path = '/airplay_devices/' + device_id + '/volume' + return self._request('PUT', path, {'level': level}) + +# pylint: disable=unused-argument +# pylint: disable=abstract-method +# pylint: disable=too-many-instance-attributes + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the itunes platform. """ + + add_devices([ + ItunesDevice( + config.get('name', 'iTunes'), + config.get('host'), + config.get('port'), + add_devices + ) + ]) + + +class ItunesDevice(MediaPlayerDevice): + """ Represents a iTunes-API instance. """ + + # pylint: disable=too-many-public-methods + + def __init__(self, name, host, port, add_devices): + self._name = name + self._host = host + self._port = port + self._add_devices = add_devices + + self.client = Itunes(self._host, self._port) + + self.current_volume = None + self.muted = None + self.current_title = None + self.current_album = None + self.current_artist = None + self.current_playlist = None + self.content_id = None + + self.player_state = None + + self.airplay_devices = {} + + self.update() + + def update_state(self, state_hash): + """ Update all the state properties with the passed in dictionary. """ + self.player_state = state_hash.get('player_state', None) + + self.current_volume = state_hash.get('volume', 0) + self.muted = state_hash.get('muted', None) + self.current_title = state_hash.get('name', None) + self.current_album = state_hash.get('album', None) + self.current_artist = state_hash.get('artist', None) + self.current_playlist = state_hash.get('playlist', None) + self.content_id = state_hash.get('id', None) + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + + if self.player_state == 'offline' or self.player_state is None: + return 'offline' + + if self.player_state == 'error': + return 'error' + + if self.player_state == 'stopped': + return STATE_IDLE + + if self.player_state == 'paused': + return STATE_PAUSED + else: + return STATE_PLAYING + + def update(self): + """ Retrieve latest state. """ + now_playing = self.client.now_playing() + self.update_state(now_playing) + + found_devices = self.client.airplay_devices() + found_devices = found_devices.get('airplay_devices', []) + + new_devices = [] + + for device_data in found_devices: + device_id = device_data.get('id') + + if self.airplay_devices.get(device_id): + # update it + airplay_device = self.airplay_devices.get(device_id) + airplay_device.update_state(device_data) + else: + # add it + airplay_device = AirPlayDevice(device_id, self.client) + airplay_device.update_state(device_data) + self.airplay_devices[device_id] = airplay_device + new_devices.append(airplay_device) + + if new_devices: + self._add_devices(new_devices) + + @property + def is_volume_muted(self): + """ Boolean if volume is currently muted. """ + return self.muted + + @property + def volume_level(self): + """ Volume level of the media player (0..1). """ + return self.current_volume/100.0 + + @property + def media_content_id(self): + """ Content ID of current playing media. """ + return self.content_id + + @property + def media_content_type(self): + """ Content type of current playing media. """ + return MEDIA_TYPE_MUSIC + + @property + def media_image_url(self): + """ Image url of current playing media. """ + + if self.player_state in (STATE_PLAYING, STATE_IDLE, STATE_PAUSED) and \ + self.current_title is not None: + return self.client.artwork_url() + else: + return 'https://cloud.githubusercontent.com/assets/260/9829355' \ + '/33fab972-58cf-11e5-8ea2-2ca74bdaae40.png' + + @property + def media_title(self): + """ Title of current playing media. """ + return self.current_title + + @property + def media_artist(self): + """ Artist of current playing media. (Music track only) """ + return self.current_artist + + @property + def media_album_name(self): + """ Album of current playing media. (Music track only) """ + return self.current_album + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_ITUNES + + def set_volume_level(self, volume): + """ set volume level, range 0..1. """ + response = self.client.set_volume(int(volume * 100)) + self.update_state(response) + + def mute_volume(self, mute): + """ mute (true) or unmute (false) media player. """ + response = self.client.set_muted(mute) + self.update_state(response) + + def media_play(self): + """ media_play media player. """ + response = self.client.play() + self.update_state(response) + + def media_pause(self): + """ media_pause media player. """ + response = self.client.pause() + self.update_state(response) + + def media_next_track(self): + """ media_next media player. """ + response = self.client.next() + self.update_state(response) + + def media_previous_track(self): + """ media_previous media player. """ + response = self.client.previous() + self.update_state(response) + + +class AirPlayDevice(MediaPlayerDevice): + """ Represents an AirPlay device via an iTunes-API instance. """ + + # pylint: disable=too-many-public-methods + + def __init__(self, device_id, client): + self._id = device_id + + self.client = client + + self.device_name = "AirPlay" + self.kind = None + self.active = False + self.selected = False + self.volume = 0 + self.supports_audio = False + self.supports_video = False + self.player_state = None + + def update_state(self, state_hash): + """ Update all the state properties with the passed in dictionary. """ + + if 'player_state' in state_hash: + self.player_state = state_hash.get('player_state', None) + + if 'name' in state_hash: + name = state_hash.get('name', '') + self.device_name = (name + ' AirTunes Speaker').strip() + + if 'kind' in state_hash: + self.kind = state_hash.get('kind', None) + + if 'active' in state_hash: + self.active = state_hash.get('active', None) + + if 'selected' in state_hash: + self.selected = state_hash.get('selected', None) + + if 'sound_volume' in state_hash: + self.volume = state_hash.get('sound_volume', 0) + + if 'supports_audio' in state_hash: + self.supports_audio = state_hash.get('supports_audio', None) + + if 'supports_video' in state_hash: + self.supports_video = state_hash.get('supports_video', None) + + @property + def name(self): + """ Returns the name of the device. """ + return self.device_name + + @property + def state(self): + """ Returns the state of the device. """ + + if self.selected is True: + return STATE_ON + else: + return STATE_OFF + + def update(self): + """ Retrieve latest state. """ + + @property + def volume_level(self): + return float(self.volume)/100.0 + + @property + def media_content_type(self): + return MEDIA_TYPE_MUSIC + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_AIRPLAY + + @property + def device_state_attributes(self): + """ Return the state attributes. """ + state_attr = {} + state_attr[ATTR_SUPPORTED_MEDIA_COMMANDS] = SUPPORT_AIRPLAY + + if self.state == STATE_OFF: + state_attr[ATTR_ENTITY_PICTURE] = \ + ('https://cloud.githubusercontent.com/assets/260/9833073' + '/6eb5c906-5958-11e5-9b4a-472cdf36be16.png') + else: + state_attr[ATTR_ENTITY_PICTURE] = \ + ('https://cloud.githubusercontent.com/assets/260/9833072' + '/6eb13cce-5958-11e5-996f-e2aaefbc9a24.png') + + return state_attr + + def set_volume_level(self, volume): + """ set volume level, range 0..1. """ + volume = int(volume * 100) + response = self.client.set_volume_airplay_device(self._id, volume) + self.update_state(response) + + def turn_on(self): + """ Select AirPlay. """ + self.update_state({"selected": True}) + self.update_ha_state() + response = self.client.toggle_airplay_device(self._id, True) + self.update_state(response) + + def turn_off(self): + """ Deselect AirPlay. """ + self.update_state({"selected": False}) + self.update_ha_state() + response = self.client.toggle_airplay_device(self._id, False) + self.update_state(response) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index ab8af7787c3..7bfd385f65b 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -107,6 +107,7 @@ class KodiDevice(MediaPlayerDevice): try: return self._server.Player.GetActivePlayers() except jsonrpc_requests.jsonrpc.TransportError: + _LOGGER.exception('Unable to fetch kodi data') return None @property diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py new file mode 100644 index 00000000000..a43916f10e3 --- /dev/null +++ b/homeassistant/components/media_player/plex.py @@ -0,0 +1,188 @@ +""" +homeassistant.components.media_player.plex +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Provides an interface to the Plex API + +Configuration: + +To use Plex add something like this to your configuration: + +media_player: + platform: plex + name: plex_server + user: plex + password: my_secure_password + +Variables: + +name +*Required +The name of the backend device (Under Plex Media Server > settings > server). + +user +*Required +The Plex username + +password +*Required +The Plex password +""" + +import logging + +from homeassistant.components.media_player import ( + MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO) +from homeassistant.const import ( + STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN) + +REQUIREMENTS = ['https://github.com/miniconfig/python-plexapi/archive/' + '437e36dca3b7780dc0cb73941d662302c0cd2fa9.zip' + '#python-plexapi==1.0.2'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + +# pylint: disable=abstract-method +# pylint: disable=unused-argument + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the plex platform. """ + from plexapi.myplex import MyPlexUser + name = config.get('name', '') + user = config.get('user', '') + password = config.get('password', '') + plexuser = MyPlexUser.signin(user, password) + plexserver = plexuser.getResource(name).connect() + dev = plexserver.clients() + for device in dev: + if "PlayStation" not in device.name: + add_devices([PlexClient(device.name, plexserver)]) + + +class PlexClient(MediaPlayerDevice): + """ Represents a Plex device. """ + + # pylint: disable=too-many-public-methods + + def __init__(self, name, plexserver): + self.client = plexserver.client(name) + self._name = name + self._media = None + self.update() + self.server = plexserver + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + if self._media is None: + return STATE_IDLE + else: + state = self._media.get('state') + if state == 'playing': + return STATE_PLAYING + elif state == 'paused': + return STATE_PAUSED + return STATE_UNKNOWN + + def update(self): + timeline = self.client.timeline() + for timeline_item in timeline: + if timeline_item.get('state') in ('playing', 'paused'): + self._media = timeline_item + + @property + def media_content_id(self): + """ Content ID of current playing media. """ + if self._media is not None: + return self._media.get('ratingKey') + + @property + def media_content_type(self): + """ Content type of current playing media. """ + if self._media is None: + return None + media_type = self.server.library.getByKey( + self.media_content_id).type + if media_type == 'episode': + return MEDIA_TYPE_TVSHOW + elif media_type == 'movie': + return MEDIA_TYPE_VIDEO + return None + + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + if self._media is not None: + total_time = self._media.get('duration') + return total_time + + @property + def media_image_url(self): + """ Image url of current playing media. """ + if self._media is not None: + return self.server.library.getByKey(self.media_content_id).thumbUrl + return None + + @property + def media_title(self): + """ Title of current playing media. """ + # find a string we can use as a title + if self._media is not None: + return self.server.library.getByKey(self.media_content_id).title + + @property + def media_season(self): + """ Season of curent playing media. (TV Show only) """ + if self._media is not None: + show_season = self.server.library.getByKey( + self.media_content_id).season().index + return show_season + return None + + @property + def media_series_title(self): + """ Series title of current playing media. (TV Show only)""" + if self._media is not None: + series_title = self.server.library.getByKey( + self.media_content_id).show().title + return series_title + return None + + @property + def media_episode(self): + """ Episode of current playing media. (TV Show only) """ + if self._media is not None: + show_episode = self.server.library.getByKey( + self.media_content_id).index + return show_episode + return None + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_PLEX + + def media_play(self): + """ media_play media player. """ + self.client.play() + + def media_pause(self): + """ media_pause media player. """ + self.client.pause() + + def media_next_track(self): + """ Send next track command. """ + self.client.skipNext() + + def media_previous_track(self): + """ Send previous track command. """ + self.client.skipPrevious() diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py new file mode 100644 index 00000000000..faf4f6aa983 --- /dev/null +++ b/homeassistant/components/media_player/sonos.py @@ -0,0 +1,206 @@ +""" +homeassistant.components.media_player.sonos +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Provides an interface to Sonos players (via SoCo) + +Configuration: + +To use SoCo, add something like this to your configuration: + +media_player: + platform: sonos +""" + +import logging +import datetime + +from homeassistant.components.media_player import ( + MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_SEEK, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_MUTE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + MEDIA_TYPE_MUSIC) + +from homeassistant.const import ( + STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN) + + +REQUIREMENTS = ['SoCo==0.11.1'] + +_LOGGER = logging.getLogger(__name__) + +# The soco library is excessively chatty when it comes to logging and +# causes a LOT of spam in the logs due to making a http connection to each +# speaker every 10 seconds. Quiet it down a bit to just actual problems. +_SOCO_LOGGER = logging.getLogger('soco') +_SOCO_LOGGER.setLevel(logging.ERROR) +_REQUESTS_LOGGER = logging.getLogger('requests') +_REQUESTS_LOGGER.setLevel(logging.ERROR) + +SUPPORT_SONOS = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Sonos platform. """ + import soco + + players = soco.discover() + if not players: + _LOGGER.warning('No Sonos speakers found. Disabling: %s', __name__) + return False + + add_devices(SonosDevice(hass, p) for p in players) + _LOGGER.info('Added %s Sonos speakers', len(players)) + + return True + + +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-public-methods +# pylint: disable=abstract-method +class SonosDevice(MediaPlayerDevice): + """ Represents a Sonos device. """ + + # pylint: disable=too-many-arguments + def __init__(self, hass, player): + self.hass = hass + super(SonosDevice, self).__init__() + self._player = player + self.update() + + @property + def should_poll(self): + return True + + def update_sonos(self, now): + """ Updates state, called by track_utc_time_change """ + self.update_ha_state(True) + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def unique_id(self): + """ Returns a unique id. """ + return "{}.{}".format(self.__class__, self._player.uid) + + @property + def state(self): + """ Returns the state of the device. """ + if self._status == 'PAUSED_PLAYBACK': + return STATE_PAUSED + if self._status == 'PLAYING': + return STATE_PLAYING + if self._status == 'STOPPED': + return STATE_IDLE + return STATE_UNKNOWN + + def update(self): + """ Retrieve latest state. """ + self._name = self._player.get_speaker_info()['zone_name'].replace( + ' (R)', '').replace(' (L)', '') + self._status = self._player.get_current_transport_info().get( + 'current_transport_state') + self._trackinfo = self._player.get_current_track_info() + + @property + def volume_level(self): + """ Volume level of the media player (0..1). """ + return self._player.volume / 100.0 + + @property + def is_volume_muted(self): + return self._player.mute + + @property + def media_content_id(self): + """ Content ID of current playing media. """ + return self._trackinfo.get('title', None) + + @property + def media_content_type(self): + """ Content type of current playing media. """ + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + dur = self._trackinfo.get('duration', '0:00') + + # If the speaker is playing from the "line-in" source, getting + # track metadata can return NOT_IMPLEMENTED, which breaks the + # volume logic below + if dur == 'NOT_IMPLEMENTED': + return None + + return sum(60 ** x[0] * int(x[1]) for x in + enumerate(reversed(dur.split(':')))) + + @property + def media_image_url(self): + """ Image url of current playing media. """ + if 'album_art' in self._trackinfo: + return self._trackinfo['album_art'] + + @property + def media_title(self): + """ Title of current playing media. """ + if 'artist' in self._trackinfo and 'title' in self._trackinfo: + return '{artist} - {title}'.format( + artist=self._trackinfo['artist'], + title=self._trackinfo['title'] + ) + if 'title' in self._status: + return self._trackinfo['title'] + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_SONOS + + def turn_off(self): + """ turn_off media player. """ + self._player.pause() + + def volume_up(self): + """ volume_up media player. """ + self._player.volume += 1 + + def volume_down(self): + """ volume_down media player. """ + self._player.volume -= 1 + + def set_volume_level(self, volume): + """ set volume level, range 0..1. """ + self._player.volume = str(int(volume * 100)) + + def mute_volume(self, mute): + """ mute (true) or unmute (false) media player. """ + self._player.mute = mute + + def media_play(self): + """ media_play media player. """ + self._player.play() + + def media_pause(self): + """ media_pause media player. """ + self._player.pause() + + def media_next_track(self): + """ Send next track command. """ + self._player.next() + + def media_previous_track(self): + """ Send next track command. """ + self._player.previous() + + def media_seek(self, position): + """ Send seek command. """ + self._player.seek(str(datetime.timedelta(seconds=int(position)))) + + def turn_on(self): + """ turn the media player on. """ + self._player.play() diff --git a/homeassistant/components/mqtt.py b/homeassistant/components/mqtt.py index e157f15f84b..7c7a12c2ac3 100644 --- a/homeassistant/components/mqtt.py +++ b/homeassistant/components/mqtt.py @@ -60,6 +60,7 @@ MQTT_CLIENT = None DEFAULT_PORT = 1883 DEFAULT_KEEPALIVE = 60 +DEFAULT_QOS = 0 SERVICE_PUBLISH = 'publish' EVENT_MQTT_MESSAGE_RECEIVED = 'MQTT_MESSAGE_RECEIVED' @@ -79,17 +80,18 @@ ATTR_PAYLOAD = 'payload' ATTR_QOS = 'qos' -def publish(hass, topic, payload, qos=0): +def publish(hass, topic, payload, qos=None): """ Send an MQTT message. """ data = { ATTR_TOPIC: topic, ATTR_PAYLOAD: payload, - ATTR_QOS: qos, } + if qos is not None: + data[ATTR_QOS] = qos hass.services.call(DOMAIN, SERVICE_PUBLISH, data) -def subscribe(hass, topic, callback, qos=0): +def subscribe(hass, topic, callback, qos=DEFAULT_QOS): """ Subscribe to a topic. """ def mqtt_topic_subscriber(event): """ Match subscribed MQTT topic. """ @@ -141,7 +143,7 @@ def setup(hass, config): """ Handle MQTT publish service calls. """ msg_topic = call.data.get(ATTR_TOPIC) payload = call.data.get(ATTR_PAYLOAD) - qos = call.data.get(ATTR_QOS) + qos = call.data.get(ATTR_QOS, DEFAULT_QOS) if msg_topic is None or payload is None: return MQTT_CLIENT.publish(msg_topic, payload, qos) diff --git a/homeassistant/components/recorder.py b/homeassistant/components/recorder.py index 73487163425..10f6576d23f 100644 --- a/homeassistant/components/recorder.py +++ b/homeassistant/components/recorder.py @@ -256,7 +256,7 @@ class Recorder(threading.Thread): """ Query the database. """ try: with self.conn, self.lock: - _LOGGER.info("Running query %s", sql_query) + _LOGGER.debug("Running query %s", sql_query) cur = self.conn.cursor() diff --git a/homeassistant/components/scheduler/__init__.py b/homeassistant/components/scheduler/__init__.py deleted file mode 100644 index 1a67636da3d..00000000000 --- a/homeassistant/components/scheduler/__init__.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -homeassistant.components.scheduler -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -A component that will act as a scheduler and perform actions based -on the events in the schedule. - -It will read a json object from schedule.json in the config dir -and create a schedule based on it. -Each schedule is a JSON with the keys id, name, description, -entity_ids, and events. -- days is an array with the weekday number (monday=0) that the schedule - is active -- entity_ids an array with entity ids that the events in the schedule should - effect (can also be groups) -- events is an array of objects that describe the different events that is - supported. Read in the events descriptions for more information. -""" -import logging -import json - -from homeassistant import bootstrap -from homeassistant.loader import get_component -from homeassistant.const import ATTR_ENTITY_ID - -DOMAIN = 'scheduler' - -DEPENDENCIES = [] - -_LOGGER = logging.getLogger(__name__) - -_SCHEDULE_FILE = 'schedule.json' - - -def setup(hass, config): - """ Create the schedules. """ - - def setup_listener(schedule, event_data): - """ Creates the event listener based on event_data. """ - event_type = event_data['type'] - component = event_type - - # if the event isn't part of a component - if event_type in ['time']: - component = 'scheduler.{}'.format(event_type) - - elif not bootstrap.setup_component(hass, component, config): - _LOGGER.warn("Could setup event listener for %s", component) - return None - - return get_component(component).create_event_listener(schedule, - event_data) - - def setup_schedule(schedule_data): - """ Setup a schedule based on the description. """ - - schedule = Schedule(schedule_data['id'], - name=schedule_data['name'], - description=schedule_data['description'], - entity_ids=schedule_data['entity_ids'], - days=schedule_data['days']) - - for event_data in schedule_data['events']: - event_listener = setup_listener(schedule, event_data) - - if event_listener: - schedule.add_event_listener(event_listener) - - schedule.schedule(hass) - return True - - with open(hass.config.path(_SCHEDULE_FILE)) as schedule_file: - schedule_descriptions = json.load(schedule_file) - - for schedule_description in schedule_descriptions: - if not setup_schedule(schedule_description): - return False - - return True - - -class Schedule(object): - """ A Schedule """ - - # pylint: disable=too-many-arguments - def __init__(self, schedule_id, name=None, description=None, - entity_ids=None, days=None): - - self.schedule_id = schedule_id - self.name = name - self.description = description - - self.entity_ids = entity_ids or [] - - self.days = days or [0, 1, 2, 3, 4, 5, 6] - - self.__event_listeners = [] - - def add_event_listener(self, event_listener): - """ Add a event to the schedule. """ - self.__event_listeners.append(event_listener) - - def schedule(self, hass): - """ Schedule all the events in the schedule. """ - for event in self.__event_listeners: - event.schedule(hass) - - -class EventListener(object): - """ The base EventListener class that the schedule uses. """ - def __init__(self, schedule): - self.my_schedule = schedule - - def schedule(self, hass): - """ Schedule the event """ - pass - - def execute(self, hass): - """ execute the event """ - pass - - -# pylint: disable=too-few-public-methods -class ServiceEventListener(EventListener): - """ A EventListener that calls a service when executed. """ - - def __init__(self, schdule, service): - EventListener.__init__(self, schdule) - - (self.domain, self.service) = service.split('.') - - def execute(self, hass): - """ Call the service. """ - data = {ATTR_ENTITY_ID: self.my_schedule.entity_ids} - hass.services.call(self.domain, self.service, data) - - # Reschedule for next day - self.schedule(hass) diff --git a/homeassistant/components/scheduler/time.py b/homeassistant/components/scheduler/time.py deleted file mode 100644 index 9fec19fbe57..00000000000 --- a/homeassistant/components/scheduler/time.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -homeassistant.components.scheduler.time -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -An event in the scheduler component that will call the service -every specified day at the time specified. -A time event need to have the type 'time', which service to call and at -which time. - -{ - "type": "time", - "service": "switch.turn_off", - "time": "22:00:00" -} - -""" -from datetime import timedelta -import logging - -import homeassistant.util.dt as dt_util -from homeassistant.helpers.event import track_point_in_time -from homeassistant.components.scheduler import ServiceEventListener - -_LOGGER = logging.getLogger(__name__) - - -def create_event_listener(schedule, event_listener_data): - """ Create a TimeEvent based on the description. """ - - service = event_listener_data['service'] - (hour, minute, second) = [int(x) for x in - event_listener_data['time'].split(':')] - - return TimeEventListener(schedule, service, hour, minute, second) - - -# pylint: disable=too-few-public-methods -class TimeEventListener(ServiceEventListener): - """ The time event that the scheduler uses. """ - - # pylint: disable=too-many-arguments - def __init__(self, schedule, service, hour, minute, second): - ServiceEventListener.__init__(self, schedule, service) - - self.hour = hour - self.minute = minute - self.second = second - - def schedule(self, hass): - """ Schedule this event so that it will be called. """ - - next_time = dt_util.now().replace( - hour=self.hour, minute=self.minute, second=self.second) - - # Calculate the next time the event should be executed. - # That is the next day that the schedule is configured to run - while next_time < dt_util.now() or \ - next_time.weekday() not in self.my_schedule.days: - - next_time = next_time + timedelta(days=1) - - # pylint: disable=unused-argument - def execute(now): - """ Call the execute method """ - self.execute(hass) - - track_point_in_time(hass, execute, next_time) - - _LOGGER.info( - 'TimeEventListener scheduled for %s, will call service %s.%s', - next_time, self.domain, self.service) diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index 788eb8af96d..c4f70b6d6d3 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -1,7 +1,7 @@ """ homeassistant.components.script ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - +entity_id Scripts are a sequence of actions that can be triggered manually by the user or automatically based upon automation events, etc. """ @@ -22,7 +22,10 @@ CONF_ALIAS = "alias" CONF_SERVICE = "execute_service" CONF_SERVICE_DATA = "service_data" CONF_SEQUENCE = "sequence" +CONF_EVENT = "event" +CONF_EVENT_DATA = "event_data" CONF_DELAY = "delay" +ATTR_ENTITY_ID = "entity_id" _LOGGER = logging.getLogger(__name__) @@ -41,15 +44,22 @@ def setup(hass, config): hass.services.register(DOMAIN, name, script) scripts.append(script) + def _get_entities(service): + """ Make sure that we always get a list of entities """ + if isinstance(service.data[ATTR_ENTITY_ID], list): + return service.data[ATTR_ENTITY_ID] + else: + return [service.data[ATTR_ENTITY_ID]] + def turn_on(service): """ Calls a script. """ - for entity_id in service.data['entity_id']: + for entity_id in _get_entities(service): domain, service = split_entity_id(entity_id) hass.services.call(domain, service, {}) def turn_off(service): """ Cancels a script. """ - for entity_id in service.data['entity_id']: + for entity_id in _get_entities(service): for script in scripts: if script.entity_id == entity_id: script.cancel() @@ -109,6 +119,8 @@ class Script(object): for action in self.actions: if CONF_SERVICE in action: self._call_service(action) + elif CONF_EVENT in action: + self._fire_event(action) elif CONF_DELAY in action: delay = timedelta(**action[CONF_DELAY]) point_in_time = date_util.now() + delay @@ -140,3 +152,10 @@ class Script(object): domain, service = split_entity_id(action[CONF_SERVICE]) data = action.get(CONF_SERVICE_DATA, {}) self.hass.services.call(domain, service, data) + + def _fire_event(self, action): + """ Fires an event. """ + self.last_action = action.get(CONF_ALIAS, action[CONF_EVENT]) + _LOGGER.info("Executing script %s step %s", self.alias, + self.last_action) + self.hass.bus.fire(action[CONF_EVENT], action.get(CONF_EVENT_DATA)) diff --git a/homeassistant/components/sensor/arest.py b/homeassistant/components/sensor/arest.py index 78d173ef283..cfe88e0f0d6 100644 --- a/homeassistant/components/sensor/arest.py +++ b/homeassistant/components/sensor/arest.py @@ -65,7 +65,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): resource = config.get('resource', None) try: - response = get(resource) + response = get(resource, timeout=10) except exceptions.MissingSchema: _LOGGER.error("Missing resource or schema in configuration. " "Add http:// to your URL.") @@ -141,7 +141,7 @@ class ArestData(object): def update(self): """ Gets the latest data from aREST device. """ try: - response = get(self.resource) + response = get(self.resource, timeout=10) if 'error' in self.data: del self.data['error'] self.data = response.json()['variables'] diff --git a/homeassistant/components/sensor/command_sensor.py b/homeassistant/components/sensor/command_sensor.py new file mode 100644 index 00000000000..a6e6c19fdb8 --- /dev/null +++ b/homeassistant/components/sensor/command_sensor.py @@ -0,0 +1,139 @@ +""" +homeassistant.components.sensor.command_sensor +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Allows to configure custom shell commands to turn a value for a sensor. + +Configuration: + +To use the command_line sensor you will need to add something like the +following to your configuration.yaml file. + +sensor: + platform: command_sensor + name: "Command sensor" + command: sensor_command + unit_of_measurement: "°C" + correction_factor: 0.0001 + decimal_places: 0 + +Variables: + +name +*Optional +Name of the command sensor. + +command +*Required +The action to take to get the value. + +unit_of_measurement +*Optional +Defines the units of measurement of the sensor, if any. + +correction_factor +*Optional +A float value to do some basic calculations. + +decimal_places +*Optional +Number of decimal places of the value. Default is 0. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.command_sensor.html +""" +import logging +import subprocess +from datetime import timedelta + +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Command Sensor" + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Add the Command Sensor. """ + + if config.get('command') is None: + _LOGGER.error('Missing required variable: "command"') + return False + + data = CommandSensorData(config.get('command')) + + add_devices_callback([CommandSensor( + data, + config.get('name', DEFAULT_NAME), + config.get('unit_of_measurement'), + config.get('correction_factor', 1.0), + config.get('decimal_places', 0) + )]) + + +# pylint: disable=too-many-arguments +class CommandSensor(Entity): + """ Represents a sensor that is returning a value of a shell commands. """ + def __init__(self, data, name, unit_of_measurement, corr_factor, + decimal_places): + self.data = data + self._name = name + self._state = False + self._unit_of_measurement = unit_of_measurement + self._corr_factor = float(corr_factor) + self._decimal_places = decimal_places + self.update() + + @property + def name(self): + """ The name of the sensor. """ + return self._name + + @property + def unit_of_measurement(self): + """ Unit the value is expressed in. """ + return self._unit_of_measurement + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + def update(self): + """ Gets the latest data and updates the state. """ + self.data.update() + value = self.data.value + + try: + if value is not None: + if self._corr_factor is not None: + self._state = round((float(value) * self._corr_factor), + self._decimal_places) + else: + self._state = value + except ValueError: + self._state = value + + +# pylint: disable=too-few-public-methods +class CommandSensorData(object): + """ Class for handling the data retrieval. """ + + def __init__(self, command): + self.command = command + self.value = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data with a shell command. """ + _LOGGER.info('Running command: %s', self.command) + + try: + return_value = subprocess.check_output(self.command.split()) + self.value = return_value.strip().decode('utf-8') + except subprocess.CalledProcessError: + _LOGGER.error('Command failed: %s', self.command) diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py new file mode 100644 index 00000000000..f6031b5a131 --- /dev/null +++ b/homeassistant/components/sensor/glances.py @@ -0,0 +1,204 @@ +""" +homeassistant.components.sensor.glances +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Gathers system information of hosts which running glances. + +Configuration: + +To use the glances sensor you will need to add something like the following +to your configuration.yaml file. + +sensor: + platform: glances + name: Glances sensor + host: IP_ADDRESS + port: 61208 + resources: + - 'disk_use_percent' + - 'disk_use' + - 'disk_free' + - 'memory_use_percent' + - 'memory_use' + - 'memory_free' + - 'swap_use_percent' + - 'swap_use' + - 'swap_free' + - 'processor_load' + - 'process_running' + - 'process_total' + - 'process_thread' + - 'process_sleeping' + +Variables: + +name +*Optional +The name of the sensor. Default is 'Glances Sensor'. + +host +*Required +The IP address of your host, e.g. 192.168.1.32. + +port +*Optional +The network port to connect to. Default is 61208. + +resources +*Required +Resources to monitor on the host. See the configuration example above for a +list of all available conditions to monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.glances.html +""" +import logging +import requests +from datetime import timedelta + +from homeassistant.util import Throttle +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Glances Sensor' +_RESOURCE = '/api/2/all' +SENSOR_TYPES = { + 'disk_use_percent': ['Disk Use', '%'], + 'disk_use': ['Disk Use', 'GiB'], + 'disk_free': ['Disk Free', 'GiB'], + 'memory_use_percent': ['RAM Use', '%'], + 'memory_use': ['RAM Use', 'MiB'], + 'memory_free': ['RAM Free', 'MiB'], + 'swap_use_percent': ['Swap Use', '%'], + 'swap_use': ['Swap Use', 'GiB'], + 'swap_free': ['Swap Free', 'GiB'], + 'processor_load': ['CPU Load', ''], + 'process_running': ['Running', ''], + 'process_total': ['Total', ''], + 'process_thread': ['Thread', ''], + 'process_sleeping': ['Sleeping', ''] +} + +_LOGGER = logging.getLogger(__name__) +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + + +# pylint: disable=unused-variable +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Setup the Glances sensor. """ + + if not config.get('host'): + _LOGGER.error('"host:" is missing your configuration') + return False + + host = config.get('host') + port = config.get('port', 61208) + url = 'http://{}:{}{}'.format(host, port, _RESOURCE) + + try: + response = requests.get(url, timeout=10) + if not response.ok: + _LOGGER.error('Response status is "%s"', response.status_code) + return False + except requests.exceptions.MissingSchema: + _LOGGER.error('Missing resource or schema in configuration. ' + 'Please heck our details in the configuration file.') + return False + except requests.exceptions.ConnectionError: + _LOGGER.error('No route to resource/endpoint. ' + 'Please check the details in the configuration file.') + return False + + rest = GlancesData(url) + + dev = [] + for resource in config['resources']: + if resource not in SENSOR_TYPES: + _LOGGER.error('Sensor type: "%s" does not exist', resource) + else: + dev.append(GlancesSensor(rest, resource)) + + add_devices(dev) + + +class GlancesSensor(Entity): + """ Implements a REST sensor. """ + + def __init__(self, rest, sensor_type): + self.rest = rest + self._name = SENSOR_TYPES[sensor_type][0] + self.type = sensor_type + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.update() + + @property + def name(self): + """ The name of the sensor. """ + return self._name + + @property + def unit_of_measurement(self): + """ Unit the value is expressed in. """ + return self._unit_of_measurement + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + # pylint: disable=too-many-branches + def update(self): + """ Gets the latest data from REST API and updates the state. """ + self.rest.update() + value = self.rest.data + + if value is not None: + if self.type == 'disk_use_percent': + self._state = value['fs'][0]['percent'] + elif self.type == 'disk_use': + self._state = round(value['fs'][0]['used'] / 1024**3, 1) + elif self.type == 'disk_free': + self._state = round(value['fs'][0]['free'] / 1024**3, 1) + elif self.type == 'memory_use_percent': + self._state = value['mem']['percent'] + elif self.type == 'memory_use': + self._state = round(value['mem']['used'] / 1024**2, 1) + elif self.type == 'memory_free': + self._state = round(value['mem']['free'] / 1024**2, 1) + elif self.type == 'swap_use_percent': + self._state = value['memswap']['percent'] + elif self.type == 'swap_use': + self._state = round(value['memswap']['used'] / 1024**3, 1) + elif self.type == 'swap_free': + self._state = round(value['memswap']['free'] / 1024**3, 1) + elif self.type == 'processor_load': + self._state = value['load']['min15'] + elif self.type == 'process_running': + self._state = value['processcount']['running'] + elif self.type == 'process_total': + self._state = value['processcount']['total'] + elif self.type == 'process_thread': + self._state = value['processcount']['thread'] + elif self.type == 'process_sleeping': + self._state = value['processcount']['sleeping'] + + +# pylint: disable=too-few-public-methods +class GlancesData(object): + """ Class for handling the data retrieval. """ + + def __init__(self, resource): + self.resource = resource + self.data = dict() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from REST service. """ + try: + response = requests.get(self.resource, timeout=10) + self.data = response.json() + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to host/endpoint.") + self.data = None diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index 5ca292a599f..9d33264ea70 100644 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -91,7 +91,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error( "Connection error " "Please check your settings for OpenWeatherMap.") - return None + return False data = WeatherData(owm, forecast, hass.config.latitude, hass.config.longitude) diff --git a/homeassistant/components/sensor/swiss_public_transport.py b/homeassistant/components/sensor/swiss_public_transport.py index 440c7f8ad28..5a07b585430 100644 --- a/homeassistant/components/sensor/swiss_public_transport.py +++ b/homeassistant/components/sensor/swiss_public_transport.py @@ -31,7 +31,7 @@ Details for the API : http://transport.opendata.ch """ import logging from datetime import timedelta -from requests import get +import requests from homeassistant.util import Throttle import homeassistant.util.dt as dt_util @@ -53,7 +53,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): try: for location in [config.get('from', None), config.get('to', None)]: # transport.opendata.ch doesn't play nice with requests.Session - result = get(_RESOURCE + 'locations?query=%s' % location) + result = requests.get(_RESOURCE + 'locations?query=%s' % location, + timeout=10) journey.append(result.json()['stations'][0]['name']) except KeyError: _LOGGER.exception( @@ -109,14 +110,14 @@ class PublicTransportData(object): def update(self): """ Gets the latest data from opendata.ch. """ - response = get( + response = requests.get( _RESOURCE + 'connections?' + 'from=' + self.start + '&' + 'to=' + self.destination + '&' + 'fields[]=connections/from/departureTimestamp/&' + - 'fields[]=connections/') - + 'fields[]=connections/', + timeout=10) connections = response.json()['connections'][:2] try: diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index ff7e908ccf1..82f62da10e0 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -59,7 +59,6 @@ arg Additional details for the type, eg. path, binary name, etc. """ import logging -import psutil import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import Entity @@ -120,7 +119,7 @@ class SystemMonitorSensor(Entity): @property def name(self): - return self._name + return self._name.rstrip() @property def state(self): @@ -133,6 +132,7 @@ class SystemMonitorSensor(Entity): # pylint: disable=too-many-branches def update(self): + import psutil if self.type == 'disk_use_percent': self._state = psutil.disk_usage(self.argument).percent elif self.type == 'disk_use': diff --git a/homeassistant/components/sensor/verisure.py b/homeassistant/components/sensor/verisure.py index 61af1089775..47efa197870 100644 --- a/homeassistant/components/sensor/verisure.py +++ b/homeassistant/components/sensor/verisure.py @@ -36,12 +36,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hasattr(value, 'humidity') and value.humidity ]) - sensors.extend([ - VerisureAlarm(value) - for value in verisure.get_alarm_status().values() - if verisure.SHOW_ALARM - ]) - add_devices(sensors) @@ -103,25 +97,3 @@ class VerisureHygrometer(Entity): def update(self): ''' update sensor ''' verisure.update() - - -class VerisureAlarm(Entity): - """ represents a Verisure alarm status within home assistant. """ - - def __init__(self, alarm_status): - self._id = alarm_status.id - self._device = verisure.MY_PAGES.DEVICE_ALARM - - @property - def name(self): - """ Returns the name of the device. """ - return 'Alarm {}'.format(self._id) - - @property - def state(self): - """ Returns the state of the device. """ - return verisure.STATUS[self._device][self._id].label - - def update(self): - ''' update sensor ''' - verisure.update() diff --git a/homeassistant/components/sun.py b/homeassistant/components/sun.py index 802eddb4a3a..ce4dbd1e937 100644 --- a/homeassistant/components/sun.py +++ b/homeassistant/components/sun.py @@ -25,10 +25,8 @@ import urllib import homeassistant.util as util import homeassistant.util.dt as dt_util -from homeassistant.helpers.event import ( - track_point_in_utc_time, track_point_in_time) +from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.entity import Entity -from homeassistant.components.scheduler import ServiceEventListener DEPENDENCIES = [] REQUIREMENTS = ['astral==0.8.1'] @@ -214,95 +212,3 @@ class Sun(Entity): track_point_in_utc_time( self.hass, self.point_in_time_listener, self.next_change + timedelta(seconds=1)) - - -def create_event_listener(schedule, event_listener_data): - """ Create a sun event listener based on the description. """ - - negative_offset = False - service = event_listener_data['service'] - offset_str = event_listener_data['offset'] - event = event_listener_data['event'] - - if offset_str.startswith('-'): - negative_offset = True - offset_str = offset_str[1:] - - (hour, minute, second) = [int(x) for x in offset_str.split(':')] - - offset = timedelta(hours=hour, minutes=minute, seconds=second) - - if event == 'sunset': - return SunsetEventListener(schedule, service, offset, negative_offset) - - return SunriseEventListener(schedule, service, offset, negative_offset) - - -# pylint: disable=too-few-public-methods -class SunEventListener(ServiceEventListener): - """ This is the base class for sun event listeners. """ - - def __init__(self, schedule, service, offset, negative_offset): - ServiceEventListener.__init__(self, schedule, service) - - self.offset = offset - self.negative_offset = negative_offset - - def __get_next_time(self, next_event): - """ - Returns when the next time the service should be called. - Taking into account the offset and which days the event should execute. - """ - - if self.negative_offset: - next_time = next_event - self.offset - else: - next_time = next_event + self.offset - - while next_time < dt_util.now() or \ - next_time.weekday() not in self.my_schedule.days: - next_time = next_time + timedelta(days=1) - - return next_time - - def schedule_next_event(self, hass, next_event): - """ Schedule the event. """ - next_time = self.__get_next_time(next_event) - - # pylint: disable=unused-argument - def execute(now): - """ Call the execute method. """ - self.execute(hass) - - track_point_in_time(hass, execute, next_time) - - return next_time - - -# pylint: disable=too-few-public-methods -class SunsetEventListener(SunEventListener): - """ This class is used the call a service when the sun sets. """ - def schedule(self, hass): - """ Schedule the event """ - next_setting_dt = next_setting(hass) - - next_time_dt = self.schedule_next_event(hass, next_setting_dt) - - _LOGGER.info( - 'SunsetEventListener scheduled for %s, will call service %s.%s', - next_time_dt, self.domain, self.service) - - -# pylint: disable=too-few-public-methods -class SunriseEventListener(SunEventListener): - """ This class is used the call a service when the sun rises. """ - - def schedule(self, hass): - """ Schedule the event. """ - next_rising_dt = next_rising(hass) - - next_time_dt = self.schedule_next_event(hass, next_rising_dt) - - _LOGGER.info( - 'SunriseEventListener scheduled for %s, will call service %s.%s', - next_time_dt, self.domain, self.service) diff --git a/homeassistant/components/switch/arduino.py b/homeassistant/components/switch/arduino.py index bb962c08f72..b38cc290b23 100644 --- a/homeassistant/components/switch/arduino.py +++ b/homeassistant/components/switch/arduino.py @@ -1,7 +1,7 @@ """ homeassistant.components.switch.arduino ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Support for switching Arduino pins on and off. So fare only digital pins are +Support for switching Arduino pins on and off. So far only digital pins are supported. Configuration: diff --git a/homeassistant/components/switch/arest.py b/homeassistant/components/switch/arest.py new file mode 100644 index 00000000000..239e24a4925 --- /dev/null +++ b/homeassistant/components/switch/arest.py @@ -0,0 +1,123 @@ +""" +homeassistant.components.switch.arest +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The arest switch can control the digital pins of a device running with the +aREST RESTful framework for Arduino, the ESP8266, and the Raspberry Pi. +Only tested with Arduino boards so far. + +Configuration: + +To use the arest switch you will need to add something like the following +to your configuration.yaml file. + +sensor: + platform: arest + resource: http://IP_ADDRESS + pins: + 11: + name: Fan Office + 12: + name: Light Desk + +Variables: + +resource: +*Required +IP address of the device that is exposing an aREST API. + +pins: +The number of the digital pin to switch. + +These are the variables for the pins array: + +name +*Required +The name for the pin that will be used in the frontend. + +Details for the API: http://arest.io +""" +import logging +from requests import get, exceptions + +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import DEVICE_DEFAULT_NAME + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the aREST switches. """ + + resource = config.get('resource', None) + + try: + response = get(resource, timeout=10) + except exceptions.MissingSchema: + _LOGGER.error("Missing resource or schema in configuration. " + "Add http:// to your URL.") + return False + except exceptions.ConnectionError: + _LOGGER.error("No route to device. " + "Please check the IP address in the configuration file.") + return False + + dev = [] + pins = config.get('pins') + for pinnum, pin in pins.items(): + dev.append(ArestSwitch(resource, + response.json()['name'], + pin.get('name'), + pinnum)) + add_devices(dev) + + +class ArestSwitch(SwitchDevice): + """ Implements an aREST switch. """ + + def __init__(self, resource, location, name, pin): + self._resource = resource + self._name = '{} {}'.format(location.title(), name.title()) \ + or DEVICE_DEFAULT_NAME + self._pin = pin + self._state = None + + request = get('{}/mode/{}/o'.format(self._resource, self._pin), + timeout=10) + if request.status_code is not 200: + _LOGGER.error("Can't set mode. Is device offline?") + + @property + def name(self): + """ The name of the switch. """ + return self._name + + @property + def is_on(self): + """ True if device is on. """ + return self._state + + def turn_on(self, **kwargs): + """ Turn the device on. """ + request = get('{}/digital/{}/1'.format(self._resource, self._pin), + timeout=10) + if request.status_code == 200: + self._state = True + else: + _LOGGER.error("Can't turn on pin %s at %s. Is device offline?", + self._resource, self._pin) + + def turn_off(self, **kwargs): + """ Turn the device off. """ + request = get('{}/digital/{}/0'.format(self._resource, self._pin), + timeout=10) + if request.status_code == 200: + self._state = False + else: + _LOGGER.error("Can't turn off pin %s at %s. Is device offline?", + self._resource, self._pin) + + def update(self): + """ Gets the latest data from aREST API and updates the state. """ + request = get('{}/digital/{}'.format(self._resource, self._pin), + timeout=10) + self._state = request.json()['return_value'] != 0 diff --git a/homeassistant/components/switch/command_switch.py b/homeassistant/components/switch/command_switch.py index 4d5aeba94f5..3a1964ad4d0 100644 --- a/homeassistant/components/switch/command_switch.py +++ b/homeassistant/components/switch/command_switch.py @@ -2,13 +2,44 @@ """ homeassistant.components.switch.command_switch ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Allows to configure custom shell commands to turn a switch on/off. + +Configuration: + +To use the command_line switch you will need to add something like the +following to your configuration.yaml file. + +switch: + platform: command_switch + switches: + name_of_the_switch: + oncmd: switch_command on for name_of_the_switch + offcmd: switch_command off for name_of_the_switch + +Variables: + +These are the variables for the switches array: + +name_of_the_switch +*Required +Name of the command switch. Multiple entries are possible. + +oncmd +*Required +The action to take for on. + +offcmd +*Required +The action to take for off. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.command_switch.html """ import logging -from homeassistant.components.switch import SwitchDevice import subprocess +from homeassistant.components.switch import SwitchDevice + _LOGGER = logging.getLogger(__name__) @@ -22,7 +53,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): for dev_name, properties in switches.items(): devices.append( CommandSwitch( - dev_name, + properties.get('name', dev_name), properties.get('oncmd', 'true'), properties.get('offcmd', 'true'))) diff --git a/homeassistant/components/switch/tellstick.py b/homeassistant/components/switch/tellstick.py index ae064d4fdb8..96b10a0a977 100644 --- a/homeassistant/components/switch/tellstick.py +++ b/homeassistant/components/switch/tellstick.py @@ -11,11 +11,10 @@ signal_repetitions: 3 """ import logging - from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.helpers.entity import ToggleEntity import tellcore.constants as tellcore_constants - +from tellcore.library import DirectCallbackDispatcher SINGAL_REPETITIONS = 1 REQUIREMENTS = ['tellcore-py==1.0.4'] @@ -31,16 +30,31 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): "Failed to import tellcore") return + # pylint: disable=no-member + if telldus.TelldusCore.callback_dispatcher is None: + dispatcher = DirectCallbackDispatcher() + core = telldus.TelldusCore(callback_dispatcher=dispatcher) + else: + core = telldus.TelldusCore() + signal_repetitions = config.get('signal_repetitions', SINGAL_REPETITIONS) - core = telldus.TelldusCore() switches_and_lights = core.devices() switches = [] for switch in switches_and_lights: if not switch.methods(tellcore_constants.TELLSTICK_DIM): - switches.append(TellstickSwitchDevice(switch, signal_repetitions)) + switches.append( + TellstickSwitchDevice(switch, signal_repetitions)) + + def _device_event_callback(id_, method, data, cid): + """ Called from the TelldusCore library to update one device """ + for switch_device in switches: + if switch_device.tellstick_device.id == id_: + switch_device.update_ha_state(True) + + core.register_device_event(_device_event_callback) add_devices_callback(switches) @@ -50,15 +64,20 @@ class TellstickSwitchDevice(ToggleEntity): last_sent_command_mask = (tellcore_constants.TELLSTICK_TURNON | tellcore_constants.TELLSTICK_TURNOFF) - def __init__(self, tellstick, signal_repetitions): - self.tellstick = tellstick - self.state_attr = {ATTR_FRIENDLY_NAME: tellstick.name} + def __init__(self, tellstick_device, signal_repetitions): + self.tellstick_device = tellstick_device + self.state_attr = {ATTR_FRIENDLY_NAME: tellstick_device.name} self.signal_repetitions = signal_repetitions + @property + def should_poll(self): + """ Tells Home Assistant not to poll this entity. """ + return False + @property def name(self): """ Returns the name of the switch if any. """ - return self.tellstick.name + return self.tellstick_device.name @property def state_attributes(self): @@ -68,7 +87,7 @@ class TellstickSwitchDevice(ToggleEntity): @property def is_on(self): """ True if switch is on. """ - last_command = self.tellstick.last_sent_command( + last_command = self.tellstick_device.last_sent_command( self.last_sent_command_mask) return last_command == tellcore_constants.TELLSTICK_TURNON @@ -76,9 +95,11 @@ class TellstickSwitchDevice(ToggleEntity): def turn_on(self, **kwargs): """ Turns the switch on. """ for _ in range(self.signal_repetitions): - self.tellstick.turn_on() + self.tellstick_device.turn_on() + self.update_ha_state() def turn_off(self, **kwargs): """ Turns the switch off. """ for _ in range(self.signal_repetitions): - self.tellstick.turn_off() + self.tellstick_device.turn_off() + self.update_ha_state() diff --git a/homeassistant/components/thermostat/__init__.py b/homeassistant/components/thermostat/__init__.py index bbc0979e38c..e9d3c50451b 100644 --- a/homeassistant/components/thermostat/__init__.py +++ b/homeassistant/components/thermostat/__init__.py @@ -23,10 +23,17 @@ SCAN_INTERVAL = 60 SERVICE_SET_AWAY_MODE = "set_away_mode" SERVICE_SET_TEMPERATURE = "set_temperature" +STATE_HEAT = "heat" +STATE_COOL = "cool" +STATE_IDLE = "idle" + ATTR_CURRENT_TEMPERATURE = "current_temperature" ATTR_AWAY_MODE = "away_mode" ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" +ATTR_TEMPERATURE_LOW = "target_temp_low" +ATTR_TEMPERATURE_HIGH = "target_temp_high" +ATTR_OPERATION = "current_operation" _LOGGER = logging.getLogger(__name__) @@ -126,19 +133,25 @@ class ThermostatDevice(Entity): user_unit = self.hass.config.temperature_unit data = { - ATTR_CURRENT_TEMPERATURE: round(convert(self.current_temperature, - thermostat_unit, - user_unit), 1), - ATTR_MIN_TEMP: round(convert(self.min_temp, - thermostat_unit, - user_unit), 0), - ATTR_MAX_TEMP: round(convert(self.max_temp, - thermostat_unit, - user_unit), 0) + ATTR_CURRENT_TEMPERATURE: round(convert( + self.current_temperature, thermostat_unit, user_unit), 1), + ATTR_MIN_TEMP: round(convert( + self.min_temp, thermostat_unit, user_unit), 0), + ATTR_MAX_TEMP: round(convert( + self.max_temp, thermostat_unit, user_unit), 0), + ATTR_TEMPERATURE: round(convert( + self.target_temperature, thermostat_unit, user_unit), 0), + ATTR_TEMPERATURE_LOW: round(convert( + self.target_temperature_low, thermostat_unit, user_unit), 0), + ATTR_TEMPERATURE_HIGH: round(convert( + self.target_temperature_high, thermostat_unit, user_unit), 0), } - is_away = self.is_away_mode_on + operation = self.operation + if operation is not None: + data[ATTR_OPERATION] = operation + is_away = self.is_away_mode_on if is_away is not None: data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF @@ -152,18 +165,33 @@ class ThermostatDevice(Entity): @property def unit_of_measurement(self): """ Unit of measurement this thermostat expresses itself in. """ - return NotImplementedError + raise NotImplementedError @property def current_temperature(self): """ Returns the current temperature. """ raise NotImplementedError + @property + def operation(self): + """ Returns current operation ie. heat, cool, idle """ + return None + @property def target_temperature(self): """ Returns the temperature we try to reach. """ raise NotImplementedError + @property + def target_temperature_low(self): + """ Returns the lower bound temperature we try to reach. """ + return self.target_temperature + + @property + def target_temperature_high(self): + """ Returns the upper bound temperature we try to reach. """ + return self.target_temperature + @property def is_away_mode_on(self): """ diff --git a/homeassistant/components/thermostat/nest.py b/homeassistant/components/thermostat/nest.py index 72f10033a91..656becd6a21 100644 --- a/homeassistant/components/thermostat/nest.py +++ b/homeassistant/components/thermostat/nest.py @@ -3,9 +3,11 @@ homeassistant.components.thermostat.nest ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Adds support for Nest thermostats. """ +import socket import logging -from homeassistant.components.thermostat import ThermostatDevice +from homeassistant.components.thermostat import (ThermostatDevice, STATE_COOL, + STATE_IDLE, STATE_HEAT) from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, TEMP_CELCIUS) REQUIREMENTS = ['python-nest==2.6.0'] @@ -34,12 +36,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return napi = nest.Nest(username, password) - - add_devices([ - NestThermostat(structure, device) - for structure in napi.structures - for device in structure.devices - ]) + try: + add_devices([ + NestThermostat(structure, device) + for structure in napi.structures + for device in structure.devices + ]) + except socket.error: + logger.error( + "Connection error logging into the nest web service" + ) class NestThermostat(ThermostatDevice): @@ -83,25 +89,52 @@ class NestThermostat(ThermostatDevice): """ Returns the current temperature. """ return round(self.device.temperature, 1) + @property + def operation(self): + """ Returns current operation ie. heat, cool, idle """ + if self.device.hvac_ac_state is True: + return STATE_COOL + elif self.device.hvac_heater_state is True: + return STATE_HEAT + else: + return STATE_IDLE + @property def target_temperature(self): """ Returns the temperature we try to reach. """ target = self.device.target - if isinstance(target, tuple): + if self.device.mode == 'range': low, high = target - - if self.current_temperature < low: - temp = low - elif self.current_temperature > high: + if self.operation == STATE_COOL: temp = high + elif self.operation == STATE_HEAT: + temp = low else: - temp = (low + high)/2 + range_average = (low + high)/2 + if self.current_temperature < range_average: + temp = low + elif self.current_temperature >= range_average: + temp = high else: temp = target return round(temp, 1) + @property + def target_temperature_low(self): + """ Returns the lower bound temperature we try to reach. """ + if self.device.mode == 'range': + return round(self.device.target[0], 1) + return round(self.target_temperature, 1) + + @property + def target_temperature_high(self): + """ Returns the upper bound temperature we try to reach. """ + if self.device.mode == 'range': + return round(self.device.target[1], 1) + return round(self.target_temperature, 1) + @property def is_away_mode_on(self): """ Returns if away mode is on. """ @@ -109,6 +142,11 @@ class NestThermostat(ThermostatDevice): def set_temperature(self, temperature): """ Set new target temperature """ + if self.device.mode == 'range': + if self.target_temperature == self.target_temperature_low: + temperature = (temperature, self.target_temperature_high) + elif self.target_temperature == self.target_temperature_high: + temperature = (self.target_temperature_low, temperature) self.device.target = temperature def turn_away_mode_on(self): diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index c7bc7c205e8..50fd9d6c7a9 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -59,8 +59,9 @@ from homeassistant.const import ( DOMAIN = "verisure" DISCOVER_SENSORS = 'verisure.sensors' DISCOVER_SWITCHES = 'verisure.switches' +DISCOVER_ALARMS = 'verisure.alarm_control_panel' -DEPENDENCIES = [] +DEPENDENCIES = ['alarm_control_panel'] REQUIREMENTS = [ 'https://github.com/persandstrom/python-verisure/archive/' '9873c4527f01b1ba1f72ae60f7f35854390d59be.zip#python-verisure==0.2.6' @@ -123,7 +124,8 @@ def setup(hass, config): # Load components for the devices in the ISY controller that we support for comp_name, discovery in ((('sensor', DISCOVER_SENSORS), - ('switch', DISCOVER_SWITCHES))): + ('switch', DISCOVER_SWITCHES), + ('alarm_control_panel', DISCOVER_ALARMS))): component = get_component(comp_name) _LOGGER.info(config[DOMAIN]) bootstrap.setup_component(hass, component.DOMAIN, config) @@ -166,7 +168,7 @@ def reconnect(): def update(): """ Updates the status of verisure components. """ if WRONG_PASSWORD_GIVEN: - # Is there any way to inform user? + _LOGGER.error('Wrong password') return try: diff --git a/homeassistant/const.py b/homeassistant/const.py index e5a44ff7b55..c644f6883d1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,6 +1,6 @@ """ Constants used by Home Assistant components. """ -__version__ = "0.7.2" +__version__ = "0.7.4dev0" # Can be used to specify a catch all when registering state or event listeners. MATCH_ALL = '*' @@ -40,13 +40,16 @@ STATE_ON = 'on' STATE_OFF = 'off' STATE_HOME = 'home' STATE_NOT_HOME = 'not_home' -STATE_UNKNOWN = "unknown" +STATE_UNKNOWN = 'unknown' STATE_OPEN = 'open' STATE_CLOSED = 'closed' STATE_PLAYING = 'playing' STATE_PAUSED = 'paused' STATE_IDLE = 'idle' STATE_STANDBY = 'standby' +STATE_ALARM_DISARMED = 'disarmed' +STATE_ALARM_ARMED_HOME = 'armed_home' +STATE_ALARM_ARMED_AWAY = 'armed_away' # #### STATE AND EVENT ATTRIBUTES #### # Contains current time for a TIME_CHANGED event @@ -114,6 +117,10 @@ SERVICE_MEDIA_NEXT_TRACK = "media_next_track" SERVICE_MEDIA_PREVIOUS_TRACK = "media_previous_track" SERVICE_MEDIA_SEEK = "media_seek" +SERVICE_ALARM_DISARM = "alarm_disarm" +SERVICE_ALARM_ARM_HOME = "alarm_arm_home" +SERVICE_ALARM_ARM_AWAY = "alarm_arm_away" + # #### API / REMOTE #### SERVER_PORT = 8123 diff --git a/homeassistant/core.py b/homeassistant/core.py index df18d7e7902..d0494e070f6 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -26,6 +26,7 @@ from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError) import homeassistant.util as util import homeassistant.util.dt as date_util +import homeassistant.util.location as location import homeassistant.helpers.temperature as temp_helper from homeassistant.config import get_default_config_dir @@ -676,6 +677,10 @@ class Config(object): # Directory that holds the configuration self.config_dir = get_default_config_dir() + def distance(self, lat, lon): + """ Calculate distance from Home Assistant in meters. """ + return location.distance(self.latitude, self.longitude, lat, lon) + def path(self, *path): """ Returns path to the file within the config dir. """ return os.path.join(self.config_dir, *path) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index b29379049d3..82dafac5576 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -10,8 +10,8 @@ from collections import defaultdict from homeassistant.exceptions import NoEntitySpecifiedError from homeassistant.const import ( - ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, ATTR_HIDDEN, - STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, TEMP_CELCIUS, + ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_UNIT_OF_MEASUREMENT, + DEVICE_DEFAULT_NAME, STATE_ON, STATE_OFF, STATE_UNKNOWN, TEMP_CELCIUS, TEMP_FAHRENHEIT) # Dict mapping entity_id to a boolean that overwrites the hidden property @@ -44,17 +44,17 @@ class Entity(object): @property def name(self): """ Returns the name of the entity. """ - return self.get_name() + return DEVICE_DEFAULT_NAME @property def state(self): """ Returns the state of the entity. """ - return self.get_state() + return STATE_UNKNOWN @property def state_attributes(self): """ Returns the state attributes. """ - return {} + return None @property def unit_of_measurement(self): @@ -64,34 +64,12 @@ class Entity(object): @property def hidden(self): """ Suggestion if the entity should be hidden from UIs. """ - return self._hidden - - @hidden.setter - def hidden(self, val): - """ Sets the suggestion for visibility. """ - self._hidden = bool(val) + return False def update(self): """ Retrieve latest state. """ pass - # DEPRECATION NOTICE: - # Device is moving from getters to properties. - # For now the new properties will call the old functions - # This will be removed in the future. - - def get_name(self): - """ Returns the name of the entity if any. """ - return DEVICE_DEFAULT_NAME - - def get_state(self): - """ Returns state of the entity. """ - return "Unknown" - - def get_state_attributes(self): - """ Returns optional state attributes. """ - return None - # DO NOT OVERWRITE # These properties and methods are either managed by Home Assistant or they # are used to perform a very specific function. Overwriting these may diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 80084178fe0..708bf6e93a9 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -129,13 +129,13 @@ class EntityComponent(object): if platform is None: return - platform_name = '{}.{}'.format(self.domain, platform_type) - try: platform.setup_platform( self.hass, platform_config, self.add_entities, discovery_info) - - self.hass.config.components.append(platform_name) except Exception: # pylint: disable=broad-except self.logger.exception( 'Error while setting up platform %s', platform_type) + return + + platform_name = '{}.{}'.format(self.domain, platform_type) + self.hass.config.components.append(platform_name) diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index d87ee48930c..d4a18806a17 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -51,6 +51,8 @@ def reproduce_state(hass, states, blocking=False): current_state = hass.states.get(state.entity_id) if current_state is None: + _LOGGER.warning('reproduce_state: Unable to find entity %s', + state.entity_id) continue if state.state == STATE_ON: @@ -58,7 +60,8 @@ def reproduce_state(hass, states, blocking=False): elif state.state == STATE_OFF: service = SERVICE_TURN_OFF else: - _LOGGER.warning("Unable to reproduce state for %s", state) + _LOGGER.warning("reproduce_state: Unable to reproduce state %s", + state) continue service_data = dict(state.attributes) diff --git a/homeassistant/startup/__init__.py b/homeassistant/startup/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/startup/launchd.plist b/homeassistant/startup/launchd.plist new file mode 100644 index 00000000000..50bc18e3e38 --- /dev/null +++ b/homeassistant/startup/launchd.plist @@ -0,0 +1,36 @@ + + + + + Label + org.homeassitant + + EnvironmentVariables + + PATH + /usr/local/bin/:/usr/bin:$PATH + + + Program + $HASS_PATH$ + + AbandonProcessGroup + + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + StandardErrorPath + /Users/$USER$/Library/Logs/homeassitant.log + + StandardOutPath + /Users/$USER$/Library/Logs/homeassitant.log + + + diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 2e399384e63..805937376a0 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -21,7 +21,7 @@ from .dt import datetime_to_local_str, utcnow RE_SANITIZE_FILENAME = re.compile(r'(~|\.\.|/|\\)') RE_SANITIZE_PATH = re.compile(r'(~|\.(\.)+)') -RE_SLUGIFY = re.compile(r'[^A-Za-z0-9_]+') +RE_SLUGIFY = re.compile(r'[^a-z0-9_]+') def sanitize_filename(filename): @@ -36,7 +36,7 @@ def sanitize_path(path): def slugify(text): """ Slugifies a given text. """ - text = text.replace(" ", "_") + text = text.lower().replace(" ", "_") return RE_SLUGIFY.sub("", text) @@ -71,7 +71,7 @@ def ensure_unique_string(preferred_string, current_strings): """ Returns a string that is not present in current_strings. If preferred string exists will append _2, _3, .. """ test_string = preferred_string - current_strings = list(current_strings) + current_strings = set(current_strings) tries = 1 @@ -244,22 +244,22 @@ class Throttle(object): Wrapper that allows wrapped to be called only once per min_time. If we cannot acquire the lock, it is running so return None. """ - if lock.acquire(False): - try: - last_call = wrapper.last_call + if not lock.acquire(False): + return None + try: + last_call = wrapper.last_call - # Check if method is never called or no_throttle is given - force = not last_call or kwargs.pop('no_throttle', False) + # Check if method is never called or no_throttle is given + force = not last_call or kwargs.pop('no_throttle', False) - if force or datetime.now() - last_call > self.min_time: - - result = method(*args, **kwargs) - wrapper.last_call = datetime.now() - return result - else: - return None - finally: - lock.release() + if force or utcnow() - last_call > self.min_time: + result = method(*args, **kwargs) + wrapper.last_call = utcnow() + return result + else: + return None + finally: + lock.release() wrapper.last_call = None diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index d8fecf20db8..35795a7ae7f 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -131,3 +131,20 @@ def date_str_to_date(dt_str): def strip_microseconds(dattim): """ Returns a copy of dattime object but with microsecond set to 0. """ return dattim.replace(microsecond=0) + + +def parse_time_str(time_str): + """ Parse a time string (00:20:00) into Time object. + Return None if invalid. + """ + parts = str(time_str).split(':') + if len(parts) < 2: + return None + try: + hour = int(parts[0]) + minute = int(parts[1]) + second = int(parts[2]) if len(parts) > 2 else 0 + return dt.time(hour, minute, second) + except ValueError: + # ValueError if value cannot be converted to an int or not in range + return None diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 8cc008613cb..ade15131a8f 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -1,5 +1,6 @@ """Module with location helpers.""" import collections +from math import radians, cos, sin, asin, sqrt import requests @@ -28,3 +29,20 @@ def detect_location_info(): 'BS', 'BZ', 'KY', 'PW', 'US', 'AS', 'VI') return LocationInfo(**data) + + +# From: http://stackoverflow.com/a/4913653/646416 +def distance(lon1, lat1, lon2, lat2): + """ + Calculate the great circle distance in meters between two points specified + in decimal degrees on the earth using the Haversine algorithm. + """ + # convert decimal degrees to radians + lon1, lat1, lon2, lat2 = (radians(val) for val in (lon1, lat1, lon2, lat2)) + + dlon = lon2 - lon1 + dlat = lat2 - lat1 + angle = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 + # Radius of earth in meters. + radius = 6371000 + return 2 * radius * asin(sqrt(angle)) diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 5d32c087efe..966ecc1dcc2 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -1,6 +1,6 @@ """Helpers to install PyPi packages.""" -import os import logging +import os import pkg_resources import subprocess import sys @@ -15,25 +15,24 @@ def install_package(package, upgrade=True, target=None): """Install a package on PyPi. Accepts pip compatible package strings. Return boolean if install successfull.""" # Not using 'import pip; pip.main([])' because it breaks the logger - args = [sys.executable, '-m', 'pip', 'install', '--quiet', package] - - if upgrade: - args.append('--upgrade') - if target: - args += ['--target', os.path.abspath(target)] - with INSTALL_LOCK: if check_package_exists(package, target): return True _LOGGER.info('Attempting install of %s', package) + args = [sys.executable, '-m', 'pip', 'install', '--quiet', package] + if upgrade: + args.append('--upgrade') + if target: + args += ['--target', os.path.abspath(target)] + try: return 0 == subprocess.call(args) except subprocess.SubprocessError: return False -def check_package_exists(package, target=None): +def check_package_exists(package, target): """Check if a package exists. Returns True when the requirement is met. Returns False when the package is not installed or doesn't meet req.""" @@ -43,16 +42,5 @@ def check_package_exists(package, target=None): # This is a zip file req = pkg_resources.Requirement.parse(urlparse(package).fragment) - if target: - work_set = pkg_resources.WorkingSet([target]) - search_fun = work_set.find - - else: - search_fun = pkg_resources.get_distribution - - try: - result = search_fun(req) - except (pkg_resources.DistributionNotFound, pkg_resources.VersionConflict): - return False - - return bool(result) + return any(dist in req for dist in + pkg_resources.find_distributions(target)) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000000..5ee64771657 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/requirements_all.txt b/requirements_all.txt index f44b14d9ba7..77f725ed4f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -25,7 +25,7 @@ pyuserinput==0.1.9 tellcore-py==1.0.4 # Nmap bindings (device_tracker.nmap) -python-nmap==0.4.1 +python-nmap==0.4.3 # PushBullet bindings (notify.pushbullet) pushbullet.py==0.7.1 @@ -86,7 +86,7 @@ https://github.com/theolind/pymysensors/archive/35b87d880147a34107da0d40cb815d75 pynetgear==0.3 # Netdisco (discovery) -netdisco==0.3 +netdisco==0.4.1 # Wemo (switch.wemo) pywemo==0.3 @@ -130,3 +130,9 @@ https://github.com/balloob/home-assistant-nzb-clients/archive/616cad591540925992 # sensor.vera # light.vera https://github.com/balloob/home-assistant-vera-api/archive/a8f823066ead6c7da6fb5e7abaf16fef62e63364.zip#python-vera==0.1 + +# Sonos bindings (media_player.sonos) +SoCo==0.11.1 + +# PlexAPI (media_player.plex) +https://github.com/miniconfig/python-plexapi/archive/437e36dca3b7780dc0cb73941d662302c0cd2fa9.zip#python-plexapi==1.0.2 diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 00000000000..f4cb6753fe8 --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,9 @@ +#!/bin/sh + +# script/bootstrap: Resolve all dependencies that the application requires to +# run. + +cd "$(dirname "$0")/.." + +script/bootstrap_server +script/bootstrap_frontend diff --git a/script/bootstrap_frontend b/script/bootstrap_frontend new file mode 100755 index 00000000000..6fc94f95725 --- /dev/null +++ b/script/bootstrap_frontend @@ -0,0 +1,5 @@ +echo "Bootstrapping frontend..." +cd homeassistant/components/frontend/www_static/home-assistant-polymer +npm install +npm run setup_js_dev +cd ../../../../.. diff --git a/script/bootstrap_server b/script/bootstrap_server new file mode 100755 index 00000000000..8d71e01fa78 --- /dev/null +++ b/script/bootstrap_server @@ -0,0 +1,10 @@ +cd "$(dirname "$0")/.." + +echo "Update the submodule to latest version..." +git submodule update + +echo "Installing dependencies..." +python3 -m pip install --upgrade -r requirements_all.txt + +echo "Installing development dependencies.." +python3 -m pip install --upgrade flake8 pylint coveralls pytest pytest-cov diff --git a/scripts/build_frontend b/script/build_frontend similarity index 86% rename from scripts/build_frontend rename to script/build_frontend index 9554e82256d..70eacdb6baf 100755 --- a/scripts/build_frontend +++ b/script/build_frontend @@ -1,12 +1,8 @@ # Builds the frontend for production -# If current pwd is scripts, go 1 up. -if [ ${PWD##*/} == "scripts" ]; then - cd .. -fi +cd "$(dirname "$0")/.." cd homeassistant/components/frontend/www_static/home-assistant-polymer -npm install npm run frontend_prod cp bower_components/webcomponentsjs/webcomponents-lite.min.js .. diff --git a/scripts/build_python_openzwave b/script/build_python_openzwave similarity index 87% rename from scripts/build_python_openzwave rename to script/build_python_openzwave index 24bd8e2b64f..02c088fca44 100755 --- a/scripts/build_python_openzwave +++ b/script/build_python_openzwave @@ -3,10 +3,7 @@ # apt-get install cython3 libudev-dev python-sphinx python3-setuptools # pip3 install cython -# If current pwd is scripts, go 1 up. -if [ ${PWD##*/} == "scripts" ]; then - cd .. -fi +cd "$(dirname "$0")/.." if [ ! -d build ]; then mkdir build diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 00000000000..bd8ac963429 --- /dev/null +++ b/script/cibuild @@ -0,0 +1,14 @@ +#!/bin/sh + +# script/cibuild: Setup environment for CI to run tests. This is primarily +# designed to run on the continuous integration server. + +cd "$(dirname "$0")/.." + +script/test coverage + +STATUS=$? + +coveralls + +exit $STATUS diff --git a/scripts/dev_docker b/script/dev_docker similarity index 86% rename from scripts/dev_docker rename to script/dev_docker index b3672e56095..b63afaa36da 100755 --- a/scripts/dev_docker +++ b/script/dev_docker @@ -3,10 +3,7 @@ # Optional: pass in a timezone as first argument # If not given will attempt to mount /etc/localtime -# If current pwd is scripts, go 1 up. -if [ ${PWD##*/} == "scripts" ]; then - cd .. -fi +cd "$(dirname "$0")/.." docker build -t home-assistant-dev . diff --git a/scripts/dev_openzwave_docker b/script/dev_openzwave_docker similarity index 78% rename from scripts/dev_openzwave_docker rename to script/dev_openzwave_docker index f27816a8e39..387c38ef6da 100755 --- a/scripts/dev_openzwave_docker +++ b/script/dev_openzwave_docker @@ -1,10 +1,7 @@ # Open a docker that can be used to debug/dev python-openzwave # Pass in a command line argument to build -# If current pwd is scripts, go 1 up. -if [ ${PWD##*/} == "scripts" ]; then - cd .. -fi +cd "$(dirname "$0")/.." if [ $# -gt 0 ] then diff --git a/scripts/get_entities.py b/script/get_entities.py similarity index 100% rename from scripts/get_entities.py rename to script/get_entities.py diff --git a/scripts/hass-daemon b/script/hass-daemon old mode 100644 new mode 100755 similarity index 88% rename from scripts/hass-daemon rename to script/hass-daemon index d11c2669e87..bb14ce7f0a6 --- a/scripts/hass-daemon +++ b/script/hass-daemon @@ -34,25 +34,27 @@ RUN_AS="USER" PID_FILE="/var/run/hass.pid" CONFIG_DIR="/var/opt/homeassistant" FLAGS="-v --config $CONFIG_DIR --pid-file $PID_FILE --daemon" +REDIRECT="> $CONFIG_DIR/home-assistant.log 2>&1" start() { - if [ -f $PID_FILE ] && kill -0 $(cat $PID_FILE); then + if [ -f $PID_FILE ] && kill -0 $(cat $PID_FILE) 2> /dev/null; then echo 'Service already running' >&2 return 1 fi echo 'Starting service…' >&2 - local CMD="$PRE_EXEC hass $FLAGS;" + local CMD="$PRE_EXEC hass $FLAGS $REDIRECT;" su -c "$CMD" $RUN_AS echo 'Service started' >&2 } stop() { - if [ ! -f "$PID_FILE" ] || ! kill -0 $(cat "$PID_FILE"); then + if [ ! -f "$PID_FILE" ] || ! kill -0 $(cat "$PID_FILE") 2> /dev/null; then echo 'Service not running' >&2 return 1 fi echo 'Stopping service…' >&2 kill -3 $(cat "$PID_FILE") + while ps -p $(cat "$PID_FILE") > /dev/null 2>&1; do sleep 1;done; echo 'Service stopped' >&2 } diff --git a/script/home-assistant@.service b/script/home-assistant@.service new file mode 100644 index 00000000000..983844a95a3 --- /dev/null +++ b/script/home-assistant@.service @@ -0,0 +1,14 @@ +# This is a simple service file for systems with systemd to tun HA as user. +# +[Unit] +Description=Home Assistant for %i +After=network.target + +[Service] +Type=simple +User=%i +WorkingDirectory=%h +ExecStart=/usr/bin/hass --config %h/.homeassistant/ + +[Install] +WantedBy=multi-user.target diff --git a/script/lint b/script/lint new file mode 100755 index 00000000000..75667ef88a4 --- /dev/null +++ b/script/lint @@ -0,0 +1,19 @@ +# Run style checks + +cd "$(dirname "$0")/.." + +echo "Checking style with flake8..." +flake8 --exclude www_static homeassistant + +FLAKE8_STATUS=$? + +echo "Checking style with pylint..." +pylint homeassistant +PYLINT_STATUS=$? + +if [ $FLAKE8_STATUS -eq 0 ] +then + exit $PYLINT_STATUS +else + exit $FLAKE8_STATUS +fi diff --git a/script/release b/script/release new file mode 100755 index 00000000000..40d906b17bf --- /dev/null +++ b/script/release @@ -0,0 +1,21 @@ +# Pushes a new version to PyPi + +cd "$(dirname "$0")/.." + +head -n 3 homeassistant/const.py | tail -n 1 | grep dev + +if [ $? -eq 0 ] +then + echo "Release version should not contain dev tag" + exit 1 +fi + +CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD` + +if [ "$CURRENT_BRANCH" != "master" ] +then + echo "You have to be on the master branch to release." + exit 1 +fi + +python3 setup.py sdist bdist_wheel upload diff --git a/script/server b/script/server new file mode 100755 index 00000000000..0904bfd728e --- /dev/null +++ b/script/server @@ -0,0 +1,8 @@ +#!/bin/sh + +# script/server: Launch the application and any extra required processes +# locally. + +cd "$(dirname "$0")/.." + +python3 -m homeassistant -c config diff --git a/script/setup b/script/setup new file mode 100755 index 00000000000..6d3a774dd54 --- /dev/null +++ b/script/setup @@ -0,0 +1,5 @@ +cd "$(dirname "$0")/.." + +git submodule init +script/bootstrap +python3 setup.py develop diff --git a/script/test b/script/test new file mode 100755 index 00000000000..d407f57a338 --- /dev/null +++ b/script/test @@ -0,0 +1,27 @@ +#!/bin/sh + +# script/test: Run test suite for application. Optionallly pass in a path to an +# individual test file to run a single test. + +cd "$(dirname "$0")/.." + +script/lint + +LINT_STATUS=$? + +echo "Running tests..." + +if [ "$1" = "coverage" ]; then + py.test --cov --cov-report= + TEST_STATUS=$? +else + py.test + TEST_STATUS=$? +fi + +if [ $LINT_STATUS -eq 0 ] +then + exit $TEST_STATUS +else + exit $LINT_STATUS +fi diff --git a/script/update b/script/update new file mode 100755 index 00000000000..9f8b2530a7e --- /dev/null +++ b/script/update @@ -0,0 +1,8 @@ +#!/bin/sh + +# script/update: Update application to run for its current checkout. + +cd "$(dirname "$0")/.." + +git pull +git submodule update diff --git a/scripts/check_style b/scripts/check_style deleted file mode 100755 index 5fc8861b91a..00000000000 --- a/scripts/check_style +++ /dev/null @@ -1,9 +0,0 @@ -# Run style checks - -# If current pwd is scripts, go 1 up. -if [ ${PWD##*/} == "scripts" ]; then - cd .. -fi - -flake8 homeassistant -pylint homeassistant diff --git a/scripts/run_tests b/scripts/run_tests deleted file mode 100755 index 75b25ca805a..00000000000 --- a/scripts/run_tests +++ /dev/null @@ -1,10 +0,0 @@ -# If current pwd is scripts, go 1 up. -if [ ${PWD##*/} == "scripts" ]; then - cd .. -fi - -if [ "$1" = "coverage" ]; then - coverage run -m unittest discover tests -else - python3 -m unittest discover tests -fi diff --git a/scripts/update b/scripts/update deleted file mode 100755 index be5e8fc01bf..00000000000 --- a/scripts/update +++ /dev/null @@ -1,6 +0,0 @@ -echo "The update script has been deprecated since Home Assistant v0.7" -echo -echo "Home Assistant is now distributed via PyPi and can be installed and" -echo "upgraded by running: pip3 install --upgrade homeassistant" -echo -echo "If you are developing a new feature for Home Assistant, run: git pull" diff --git a/setup.py b/setup.py index ce8a75072ac..fde7f9bf898 100755 --- a/setup.py +++ b/setup.py @@ -12,7 +12,8 @@ PACKAGES = find_packages(exclude=['tests', 'tests.*']) PACKAGE_DATA = \ {'homeassistant.components.frontend': ['index.html.template'], 'homeassistant.components.frontend.www_static': ['*.*'], - 'homeassistant.components.frontend.www_static.images': ['*.*']} + 'homeassistant.components.frontend.www_static.images': ['*.*'], + 'homeassistant.startup': ['*.*']} REQUIRES = [ 'requests>=2,<3', diff --git a/tests/common.py b/tests/common.py index 72be8c5b735..830b21ed47c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,11 +10,11 @@ from unittest import mock from homeassistant import core as ha, loader import homeassistant.util.location as location_util -import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import ToggleEntity from homeassistant.const import ( STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, EVENT_TIME_CHANGED, - EVENT_STATE_CHANGED) + EVENT_STATE_CHANGED, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, + ATTR_DISCOVERED) from homeassistant.components import sun, mqtt @@ -38,8 +38,8 @@ def get_test_home_assistant(num_threads=None): hass.config.latitude = 32.87336 hass.config.longitude = -117.22743 - # if not loader.PREPARED: - loader. prepare(hass) + if 'custom_components.test' not in loader.AVAILABLE_COMPONENTS: + loader.prepare(hass) return hass @@ -86,10 +86,11 @@ def fire_time_changed(hass, time): hass.bus.fire(EVENT_TIME_CHANGED, {'now': time}) -def trigger_device_tracker_scan(hass): - """ Triggers the device tracker to scan. """ - fire_time_changed( - hass, dt_util.utcnow().replace(second=0) + timedelta(hours=1)) +def fire_service_discovered(hass, service, info): + hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { + ATTR_SERVICE: service, + ATTR_DISCOVERED: info + }) def ensure_sun_risen(hass): diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index a2c36283c9a..465faf4ec8f 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -1,15 +1,13 @@ """ -tests.test_component_demo -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +tests.components.automation.test_event +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Tests demo component. +Tests event automation. """ import unittest import homeassistant.core as ha import homeassistant.components.automation as automation -import homeassistant.components.automation.event as event -from homeassistant.const import CONF_PLATFORM class TestAutomationEvent(unittest.TestCase): @@ -28,20 +26,57 @@ class TestAutomationEvent(unittest.TestCase): """ Stop down stuff we started. """ self.hass.stop() - def test_fails_setup_if_no_event_type(self): - self.assertFalse(automation.setup(self.hass, { + def test_old_config_if_fires_on_event(self): + self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'event', - automation.CONF_SERVICE: 'test.automation' + 'platform': 'event', + 'event_type': 'test_event', + 'execute_service': 'test.automation' } })) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_old_config_if_fires_on_event_with_data(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'event', + 'event_type': 'test_event', + 'event_data': {'some_attr': 'some_value'}, + 'execute_service': 'test.automation' + } + })) + + self.hass.bus.fire('test_event', {'some_attr': 'some_value'}) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_old_config_if_not_fires_if_event_data_not_matches(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'event', + 'event_type': 'test_event', + 'event_data': {'some_attr': 'some_value'}, + 'execute_service': 'test.automation' + } + })) + + self.hass.bus.fire('test_event', {'some_attr': 'some_other_value'}) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + def test_if_fires_on_event(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'event', - event.CONF_EVENT_TYPE: 'test_event', - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + } } })) @@ -52,24 +87,33 @@ class TestAutomationEvent(unittest.TestCase): def test_if_fires_on_event_with_data(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'event', - event.CONF_EVENT_TYPE: 'test_event', - event.CONF_EVENT_DATA: {'some_attr': 'some_value'}, - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + 'event_data': {'some_attr': 'some_value'} + }, + 'action': { + 'service': 'test.automation', + } } })) - self.hass.bus.fire('test_event', {'some_attr': 'some_value'}) + self.hass.bus.fire('test_event', {'some_attr': 'some_value', + 'another': 'value'}) self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) def test_if_not_fires_if_event_data_not_matches(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'event', - event.CONF_EVENT_TYPE: 'test_event', - event.CONF_EVENT_DATA: {'some_attr': 'some_value'}, - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + 'event_data': {'some_attr': 'some_value'} + }, + 'action': { + 'service': 'test.automation', + } } })) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 507c37dc20a..3f6b0dab6f1 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,18 +1,16 @@ """ -tests.test_component_demo -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Tests demo component. +tests.components.automation.test_init +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Tests automation component. """ import unittest import homeassistant.core as ha import homeassistant.components.automation as automation -import homeassistant.components.automation.event as event -from homeassistant.const import CONF_PLATFORM, ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID -class TestAutomationEvent(unittest.TestCase): +class TestAutomation(unittest.TestCase): """ Test the event automation. """ def setUp(self): # pylint: disable=invalid-name @@ -28,20 +26,78 @@ class TestAutomationEvent(unittest.TestCase): """ Stop down stuff we started. """ self.hass.stop() - def test_setup_fails_if_unknown_platform(self): - self.assertFalse(automation.setup(self.hass, { + def test_old_config_service_data_not_a_dict(self): + automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'i_do_not_exist' + 'platform': 'event', + 'event_type': 'test_event', + 'execute_service': 'test.automation', + 'service_data': 100 } - })) + }) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_old_config_service_specify_data(self): + automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'event', + 'event_type': 'test_event', + 'execute_service': 'test.automation', + 'service_data': {'some': 'data'} + } + }) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual('data', self.calls[0].data['some']) + + def test_old_config_service_specify_entity_id(self): + automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'event', + 'event_type': 'test_event', + 'execute_service': 'test.automation', + 'service_entity_id': 'hello.world' + } + }) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual(['hello.world'], + self.calls[0].data.get(ATTR_ENTITY_ID)) + + def test_old_config_service_specify_entity_id_list(self): + automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'event', + 'event_type': 'test_event', + 'execute_service': 'test.automation', + 'service_entity_id': ['hello.world', 'hello.world2'] + } + }) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual(['hello.world', 'hello.world2'], + self.calls[0].data.get(ATTR_ENTITY_ID)) def test_service_data_not_a_dict(self): automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'event', - event.CONF_EVENT_TYPE: 'test_event', - automation.CONF_SERVICE: 'test.automation', - automation.CONF_SERVICE_DATA: 100 + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + 'data': 100, + } } }) @@ -52,10 +108,14 @@ class TestAutomationEvent(unittest.TestCase): def test_service_specify_data(self): automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'event', - event.CONF_EVENT_TYPE: 'test_event', - automation.CONF_SERVICE: 'test.automation', - automation.CONF_SERVICE_DATA: {'some': 'data'} + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + 'data': {'some': 'data'} + } } }) @@ -67,29 +127,247 @@ class TestAutomationEvent(unittest.TestCase): def test_service_specify_entity_id(self): automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'event', - event.CONF_EVENT_TYPE: 'test_event', - automation.CONF_SERVICE: 'test.automation', - automation.CONF_SERVICE_ENTITY_ID: 'hello.world' + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + 'entity_id': 'hello.world' + } } }) self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) - self.assertEqual(['hello.world'], self.calls[0].data[ATTR_ENTITY_ID]) + self.assertEqual(['hello.world'], + self.calls[0].data.get(ATTR_ENTITY_ID)) def test_service_specify_entity_id_list(self): automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'event', - event.CONF_EVENT_TYPE: 'test_event', - automation.CONF_SERVICE: 'test.automation', - automation.CONF_SERVICE_ENTITY_ID: ['hello.world', 'hello.world2'] + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + 'entity_id': ['hello.world', 'hello.world2'] + } } }) self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) - self.assertEqual(['hello.world', 'hello.world2'], self.calls[0].data[ATTR_ENTITY_ID]) + self.assertEqual(['hello.world', 'hello.world2'], + self.calls[0].data.get(ATTR_ENTITY_ID)) + + def test_two_triggers(self): + automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': [ + { + 'platform': 'event', + 'event_type': 'test_event', + }, + { + 'platform': 'state', + 'entity_id': 'test.entity', + } + ], + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + self.hass.states.set('test.entity', 'hello') + self.hass.pool.block_till_done() + self.assertEqual(2, len(self.calls)) + + def test_two_conditions_with_and(self): + entity_id = 'test.entity' + automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': [ + { + 'platform': 'event', + 'event_type': 'test_event', + }, + ], + 'condition': [ + { + 'platform': 'state', + 'entity_id': entity_id, + 'state': 100 + }, + { + 'platform': 'numeric_state', + 'entity_id': entity_id, + 'below': 150 + } + ], + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set(entity_id, 100) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + self.hass.states.set(entity_id, 101) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + self.hass.states.set(entity_id, 151) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_two_conditions_with_or(self): + entity_id = 'test.entity' + automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': [ + { + 'platform': 'event', + 'event_type': 'test_event', + }, + ], + 'condition_type': 'OR', + 'condition': [ + { + 'platform': 'state', + 'entity_id': entity_id, + 'state': 200 + }, + { + 'platform': 'numeric_state', + 'entity_id': entity_id, + 'below': 150 + } + ], + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set(entity_id, 200) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + self.hass.states.set(entity_id, 100) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(2, len(self.calls)) + + self.hass.states.set(entity_id, 250) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(2, len(self.calls)) + + def test_using_trigger_as_condition(self): + """ """ + entity_id = 'test.entity' + automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': [ + { + 'platform': 'state', + 'entity_id': entity_id, + 'state': 100 + }, + { + 'platform': 'numeric_state', + 'entity_id': entity_id, + 'below': 150 + } + ], + 'condition': 'use_trigger_values', + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set(entity_id, 100) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + self.hass.states.set(entity_id, 120) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + self.hass.states.set(entity_id, 151) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_using_trigger_as_condition_with_invalid_condition(self): + """ Event is not a valid condition. Will it still work? """ + entity_id = 'test.entity' + self.hass.states.set(entity_id, 100) + automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': [ + { + 'platform': 'event', + 'event_type': 'test_event', + }, + { + 'platform': 'numeric_state', + 'entity_id': entity_id, + 'below': 150 + } + ], + 'condition': 'use_trigger_values', + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_automation_list_setting(self): + """ Event is not a valid condition. Will it still work? """ + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: [{ + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + + 'action': { + 'service': 'test.automation', + } + }, { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event_2', + }, + 'action': { + 'service': 'test.automation', + } + }] + })) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + self.hass.bus.fire('test_event_2') + self.hass.pool.block_till_done() + self.assertEqual(2, len(self.calls)) diff --git a/tests/components/automation/test_mqtt.py b/tests/components/automation/test_mqtt.py index 9402b5300b6..174ef91f1c4 100644 --- a/tests/components/automation/test_mqtt.py +++ b/tests/components/automation/test_mqtt.py @@ -1,16 +1,13 @@ """ -tests.test_component_demo -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +tests.components.automation.test_mqtt +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Tests demo component. +Tests mqtt automation. """ import unittest import homeassistant.core as ha import homeassistant.components.automation as automation -import homeassistant.components.automation.mqtt as mqtt -from homeassistant.const import CONF_PLATFORM - from tests.common import mock_mqtt_component, fire_mqtt_message @@ -31,20 +28,57 @@ class TestAutomationState(unittest.TestCase): """ Stop down stuff we started. """ self.hass.stop() - def test_setup_fails_if_no_topic(self): - self.assertFalse(automation.setup(self.hass, { + def test_old_config_if_fires_on_topic_match(self): + self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'mqtt', - automation.CONF_SERVICE: 'test.automation' + 'platform': 'mqtt', + 'mqtt_topic': 'test-topic', + 'execute_service': 'test.automation' } })) + fire_mqtt_message(self.hass, 'test-topic', '') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_old_config_if_fires_on_topic_and_payload_match(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'mqtt', + 'mqtt_topic': 'test-topic', + 'mqtt_payload': 'hello', + 'execute_service': 'test.automation' + } + })) + + fire_mqtt_message(self.hass, 'test-topic', 'hello') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_old_config_if_not_fires_on_topic_but_no_payload_match(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'mqtt', + 'mqtt_topic': 'test-topic', + 'mqtt_payload': 'hello', + 'execute_service': 'test.automation' + } + })) + + fire_mqtt_message(self.hass, 'test-topic', 'no-hello') + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + def test_if_fires_on_topic_match(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'mqtt', - mqtt.CONF_TOPIC: 'test-topic', - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'mqtt', + 'topic': 'test-topic' + }, + 'action': { + 'service': 'test.automation' + } } })) @@ -55,10 +89,14 @@ class TestAutomationState(unittest.TestCase): def test_if_fires_on_topic_and_payload_match(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'mqtt', - mqtt.CONF_TOPIC: 'test-topic', - mqtt.CONF_PAYLOAD: 'hello', - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'mqtt', + 'topic': 'test-topic', + 'payload': 'hello' + }, + 'action': { + 'service': 'test.automation' + } } })) @@ -69,10 +107,14 @@ class TestAutomationState(unittest.TestCase): def test_if_not_fires_on_topic_but_no_payload_match(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'mqtt', - mqtt.CONF_TOPIC: 'test-topic', - mqtt.CONF_PAYLOAD: 'hello', - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'mqtt', + 'topic': 'test-topic', + 'payload': 'hello' + }, + 'action': { + 'service': 'test.automation' + } } })) diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py new file mode 100644 index 00000000000..a04b8d01f4e --- /dev/null +++ b/tests/components/automation/test_numeric_state.py @@ -0,0 +1,293 @@ +""" +tests.components.automation.test_numeric_state +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests numeric state automation. +""" +import unittest + +import homeassistant.core as ha +import homeassistant.components.automation as automation + + +class TestAutomationNumericState(unittest.TestCase): + """ Test the event automation. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + self.calls = [] + + def record_call(service): + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_if_fires_on_entity_change_below(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'below': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + # 9 is below 10 + self.hass.states.set('test.entity', 9) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_over_to_below(self): + self.hass.states.set('test.entity', 11) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'below': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + # 9 is below 10 + self.hass.states.set('test.entity', 9) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_on_entity_change_below_to_below(self): + self.hass.states.set('test.entity', 9) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'below': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + # 9 is below 10 so this should not fire again + self.hass.states.set('test.entity', 8) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_entity_change_above(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + # 11 is above 10 + self.hass.states.set('test.entity', 11) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_below_to_above(self): + # set initial state + self.hass.states.set('test.entity', 9) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + # 11 is above 10 and 9 is below + self.hass.states.set('test.entity', 11) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_on_entity_change_above_to_above(self): + # set initial state + self.hass.states.set('test.entity', 11) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + # 11 is above 10 so this should fire again + self.hass.states.set('test.entity', 12) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_entity_change_below_range(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'below': 10, + 'above': 5, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + # 9 is below 10 + self.hass.states.set('test.entity', 9) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_below_above_range(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'below': 10, + 'above': 5, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + # 4 is below 5 + self.hass.states.set('test.entity', 4) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_entity_change_over_to_below_range(self): + self.hass.states.set('test.entity', 11) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'below': 10, + 'above': 5, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + # 9 is below 10 + self.hass.states.set('test.entity', 9) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_over_to_below_above_range(self): + self.hass.states.set('test.entity', 11) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'below': 10, + 'above': 5, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + # 4 is below 5 so it should not fire + self.hass.states.set('test.entity', 4) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_not_fires_if_entity_not_match(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.another_entity', + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + self.hass.states.set('test.entity', 11) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_action(self): + entity_id = 'domain.test_entity' + test_state = 10 + automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'condition': { + 'platform': 'numeric_state', + 'entity_id': entity_id, + 'above': test_state, + 'below': test_state + 2 + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + self.hass.states.set(entity_id, test_state) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + self.hass.states.set(entity_id, test_state - 1) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + self.hass.states.set(entity_id, test_state + 1) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(2, len(self.calls)) diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index 47d612cbb02..a7c13e866c6 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -1,15 +1,13 @@ """ -tests.test_component_demo -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +tests.components.automation.test_state +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Tests demo component. +Tests state automation. """ import unittest import homeassistant.core as ha import homeassistant.components.automation as automation -import homeassistant.components.automation.state as state -from homeassistant.const import CONF_PLATFORM class TestAutomationState(unittest.TestCase): @@ -29,20 +27,145 @@ class TestAutomationState(unittest.TestCase): """ Stop down stuff we started. """ self.hass.stop() - def test_setup_fails_if_no_entity_id(self): - self.assertFalse(automation.setup(self.hass, { + def test_old_config_if_fires_on_entity_change(self): + self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'state', - automation.CONF_SERVICE: 'test.automation' + 'platform': 'state', + 'state_entity_id': 'test.entity', + 'execute_service': 'test.automation' } })) + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_old_config_if_fires_on_entity_change_with_from_filter(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'state', + 'state_entity_id': 'test.entity', + 'state_from': 'hello', + 'execute_service': 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_old_config_if_fires_on_entity_change_with_to_filter(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'state', + 'state_entity_id': 'test.entity', + 'state_to': 'world', + 'execute_service': 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_old_config_if_fires_on_entity_change_with_both_filters(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'state', + 'state_entity_id': 'test.entity', + 'state_from': 'hello', + 'state_to': 'world', + 'execute_service': 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_old_config_if_not_fires_if_to_filter_not_match(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'state', + 'state_entity_id': 'test.entity', + 'state_from': 'hello', + 'state_to': 'world', + 'execute_service': 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'moon') + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_old_config_if_not_fires_if_from_filter_not_match(self): + self.hass.states.set('test.entity', 'bye') + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'state', + 'state_entity_id': 'test.entity', + 'state_from': 'hello', + 'state_to': 'world', + 'execute_service': 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_old_config_if_not_fires_if_entity_not_match(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'state', + 'state_entity_id': 'test.another_entity', + 'execute_service': 'test.automation' + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_old_config_if_action(self): + entity_id = 'domain.test_entity' + test_state = 'new_state' + automation.setup(self.hass, { + automation.DOMAIN: { + 'platform': 'event', + 'event_type': 'test_event', + 'execute_service': 'test.automation', + 'if': [{ + 'platform': 'state', + 'entity_id': entity_id, + 'state': test_state, + }] + } + }) + + self.hass.states.set(entity_id, test_state) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + self.hass.states.set(entity_id, test_state + 'something') + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_entity_change(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'state', - state.CONF_ENTITY_ID: 'test.entity', - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + }, + 'action': { + 'service': 'test.automation' + } } })) @@ -53,10 +176,14 @@ class TestAutomationState(unittest.TestCase): def test_if_fires_on_entity_change_with_from_filter(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'state', - state.CONF_ENTITY_ID: 'test.entity', - state.CONF_FROM: 'hello', - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + 'from': 'hello' + }, + 'action': { + 'service': 'test.automation' + } } })) @@ -67,10 +194,32 @@ class TestAutomationState(unittest.TestCase): def test_if_fires_on_entity_change_with_to_filter(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'state', - state.CONF_ENTITY_ID: 'test.entity', - state.CONF_TO: 'world', - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + 'to': 'world' + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_with_state_filter(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + 'state': 'world' + }, + 'action': { + 'service': 'test.automation' + } } })) @@ -81,11 +230,15 @@ class TestAutomationState(unittest.TestCase): def test_if_fires_on_entity_change_with_both_filters(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'state', - state.CONF_ENTITY_ID: 'test.entity', - state.CONF_FROM: 'hello', - state.CONF_TO: 'world', - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + 'from': 'hello', + 'to': 'world' + }, + 'action': { + 'service': 'test.automation' + } } })) @@ -96,11 +249,15 @@ class TestAutomationState(unittest.TestCase): def test_if_not_fires_if_to_filter_not_match(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'state', - state.CONF_ENTITY_ID: 'test.entity', - state.CONF_FROM: 'hello', - state.CONF_TO: 'world', - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + 'from': 'hello', + 'to': 'world' + }, + 'action': { + 'service': 'test.automation' + } } })) @@ -113,11 +270,15 @@ class TestAutomationState(unittest.TestCase): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'state', - state.CONF_ENTITY_ID: 'test.entity', - state.CONF_FROM: 'hello', - state.CONF_TO: 'world', - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + 'from': 'hello', + 'to': 'world' + }, + 'action': { + 'service': 'test.automation' + } } })) @@ -128,12 +289,48 @@ class TestAutomationState(unittest.TestCase): def test_if_not_fires_if_entity_not_match(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'state', - state.CONF_ENTITY_ID: 'test.another_entity', - automation.CONF_SERVICE: 'test.automation' + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.anoter_entity', + }, + 'action': { + 'service': 'test.automation' + } } })) self.hass.states.set('test.entity', 'world') self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) + + def test_if_action(self): + entity_id = 'domain.test_entity' + test_state = 'new_state' + automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'condition': [{ + 'platform': 'state', + 'entity_id': entity_id, + 'state': test_state + }], + 'action': { + 'service': 'test.automation' + } + } + }) + + self.hass.states.set(entity_id, test_state) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + self.hass.states.set(entity_id, test_state + 'something') + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) diff --git a/tests/components/automation/test_sun.py b/tests/components/automation/test_sun.py new file mode 100644 index 00000000000..de8b2f8121b --- /dev/null +++ b/tests/components/automation/test_sun.py @@ -0,0 +1,141 @@ +""" +tests.components.automation.test_sun +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests sun automation. +""" +from datetime import datetime +import unittest +from unittest.mock import patch + +import homeassistant.core as ha +from homeassistant.components import sun +import homeassistant.components.automation as automation +import homeassistant.util.dt as dt_util + +from tests.common import fire_time_changed + + +class TestAutomationSun(unittest.TestCase): + """ Test the sun automation. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + self.hass.config.components.append('sun') + + self.calls = [] + + def record_call(service): + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_sunset_trigger(self): + self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { + sun.STATE_ATTR_NEXT_SETTING: '02:00:00 16-09-2015', + }) + + now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) + trigger_time = datetime(2015, 9, 16, 2, tzinfo=dt_util.UTC) + + with patch('homeassistant.components.automation.sun.dt_util.utcnow', + return_value=now): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'sun', + 'event': 'sunset', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + fire_time_changed(self.hass, trigger_time) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_sunrise_trigger(self): + self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { + sun.STATE_ATTR_NEXT_RISING: '14:00:00 16-09-2015', + }) + + now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC) + trigger_time = datetime(2015, 9, 16, 14, tzinfo=dt_util.UTC) + + with patch('homeassistant.components.automation.sun.dt_util.utcnow', + return_value=now): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'sun', + 'event': 'sunrise', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + fire_time_changed(self.hass, trigger_time) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_sunset_trigger_with_offset(self): + self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { + sun.STATE_ATTR_NEXT_SETTING: '02:00:00 16-09-2015', + }) + + now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) + trigger_time = datetime(2015, 9, 16, 2, 30, tzinfo=dt_util.UTC) + + with patch('homeassistant.components.automation.sun.dt_util.utcnow', + return_value=now): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'sun', + 'event': 'sunset', + 'offset': '0:30:00' + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + fire_time_changed(self.hass, trigger_time) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_sunrise_trigger_with_offset(self): + self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, { + sun.STATE_ATTR_NEXT_RISING: '14:00:00 16-09-2015', + }) + + now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC) + trigger_time = datetime(2015, 9, 16, 13, 30, tzinfo=dt_util.UTC) + + with patch('homeassistant.components.automation.sun.dt_util.utcnow', + return_value=now): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'sun', + 'event': 'sunrise', + 'offset': '-0:30:00' + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + fire_time_changed(self.hass, trigger_time) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index 05c5ade1d53..e233c93988d 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -1,16 +1,17 @@ """ -tests.test_component_demo -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +tests.components.automation.test_time +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Tests demo component. +Tests time automation. """ +from datetime import timedelta import unittest +from unittest.mock import patch import homeassistant.core as ha -import homeassistant.loader as loader import homeassistant.util.dt as dt_util import homeassistant.components.automation as automation -import homeassistant.components.automation.time as time +from homeassistant.components.automation import time, event from homeassistant.const import CONF_PLATFORM from tests.common import fire_time_changed @@ -32,12 +33,12 @@ class TestAutomationTime(unittest.TestCase): """ Stop down stuff we started. """ self.hass.stop() - def test_if_fires_when_hour_matches(self): + def test_old_config_if_fires_when_hour_matches(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'time', + 'platform': 'time', time.CONF_HOURS: 0, - automation.CONF_SERVICE: 'test.automation' + 'execute_service': 'test.automation' } })) @@ -47,12 +48,12 @@ class TestAutomationTime(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) - def test_if_fires_when_minute_matches(self): + def test_old_config_if_fires_when_minute_matches(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'time', + 'platform': 'time', time.CONF_MINUTES: 0, - automation.CONF_SERVICE: 'test.automation' + 'execute_service': 'test.automation' } })) @@ -62,12 +63,12 @@ class TestAutomationTime(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) - def test_if_fires_when_second_matches(self): + def test_old_config_if_fires_when_second_matches(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { - CONF_PLATFORM: 'time', + 'platform': 'time', time.CONF_SECONDS: 0, - automation.CONF_SERVICE: 'test.automation' + 'execute_service': 'test.automation' } })) @@ -77,14 +78,14 @@ class TestAutomationTime(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) - def test_if_fires_when_all_matches(self): + def test_old_config_if_fires_when_all_matches(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { CONF_PLATFORM: 'time', time.CONF_HOURS: 0, time.CONF_MINUTES: 0, time.CONF_SECONDS: 0, - automation.CONF_SERVICE: 'test.automation' + 'execute_service': 'test.automation' } })) @@ -94,3 +95,397 @@ class TestAutomationTime(unittest.TestCase): self.hass.states.set('test.entity', 'world') self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) + + def test_old_config_if_action_before(self): + automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + event.CONF_EVENT_TYPE: 'test_event', + 'execute_service': 'test.automation', + 'if': { + CONF_PLATFORM: 'time', + time.CONF_BEFORE: '10:00' + } + } + }) + + before_10 = dt_util.now().replace(hour=8) + after_10 = dt_util.now().replace(hour=14) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=before_10): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=after_10): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_old_config_if_action_after(self): + automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + event.CONF_EVENT_TYPE: 'test_event', + 'execute_service': 'test.automation', + 'if': { + CONF_PLATFORM: 'time', + time.CONF_AFTER: '10:00' + } + } + }) + + before_10 = dt_util.now().replace(hour=8) + after_10 = dt_util.now().replace(hour=14) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=before_10): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(0, len(self.calls)) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=after_10): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_old_config_if_action_one_weekday(self): + automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + event.CONF_EVENT_TYPE: 'test_event', + 'execute_service': 'test.automation', + 'if': { + CONF_PLATFORM: 'time', + time.CONF_WEEKDAY: 'mon', + } + } + }) + + days_past_monday = dt_util.now().weekday() + monday = dt_util.now() - timedelta(days=days_past_monday) + tuesday = monday + timedelta(days=1) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=monday): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=tuesday): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_old_config_if_action_list_weekday(self): + automation.setup(self.hass, { + automation.DOMAIN: { + CONF_PLATFORM: 'event', + event.CONF_EVENT_TYPE: 'test_event', + 'execute_service': 'test.automation', + 'if': { + CONF_PLATFORM: 'time', + time.CONF_WEEKDAY: ['mon', 'tue'], + } + } + }) + + days_past_monday = dt_util.now().weekday() + monday = dt_util.now() - timedelta(days=days_past_monday) + tuesday = monday + timedelta(days=1) + wednesday = tuesday + timedelta(days=1) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=monday): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=tuesday): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(2, len(self.calls)) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=wednesday): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(2, len(self.calls)) + + def test_if_fires_when_hour_matches(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time', + 'hours': 0, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + fire_time_changed(self.hass, dt_util.utcnow().replace(hour=0)) + + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_when_minute_matches(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time', + 'minutes': 0, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + fire_time_changed(self.hass, dt_util.utcnow().replace(minute=0)) + + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_when_second_matches(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time', + 'seconds': 0, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + fire_time_changed(self.hass, dt_util.utcnow().replace(second=0)) + + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_when_all_matches(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time', + 'hours': 1, + 'minutes': 2, + 'seconds': 3, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + fire_time_changed(self.hass, dt_util.utcnow().replace( + hour=1, minute=2, second=3)) + + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_using_after(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time', + 'after': '5:00:00', + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + fire_time_changed(self.hass, dt_util.utcnow().replace( + hour=5, minute=0, second=0)) + + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + @patch('homeassistant.components.automation.time._LOGGER.error') + def test_if_not_fires_using_wrong_after(self, mock_error): + """ YAML translates time values to total seconds. This should break the + before rule. """ + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'time', + 'after': 3605, + # Total seconds. Hour = 3600 second + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + fire_time_changed(self.hass, dt_util.utcnow().replace( + hour=1, minute=0, second=5)) + + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + self.assertEqual(2, mock_error.call_count) + + def test_if_action_before(self): + automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event' + }, + 'condition': { + 'platform': 'time', + 'before': '10:00', + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + before_10 = dt_util.now().replace(hour=8) + after_10 = dt_util.now().replace(hour=14) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=before_10): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=after_10): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_action_after(self): + automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event' + }, + 'condition': { + 'platform': 'time', + 'after': '10:00', + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + before_10 = dt_util.now().replace(hour=8) + after_10 = dt_util.now().replace(hour=14) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=before_10): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(0, len(self.calls)) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=after_10): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_action_one_weekday(self): + automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event' + }, + 'condition': { + 'platform': 'time', + 'weekday': 'mon', + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + days_past_monday = dt_util.now().weekday() + monday = dt_util.now() - timedelta(days=days_past_monday) + tuesday = monday + timedelta(days=1) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=monday): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=tuesday): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_action_list_weekday(self): + automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event' + }, + 'condition': { + 'platform': 'time', + 'weekday': ['mon', 'tue'], + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + days_past_monday = dt_util.now().weekday() + monday = dt_util.now() - timedelta(days=days_past_monday) + tuesday = monday + timedelta(days=1) + wednesday = tuesday + timedelta(days=1) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=monday): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=tuesday): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(2, len(self.calls)) + + with patch('homeassistant.components.automation.time.dt_util.now', + return_value=wednesday): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + self.assertEqual(2, len(self.calls)) diff --git a/tests/components/device_tracker/__init__.py b/tests/components/device_tracker/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py new file mode 100644 index 00000000000..fb368bf863a --- /dev/null +++ b/tests/components/device_tracker/test_init.py @@ -0,0 +1,243 @@ +""" +tests.test_component_device_tracker +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests the device tracker compoments. +""" +# pylint: disable=protected-access,too-many-public-methods +import unittest +from unittest.mock import patch +from datetime import datetime, timedelta +import os + +from homeassistant.config import load_yaml_config_file +from homeassistant.loader import get_component +import homeassistant.util.dt as dt_util +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, + STATE_HOME, STATE_NOT_HOME, CONF_PLATFORM, DEVICE_DEFAULT_NAME) +import homeassistant.components.device_tracker as device_tracker + +from tests.common import ( + get_test_home_assistant, fire_time_changed, fire_service_discovered) + + +class TestComponentsDeviceTracker(unittest.TestCase): + """ Tests homeassistant.components.device_tracker module. """ + + def setUp(self): # pylint: disable=invalid-name + """ Init needed objects. """ + self.hass = get_test_home_assistant() + self.yaml_devices = self.hass.config.path(device_tracker.YAML_DEVICES) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + try: + os.remove(self.yaml_devices) + except FileNotFoundError: + pass + + self.hass.stop() + + def test_is_on(self): + """ Test is_on method. """ + entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') + + self.hass.states.set(entity_id, STATE_HOME) + + self.assertTrue(device_tracker.is_on(self.hass, entity_id)) + + self.hass.states.set(entity_id, STATE_NOT_HOME) + + self.assertFalse(device_tracker.is_on(self.hass, entity_id)) + + def test_migrating_config(self): + csv_devices = self.hass.config.path(device_tracker.CSV_DEVICES) + + self.assertFalse(os.path.isfile(csv_devices)) + self.assertFalse(os.path.isfile(self.yaml_devices)) + + person1 = { + 'mac': 'AB:CD:EF:GH:IJ:KL', + 'name': 'Paulus', + 'track': True, + 'picture': 'http://placehold.it/200x200', + } + person2 = { + 'mac': 'MN:OP:QR:ST:UV:WX:YZ', + 'name': '', + 'track': False, + 'picture': None, + } + + try: + with open(csv_devices, 'w') as fil: + fil.write('device,name,track,picture\n') + for pers in (person1, person2): + fil.write('{},{},{},{}\n'.format( + pers['mac'], pers['name'], + '1' if pers['track'] else '0', pers['picture'] or '')) + + self.assertTrue(device_tracker.setup(self.hass, {})) + self.assertFalse(os.path.isfile(csv_devices)) + self.assertTrue(os.path.isfile(self.yaml_devices)) + + yaml_config = load_yaml_config_file(self.yaml_devices) + + self.assertEqual(2, len(yaml_config)) + + for pers, yaml_pers in zip( + (person1, person2), sorted(yaml_config.values(), + key=lambda pers: pers['mac'])): + for key, value in pers.items(): + if key == 'name' and value == '': + value = DEVICE_DEFAULT_NAME + self.assertEqual(value, yaml_pers.get(key)) + + finally: + try: + os.remove(csv_devices) + except FileNotFoundError: + pass + + def test_reading_yaml_config(self): + dev_id = 'test' + device = device_tracker.Device( + self.hass, timedelta(seconds=180), 0, True, dev_id, + 'AB:CD:EF:GH:IJ', 'Test name', 'http://test.picture', True) + device_tracker.update_config(self.yaml_devices, dev_id, device) + self.assertTrue(device_tracker.setup(self.hass, {})) + config = device_tracker.load_config(self.yaml_devices, self.hass, + device.consider_home, 0)[0] + self.assertEqual(device.dev_id, config.dev_id) + self.assertEqual(device.track, config.track) + self.assertEqual(device.mac, config.mac) + self.assertEqual(device.config_picture, config.config_picture) + self.assertEqual(device.away_hide, config.away_hide) + self.assertEqual(device.consider_home, config.consider_home) + + def test_setup_without_yaml_file(self): + self.assertTrue(device_tracker.setup(self.hass, {})) + + def test_adding_unknown_device_to_config(self): + scanner = get_component('device_tracker.test').SCANNER + scanner.reset() + scanner.come_home('DEV1') + self.assertTrue(device_tracker.setup(self.hass, { + device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}})) + config = device_tracker.load_config(self.yaml_devices, self.hass, + timedelta(seconds=0), 0)[0] + self.assertEqual('dev1', config.dev_id) + self.assertEqual(True, config.track) + + def test_discovery(self): + scanner = get_component('device_tracker.test').SCANNER + + with patch.dict(device_tracker.DISCOVERY_PLATFORMS, {'test': 'test'}): + with patch.object(scanner, 'scan_devices') as mock_scan: + self.assertTrue(device_tracker.setup(self.hass, { + device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}})) + fire_service_discovered(self.hass, 'test', {}) + self.assertTrue(mock_scan.called) + + def test_update_stale(self): + scanner = get_component('device_tracker.test').SCANNER + scanner.reset() + scanner.come_home('DEV1') + + register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) + scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) + + with patch('homeassistant.components.device_tracker.dt_util.utcnow', + return_value=register_time): + self.assertTrue(device_tracker.setup(self.hass, { + 'device_tracker': { + 'platform': 'test', + 'consider_home': 59, + }})) + + self.assertEqual(STATE_HOME, + self.hass.states.get('device_tracker.dev1').state) + + scanner.leave_home('DEV1') + + with patch('homeassistant.components.device_tracker.dt_util.utcnow', + return_value=scan_time): + fire_time_changed(self.hass, scan_time) + self.hass.pool.block_till_done() + + self.assertEqual(STATE_NOT_HOME, + self.hass.states.get('device_tracker.dev1').state) + + def test_entity_attributes(self): + dev_id = 'test_entity' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + friendly_name = 'Paulus' + picture = 'http://placehold.it/200x200' + + device = device_tracker.Device( + self.hass, timedelta(seconds=180), 0, True, dev_id, None, + friendly_name, picture, away_hide=True) + device_tracker.update_config(self.yaml_devices, dev_id, device) + + self.assertTrue(device_tracker.setup(self.hass, {})) + + attrs = self.hass.states.get(entity_id).attributes + + self.assertEqual(friendly_name, attrs.get(ATTR_FRIENDLY_NAME)) + self.assertEqual(picture, attrs.get(ATTR_ENTITY_PICTURE)) + + def test_device_hidden(self): + dev_id = 'test_entity' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + device = device_tracker.Device( + self.hass, timedelta(seconds=180), 0, True, dev_id, None, + away_hide=True) + device_tracker.update_config(self.yaml_devices, dev_id, device) + + scanner = get_component('device_tracker.test').SCANNER + scanner.reset() + + self.assertTrue(device_tracker.setup(self.hass, { + device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}})) + + self.assertTrue(self.hass.states.get(entity_id) + .attributes.get(ATTR_HIDDEN)) + + def test_group_all_devices(self): + dev_id = 'test_entity' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + device = device_tracker.Device( + self.hass, timedelta(seconds=180), 0, True, dev_id, None, + away_hide=True) + device_tracker.update_config(self.yaml_devices, dev_id, device) + + scanner = get_component('device_tracker.test').SCANNER + scanner.reset() + + self.assertTrue(device_tracker.setup(self.hass, { + device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}})) + + state = self.hass.states.get(device_tracker.ENTITY_ID_ALL_DEVICES) + self.assertIsNotNone(state) + self.assertEqual(STATE_NOT_HOME, state.state) + self.assertSequenceEqual((entity_id,), + state.attributes.get(ATTR_ENTITY_ID)) + + @patch('homeassistant.components.device_tracker.DeviceTracker.see') + def test_see_service(self, mock_see): + self.assertTrue(device_tracker.setup(self.hass, {})) + mac = 'AB:CD:EF:GH' + dev_id = 'some_device' + host_name = 'example.com' + location_name = 'Work' + gps = [.3, .8] + + device_tracker.see(self.hass, mac, dev_id, host_name, location_name, + gps) + + self.hass.pool.block_till_done() + + mock_see.assert_called_once_with( + mac=mac, dev_id=dev_id, host_name=host_name, + location_name=location_name, gps=gps) diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py new file mode 100644 index 00000000000..6e219621a60 --- /dev/null +++ b/tests/components/device_tracker/test_mqtt.py @@ -0,0 +1,37 @@ +import unittest +import os + +from homeassistant.components import device_tracker +from homeassistant.const import CONF_PLATFORM + +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) + + +class TestComponentsDeviceTrackerMQTT(unittest.TestCase): + def setUp(self): # pylint: disable=invalid-name + """ Init needed objects. """ + self.hass = get_test_home_assistant() + mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + try: + os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) + except FileNotFoundError: + pass + + def test_new_message(self): + dev_id = 'paulus' + enttiy_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + topic = '/location/paulus' + location = 'work' + + self.assertTrue(device_tracker.setup(self.hass, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: topic} + }})) + fire_mqtt_message(self.hass, topic, location) + self.hass.pool.block_till_done() + self.assertEqual(location, self.hass.states.get(enttiy_id).state) diff --git a/tests/components/test_demo.py b/tests/components/test_demo.py index 0abd546e4c4..13cc55ed7dc 100644 --- a/tests/components/test_demo.py +++ b/tests/components/test_demo.py @@ -4,14 +4,18 @@ tests.test_component_demo Tests demo component. """ +import json import unittest +from unittest.mock import patch import homeassistant.core as ha import homeassistant.components.demo as demo +from homeassistant.remote import JSONEncoder from tests.common import mock_http_component +@patch('homeassistant.components.sun.setup') class TestDemo(unittest.TestCase): """ Test the demo module. """ @@ -23,14 +27,24 @@ class TestDemo(unittest.TestCase): """ Stop down stuff we started. """ self.hass.stop() - def test_if_demo_state_shows_by_default(self): + def test_if_demo_state_shows_by_default(self, mock_sun_setup): """ Test if demo state shows if we give no configuration. """ demo.setup(self.hass, {demo.DOMAIN: {}}) self.assertIsNotNone(self.hass.states.get('a.Demo_Mode')) - def test_hiding_demo_state(self): + def test_hiding_demo_state(self, mock_sun_setup): """ Test if you can hide the demo card. """ demo.setup(self.hass, {demo.DOMAIN: {'hide_demo_state': 1}}) self.assertIsNone(self.hass.states.get('a.Demo_Mode')) + + def test_all_entities_can_be_loaded_over_json(self, mock_sun_setup): + """ Test if you can hide the demo card. """ + demo.setup(self.hass, {demo.DOMAIN: {'hide_demo_state': 1}}) + + try: + json.dumps(self.hass.states.all(), cls=JSONEncoder) + except Exception: + self.fail('Unable to convert all demo entities to JSON. ' + 'Wrong data in state machine!') diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py index 1f4dbf765ff..f3ec23a96bf 100644 --- a/tests/components/test_device_sun_light_trigger.py +++ b/tests/components/test_device_sun_light_trigger.py @@ -9,14 +9,14 @@ import os import unittest import homeassistant.loader as loader -from homeassistant.const import CONF_PLATFORM +from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME from homeassistant.components import ( device_tracker, light, sun, device_sun_light_trigger) from tests.common import ( get_test_config_dir, get_test_home_assistant, ensure_sun_risen, - ensure_sun_set, trigger_device_tracker_scan) + ensure_sun_set) KNOWN_DEV_PATH = None @@ -27,7 +27,7 @@ def setUpModule(): # pylint: disable=invalid-name global KNOWN_DEV_PATH KNOWN_DEV_PATH = os.path.join(get_test_config_dir(), - device_tracker.KNOWN_DEVICES_FILE) + device_tracker.CSV_DEVICES) with open(KNOWN_DEV_PATH, 'w') as fil: fil.write('device,name,track,picture\n') @@ -37,7 +37,8 @@ def setUpModule(): # pylint: disable=invalid-name def tearDownModule(): # pylint: disable=invalid-name """ Stops the Home Assistant server. """ - os.remove(KNOWN_DEV_PATH) + os.remove(os.path.join(get_test_config_dir(), + device_tracker.YAML_DEVICES)) class TestDeviceSunLightTrigger(unittest.TestCase): @@ -54,15 +55,16 @@ class TestDeviceSunLightTrigger(unittest.TestCase): loader.get_component('light.test').init() - device_tracker.setup(self.hass, { + self.assertTrue(device_tracker.setup(self.hass, { device_tracker.DOMAIN: {CONF_PLATFORM: 'test'} - }) + })) - light.setup(self.hass, { + self.assertTrue(light.setup(self.hass, { light.DOMAIN: {CONF_PLATFORM: 'test'} - }) + })) - sun.setup(self.hass, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + self.assertTrue(sun.setup( + self.hass, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}})) def tearDown(self): # pylint: disable=invalid-name """ Stop down stuff we started. """ @@ -71,8 +73,8 @@ class TestDeviceSunLightTrigger(unittest.TestCase): def test_lights_on_when_sun_sets(self): """ Test lights go on when there is someone home and the sun sets. """ - device_sun_light_trigger.setup( - self.hass, {device_sun_light_trigger.DOMAIN: {}}) + self.assertTrue(device_sun_light_trigger.setup( + self.hass, {device_sun_light_trigger.DOMAIN: {}})) ensure_sun_risen(self.hass) @@ -92,12 +94,11 @@ class TestDeviceSunLightTrigger(unittest.TestCase): self.hass.pool.block_till_done() - device_sun_light_trigger.setup( - self.hass, {device_sun_light_trigger.DOMAIN: {}}) + self.assertTrue(device_sun_light_trigger.setup( + self.hass, {device_sun_light_trigger.DOMAIN: {}})) - self.scanner.leave_home('DEV1') - - trigger_device_tracker_scan(self.hass) + self.hass.states.set(device_tracker.ENTITY_ID_ALL_DEVICES, + STATE_NOT_HOME) self.hass.pool.block_till_done() @@ -111,11 +112,11 @@ class TestDeviceSunLightTrigger(unittest.TestCase): self.hass.pool.block_till_done() - device_sun_light_trigger.setup( - self.hass, {device_sun_light_trigger.DOMAIN: {}}) + self.assertTrue(device_sun_light_trigger.setup( + self.hass, {device_sun_light_trigger.DOMAIN: {}})) - self.scanner.come_home('DEV2') - trigger_device_tracker_scan(self.hass) + self.hass.states.set( + device_tracker.ENTITY_ID_FORMAT.format('device_2'), STATE_HOME) self.hass.pool.block_till_done() diff --git a/tests/components/test_device_tracker.py b/tests/components/test_device_tracker.py deleted file mode 100644 index 08ac641d19f..00000000000 --- a/tests/components/test_device_tracker.py +++ /dev/null @@ -1,193 +0,0 @@ -""" -tests.test_component_device_tracker -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Tests the device tracker compoments. -""" -# pylint: disable=protected-access,too-many-public-methods -import unittest -from datetime import timedelta -import os - -import homeassistant.core as ha -import homeassistant.loader as loader -import homeassistant.util.dt as dt_util -from homeassistant.const import ( - STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_PICTURE, CONF_PLATFORM, - DEVICE_DEFAULT_NAME) -import homeassistant.components.device_tracker as device_tracker - -from tests.common import get_test_home_assistant - - -class TestComponentsDeviceTracker(unittest.TestCase): - """ Tests homeassistant.components.device_tracker module. """ - - def setUp(self): # pylint: disable=invalid-name - """ Init needed objects. """ - self.hass = get_test_home_assistant() - - self.known_dev_path = self.hass.config.path( - device_tracker.KNOWN_DEVICES_FILE) - - def tearDown(self): # pylint: disable=invalid-name - """ Stop down stuff we started. """ - self.hass.stop() - - if os.path.isfile(self.known_dev_path): - os.remove(self.known_dev_path) - - def test_is_on(self): - """ Test is_on method. """ - entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') - - self.hass.states.set(entity_id, STATE_HOME) - - self.assertTrue(device_tracker.is_on(self.hass, entity_id)) - - self.hass.states.set(entity_id, STATE_NOT_HOME) - - self.assertFalse(device_tracker.is_on(self.hass, entity_id)) - - def test_setup(self): - """ Test setup method. """ - # Bogus config - self.assertFalse(device_tracker.setup(self.hass, {})) - - self.assertFalse( - device_tracker.setup(self.hass, {device_tracker.DOMAIN: {}})) - - # Test with non-existing component - self.assertFalse(device_tracker.setup( - self.hass, {device_tracker.DOMAIN: {CONF_PLATFORM: 'nonexisting'}} - )) - - # Test with a bad known device file around - with open(self.known_dev_path, 'w') as fil: - fil.write("bad data\nbad data\n") - - self.assertFalse(device_tracker.setup(self.hass, { - device_tracker.DOMAIN: {CONF_PLATFORM: 'test'} - })) - - def test_writing_known_devices_file(self): - """ Test the device tracker class. """ - scanner = loader.get_component( - 'device_tracker.test').get_scanner(None, None) - - scanner.reset() - - scanner.come_home('DEV1') - scanner.come_home('DEV2') - - self.assertTrue(device_tracker.setup(self.hass, { - device_tracker.DOMAIN: {CONF_PLATFORM: 'test'} - })) - - # Ensure a new known devices file has been created. - # Since the device_tracker uses a set internally we cannot - # know what the order of the devices in the known devices file is. - # To ensure all the three expected lines are there, we sort the file - with open(self.known_dev_path) as fil: - self.assertEqual( - ['DEV1,{},0,\n'.format(DEVICE_DEFAULT_NAME), 'DEV2,dev2,0,\n', - 'device,name,track,picture\n'], - sorted(fil)) - - # Write one where we track dev1, dev2 - with open(self.known_dev_path, 'w') as fil: - fil.write('device,name,track,picture\n') - fil.write('DEV1,device 1,1,http://example.com/dev1.jpg\n') - fil.write('DEV2,device 2,1,http://example.com/dev2.jpg\n') - - scanner.leave_home('DEV1') - scanner.come_home('DEV3') - - self.hass.services.call( - device_tracker.DOMAIN, - device_tracker.SERVICE_DEVICE_TRACKER_RELOAD) - - self.hass.pool.block_till_done() - - dev1 = device_tracker.ENTITY_ID_FORMAT.format('device_1') - dev2 = device_tracker.ENTITY_ID_FORMAT.format('device_2') - dev3 = device_tracker.ENTITY_ID_FORMAT.format('DEV3') - - now = dt_util.utcnow() - - # Device scanner scans every 12 seconds. We need to sync our times to - # be every 12 seconds or else the time_changed event will be ignored. - nowAlmostMinimumGone = now + device_tracker.TIME_DEVICE_NOT_FOUND - nowAlmostMinimumGone -= timedelta( - seconds=12+(nowAlmostMinimumGone.second % 12)) - - nowMinimumGone = now + device_tracker.TIME_DEVICE_NOT_FOUND - nowMinimumGone += timedelta(seconds=12-(nowMinimumGone.second % 12)) - - # Test initial is correct - self.assertTrue(device_tracker.is_on(self.hass)) - self.assertFalse(device_tracker.is_on(self.hass, dev1)) - self.assertTrue(device_tracker.is_on(self.hass, dev2)) - self.assertIsNone(self.hass.states.get(dev3)) - - self.assertEqual( - 'http://example.com/dev1.jpg', - self.hass.states.get(dev1).attributes.get(ATTR_ENTITY_PICTURE)) - self.assertEqual( - 'http://example.com/dev2.jpg', - self.hass.states.get(dev2).attributes.get(ATTR_ENTITY_PICTURE)) - - # Test if dev3 got added to known dev file - with open(self.known_dev_path) as fil: - self.assertEqual('DEV3,dev3,0,\n', list(fil)[-1]) - - # Change dev3 to track - with open(self.known_dev_path, 'w') as fil: - fil.write("device,name,track,picture\n") - fil.write('DEV1,Device 1,1,http://example.com/picture.jpg\n') - fil.write('DEV2,Device 2,1,http://example.com/picture.jpg\n') - fil.write('DEV3,DEV3,1,\n') - - scanner.come_home('DEV1') - scanner.leave_home('DEV2') - - # reload dev file - self.hass.services.call( - device_tracker.DOMAIN, - device_tracker.SERVICE_DEVICE_TRACKER_RELOAD) - - self.hass.pool.block_till_done() - - # Test what happens if a device comes home and another leaves - self.assertTrue(device_tracker.is_on(self.hass)) - self.assertTrue(device_tracker.is_on(self.hass, dev1)) - # Dev2 will still be home because of the error margin on time - self.assertTrue(device_tracker.is_on(self.hass, dev2)) - # dev3 should be tracked now after we reload the known devices - self.assertTrue(device_tracker.is_on(self.hass, dev3)) - - self.assertIsNone( - self.hass.states.get(dev3).attributes.get(ATTR_ENTITY_PICTURE)) - - # Test if device leaves what happens, test the time span - self.hass.bus.fire( - ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: nowAlmostMinimumGone}) - - self.hass.pool.block_till_done() - - self.assertTrue(device_tracker.is_on(self.hass)) - self.assertTrue(device_tracker.is_on(self.hass, dev1)) - # Dev2 will still be home because of the error time - self.assertTrue(device_tracker.is_on(self.hass, dev2)) - self.assertTrue(device_tracker.is_on(self.hass, dev3)) - - # Now test if gone for longer then error margin - self.hass.bus.fire( - ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: nowMinimumGone}) - - self.hass.pool.block_till_done() - - self.assertTrue(device_tracker.is_on(self.hass)) - self.assertTrue(device_tracker.is_on(self.hass, dev1)) - self.assertFalse(device_tracker.is_on(self.hass, dev2)) - self.assertTrue(device_tracker.is_on(self.hass, dev3)) diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 12d10c52744..fdd8270a661 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -8,6 +8,8 @@ Tests the history component. import time import os import unittest +from unittest.mock import patch +from datetime import timedelta import homeassistant.core as ha import homeassistant.util.dt as dt_util @@ -68,11 +70,7 @@ class TestComponentHistory(unittest.TestCase): self.init_recorder() states = [] - # Create 10 states for 5 different entities - # After the first 5, sleep a second and save the time - # history.get_states takes the latest states BEFORE point X - - for i in range(10): + for i in range(5): state = ha.State( 'test.point_in_time_{}'.format(i % 5), "State {}".format(i), @@ -80,19 +78,27 @@ class TestComponentHistory(unittest.TestCase): mock_state_change_event(self.hass, state) self.hass.pool.block_till_done() - recorder._INSTANCE.block_till_done() - if i < 5: - states.append(state) + states.append(state) - if i == 4: - time.sleep(1) - point = dt_util.utcnow() + recorder._INSTANCE.block_till_done() - self.assertEqual( - states, - sorted( - history.get_states(point), key=lambda state: state.entity_id)) + point = dt_util.utcnow() + timedelta(seconds=1) + + with patch('homeassistant.util.dt.utcnow', return_value=point): + for i in range(5): + state = ha.State( + 'test.point_in_time_{}'.format(i % 5), + "State {}".format(i), + {'attribute_test': i}) + + mock_state_change_event(self.hass, state) + self.hass.pool.block_till_done() + + # Get states returns everything before POINT + self.assertEqual(states, + sorted(history.get_states(point), + key=lambda state: state.entity_id)) # Test get_state here because we have a DB setup self.assertEqual( @@ -113,22 +119,20 @@ class TestComponentHistory(unittest.TestCase): set_state('YouTube') start = dt_util.utcnow() + point = start + timedelta(seconds=1) + end = point + timedelta(seconds=1) - time.sleep(1) + with patch('homeassistant.util.dt.utcnow', return_value=point): + states = [ + set_state('idle'), + set_state('Netflix'), + set_state('Plex'), + set_state('YouTube'), + ] - states = [ - set_state('idle'), - set_state('Netflix'), - set_state('Plex'), - set_state('YouTube'), - ] - - time.sleep(1) - - end = dt_util.utcnow() - - set_state('Netflix') - set_state('Plex') + with patch('homeassistant.util.dt.utcnow', return_value=end): + set_state('Netflix') + set_state('Plex') self.assertEqual( {entity_id: states}, diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 16f6ba8aa33..8b394559512 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -63,6 +63,25 @@ class TestComponentHistory(unittest.TestCase): entries[0], name='Home Assistant', message='restarted', domain=ha.DOMAIN) + def test_process_custom_logbook_entries(self): + """ Tests if custom log book entries get added as an entry. """ + name = 'Nice name' + message = 'has a custom entry' + entity_id = 'sun.sun' + + entries = list(logbook.humanify(( + ha.Event(logbook.EVENT_LOGBOOK_ENTRY, { + logbook.ATTR_NAME: name, + logbook.ATTR_MESSAGE: message, + logbook.ATTR_ENTITY_ID: entity_id, + }), + ))) + + self.assertEqual(1, len(entries)) + self.assert_entry( + entries[0], name=name, message=message, + domain='sun', entity_id=entity_id) + def assert_entry(self, entry, when=None, name=None, message=None, domain=None, entity_id=None): """ Asserts an entry is what is expected """ diff --git a/tests/components/test_switch.py b/tests/components/test_switch.py index afa96290aa0..dc7129ca541 100644 --- a/tests/components/test_switch.py +++ b/tests/components/test_switch.py @@ -7,9 +7,9 @@ Tests switch component. # pylint: disable=too-many-public-methods,protected-access import unittest -import homeassistant.loader as loader +from homeassistant import loader +from homeassistant.components import switch from homeassistant.const import STATE_ON, STATE_OFF, CONF_PLATFORM -import homeassistant.components.switch as switch from tests.common import get_test_home_assistant diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index b8823f23a5a..cdca36a9701 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -34,14 +34,6 @@ class TestHelpersEntity(unittest.TestCase): ATTR_HIDDEN, self.hass.states.get(self.entity.entity_id).attributes) - def test_setting_hidden_to_true(self): - self.entity.hidden = True - self.entity.update_ha_state() - - state = self.hass.states.get(self.entity.entity_id) - - self.assertTrue(state.attributes.get(ATTR_HIDDEN)) - def test_overwriting_hidden_property_to_true(self): """ Test we can overwrite hidden property to True. """ entity.Entity.overwrite_attribute(self.entity.entity_id, @@ -50,14 +42,3 @@ class TestHelpersEntity(unittest.TestCase): state = self.hass.states.get(self.entity.entity_id) self.assertTrue(state.attributes.get(ATTR_HIDDEN)) - - def test_overwriting_hidden_property_to_false(self): - """ Test we can overwrite hidden property to True. """ - entity.Entity.overwrite_attribute(self.entity.entity_id, - [ATTR_HIDDEN], [False]) - self.entity.hidden = True - self.entity.update_ha_state() - - self.assertNotIn( - ATTR_HIDDEN, - self.hass.states.get(self.entity.entity_id).attributes) diff --git a/tests/helpers/test_init.py b/tests/helpers/test_init.py index 1e3d8b98b1a..0e7c310d91f 100644 --- a/tests/helpers/test_init.py +++ b/tests/helpers/test_init.py @@ -7,13 +7,13 @@ Tests component helpers. # pylint: disable=protected-access,too-many-public-methods import unittest -from common import get_test_home_assistant - import homeassistant.core as ha import homeassistant.loader as loader from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ENTITY_ID from homeassistant.helpers import extract_entity_ids +from tests.common import get_test_home_assistant + class TestComponentsCore(unittest.TestCase): """ Tests homeassistant.components module. """ diff --git a/tests/test_config.py b/tests/test_config.py index c986d7551c6..65c93f9f333 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_TEMPERATURE_UNIT, CONF_NAME, CONF_TIME_ZONE) -from common import get_test_config_dir, mock_detect_location_info +from tests.common import get_test_config_dir, mock_detect_location_info CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) diff --git a/tests/test_core.py b/tests/test_core.py index 1aab679805a..30ef03ac1b4 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -8,10 +8,10 @@ Provides tests to verify that Home Assistant core works. # pylint: disable=too-few-public-methods import os import unittest -import unittest.mock as mock +from unittest.mock import patch import time import threading -from datetime import datetime +from datetime import datetime, timedelta import pytz @@ -55,29 +55,26 @@ class TestHomeAssistant(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(calls)) + # @patch('homeassistant.core.time.sleep') def test_block_till_stoped(self): """ Test if we can block till stop service is called. """ - blocking_thread = threading.Thread(target=self.hass.block_till_stopped) + with patch('time.sleep'): + blocking_thread = threading.Thread( + target=self.hass.block_till_stopped) - self.assertFalse(blocking_thread.is_alive()) + self.assertFalse(blocking_thread.is_alive()) - blocking_thread.start() + blocking_thread.start() - # Threads are unpredictable, try 20 times if we're ready - wait_loops = 0 - while not blocking_thread.is_alive() and wait_loops < 20: - wait_loops += 1 - time.sleep(0.05) + self.assertTrue(blocking_thread.is_alive()) - self.assertTrue(blocking_thread.is_alive()) + self.hass.services.call(ha.DOMAIN, ha.SERVICE_HOMEASSISTANT_STOP) + self.hass.pool.block_till_done() - self.hass.services.call(ha.DOMAIN, ha.SERVICE_HOMEASSISTANT_STOP) - self.hass.pool.block_till_done() - - # Threads are unpredictable, try 20 times if we're ready - wait_loops = 0 - while blocking_thread.is_alive() and wait_loops < 20: - wait_loops += 1 + # Wait for thread to stop + for _ in range(20): + if not blocking_thread.is_alive(): + break time.sleep(0.05) self.assertFalse(blocking_thread.is_alive()) @@ -88,13 +85,9 @@ class TestHomeAssistant(unittest.TestCase): lambda event: calls.append(1)) def raise_keyboardinterrupt(length): - # We don't want to patch the sleep of the timer. - if length == 1: - raise KeyboardInterrupt + raise KeyboardInterrupt - self.hass.start() - - with mock.patch('time.sleep', raise_keyboardinterrupt): + with patch('homeassistant.core.time.sleep', raise_keyboardinterrupt): self.hass.block_till_stopped() self.assertEqual(1, len(calls)) @@ -400,9 +393,10 @@ class TestStateMachine(unittest.TestCase): def test_last_changed_not_updated_on_same_state(self): state = self.states.get('light.Bowl') - time.sleep(1) + future = dt_util.utcnow() + timedelta(hours=10) - self.states.set("light.Bowl", "on") + with patch('homeassistant.util.dt.utcnow', return_value=future): + self.states.set("light.Bowl", "on", {'attr': 'triggers_change'}) self.assertEqual(state.last_changed, self.states.get('light.Bowl').last_changed) diff --git a/tests/test_loader.py b/tests/test_loader.py index 67b8e8d11a6..124a5c87d16 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -10,7 +10,7 @@ import unittest import homeassistant.loader as loader import homeassistant.components.http as http -from common import get_test_home_assistant, MockModule +from tests.common import get_test_home_assistant, MockModule class TestLoader(unittest.TestCase): @@ -24,9 +24,9 @@ class TestLoader(unittest.TestCase): def test_set_component(self): """ Test if set_component works. """ - loader.set_component('switch.test', http) + loader.set_component('switch.test_set', http) - self.assertEqual(http, loader.get_component('switch.test')) + self.assertEqual(http, loader.get_component('switch.test_set')) def test_get_component(self): """ Test if get_component works. """ diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 5a4fb44b2d4..94358f5eb51 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -6,10 +6,11 @@ Tests Home Assistant util methods. """ # pylint: disable=too-many-public-methods import unittest -import time +from unittest.mock import patch from datetime import datetime, timedelta -import homeassistant.util as util +from homeassistant import util +import homeassistant.util.dt as dt_util class TestUtil(unittest.TestCase): @@ -31,9 +32,9 @@ class TestUtil(unittest.TestCase): def test_slugify(self): """ Test slugify. """ - self.assertEqual("Test", util.slugify("T-!@#$!#@$!$est")) - self.assertEqual("Test_More", util.slugify("Test More")) - self.assertEqual("Test_More", util.slugify("Test_(More)")) + self.assertEqual("test", util.slugify("T-!@#$!#@$!$est")) + self.assertEqual("test_more", util.slugify("Test More")) + self.assertEqual("test_more", util.slugify("Test_(More)")) def test_split_entity_id(self): """ Test split_entity_id. """ @@ -169,21 +170,19 @@ class TestUtil(unittest.TestCase): def test_throttle(self): """ Test the add cooldown decorator. """ calls1 = [] + calls2 = [] - @util.Throttle(timedelta(milliseconds=500)) + @util.Throttle(timedelta(seconds=4)) def test_throttle1(): calls1.append(1) - calls2 = [] - - @util.Throttle( - timedelta(milliseconds=500), timedelta(milliseconds=250)) + @util.Throttle(timedelta(seconds=4), timedelta(seconds=2)) def test_throttle2(): calls2.append(1) - # Ensure init is ok - self.assertEqual(0, len(calls1)) - self.assertEqual(0, len(calls2)) + now = dt_util.utcnow() + plus3 = now + timedelta(seconds=3) + plus5 = plus3 + timedelta(seconds=2) # Call first time and ensure methods got called test_throttle1() @@ -206,25 +205,16 @@ class TestUtil(unittest.TestCase): self.assertEqual(2, len(calls1)) self.assertEqual(1, len(calls2)) - # Sleep past the no throttle interval for throttle2 - time.sleep(.3) - - test_throttle1() - test_throttle2() + with patch('homeassistant.util.utcnow', return_value=plus3): + test_throttle1() + test_throttle2() self.assertEqual(2, len(calls1)) self.assertEqual(1, len(calls2)) - test_throttle1(no_throttle=True) - test_throttle2(no_throttle=True) + with patch('homeassistant.util.utcnow', return_value=plus5): + test_throttle1() + test_throttle2() self.assertEqual(3, len(calls1)) self.assertEqual(2, len(calls2)) - - time.sleep(.5) - - test_throttle1() - test_throttle2() - - self.assertEqual(4, len(calls1)) - self.assertEqual(3, len(calls2))