diff --git a/.gitmodules b/.gitmodules index 8ea8376a6a4..b9cf022a8f4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,9 @@ [submodule "homeassistant/external/pywemo"] path = homeassistant/external/pywemo url = https://github.com/balloob/pywemo.git +[submodule "homeassistant/external/netdisco"] + path = homeassistant/external/netdisco + url = https://github.com/balloob/netdisco.git +[submodule "homeassistant/external/noop"] + path = homeassistant/external/noop + url = https://github.com/balloob/noop.git diff --git a/.travis.yml b/.travis.yml index 61ed87bf6b5..b9427d41b24 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,6 @@ install: script: - flake8 homeassistant --exclude bower_components,external - pylint homeassistant - - coverage run --source=homeassistant -m unittest discover ha_test + - coverage run --source=homeassistant --omit "homeassistant/external/*" -m unittest discover tests after_success: - coveralls diff --git a/Dockerfile b/Dockerfile index b103cf4545f..1c13ac9542e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,4 @@ MAINTAINER Paulus Schoutsen VOLUME /config -EXPOSE 8123 - CMD [ "python", "-m", "homeassistant", "--config", "/config" ] diff --git a/README.md b/README.md index 62d20f7df4c..1259d5f8047 100644 --- a/README.md +++ b/README.md @@ -33,26 +33,27 @@ If you run into issues while using Home Assistant or during development of a com ## Installation instructions / Quick-start guide -Running Home Assistant requires that python3 and the package requests are installed. - -Run the following code to get up and running with the minimum setup: +Running Home Assistant requires that python3 and the package requests are installed. Run the following code to install and start Home Assistant: ```python git clone --recursive https://github.com/balloob/home-assistant.git cd home-assistant pip3 install -r requirements.txt - -python3 -m homeassistant +python3 -m homeassistant --open-ui ``` -This will start the Home Assistant server and create an initial configuration file in `config/home-assistant.conf` that is setup for demo mode. It will launch its web interface on [http://127.0.0.1:8123](http://127.0.0.1:8123). The default password is 'password'. +The last command will start the Home Assistant server and launch its webinterface. By default Home Assistant looks for the configuration file `config/home-assistant.conf`. A standard configuration file will be written if none exists. + +If you are still exploring if you want to use Home Assistant in the first place, you can enable the demo mode by adding the `--demo-mode` argument to the last command. If you're using Docker, you can use ```bash -docker run -d --name="home-assistant" -v /path/to/homeassistant/config:/config -v /etc/localtime:/etc/localtime:ro -p 8123:8123 balloob/home-assistant +docker run -d --name="home-assistant" -v /path/to/homeassistant/config:/config -v /etc/localtime:/etc/localtime:ro --net=host balloob/home-assistant ``` +After you have launched the Docker image, navigate to its web interface on [http://127.0.0.1:8123](http://127.0.0.1:8123). + After you got the demo mode running it is time to enable some real components and get started. An example configuration file has been provided in [/config/home-assistant.conf.example](https://github.com/balloob/home-assistant/blob/master/config/home-assistant.conf.example). *Note:* you can append `?api_password=YOUR_PASSWORD` to the url of the web interface to log in automatically. diff --git a/config/custom_components/example.py b/config/custom_components/example.py index ee422174377..a972e3ab576 100644 --- a/config/custom_components/example.py +++ b/config/custom_components/example.py @@ -34,7 +34,6 @@ SERVICE_FLASH = 'flash' _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument def setup(hass, config): """ Setup example component. """ diff --git a/config/home-assistant.conf.example b/config/home-assistant.conf.example index 0edd11e98f6..2ad1fcf8570 100644 --- a/config/home-assistant.conf.example +++ b/config/home-assistant.conf.example @@ -11,6 +11,10 @@ api_password=mypass [light] platform=hue +[wink] +# Get your token at https://winkbearertoken.appspot.com +access_token=YOUR_TOKEN + [device_tracker] # The following types are available: netgear, tomato, luci, nmap_tracker platform=netgear @@ -33,9 +37,19 @@ platform=wemo # Optional: hard code the hosts (comma seperated) to avoid scanning the network # hosts=192.168.1.9,192.168.1.12 +[thermostat] +platform=nest +# Required: username and password that are used to login to the Nest thermostat. +username=myemail@mydomain.com +password=mypassword + [downloader] download_dir=downloads +[notify] +platform=pushbullet +api_key=ABCDEFGHJKLMNOPQRSTUVXYZ + [device_sun_light_trigger] # Optional: specify a specific light/group of lights that has to be turned on light_group=group.living_room @@ -65,3 +79,26 @@ unknown_light=group.living_room [browser] [keyboard] + +[automation] +platform=state +alias=Sun starts shining + +state_entity_id=sun.sun +# Next two are optional, omit to match all +state_from=below_horizon +state_to=above_horizon + +execute_service=light.turn_off +service_entity_id=group.living_room + +[automation 2] +platform=time +alias=Beer o Clock + +time_hours=16 +time_minutes=0 +time_seconds=0 + +execute_service=notify.notify +execute_service_data={"message":"It's 4, time for beer!"} diff --git a/homeassistant/__init__.py b/homeassistant/__init__.py index e0a2316dfd8..96d24cb62cf 100644 --- a/homeassistant/__init__.py +++ b/homeassistant/__init__.py @@ -27,7 +27,7 @@ import homeassistant.util as util DOMAIN = "homeassistant" # How often time_changed event should fire -TIMER_INTERVAL = 10 # seconds +TIMER_INTERVAL = 1 # seconds # How long we wait for the result of a service call SERVICE_CALL_LIMIT = 10 # seconds @@ -52,6 +52,13 @@ class HomeAssistant(object): self.services = ServiceRegistry(self.bus, pool) self.states = StateMachine(self.bus) + # List of loaded components + self.components = [] + + # Remote.API object pointing at local API + self.local_api = None + + # Directory that holds the configuration self.config_dir = os.path.join(os.getcwd(), 'config') def get_config_path(self, path): @@ -219,10 +226,10 @@ def _process_match_param(parameter): """ Wraps parameter in a list if it is not one and returns it. """ if parameter is None or parameter == MATCH_ALL: return MATCH_ALL - elif isinstance(parameter, list): - return parameter + elif isinstance(parameter, str) or not hasattr(parameter, '__iter__'): + return (parameter,) else: - return [parameter] + return tuple(parameter) def _matcher(subject, pattern): @@ -412,26 +419,36 @@ class EventBus(object): class State(object): - """ Object to represent a state within the state machine. """ + """ + Object to represent a state within the state machine. - __slots__ = ['entity_id', 'state', 'attributes', 'last_changed'] + entity_id: the entity that is represented. + state: the state of the entity + attributes: extra information on entity and state + last_changed: last time the state was changed, not the attributes. + last_updated: last time this object was updated. + """ + + __slots__ = ['entity_id', 'state', 'attributes', + 'last_changed', 'last_updated'] def __init__(self, entity_id, state, attributes=None, last_changed=None): if not ENTITY_ID_PATTERN.match(entity_id): raise InvalidEntityFormatError(( "Invalid entity id encountered: {}. " - "Format should be .").format(entity_id)) + "Format should be .").format(entity_id)) self.entity_id = entity_id self.state = state self.attributes = attributes or {} + self.last_updated = dt.datetime.now() # Strip microsecond from last_changed else we cannot guarantee # state == State.from_dict(state.as_dict()) # This behavior occurs because to_dict uses datetime_to_str # which does not preserve microseconds self.last_changed = util.strip_microseconds( - last_changed or dt.datetime.now()) + last_changed or self.last_updated) def copy(self): """ Creates a copy of itself. """ @@ -467,17 +484,17 @@ class State(object): def __eq__(self, other): return (self.__class__ == other.__class__ and + self.entity_id == other.entity_id and self.state == other.state and self.attributes == other.attributes) def __repr__(self): - if self.attributes: - return "".format( - self.state, util.repr_helper(self.attributes), - util.datetime_to_str(self.last_changed)) - else: - return "".format( - self.state, util.datetime_to_str(self.last_changed)) + attr = "; {}".format(util.repr_helper(self.attributes)) \ + if self.attributes else "" + + return "".format( + self.entity_id, self.state, attr, + util.datetime_to_str(self.last_changed)) class StateMachine(object): @@ -513,15 +530,12 @@ class StateMachine(object): def get_since(self, point_in_time): """ Returns all states that have been changed since point_in_time. - - Note: States keep track of last_changed -without- microseconds. - Therefore your point_in_time will also be stripped of microseconds. """ point_in_time = util.strip_microseconds(point_in_time) with self._lock: return [state for state in self._states.values() - if state.last_changed >= point_in_time] + if state.last_updated >= point_in_time] def is_state(self, entity_id, state): """ Returns True if entity exists and is specified state. """ @@ -538,20 +552,28 @@ class StateMachine(object): def set(self, entity_id, new_state, attributes=None): """ Set the state of an entity, add entity if it does not exist. - Attributes is an optional dict to specify attributes of this state. """ + Attributes is an optional dict to specify attributes of this state. + If you just update the attributes and not the state, last changed will + not be affected. + """ + + new_state = str(new_state) attributes = attributes or {} with self._lock: old_state = self._states.get(entity_id) + is_existing = old_state is not None + same_state = is_existing and old_state.state == new_state + same_attr = is_existing and old_state.attributes == attributes + # If state did not exist or is different, set it - if not old_state or \ - old_state.state != new_state or \ - old_state.attributes != attributes: + if not (same_state and same_attr): + last_changed = old_state.last_changed if same_state else None state = self._states[entity_id] = \ - State(entity_id, new_state, attributes) + State(entity_id, new_state, attributes, last_changed) event_data = {'entity_id': entity_id, 'new_state': state} @@ -574,9 +596,9 @@ class StateMachine(object): # Ensure it is a lowercase list with entity ids we want to match on if isinstance(entity_ids, str): - entity_ids = [entity_ids.lower()] + entity_ids = (entity_ids.lower(),) else: - entity_ids = [entity_id.lower() for entity_id in entity_ids] + entity_ids = tuple(entity_id.lower() for entity_id in entity_ids) @ft.wraps(action) def state_listener(event): diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index c8d50151cea..2b48882712c 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -1,36 +1,23 @@ """ Starts home assistant. """ +from __future__ import print_function import sys import os import argparse import importlib -try: - from homeassistant import bootstrap -except ImportError: - # This is to add support to load Home Assistant using - # `python3 homeassistant` instead of `python3 -m homeassistant` +def validate_python(): + """ Validate we're running the right Python version. """ + major, minor = sys.version_info[:2] - # Insert the parent directory of this file into the module search path - sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - - from homeassistant import bootstrap + if major < 3 or (major == 3 and minor < 4): + print("Home Assistant requires atleast Python 3.4") + sys.exit() -def main(): - """ Starts Home Assistant. Will create demo config if no config found. """ - - parser = argparse.ArgumentParser() - parser.add_argument( - '-c', '--config', - metavar='path_to_config_dir', - default="config", - help="Directory that contains the Home Assistant configuration") - - args = parser.parse_args() - - # Validate that all core dependencies are installed +def validate_dependencies(): + """ Validate all dependencies that HA uses. """ import_fail = False for module in ['requests']: @@ -44,11 +31,42 @@ def main(): if import_fail: print(("Install dependencies by running: " "pip3 install -r requirements.txt")) - exit() + sys.exit() + + +def ensure_path_and_load_bootstrap(): + """ Ensure sys load path is correct and load Home Assistant bootstrap. """ + try: + from homeassistant import bootstrap + + except ImportError: + # This is to add support to load Home Assistant using + # `python3 homeassistant` instead of `python3 -m homeassistant` + + # Insert the parent directory of this file into the module search path + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + from homeassistant import bootstrap + + return bootstrap + + +def validate_git_submodules(): + """ Validate the git submodules are cloned. """ + try: + # pylint: disable=no-name-in-module, unused-variable + from homeassistant.external.noop import WORKING # noqa + except ImportError: + print("Repository submodules have not been initialized") + print("Please run: git submodule update --init --recursive") + sys.exit() + + +def ensure_config_path(config_dir): + """ Gets the path to the configuration file. + Creates one if it not exists. """ # Test if configuration directory exists - config_dir = os.path.join(os.getcwd(), args.config) - if not os.path.isdir(config_dir): print(('Fatal Error: Unable to find specified configuration ' 'directory {} ').format(config_dir)) @@ -60,15 +78,72 @@ def main(): if not os.path.isfile(config_path): try: with open(config_path, 'w') as conf: - conf.write("[http]\n") - conf.write("api_password=password\n\n") - conf.write("[demo]\n") + conf.write("[http]\n\n") + conf.write("[discovery]\n\n") except IOError: print(('Fatal Error: No configuration file found and unable ' 'to write a default one to {}').format(config_path)) sys.exit() - hass = bootstrap.from_config_file(config_path) + return config_path + + +def get_arguments(): + """ Get parsed passed in arguments. """ + parser = argparse.ArgumentParser() + parser.add_argument( + '-c', '--config', + metavar='path_to_config_dir', + default="config", + help="Directory that contains the Home Assistant configuration") + parser.add_argument( + '--demo-mode', + action='store_true', + help='Start Home Assistant in demo mode') + parser.add_argument( + '--open-ui', + action='store_true', + help='Open the webinterface in a browser') + + return parser.parse_args() + + +def main(): + """ Starts Home Assistant. """ + validate_python() + validate_dependencies() + + bootstrap = ensure_path_and_load_bootstrap() + + validate_git_submodules() + + args = get_arguments() + + config_dir = os.path.join(os.getcwd(), args.config) + config_path = ensure_config_path(config_dir) + + if args.demo_mode: + from homeassistant.components import http, demo + + # Demo mode only requires http and demo components. + hass = bootstrap.from_config_dict({ + http.DOMAIN: {}, + demo.DOMAIN: {} + }) + else: + hass = bootstrap.from_config_file(config_path) + + if args.open_ui: + from homeassistant.const import EVENT_HOMEASSISTANT_START + + def open_browser(event): + """ Open the webinterface in a browser. """ + if hass.local_api is not None: + import webbrowser + webbrowser.open(hass.local_api.base_url) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, open_browser) + hass.start() hass.block_till_stopped() diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 1b2a8ee7312..61f856b6e3e 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -17,6 +17,38 @@ from collections import defaultdict import homeassistant import homeassistant.loader as loader import homeassistant.components as core_components +import homeassistant.components.group as group + +_LOGGER = logging.getLogger(__name__) + + +def setup_component(hass, domain, config=None): + """ Setup a component for Home Assistant. """ + if config is None: + config = defaultdict(dict) + + component = loader.get_component(domain) + + try: + if component.setup(hass, config): + hass.components.append(component.DOMAIN) + + _LOGGER.info("component %s initialized", domain) + + # Assumption: if a component does not depend on groups + # it communicates with devices + if group.DOMAIN not in component.DEPENDENCIES: + hass.pool.add_worker() + + return True + + else: + _LOGGER.error("component %s failed to initialize", domain) + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error during setup of component %s", domain) + + return False # pylint: disable=too-many-branches, too-many-statements @@ -29,7 +61,7 @@ def from_config_dict(config, hass=None): if hass is None: hass = homeassistant.HomeAssistant() - logger = logging.getLogger(__name__) + enable_logging(hass) loader.prepare(hass) @@ -42,42 +74,21 @@ def from_config_dict(config, hass=None): if ' ' not in key and key != homeassistant.DOMAIN) if not core_components.setup(hass, config): - logger.error(("Home Assistant core failed to initialize. " - "Further initialization aborted.")) + _LOGGER.error("Home Assistant core failed to initialize. " + "Further initialization aborted.") return hass - logger.info("Home Assistant core initialized") + _LOGGER.info("Home Assistant core initialized") # Setup the components - - # We assume that all components that load before the group component loads - # are components that poll devices. As their tasks are IO based, we will - # add an extra worker for each of them. - add_worker = True - for domain in loader.load_order_components(components): - component = loader.get_component(domain) - - try: - if component.setup(hass, config): - logger.info("component %s initialized", domain) - - add_worker = add_worker and domain != "group" - - if add_worker: - hass.pool.add_worker() - - else: - logger.error("component %s failed to initialize", domain) - - except Exception: # pylint: disable=broad-except - logger.exception("Error during setup of component %s", domain) + setup_component(hass, domain, config) return hass -def from_config_file(config_path, hass=None, enable_logging=True): +def from_config_file(config_path, hass=None): """ Reads the configuration file and tries to start all the required functionality. Will add functionality to 'hass' parameter if given, @@ -89,32 +100,6 @@ def from_config_file(config_path, hass=None, enable_logging=True): # Set config dir to directory holding config file hass.config_dir = os.path.abspath(os.path.dirname(config_path)) - if enable_logging: - # Setup the logging for home assistant. - logging.basicConfig(level=logging.INFO) - - # Log errors to a file if we have write access to file or config dir - err_log_path = hass.get_config_path("home-assistant.log") - err_path_exists = os.path.isfile(err_log_path) - - # Check if we can write to the error log if it exists or that - # we can create files in the containgin directory if not. - if (err_path_exists and os.access(err_log_path, os.W_OK)) or \ - (not err_path_exists and os.access(hass.config_dir, os.W_OK)): - - err_handler = logging.FileHandler( - err_log_path, mode='w', delay=True) - - err_handler.setLevel(logging.WARNING) - err_handler.setFormatter( - logging.Formatter('%(asctime)s %(name)s: %(message)s', - datefmt='%H:%M %d-%m-%y')) - logging.getLogger('').addHandler(err_handler) - - else: - logging.getLogger(__name__).error( - "Unable to setup error log %s (access denied)", err_log_path) - # Read config config = configparser.ConfigParser() config.read(config_path) @@ -128,3 +113,30 @@ def from_config_file(config_path, hass=None, enable_logging=True): config_dict[section][key] = val return from_config_dict(config_dict, hass) + + +def enable_logging(hass): + """ Setup the logging for home assistant. """ + logging.basicConfig(level=logging.INFO) + + # Log errors to a file if we have write access to file or config dir + err_log_path = hass.get_config_path("home-assistant.log") + err_path_exists = os.path.isfile(err_log_path) + + # Check if we can write to the error log if it exists or that + # we can create files in the containing directory if not. + if (err_path_exists and os.access(err_log_path, os.W_OK)) or \ + (not err_path_exists and os.access(hass.config_dir, os.W_OK)): + + err_handler = logging.FileHandler( + err_log_path, mode='w', delay=True) + + err_handler.setLevel(logging.WARNING) + err_handler.setFormatter( + logging.Formatter('%(asctime)s %(name)s: %(message)s', + datefmt='%H:%M %d-%m-%y')) + logging.getLogger('').addHandler(err_handler) + + else: + _LOGGER.error( + "Unable to setup error log %s (access denied)", err_log_path) diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 6720ae2a2d9..0b757766bc0 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -70,7 +70,6 @@ def turn_off(hass, entity_id=None, **service_data): hass.services.call(ha.DOMAIN, SERVICE_TURN_OFF, service_data) -# pylint: disable=unused-argument def setup(hass, config): """ Setup general services related to homeassistant. """ diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py new file mode 100644 index 00000000000..33eef9fe3dc --- /dev/null +++ b/homeassistant/components/automation/__init__.py @@ -0,0 +1,71 @@ +""" +homeassistant.components.automation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Allows to setup simple automation rules via the config file. +""" +import logging +import json + +from homeassistant.loader import get_component +from homeassistant.helpers import config_per_platform +from homeassistant.util import convert, split_entity_id +from homeassistant.const import ATTR_ENTITY_ID + +DOMAIN = "automation" + +DEPENDENCIES = ["group"] + +CONF_ALIAS = "alias" +CONF_SERVICE = "execute_service" +CONF_SERVICE_ENTITY_ID = "service_entity_id" +CONF_SERVICE_DATA = "service_data" + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """ Sets up automation. """ + + for p_type, p_config in config_per_platform(config, DOMAIN, _LOGGER): + platform = get_component('automation.{}'.format(p_type)) + + if platform is None: + _LOGGER.error("Unknown automation platform specified: %s", p_type) + continue + + if platform.register(hass, p_config, _get_action(hass, p_config)): + _LOGGER.info( + "Initialized %s rule %s", p_type, p_config.get(CONF_ALIAS, "")) + else: + _LOGGER.error( + "Error setting up rule %s", p_config.get(CONF_ALIAS, "")) + + return True + + +def _get_action(hass, config): + """ Return an action based on a config. """ + + def action(): + """ Action to be executed. """ + _LOGGER.info("Executing rule %s", config.get(CONF_ALIAS, "")) + + if CONF_SERVICE in config: + domain, service = split_entity_id(config[CONF_SERVICE]) + + service_data = convert( + config.get(CONF_SERVICE_DATA), json.loads, {}) + + if not isinstance(service_data, dict): + _LOGGER.error( + "%s should be a serialized JSON object", CONF_SERVICE_DATA) + service_data = {} + + if CONF_SERVICE_ENTITY_ID in config: + service_data[ATTR_ENTITY_ID] = \ + config[CONF_SERVICE_ENTITY_ID].split(",") + + hass.services.call(domain, service, service_data) + + return action diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py new file mode 100644 index 00000000000..c8adfe95bbe --- /dev/null +++ b/homeassistant/components/automation/state.py @@ -0,0 +1,36 @@ +""" +homeassistant.components.automation.state +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Offers state listening automation rules. +""" +import logging + +from homeassistant.const import MATCH_ALL + + +CONF_ENTITY_ID = "state_entity_id" +CONF_FROM = "state_from" +CONF_TO = "state_to" + + +def register(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) + return False + + from_state = config.get(CONF_FROM, MATCH_ALL) + to_state = config.get(CONF_TO, MATCH_ALL) + + def state_automation_listener(entity, from_s, to_s): + """ Listens for state changes and calls action. """ + action() + + hass.states.track_change( + entity_id, state_automation_listener, from_state, to_state) + + return True diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py new file mode 100644 index 00000000000..7e38960534d --- /dev/null +++ b/homeassistant/components/automation/time.py @@ -0,0 +1,28 @@ +""" +homeassistant.components.automation.time +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Offers time listening automation rules. +""" +from homeassistant.util import convert + +CONF_HOURS = "time_hours" +CONF_MINUTES = "time_minutes" +CONF_SECONDS = "time_seconds" + + +def register(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) + + def time_automation_listener(now): + """ Listens for time changes and calls action. """ + action() + + hass.track_time_change( + time_automation_listener, + hour=hours, minute=minutes, second=seconds) + + return True diff --git a/homeassistant/components/browser.py b/homeassistant/components/browser.py index dc3fc568fde..c5a55afad40 100644 --- a/homeassistant/components/browser.py +++ b/homeassistant/components/browser.py @@ -11,7 +11,6 @@ DEPENDENCIES = [] SERVICE_BROWSE_URL = "browse_url" -# pylint: disable=unused-argument def setup(hass, config): """ Listen for browse_url events and open the url in the default webbrowser. """ diff --git a/homeassistant/components/chromecast.py b/homeassistant/components/chromecast.py index fc5f7e73dc3..b628a21e97c 100644 --- a/homeassistant/components/chromecast.py +++ b/homeassistant/components/chromecast.py @@ -6,13 +6,19 @@ Provides functionality to interact with Chromecasts. """ import logging +try: + import pychromecast +except ImportError: + # Ignore, we will raise appropriate error later + pass + +from homeassistant.loader import get_component import homeassistant.util as util from homeassistant.helpers import extract_entity_ids from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PLAY, - SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREV_TRACK, - CONF_HOSTS) + SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREV_TRACK) DOMAIN = 'chromecast' @@ -105,12 +111,35 @@ def media_prev_track(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_MEDIA_PREV_TRACK, data) -# pylint: disable=too-many-locals, too-many-branches -def setup(hass, config): - """ Listen for chromecast events. """ - logger = logging.getLogger(__name__) +def setup_chromecast(casts, host): + """ Tries to convert host to Chromecast object and set it up. """ + + # Check if already setup + if any(cast.host == host for cast in casts.values()): + return try: + cast = pychromecast.PyChromecast(host) + + entity_id = util.ensure_unique_string( + ENTITY_ID_FORMAT.format( + util.slugify(cast.device.friendly_name)), + casts.keys()) + + casts[entity_id] = cast + + except pychromecast.ChromecastConnectionError: + pass + + +def setup(hass, config): + # pylint: disable=unused-argument,too-many-locals + """ Listen for chromecast events. """ + logger = logging.getLogger(__name__) + discovery = get_component('discovery') + + try: + # pylint: disable=redefined-outer-name import pychromecast except ImportError: logger.exception(("Failed to import pychromecast. " @@ -119,33 +148,23 @@ def setup(hass, config): return False - if CONF_HOSTS in config[DOMAIN]: - hosts = config[DOMAIN][CONF_HOSTS].split(",") + casts = {} - # If no hosts given, scan for chromecasts - else: + # If discovery component not loaded, scan ourselves + if discovery.DOMAIN not in hass.components: logger.info("Scanning for Chromecasts") hosts = pychromecast.discover_chromecasts() - casts = {} + for host in hosts: + setup_chromecast(casts, host) - for host in hosts: - try: - cast = pychromecast.PyChromecast(host) + def chromecast_discovered(service, info): + """ Called when a Chromecast has been discovered. """ + logger.info("New Chromecast discovered: %s", info[0]) + setup_chromecast(casts, info[0]) - entity_id = util.ensure_unique_string( - ENTITY_ID_FORMAT.format( - util.slugify(cast.device.friendly_name)), - casts.keys()) - - casts[entity_id] = cast - - except pychromecast.ChromecastConnectionError: - pass - - if not casts: - logger.error("Could not find Chromecasts") - return False + discovery.listen( + hass, discovery.services.GOOGLE_CAST, chromecast_discovered) def update_chromecast_state(entity_id, chromecast): """ Retrieve state of Chromecast and update statemachine. """ @@ -192,12 +211,13 @@ def setup(hass, config): hass.states.set(entity_id, state, state_attr) - def update_chromecast_states(time): # pylint: disable=unused-argument + def update_chromecast_states(time): """ Updates all chromecast states. """ - logger.info("Updating Chromecast status") + if casts: + logger.info("Updating Chromecast status") - for entity_id, cast in casts.items(): - update_chromecast_state(entity_id, cast) + for entity_id, cast in casts.items(): + update_chromecast_state(entity_id, cast) def _service_to_entities(service): """ Helper method to get entities from service. """ @@ -277,7 +297,7 @@ def setup(hass, config): pychromecast.play_youtube_video(video_id, cast.host) update_chromecast_state(entity_id, cast) - hass.track_time_change(update_chromecast_states) + hass.track_time_change(update_chromecast_states, second=range(0, 60, 15)) hass.services.register(DOMAIN, SERVICE_TURN_OFF, turn_off_service) diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py new file mode 100644 index 00000000000..fdd3c571601 --- /dev/null +++ b/homeassistant/components/configurator.py @@ -0,0 +1,190 @@ +""" +homeassistant.components.configurator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A component to allow pieces of code to request configuration from the user. + +Initiate a request by calling the `request_config` method with a callback. +This will return a request id that has to be used for future calls. +A callback has to be provided to `request_config` which will be called when +the user has submitted configuration information. +""" +import logging + +from homeassistant.helpers import generate_entity_id +from homeassistant.const import EVENT_TIME_CHANGED + +DOMAIN = "configurator" +DEPENDENCIES = [] +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +SERVICE_CONFIGURE = "configure" + +STATE_CONFIGURE = "configure" +STATE_CONFIGURED = "configured" + +ATTR_CONFIGURE_ID = "configure_id" +ATTR_DESCRIPTION = "description" +ATTR_DESCRIPTION_IMAGE = "description_image" +ATTR_SUBMIT_CAPTION = "submit_caption" +ATTR_FIELDS = "fields" +ATTR_ERRORS = "errors" + +_REQUESTS = {} +_INSTANCES = {} +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=too-many-arguments +def request_config( + hass, name, callback, description=None, description_image=None, + submit_caption=None, fields=None): + """ Create a new request for config. + Will return an ID to be used for sequent calls. """ + + instance = _get_instance(hass) + + request_id = instance.request_config( + name, callback, + description, description_image, submit_caption, fields) + + _REQUESTS[request_id] = instance + + return request_id + + +def notify_errors(request_id, error): + """ Add errors to a config request. """ + try: + _REQUESTS[request_id].notify_errors(request_id, error) + except KeyError: + # If request_id does not exist + pass + + +def request_done(request_id): + """ Mark a config request as done. """ + try: + _REQUESTS.pop(request_id).request_done(request_id) + except KeyError: + # If request_id does not exist + pass + + +def setup(hass, config): + """ Set up Configurator. """ + return True + + +def _get_instance(hass): + """ Get an instance per hass object. """ + try: + return _INSTANCES[hass] + except KeyError: + _INSTANCES[hass] = Configurator(hass) + + if DOMAIN not in hass.components: + hass.components.append(DOMAIN) + + return _INSTANCES[hass] + + +class Configurator(object): + """ + Class to keep track of current configuration requests. + """ + + def __init__(self, hass): + self.hass = hass + self._cur_id = 0 + self._requests = {} + hass.services.register( + DOMAIN, SERVICE_CONFIGURE, self.handle_service_call) + + # pylint: disable=too-many-arguments + def request_config( + self, name, callback, + description, description_image, submit_caption, fields): + """ Setup a request for configuration. """ + + entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=self.hass) + + if fields is None: + fields = [] + + request_id = self._generate_unique_id() + + self._requests[request_id] = (entity_id, fields, callback) + + data = { + ATTR_CONFIGURE_ID: request_id, + ATTR_FIELDS: fields, + } + + data.update({ + key: value for key, value in [ + (ATTR_DESCRIPTION, description), + (ATTR_DESCRIPTION_IMAGE, description_image), + (ATTR_SUBMIT_CAPTION, submit_caption), + ] if value is not None + }) + + self.hass.states.set(entity_id, STATE_CONFIGURE, data) + + return request_id + + def notify_errors(self, request_id, error): + """ Update the state with errors. """ + if not self._validate_request_id(request_id): + return + + entity_id = self._requests[request_id][0] + + state = self.hass.states.get(entity_id) + + new_data = state.attributes + new_data[ATTR_ERRORS] = error + + self.hass.states.set(entity_id, STATE_CONFIGURE, new_data) + + def request_done(self, request_id): + """ Remove the config request. """ + if not self._validate_request_id(request_id): + return + + entity_id = self._requests.pop(request_id)[0] + + # If we remove the state right away, it will not be included with + # the result fo the service call (current design limitation). + # Instead, we will set it to configured to give as feedback but delete + # it shortly after so that it is deleted when the client updates. + self.hass.states.set(entity_id, STATE_CONFIGURED) + + def deferred_remove(event): + """ Remove the request state. """ + self.hass.states.remove(entity_id) + + self.hass.bus.listen_once(EVENT_TIME_CHANGED, deferred_remove) + + def handle_service_call(self, call): + """ Handle a configure service call. """ + request_id = call.data.get(ATTR_CONFIGURE_ID) + + if not self._validate_request_id(request_id): + return + + # pylint: disable=unused-variable + entity_id, fields, callback = self._requests[request_id] + + # field validation goes here? + + callback(call.data.get(ATTR_FIELDS, {})) + + def _generate_unique_id(self): + """ Generates a unique configurator id. """ + self._cur_id += 1 + return "{}-{}".format(id(self), self._cur_id) + + def _validate_request_id(self, request_id): + """ Validate that the request belongs to this instance. """ + return request_id in self._requests diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 3bd7d2f1e33..0dcc1a41bf7 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -5,16 +5,21 @@ homeassistant.components.demo Sets up a demo environment that mimics interaction with devices """ import random +import time import homeassistant as ha import homeassistant.loader as loader from homeassistant.helpers import extract_entity_ids from homeassistant.const import ( - SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON, STATE_OFF, - ATTR_ENTITY_PICTURE, ATTR_ENTITY_ID, CONF_LATITUDE, CONF_LONGITUDE) + SERVICE_TURN_ON, SERVICE_TURN_OFF, + STATE_ON, STATE_OFF, TEMP_CELCIUS, + ATTR_ENTITY_PICTURE, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.components.light import ( - ATTR_XY_COLOR, ATTR_BRIGHTNESS, GROUP_NAME_ALL_LIGHTS) -from homeassistant.util import split_entity_id + ATTR_XY_COLOR, ATTR_RGB_COLOR, ATTR_BRIGHTNESS, GROUP_NAME_ALL_LIGHTS) +from homeassistant.components.thermostat import ( + ATTR_CURRENT_TEMPERATURE, ATTR_AWAY_MODE) +from homeassistant.util import split_entity_id, color_RGB_to_xy DOMAIN = "demo" @@ -24,6 +29,7 @@ DEPENDENCIES = [] def setup(hass, config): """ Setup a demo environment. """ group = loader.get_component('group') + configurator = loader.get_component('configurator') config.setdefault(ha.DOMAIN, {}) config.setdefault(DOMAIN, {}) @@ -48,8 +54,25 @@ def setup(hass, config): domain, _ = split_entity_id(entity_id) if domain == "light": - data = {ATTR_BRIGHTNESS: 200, - ATTR_XY_COLOR: random.choice(light_colors)} + rgb_color = service.data.get(ATTR_RGB_COLOR) + + if rgb_color: + color = color_RGB_to_xy( + rgb_color[0], rgb_color[1], rgb_color[2]) + + else: + cur_state = hass.states.get(entity_id) + + # Use current color if available + if cur_state and cur_state.attributes.get(ATTR_XY_COLOR): + color = cur_state.attributes.get(ATTR_XY_COLOR) + else: + color = random.choice(light_colors) + + data = { + ATTR_BRIGHTNESS: service.data.get(ATTR_BRIGHTNESS, 200), + ATTR_XY_COLOR: color + } else: data = None @@ -124,7 +147,7 @@ def setup(hass, config): }) # Setup chromecast - hass.states.set("chromecast.Living_Rm", "Netflix", + hass.states.set("chromecast.Living_Rm", "Plex", {'friendly_name': 'Living Room', ATTR_ENTITY_PICTURE: 'http://graph.facebook.com/KillBillMovie/picture'}) @@ -141,4 +164,38 @@ def setup(hass, config): 'unit_of_measurement': '%' }) + # Nest demo + hass.states.set("thermostat.Nest", "23", + { + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELCIUS, + ATTR_CURRENT_TEMPERATURE: '18', + ATTR_AWAY_MODE: STATE_OFF + }) + + configurator_ids = [] + + def hue_configuration_callback(data): + """ Fake callback, mark config as done. """ + time.sleep(2) + + # First time it is called, pretend it failed. + if len(configurator_ids) == 1: + configurator.notify_errors( + configurator_ids[0], + "Failed to register, please try again.") + + configurator_ids.append(0) + else: + configurator.request_done(configurator_ids[0]) + + request_id = configurator.request_config( + hass, "Philips Hue", hue_configuration_callback, + description=("Press the button on the bridge to register Philips Hue " + "with Home Assistant."), + description_image="/static/images/config_philips_hue.jpg", + submit_caption="I have pressed the button" + ) + + configurator_ids.append(request_id) + return True diff --git a/homeassistant/components/device_sun_light_trigger.py b/homeassistant/components/device_sun_light_trigger.py index c9668853c73..90d06e6a3c1 100644 --- a/homeassistant/components/device_sun_light_trigger.py +++ b/homeassistant/components/device_sun_light_trigger.py @@ -66,7 +66,6 @@ def setup(hass, config): else: return None - # pylint: disable=unused-argument def schedule_light_on_sun_rise(entity, old_state, new_state): """The moment sun sets we want to have all the lights on. We will schedule to have each light start after one another diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index c478e118036..10efdf8c840 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -24,9 +24,8 @@ DEPENDENCIES = [] SERVICE_DEVICE_TRACKER_RELOAD = "reload_devices_csv" -GROUP_NAME_ALL_DEVICES = 'all_devices' -ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format( - GROUP_NAME_ALL_DEVICES) +GROUP_NAME_ALL_DEVICES = 'all devices' +ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format('all_devices') ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -111,26 +110,24 @@ class DeviceTracker(object): """ Triggers update of the device states. """ self.update_devices(now) - # pylint: disable=unused-argument + dev_group = group.Group( + hass, GROUP_NAME_ALL_DEVICES, user_defined=False) + def reload_known_devices_service(service): """ Reload known devices file. """ - group.remove_group(self.hass, GROUP_NAME_ALL_DEVICES) - self._read_known_devices_file() self.update_devices(datetime.now()) - if self.tracked: - group.setup_group( - self.hass, GROUP_NAME_ALL_DEVICES, - self.device_entity_ids, False) + dev_group.update_tracked_entity_ids(self.device_entity_ids) reload_known_devices_service(None) if self.invalid_known_devices_file: return - hass.track_time_change(update_device_state) + hass.track_time_change( + update_device_state, second=range(0, 60, 12)) hass.services.register(DOMAIN, SERVICE_DEVICE_TRACKER_RELOAD, diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py index 9ed73f21375..637c48ddf26 100644 --- a/homeassistant/components/device_tracker/luci.py +++ b/homeassistant/components/device_tracker/luci.py @@ -17,7 +17,6 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument def get_scanner(hass, config): """ Validates config and returns a Luci scanner. """ if not validate_config(config, @@ -101,7 +100,12 @@ class LuciDeviceScanner(object): result = _req_json_rpc(url, 'net.arptable', params={'auth': self.token}) if result: - self.last_results = [x['HW address'] for x in result] + self.last_results = [] + for device_entry in result: + # Check if the Flags for each device contain + # NUD_REACHABLE and if so, add it to last_results + if int(device_entry['Flags'], 16) & 0x2: + self.last_results.append(device_entry['HW address']) return True diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index 0b0f1107b21..aac09536746 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -14,7 +14,6 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument def get_scanner(hass, config): """ Validates config and returns a Netgear scanner. """ if not validate_config(config, @@ -22,7 +21,10 @@ def get_scanner(hass, config): _LOGGER): return None - scanner = NetgearDeviceScanner(config[DOMAIN]) + info = config[DOMAIN] + + scanner = NetgearDeviceScanner( + info[CONF_HOST], info[CONF_USERNAME], info[CONF_PASSWORD]) return scanner if scanner.success_init else None @@ -30,10 +32,7 @@ def get_scanner(hass, config): class NetgearDeviceScanner(object): """ This class queries a Netgear wireless router using the SOAP-api. """ - def __init__(self, config): - host = config[CONF_HOST] - username, password = config[CONF_USERNAME], config[CONF_PASSWORD] - + def __init__(self, host, username, password): self.last_results = [] try: @@ -54,32 +53,27 @@ class NetgearDeviceScanner(object): self.lock = threading.Lock() _LOGGER.info("Logging in") - if self._api.login(): - self.success_init = True - self._update_info() + self.success_init = self._api.login() + + if self.success_init: + self._update_info() else: _LOGGER.error("Failed to Login") - self.success_init = False - def scan_devices(self): """ Scans for new devices and return a list containing found device ids. """ - self._update_info() - return [device.mac for device in self.last_results] + return (device.mac for device in self.last_results) def get_device_name(self, mac): """ Returns the name of the given device or None if we don't know. """ - - filter_named = [device.name for device in self.last_results - if device.mac == mac] - - if filter_named: - return filter_named[0] - else: + try: + return next(device.name for device in self.last_results + if device.mac == mac) + except StopIteration: return None @Throttle(MIN_TIME_BETWEEN_SCANS) @@ -92,4 +86,4 @@ class NetgearDeviceScanner(object): with self.lock: _LOGGER.info("Scanning") - self.last_results = self._api.get_attached_devices() + self.last_results = self._api.get_attached_devices() or [] diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 4d914d5c1c0..a4fd5f6cfff 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -20,7 +20,6 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument def get_scanner(hass, config): """ Validates config and returns a Nmap scanner. """ if not validate_config(config, {DOMAIN: [CONF_HOSTS]}, diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py index 81755f42c66..265dcf84b57 100644 --- a/homeassistant/components/device_tracker/tomato.py +++ b/homeassistant/components/device_tracker/tomato.py @@ -20,7 +20,6 @@ CONF_HTTP_ID = "http_id" _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument def get_scanner(hass, config): """ Validates config and returns a Tomato scanner. """ if not validate_config(config, diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py new file mode 100644 index 00000000000..195203e8134 --- /dev/null +++ b/homeassistant/components/discovery.py @@ -0,0 +1,86 @@ +""" +Starts a service to scan in intervals for new devices. + +Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered. + +Knows which components handle certain types, will make sure they are +loaded before the EVENT_PLATFORM_DISCOVERED is fired. + +""" +import logging +import threading + +# pylint: disable=no-name-in-module, import-error +from homeassistant.external.netdisco.netdisco import DiscoveryService +import homeassistant.external.netdisco.netdisco.const as services + +from homeassistant import bootstrap +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_PLATFORM_DISCOVERED, + ATTR_SERVICE, ATTR_DISCOVERED) + +DOMAIN = "discovery" +DEPENDENCIES = [] + +SCAN_INTERVAL = 300 # seconds + +SERVICE_HANDLERS = { + services.BELKIN_WEMO: "switch", + services.GOOGLE_CAST: "chromecast", + services.PHILIPS_HUE: "light", +} + + +def listen(hass, service, callback): + """ + Setup listener for discovery of specific service. + Service can be a string or a list/tuple. + """ + + if isinstance(service, str): + service = (service,) + else: + service = tuple(service) + + def discovery_event_listener(event): + """ Listens for discovery events. """ + if event.data[ATTR_SERVICE] in service: + callback(event.data[ATTR_SERVICE], event.data[ATTR_DISCOVERED]) + + hass.bus.listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener) + + +def setup(hass, config): + """ Starts a discovery service. """ + + # Disable zeroconf logging, it spams + logging.getLogger('zeroconf').setLevel(logging.CRITICAL) + + logger = logging.getLogger(__name__) + + lock = threading.Lock() + + def new_service_listener(service, info): + """ Called when a new service is found. """ + with lock: + component = SERVICE_HANDLERS.get(service) + + logger.info("Found new service: %s %s", service, info) + + if component and component not in hass.components: + bootstrap.setup_component(hass, component, config) + + hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { + ATTR_SERVICE: service, + ATTR_DISCOVERED: info + }) + + def start_discovery(event): + """ Start discovering. """ + netdisco = DiscoveryService(SCAN_INTERVAL) + netdisco.add_listener(new_service_listener) + netdisco.start() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_discovery) + + return True diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index eac63ee845b..bcf7f3fe8b4 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -5,12 +5,11 @@ homeassistant.components.groups Provides functionality to group devices that can be turned on or off. """ -import logging - import homeassistant as ha import homeassistant.util as util from homeassistant.const import ( - ATTR_ENTITY_ID, STATE_ON, STATE_OFF, STATE_HOME, STATE_NOT_HOME) + ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_ON, STATE_OFF, + STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN) DOMAIN = "group" DEPENDENCIES = [] @@ -22,8 +21,6 @@ ATTR_AUTO = "auto" # List of ON/OFF state tuples for groupable states _GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME)] -_GROUPS = {} - def _get_group_on_off(state): """ Determine the group on/off states based on a state. """ @@ -94,89 +91,97 @@ def get_entity_ids(hass, entity_id, domain_filter=None): def setup(hass, config): """ Sets up all groups found definded in the configuration. """ for name, entity_ids in config.get(DOMAIN, {}).items(): - entity_ids = entity_ids.split(",") - - setup_group(hass, name, entity_ids) + setup_group(hass, name, entity_ids.split(",")) return True -def setup_group(hass, name, entity_ids, user_defined=True): - """ Sets up a group state that is the combined state of - several states. Supports ON/OFF and DEVICE_HOME/DEVICE_NOT_HOME. """ - logger = logging.getLogger(__name__) +class Group(object): + """ Tracks a group of entity ids. """ + def __init__(self, hass, name, entity_ids=None, user_defined=True): + self.hass = hass + self.name = name + self.user_defined = user_defined - # In case an iterable is passed in - entity_ids = list(entity_ids) + self.entity_id = util.ensure_unique_string( + ENTITY_ID_FORMAT.format(util.slugify(name)), + hass.states.entity_ids(DOMAIN)) - if not entity_ids: - logger.error( - 'Error setting up group %s: no entities passed in to track', name) + self.tracking = [] + self.group_on, self.group_off = None, None - return False - - # Loop over the given entities to: - # - determine which group type this is (on_off, device_home) - # - determine which states exist and have groupable states - # - determine the current state of the group - warnings = [] - group_ids = [] - group_on, group_off = None, None - group_state = False - - for entity_id in entity_ids: - state = hass.states.get(entity_id) - - # Try to determine group type if we didn't yet - if group_on is None and state: - group_on, group_off = _get_group_on_off(state.state) - - if group_on is None: - # We did not find a matching group_type - warnings.append( - "Entity {} has ungroupable state '{}'".format( - name, state.state)) - - continue - - # Check if entity exists - if not state: - warnings.append("Entity {} does not exist".format(entity_id)) - - # Check if entity is invalid state - elif state.state != group_off and state.state != group_on: - - warnings.append("State of {} is {} (expected: {} or {})".format( - entity_id, state.state, group_off, group_on)) - - # We have a valid group state + if entity_ids is not None: + self.update_tracked_entity_ids(entity_ids) else: - group_ids.append(entity_id) + self.force_update() - # Keep track of the group state to init later on - group_state = group_state or state.state == group_on + @property + def state(self): + """ Return the current state from the group. """ + return self.hass.states.get(self.entity_id) - # If none of the entities could be found during setup - if not group_ids: - logger.error('Unable to find any entities to track for group %s', name) + @property + def state_attr(self): + """ State attributes of this group. """ + return { + ATTR_ENTITY_ID: self.tracking, + ATTR_AUTO: not self.user_defined, + ATTR_FRIENDLY_NAME: self.name + } - return False + def update_tracked_entity_ids(self, entity_ids): + """ Update the tracked entity IDs. """ + self.stop() + self.tracking = tuple(entity_ids) + self.group_on, self.group_off = None, None - elif warnings: - logger.warning( - 'Warnings during setting up group %s: %s', - name, ", ".join(warnings)) + self.force_update() - group_entity_id = ENTITY_ID_FORMAT.format(util.slugify(name)) - state = group_on if group_state else group_off - state_attr = {ATTR_ENTITY_ID: group_ids, ATTR_AUTO: not user_defined} + self.start() - # pylint: disable=unused-argument - def update_group_state(entity_id, old_state, new_state): + def force_update(self): + """ Query all the tracked states and update group state. """ + for entity_id in self.tracking: + state = self.hass.states.get(entity_id) + + if state is not None: + self._update_group_state(state.entity_id, None, state) + + # If parsing the entitys did not result in a state, set UNKNOWN + if self.state is None: + self.hass.states.set( + self.entity_id, STATE_UNKNOWN, self.state_attr) + + def start(self): + """ Starts the tracking. """ + self.hass.states.track_change(self.tracking, self._update_group_state) + + def stop(self): + """ Unregisters the group from Home Assistant. """ + self.hass.states.remove(self.entity_id) + + self.hass.bus.remove_listener( + ha.EVENT_STATE_CHANGED, self._update_group_state) + + def _update_group_state(self, entity_id, old_state, new_state): """ Updates the group state based on a state change by a tracked entity. """ - cur_gr_state = hass.states.get(group_entity_id).state + # We have not determined type of group yet + if self.group_on is None: + self.group_on, self.group_off = _get_group_on_off(new_state.state) + + if self.group_on is not None: + # New state of the group is going to be based on the first + # state that we can recognize + self.hass.states.set( + self.entity_id, new_state.state, self.state_attr) + + return + + # There is already a group state + cur_gr_state = self.hass.states.get(self.entity_id).state + group_on, group_off = self.group_on, self.group_off # if cur_gr_state = OFF and new_state = ON: set ON # if cur_gr_state = ON and new_state = OFF: research @@ -184,31 +189,21 @@ def setup_group(hass, name, entity_ids, user_defined=True): if cur_gr_state == group_off and new_state.state == group_on: - hass.states.set(group_entity_id, group_on, state_attr) + self.hass.states.set( + self.entity_id, group_on, self.state_attr) - elif cur_gr_state == group_on and new_state.state == group_off: + elif (cur_gr_state == group_on and + new_state.state == group_off): # Check if any of the other states is still on - if not any([hass.states.is_state(ent_id, group_on) - for ent_id in group_ids - if entity_id != ent_id]): - hass.states.set(group_entity_id, group_off, state_attr) - - _GROUPS[group_entity_id] = hass.states.track_change( - group_ids, update_group_state) - - hass.states.set(group_entity_id, state, state_attr) - - return True + if not any(self.hass.states.is_state(ent_id, group_on) + for ent_id in self.tracking if entity_id != ent_id): + self.hass.states.set( + self.entity_id, group_off, self.state_attr) -def remove_group(hass, name): - """ Remove a group and its state listener from Home Assistant. """ - group_entity_id = ENTITY_ID_FORMAT.format(util.slugify(name)) +def setup_group(hass, name, entity_ids, user_defined=True): + """ Sets up a group state that is the combined state of + several states. Supports ON/OFF and DEVICE_HOME/DEVICE_NOT_HOME. """ - if hass.states.get(group_entity_id) is not None: - hass.states.remove(group_entity_id) - - if group_entity_id in _GROUPS: - hass.bus.remove_listener( - ha.EVENT_STATE_CHANGED, _GROUPS.pop(group_entity_id)) + return Group(hass, name, entity_ids, user_defined) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index a50ed7c9845..67030407f5f 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -86,7 +86,7 @@ import homeassistant as ha from homeassistant.const import ( SERVER_PORT, URL_API, URL_API_STATES, URL_API_EVENTS, URL_API_SERVICES, URL_API_EVENT_FORWARD, URL_API_STATES_ENTITY, AUTH_HEADER) -from homeassistant.helpers import validate_config, TrackStates +from homeassistant.helpers import TrackStates import homeassistant.remote as rem import homeassistant.util as util from . import frontend @@ -117,13 +117,18 @@ DATA_API_PASSWORD = 'api_password' _LOGGER = logging.getLogger(__name__) -def setup(hass, config): +def setup(hass, config=None): """ Sets up the HTTP API and debug interface. """ - if not validate_config(config, {DOMAIN: [CONF_API_PASSWORD]}, _LOGGER): - return False + if config is None or DOMAIN not in config: + config = {DOMAIN: {}} - api_password = config[DOMAIN][CONF_API_PASSWORD] + api_password = config[DOMAIN].get(CONF_API_PASSWORD) + + no_password_set = api_password is None + + if no_password_set: + api_password = util.get_random_string() # If no server host is given, accept all incoming requests server_host = config[DOMAIN].get(CONF_SERVER_HOST, '0.0.0.0') @@ -132,19 +137,16 @@ def setup(hass, config): development = config[DOMAIN].get(CONF_DEVELOPMENT, "") == "1" - server = HomeAssistantHTTPServer((server_host, server_port), - RequestHandler, hass, api_password, - development) + server = HomeAssistantHTTPServer( + (server_host, server_port), RequestHandler, hass, api_password, + development, no_password_set) hass.bus.listen_once( ha.EVENT_HOMEASSISTANT_START, lambda event: threading.Thread(target=server.start, daemon=True).start()) - # If no local api set, set one with known information - if isinstance(hass, rem.HomeAssistant) and hass.local_api is None: - hass.local_api = \ - rem.API(util.get_local_ip(), api_password, server_port) + hass.local_api = rem.API(util.get_local_ip(), api_password, server_port) return True @@ -158,13 +160,14 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): # pylint: disable=too-many-arguments def __init__(self, server_address, request_handler_class, - hass, api_password, development=False): + hass, api_password, development, no_password_set): super().__init__(server_address, request_handler_class) self.server_address = server_address self.hass = hass self.api_password = api_password self.development = development + self.no_password_set = no_password_set # We will lazy init this one if needed self.event_forwarder = None @@ -273,10 +276,13 @@ class RequestHandler(SimpleHTTPRequestHandler): "Error parsing JSON", HTTP_UNPROCESSABLE_ENTITY) return - api_password = self.headers.get(AUTH_HEADER) + if self.server.no_password_set: + api_password = self.server.api_password + else: + api_password = self.headers.get(AUTH_HEADER) - if not api_password and DATA_API_PASSWORD in data: - api_password = data[DATA_API_PASSWORD] + if not api_password and DATA_API_PASSWORD in data: + api_password = data[DATA_API_PASSWORD] if '_METHOD' in data: method = data.pop('_METHOD') @@ -345,7 +351,6 @@ class RequestHandler(SimpleHTTPRequestHandler): """ DELETE request handler. """ self._handle_request('DELETE') - # pylint: disable=unused-argument def _handle_get_root(self, path_match, data): """ Renders the debug interface. """ @@ -360,6 +365,10 @@ class RequestHandler(SimpleHTTPRequestHandler): else: app_url = "frontend-{}.html".format(frontend.VERSION) + # auto login if no password was set, else check api_password param + auth = (self.server.api_password if self.server.no_password_set + else data.get('api_password', '')) + write(("" "" "Home Assistant" @@ -378,19 +387,16 @@ class RequestHandler(SimpleHTTPRequestHandler): " src='/static/webcomponents.min.js'>" "" "" - "").format(app_url, data.get('api_password', ''))) + "").format(app_url, auth)) - # pylint: disable=unused-argument def _handle_get_api(self, path_match, data): """ Renders the debug interface. """ self._json_message("API running.") - # pylint: disable=unused-argument def _handle_get_api_states(self, path_match, data): """ Returns a dict containing all entity ids and their state. """ self._write_json(self.server.hass.states.all()) - # pylint: disable=unused-argument def _handle_get_api_states_entity(self, path_match, data): """ Returns the state of a specific entity. """ entity_id = path_match.group('entity_id') diff --git a/homeassistant/components/http/frontend.py b/homeassistant/components/http/frontend.py index 2ec7cc69630..cd61782ef8a 100644 --- a/homeassistant/components/http/frontend.py +++ b/homeassistant/components/http/frontend.py @@ -1,2 +1,2 @@ """ DO NOT MODIFY. Auto-generated by build_frontend script """ -VERSION = "78343829ea70bf07a9e939b321587122" +VERSION = "43699d5ec727d3444985a1028d21e0d9" diff --git a/homeassistant/components/http/www_static/frontend.html b/homeassistant/components/http/www_static/frontend.html index 35d51134f0c..7a0accc1921 100644 --- a/homeassistant/components/http/www_static/frontend.html +++ b/homeassistant/components/http/www_static/frontend.html @@ -1,9 +1,13 @@ - - -