Automatic discovery and setting up of devices

This commit is contained in:
Paulus Schoutsen 2015-01-09 00:07:58 -08:00
parent 035d994705
commit ba179bc638
13 changed files with 252 additions and 110 deletions

3
.gitmodules vendored
View file

@ -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

View file

@ -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')

View file

@ -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

View file

@ -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. """

View file

@ -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

View file

@ -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):

View file

@ -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])

View file

@ -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):

1
homeassistant/external/netdisco vendored Submodule

@ -0,0 +1 @@
Subproject commit 20cb8863fce3ce7d771ae077ce29ecafe98f8960

@ -1 +1 @@
Subproject commit 6355e04357cf78b38d293fae7bd418cf9f8d1ca0
Subproject commit 7f6c383ded75f1273cbca28e858b8a8c96da66d4

View file

@ -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. """

View file

@ -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'}}))

View file

@ -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)