From ba179bc63883eb37278de2229c9a5566b6aa5be6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 9 Jan 2015 00:07:58 -0800 Subject: [PATCH] Automatic discovery and setting up of devices --- .gitmodules | 3 + homeassistant/__init__.py | 1 + homeassistant/bootstrap.py | 56 ++++++++----- homeassistant/components/chromecast.py | 70 +++++++++------- homeassistant/components/discovery.py | 88 +++++++++++++++++++++ homeassistant/components/group.py | 22 +++--- homeassistant/components/switch/__init__.py | 45 ++++++++--- homeassistant/components/switch/wemo.py | 41 +++++++--- homeassistant/external/netdisco | 1 + homeassistant/external/pywemo | 2 +- homeassistant/helpers.py | 2 +- tests/test_component_chromecast.py | 9 --- tests/test_component_switch.py | 22 +----- 13 files changed, 252 insertions(+), 110 deletions(-) create mode 100644 homeassistant/components/discovery.py create mode 160000 homeassistant/external/netdisco diff --git a/.gitmodules b/.gitmodules index 8ea8376a6a4..5cfe7de0098 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [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 diff --git a/homeassistant/__init__.py b/homeassistant/__init__.py index 777164b3283..34e8b393bcc 100644 --- a/homeassistant/__init__.py +++ b/homeassistant/__init__.py @@ -51,6 +51,7 @@ class HomeAssistant(object): self.bus = EventBus(pool) self.services = ServiceRegistry(self.bus, pool) self.states = StateMachine(self.bus) + self.components = [] self.config_dir = os.path.join(os.getcwd(), 'config') diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 1b2a8ee7312..07dc46692d1 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -19,6 +19,33 @@ import homeassistant.loader as loader import homeassistant.components as core_components +_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) + + 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 def from_config_dict(config, hass=None): """ @@ -29,8 +56,6 @@ def from_config_dict(config, hass=None): if hass is None: hass = homeassistant.HomeAssistant() - logger = logging.getLogger(__name__) - loader.prepare(hass) # Make a copy because we are mutating it. @@ -42,12 +67,12 @@ 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 @@ -57,22 +82,11 @@ def from_config_dict(config, hass=None): add_worker = True for domain in loader.load_order_components(components): - component = loader.get_component(domain) + if setup_component(hass, domain, config): + add_worker = add_worker and domain != "group" - 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) + if add_worker: + hass.pool.add_worker() return hass @@ -112,7 +126,7 @@ def from_config_file(config_path, hass=None, enable_logging=True): logging.getLogger('').addHandler(err_handler) else: - logging.getLogger(__name__).error( + _LOGGER.error( "Unable to setup error log %s (access denied)", err_log_path) # Read config diff --git a/homeassistant/components/chromecast.py b/homeassistant/components/chromecast.py index fc5f7e73dc3..b15bd8b08a7 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,30 @@ 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_chromecast(casts, host): + """ Tries to convert host to Chromecast object and set it up. """ + 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 +143,24 @@ 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) + # pylint: disable=unused-argument + 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. """ @@ -194,10 +209,11 @@ def setup(hass, config): def update_chromecast_states(time): # pylint: disable=unused-argument """ 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. """ diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py new file mode 100644 index 00000000000..99c13c63794 --- /dev/null +++ b/homeassistant/components/discovery.py @@ -0,0 +1,88 @@ +""" +Starts a service to scan in intervals for new devices. + +Will emit EVENT_SERVICE_DISCOVERED whenever a new service has been discovered. + +Knows which components handle certain types, will make sure they are +loaded before the EVENT_SERVICE_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, ATTR_SERVICE + +DOMAIN = "discovery" +DEPENDENCIES = [] + +EVENT_SERVICE_DISCOVERED = "service_discovered" + +ATTR_DISCOVERED = "discovered" + +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 not isinstance(service, str): + service = (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_SERVICE_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: + if bootstrap.setup_component(hass, component, config): + hass.pool.add_worker() + + hass.bus.fire(EVENT_SERVICE_DISCOVERED, { + ATTR_SERVICE: service, + ATTR_DISCOVERED: info + }) + + # pylint: disable=unused-argument + 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 f717ffc0b34..05c69b6e230 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -132,8 +132,7 @@ class Group(object): def update_tracked_entity_ids(self, entity_ids): """ Update the tracked entity IDs. """ self.stop() - - self.tracking = list(entity_ids) + self.tracking = tuple(entity_ids) self.group_on, self.group_off = None, None self.force_update() @@ -150,7 +149,8 @@ class Group(object): # 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.hass.states.set( + self.entity_id, STATE_UNKNOWN, self.state_attr) def start(self): """ Starts the tracking. """ @@ -182,25 +182,25 @@ class Group(object): # 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 # else: ignore - if cur_gr_state == self.group_off and new_state.state == self.group_on: + if cur_gr_state == group_off and new_state.state == group_on: self.hass.states.set( - self.entity_id, self.group_on, self.state_attr) + self.entity_id, group_on, self.state_attr) - elif (cur_gr_state == self.group_on and - new_state.state == self.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([self.hass.states.is_state(ent_id, self.group_on) - for ent_id in self.tracking - if entity_id != ent_id]): + 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, self.group_off, self.state_attr) + self.entity_id, group_off, self.state_attr) def setup_group(hass, name, entity_ids, user_defined=True): diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index a8e626fbab0..3f5e0468040 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -6,12 +6,13 @@ Component to interface with various switches that can be controlled remotely. import logging from datetime import timedelta +from homeassistant.loader import get_component import homeassistant.util as util from homeassistant.const import ( STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID) from homeassistant.helpers import ( extract_entity_ids, platform_devices_from_config) -from homeassistant.components import group +from homeassistant.components import group, discovery DOMAIN = 'switch' DEPENDENCIES = [] @@ -27,6 +28,11 @@ ATTR_CURRENT_POWER_MWH = "current_power_mwh" MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) +# Maps discovered services to their platforms +DISCOVERY = { + discovery.services.BELKIN_WEMO: 'wemo' +} + _LOGGER = logging.getLogger(__name__) @@ -58,21 +64,41 @@ def setup(hass, config): switches = platform_devices_from_config( config, DOMAIN, hass, ENTITY_ID_FORMAT, logger) - if not switches: - return False - # pylint: disable=unused-argument @util.Throttle(MIN_TIME_BETWEEN_SCANS) def update_states(now): """ Update states of all switches. """ + if switches: + logger.info("Updating switch states") - logger.info("Updating switch states") - - for switch in switches.values(): - switch.update_ha_state(hass) + for switch in switches.values(): + switch.update_ha_state(hass) update_states(None) + # Track all switches in a group + switch_group = group.Group( + hass, GROUP_NAME_ALL_SWITCHES, switches.keys(), False) + + def switch_discovered(service, info): + """ Called when a switch is discovered. """ + platform = get_component("{}.{}".format(DOMAIN, DISCOVERY[service])) + + switch = platform.device_discovered(hass, config, info) + + if switch is not None: + switch.entity_id = util.ensure_unique_string( + ENTITY_ID_FORMAT.format(util.slugify(switch.get_name())), + switches.keys()) + + switches[switch.entity_id] = switch + + switch.update_ha_state(hass) + + switch_group.update_tracked_entity_ids(switches.keys()) + + discovery.listen(hass, discovery.services.BELKIN_WEMO, switch_discovered) + def handle_switch_service(service): """ Handles calls to the switch services. """ target_switches = [switches[entity_id] for entity_id @@ -90,9 +116,6 @@ def setup(hass, config): switch.update_ha_state(hass) - # Track all switches in a group - group.Group(hass, GROUP_NAME_ALL_SWITCHES, switches.keys(), False) - # Update state every 30 seconds hass.track_time_change(update_states, second=[0, 30]) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index e75d1832ea1..98bbfcefa0c 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -11,16 +11,9 @@ from homeassistant.components.switch import ( def get_devices(hass, config): """ Find and return WeMo switches. """ - try: - # Pylint does not play nice if not every folders has an __init__.py - # pylint: disable=no-name-in-module, import-error - import homeassistant.external.pywemo.pywemo as pywemo - except ImportError: - logging.getLogger(__name__).exception(( - "Failed to import pywemo. " - "Did you maybe not run `git submodule init` " - "and `git submodule update`?")) + pywemo, _ = get_pywemo() + if pywemo is None: return [] logging.getLogger(__name__).info("Scanning for WeMo devices") @@ -31,6 +24,36 @@ def get_devices(hass, config): if isinstance(switch, pywemo.Switch)] +def device_discovered(hass, config, info): + """ Called when a device is discovered. """ + _, discovery = get_pywemo() + + if discovery is None: + return + + device = discovery.device_from_description(info) + + return None if device is None else WemoSwitch(device) + + +def get_pywemo(): + """ Tries to import PyWemo. """ + try: + # pylint: disable=no-name-in-module, import-error + import homeassistant.external.pywemo.pywemo as pywemo + import homeassistant.external.pywemo.pywemo.discovery as discovery + + return pywemo, discovery + + except ImportError: + logging.getLogger(__name__).exception(( + "Failed to import pywemo. " + "Did you maybe not run `git submodule init` " + "and `git submodule update`?")) + + return None, None + + class WemoSwitch(ToggleDevice): """ represents a WeMo switch within home assistant. """ def __init__(self, wemo): diff --git a/homeassistant/external/netdisco b/homeassistant/external/netdisco new file mode 160000 index 00000000000..20cb8863fce --- /dev/null +++ b/homeassistant/external/netdisco @@ -0,0 +1 @@ +Subproject commit 20cb8863fce3ce7d771ae077ce29ecafe98f8960 diff --git a/homeassistant/external/pywemo b/homeassistant/external/pywemo index 6355e04357c..7f6c383ded7 160000 --- a/homeassistant/external/pywemo +++ b/homeassistant/external/pywemo @@ -1 +1 @@ -Subproject commit 6355e04357cf78b38d293fae7bd418cf9f8d1ca0 +Subproject commit 7f6c383ded75f1273cbca28e858b8a8c96da66d4 diff --git a/homeassistant/helpers.py b/homeassistant/helpers.py index 8bd69d0b1a0..4e479de1472 100644 --- a/homeassistant/helpers.py +++ b/homeassistant/helpers.py @@ -179,7 +179,7 @@ class Device(object): def get_name(self): """ Returns the name of the device if any. """ - return None + return "No Name" def get_state(self): """ Returns state of the device. """ diff --git a/tests/test_component_chromecast.py b/tests/test_component_chromecast.py index 0067a9456a5..962afcf982a 100644 --- a/tests/test_component_chromecast.py +++ b/tests/test_component_chromecast.py @@ -79,12 +79,3 @@ class TestChromecast(unittest.TestCase): self.assertEqual(service_name, call.service) self.assertEqual(self.test_entity, call.data.get(ATTR_ENTITY_ID)) - - def test_setup(self): - """ - Test Chromecast setup. - We do not have access to a Chromecast while testing so test errors. - In an ideal world we would create a mock pychromecast API.. - """ - self.assertFalse(chromecast.setup( - self.hass, {chromecast.DOMAIN: {CONF_HOSTS: '127.0.0.1'}})) diff --git a/tests/test_component_switch.py b/tests/test_component_switch.py index 02ee98bc4c0..9c2624e0ce6 100644 --- a/tests/test_component_switch.py +++ b/tests/test_component_switch.py @@ -7,7 +7,6 @@ Tests switch component. # pylint: disable=too-many-public-methods,protected-access import unittest -import homeassistant as ha import homeassistant.loader as loader from homeassistant.const import STATE_ON, STATE_OFF, CONF_PLATFORM import homeassistant.components.switch as switch @@ -82,29 +81,12 @@ class TestSwitch(unittest.TestCase): self.assertTrue(switch.is_on(self.hass, self.switch_2.entity_id)) self.assertTrue(switch.is_on(self.hass, self.switch_3.entity_id)) - def test_setup(self): - # Bogus config - self.assertFalse(switch.setup(self.hass, {})) - - self.assertFalse(switch.setup(self.hass, {switch.DOMAIN: {}})) - - # Test with non-existing component - self.assertFalse(switch.setup( - self.hass, {switch.DOMAIN: {CONF_PLATFORM: 'nonexisting'}} - )) - + def test_setup_two_platforms(self): + """ Test with bad config. """ # Test if switch component returns 0 switches test_platform = loader.get_component('switch.test') test_platform.init(True) - self.assertEqual( - [], test_platform.get_switches(None, None)) - - self.assertFalse(switch.setup( - self.hass, {switch.DOMAIN: {CONF_PLATFORM: 'test'}} - )) - - # Test if we can load 2 platforms loader.set_component('switch.test2', test_platform) test_platform.init(False)