diff --git a/homeassistant/components/emulated_hue.py b/homeassistant/components/emulated_hue.py
new file mode 100755
index 00000000000..f7a353d5c7f
--- /dev/null
+++ b/homeassistant/components/emulated_hue.py
@@ -0,0 +1,542 @@
+"""
+Support for local control of entities by emulating the Phillips Hue bridge.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/emulated_hue/
+"""
+import threading
+import socket
+import logging
+import json
+import os
+import select
+
+import voluptuous as vol
+
+from homeassistant import util, core
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON,
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
+ STATE_ON
+)
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_SUPPORTED_FEATURES, SUPPORT_BRIGHTNESS
+)
+from homeassistant.components.http import (
+ HomeAssistantView, HomeAssistantWSGI
+)
+import homeassistant.helpers.config_validation as cv
+
+DOMAIN = 'emulated_hue'
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_HOST_IP = 'host_ip'
+CONF_LISTEN_PORT = 'listen_port'
+CONF_OFF_MAPS_TO_ON_DOMAINS = 'off_maps_to_on_domains'
+CONF_EXPOSE_BY_DEFAULT = 'expose_by_default'
+CONF_EXPOSED_DOMAINS = 'exposed_domains'
+
+ATTR_EMULATED_HUE = 'emulated_hue'
+ATTR_EMULATED_HUE_NAME = 'emulated_hue_name'
+
+DEFAULT_LISTEN_PORT = 8300
+DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ['script', 'scene']
+DEFAULT_EXPOSE_BY_DEFAULT = True
+DEFAULT_EXPOSED_DOMAINS = [
+ 'switch', 'light', 'group', 'input_boolean', 'media_player'
+]
+
+HUE_API_STATE_ON = 'on'
+HUE_API_STATE_BRI = 'bri'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_HOST_IP): cv.string,
+ vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT):
+ vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
+ vol.Optional(CONF_OFF_MAPS_TO_ON_DOMAINS): cv.ensure_list,
+ vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean,
+ vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, yaml_config):
+ """Activate the emulated_hue component."""
+ config = Config(yaml_config)
+
+ server = HomeAssistantWSGI(
+ hass,
+ development=False,
+ server_host=config.host_ip_addr,
+ server_port=config.listen_port,
+ api_password=None,
+ ssl_certificate=None,
+ ssl_key=None,
+ cors_origins=[]
+ )
+
+ server.register_view(DescriptionXmlView(hass, config))
+ server.register_view(HueUsernameView(hass))
+ server.register_view(HueLightsView(hass, config))
+
+ upnp_listener = UPNPResponderThread(
+ config.host_ip_addr, config.listen_port)
+
+ def start_emulated_hue_bridge(event):
+ """Start the emulated hue bridge."""
+ server.start()
+ upnp_listener.start()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge)
+
+ def stop_emulated_hue_bridge(event):
+ """Stop the emulated hue bridge."""
+ upnp_listener.stop()
+ server.stop()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge)
+
+ return True
+
+
+# pylint: disable=too-few-public-methods
+class Config(object):
+ """Holds configuration variables for the emulated hue bridge."""
+
+ def __init__(self, yaml_config):
+ """Initialize the instance."""
+ conf = yaml_config.get(DOMAIN, {})
+
+ # Get the IP address that will be passed to the Echo during discovery
+ self.host_ip_addr = conf.get(CONF_HOST_IP)
+ if self.host_ip_addr is None:
+ self.host_ip_addr = util.get_local_ip()
+ _LOGGER.warning(
+ "Listen IP address not specified, auto-detected address is %s",
+ self.host_ip_addr)
+
+ # Get the port that the Hue bridge will listen on
+ self.listen_port = conf.get(CONF_LISTEN_PORT)
+ if not isinstance(self.listen_port, int):
+ self.listen_port = DEFAULT_LISTEN_PORT
+ _LOGGER.warning(
+ "Listen port not specified, defaulting to %s",
+ self.listen_port)
+
+ # Get domains that cause both "on" and "off" commands to map to "on"
+ # This is primarily useful for things like scenes or scripts, which
+ # don't really have a concept of being off
+ self.off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS)
+ if not isinstance(self.off_maps_to_on_domains, list):
+ self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS
+
+ # Get whether or not entities should be exposed by default, or if only
+ # explicitly marked ones will be exposed
+ self.expose_by_default = conf.get(
+ CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT)
+
+ # Get domains that are exposed by default when expose_by_default is
+ # True
+ self.exposed_domains = conf.get(
+ CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS)
+
+
+class DescriptionXmlView(HomeAssistantView):
+ """Handles requests for the description.xml file."""
+
+ url = '/description.xml'
+ name = 'description:xml'
+ requires_auth = False
+
+ def __init__(self, hass, config):
+ """Initialize the instance of the view."""
+ super().__init__(hass)
+ self.config = config
+
+ def get(self, request):
+ """Handle a GET request."""
+ xml_template = """
+
+
+1
+0
+
+http://{0}:{1}/
+
+urn:schemas-upnp-org:device:Basic:1
+HASS Bridge ({0})
+Royal Philips Electronics
+http://www.philips.com
+Philips hue Personal Wireless Lighting
+Philips hue bridge 2015
+BSB002
+http://www.meethue.com
+1234
+uuid:2f402f80-da50-11e1-9b23-001788255acc
+
+
+"""
+
+ resp_text = xml_template.format(
+ self.config.host_ip_addr, self.config.listen_port)
+
+ return self.Response(resp_text, mimetype='text/xml')
+
+
+class HueUsernameView(HomeAssistantView):
+ """Handle requests to create a username for the emulated hue bridge."""
+
+ url = '/api'
+ name = 'hue:api'
+ requires_auth = False
+
+ def __init__(self, hass):
+ """Initialize the instance of the view."""
+ super().__init__(hass)
+
+ def post(self, request):
+ """Handle a POST request."""
+ data = request.json
+
+ if 'devicetype' not in data:
+ return self.Response("devicetype not specified", status=400)
+
+ json_response = [{'success': {'username': '12345678901234567890'}}]
+
+ return self.json(json_response)
+
+
+class HueLightsView(HomeAssistantView):
+ """Handle requests for getting and setting info about entities."""
+
+ url = '/api//lights'
+ name = 'api:username:lights'
+ extra_urls = ['/api//lights/',
+ '/api//lights//state']
+ requires_auth = False
+
+ def __init__(self, hass, config):
+ """Initialize the instance of the view."""
+ super().__init__(hass)
+ self.config = config
+ self.cached_states = {}
+
+ def get(self, request, username, entity_id=None):
+ """Handle a GET request."""
+ if entity_id is None:
+ return self.get_lights_list()
+
+ if not request.base_url.endswith('state'):
+ return self.get_light_state(entity_id)
+
+ return self.Response("Method not allowed", status=405)
+
+ def put(self, request, username, entity_id=None):
+ """Handle a PUT request."""
+ if not request.base_url.endswith('state'):
+ return self.Response("Method not allowed", status=405)
+
+ content_type = request.environ.get('CONTENT_TYPE', '')
+ if content_type == 'application/x-www-form-urlencoded':
+ # Alexa sends JSON data with a form data content type, for
+ # whatever reason, and Werkzeug parses form data automatically,
+ # so we need to do some gymnastics to get the data we need
+ json_data = None
+
+ for key in request.form:
+ try:
+ json_data = json.loads(key)
+ break
+ except ValueError:
+ # Try the next key?
+ pass
+
+ if json_data is None:
+ return self.Response("Bad request", status=400)
+ else:
+ json_data = request.json
+
+ return self.put_light_state(json_data, entity_id)
+
+ def get_lights_list(self):
+ """Process a request to get the list of available lights."""
+ json_response = {}
+
+ for entity in self.hass.states.all():
+ if self.is_entity_exposed(entity):
+ json_response[entity.entity_id] = entity_to_json(entity)
+
+ return self.json(json_response)
+
+ def get_light_state(self, entity_id):
+ """Process a request to get the state of an individual light."""
+ entity = self.hass.states.get(entity_id)
+ if entity is None or not self.is_entity_exposed(entity):
+ return self.Response("Entity not found", status=404)
+
+ cached_state = self.cached_states.get(entity_id, None)
+
+ if cached_state is None:
+ final_state = entity.state == STATE_ON
+ final_brightness = entity.attributes.get(
+ ATTR_BRIGHTNESS, 255 if final_state else 0)
+ else:
+ final_state, final_brightness = cached_state
+
+ json_response = entity_to_json(entity, final_state, final_brightness)
+
+ return self.json(json_response)
+
+ def put_light_state(self, request_json, entity_id):
+ """Process a request to set the state of an individual light."""
+ config = self.config
+
+ # Retrieve the entity from the state machine
+ entity = self.hass.states.get(entity_id)
+ if entity is None:
+ return self.Response("Entity not found", status=404)
+
+ if not self.is_entity_exposed(entity):
+ return self.Response("Entity not found", status=404)
+
+ # Parse the request into requested "on" status and brightness
+ parsed = parse_hue_api_put_light_body(request_json, entity)
+
+ if parsed is None:
+ return self.Response("Bad request", status=400)
+
+ result, brightness = parsed
+
+ # Convert the resulting "on" status into the service we need to call
+ service = SERVICE_TURN_ON if result else SERVICE_TURN_OFF
+
+ # Construct what we need to send to the service
+ data = {ATTR_ENTITY_ID: entity_id}
+
+ if brightness is not None:
+ data[ATTR_BRIGHTNESS] = brightness
+
+ if entity.domain.lower() in config.off_maps_to_on_domains:
+ # Map the off command to on
+ service = SERVICE_TURN_ON
+
+ # Caching is required because things like scripts and scenes won't
+ # report as "off" to Alexa if an "off" command is received, because
+ # they'll map to "on". Thus, instead of reporting its actual
+ # status, we report what Alexa will want to see, which is the same
+ # as the actual requested command.
+ self.cached_states[entity_id] = (result, brightness)
+
+ # Perform the requested action
+ self.hass.services.call(core.DOMAIN, service, data, blocking=True)
+
+ json_response = \
+ [create_hue_success_response(entity_id, HUE_API_STATE_ON, result)]
+
+ if brightness is not None:
+ json_response.append(create_hue_success_response(
+ entity_id, HUE_API_STATE_BRI, brightness))
+
+ return self.json(json_response)
+
+ def is_entity_exposed(self, entity):
+ """Determine if an entity should be exposed on the emulated bridge."""
+ config = self.config
+
+ if entity.attributes.get('view') is not None:
+ # Ignore entities that are views
+ return False
+
+ domain = entity.domain.lower()
+ explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None)
+
+ domain_exposed_by_default = \
+ config.expose_by_default and domain in config.exposed_domains
+
+ # Expose an entity if the entity's domain is exposed by default and
+ # the configuration doesn't explicitly exclude it from being
+ # exposed, or if the entity is explicitly exposed
+ is_default_exposed = \
+ domain_exposed_by_default and explicit_expose is not False
+
+ return is_default_exposed or explicit_expose
+
+
+def parse_hue_api_put_light_body(request_json, entity):
+ """Parse the body of a request to change the state of a light."""
+ if HUE_API_STATE_ON in request_json:
+ if not isinstance(request_json[HUE_API_STATE_ON], bool):
+ return None
+
+ if request_json['on']:
+ # Echo requested device be turned on
+ brightness = None
+ report_brightness = False
+ result = True
+ else:
+ # Echo requested device be turned off
+ brightness = None
+ report_brightness = False
+ result = False
+
+ if HUE_API_STATE_BRI in request_json:
+ # Make sure the entity actually supports brightness
+ entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+
+ if (entity_features & SUPPORT_BRIGHTNESS) == SUPPORT_BRIGHTNESS:
+ try:
+ # Clamp brightness from 0 to 255
+ brightness = \
+ max(0, min(int(request_json[HUE_API_STATE_BRI]), 255))
+ except ValueError:
+ return None
+
+ report_brightness = True
+ result = (brightness > 0)
+
+ return (result, brightness) if report_brightness else (result, None)
+
+
+def entity_to_json(entity, is_on=None, brightness=None):
+ """Convert an entity to its Hue bridge JSON representation."""
+ if is_on is None:
+ is_on = entity.state == STATE_ON
+
+ if brightness is None:
+ brightness = 255 if is_on else 0
+
+ name = entity.attributes.get(
+ ATTR_EMULATED_HUE_NAME, entity.attributes[ATTR_FRIENDLY_NAME])
+
+ return {
+ 'state':
+ {
+ HUE_API_STATE_ON: is_on,
+ HUE_API_STATE_BRI: brightness,
+ 'reachable': True
+ },
+ 'type': 'Dimmable light',
+ 'name': name,
+ 'modelid': 'HASS123',
+ 'uniqueid': entity.entity_id,
+ 'swversion': '123'
+ }
+
+
+def create_hue_success_response(entity_id, attr, value):
+ """Create a success response for an attribute set on a light."""
+ success_key = '/lights/{}/state/{}'.format(entity_id, attr)
+ return {'success': {success_key: value}}
+
+
+class UPNPResponderThread(threading.Thread):
+ """Handle responding to UPNP/SSDP discovery requests."""
+
+ _interrupted = False
+
+ def __init__(self, host_ip_addr, listen_port):
+ """Initialize the class."""
+ threading.Thread.__init__(self)
+
+ self.host_ip_addr = host_ip_addr
+ self.listen_port = listen_port
+
+ # Note that the double newline at the end of
+ # this string is required per the SSDP spec
+ resp_template = """HTTP/1.1 200 OK
+CACHE-CONTROL: max-age=60
+EXT:
+LOCATION: http://{0}:{1}/description.xml
+SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/0.1
+ST: urn:schemas-upnp-org:device:basic:1
+USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1
+
+"""
+
+ self.upnp_response = resp_template.format(host_ip_addr, listen_port) \
+ .replace("\n", "\r\n") \
+ .encode('utf-8')
+
+ # Set up a pipe for signaling to the receiver that it's time to
+ # shutdown. Essentially, we place the SSDP socket into nonblocking
+ # mode and use select() to wait for data to arrive on either the SSDP
+ # socket or the pipe. If data arrives on either one, select() returns
+ # and tells us which filenos have data ready to read.
+ #
+ # When we want to stop the responder, we write data to the pipe, which
+ # causes the select() to return and indicate that said pipe has data
+ # ready to be read, which indicates to us that the responder needs to
+ # be shutdown.
+ self._interrupted_read_pipe, self._interrupted_write_pipe = os.pipe()
+
+ def run(self):
+ """Run the server."""
+ # Listen for UDP port 1900 packets sent to SSDP multicast address
+ ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ ssdp_socket.setblocking(False)
+
+ # Required for receiving multicast
+ ssdp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+
+ ssdp_socket.setsockopt(
+ socket.SOL_IP,
+ socket.IP_MULTICAST_IF,
+ socket.inet_aton(self.host_ip_addr))
+
+ ssdp_socket.setsockopt(
+ socket.SOL_IP,
+ socket.IP_ADD_MEMBERSHIP,
+ socket.inet_aton("239.255.255.250") +
+ socket.inet_aton(self.host_ip_addr))
+
+ ssdp_socket.bind(("239.255.255.250", 1900))
+
+ while True:
+ if self._interrupted:
+ clean_socket_close(ssdp_socket)
+ return
+
+ try:
+ read, _, _ = select.select(
+ [self._interrupted_read_pipe, ssdp_socket], [],
+ [ssdp_socket])
+
+ if self._interrupted_read_pipe in read:
+ # Implies self._interrupted is True
+ clean_socket_close(ssdp_socket)
+ return
+ elif ssdp_socket in read:
+ data, addr = ssdp_socket.recvfrom(1024)
+ else:
+ continue
+ except socket.error as ex:
+ if self._interrupted:
+ clean_socket_close(ssdp_socket)
+ return
+
+ _LOGGER.error("UPNP Responder socket exception occured: %s",
+ ex.__str__)
+
+ if "M-SEARCH" in data.decode('utf-8'):
+ # SSDP M-SEARCH method received, respond to it with our info
+ resp_socket = socket.socket(
+ socket.AF_INET, socket.SOCK_DGRAM)
+
+ resp_socket.sendto(self.upnp_response, addr)
+ resp_socket.close()
+
+ def stop(self):
+ """Stop the server."""
+ # Request for server
+ self._interrupted = True
+ os.write(self._interrupted_write_pipe, bytes([0]))
+ self.join()
+
+
+def clean_socket_close(sock):
+ """Close a socket connection and logs its closure."""
+ _LOGGER.info("UPNP responder shutting down.")
+
+ sock.close()
diff --git a/tests/components/test_emulated_hue.py b/tests/components/test_emulated_hue.py
new file mode 100755
index 00000000000..c9efa6e9fda
--- /dev/null
+++ b/tests/components/test_emulated_hue.py
@@ -0,0 +1,445 @@
+import time
+import json
+import threading
+import asyncio
+
+import unittest
+import requests
+
+from homeassistant import bootstrap, const, core
+import homeassistant.components as core_components
+from homeassistant.components import emulated_hue, http, light, mqtt
+from homeassistant.const import STATE_ON, STATE_OFF
+from homeassistant.components.emulated_hue import (
+ HUE_API_STATE_ON, HUE_API_STATE_BRI
+)
+
+from tests.common import get_test_instance_port, get_test_home_assistant
+
+HTTP_SERVER_PORT = get_test_instance_port()
+BRIDGE_SERVER_PORT = get_test_instance_port()
+MQTT_BROKER_PORT = get_test_instance_port()
+
+BRIDGE_URL_BASE = "http://127.0.0.1:{}".format(BRIDGE_SERVER_PORT) + "{}"
+JSON_HEADERS = {const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON}
+
+mqtt_broker = None
+
+
+def setUpModule():
+ global mqtt_broker
+
+ mqtt_broker = MQTTBroker('127.0.0.1', MQTT_BROKER_PORT)
+ mqtt_broker.start()
+
+
+def tearDownModule():
+ global mqtt_broker
+
+ mqtt_broker.stop()
+
+
+def setup_hass_instance(emulated_hue_config):
+ hass = get_test_home_assistant()
+
+ # We need to do this to get access to homeassistant/turn_(on,off)
+ core_components.setup(hass, {core.DOMAIN: {}})
+
+ bootstrap.setup_component(
+ hass, http.DOMAIN,
+ {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}})
+
+ bootstrap.setup_component(hass, emulated_hue.DOMAIN, emulated_hue_config)
+
+ return hass
+
+
+def start_hass_instance(hass):
+ hass.start()
+ time.sleep(0.05)
+
+
+class TestEmulatedHue(unittest.TestCase):
+ hass = None
+
+ @classmethod
+ def setUpClass(cls):
+ cls.hass = setup_hass_instance({
+ emulated_hue.DOMAIN: {
+ emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT
+ }})
+
+ start_hass_instance(cls.hass)
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.hass.stop()
+
+ def test_description_xml(self):
+ import xml.etree.ElementTree as ET
+
+ result = requests.get(
+ BRIDGE_URL_BASE.format('/description.xml'), timeout=5)
+
+ self.assertEqual(result.status_code, 200)
+ self.assertTrue('text/xml' in result.headers['content-type'])
+
+ # Make sure the XML is parsable
+ try:
+ ET.fromstring(result.text)
+ except:
+ self.fail('description.xml is not valid XML!')
+
+ def test_create_username(self):
+ request_json = {'devicetype': 'my_device'}
+
+ result = requests.post(
+ BRIDGE_URL_BASE.format('/api'), data=json.dumps(request_json),
+ timeout=5)
+
+ self.assertEqual(result.status_code, 200)
+ self.assertTrue('application/json' in result.headers['content-type'])
+
+ resp_json = result.json()
+ success_json = resp_json[0]
+
+ self.assertTrue('success' in success_json)
+ self.assertTrue('username' in success_json['success'])
+
+ def test_valid_username_request(self):
+ request_json = {'invalid_key': 'my_device'}
+
+ result = requests.post(
+ BRIDGE_URL_BASE.format('/api'), data=json.dumps(request_json),
+ timeout=5)
+
+ self.assertEqual(result.status_code, 400)
+
+
+class TestEmulatedHueExposedByDefault(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.hass = setup_hass_instance({
+ emulated_hue.DOMAIN: {
+ emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT,
+ emulated_hue.CONF_EXPOSE_BY_DEFAULT: True
+ }
+ })
+
+ bootstrap.setup_component(cls.hass, mqtt.DOMAIN, {
+ 'mqtt': {
+ 'broker': '127.0.0.1',
+ 'port': MQTT_BROKER_PORT
+ }
+ })
+
+ bootstrap.setup_component(cls.hass, light.DOMAIN, {
+ 'light': [
+ {
+ 'platform': 'mqtt',
+ 'name': 'Office light',
+ 'state_topic': 'office/rgb1/light/status',
+ 'command_topic': 'office/rgb1/light/switch',
+ 'brightness_state_topic': 'office/rgb1/brightness/status',
+ 'brightness_command_topic': 'office/rgb1/brightness/set',
+ 'optimistic': True
+ },
+ {
+ 'platform': 'mqtt',
+ 'name': 'Bedroom light',
+ 'state_topic': 'bedroom/rgb1/light/status',
+ 'command_topic': 'bedroom/rgb1/light/switch',
+ 'brightness_state_topic': 'bedroom/rgb1/brightness/status',
+ 'brightness_command_topic': 'bedroom/rgb1/brightness/set',
+ 'optimistic': True
+ },
+ {
+ 'platform': 'mqtt',
+ 'name': 'Kitchen light',
+ 'state_topic': 'kitchen/rgb1/light/status',
+ 'command_topic': 'kitchen/rgb1/light/switch',
+ 'brightness_state_topic': 'kitchen/rgb1/brightness/status',
+ 'brightness_command_topic': 'kitchen/rgb1/brightness/set',
+ 'optimistic': True
+ }
+ ]
+ })
+
+ start_hass_instance(cls.hass)
+
+ # Kitchen light is explicitly excluded from being exposed
+ kitchen_light_entity = cls.hass.states.get('light.kitchen_light')
+ attrs = dict(kitchen_light_entity.attributes)
+ attrs[emulated_hue.ATTR_EMULATED_HUE] = False
+ cls.hass.states.set(
+ kitchen_light_entity.entity_id, kitchen_light_entity.state,
+ attributes=attrs)
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.hass.stop()
+
+ def test_discover_lights(self):
+ result = requests.get(
+ BRIDGE_URL_BASE.format('/api/username/lights'), timeout=5)
+
+ self.assertEqual(result.status_code, 200)
+ self.assertTrue('application/json' in result.headers['content-type'])
+
+ result_json = result.json()
+
+ # Make sure the lights we added to the config are there
+ self.assertTrue('light.office_light' in result_json)
+ self.assertTrue('light.bedroom_light' in result_json)
+ self.assertTrue('light.kitchen_light' not in result_json)
+
+ def test_get_light_state(self):
+ # Turn office light on and set to 127 brightness
+ self.hass.services.call(
+ light.DOMAIN, const.SERVICE_TURN_ON,
+ {
+ const.ATTR_ENTITY_ID: 'light.office_light',
+ light.ATTR_BRIGHTNESS: 127
+ },
+ blocking=True)
+
+ office_json = self.perform_get_light_state('light.office_light', 200)
+
+ self.assertEqual(office_json['state'][HUE_API_STATE_ON], True)
+ self.assertEqual(office_json['state'][HUE_API_STATE_BRI], 127)
+
+ # Turn bedroom light off
+ self.hass.services.call(
+ light.DOMAIN, const.SERVICE_TURN_OFF,
+ {
+ const.ATTR_ENTITY_ID: 'light.bedroom_light'
+ },
+ blocking=True)
+
+ bedroom_json = self.perform_get_light_state('light.bedroom_light', 200)
+
+ self.assertEqual(bedroom_json['state'][HUE_API_STATE_ON], False)
+ self.assertEqual(bedroom_json['state'][HUE_API_STATE_BRI], 0)
+
+ # Make sure kitchen light isn't accessible
+ kitchen_url = '/api/username/lights/{}'.format('light.kitchen_light')
+ kitchen_result = requests.get(
+ BRIDGE_URL_BASE.format(kitchen_url), timeout=5)
+
+ self.assertEqual(kitchen_result.status_code, 404)
+
+ def test_put_light_state(self):
+ self.perform_put_test_on_office_light()
+
+ # Turn the bedroom light on first
+ self.hass.services.call(
+ light.DOMAIN, const.SERVICE_TURN_ON,
+ {const.ATTR_ENTITY_ID: 'light.bedroom_light',
+ light.ATTR_BRIGHTNESS: 153},
+ blocking=True)
+
+ bedroom_light = self.hass.states.get('light.bedroom_light')
+ self.assertEqual(bedroom_light.state, STATE_ON)
+ self.assertEqual(bedroom_light.attributes[light.ATTR_BRIGHTNESS], 153)
+
+ # Go through the API to turn it off
+ bedroom_result = self.perform_put_light_state(
+ 'light.bedroom_light', False)
+
+ bedroom_result_json = bedroom_result.json()
+
+ self.assertEqual(bedroom_result.status_code, 200)
+ self.assertTrue(
+ 'application/json' in bedroom_result.headers['content-type'])
+
+ self.assertEqual(len(bedroom_result_json), 1)
+
+ # Check to make sure the state changed
+ bedroom_light = self.hass.states.get('light.bedroom_light')
+ self.assertEqual(bedroom_light.state, STATE_OFF)
+
+ # Make sure we can't change the kitchen light state
+ kitchen_result = self.perform_put_light_state(
+ 'light.kitchen_light', True)
+ self.assertEqual(kitchen_result.status_code, 404)
+
+ def test_put_with_form_urlencoded_content_type(self):
+ # Needed for Alexa
+ self.perform_put_test_on_office_light(
+ 'application/x-www-form-urlencoded')
+
+ # Make sure we fail gracefully when we can't parse the data
+ data = {'key1': 'value1', 'key2': 'value2'}
+ result = requests.put(
+ BRIDGE_URL_BASE.format(
+ '/api/username/lights/{}/state'.format("light.office_light")),
+ data=data)
+
+ self.assertEqual(result.status_code, 400)
+
+ def test_entity_not_found(self):
+ result = requests.get(
+ BRIDGE_URL_BASE.format(
+ '/api/username/lights/{}'.format("not.existant_entity")),
+ timeout=5)
+
+ self.assertEqual(result.status_code, 404)
+
+ result = requests.put(
+ BRIDGE_URL_BASE.format(
+ '/api/username/lights/{}/state'.format("non.existant_entity")),
+ timeout=5)
+
+ self.assertEqual(result.status_code, 404)
+
+ def test_allowed_methods(self):
+ result = requests.get(
+ BRIDGE_URL_BASE.format(
+ '/api/username/lights/{}/state'.format("light.office_light")))
+
+ self.assertEqual(result.status_code, 405)
+
+ result = requests.put(
+ BRIDGE_URL_BASE.format(
+ '/api/username/lights/{}'.format("light.office_light")),
+ data={'key1': 'value1'})
+
+ self.assertEqual(result.status_code, 405)
+
+ result = requests.put(
+ BRIDGE_URL_BASE.format('/api/username/lights'),
+ data={'key1': 'value1'})
+
+ self.assertEqual(result.status_code, 405)
+
+ def test_proper_put_state_request(self):
+ # Test proper on value parsing
+ result = requests.put(
+ BRIDGE_URL_BASE.format(
+ '/api/username/lights/{}/state'.format("light.office_light")),
+ data=json.dumps({HUE_API_STATE_ON: 1234}))
+
+ self.assertEqual(result.status_code, 400)
+
+ # Test proper brightness value parsing
+ result = requests.put(
+ BRIDGE_URL_BASE.format(
+ '/api/username/lights/{}/state'.format("light.office_light")),
+ data=json.dumps({
+ HUE_API_STATE_ON: True,
+ HUE_API_STATE_BRI: 'Hello world!'
+ }))
+
+ self.assertEqual(result.status_code, 400)
+
+ def perform_put_test_on_office_light(self,
+ content_type='application/json'):
+ # Turn the office light off first
+ self.hass.services.call(
+ light.DOMAIN, const.SERVICE_TURN_OFF,
+ {const.ATTR_ENTITY_ID: 'light.office_light'},
+ blocking=True)
+
+ office_light = self.hass.states.get('light.office_light')
+ self.assertEqual(office_light.state, STATE_OFF)
+
+ # Go through the API to turn it on
+ office_result = self.perform_put_light_state(
+ 'light.office_light', True, 56, content_type)
+
+ office_result_json = office_result.json()
+
+ self.assertEqual(office_result.status_code, 200)
+ self.assertTrue(
+ 'application/json' in office_result.headers['content-type'])
+
+ self.assertEqual(len(office_result_json), 2)
+
+ # Check to make sure the state changed
+ office_light = self.hass.states.get('light.office_light')
+ self.assertEqual(office_light.state, STATE_ON)
+ self.assertEqual(office_light.attributes[light.ATTR_BRIGHTNESS], 56)
+
+ def perform_get_light_state(self, entity_id, expected_status):
+ result = requests.get(
+ BRIDGE_URL_BASE.format(
+ '/api/username/lights/{}'.format(entity_id)), timeout=5)
+
+ self.assertEqual(result.status_code, expected_status)
+
+ if expected_status == 200:
+ self.assertTrue(
+ 'application/json' in result.headers['content-type'])
+
+ return result.json()
+
+ return None
+
+ def perform_put_light_state(self, entity_id, is_on, brightness=None,
+ content_type='application/json'):
+ url = BRIDGE_URL_BASE.format(
+ '/api/username/lights/{}/state'.format(entity_id))
+
+ req_headers = {'Content-Type': content_type}
+
+ data = {HUE_API_STATE_ON: is_on}
+
+ if brightness is not None:
+ data[HUE_API_STATE_BRI] = brightness
+
+ result = requests.put(
+ url, data=json.dumps(data), timeout=5, headers=req_headers)
+ return result
+
+
+class MQTTBroker(object):
+ """Encapsulates an embedded MQTT broker."""
+
+ def __init__(self, host, port):
+ """Initialize a new instance."""
+ from hbmqtt.broker import Broker
+
+ self._loop = asyncio.new_event_loop()
+
+ hbmqtt_config = {
+ 'listeners': {
+ 'default': {
+ 'max-connections': 50000,
+ 'type': 'tcp',
+ 'bind': '{}:{}'.format(host, port)
+ }
+ },
+ 'auth': {
+ 'plugins': ['auth.anonymous'],
+ 'allow-anonymous': True
+ }
+ }
+
+ self._broker = Broker(config=hbmqtt_config, loop=self._loop)
+
+ self._thread = threading.Thread(target=self._run_loop)
+ self._started_ev = threading.Event()
+
+ def start(self):
+ """Start the broker."""
+ self._thread.start()
+ self._started_ev.wait()
+
+ def stop(self):
+ """Stop the broker."""
+ self._loop.call_soon_threadsafe(asyncio.async, self._broker.shutdown())
+ self._loop.call_soon_threadsafe(self._loop.stop)
+ self._thread.join()
+
+ def _run_loop(self):
+ asyncio.set_event_loop(self._loop)
+ self._loop.run_until_complete(self._broker_coroutine())
+
+ self._started_ev.set()
+
+ self._loop.run_forever()
+ self._loop.close()
+
+ @asyncio.coroutine
+ def _broker_coroutine(self):
+ yield from self._broker.start()