mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +00:00
commit
3a2cdd3de0
@ -1,566 +0,0 @@
|
|||||||
"""
|
|
||||||
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 asyncio
|
|
||||||
import threading
|
|
||||||
import socket
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import select
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
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, STATE_OFF, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
|
|
||||||
)
|
|
||||||
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', 'fan'
|
|
||||||
]
|
|
||||||
|
|
||||||
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=None,
|
|
||||||
use_x_forwarded_for=False,
|
|
||||||
trusted_networks=[],
|
|
||||||
login_threshold=0,
|
|
||||||
is_ban_enabled=False
|
|
||||||
)
|
|
||||||
|
|
||||||
server.register_view(DescriptionXmlView(config))
|
|
||||||
server.register_view(HueUsernameView)
|
|
||||||
server.register_view(HueLightsView(config))
|
|
||||||
|
|
||||||
upnp_listener = UPNPResponderThread(
|
|
||||||
config.host_ip_addr, config.listen_port)
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
|
||||||
def stop_emulated_hue_bridge(event):
|
|
||||||
"""Stop the emulated hue bridge."""
|
|
||||||
upnp_listener.stop()
|
|
||||||
yield from server.stop()
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
|
||||||
def start_emulated_hue_bridge(event):
|
|
||||||
"""Start the emulated hue bridge."""
|
|
||||||
upnp_listener.start()
|
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
|
|
||||||
stop_emulated_hue_bridge)
|
|
||||||
yield from server.start()
|
|
||||||
|
|
||||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
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, config):
|
|
||||||
"""Initialize the instance of the view."""
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
@core.callback
|
|
||||||
def get(self, request):
|
|
||||||
"""Handle a GET request."""
|
|
||||||
xml_template = """<?xml version="1.0" encoding="UTF-8" ?>
|
|
||||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
|
||||||
<specVersion>
|
|
||||||
<major>1</major>
|
|
||||||
<minor>0</minor>
|
|
||||||
</specVersion>
|
|
||||||
<URLBase>http://{0}:{1}/</URLBase>
|
|
||||||
<device>
|
|
||||||
<deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType>
|
|
||||||
<friendlyName>HASS Bridge ({0})</friendlyName>
|
|
||||||
<manufacturer>Royal Philips Electronics</manufacturer>
|
|
||||||
<manufacturerURL>http://www.philips.com</manufacturerURL>
|
|
||||||
<modelDescription>Philips hue Personal Wireless Lighting</modelDescription>
|
|
||||||
<modelName>Philips hue bridge 2015</modelName>
|
|
||||||
<modelNumber>BSB002</modelNumber>
|
|
||||||
<modelURL>http://www.meethue.com</modelURL>
|
|
||||||
<serialNumber>1234</serialNumber>
|
|
||||||
<UDN>uuid:2f402f80-da50-11e1-9b23-001788255acc</UDN>
|
|
||||||
</device>
|
|
||||||
</root>
|
|
||||||
"""
|
|
||||||
|
|
||||||
resp_text = xml_template.format(
|
|
||||||
self.config.host_ip_addr, self.config.listen_port)
|
|
||||||
|
|
||||||
return web.Response(text=resp_text, content_type='text/xml')
|
|
||||||
|
|
||||||
|
|
||||||
class HueUsernameView(HomeAssistantView):
|
|
||||||
"""Handle requests to create a username for the emulated hue bridge."""
|
|
||||||
|
|
||||||
url = '/api'
|
|
||||||
name = 'hue:api'
|
|
||||||
extra_urls = ['/api/']
|
|
||||||
requires_auth = False
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
|
||||||
def post(self, request):
|
|
||||||
"""Handle a POST request."""
|
|
||||||
try:
|
|
||||||
data = yield from request.json()
|
|
||||||
except ValueError:
|
|
||||||
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
|
||||||
|
|
||||||
if 'devicetype' not in data:
|
|
||||||
return self.json_message('devicetype not specified',
|
|
||||||
HTTP_BAD_REQUEST)
|
|
||||||
|
|
||||||
return self.json([{'success': {'username': '12345678901234567890'}}])
|
|
||||||
|
|
||||||
|
|
||||||
class HueLightsView(HomeAssistantView):
|
|
||||||
"""Handle requests for getting and setting info about entities."""
|
|
||||||
|
|
||||||
url = '/api/{username}/lights'
|
|
||||||
name = 'api:username:lights'
|
|
||||||
extra_urls = ['/api/{username}/lights/{entity_id}',
|
|
||||||
'/api/{username}/lights/{entity_id}/state']
|
|
||||||
requires_auth = False
|
|
||||||
|
|
||||||
def __init__(self, config):
|
|
||||||
"""Initialize the instance of the view."""
|
|
||||||
self.config = config
|
|
||||||
self.cached_states = {}
|
|
||||||
|
|
||||||
@core.callback
|
|
||||||
def get(self, request, username, entity_id=None):
|
|
||||||
"""Handle a GET request."""
|
|
||||||
hass = request.app['hass']
|
|
||||||
|
|
||||||
if entity_id is None:
|
|
||||||
return self.async_get_lights_list(hass)
|
|
||||||
|
|
||||||
if not request.path.endswith('state'):
|
|
||||||
return self.async_get_light_state(hass, entity_id)
|
|
||||||
|
|
||||||
return web.Response(text="Method not allowed", status=405)
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
|
||||||
def put(self, request, username, entity_id=None):
|
|
||||||
"""Handle a PUT request."""
|
|
||||||
hass = request.app['hass']
|
|
||||||
|
|
||||||
if not request.path.endswith('state'):
|
|
||||||
return web.Response(text="Method not allowed", status=405)
|
|
||||||
|
|
||||||
if entity_id and hass.states.get(entity_id) is None:
|
|
||||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
|
||||||
|
|
||||||
try:
|
|
||||||
json_data = yield from request.json()
|
|
||||||
except ValueError:
|
|
||||||
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
|
||||||
|
|
||||||
result = yield from self.async_put_light_state(hass, json_data,
|
|
||||||
entity_id)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@core.callback
|
|
||||||
def async_get_lights_list(self, hass):
|
|
||||||
"""Process a request to get the list of available lights."""
|
|
||||||
json_response = {}
|
|
||||||
|
|
||||||
for entity in hass.states.async_all():
|
|
||||||
if self.is_entity_exposed(entity):
|
|
||||||
json_response[entity.entity_id] = entity_to_json(entity)
|
|
||||||
|
|
||||||
return self.json(json_response)
|
|
||||||
|
|
||||||
@core.callback
|
|
||||||
def async_get_light_state(self, hass, entity_id):
|
|
||||||
"""Process a request to get the state of an individual light."""
|
|
||||||
entity = hass.states.get(entity_id)
|
|
||||||
if entity is None or not self.is_entity_exposed(entity):
|
|
||||||
return web.Response(text="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)
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
|
||||||
def async_put_light_state(self, hass, 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 = hass.states.get(entity_id)
|
|
||||||
if entity is None:
|
|
||||||
return web.Response(text="Entity not found", status=404)
|
|
||||||
|
|
||||||
if not self.is_entity_exposed(entity):
|
|
||||||
return web.Response(text="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 web.Response(text="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 the requested entity is a script add some variables
|
|
||||||
if entity.domain.lower() == "script":
|
|
||||||
data['variables'] = {
|
|
||||||
'requested_state': STATE_ON if result else STATE_OFF
|
|
||||||
}
|
|
||||||
|
|
||||||
if brightness is not None:
|
|
||||||
data['variables']['requested_level'] = brightness
|
|
||||||
|
|
||||||
elif 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
|
|
||||||
yield from hass.services.async_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.
|
|
||||||
|
|
||||||
Async friendly.
|
|
||||||
"""
|
|
||||||
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)
|
|
||||||
elif entity.domain.lower() == "script":
|
|
||||||
# Convert 0-255 to 0-100
|
|
||||||
level = int(request_json[HUE_API_STATE_BRI]) / 255 * 100
|
|
||||||
|
|
||||||
brightness = round(level)
|
|
||||||
report_brightness = True
|
|
||||||
result = True
|
|
||||||
|
|
||||||
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()
|
|
198
homeassistant/components/emulated_hue/__init__.py
Normal file
198
homeassistant/components/emulated_hue/__init__.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
"""
|
||||||
|
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 asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import util
|
||||||
|
from homeassistant.const import (
|
||||||
|
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
|
||||||
|
)
|
||||||
|
from homeassistant.components.http import HomeAssistantWSGI
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from .hue_api import (
|
||||||
|
HueUsernameView, HueAllLightsStateView, HueOneLightStateView,
|
||||||
|
HueOneLightChangeView)
|
||||||
|
from .upnp import DescriptionXmlView, UPNPResponderThread
|
||||||
|
|
||||||
|
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'
|
||||||
|
CONF_TYPE = 'type'
|
||||||
|
|
||||||
|
TYPE_ALEXA = 'alexa'
|
||||||
|
TYPE_GOOGLE = 'google_home'
|
||||||
|
|
||||||
|
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', 'fan'
|
||||||
|
]
|
||||||
|
DEFAULT_TYPE = TYPE_ALEXA
|
||||||
|
|
||||||
|
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,
|
||||||
|
vol.Optional(CONF_TYPE, default=DEFAULT_TYPE):
|
||||||
|
vol.Any(TYPE_ALEXA, TYPE_GOOGLE)
|
||||||
|
})
|
||||||
|
}, extra=vol.ALLOW_EXTRA)
|
||||||
|
|
||||||
|
ATTR_EMULATED_HUE = 'emulated_hue'
|
||||||
|
|
||||||
|
|
||||||
|
def setup(hass, yaml_config):
|
||||||
|
"""Activate the emulated_hue component."""
|
||||||
|
config = Config(yaml_config.get(DOMAIN, {}))
|
||||||
|
|
||||||
|
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=None,
|
||||||
|
use_x_forwarded_for=False,
|
||||||
|
trusted_networks=[],
|
||||||
|
login_threshold=0,
|
||||||
|
is_ban_enabled=False
|
||||||
|
)
|
||||||
|
|
||||||
|
server.register_view(DescriptionXmlView(config))
|
||||||
|
server.register_view(HueUsernameView)
|
||||||
|
server.register_view(HueAllLightsStateView(config))
|
||||||
|
server.register_view(HueOneLightStateView(config))
|
||||||
|
server.register_view(HueOneLightChangeView(config))
|
||||||
|
|
||||||
|
upnp_listener = UPNPResponderThread(
|
||||||
|
config.host_ip_addr, config.listen_port)
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def stop_emulated_hue_bridge(event):
|
||||||
|
"""Stop the emulated hue bridge."""
|
||||||
|
upnp_listener.stop()
|
||||||
|
yield from server.stop()
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def start_emulated_hue_bridge(event):
|
||||||
|
"""Start the emulated hue bridge."""
|
||||||
|
upnp_listener.start()
|
||||||
|
yield from server.start()
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
|
||||||
|
stop_emulated_hue_bridge)
|
||||||
|
|
||||||
|
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class Config(object):
|
||||||
|
"""Holds configuration variables for the emulated hue bridge."""
|
||||||
|
|
||||||
|
def __init__(self, conf):
|
||||||
|
"""Initialize the instance."""
|
||||||
|
self.type = conf.get(CONF_TYPE)
|
||||||
|
self.numbers = {}
|
||||||
|
self.cached_states = {}
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
if self.type == TYPE_GOOGLE and self.listen_port != 80:
|
||||||
|
_LOGGER.warning('When targetting Google Home, listening port has '
|
||||||
|
'to be port 80')
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
def entity_id_to_number(self, entity_id):
|
||||||
|
"""Get a unique number for the entity id."""
|
||||||
|
if self.type == TYPE_ALEXA:
|
||||||
|
return entity_id
|
||||||
|
|
||||||
|
# Google Home
|
||||||
|
for number, ent_id in self.numbers.items():
|
||||||
|
if entity_id == ent_id:
|
||||||
|
return number
|
||||||
|
|
||||||
|
number = str(len(self.numbers) + 1)
|
||||||
|
self.numbers[number] = entity_id
|
||||||
|
return number
|
||||||
|
|
||||||
|
def number_to_entity_id(self, number):
|
||||||
|
"""Convert unique number to entity id."""
|
||||||
|
if self.type == TYPE_ALEXA:
|
||||||
|
return number
|
||||||
|
|
||||||
|
# Google Home
|
||||||
|
assert isinstance(number, str)
|
||||||
|
return self.numbers.get(number)
|
||||||
|
|
||||||
|
def is_entity_exposed(self, entity):
|
||||||
|
"""Determine if an entity should be exposed on the emulated bridge.
|
||||||
|
|
||||||
|
Async friendly.
|
||||||
|
"""
|
||||||
|
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 = \
|
||||||
|
self.expose_by_default and domain in self.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
|
275
homeassistant/components/emulated_hue/hue_api.py
Normal file
275
homeassistant/components/emulated_hue/hue_api.py
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
"""Provides a Hue API to control Home Assistant."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from homeassistant import core
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||||
|
STATE_ON, STATE_OFF, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
|
||||||
|
)
|
||||||
|
from homeassistant.components.light import (
|
||||||
|
ATTR_BRIGHTNESS, ATTR_SUPPORTED_FEATURES, SUPPORT_BRIGHTNESS
|
||||||
|
)
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ATTR_EMULATED_HUE = 'emulated_hue'
|
||||||
|
ATTR_EMULATED_HUE_NAME = 'emulated_hue_name'
|
||||||
|
|
||||||
|
HUE_API_STATE_ON = 'on'
|
||||||
|
HUE_API_STATE_BRI = 'bri'
|
||||||
|
|
||||||
|
|
||||||
|
class HueUsernameView(HomeAssistantView):
|
||||||
|
"""Handle requests to create a username for the emulated hue bridge."""
|
||||||
|
|
||||||
|
url = '/api'
|
||||||
|
name = 'emulated_hue:api:create_username'
|
||||||
|
extra_urls = ['/api/']
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def post(self, request):
|
||||||
|
"""Handle a POST request."""
|
||||||
|
try:
|
||||||
|
data = yield from request.json()
|
||||||
|
except ValueError:
|
||||||
|
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
||||||
|
|
||||||
|
if 'devicetype' not in data:
|
||||||
|
return self.json_message('devicetype not specified',
|
||||||
|
HTTP_BAD_REQUEST)
|
||||||
|
|
||||||
|
return self.json([{'success': {'username': '12345678901234567890'}}])
|
||||||
|
|
||||||
|
|
||||||
|
class HueAllLightsStateView(HomeAssistantView):
|
||||||
|
"""Handle requests for getting and setting info about entities."""
|
||||||
|
|
||||||
|
url = '/api/{username}/lights'
|
||||||
|
name = 'emulated_hue:lights:state'
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
"""Initialize the instance of the view."""
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
@core.callback
|
||||||
|
def get(self, request, username):
|
||||||
|
"""Process a request to get the list of available lights."""
|
||||||
|
hass = request.app['hass']
|
||||||
|
json_response = {}
|
||||||
|
|
||||||
|
for entity in hass.states.async_all():
|
||||||
|
if self.config.is_entity_exposed(entity):
|
||||||
|
number = self.config.entity_id_to_number(entity.entity_id)
|
||||||
|
json_response[number] = entity_to_json(entity)
|
||||||
|
|
||||||
|
return self.json(json_response)
|
||||||
|
|
||||||
|
|
||||||
|
class HueOneLightStateView(HomeAssistantView):
|
||||||
|
"""Handle requests for getting and setting info about entities."""
|
||||||
|
|
||||||
|
url = '/api/{username}/lights/{entity_id}'
|
||||||
|
name = 'emulated_hue:light:state'
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
"""Initialize the instance of the view."""
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
@core.callback
|
||||||
|
def get(self, request, username, entity_id=None):
|
||||||
|
"""Process a request to get the state of an individual light."""
|
||||||
|
hass = request.app['hass']
|
||||||
|
entity_id = self.config.number_to_entity_id(entity_id)
|
||||||
|
entity = hass.states.get(entity_id)
|
||||||
|
|
||||||
|
if entity is None:
|
||||||
|
_LOGGER.error('Entity not found: %s', entity_id)
|
||||||
|
return web.Response(text="Entity not found", status=404)
|
||||||
|
|
||||||
|
if not self.config.is_entity_exposed(entity):
|
||||||
|
_LOGGER.error('Entity not exposed: %s', entity_id)
|
||||||
|
return web.Response(text="Entity not exposed", status=404)
|
||||||
|
|
||||||
|
cached_state = self.config.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)
|
||||||
|
|
||||||
|
|
||||||
|
class HueOneLightChangeView(HomeAssistantView):
|
||||||
|
"""Handle requests for getting and setting info about entities."""
|
||||||
|
|
||||||
|
url = '/api/{username}/lights/{entity_number}/state'
|
||||||
|
name = 'emulated_hue:light:state'
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
"""Initialize the instance of the view."""
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def put(self, request, username, entity_number):
|
||||||
|
"""Process a request to set the state of an individual light."""
|
||||||
|
config = self.config
|
||||||
|
hass = request.app['hass']
|
||||||
|
entity_id = config.number_to_entity_id(entity_number)
|
||||||
|
|
||||||
|
if entity_id is None:
|
||||||
|
_LOGGER.error('Unknown entity number: %s', entity_number)
|
||||||
|
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||||
|
|
||||||
|
entity = hass.states.get(entity_id)
|
||||||
|
|
||||||
|
if entity is None:
|
||||||
|
_LOGGER.error('Entity not found: %s', entity_id)
|
||||||
|
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||||
|
|
||||||
|
if not config.is_entity_exposed(entity):
|
||||||
|
_LOGGER.error('Entity not exposed: %s', entity_id)
|
||||||
|
return web.Response(text="Entity not exposed", status=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
request_json = yield from request.json()
|
||||||
|
except ValueError:
|
||||||
|
_LOGGER.error('Received invalid json')
|
||||||
|
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Parse the request into requested "on" status and brightness
|
||||||
|
parsed = parse_hue_api_put_light_body(request_json, entity)
|
||||||
|
|
||||||
|
if parsed is None:
|
||||||
|
_LOGGER.error('Unable to parse data: %s', request_json)
|
||||||
|
return web.Response(text="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 the requested entity is a script add some variables
|
||||||
|
if entity.domain == "script":
|
||||||
|
data['variables'] = {
|
||||||
|
'requested_state': STATE_ON if result else STATE_OFF
|
||||||
|
}
|
||||||
|
|
||||||
|
if brightness is not None:
|
||||||
|
data['variables']['requested_level'] = brightness
|
||||||
|
|
||||||
|
elif brightness is not None:
|
||||||
|
data[ATTR_BRIGHTNESS] = brightness
|
||||||
|
|
||||||
|
if entity.domain 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.
|
||||||
|
config.cached_states[entity_id] = (result, brightness)
|
||||||
|
|
||||||
|
# Perform the requested action
|
||||||
|
yield from hass.services.async_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 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)
|
||||||
|
elif entity.domain.lower() == "script":
|
||||||
|
# Convert 0-255 to 0-100
|
||||||
|
level = int(request_json[HUE_API_STATE_BRI]) / 255 * 100
|
||||||
|
|
||||||
|
brightness = round(level)
|
||||||
|
report_brightness = True
|
||||||
|
result = True
|
||||||
|
|
||||||
|
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}}
|
166
homeassistant/components/emulated_hue/upnp.py
Normal file
166
homeassistant/components/emulated_hue/upnp.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
"""Provides a UPNP discovery method that mimicks Hue hubs."""
|
||||||
|
import threading
|
||||||
|
import socket
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from homeassistant import core
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DescriptionXmlView(HomeAssistantView):
|
||||||
|
"""Handles requests for the description.xml file."""
|
||||||
|
|
||||||
|
url = '/description.xml'
|
||||||
|
name = 'description:xml'
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
"""Initialize the instance of the view."""
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
@core.callback
|
||||||
|
def get(self, request):
|
||||||
|
"""Handle a GET request."""
|
||||||
|
xml_template = """<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>0</minor>
|
||||||
|
</specVersion>
|
||||||
|
<URLBase>http://{0}:{1}/</URLBase>
|
||||||
|
<device>
|
||||||
|
<deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType>
|
||||||
|
<friendlyName>HASS Bridge ({0})</friendlyName>
|
||||||
|
<manufacturer>Royal Philips Electronics</manufacturer>
|
||||||
|
<manufacturerURL>http://www.philips.com</manufacturerURL>
|
||||||
|
<modelDescription>Philips hue Personal Wireless Lighting</modelDescription>
|
||||||
|
<modelName>Philips hue bridge 2015</modelName>
|
||||||
|
<modelNumber>BSB002</modelNumber>
|
||||||
|
<modelURL>http://www.meethue.com</modelURL>
|
||||||
|
<serialNumber>1234</serialNumber>
|
||||||
|
<UDN>uuid:2f402f80-da50-11e1-9b23-001788255acc</UDN>
|
||||||
|
</device>
|
||||||
|
</root>
|
||||||
|
"""
|
||||||
|
|
||||||
|
resp_text = xml_template.format(
|
||||||
|
self.config.host_ip_addr, self.config.listen_port)
|
||||||
|
|
||||||
|
return web.Response(text=resp_text, content_type='text/xml')
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
@ -544,19 +544,19 @@ def _hm_event_handler(hass, proxy, device, caller, attribute, value):
|
|||||||
|
|
||||||
# keypress event
|
# keypress event
|
||||||
if attribute in HM_PRESS_EVENTS:
|
if attribute in HM_PRESS_EVENTS:
|
||||||
hass.bus.fire(EVENT_KEYPRESS, {
|
hass.add_job(hass.bus.async_fire(EVENT_KEYPRESS, {
|
||||||
ATTR_NAME: hmdevice.NAME,
|
ATTR_NAME: hmdevice.NAME,
|
||||||
ATTR_PARAM: attribute,
|
ATTR_PARAM: attribute,
|
||||||
ATTR_CHANNEL: channel
|
ATTR_CHANNEL: channel
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
|
|
||||||
# impulse event
|
# impulse event
|
||||||
if attribute in HM_IMPULSE_EVENTS:
|
if attribute in HM_IMPULSE_EVENTS:
|
||||||
hass.bus.fire(EVENT_KEYPRESS, {
|
hass.add_job(hass.bus.async_fire(EVENT_KEYPRESS, {
|
||||||
ATTR_NAME: hmdevice.NAME,
|
ATTR_NAME: hmdevice.NAME,
|
||||||
ATTR_CHANNEL: channel
|
ATTR_CHANNEL: channel
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
|
|
||||||
_LOGGER.warning("Event is unknown and not forwarded to HA")
|
_LOGGER.warning("Event is unknown and not forwarded to HA")
|
||||||
|
@ -277,9 +277,15 @@ class HomeAssistantWSGI(object):
|
|||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start the wsgi server."""
|
"""Start the wsgi server."""
|
||||||
|
cors_added = set()
|
||||||
if self.cors is not None:
|
if self.cors is not None:
|
||||||
for route in list(self.app.router.routes()):
|
for route in list(self.app.router.routes()):
|
||||||
|
if hasattr(route, 'resource'):
|
||||||
|
route = route.resource
|
||||||
|
if route in cors_added:
|
||||||
|
continue
|
||||||
self.cors.add(route)
|
self.cors.add(route)
|
||||||
|
cors_added.add(route)
|
||||||
|
|
||||||
if self.ssl_certificate:
|
if self.ssl_certificate:
|
||||||
context = ssl.SSLContext(SSL_VERSION)
|
context = ssl.SSLContext(SSL_VERSION)
|
||||||
|
@ -59,7 +59,7 @@ ATTR_SLEEP_TIME = 'sleep_time'
|
|||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
vol.Optional(CONF_ADVERTISE_ADDR): cv.string,
|
vol.Optional(CONF_ADVERTISE_ADDR): cv.string,
|
||||||
vol.Optional(CONF_INTERFACE_ADDR): cv.string,
|
vol.Optional(CONF_INTERFACE_ADDR): cv.string,
|
||||||
vol.Optional(CONF_HOSTS): cv.ensure_list(cv.string),
|
vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list, [cv.string]),
|
||||||
})
|
})
|
||||||
|
|
||||||
SONOS_SCHEMA = vol.Schema({
|
SONOS_SCHEMA = vol.Schema({
|
||||||
|
@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
REQUIREMENTS = [
|
REQUIREMENTS = [
|
||||||
'http://github.com/technicalpickles/python-nest'
|
'http://github.com/technicalpickles/python-nest'
|
||||||
'/archive/0be5c8a6307ee81540f21aac4fcd22cc5d98c988.zip' # nest-cam branch
|
'/archive/2512973b4b390d3965da43529cd20402ad374bfa.zip' # nest-cam branch
|
||||||
'#python-nest==3.0.0']
|
'#python-nest==3.0.0']
|
||||||
|
|
||||||
DOMAIN = 'nest'
|
DOMAIN = 'nest'
|
||||||
|
@ -151,15 +151,9 @@ class SynoApi():
|
|||||||
except:
|
except:
|
||||||
_LOGGER.error("Error setting up Synology DSM")
|
_LOGGER.error("Error setting up Synology DSM")
|
||||||
|
|
||||||
def utilisation(self):
|
# Will be updated when `update` gets called.
|
||||||
"""Return utilisation information from API."""
|
self.utilisation = self._api.utilisation
|
||||||
if self._api is not None:
|
self.storage = self._api.storage
|
||||||
return self._api.utilisation
|
|
||||||
|
|
||||||
def storage(self):
|
|
||||||
"""Return storage information from API."""
|
|
||||||
if self._api is not None:
|
|
||||||
return self._api.storage
|
|
||||||
|
|
||||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||||
def update(self):
|
def update(self):
|
||||||
@ -219,14 +213,14 @@ class SynoNasUtilSensor(SynoNasSensor):
|
|||||||
'memory_total_swap', 'memory_total_real']
|
'memory_total_swap', 'memory_total_real']
|
||||||
|
|
||||||
if self.var_id in network_sensors or self.var_id in memory_sensors:
|
if self.var_id in network_sensors or self.var_id in memory_sensors:
|
||||||
attr = getattr(self._api.utilisation(), self.var_id)(False)
|
attr = getattr(self._api.utilisation, self.var_id)(False)
|
||||||
|
|
||||||
if self.var_id in network_sensors:
|
if self.var_id in network_sensors:
|
||||||
return round(attr / 1024.0, 1)
|
return round(attr / 1024.0, 1)
|
||||||
elif self.var_id in memory_sensors:
|
elif self.var_id in memory_sensors:
|
||||||
return round(attr / 1024.0 / 1024.0, 1)
|
return round(attr / 1024.0 / 1024.0, 1)
|
||||||
else:
|
else:
|
||||||
return getattr(self._api.utilisation(), self.var_id)
|
return getattr(self._api.utilisation, self.var_id)
|
||||||
|
|
||||||
|
|
||||||
class SynoNasStorageSensor(SynoNasSensor):
|
class SynoNasStorageSensor(SynoNasSensor):
|
||||||
@ -240,7 +234,7 @@ class SynoNasStorageSensor(SynoNasSensor):
|
|||||||
|
|
||||||
if self.monitor_device is not None:
|
if self.monitor_device is not None:
|
||||||
if self.var_id in temp_sensors:
|
if self.var_id in temp_sensors:
|
||||||
attr = getattr(self._api.storage(),
|
attr = getattr(self._api.storage,
|
||||||
self.var_id)(self.monitor_device)
|
self.var_id)(self.monitor_device)
|
||||||
|
|
||||||
if self._api.temp_unit == TEMP_CELSIUS:
|
if self._api.temp_unit == TEMP_CELSIUS:
|
||||||
@ -248,5 +242,5 @@ class SynoNasStorageSensor(SynoNasSensor):
|
|||||||
else:
|
else:
|
||||||
return round(attr * 1.8 + 32.0, 1)
|
return round(attr * 1.8 + 32.0, 1)
|
||||||
else:
|
else:
|
||||||
return getattr(self._api.storage(),
|
return getattr(self._api.storage,
|
||||||
self.var_id)(self.monitor_device)
|
self.var_id)(self.monitor_device)
|
||||||
|
@ -15,7 +15,7 @@ from homeassistant.const import (CONF_HOST, CONF_NAME)
|
|||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
REQUIREMENTS = ['https://github.com/GadgetReactor/pyHS100/archive/'
|
REQUIREMENTS = ['https://github.com/GadgetReactor/pyHS100/archive/'
|
||||||
'fadb76c5a0e04f4995f16055845ffedc6d658316.zip#pyHS100==0.2.1']
|
'1f771b7d8090a91c6a58931532e42730b021cbde.zip#pyHS100==0.2.0']
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"""Constants used by Home Assistant components."""
|
"""Constants used by Home Assistant components."""
|
||||||
MAJOR_VERSION = 0
|
MAJOR_VERSION = 0
|
||||||
MINOR_VERSION = 34
|
MINOR_VERSION = 34
|
||||||
PATCH_VERSION = '0'
|
PATCH_VERSION = '1'
|
||||||
__short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION)
|
__short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION)
|
||||||
__version__ = '{}.{}'.format(__short_version__, PATCH_VERSION)
|
__version__ = '{}.{}'.format(__short_version__, PATCH_VERSION)
|
||||||
REQUIRED_PYTHON_VER = (3, 4, 2)
|
REQUIRED_PYTHON_VER = (3, 4, 2)
|
||||||
|
@ -164,13 +164,13 @@ hikvision==0.4
|
|||||||
# http://github.com/adafruit/Adafruit_Python_DHT/archive/310c59b0293354d07d94375f1365f7b9b9110c7d.zip#Adafruit_DHT==1.3.0
|
# http://github.com/adafruit/Adafruit_Python_DHT/archive/310c59b0293354d07d94375f1365f7b9b9110c7d.zip#Adafruit_DHT==1.3.0
|
||||||
|
|
||||||
# homeassistant.components.nest
|
# homeassistant.components.nest
|
||||||
http://github.com/technicalpickles/python-nest/archive/0be5c8a6307ee81540f21aac4fcd22cc5d98c988.zip#python-nest==3.0.0
|
http://github.com/technicalpickles/python-nest/archive/2512973b4b390d3965da43529cd20402ad374bfa.zip#python-nest==3.0.0
|
||||||
|
|
||||||
# homeassistant.components.light.flux_led
|
# homeassistant.components.light.flux_led
|
||||||
https://github.com/Danielhiversen/flux_led/archive/0.9.zip#flux_led==0.9
|
https://github.com/Danielhiversen/flux_led/archive/0.9.zip#flux_led==0.9
|
||||||
|
|
||||||
# homeassistant.components.switch.tplink
|
# homeassistant.components.switch.tplink
|
||||||
https://github.com/GadgetReactor/pyHS100/archive/fadb76c5a0e04f4995f16055845ffedc6d658316.zip#pyHS100==0.2.1
|
https://github.com/GadgetReactor/pyHS100/archive/1f771b7d8090a91c6a58931532e42730b021cbde.zip#pyHS100==0.2.0
|
||||||
|
|
||||||
# homeassistant.components.switch.dlink
|
# homeassistant.components.switch.dlink
|
||||||
https://github.com/LinuxChristian/pyW215/archive/v0.3.7.zip#pyW215==0.3.7
|
https://github.com/LinuxChristian/pyW215/archive/v0.3.7.zip#pyW215==0.3.7
|
||||||
|
1
tests/components/emulated_hue/__init__.py
Normal file
1
tests/components/emulated_hue/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for emulated_hue."""
|
784
tests/components/test_emulated_hue.py → tests/components/emulated_hue/test_hue_api.py
Executable file → Normal file
784
tests/components/test_emulated_hue.py → tests/components/emulated_hue/test_hue_api.py
Executable file → Normal file
@ -1,429 +1,355 @@
|
|||||||
"""The tests for the emulated Hue component."""
|
"""The tests for the emulated Hue component."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
import requests
|
from unittest.mock import patch
|
||||||
|
import requests
|
||||||
from homeassistant import bootstrap, const, core
|
|
||||||
import homeassistant.components as core_components
|
from homeassistant import bootstrap, const, core
|
||||||
from homeassistant.components import emulated_hue, http, light, script
|
import homeassistant.components as core_components
|
||||||
from homeassistant.const import STATE_ON, STATE_OFF
|
from homeassistant.components import emulated_hue, http, light, script
|
||||||
from homeassistant.components.emulated_hue import (
|
from homeassistant.const import STATE_ON, STATE_OFF
|
||||||
HUE_API_STATE_ON, HUE_API_STATE_BRI)
|
from homeassistant.components.emulated_hue.hue_api import (
|
||||||
from homeassistant.util.async import run_coroutine_threadsafe
|
HUE_API_STATE_ON, HUE_API_STATE_BRI)
|
||||||
|
from homeassistant.util.async import run_coroutine_threadsafe
|
||||||
from tests.common import get_test_instance_port, get_test_home_assistant
|
|
||||||
|
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()
|
HTTP_SERVER_PORT = get_test_instance_port()
|
||||||
|
BRIDGE_SERVER_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}
|
BRIDGE_URL_BASE = 'http://127.0.0.1:{}'.format(BRIDGE_SERVER_PORT) + '{}'
|
||||||
|
JSON_HEADERS = {const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON}
|
||||||
|
|
||||||
def setup_hass_instance(emulated_hue_config):
|
|
||||||
"""Set up the Home Assistant instance to test."""
|
class TestEmulatedHueExposedByDefault(unittest.TestCase):
|
||||||
hass = get_test_home_assistant()
|
"""Test class for emulated hue component."""
|
||||||
|
|
||||||
# We need to do this to get access to homeassistant/turn_(on,off)
|
@classmethod
|
||||||
run_coroutine_threadsafe(
|
def setUpClass(cls):
|
||||||
core_components.async_setup(hass, {core.DOMAIN: {}}), hass.loop
|
"""Setup the class."""
|
||||||
).result()
|
cls.hass = hass = get_test_home_assistant()
|
||||||
|
|
||||||
bootstrap.setup_component(
|
# We need to do this to get access to homeassistant/turn_(on,off)
|
||||||
hass, http.DOMAIN,
|
run_coroutine_threadsafe(
|
||||||
{http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}})
|
core_components.async_setup(hass, {core.DOMAIN: {}}), hass.loop
|
||||||
|
).result()
|
||||||
bootstrap.setup_component(hass, emulated_hue.DOMAIN, emulated_hue_config)
|
|
||||||
|
bootstrap.setup_component(
|
||||||
return hass
|
hass, http.DOMAIN,
|
||||||
|
{http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}})
|
||||||
|
|
||||||
def start_hass_instance(hass):
|
with patch('homeassistant.components'
|
||||||
"""Start the Home Assistant instance to test."""
|
'.emulated_hue.UPNPResponderThread'):
|
||||||
hass.start()
|
bootstrap.setup_component(hass, emulated_hue.DOMAIN, {
|
||||||
|
emulated_hue.DOMAIN: {
|
||||||
|
emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT,
|
||||||
class TestEmulatedHue(unittest.TestCase):
|
emulated_hue.CONF_EXPOSE_BY_DEFAULT: True
|
||||||
"""Test the emulated Hue component."""
|
}
|
||||||
|
})
|
||||||
hass = None
|
|
||||||
|
bootstrap.setup_component(cls.hass, light.DOMAIN, {
|
||||||
@classmethod
|
'light': [
|
||||||
def setUpClass(cls):
|
{
|
||||||
"""Setup the class."""
|
'platform': 'demo',
|
||||||
cls.hass = setup_hass_instance({
|
}
|
||||||
emulated_hue.DOMAIN: {
|
]
|
||||||
emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT
|
})
|
||||||
}})
|
|
||||||
|
bootstrap.setup_component(cls.hass, script.DOMAIN, {
|
||||||
start_hass_instance(cls.hass)
|
'script': {
|
||||||
|
'set_kitchen_light': {
|
||||||
@classmethod
|
'sequence': [
|
||||||
def tearDownClass(cls):
|
{
|
||||||
"""Stop the class."""
|
'service_template':
|
||||||
cls.hass.stop()
|
"light.turn_{{ requested_state }}",
|
||||||
|
'data_template': {
|
||||||
def test_description_xml(self):
|
'entity_id': 'light.kitchen_lights',
|
||||||
"""Test the description."""
|
'brightness': "{{ requested_level }}"
|
||||||
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'])
|
|
||||||
|
cls.hass.start()
|
||||||
# Make sure the XML is parsable
|
|
||||||
# pylint: disable=bare-except
|
# Kitchen light is explicitly excluded from being exposed
|
||||||
try:
|
kitchen_light_entity = cls.hass.states.get('light.kitchen_lights')
|
||||||
ET.fromstring(result.text)
|
attrs = dict(kitchen_light_entity.attributes)
|
||||||
except:
|
attrs[emulated_hue.ATTR_EMULATED_HUE] = False
|
||||||
self.fail('description.xml is not valid XML!')
|
cls.hass.states.set(
|
||||||
|
kitchen_light_entity.entity_id, kitchen_light_entity.state,
|
||||||
def test_create_username(self):
|
attributes=attrs)
|
||||||
"""Test the creation of an username."""
|
|
||||||
request_json = {'devicetype': 'my_device'}
|
# Expose the script
|
||||||
|
script_entity = cls.hass.states.get('script.set_kitchen_light')
|
||||||
result = requests.post(
|
attrs = dict(script_entity.attributes)
|
||||||
BRIDGE_URL_BASE.format('/api'), data=json.dumps(request_json),
|
attrs[emulated_hue.ATTR_EMULATED_HUE] = True
|
||||||
timeout=5)
|
cls.hass.states.set(
|
||||||
|
script_entity.entity_id, script_entity.state, attributes=attrs
|
||||||
self.assertEqual(result.status_code, 200)
|
)
|
||||||
self.assertTrue('application/json' in result.headers['content-type'])
|
|
||||||
|
@classmethod
|
||||||
resp_json = result.json()
|
def tearDownClass(cls):
|
||||||
success_json = resp_json[0]
|
"""Stop the class."""
|
||||||
|
cls.hass.stop()
|
||||||
self.assertTrue('success' in success_json)
|
|
||||||
self.assertTrue('username' in success_json['success'])
|
def test_discover_lights(self):
|
||||||
|
"""Test the discovery of lights."""
|
||||||
def test_valid_username_request(self):
|
result = requests.get(
|
||||||
"""Test request with a valid username."""
|
BRIDGE_URL_BASE.format('/api/username/lights'), timeout=5)
|
||||||
request_json = {'invalid_key': 'my_device'}
|
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
result = requests.post(
|
self.assertTrue('application/json' in result.headers['content-type'])
|
||||||
BRIDGE_URL_BASE.format('/api'), data=json.dumps(request_json),
|
|
||||||
timeout=5)
|
result_json = result.json()
|
||||||
|
|
||||||
self.assertEqual(result.status_code, 400)
|
# Make sure the lights we added to the config are there
|
||||||
|
self.assertTrue('light.ceiling_lights' in result_json)
|
||||||
|
self.assertTrue('light.bed_light' in result_json)
|
||||||
class TestEmulatedHueExposedByDefault(unittest.TestCase):
|
self.assertTrue('script.set_kitchen_light' in result_json)
|
||||||
"""Test class for emulated hue component."""
|
self.assertTrue('light.kitchen_lights' not in result_json)
|
||||||
|
|
||||||
@classmethod
|
def test_get_light_state(self):
|
||||||
def setUpClass(cls):
|
"""Test the getting of light state."""
|
||||||
"""Setup the class."""
|
# Turn office light on and set to 127 brightness
|
||||||
cls.hass = setup_hass_instance({
|
self.hass.services.call(
|
||||||
emulated_hue.DOMAIN: {
|
light.DOMAIN, const.SERVICE_TURN_ON,
|
||||||
emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT,
|
{
|
||||||
emulated_hue.CONF_EXPOSE_BY_DEFAULT: True
|
const.ATTR_ENTITY_ID: 'light.ceiling_lights',
|
||||||
}
|
light.ATTR_BRIGHTNESS: 127
|
||||||
})
|
},
|
||||||
|
blocking=True)
|
||||||
bootstrap.setup_component(cls.hass, light.DOMAIN, {
|
|
||||||
'light': [
|
office_json = self.perform_get_light_state('light.ceiling_lights', 200)
|
||||||
{
|
|
||||||
'platform': 'demo',
|
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(
|
||||||
bootstrap.setup_component(cls.hass, script.DOMAIN, {
|
light.DOMAIN, const.SERVICE_TURN_OFF,
|
||||||
'script': {
|
{
|
||||||
'set_kitchen_light': {
|
const.ATTR_ENTITY_ID: 'light.bed_light'
|
||||||
'sequence': [
|
},
|
||||||
{
|
blocking=True)
|
||||||
'service_template':
|
|
||||||
"light.turn_{{ requested_state }}",
|
bedroom_json = self.perform_get_light_state('light.bed_light', 200)
|
||||||
'data_template': {
|
|
||||||
'entity_id': 'light.kitchen_lights',
|
self.assertEqual(bedroom_json['state'][HUE_API_STATE_ON], False)
|
||||||
'brightness': "{{ requested_level }}"
|
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_lights')
|
||||||
}
|
kitchen_result = requests.get(
|
||||||
}
|
BRIDGE_URL_BASE.format(kitchen_url), timeout=5)
|
||||||
})
|
|
||||||
|
self.assertEqual(kitchen_result.status_code, 404)
|
||||||
start_hass_instance(cls.hass)
|
|
||||||
|
def test_put_light_state(self):
|
||||||
# Kitchen light is explicitly excluded from being exposed
|
"""Test the seeting of light states."""
|
||||||
kitchen_light_entity = cls.hass.states.get('light.kitchen_lights')
|
self.perform_put_test_on_ceiling_lights()
|
||||||
attrs = dict(kitchen_light_entity.attributes)
|
|
||||||
attrs[emulated_hue.ATTR_EMULATED_HUE] = False
|
# Turn the bedroom light on first
|
||||||
cls.hass.states.set(
|
self.hass.services.call(
|
||||||
kitchen_light_entity.entity_id, kitchen_light_entity.state,
|
light.DOMAIN, const.SERVICE_TURN_ON,
|
||||||
attributes=attrs)
|
{const.ATTR_ENTITY_ID: 'light.bed_light',
|
||||||
|
light.ATTR_BRIGHTNESS: 153},
|
||||||
# Expose the script
|
blocking=True)
|
||||||
script_entity = cls.hass.states.get('script.set_kitchen_light')
|
|
||||||
attrs = dict(script_entity.attributes)
|
bed_light = self.hass.states.get('light.bed_light')
|
||||||
attrs[emulated_hue.ATTR_EMULATED_HUE] = True
|
self.assertEqual(bed_light.state, STATE_ON)
|
||||||
cls.hass.states.set(
|
self.assertEqual(bed_light.attributes[light.ATTR_BRIGHTNESS], 153)
|
||||||
script_entity.entity_id, script_entity.state, attributes=attrs
|
|
||||||
)
|
# Go through the API to turn it off
|
||||||
|
bedroom_result = self.perform_put_light_state(
|
||||||
@classmethod
|
'light.bed_light', False)
|
||||||
def tearDownClass(cls):
|
|
||||||
"""Stop the class."""
|
bedroom_result_json = bedroom_result.json()
|
||||||
cls.hass.stop()
|
|
||||||
|
self.assertEqual(bedroom_result.status_code, 200)
|
||||||
def test_discover_lights(self):
|
self.assertTrue(
|
||||||
"""Test the discovery of lights."""
|
'application/json' in bedroom_result.headers['content-type'])
|
||||||
result = requests.get(
|
|
||||||
BRIDGE_URL_BASE.format('/api/username/lights'), timeout=5)
|
self.assertEqual(len(bedroom_result_json), 1)
|
||||||
|
|
||||||
self.assertEqual(result.status_code, 200)
|
# Check to make sure the state changed
|
||||||
self.assertTrue('application/json' in result.headers['content-type'])
|
bed_light = self.hass.states.get('light.bed_light')
|
||||||
|
self.assertEqual(bed_light.state, STATE_OFF)
|
||||||
result_json = result.json()
|
|
||||||
|
# Make sure we can't change the kitchen light state
|
||||||
# Make sure the lights we added to the config are there
|
kitchen_result = self.perform_put_light_state(
|
||||||
self.assertTrue('light.ceiling_lights' in result_json)
|
'light.kitchen_light', True)
|
||||||
self.assertTrue('light.bed_light' in result_json)
|
self.assertEqual(kitchen_result.status_code, 404)
|
||||||
self.assertTrue('script.set_kitchen_light' in result_json)
|
|
||||||
self.assertTrue('light.kitchen_lights' not in result_json)
|
def test_put_light_state_script(self):
|
||||||
|
"""Test the setting of script variables."""
|
||||||
def test_get_light_state(self):
|
# Turn the kitchen light off first
|
||||||
"""Test the getting of light state."""
|
self.hass.services.call(
|
||||||
# Turn office light on and set to 127 brightness
|
light.DOMAIN, const.SERVICE_TURN_OFF,
|
||||||
self.hass.services.call(
|
{const.ATTR_ENTITY_ID: 'light.kitchen_lights'},
|
||||||
light.DOMAIN, const.SERVICE_TURN_ON,
|
blocking=True)
|
||||||
{
|
|
||||||
const.ATTR_ENTITY_ID: 'light.ceiling_lights',
|
# Emulated hue converts 0-100% to 0-255.
|
||||||
light.ATTR_BRIGHTNESS: 127
|
level = 23
|
||||||
},
|
brightness = round(level * 255 / 100)
|
||||||
blocking=True)
|
|
||||||
|
script_result = self.perform_put_light_state(
|
||||||
office_json = self.perform_get_light_state('light.ceiling_lights', 200)
|
'script.set_kitchen_light', True, brightness)
|
||||||
|
|
||||||
self.assertEqual(office_json['state'][HUE_API_STATE_ON], True)
|
script_result_json = script_result.json()
|
||||||
self.assertEqual(office_json['state'][HUE_API_STATE_BRI], 127)
|
|
||||||
|
self.assertEqual(script_result.status_code, 200)
|
||||||
# Turn bedroom light off
|
self.assertEqual(len(script_result_json), 2)
|
||||||
self.hass.services.call(
|
|
||||||
light.DOMAIN, const.SERVICE_TURN_OFF,
|
# Wait until script is complete before continuing
|
||||||
{
|
self.hass.block_till_done()
|
||||||
const.ATTR_ENTITY_ID: 'light.bed_light'
|
|
||||||
},
|
kitchen_light = self.hass.states.get('light.kitchen_lights')
|
||||||
blocking=True)
|
self.assertEqual(kitchen_light.state, 'on')
|
||||||
|
self.assertEqual(
|
||||||
bedroom_json = self.perform_get_light_state('light.bed_light', 200)
|
kitchen_light.attributes[light.ATTR_BRIGHTNESS],
|
||||||
|
level)
|
||||||
self.assertEqual(bedroom_json['state'][HUE_API_STATE_ON], False)
|
|
||||||
self.assertEqual(bedroom_json['state'][HUE_API_STATE_BRI], 0)
|
# pylint: disable=invalid-name
|
||||||
|
def test_put_with_form_urlencoded_content_type(self):
|
||||||
# Make sure kitchen light isn't accessible
|
"""Test the form with urlencoded content."""
|
||||||
kitchen_url = '/api/username/lights/{}'.format('light.kitchen_lights')
|
# Needed for Alexa
|
||||||
kitchen_result = requests.get(
|
self.perform_put_test_on_ceiling_lights(
|
||||||
BRIDGE_URL_BASE.format(kitchen_url), timeout=5)
|
'application/x-www-form-urlencoded')
|
||||||
|
|
||||||
self.assertEqual(kitchen_result.status_code, 404)
|
# Make sure we fail gracefully when we can't parse the data
|
||||||
|
data = {'key1': 'value1', 'key2': 'value2'}
|
||||||
def test_put_light_state(self):
|
result = requests.put(
|
||||||
"""Test the seeting of light states."""
|
BRIDGE_URL_BASE.format(
|
||||||
self.perform_put_test_on_ceiling_lights()
|
'/api/username/lights/{}/state'.format(
|
||||||
|
'light.ceiling_lights')), data=data)
|
||||||
# Turn the bedroom light on first
|
|
||||||
self.hass.services.call(
|
self.assertEqual(result.status_code, 400)
|
||||||
light.DOMAIN, const.SERVICE_TURN_ON,
|
|
||||||
{const.ATTR_ENTITY_ID: 'light.bed_light',
|
def test_entity_not_found(self):
|
||||||
light.ATTR_BRIGHTNESS: 153},
|
"""Test for entity which are not found."""
|
||||||
blocking=True)
|
result = requests.get(
|
||||||
|
BRIDGE_URL_BASE.format(
|
||||||
bed_light = self.hass.states.get('light.bed_light')
|
'/api/username/lights/{}'.format("not.existant_entity")),
|
||||||
self.assertEqual(bed_light.state, STATE_ON)
|
timeout=5)
|
||||||
self.assertEqual(bed_light.attributes[light.ATTR_BRIGHTNESS], 153)
|
|
||||||
|
self.assertEqual(result.status_code, 404)
|
||||||
# Go through the API to turn it off
|
|
||||||
bedroom_result = self.perform_put_light_state(
|
result = requests.put(
|
||||||
'light.bed_light', False)
|
BRIDGE_URL_BASE.format(
|
||||||
|
'/api/username/lights/{}/state'.format("non.existant_entity")),
|
||||||
bedroom_result_json = bedroom_result.json()
|
timeout=5)
|
||||||
|
|
||||||
self.assertEqual(bedroom_result.status_code, 200)
|
self.assertEqual(result.status_code, 404)
|
||||||
self.assertTrue(
|
|
||||||
'application/json' in bedroom_result.headers['content-type'])
|
def test_allowed_methods(self):
|
||||||
|
"""Test the allowed methods."""
|
||||||
self.assertEqual(len(bedroom_result_json), 1)
|
result = requests.get(
|
||||||
|
BRIDGE_URL_BASE.format(
|
||||||
# Check to make sure the state changed
|
'/api/username/lights/{}/state'.format(
|
||||||
bed_light = self.hass.states.get('light.bed_light')
|
"light.ceiling_lights")))
|
||||||
self.assertEqual(bed_light.state, STATE_OFF)
|
|
||||||
|
self.assertEqual(result.status_code, 405)
|
||||||
# Make sure we can't change the kitchen light state
|
|
||||||
kitchen_result = self.perform_put_light_state(
|
result = requests.put(
|
||||||
'light.kitchen_light', True)
|
BRIDGE_URL_BASE.format(
|
||||||
self.assertEqual(kitchen_result.status_code, 404)
|
'/api/username/lights/{}'.format("light.ceiling_lights")),
|
||||||
|
data={'key1': 'value1'})
|
||||||
def test_put_light_state_script(self):
|
|
||||||
"""Test the setting of script variables."""
|
self.assertEqual(result.status_code, 405)
|
||||||
# Turn the kitchen light off first
|
|
||||||
self.hass.services.call(
|
result = requests.put(
|
||||||
light.DOMAIN, const.SERVICE_TURN_OFF,
|
BRIDGE_URL_BASE.format('/api/username/lights'),
|
||||||
{const.ATTR_ENTITY_ID: 'light.kitchen_lights'},
|
data={'key1': 'value1'})
|
||||||
blocking=True)
|
|
||||||
|
self.assertEqual(result.status_code, 405)
|
||||||
# Emulated hue converts 0-100% to 0-255.
|
|
||||||
level = 23
|
def test_proper_put_state_request(self):
|
||||||
brightness = round(level * 255 / 100)
|
"""Test the request to set the state."""
|
||||||
|
# Test proper on value parsing
|
||||||
script_result = self.perform_put_light_state(
|
result = requests.put(
|
||||||
'script.set_kitchen_light', True, brightness)
|
BRIDGE_URL_BASE.format(
|
||||||
|
'/api/username/lights/{}/state'.format(
|
||||||
script_result_json = script_result.json()
|
'light.ceiling_lights')),
|
||||||
|
data=json.dumps({HUE_API_STATE_ON: 1234}))
|
||||||
self.assertEqual(script_result.status_code, 200)
|
|
||||||
self.assertEqual(len(script_result_json), 2)
|
self.assertEqual(result.status_code, 400)
|
||||||
|
|
||||||
# Wait until script is complete before continuing
|
# Test proper brightness value parsing
|
||||||
self.hass.block_till_done()
|
result = requests.put(
|
||||||
|
BRIDGE_URL_BASE.format(
|
||||||
kitchen_light = self.hass.states.get('light.kitchen_lights')
|
'/api/username/lights/{}/state'.format(
|
||||||
self.assertEqual(kitchen_light.state, 'on')
|
'light.ceiling_lights')), data=json.dumps({
|
||||||
self.assertEqual(
|
HUE_API_STATE_ON: True,
|
||||||
kitchen_light.attributes[light.ATTR_BRIGHTNESS],
|
HUE_API_STATE_BRI: 'Hello world!'
|
||||||
level)
|
}))
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
self.assertEqual(result.status_code, 400)
|
||||||
def test_put_with_form_urlencoded_content_type(self):
|
|
||||||
"""Test the form with urlencoded content."""
|
# pylint: disable=invalid-name
|
||||||
# Needed for Alexa
|
def perform_put_test_on_ceiling_lights(self,
|
||||||
self.perform_put_test_on_ceiling_lights(
|
content_type='application/json'):
|
||||||
'application/x-www-form-urlencoded')
|
"""Test the setting of a light."""
|
||||||
|
# Turn the office light off first
|
||||||
# Make sure we fail gracefully when we can't parse the data
|
self.hass.services.call(
|
||||||
data = {'key1': 'value1', 'key2': 'value2'}
|
light.DOMAIN, const.SERVICE_TURN_OFF,
|
||||||
result = requests.put(
|
{const.ATTR_ENTITY_ID: 'light.ceiling_lights'},
|
||||||
BRIDGE_URL_BASE.format(
|
blocking=True)
|
||||||
'/api/username/lights/{}/state'.format(
|
|
||||||
'light.ceiling_lights')), data=data)
|
ceiling_lights = self.hass.states.get('light.ceiling_lights')
|
||||||
|
self.assertEqual(ceiling_lights.state, STATE_OFF)
|
||||||
self.assertEqual(result.status_code, 400)
|
|
||||||
|
# Go through the API to turn it on
|
||||||
def test_entity_not_found(self):
|
office_result = self.perform_put_light_state(
|
||||||
"""Test for entity which are not found."""
|
'light.ceiling_lights', True, 56, content_type)
|
||||||
result = requests.get(
|
|
||||||
BRIDGE_URL_BASE.format(
|
office_result_json = office_result.json()
|
||||||
'/api/username/lights/{}'.format("not.existant_entity")),
|
|
||||||
timeout=5)
|
self.assertEqual(office_result.status_code, 200)
|
||||||
|
self.assertTrue(
|
||||||
self.assertEqual(result.status_code, 404)
|
'application/json' in office_result.headers['content-type'])
|
||||||
|
|
||||||
result = requests.put(
|
self.assertEqual(len(office_result_json), 2)
|
||||||
BRIDGE_URL_BASE.format(
|
|
||||||
'/api/username/lights/{}/state'.format("non.existant_entity")),
|
# Check to make sure the state changed
|
||||||
timeout=5)
|
ceiling_lights = self.hass.states.get('light.ceiling_lights')
|
||||||
|
self.assertEqual(ceiling_lights.state, STATE_ON)
|
||||||
self.assertEqual(result.status_code, 404)
|
self.assertEqual(ceiling_lights.attributes[light.ATTR_BRIGHTNESS], 56)
|
||||||
|
|
||||||
def test_allowed_methods(self):
|
def perform_get_light_state(self, entity_id, expected_status):
|
||||||
"""Test the allowed methods."""
|
"""Test the gettting of a light state."""
|
||||||
result = requests.get(
|
result = requests.get(
|
||||||
BRIDGE_URL_BASE.format(
|
BRIDGE_URL_BASE.format(
|
||||||
'/api/username/lights/{}/state'.format(
|
'/api/username/lights/{}'.format(entity_id)), timeout=5)
|
||||||
"light.ceiling_lights")))
|
|
||||||
|
self.assertEqual(result.status_code, expected_status)
|
||||||
self.assertEqual(result.status_code, 405)
|
|
||||||
|
if expected_status == 200:
|
||||||
result = requests.put(
|
self.assertTrue(
|
||||||
BRIDGE_URL_BASE.format(
|
'application/json' in result.headers['content-type'])
|
||||||
'/api/username/lights/{}'.format("light.ceiling_lights")),
|
|
||||||
data={'key1': 'value1'})
|
return result.json()
|
||||||
|
|
||||||
self.assertEqual(result.status_code, 405)
|
return None
|
||||||
|
|
||||||
result = requests.put(
|
# pylint: disable=no-self-use
|
||||||
BRIDGE_URL_BASE.format('/api/username/lights'),
|
def perform_put_light_state(self, entity_id, is_on, brightness=None,
|
||||||
data={'key1': 'value1'})
|
content_type='application/json'):
|
||||||
|
"""Test the setting of a light state."""
|
||||||
self.assertEqual(result.status_code, 405)
|
url = BRIDGE_URL_BASE.format(
|
||||||
|
'/api/username/lights/{}/state'.format(entity_id))
|
||||||
def test_proper_put_state_request(self):
|
|
||||||
"""Test the request to set the state."""
|
req_headers = {'Content-Type': content_type}
|
||||||
# Test proper on value parsing
|
|
||||||
result = requests.put(
|
data = {HUE_API_STATE_ON: is_on}
|
||||||
BRIDGE_URL_BASE.format(
|
|
||||||
'/api/username/lights/{}/state'.format(
|
if brightness is not None:
|
||||||
'light.ceiling_lights')),
|
data[HUE_API_STATE_BRI] = brightness
|
||||||
data=json.dumps({HUE_API_STATE_ON: 1234}))
|
|
||||||
|
result = requests.put(
|
||||||
self.assertEqual(result.status_code, 400)
|
url, data=json.dumps(data), timeout=5, headers=req_headers)
|
||||||
|
|
||||||
# Test proper brightness value parsing
|
return result
|
||||||
result = requests.put(
|
|
||||||
BRIDGE_URL_BASE.format(
|
|
||||||
'/api/username/lights/{}/state'.format(
|
|
||||||
'light.ceiling_lights')), data=json.dumps({
|
|
||||||
HUE_API_STATE_ON: True,
|
|
||||||
HUE_API_STATE_BRI: 'Hello world!'
|
|
||||||
}))
|
|
||||||
|
|
||||||
self.assertEqual(result.status_code, 400)
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
|
||||||
def perform_put_test_on_ceiling_lights(self,
|
|
||||||
content_type='application/json'):
|
|
||||||
"""Test the setting of a light."""
|
|
||||||
# Turn the office light off first
|
|
||||||
self.hass.services.call(
|
|
||||||
light.DOMAIN, const.SERVICE_TURN_OFF,
|
|
||||||
{const.ATTR_ENTITY_ID: 'light.ceiling_lights'},
|
|
||||||
blocking=True)
|
|
||||||
|
|
||||||
ceiling_lights = self.hass.states.get('light.ceiling_lights')
|
|
||||||
self.assertEqual(ceiling_lights.state, STATE_OFF)
|
|
||||||
|
|
||||||
# Go through the API to turn it on
|
|
||||||
office_result = self.perform_put_light_state(
|
|
||||||
'light.ceiling_lights', 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
|
|
||||||
ceiling_lights = self.hass.states.get('light.ceiling_lights')
|
|
||||||
self.assertEqual(ceiling_lights.state, STATE_ON)
|
|
||||||
self.assertEqual(ceiling_lights.attributes[light.ATTR_BRIGHTNESS], 56)
|
|
||||||
|
|
||||||
def perform_get_light_state(self, entity_id, expected_status):
|
|
||||||
"""Test the gettting of a light state."""
|
|
||||||
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
|
|
||||||
|
|
||||||
# pylint: disable=no-self-use
|
|
||||||
def perform_put_light_state(self, entity_id, is_on, brightness=None,
|
|
||||||
content_type='application/json'):
|
|
||||||
"""Test the setting of a light state."""
|
|
||||||
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
|
|
55
tests/components/emulated_hue/test_init.py
Executable file
55
tests/components/emulated_hue/test_init.py
Executable file
@ -0,0 +1,55 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from homeassistant.components.emulated_hue import Config, _LOGGER
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_google_home_entity_id_to_number():
|
||||||
|
"""Test config adheres to the type."""
|
||||||
|
conf = Config({
|
||||||
|
'type': 'google_home'
|
||||||
|
})
|
||||||
|
|
||||||
|
number = conf.entity_id_to_number('light.test')
|
||||||
|
assert number == '1'
|
||||||
|
|
||||||
|
number = conf.entity_id_to_number('light.test')
|
||||||
|
assert number == '1'
|
||||||
|
|
||||||
|
number = conf.entity_id_to_number('light.test2')
|
||||||
|
assert number == '2'
|
||||||
|
|
||||||
|
entity_id = conf.number_to_entity_id('1')
|
||||||
|
assert entity_id == 'light.test'
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_alexa_entity_id_to_number():
|
||||||
|
"""Test config adheres to the type."""
|
||||||
|
conf = Config({
|
||||||
|
'type': 'alexa'
|
||||||
|
})
|
||||||
|
|
||||||
|
number = conf.entity_id_to_number('light.test')
|
||||||
|
assert number == 'light.test'
|
||||||
|
|
||||||
|
number = conf.entity_id_to_number('light.test')
|
||||||
|
assert number == 'light.test'
|
||||||
|
|
||||||
|
number = conf.entity_id_to_number('light.test2')
|
||||||
|
assert number == 'light.test2'
|
||||||
|
|
||||||
|
entity_id = conf.number_to_entity_id('light.test')
|
||||||
|
assert entity_id == 'light.test'
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_config_google_home_listen_port():
|
||||||
|
"""Test we warn when non-default port is used for Google Home."""
|
||||||
|
with patch.object(_LOGGER, 'warning') as mock_warn:
|
||||||
|
Config({
|
||||||
|
'type': 'google_home',
|
||||||
|
'host_ip': '123.123.123.123',
|
||||||
|
'listen_port': 8300
|
||||||
|
})
|
||||||
|
|
||||||
|
assert mock_warn.called
|
||||||
|
assert mock_warn.mock_calls[0][1][0] == \
|
||||||
|
"When targetting Google Home, listening port has to be port 80"
|
120
tests/components/emulated_hue/test_upnp.py
Normal file
120
tests/components/emulated_hue/test_upnp.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""The tests for the emulated Hue component."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from homeassistant import bootstrap, const, core
|
||||||
|
import homeassistant.components as core_components
|
||||||
|
from homeassistant.components import emulated_hue, http
|
||||||
|
from homeassistant.util.async import run_coroutine_threadsafe
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
BRIDGE_URL_BASE = 'http://127.0.0.1:{}'.format(BRIDGE_SERVER_PORT) + '{}'
|
||||||
|
JSON_HEADERS = {const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON}
|
||||||
|
|
||||||
|
|
||||||
|
def setup_hass_instance(emulated_hue_config):
|
||||||
|
"""Set up the Home Assistant instance to test."""
|
||||||
|
hass = get_test_home_assistant()
|
||||||
|
|
||||||
|
# We need to do this to get access to homeassistant/turn_(on,off)
|
||||||
|
run_coroutine_threadsafe(
|
||||||
|
core_components.async_setup(hass, {core.DOMAIN: {}}), hass.loop
|
||||||
|
).result()
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Start the Home Assistant instance to test."""
|
||||||
|
hass.start()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmulatedHue(unittest.TestCase):
|
||||||
|
"""Test the emulated Hue component."""
|
||||||
|
|
||||||
|
hass = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
"""Setup the class."""
|
||||||
|
cls.hass = hass = get_test_home_assistant()
|
||||||
|
|
||||||
|
# We need to do this to get access to homeassistant/turn_(on,off)
|
||||||
|
run_coroutine_threadsafe(
|
||||||
|
core_components.async_setup(hass, {core.DOMAIN: {}}), hass.loop
|
||||||
|
).result()
|
||||||
|
|
||||||
|
bootstrap.setup_component(
|
||||||
|
hass, http.DOMAIN,
|
||||||
|
{http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}})
|
||||||
|
|
||||||
|
with patch('homeassistant.components'
|
||||||
|
'.emulated_hue.UPNPResponderThread'):
|
||||||
|
bootstrap.setup_component(hass, emulated_hue.DOMAIN, {
|
||||||
|
emulated_hue.DOMAIN: {
|
||||||
|
emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT
|
||||||
|
}})
|
||||||
|
|
||||||
|
cls.hass.start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
"""Stop the class."""
|
||||||
|
cls.hass.stop()
|
||||||
|
|
||||||
|
def test_description_xml(self):
|
||||||
|
"""Test the description."""
|
||||||
|
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
|
||||||
|
# pylint: disable=bare-except
|
||||||
|
try:
|
||||||
|
ET.fromstring(result.text)
|
||||||
|
except:
|
||||||
|
self.fail('description.xml is not valid XML!')
|
||||||
|
|
||||||
|
def test_create_username(self):
|
||||||
|
"""Test the creation of an username."""
|
||||||
|
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):
|
||||||
|
"""Test request with a valid username."""
|
||||||
|
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)
|
@ -44,6 +44,10 @@ def setUpModule():
|
|||||||
|
|
||||||
bootstrap.setup_component(hass, 'api')
|
bootstrap.setup_component(hass, 'api')
|
||||||
|
|
||||||
|
# Registering static path as it caused CORS to blow up
|
||||||
|
hass.http.register_static_path(
|
||||||
|
'/custom_components', hass.config.path('custom_components'))
|
||||||
|
|
||||||
hass.start()
|
hass.start()
|
||||||
|
|
||||||
|
|
||||||
@ -53,11 +57,12 @@ def tearDownModule():
|
|||||||
hass.stop()
|
hass.stop()
|
||||||
|
|
||||||
|
|
||||||
class TestHttp:
|
class TestCors:
|
||||||
"""Test HTTP component."""
|
"""Test HTTP component."""
|
||||||
|
|
||||||
def test_cors_allowed_with_password_in_url(self):
|
def test_cors_allowed_with_password_in_url(self):
|
||||||
"""Test cross origin resource sharing with password in url."""
|
"""Test cross origin resource sharing with password in url."""
|
||||||
|
|
||||||
req = requests.get(_url(const.URL_API),
|
req = requests.get(_url(const.URL_API),
|
||||||
params={'api_password': API_PASSWORD},
|
params={'api_password': API_PASSWORD},
|
||||||
headers={const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL})
|
headers={const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL})
|
||||||
|
Loading…
x
Reference in New Issue
Block a user