mirror of
https://github.com/home-assistant/core.git
synced 2025-04-19 14:57:52 +00:00
async HTTP component (#3914)
* Migrate WSGI to asyncio * Rename wsgi -> http * Python 3.4 compat * Move linting to Python 3.4 * lint * Lint * Fix Python 3.4 mock_open + binary data * Surpress logging aiohttp.access * Spelling * Sending files is a coroutine * More callback annotations and naming fixes * Fix ios
This commit is contained in:
parent
9aa88819a5
commit
519d9f2fd0
@ -2,11 +2,11 @@ sudo: false
|
||||
matrix:
|
||||
fast_finish: true
|
||||
include:
|
||||
- python: "3.4"
|
||||
- python: "3.4.2"
|
||||
env: TOXENV=py34
|
||||
- python: "3.4"
|
||||
- python: "3.4.2"
|
||||
env: TOXENV=requirements
|
||||
- python: "3.5"
|
||||
- python: "3.4.2"
|
||||
env: TOXENV=lint
|
||||
- python: "3.5"
|
||||
env: TOXENV=typing
|
||||
|
@ -359,6 +359,7 @@ def enable_logging(hass: core.HomeAssistant, verbose: bool=False,
|
||||
# suppress overly verbose logs from libraries that aren't helpful
|
||||
logging.getLogger("requests").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
||||
|
||||
try:
|
||||
from colorlog import ColoredFormatter
|
||||
|
@ -4,6 +4,7 @@ Support for Alexa skill service end point.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/alexa/
|
||||
"""
|
||||
import asyncio
|
||||
import copy
|
||||
import enum
|
||||
import logging
|
||||
@ -12,6 +13,7 @@ from datetime import datetime
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import HTTP_BAD_REQUEST
|
||||
from homeassistant.helpers import template, script, config_validation as cv
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
@ -20,7 +22,7 @@ import homeassistant.util.dt as dt_util
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
INTENTS_API_ENDPOINT = '/api/alexa'
|
||||
FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/<briefing_id>'
|
||||
FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}'
|
||||
|
||||
CONF_ACTION = 'action'
|
||||
CONF_CARD = 'card'
|
||||
@ -102,8 +104,8 @@ def setup(hass, config):
|
||||
intents = config[DOMAIN].get(CONF_INTENTS, {})
|
||||
flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {})
|
||||
|
||||
hass.wsgi.register_view(AlexaIntentsView(hass, intents))
|
||||
hass.wsgi.register_view(AlexaFlashBriefingView(hass, flash_briefings))
|
||||
hass.http.register_view(AlexaIntentsView(hass, intents))
|
||||
hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings))
|
||||
|
||||
return True
|
||||
|
||||
@ -128,9 +130,10 @@ class AlexaIntentsView(HomeAssistantView):
|
||||
|
||||
self.intents = intents
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Handle Alexa."""
|
||||
data = request.json
|
||||
data = yield from request.json()
|
||||
|
||||
_LOGGER.debug('Received Alexa request: %s', data)
|
||||
|
||||
@ -176,7 +179,7 @@ class AlexaIntentsView(HomeAssistantView):
|
||||
action = config.get(CONF_ACTION)
|
||||
|
||||
if action is not None:
|
||||
action.run(response.variables)
|
||||
yield from action.async_run(response.variables)
|
||||
|
||||
# pylint: disable=unsubscriptable-object
|
||||
if speech is not None:
|
||||
@ -218,8 +221,8 @@ class AlexaResponse(object):
|
||||
self.card = card
|
||||
return
|
||||
|
||||
card["title"] = title.render(self.variables)
|
||||
card["content"] = content.render(self.variables)
|
||||
card["title"] = title.async_render(self.variables)
|
||||
card["content"] = content.async_render(self.variables)
|
||||
self.card = card
|
||||
|
||||
def add_speech(self, speech_type, text):
|
||||
@ -229,7 +232,7 @@ class AlexaResponse(object):
|
||||
key = 'ssml' if speech_type == SpeechType.ssml else 'text'
|
||||
|
||||
if isinstance(text, template.Template):
|
||||
text = text.render(self.variables)
|
||||
text = text.async_render(self.variables)
|
||||
|
||||
self.speech = {
|
||||
'type': speech_type.value,
|
||||
@ -244,7 +247,7 @@ class AlexaResponse(object):
|
||||
|
||||
self.reprompt = {
|
||||
'type': speech_type.value,
|
||||
key: text.render(self.variables)
|
||||
key: text.async_render(self.variables)
|
||||
}
|
||||
|
||||
def as_dict(self):
|
||||
@ -284,6 +287,7 @@ class AlexaFlashBriefingView(HomeAssistantView):
|
||||
template.attach(hass, self.flash_briefings)
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
@callback
|
||||
def get(self, request, briefing_id):
|
||||
"""Handle Alexa Flash Briefing request."""
|
||||
_LOGGER.debug('Received Alexa flash briefing request for: %s',
|
||||
@ -292,7 +296,7 @@ class AlexaFlashBriefingView(HomeAssistantView):
|
||||
if self.flash_briefings.get(briefing_id) is None:
|
||||
err = 'No configured Alexa flash briefing was found for: %s'
|
||||
_LOGGER.error(err, briefing_id)
|
||||
return self.Response(status=404)
|
||||
return b'', 404
|
||||
|
||||
briefing = []
|
||||
|
||||
@ -300,13 +304,13 @@ class AlexaFlashBriefingView(HomeAssistantView):
|
||||
output = {}
|
||||
if item.get(CONF_TITLE) is not None:
|
||||
if isinstance(item.get(CONF_TITLE), template.Template):
|
||||
output[ATTR_TITLE_TEXT] = item[CONF_TITLE].render()
|
||||
output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render()
|
||||
else:
|
||||
output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE)
|
||||
|
||||
if item.get(CONF_TEXT) is not None:
|
||||
if isinstance(item.get(CONF_TEXT), template.Template):
|
||||
output[ATTR_MAIN_TEXT] = item[CONF_TEXT].render()
|
||||
output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render()
|
||||
else:
|
||||
output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT)
|
||||
|
||||
@ -315,7 +319,7 @@ class AlexaFlashBriefingView(HomeAssistantView):
|
||||
|
||||
if item.get(CONF_AUDIO) is not None:
|
||||
if isinstance(item.get(CONF_AUDIO), template.Template):
|
||||
output[ATTR_STREAM_URL] = item[CONF_AUDIO].render()
|
||||
output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render()
|
||||
else:
|
||||
output[ATTR_STREAM_URL] = item.get(CONF_AUDIO)
|
||||
|
||||
@ -323,7 +327,7 @@ class AlexaFlashBriefingView(HomeAssistantView):
|
||||
if isinstance(item.get(CONF_DISPLAY_URL),
|
||||
template.Template):
|
||||
output[ATTR_REDIRECTION_URL] = \
|
||||
item[CONF_DISPLAY_URL].render()
|
||||
item[CONF_DISPLAY_URL].async_render()
|
||||
else:
|
||||
output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL)
|
||||
|
||||
|
@ -7,7 +7,9 @@ https://home-assistant.io/developers/api/
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
|
||||
from aiohttp import web
|
||||
import async_timeout
|
||||
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.remote as rem
|
||||
@ -21,7 +23,7 @@ from homeassistant.const import (
|
||||
URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE,
|
||||
__version__)
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers.state import TrackStates
|
||||
from homeassistant.helpers.state import AsyncTrackStates
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
@ -36,20 +38,20 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def setup(hass, config):
|
||||
"""Register the API with the HTTP interface."""
|
||||
hass.wsgi.register_view(APIStatusView)
|
||||
hass.wsgi.register_view(APIEventStream)
|
||||
hass.wsgi.register_view(APIConfigView)
|
||||
hass.wsgi.register_view(APIDiscoveryView)
|
||||
hass.wsgi.register_view(APIStatesView)
|
||||
hass.wsgi.register_view(APIEntityStateView)
|
||||
hass.wsgi.register_view(APIEventListenersView)
|
||||
hass.wsgi.register_view(APIEventView)
|
||||
hass.wsgi.register_view(APIServicesView)
|
||||
hass.wsgi.register_view(APIDomainServicesView)
|
||||
hass.wsgi.register_view(APIEventForwardingView)
|
||||
hass.wsgi.register_view(APIComponentsView)
|
||||
hass.wsgi.register_view(APIErrorLogView)
|
||||
hass.wsgi.register_view(APITemplateView)
|
||||
hass.http.register_view(APIStatusView)
|
||||
hass.http.register_view(APIEventStream)
|
||||
hass.http.register_view(APIConfigView)
|
||||
hass.http.register_view(APIDiscoveryView)
|
||||
hass.http.register_view(APIStatesView)
|
||||
hass.http.register_view(APIEntityStateView)
|
||||
hass.http.register_view(APIEventListenersView)
|
||||
hass.http.register_view(APIEventView)
|
||||
hass.http.register_view(APIServicesView)
|
||||
hass.http.register_view(APIDomainServicesView)
|
||||
hass.http.register_view(APIEventForwardingView)
|
||||
hass.http.register_view(APIComponentsView)
|
||||
hass.http.register_view(APIErrorLogView)
|
||||
hass.http.register_view(APITemplateView)
|
||||
|
||||
return True
|
||||
|
||||
@ -60,6 +62,7 @@ class APIStatusView(HomeAssistantView):
|
||||
url = URL_API
|
||||
name = "api:status"
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Retrieve if API is running."""
|
||||
return self.json_message('API running.')
|
||||
@ -71,12 +74,13 @@ class APIEventStream(HomeAssistantView):
|
||||
url = URL_API_STREAM
|
||||
name = "api:stream"
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request):
|
||||
"""Provide a streaming interface for the event bus."""
|
||||
stop_obj = object()
|
||||
to_write = queue.Queue()
|
||||
to_write = asyncio.Queue(loop=self.hass.loop)
|
||||
|
||||
restrict = request.args.get('restrict')
|
||||
restrict = request.GET.get('restrict')
|
||||
if restrict:
|
||||
restrict = restrict.split(',') + [EVENT_HOMEASSISTANT_STOP]
|
||||
|
||||
@ -96,38 +100,40 @@ class APIEventStream(HomeAssistantView):
|
||||
else:
|
||||
data = json.dumps(event, cls=rem.JSONEncoder)
|
||||
|
||||
to_write.put(data)
|
||||
yield from to_write.put(data)
|
||||
|
||||
def stream():
|
||||
"""Stream events to response."""
|
||||
unsub_stream = self.hass.bus.listen(MATCH_ALL, forward_events)
|
||||
response = web.StreamResponse()
|
||||
response.content_type = 'text/event-stream'
|
||||
yield from response.prepare(request)
|
||||
|
||||
try:
|
||||
_LOGGER.debug('STREAM %s ATTACHED', id(stop_obj))
|
||||
unsub_stream = self.hass.bus.async_listen(MATCH_ALL, forward_events)
|
||||
|
||||
# Fire off one message so browsers fire open event right away
|
||||
to_write.put(STREAM_PING_PAYLOAD)
|
||||
try:
|
||||
_LOGGER.debug('STREAM %s ATTACHED', id(stop_obj))
|
||||
|
||||
while True:
|
||||
try:
|
||||
payload = to_write.get(timeout=STREAM_PING_INTERVAL)
|
||||
# Fire off one message so browsers fire open event right away
|
||||
yield from to_write.put(STREAM_PING_PAYLOAD)
|
||||
|
||||
if payload is stop_obj:
|
||||
break
|
||||
while True:
|
||||
try:
|
||||
with async_timeout.timeout(STREAM_PING_INTERVAL,
|
||||
loop=self.hass.loop):
|
||||
payload = yield from to_write.get()
|
||||
|
||||
msg = "data: {}\n\n".format(payload)
|
||||
_LOGGER.debug('STREAM %s WRITING %s', id(stop_obj),
|
||||
msg.strip())
|
||||
yield msg.encode("UTF-8")
|
||||
except queue.Empty:
|
||||
to_write.put(STREAM_PING_PAYLOAD)
|
||||
except GeneratorExit:
|
||||
if payload is stop_obj:
|
||||
break
|
||||
finally:
|
||||
_LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj))
|
||||
unsub_stream()
|
||||
|
||||
return self.Response(stream(), mimetype='text/event-stream')
|
||||
msg = "data: {}\n\n".format(payload)
|
||||
_LOGGER.debug('STREAM %s WRITING %s', id(stop_obj),
|
||||
msg.strip())
|
||||
response.write(msg.encode("UTF-8"))
|
||||
yield from response.drain()
|
||||
except asyncio.TimeoutError:
|
||||
yield from to_write.put(STREAM_PING_PAYLOAD)
|
||||
|
||||
finally:
|
||||
_LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj))
|
||||
unsub_stream()
|
||||
|
||||
|
||||
class APIConfigView(HomeAssistantView):
|
||||
@ -136,6 +142,7 @@ class APIConfigView(HomeAssistantView):
|
||||
url = URL_API_CONFIG
|
||||
name = "api:config"
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get current configuration."""
|
||||
return self.json(self.hass.config.as_dict())
|
||||
@ -148,6 +155,7 @@ class APIDiscoveryView(HomeAssistantView):
|
||||
url = URL_API_DISCOVERY_INFO
|
||||
name = "api:discovery"
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get discovery info."""
|
||||
needs_auth = self.hass.config.api.api_password is not None
|
||||
@ -165,17 +173,19 @@ class APIStatesView(HomeAssistantView):
|
||||
url = URL_API_STATES
|
||||
name = "api:states"
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get current states."""
|
||||
return self.json(self.hass.states.all())
|
||||
return self.json(self.hass.states.async_all())
|
||||
|
||||
|
||||
class APIEntityStateView(HomeAssistantView):
|
||||
"""View to handle EntityState requests."""
|
||||
|
||||
url = "/api/states/<entity(exist=False):entity_id>"
|
||||
url = "/api/states/{entity_id}"
|
||||
name = "api:entity-state"
|
||||
|
||||
@ha.callback
|
||||
def get(self, request, entity_id):
|
||||
"""Retrieve state of entity."""
|
||||
state = self.hass.states.get(entity_id)
|
||||
@ -184,34 +194,41 @@ class APIEntityStateView(HomeAssistantView):
|
||||
else:
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request, entity_id):
|
||||
"""Update state of entity."""
|
||||
try:
|
||||
new_state = request.json['state']
|
||||
except KeyError:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON specified',
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
new_state = data.get('state')
|
||||
|
||||
if not new_state:
|
||||
return self.json_message('No state specified', HTTP_BAD_REQUEST)
|
||||
|
||||
attributes = request.json.get('attributes')
|
||||
force_update = request.json.get('force_update', False)
|
||||
attributes = data.get('attributes')
|
||||
force_update = data.get('force_update', False)
|
||||
|
||||
is_new_state = self.hass.states.get(entity_id) is None
|
||||
|
||||
# Write state
|
||||
self.hass.states.set(entity_id, new_state, attributes, force_update)
|
||||
self.hass.states.async_set(entity_id, new_state, attributes,
|
||||
force_update)
|
||||
|
||||
# Read the state back for our response
|
||||
resp = self.json(self.hass.states.get(entity_id))
|
||||
|
||||
if is_new_state:
|
||||
resp.status_code = HTTP_CREATED
|
||||
status_code = HTTP_CREATED if is_new_state else 200
|
||||
resp = self.json(self.hass.states.get(entity_id), status_code)
|
||||
|
||||
resp.headers.add('Location', URL_API_STATES_ENTITY.format(entity_id))
|
||||
|
||||
return resp
|
||||
|
||||
@ha.callback
|
||||
def delete(self, request, entity_id):
|
||||
"""Remove entity."""
|
||||
if self.hass.states.remove(entity_id):
|
||||
if self.hass.states.async_remove(entity_id):
|
||||
return self.json_message('Entity removed')
|
||||
else:
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
@ -223,20 +240,23 @@ class APIEventListenersView(HomeAssistantView):
|
||||
url = URL_API_EVENTS
|
||||
name = "api:event-listeners"
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get event listeners."""
|
||||
return self.json(events_json(self.hass))
|
||||
return self.json(async_events_json(self.hass))
|
||||
|
||||
|
||||
class APIEventView(HomeAssistantView):
|
||||
"""View to handle Event requests."""
|
||||
|
||||
url = '/api/events/<event_type>'
|
||||
url = '/api/events/{event_type}'
|
||||
name = "api:event"
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request, event_type):
|
||||
"""Fire events."""
|
||||
event_data = request.json
|
||||
body = yield from request.text()
|
||||
event_data = json.loads(body) if body else None
|
||||
|
||||
if event_data is not None and not isinstance(event_data, dict):
|
||||
return self.json_message('Event data should be a JSON object',
|
||||
@ -251,7 +271,7 @@ class APIEventView(HomeAssistantView):
|
||||
if state:
|
||||
event_data[key] = state
|
||||
|
||||
self.hass.bus.fire(event_type, event_data, ha.EventOrigin.remote)
|
||||
self.hass.bus.async_fire(event_type, event_data, ha.EventOrigin.remote)
|
||||
|
||||
return self.json_message("Event {} fired.".format(event_type))
|
||||
|
||||
@ -262,24 +282,30 @@ class APIServicesView(HomeAssistantView):
|
||||
url = URL_API_SERVICES
|
||||
name = "api:services"
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get registered services."""
|
||||
return self.json(services_json(self.hass))
|
||||
return self.json(async_services_json(self.hass))
|
||||
|
||||
|
||||
class APIDomainServicesView(HomeAssistantView):
|
||||
"""View to handle DomainServices requests."""
|
||||
|
||||
url = "/api/services/<domain>/<service>"
|
||||
url = "/api/services/{domain}/{service}"
|
||||
name = "api:domain-services"
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request, domain, service):
|
||||
"""Call a service.
|
||||
|
||||
Returns a list of changed states.
|
||||
"""
|
||||
with TrackStates(self.hass) as changed_states:
|
||||
self.hass.services.call(domain, service, request.json, True)
|
||||
body = yield from request.text()
|
||||
data = json.loads(body) if body else None
|
||||
|
||||
with AsyncTrackStates(self.hass) as changed_states:
|
||||
yield from self.hass.services.async_call(domain, service, data,
|
||||
True)
|
||||
|
||||
return self.json(changed_states)
|
||||
|
||||
@ -291,11 +317,14 @@ class APIEventForwardingView(HomeAssistantView):
|
||||
name = "api:event-forward"
|
||||
event_forwarder = None
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Setup an event forwarder."""
|
||||
data = request.json
|
||||
if data is None:
|
||||
try:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message("No data received.", HTTP_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
host = data['host']
|
||||
api_password = data['api_password']
|
||||
@ -311,21 +340,25 @@ class APIEventForwardingView(HomeAssistantView):
|
||||
|
||||
api = rem.API(host, api_password, port)
|
||||
|
||||
if not api.validate_api():
|
||||
valid = yield from self.hass.loop.run_in_executor(
|
||||
None, api.validate_api)
|
||||
if not valid:
|
||||
return self.json_message("Unable to validate API.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
if self.event_forwarder is None:
|
||||
self.event_forwarder = rem.EventForwarder(self.hass)
|
||||
|
||||
self.event_forwarder.connect(api)
|
||||
self.event_forwarder.async_connect(api)
|
||||
|
||||
return self.json_message("Event forwarding setup.")
|
||||
|
||||
@asyncio.coroutine
|
||||
def delete(self, request):
|
||||
"""Remove event forwarer."""
|
||||
data = request.json
|
||||
if data is None:
|
||||
"""Remove event forwarder."""
|
||||
try:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message("No data received.", HTTP_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
@ -342,7 +375,7 @@ class APIEventForwardingView(HomeAssistantView):
|
||||
if self.event_forwarder is not None:
|
||||
api = rem.API(host, None, port)
|
||||
|
||||
self.event_forwarder.disconnect(api)
|
||||
self.event_forwarder.async_disconnect(api)
|
||||
|
||||
return self.json_message("Event forwarding cancelled.")
|
||||
|
||||
@ -353,6 +386,7 @@ class APIComponentsView(HomeAssistantView):
|
||||
url = URL_API_COMPONENTS
|
||||
name = "api:components"
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get current loaded components."""
|
||||
return self.json(self.hass.config.components)
|
||||
@ -364,9 +398,12 @@ class APIErrorLogView(HomeAssistantView):
|
||||
url = URL_API_ERROR_LOG
|
||||
name = "api:error-log"
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request):
|
||||
"""Serve error log."""
|
||||
return self.file(request, self.hass.config.path(ERROR_LOG_FILENAME))
|
||||
resp = yield from self.file(
|
||||
request, self.hass.config.path(ERROR_LOG_FILENAME))
|
||||
return resp
|
||||
|
||||
|
||||
class APITemplateView(HomeAssistantView):
|
||||
@ -375,23 +412,25 @@ class APITemplateView(HomeAssistantView):
|
||||
url = URL_API_TEMPLATE
|
||||
name = "api:template"
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Render a template."""
|
||||
try:
|
||||
tpl = template.Template(request.json['template'], self.hass)
|
||||
return tpl.render(request.json.get('variables'))
|
||||
except TemplateError as ex:
|
||||
data = yield from request.json()
|
||||
tpl = template.Template(data['template'], self.hass)
|
||||
return tpl.async_render(data.get('variables'))
|
||||
except (ValueError, TemplateError) as ex:
|
||||
return self.json_message('Error rendering template: {}'.format(ex),
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
|
||||
def services_json(hass):
|
||||
def async_services_json(hass):
|
||||
"""Generate services data to JSONify."""
|
||||
return [{"domain": key, "services": value}
|
||||
for key, value in hass.services.services.items()]
|
||||
for key, value in hass.services.async_services().items()]
|
||||
|
||||
|
||||
def events_json(hass):
|
||||
def async_events_json(hass):
|
||||
"""Generate event data to JSONify."""
|
||||
return [{"event": key, "listener_count": value}
|
||||
for key, value in hass.bus.listeners.items()]
|
||||
for key, value in hass.bus.async_listeners().items()]
|
||||
|
@ -81,7 +81,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
from haffmpeg import SensorNoise, SensorMotion
|
||||
|
||||
# check source
|
||||
if not run_test(config.get(CONF_INPUT)):
|
||||
if not run_test(hass, config.get(CONF_INPUT)):
|
||||
return
|
||||
|
||||
# generate sensor object
|
||||
|
@ -5,8 +5,10 @@ Component to interface with cameras.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
@ -31,8 +33,8 @@ def setup(hass, config):
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
|
||||
|
||||
hass.wsgi.register_view(CameraImageView(hass, component.entities))
|
||||
hass.wsgi.register_view(CameraMjpegStream(hass, component.entities))
|
||||
hass.http.register_view(CameraImageView(hass, component.entities))
|
||||
hass.http.register_view(CameraMjpegStream(hass, component.entities))
|
||||
|
||||
component.setup(config)
|
||||
|
||||
@ -80,33 +82,59 @@ class Camera(Entity):
|
||||
"""Return bytes of camera image."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def mjpeg_stream(self, response):
|
||||
"""Generate an HTTP MJPEG stream from camera images."""
|
||||
def stream():
|
||||
"""Stream images as mjpeg stream."""
|
||||
try:
|
||||
last_image = None
|
||||
while True:
|
||||
img_bytes = self.camera_image()
|
||||
@asyncio.coroutine
|
||||
def async_camera_image(self):
|
||||
"""Return bytes of camera image.
|
||||
|
||||
if img_bytes is not None and img_bytes != last_image:
|
||||
yield bytes(
|
||||
'--jpegboundary\r\n'
|
||||
'Content-Type: image/jpeg\r\n'
|
||||
'Content-Length: {}\r\n\r\n'.format(
|
||||
len(img_bytes)), 'utf-8') + img_bytes + b'\r\n'
|
||||
This method must be run in the event loop.
|
||||
"""
|
||||
image = yield from self.hass.loop.run_in_executor(
|
||||
None, self.camera_image)
|
||||
return image
|
||||
|
||||
last_image = img_bytes
|
||||
@asyncio.coroutine
|
||||
def handle_async_mjpeg_stream(self, request):
|
||||
"""Generate an HTTP MJPEG stream from camera images.
|
||||
|
||||
time.sleep(0.5)
|
||||
except GeneratorExit:
|
||||
pass
|
||||
This method must be run in the event loop.
|
||||
"""
|
||||
response = web.StreamResponse()
|
||||
|
||||
return response(
|
||||
stream(),
|
||||
content_type=('multipart/x-mixed-replace; '
|
||||
'boundary=--jpegboundary')
|
||||
)
|
||||
response.content_type = ('multipart/x-mixed-replace; '
|
||||
'boundary=--jpegboundary')
|
||||
response.enable_chunked_encoding()
|
||||
yield from response.prepare(request)
|
||||
|
||||
def write(img_bytes):
|
||||
"""Write image to stream."""
|
||||
response.write(bytes(
|
||||
'--jpegboundary\r\n'
|
||||
'Content-Type: image/jpeg\r\n'
|
||||
'Content-Length: {}\r\n\r\n'.format(
|
||||
len(img_bytes)), 'utf-8') + img_bytes + b'\r\n')
|
||||
|
||||
last_image = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
img_bytes = yield from self.async_camera_image()
|
||||
if not img_bytes:
|
||||
break
|
||||
|
||||
if img_bytes is not None and img_bytes != last_image:
|
||||
write(img_bytes)
|
||||
|
||||
# Chrome seems to always ignore first picture,
|
||||
# print it twice.
|
||||
if last_image is None:
|
||||
write(img_bytes)
|
||||
|
||||
last_image = img_bytes
|
||||
yield from response.drain()
|
||||
|
||||
yield from asyncio.sleep(.5)
|
||||
finally:
|
||||
self.hass.loop.create_task(response.write_eof())
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
@ -144,22 +172,25 @@ class CameraView(HomeAssistantView):
|
||||
super().__init__(hass)
|
||||
self.entities = entities
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request, entity_id):
|
||||
"""Start a get request."""
|
||||
camera = self.entities.get(entity_id)
|
||||
|
||||
if camera is None:
|
||||
return self.Response(status=404)
|
||||
return web.Response(status=404)
|
||||
|
||||
authenticated = (request.authenticated or
|
||||
request.args.get('token') == camera.access_token)
|
||||
request.GET.get('token') == camera.access_token)
|
||||
|
||||
if not authenticated:
|
||||
return self.Response(status=401)
|
||||
return web.Response(status=401)
|
||||
|
||||
return self.handle(camera)
|
||||
response = yield from self.handle(request, camera)
|
||||
return response
|
||||
|
||||
def handle(self, camera):
|
||||
@asyncio.coroutine
|
||||
def handle(self, request, camera):
|
||||
"""Hanlde the camera request."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@ -167,25 +198,27 @@ class CameraView(HomeAssistantView):
|
||||
class CameraImageView(CameraView):
|
||||
"""Camera view to serve an image."""
|
||||
|
||||
url = "/api/camera_proxy/<entity(domain=camera):entity_id>"
|
||||
url = "/api/camera_proxy/{entity_id}"
|
||||
name = "api:camera:image"
|
||||
|
||||
def handle(self, camera):
|
||||
@asyncio.coroutine
|
||||
def handle(self, request, camera):
|
||||
"""Serve camera image."""
|
||||
response = camera.camera_image()
|
||||
image = yield from camera.async_camera_image()
|
||||
|
||||
if response is None:
|
||||
return self.Response(status=500)
|
||||
if image is None:
|
||||
return web.Response(status=500)
|
||||
|
||||
return self.Response(response)
|
||||
return web.Response(body=image)
|
||||
|
||||
|
||||
class CameraMjpegStream(CameraView):
|
||||
"""Camera View to serve an MJPEG stream."""
|
||||
|
||||
url = "/api/camera_proxy_stream/<entity(domain=camera):entity_id>"
|
||||
url = "/api/camera_proxy_stream/{entity_id}"
|
||||
name = "api:camera:stream"
|
||||
|
||||
def handle(self, camera):
|
||||
@asyncio.coroutine
|
||||
def handle(self, request, camera):
|
||||
"""Serve camera image."""
|
||||
return camera.mjpeg_stream(self.Response)
|
||||
yield from camera.handle_async_mjpeg_stream(request)
|
||||
|
@ -4,15 +4,18 @@ Support for Cameras with FFmpeg as decoder.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.ffmpeg/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA)
|
||||
from homeassistant.components.ffmpeg import (
|
||||
run_test, get_binary, CONF_INPUT, CONF_EXTRA_ARGUMENTS)
|
||||
async_run_test, get_binary, CONF_INPUT, CONF_EXTRA_ARGUMENTS)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.util.async import run_coroutine_threadsafe
|
||||
|
||||
DEPENDENCIES = ['ffmpeg']
|
||||
|
||||
@ -27,17 +30,18 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Setup a FFmpeg Camera."""
|
||||
if not run_test(config.get(CONF_INPUT)):
|
||||
if not async_run_test(hass, config.get(CONF_INPUT)):
|
||||
return
|
||||
add_devices([FFmpegCamera(config)])
|
||||
hass.loop.create_task(async_add_devices([FFmpegCamera(hass, config)]))
|
||||
|
||||
|
||||
class FFmpegCamera(Camera):
|
||||
"""An implementation of an FFmpeg camera."""
|
||||
|
||||
def __init__(self, config):
|
||||
def __init__(self, hass, config):
|
||||
"""Initialize a FFmpeg camera."""
|
||||
super().__init__()
|
||||
self._name = config.get(CONF_NAME)
|
||||
@ -45,24 +49,45 @@ class FFmpegCamera(Camera):
|
||||
self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS)
|
||||
|
||||
def camera_image(self):
|
||||
"""Return bytes of camera image."""
|
||||
return run_coroutine_threadsafe(
|
||||
self.async_camera_image(), self.hass.loop).result()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
from haffmpeg import ImageSingle, IMAGE_JPEG
|
||||
ffmpeg = ImageSingle(get_binary())
|
||||
from haffmpeg import ImageSingleAsync, IMAGE_JPEG
|
||||
ffmpeg = ImageSingleAsync(get_binary(), loop=self.hass.loop)
|
||||
|
||||
return ffmpeg.get_image(self._input, output_format=IMAGE_JPEG,
|
||||
extra_cmd=self._extra_arguments)
|
||||
image = yield from ffmpeg.get_image(
|
||||
self._input, output_format=IMAGE_JPEG,
|
||||
extra_cmd=self._extra_arguments)
|
||||
return image
|
||||
|
||||
def mjpeg_stream(self, response):
|
||||
@asyncio.coroutine
|
||||
def handle_async_mjpeg_stream(self, request):
|
||||
"""Generate an HTTP MJPEG stream from the camera."""
|
||||
from haffmpeg import CameraMjpeg
|
||||
from haffmpeg import CameraMjpegAsync
|
||||
|
||||
stream = CameraMjpeg(get_binary())
|
||||
stream.open_camera(self._input, extra_cmd=self._extra_arguments)
|
||||
return response(
|
||||
stream,
|
||||
mimetype='multipart/x-mixed-replace;boundary=ffserver',
|
||||
direct_passthrough=True
|
||||
)
|
||||
stream = CameraMjpegAsync(get_binary(), loop=self.hass.loop)
|
||||
yield from stream.open_camera(
|
||||
self._input, extra_cmd=self._extra_arguments)
|
||||
|
||||
response = web.StreamResponse()
|
||||
response.content_type = 'multipart/x-mixed-replace;boundary=ffserver'
|
||||
response.enable_chunked_encoding()
|
||||
|
||||
yield from response.prepare(request)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = yield from stream.read(102400)
|
||||
if not data:
|
||||
break
|
||||
response.write(data)
|
||||
finally:
|
||||
self.hass.loop.create_task(stream.close())
|
||||
self.hass.loop.create_task(response.write_eof())
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
@ -4,10 +4,13 @@ Support for IP Cameras.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.generic/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
|
||||
from requests.auth import HTTPDigestAuth
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
@ -16,6 +19,7 @@ from homeassistant.const import (
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util.async import run_coroutine_threadsafe
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -35,10 +39,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Setup a generic IP Camera."""
|
||||
add_devices([GenericCamera(hass, config)])
|
||||
hass.loop.create_task(async_add_devices([GenericCamera(hass, config)]))
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
@ -49,6 +54,7 @@ class GenericCamera(Camera):
|
||||
"""Initialize a generic camera."""
|
||||
super().__init__()
|
||||
self.hass = hass
|
||||
self._authentication = device_info.get(CONF_AUTHENTICATION)
|
||||
self._name = device_info.get(CONF_NAME)
|
||||
self._still_image_url = device_info[CONF_STILL_IMAGE_URL]
|
||||
self._still_image_url.hass = hass
|
||||
@ -58,20 +64,27 @@ class GenericCamera(Camera):
|
||||
password = device_info.get(CONF_PASSWORD)
|
||||
|
||||
if username and password:
|
||||
if device_info[CONF_AUTHENTICATION] == HTTP_DIGEST_AUTHENTICATION:
|
||||
if self._authentication == HTTP_DIGEST_AUTHENTICATION:
|
||||
self._auth = HTTPDigestAuth(username, password)
|
||||
else:
|
||||
self._auth = HTTPBasicAuth(username, password)
|
||||
self._auth = aiohttp.BasicAuth(username, password=password)
|
||||
else:
|
||||
self._auth = None
|
||||
|
||||
self._last_url = None
|
||||
self._last_image = None
|
||||
self._session = aiohttp.ClientSession(loop=hass.loop, auth=self._auth)
|
||||
|
||||
def camera_image(self):
|
||||
"""Return bytes of camera image."""
|
||||
return run_coroutine_threadsafe(
|
||||
self.async_camera_image(), self.hass.loop).result()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
try:
|
||||
url = self._still_image_url.render()
|
||||
url = self._still_image_url.async_render()
|
||||
except TemplateError as err:
|
||||
_LOGGER.error('Error parsing template %s: %s',
|
||||
self._still_image_url, err)
|
||||
@ -80,16 +93,32 @@ class GenericCamera(Camera):
|
||||
if url == self._last_url and self._limit_refetch:
|
||||
return self._last_image
|
||||
|
||||
kwargs = {'timeout': 10, 'auth': self._auth}
|
||||
# aiohttp don't support DigestAuth jet
|
||||
if self._authentication == HTTP_DIGEST_AUTHENTICATION:
|
||||
def fetch():
|
||||
"""Read image from a URL."""
|
||||
try:
|
||||
kwargs = {'timeout': 10, 'auth': self._auth}
|
||||
response = requests.get(url, **kwargs)
|
||||
return response.content
|
||||
except requests.exceptions.RequestException as error:
|
||||
_LOGGER.error('Error getting camera image: %s', error)
|
||||
return self._last_image
|
||||
|
||||
try:
|
||||
response = requests.get(url, **kwargs)
|
||||
except requests.exceptions.RequestException as error:
|
||||
_LOGGER.error('Error getting camera image: %s', error)
|
||||
return None
|
||||
self._last_image = yield from self.hass.loop.run_in_executor(
|
||||
None, fetch)
|
||||
# async
|
||||
else:
|
||||
try:
|
||||
with async_timeout.timeout(10, loop=self.hass.loop):
|
||||
respone = yield from self._session.get(url)
|
||||
self._last_image = yield from respone.read()
|
||||
self.hass.loop.create_task(respone.release())
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error('Timeout getting camera image')
|
||||
return self._last_image
|
||||
|
||||
self._last_url = url
|
||||
self._last_image = response.content
|
||||
return self._last_image
|
||||
|
||||
@property
|
||||
|
@ -4,9 +4,14 @@ Support for IP Cameras.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.mjpeg/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import closing
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from aiohttp.web_exceptions import HTTPGatewayTimeout
|
||||
import async_timeout
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
|
||||
import voluptuous as vol
|
||||
@ -34,10 +39,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Setup a MJPEG IP Camera."""
|
||||
add_devices([MjpegCamera(config)])
|
||||
hass.loop.create_task(async_add_devices([MjpegCamera(hass, config)]))
|
||||
|
||||
|
||||
def extract_image_from_mjpeg(stream):
|
||||
@ -56,7 +62,7 @@ def extract_image_from_mjpeg(stream):
|
||||
class MjpegCamera(Camera):
|
||||
"""An implementation of an IP camera that is reachable over a URL."""
|
||||
|
||||
def __init__(self, device_info):
|
||||
def __init__(self, hass, device_info):
|
||||
"""Initialize a MJPEG camera."""
|
||||
super().__init__()
|
||||
self._name = device_info.get(CONF_NAME)
|
||||
@ -65,32 +71,57 @@ class MjpegCamera(Camera):
|
||||
self._password = device_info.get(CONF_PASSWORD)
|
||||
self._mjpeg_url = device_info[CONF_MJPEG_URL]
|
||||
|
||||
def camera_stream(self):
|
||||
"""Return a MJPEG stream image response directly from the camera."""
|
||||
auth = None
|
||||
if self._authentication == HTTP_BASIC_AUTHENTICATION:
|
||||
auth = aiohttp.BasicAuth(self._username, password=self._password)
|
||||
|
||||
self._session = aiohttp.ClientSession(loop=hass.loop, auth=auth)
|
||||
|
||||
def camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
if self._username and self._password:
|
||||
if self._authentication == HTTP_DIGEST_AUTHENTICATION:
|
||||
auth = HTTPDigestAuth(self._username, self._password)
|
||||
else:
|
||||
auth = HTTPBasicAuth(self._username, self._password)
|
||||
return requests.get(self._mjpeg_url,
|
||||
auth=auth,
|
||||
stream=True, timeout=10)
|
||||
req = requests.get(
|
||||
self._mjpeg_url, auth=auth, stream=True, timeout=10)
|
||||
else:
|
||||
return requests.get(self._mjpeg_url, stream=True, timeout=10)
|
||||
req = requests.get(self._mjpeg_url, stream=True, timeout=10)
|
||||
|
||||
def camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
with closing(self.camera_stream()) as response:
|
||||
return extract_image_from_mjpeg(response.iter_content(1024))
|
||||
with closing(req) as response:
|
||||
return extract_image_from_mjpeg(response.iter_content(102400))
|
||||
|
||||
def mjpeg_stream(self, response):
|
||||
@asyncio.coroutine
|
||||
def handle_async_mjpeg_stream(self, request):
|
||||
"""Generate an HTTP MJPEG stream from the camera."""
|
||||
stream = self.camera_stream()
|
||||
return response(
|
||||
stream.iter_content(chunk_size=1024),
|
||||
mimetype=stream.headers[CONTENT_TYPE_HEADER],
|
||||
direct_passthrough=True
|
||||
)
|
||||
# aiohttp don't support DigestAuth -> Fallback
|
||||
if self._authentication == HTTP_DIGEST_AUTHENTICATION:
|
||||
yield from super().handle_async_mjpeg_stream(request)
|
||||
return
|
||||
|
||||
# connect to stream
|
||||
try:
|
||||
with async_timeout.timeout(10, loop=self.hass.loop):
|
||||
stream = yield from self._session.get(self._mjpeg_url)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPGatewayTimeout()
|
||||
|
||||
response = web.StreamResponse()
|
||||
response.content_type = stream.headers.get(CONTENT_TYPE_HEADER)
|
||||
response.enable_chunked_encoding()
|
||||
|
||||
yield from response.prepare(request)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = yield from stream.content.read(102400)
|
||||
if not data:
|
||||
break
|
||||
response.write(data)
|
||||
finally:
|
||||
self.hass.loop.create_task(stream.release())
|
||||
self.hass.loop.create_task(response.write_eof())
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
@ -4,6 +4,8 @@ Support for the Locative platform.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.locative/
|
||||
"""
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME
|
||||
@ -19,7 +21,7 @@ DEPENDENCIES = ['http']
|
||||
|
||||
def setup_scanner(hass, config, see):
|
||||
"""Setup an endpoint for the Locative application."""
|
||||
hass.wsgi.register_view(LocativeView(hass, see))
|
||||
hass.http.register_view(LocativeView(hass, see))
|
||||
|
||||
return True
|
||||
|
||||
@ -35,15 +37,23 @@ class LocativeView(HomeAssistantView):
|
||||
super().__init__(hass)
|
||||
self.see = see
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request):
|
||||
"""Locative message received as GET."""
|
||||
return self.post(request)
|
||||
res = yield from self._handle(request.GET)
|
||||
return res
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Locative message received."""
|
||||
# pylint: disable=too-many-return-statements
|
||||
data = request.values
|
||||
data = yield from request.post()
|
||||
res = yield from self._handle(data)
|
||||
return res
|
||||
|
||||
@asyncio.coroutine
|
||||
def _handle(self, data):
|
||||
"""Handle locative request."""
|
||||
# pylint: disable=too-many-return-statements
|
||||
if 'latitude' not in data or 'longitude' not in data:
|
||||
return ('Latitude and longitude not specified.',
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
@ -68,7 +78,9 @@ class LocativeView(HomeAssistantView):
|
||||
direction = data['trigger']
|
||||
|
||||
if direction == 'enter':
|
||||
self.see(dev_id=device, location_name=location_name)
|
||||
yield from self.hass.loop.run_in_executor(
|
||||
None, partial(self.see, dev_id=device,
|
||||
location_name=location_name))
|
||||
return 'Setting location to {}'.format(location_name)
|
||||
|
||||
elif direction == 'exit':
|
||||
@ -76,7 +88,9 @@ class LocativeView(HomeAssistantView):
|
||||
'{}.{}'.format(DOMAIN, device))
|
||||
|
||||
if current_state is None or current_state.state == location_name:
|
||||
self.see(dev_id=device, location_name=STATE_NOT_HOME)
|
||||
yield from self.hass.loop.run_in_executor(
|
||||
None, partial(self.see, dev_id=device,
|
||||
location_name=STATE_NOT_HOME))
|
||||
return 'Setting location to not home'
|
||||
else:
|
||||
# Ignore the message if it is telling us to exit a zone that we
|
||||
|
@ -4,20 +4,21 @@ 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 json
|
||||
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, HTTP_BAD_REQUEST
|
||||
STATE_ON, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
|
||||
)
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS, ATTR_SUPPORTED_FEATURES, SUPPORT_BRIGHTNESS
|
||||
@ -25,8 +26,6 @@ from homeassistant.components.light import (
|
||||
from homeassistant.components.http import (
|
||||
HomeAssistantView, HomeAssistantWSGI
|
||||
)
|
||||
# pylint: disable=unused-import
|
||||
from homeassistant.components.http import REQUIREMENTS # noqa
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
DOMAIN = 'emulated_hue'
|
||||
@ -87,19 +86,21 @@ def setup(hass, yaml_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)
|
||||
|
||||
@core.callback
|
||||
def stop_emulated_hue_bridge(event):
|
||||
"""Stop the emulated hue bridge."""
|
||||
upnp_listener.stop()
|
||||
server.stop()
|
||||
hass.loop.create_task(server.stop())
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge)
|
||||
@core.callback
|
||||
def start_emulated_hue_bridge(event):
|
||||
"""Start the emulated hue bridge."""
|
||||
hass.loop.create_task(server.start())
|
||||
upnp_listener.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
|
||||
|
||||
@ -158,6 +159,7 @@ class DescriptionXmlView(HomeAssistantView):
|
||||
super().__init__(hass)
|
||||
self.config = config
|
||||
|
||||
@core.callback
|
||||
def get(self, request):
|
||||
"""Handle a GET request."""
|
||||
xml_template = """<?xml version="1.0" encoding="UTF-8" ?>
|
||||
@ -185,7 +187,7 @@ class DescriptionXmlView(HomeAssistantView):
|
||||
resp_text = xml_template.format(
|
||||
self.config.host_ip_addr, self.config.listen_port)
|
||||
|
||||
return self.Response(resp_text, mimetype='text/xml')
|
||||
return web.Response(text=resp_text, content_type='text/xml')
|
||||
|
||||
|
||||
class HueUsernameView(HomeAssistantView):
|
||||
@ -200,9 +202,13 @@ class HueUsernameView(HomeAssistantView):
|
||||
"""Initialize the instance of the view."""
|
||||
super().__init__(hass)
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Handle a POST request."""
|
||||
data = request.json
|
||||
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',
|
||||
@ -214,10 +220,10 @@ class HueUsernameView(HomeAssistantView):
|
||||
class HueLightsView(HomeAssistantView):
|
||||
"""Handle requests for getting and setting info about entities."""
|
||||
|
||||
url = '/api/<username>/lights'
|
||||
url = '/api/{username}/lights'
|
||||
name = 'api:username:lights'
|
||||
extra_urls = ['/api/<username>/lights/<entity_id>',
|
||||
'/api/<username>/lights/<entity_id>/state']
|
||||
extra_urls = ['/api/{username}/lights/{entity_id}',
|
||||
'/api/{username}/lights/{entity_id}/state']
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, hass, config):
|
||||
@ -226,58 +232,51 @@ class HueLightsView(HomeAssistantView):
|
||||
self.config = config
|
||||
self.cached_states = {}
|
||||
|
||||
@core.callback
|
||||
def get(self, request, username, entity_id=None):
|
||||
"""Handle a GET request."""
|
||||
if entity_id is None:
|
||||
return self.get_lights_list()
|
||||
return self.async_get_lights_list()
|
||||
|
||||
if not request.base_url.endswith('state'):
|
||||
return self.get_light_state(entity_id)
|
||||
if not request.path.endswith('state'):
|
||||
return self.async_get_light_state(entity_id)
|
||||
|
||||
return self.Response("Method not allowed", status=405)
|
||||
return web.Response(text="Method not allowed", status=405)
|
||||
|
||||
@asyncio.coroutine
|
||||
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)
|
||||
if not request.path.endswith('state'):
|
||||
return web.Response(text="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
|
||||
if entity_id and self.hass.states.get(entity_id) is None:
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
|
||||
for key in request.form:
|
||||
try:
|
||||
json_data = json.loads(key)
|
||||
break
|
||||
except ValueError:
|
||||
# Try the next key?
|
||||
pass
|
||||
try:
|
||||
json_data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
||||
|
||||
if json_data is None:
|
||||
return self.Response("Bad request", status=400)
|
||||
else:
|
||||
json_data = request.json
|
||||
result = yield from self.async_put_light_state(json_data, entity_id)
|
||||
return result
|
||||
|
||||
return self.put_light_state(json_data, entity_id)
|
||||
|
||||
def get_lights_list(self):
|
||||
@core.callback
|
||||
def async_get_lights_list(self):
|
||||
"""Process a request to get the list of available lights."""
|
||||
json_response = {}
|
||||
|
||||
for entity in self.hass.states.all():
|
||||
for entity in self.hass.states.async_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):
|
||||
@core.callback
|
||||
def async_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)
|
||||
return web.Response(text="Entity not found", status=404)
|
||||
|
||||
cached_state = self.cached_states.get(entity_id, None)
|
||||
|
||||
@ -292,23 +291,24 @@ class HueLightsView(HomeAssistantView):
|
||||
|
||||
return self.json(json_response)
|
||||
|
||||
def put_light_state(self, request_json, entity_id):
|
||||
@asyncio.coroutine
|
||||
def async_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)
|
||||
return web.Response(text="Entity not found", status=404)
|
||||
|
||||
if not self.is_entity_exposed(entity):
|
||||
return self.Response("Entity not found", status=404)
|
||||
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 self.Response("Bad request", status=400)
|
||||
return web.Response(text="Bad request", status=400)
|
||||
|
||||
result, brightness = parsed
|
||||
|
||||
@ -333,7 +333,8 @@ class HueLightsView(HomeAssistantView):
|
||||
self.cached_states[entity_id] = (result, brightness)
|
||||
|
||||
# Perform the requested action
|
||||
self.hass.services.call(core.DOMAIN, service, data, blocking=True)
|
||||
yield from self.hass.services.async_call(core.DOMAIN, service, data,
|
||||
blocking=True)
|
||||
|
||||
json_response = \
|
||||
[create_hue_success_response(entity_id, HUE_API_STATE_ON, result)]
|
||||
@ -345,7 +346,10 @@ class HueLightsView(HomeAssistantView):
|
||||
return self.json(json_response)
|
||||
|
||||
def is_entity_exposed(self, entity):
|
||||
"""Determine if an entity should be exposed on the emulated bridge."""
|
||||
"""Determine if an entity should be exposed on the emulated bridge.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
config = self.config
|
||||
|
||||
if entity.attributes.get('view') is not None:
|
||||
|
@ -4,14 +4,16 @@ Component that will help set the ffmpeg component.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/ffmpeg/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util.async import run_coroutine_threadsafe
|
||||
|
||||
DOMAIN = 'ffmpeg'
|
||||
REQUIREMENTS = ["ha-ffmpeg==0.13"]
|
||||
REQUIREMENTS = ["ha-ffmpeg==0.14"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -47,13 +49,26 @@ def setup(hass, config):
|
||||
|
||||
|
||||
def get_binary():
|
||||
"""Return ffmpeg binary from config."""
|
||||
"""Return ffmpeg binary from config.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return FFMPEG_CONFIG.get(CONF_FFMPEG_BIN)
|
||||
|
||||
|
||||
def run_test(input_source):
|
||||
def run_test(hass, input_source):
|
||||
"""Run test on this input. TRUE is deactivate or run correct."""
|
||||
from haffmpeg import Test
|
||||
return run_coroutine_threadsafe(
|
||||
async_run_test(hass, input_source), hass.loop).result()
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_run_test(hass, input_source):
|
||||
"""Run test on this input. TRUE is deactivate or run correct.
|
||||
|
||||
This method must be run in the event loop.
|
||||
"""
|
||||
from haffmpeg import TestAsync
|
||||
|
||||
if FFMPEG_CONFIG.get(CONF_RUN_TEST):
|
||||
# if in cache
|
||||
@ -61,8 +76,9 @@ def run_test(input_source):
|
||||
return FFMPEG_TEST_CACHE[input_source]
|
||||
|
||||
# run test
|
||||
test = Test(get_binary())
|
||||
if not test.run_test(input_source):
|
||||
ffmpeg_test = TestAsync(get_binary(), loop=hass.loop)
|
||||
success = yield from ffmpeg_test.run_test(input_source)
|
||||
if not success:
|
||||
_LOGGER.error("FFmpeg '%s' test fails!", input_source)
|
||||
FFMPEG_TEST_CACHE[input_source] = False
|
||||
return False
|
||||
|
@ -4,14 +4,14 @@ Allows utilizing the Foursquare (Swarm) API.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/foursquare/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_BAD_REQUEST
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
@ -75,7 +75,7 @@ def setup(hass, config):
|
||||
descriptions[DOMAIN][SERVICE_CHECKIN],
|
||||
schema=CHECKIN_SERVICE_SCHEMA)
|
||||
|
||||
hass.wsgi.register_view(FoursquarePushReceiver(
|
||||
hass.http.register_view(FoursquarePushReceiver(
|
||||
hass, config[CONF_PUSH_SECRET]))
|
||||
|
||||
return True
|
||||
@ -93,16 +93,21 @@ class FoursquarePushReceiver(HomeAssistantView):
|
||||
super().__init__(hass)
|
||||
self.push_secret = push_secret
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Accept the POST from Foursquare."""
|
||||
raw_data = request.form
|
||||
_LOGGER.debug("Received Foursquare push: %s", raw_data)
|
||||
if self.push_secret != raw_data["secret"]:
|
||||
try:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
||||
|
||||
secret = data.pop('secret', None)
|
||||
|
||||
_LOGGER.debug("Received Foursquare push: %s", data)
|
||||
|
||||
if self.push_secret != secret:
|
||||
_LOGGER.error("Received Foursquare push with invalid"
|
||||
"push secret! Data: %s", raw_data)
|
||||
return
|
||||
parsed_payload = {
|
||||
key: json.loads(val) for key, val in raw_data.items()
|
||||
if key != "secret"
|
||||
}
|
||||
self.hass.bus.fire(EVENT_PUSH, parsed_payload)
|
||||
"push secret: %s", secret)
|
||||
return self.json_message('Incorrect secret', HTTP_BAD_REQUEST)
|
||||
|
||||
self.hass.bus.async_fire(EVENT_PUSH, data)
|
||||
|
@ -1,8 +1,13 @@
|
||||
"""Handle the frontend for Home Assistant."""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START
|
||||
from homeassistant.components import api
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
@ -39,7 +44,7 @@ def register_built_in_panel(hass, component_name, sidebar_title=None,
|
||||
# pylint: disable=too-many-arguments
|
||||
path = 'panels/ha-panel-{}.html'.format(component_name)
|
||||
|
||||
if hass.wsgi.development:
|
||||
if hass.http.development:
|
||||
url = ('/static/home-assistant-polymer/panels/'
|
||||
'{0}/ha-panel-{0}.html'.format(component_name))
|
||||
else:
|
||||
@ -98,7 +103,7 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None,
|
||||
url = URL_PANEL_COMPONENT.format(component_name)
|
||||
|
||||
if url not in _REGISTERED_COMPONENTS:
|
||||
hass.wsgi.register_static_path(url, path)
|
||||
hass.http.register_static_path(url, path)
|
||||
_REGISTERED_COMPONENTS.add(url)
|
||||
|
||||
fprinted_url = URL_PANEL_COMPONENT_FP.format(component_name, md5)
|
||||
@ -114,20 +119,23 @@ def add_manifest_json_key(key, val):
|
||||
|
||||
def setup(hass, config):
|
||||
"""Setup serving the frontend."""
|
||||
hass.wsgi.register_view(BootstrapView)
|
||||
hass.wsgi.register_view(ManifestJSONView)
|
||||
hass.http.register_view(BootstrapView)
|
||||
hass.http.register_view(ManifestJSONView)
|
||||
|
||||
if hass.wsgi.development:
|
||||
if hass.http.development:
|
||||
sw_path = "home-assistant-polymer/build/service_worker.js"
|
||||
else:
|
||||
sw_path = "service_worker.js"
|
||||
|
||||
hass.wsgi.register_static_path("/service_worker.js",
|
||||
hass.http.register_static_path("/service_worker.js",
|
||||
os.path.join(STATIC_PATH, sw_path), 0)
|
||||
hass.wsgi.register_static_path("/robots.txt",
|
||||
hass.http.register_static_path("/robots.txt",
|
||||
os.path.join(STATIC_PATH, "robots.txt"))
|
||||
hass.wsgi.register_static_path("/static", STATIC_PATH)
|
||||
hass.wsgi.register_static_path("/local", hass.config.path('www'))
|
||||
hass.http.register_static_path("/static", STATIC_PATH)
|
||||
|
||||
local = hass.config.path('www')
|
||||
if os.path.isdir(local):
|
||||
hass.http.register_static_path("/local", local)
|
||||
|
||||
register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location')
|
||||
|
||||
@ -140,7 +148,7 @@ def setup(hass, config):
|
||||
|
||||
Done when Home Assistant is started so that all panels are known.
|
||||
"""
|
||||
hass.wsgi.register_view(IndexView(
|
||||
hass.http.register_view(IndexView(
|
||||
hass, ['/{}'.format(name) for name in PANELS]))
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, register_frontend_index)
|
||||
@ -161,13 +169,14 @@ class BootstrapView(HomeAssistantView):
|
||||
url = "/api/bootstrap"
|
||||
name = "api:bootstrap"
|
||||
|
||||
@callback
|
||||
def get(self, request):
|
||||
"""Return all data needed to bootstrap Home Assistant."""
|
||||
return self.json({
|
||||
'config': self.hass.config.as_dict(),
|
||||
'states': self.hass.states.all(),
|
||||
'events': api.events_json(self.hass),
|
||||
'services': api.services_json(self.hass),
|
||||
'states': self.hass.states.async_all(),
|
||||
'events': api.async_events_json(self.hass),
|
||||
'services': api.async_services_json(self.hass),
|
||||
'panels': PANELS,
|
||||
})
|
||||
|
||||
@ -193,9 +202,10 @@ class IndexView(HomeAssistantView):
|
||||
)
|
||||
)
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request, entity_id=None):
|
||||
"""Serve the index view."""
|
||||
if self.hass.wsgi.development:
|
||||
if self.hass.http.development:
|
||||
core_url = '/static/home-assistant-polymer/build/core.js'
|
||||
ui_url = '/static/home-assistant-polymer/src/home-assistant.html'
|
||||
else:
|
||||
@ -215,22 +225,24 @@ class IndexView(HomeAssistantView):
|
||||
if self.hass.config.api.api_password:
|
||||
# require password if set
|
||||
no_auth = 'false'
|
||||
if self.hass.wsgi.is_trusted_ip(
|
||||
self.hass.wsgi.get_real_ip(request)):
|
||||
if self.hass.http.is_trusted_ip(
|
||||
self.hass.http.get_real_ip(request)):
|
||||
# bypass for trusted networks
|
||||
no_auth = 'true'
|
||||
|
||||
icons_url = '/static/mdi-{}.html'.format(FINGERPRINTS['mdi.html'])
|
||||
template = self.templates.get_template('index.html')
|
||||
template = yield from self.hass.loop.run_in_executor(
|
||||
None, self.templates.get_template, 'index.html')
|
||||
|
||||
# pylint is wrong
|
||||
# pylint: disable=no-member
|
||||
# This is a jinja2 template, not a HA template so we call 'render'.
|
||||
resp = template.render(
|
||||
core_url=core_url, ui_url=ui_url, no_auth=no_auth,
|
||||
icons_url=icons_url, icons=FINGERPRINTS['mdi.html'],
|
||||
panel_url=panel_url, panels=PANELS)
|
||||
|
||||
return self.Response(resp, mimetype='text/html')
|
||||
return web.Response(text=resp, content_type='text/html')
|
||||
|
||||
|
||||
class ManifestJSONView(HomeAssistantView):
|
||||
@ -240,8 +252,8 @@ class ManifestJSONView(HomeAssistantView):
|
||||
url = "/manifest.json"
|
||||
name = "manifestjson"
|
||||
|
||||
def get(self, request):
|
||||
@asyncio.coroutine
|
||||
def get(self, request): # pylint: disable=no-self-use
|
||||
"""Return the manifest.json."""
|
||||
import json
|
||||
msg = json.dumps(MANIFEST_JSON, sort_keys=True).encode('UTF-8')
|
||||
return self.Response(msg, mimetype="application/manifest+json")
|
||||
return web.Response(body=msg, content_type="application/manifest+json")
|
||||
|
@ -4,11 +4,13 @@ Provide pre-made queries on top of the recorder component.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/history/
|
||||
"""
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from itertools import groupby
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import HTTP_BAD_REQUEST
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.components import recorder, script
|
||||
@ -182,8 +184,8 @@ def setup(hass, config):
|
||||
filters.included_entities = include[CONF_ENTITIES]
|
||||
filters.included_domains = include[CONF_DOMAINS]
|
||||
|
||||
hass.wsgi.register_view(Last5StatesView(hass))
|
||||
hass.wsgi.register_view(HistoryPeriodView(hass, filters))
|
||||
hass.http.register_view(Last5StatesView(hass))
|
||||
hass.http.register_view(HistoryPeriodView(hass, filters))
|
||||
register_built_in_panel(hass, 'history', 'History', 'mdi:poll-box')
|
||||
|
||||
return True
|
||||
@ -192,16 +194,19 @@ def setup(hass, config):
|
||||
class Last5StatesView(HomeAssistantView):
|
||||
"""Handle last 5 state view requests."""
|
||||
|
||||
url = '/api/history/entity/<entity:entity_id>/recent_states'
|
||||
url = '/api/history/entity/{entity_id}/recent_states'
|
||||
name = 'api:history:entity-recent-states'
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initilalize the history last 5 states view."""
|
||||
super().__init__(hass)
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request, entity_id):
|
||||
"""Retrieve last 5 states of entity."""
|
||||
return self.json(last_5_states(entity_id))
|
||||
result = yield from self.hass.loop.run_in_executor(
|
||||
None, last_5_states, entity_id)
|
||||
return self.json(result)
|
||||
|
||||
|
||||
class HistoryPeriodView(HomeAssistantView):
|
||||
@ -209,15 +214,22 @@ class HistoryPeriodView(HomeAssistantView):
|
||||
|
||||
url = '/api/history/period'
|
||||
name = 'api:history:view-period'
|
||||
extra_urls = ['/api/history/period/<datetime:datetime>']
|
||||
extra_urls = ['/api/history/period/{datetime}']
|
||||
|
||||
def __init__(self, hass, filters):
|
||||
"""Initilalize the history period view."""
|
||||
super().__init__(hass)
|
||||
self.filters = filters
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request, datetime=None):
|
||||
"""Return history over a period of time."""
|
||||
if datetime:
|
||||
datetime = dt_util.parse_datetime(datetime)
|
||||
|
||||
if datetime is None:
|
||||
return self.json_message('Invalid datetime', HTTP_BAD_REQUEST)
|
||||
|
||||
one_day = timedelta(days=1)
|
||||
|
||||
if datetime:
|
||||
@ -226,10 +238,13 @@ class HistoryPeriodView(HomeAssistantView):
|
||||
start_time = dt_util.utcnow() - one_day
|
||||
|
||||
end_time = start_time + one_day
|
||||
entity_id = request.args.get('filter_entity_id')
|
||||
entity_id = request.GET.get('filter_entity_id')
|
||||
|
||||
return self.json(get_significant_states(
|
||||
start_time, end_time, entity_id, self.filters).values())
|
||||
result = yield from self.hass.loop.run_in_executor(
|
||||
None, get_significant_states, start_time, end_time, entity_id,
|
||||
self.filters)
|
||||
|
||||
return self.json(result.values())
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
@ -4,31 +4,36 @@ This module provides WSGI application to serve the Home Assistant API.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/http/
|
||||
"""
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import threading
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ssl
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
import voluptuous as vol
|
||||
from aiohttp import web, hdrs
|
||||
from aiohttp.file_sender import FileSender
|
||||
from aiohttp.web_exceptions import (
|
||||
HTTPUnauthorized, HTTPMovedPermanently, HTTPNotModified)
|
||||
from aiohttp.web_urldispatcher import StaticRoute
|
||||
|
||||
from homeassistant.core import callback, is_callback
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant import util
|
||||
from homeassistant.const import (
|
||||
SERVER_PORT, HTTP_HEADER_HA_AUTH, HTTP_HEADER_CACHE_CONTROL,
|
||||
HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE_JSON,
|
||||
HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_HEADERS,
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START)
|
||||
from homeassistant.core import split_entity_id
|
||||
import homeassistant.util.dt as dt_util
|
||||
SERVER_PORT, HTTP_HEADER_HA_AUTH, # HTTP_HEADER_CACHE_CONTROL,
|
||||
CONTENT_TYPE_JSON, ALLOWED_CORS_HEADERS, EVENT_HOMEASSISTANT_STOP,
|
||||
EVENT_HOMEASSISTANT_START)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components import persistent_notification
|
||||
|
||||
DOMAIN = 'http'
|
||||
REQUIREMENTS = ('cherrypy==8.1.2', 'static3==0.7.0', 'Werkzeug==0.11.11')
|
||||
REQUIREMENTS = ('aiohttp_cors==0.4.0',)
|
||||
|
||||
CONF_API_PASSWORD = 'api_password'
|
||||
CONF_SERVER_HOST = 'server_host'
|
||||
@ -83,6 +88,12 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
# TEMP TO GET TESTS TO RUN
|
||||
def request_class():
|
||||
"""."""
|
||||
raise Exception('not implemented')
|
||||
|
||||
|
||||
class HideSensitiveFilter(logging.Filter):
|
||||
"""Filter API password calls."""
|
||||
|
||||
@ -94,17 +105,17 @@ class HideSensitiveFilter(logging.Filter):
|
||||
|
||||
def filter(self, record):
|
||||
"""Hide sensitive data in messages."""
|
||||
if self.hass.wsgi.api_password is None:
|
||||
if self.hass.http.api_password is None:
|
||||
return True
|
||||
|
||||
record.msg = record.msg.replace(self.hass.wsgi.api_password, '*******')
|
||||
record.msg = record.msg.replace(self.hass.http.api_password, '*******')
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up the HTTP API and debug interface."""
|
||||
_LOGGER.addFilter(HideSensitiveFilter(hass))
|
||||
logging.getLogger('aiohttp.access').addFilter(HideSensitiveFilter(hass))
|
||||
|
||||
conf = config.get(DOMAIN, {})
|
||||
|
||||
@ -131,19 +142,20 @@ def setup(hass, config):
|
||||
trusted_networks=trusted_networks
|
||||
)
|
||||
|
||||
def start_wsgi_server(event):
|
||||
"""Start the WSGI server."""
|
||||
server.start()
|
||||
@callback
|
||||
def stop_server(event):
|
||||
"""Callback to stop the server."""
|
||||
hass.loop.create_task(server.stop())
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_wsgi_server)
|
||||
@callback
|
||||
def start_server(event):
|
||||
"""Callback to start the server."""
|
||||
hass.loop.create_task(server.start())
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
|
||||
|
||||
def stop_wsgi_server(event):
|
||||
"""Stop the WSGI server."""
|
||||
server.stop()
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_server)
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_wsgi_server)
|
||||
|
||||
hass.wsgi = server
|
||||
hass.http = server
|
||||
hass.config.api = rem.API(server_host if server_host != '0.0.0.0'
|
||||
else util.get_local_ip(),
|
||||
api_password, server_port,
|
||||
@ -152,105 +164,84 @@ def setup(hass, config):
|
||||
return True
|
||||
|
||||
|
||||
def request_class():
|
||||
"""Generate request class.
|
||||
class GzipFileSender(FileSender):
|
||||
"""FileSender class capable of sending gzip version if available."""
|
||||
|
||||
Done in method because of imports.
|
||||
"""
|
||||
from werkzeug.exceptions import BadRequest
|
||||
from werkzeug.wrappers import BaseRequest, AcceptMixin
|
||||
from werkzeug.utils import cached_property
|
||||
# pylint: disable=invalid-name, too-few-public-methods
|
||||
|
||||
class Request(BaseRequest, AcceptMixin):
|
||||
"""Base class for incoming requests."""
|
||||
development = False
|
||||
|
||||
@cached_property
|
||||
def json(self):
|
||||
"""Get the result of json.loads if possible."""
|
||||
if not self.data:
|
||||
return None
|
||||
# elif 'json' not in self.environ.get('CONTENT_TYPE', ''):
|
||||
# raise BadRequest('Not a JSON request')
|
||||
try:
|
||||
return json.loads(self.data.decode(
|
||||
self.charset, self.encoding_errors))
|
||||
except (TypeError, ValueError):
|
||||
raise BadRequest('Unable to read JSON request')
|
||||
@asyncio.coroutine
|
||||
def send(self, request, filepath):
|
||||
"""Send filepath to client using request."""
|
||||
gzip = False
|
||||
if 'gzip' in request.headers[hdrs.ACCEPT_ENCODING]:
|
||||
gzip_path = filepath.with_name(filepath.name + '.gz')
|
||||
|
||||
return Request
|
||||
if gzip_path.is_file():
|
||||
filepath = gzip_path
|
||||
gzip = True
|
||||
|
||||
st = filepath.stat()
|
||||
|
||||
modsince = request.if_modified_since
|
||||
if modsince is not None and st.st_mtime <= modsince.timestamp():
|
||||
raise HTTPNotModified()
|
||||
|
||||
ct, encoding = mimetypes.guess_type(str(filepath))
|
||||
if not ct:
|
||||
ct = 'application/octet-stream'
|
||||
|
||||
resp = self._response_factory()
|
||||
resp.content_type = ct
|
||||
if encoding:
|
||||
resp.headers[hdrs.CONTENT_ENCODING] = encoding
|
||||
if gzip:
|
||||
resp.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
|
||||
resp.last_modified = st.st_mtime
|
||||
|
||||
# CACHE HACK
|
||||
if not self.development:
|
||||
cache_time = 31 * 86400 # = 1 month
|
||||
resp.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format(
|
||||
cache_time)
|
||||
|
||||
file_size = st.st_size
|
||||
|
||||
resp.content_length = file_size
|
||||
resp.set_tcp_cork(True)
|
||||
try:
|
||||
with filepath.open('rb') as f:
|
||||
yield from self._sendfile(request, resp, f, file_size)
|
||||
|
||||
finally:
|
||||
resp.set_tcp_nodelay(True)
|
||||
|
||||
return resp
|
||||
|
||||
_GZIP_FILE_SENDER = GzipFileSender()
|
||||
|
||||
|
||||
def routing_map(hass):
|
||||
"""Generate empty routing map with HA validators."""
|
||||
from werkzeug.routing import Map, BaseConverter, ValidationError
|
||||
class HAStaticRoute(StaticRoute):
|
||||
"""StaticRoute with support for fingerprinting."""
|
||||
|
||||
class EntityValidator(BaseConverter):
|
||||
"""Validate entity_id in urls."""
|
||||
def __init__(self, prefix, path):
|
||||
"""Initialize a static route with gzip and cache busting support."""
|
||||
super().__init__(None, prefix, path)
|
||||
self._file_sender = _GZIP_FILE_SENDER
|
||||
|
||||
regex = r"(\w+)\.(\w+)"
|
||||
def match(self, path):
|
||||
"""Match path to filename."""
|
||||
if not path.startswith(self._prefix):
|
||||
return None
|
||||
|
||||
def __init__(self, url_map, exist=True, domain=None):
|
||||
"""Initilalize entity validator."""
|
||||
super().__init__(url_map)
|
||||
self._exist = exist
|
||||
self._domain = domain
|
||||
# Extra sauce to remove fingerprinted resource names
|
||||
filename = path[self._prefix_len:]
|
||||
fingerprinted = _FINGERPRINT.match(filename)
|
||||
if fingerprinted:
|
||||
filename = '{}.{}'.format(*fingerprinted.groups())
|
||||
|
||||
def to_python(self, value):
|
||||
"""Validate entity id."""
|
||||
if self._exist and hass.states.get(value) is None:
|
||||
raise ValidationError()
|
||||
if self._domain is not None and \
|
||||
split_entity_id(value)[0] != self._domain:
|
||||
raise ValidationError()
|
||||
|
||||
return value
|
||||
|
||||
def to_url(self, value):
|
||||
"""Convert entity_id for a url."""
|
||||
return value
|
||||
|
||||
class DateValidator(BaseConverter):
|
||||
"""Validate dates in urls."""
|
||||
|
||||
regex = r'\d{4}-\d{1,2}-\d{1,2}'
|
||||
|
||||
def to_python(self, value):
|
||||
"""Validate and convert date."""
|
||||
parsed = dt_util.parse_date(value)
|
||||
|
||||
if parsed is None:
|
||||
raise ValidationError()
|
||||
|
||||
return parsed
|
||||
|
||||
def to_url(self, value):
|
||||
"""Convert date to url value."""
|
||||
return value.isoformat()
|
||||
|
||||
class DateTimeValidator(BaseConverter):
|
||||
"""Validate datetimes in urls formatted per ISO 8601."""
|
||||
|
||||
regex = r'\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d' \
|
||||
r'\.\d+([+-][0-2]\d:[0-5]\d|Z)'
|
||||
|
||||
def to_python(self, value):
|
||||
"""Validate and convert date."""
|
||||
parsed = dt_util.parse_datetime(value)
|
||||
|
||||
if parsed is None:
|
||||
raise ValidationError()
|
||||
|
||||
return parsed
|
||||
|
||||
def to_url(self, value):
|
||||
"""Convert date to url value."""
|
||||
return value.isoformat()
|
||||
|
||||
return Map(converters={
|
||||
'entity': EntityValidator,
|
||||
'date': DateValidator,
|
||||
'datetime': DateTimeValidator,
|
||||
})
|
||||
return {'filename': filename}
|
||||
|
||||
|
||||
class HomeAssistantWSGI(object):
|
||||
@ -262,28 +253,35 @@ class HomeAssistantWSGI(object):
|
||||
def __init__(self, hass, development, api_password, ssl_certificate,
|
||||
ssl_key, server_host, server_port, cors_origins,
|
||||
trusted_networks):
|
||||
"""Initilalize the WSGI Home Assistant server."""
|
||||
from werkzeug.wrappers import Response
|
||||
"""Initialize the WSGI Home Assistant server."""
|
||||
import aiohttp_cors
|
||||
|
||||
Response.mimetype = 'text/html'
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
self.Request = request_class()
|
||||
self.url_map = routing_map(hass)
|
||||
self.views = {}
|
||||
self.app = web.Application(loop=hass.loop)
|
||||
self.hass = hass
|
||||
self.extra_apps = {}
|
||||
self.development = development
|
||||
self.api_password = api_password
|
||||
self.ssl_certificate = ssl_certificate
|
||||
self.ssl_key = ssl_key
|
||||
self.server_host = server_host
|
||||
self.server_port = server_port
|
||||
self.cors_origins = cors_origins
|
||||
self.trusted_networks = trusted_networks
|
||||
self.event_forwarder = None
|
||||
self._handler = None
|
||||
self.server = None
|
||||
|
||||
if cors_origins:
|
||||
self.cors = aiohttp_cors.setup(self.app, defaults={
|
||||
host: aiohttp_cors.ResourceOptions(
|
||||
allow_headers=ALLOWED_CORS_HEADERS,
|
||||
allow_methods='*',
|
||||
) for host in cors_origins
|
||||
})
|
||||
else:
|
||||
self.cors = None
|
||||
|
||||
# CACHE HACK
|
||||
_GZIP_FILE_SENDER.development = development
|
||||
|
||||
def register_view(self, view):
|
||||
"""Register a view with the WSGI server.
|
||||
|
||||
@ -291,21 +289,11 @@ class HomeAssistantWSGI(object):
|
||||
It is optional to instantiate it before registering; this method will
|
||||
handle it either way.
|
||||
"""
|
||||
from werkzeug.routing import Rule
|
||||
|
||||
if view.name in self.views:
|
||||
_LOGGER.warning("View '%s' is being overwritten", view.name)
|
||||
if isinstance(view, type):
|
||||
# Instantiate the view, if needed
|
||||
view = view(self.hass)
|
||||
|
||||
self.views[view.name] = view
|
||||
|
||||
rule = Rule(view.url, endpoint=view.name)
|
||||
self.url_map.add(rule)
|
||||
for url in view.extra_urls:
|
||||
rule = Rule(url, endpoint=view.name)
|
||||
self.url_map.add(rule)
|
||||
view.register(self.app.router)
|
||||
|
||||
def register_redirect(self, url, redirect_to):
|
||||
"""Register a redirect with the server.
|
||||
@ -316,149 +304,92 @@ class HomeAssistantWSGI(object):
|
||||
for the redirect, otherwise it has to be a string with placeholders in
|
||||
rule syntax.
|
||||
"""
|
||||
from werkzeug.routing import Rule
|
||||
def redirect(request):
|
||||
"""Redirect to location."""
|
||||
raise HTTPMovedPermanently(redirect_to)
|
||||
|
||||
self.url_map.add(Rule(url, redirect_to=redirect_to))
|
||||
self.app.router.add_route('GET', url, redirect)
|
||||
|
||||
def register_static_path(self, url_root, path, cache_length=31):
|
||||
"""Register a folder to serve as a static path.
|
||||
|
||||
Specify optional cache length of asset in days.
|
||||
"""
|
||||
from static import Cling
|
||||
if os.path.isdir(path):
|
||||
assert url_root.startswith('/')
|
||||
if not url_root.endswith('/'):
|
||||
url_root += '/'
|
||||
route = HAStaticRoute(url_root, path)
|
||||
self.app.router.register_route(route)
|
||||
return
|
||||
|
||||
headers = []
|
||||
filepath = Path(path)
|
||||
|
||||
if cache_length and not self.development:
|
||||
# 1 year in seconds
|
||||
cache_time = cache_length * 86400
|
||||
@asyncio.coroutine
|
||||
def serve_file(request):
|
||||
"""Redirect to location."""
|
||||
return _GZIP_FILE_SENDER.send(request, filepath)
|
||||
|
||||
headers.append({
|
||||
'prefix': '',
|
||||
HTTP_HEADER_CACHE_CONTROL:
|
||||
"public, max-age={}".format(cache_time)
|
||||
})
|
||||
# aiohttp supports regex matching for variables. Using that as temp
|
||||
# to work around cache busting MD5.
|
||||
# Turns something like /static/dev-panel.html into
|
||||
# /static/{filename:dev-panel(-[a-z0-9]{32}|)\.html}
|
||||
base, ext = url_root.rsplit('.', 1)
|
||||
base, file = base.rsplit('/', 1)
|
||||
regex = r"{}(-[a-z0-9]{{32}}|)\.{}".format(file, ext)
|
||||
url_pattern = "{}/{{filename:{}}}".format(base, regex)
|
||||
|
||||
self.register_wsgi_app(url_root, Cling(path, headers=headers))
|
||||
|
||||
def register_wsgi_app(self, url_root, app):
|
||||
"""Register a path to serve a WSGI app."""
|
||||
if url_root in self.extra_apps:
|
||||
_LOGGER.warning("Url root '%s' is being overwritten", url_root)
|
||||
|
||||
self.extra_apps[url_root] = app
|
||||
self.app.router.add_route('GET', url_pattern, serve_file)
|
||||
|
||||
@asyncio.coroutine
|
||||
def start(self):
|
||||
"""Start the wsgi server."""
|
||||
from cherrypy import wsgiserver
|
||||
from cherrypy.wsgiserver.ssl_builtin import BuiltinSSLAdapter
|
||||
|
||||
# pylint: disable=too-few-public-methods,super-init-not-called
|
||||
class ContextSSLAdapter(BuiltinSSLAdapter):
|
||||
"""SSL Adapter that takes in an SSL context."""
|
||||
|
||||
def __init__(self, context):
|
||||
self.context = context
|
||||
|
||||
# pylint: disable=no-member
|
||||
self.server = wsgiserver.CherryPyWSGIServer(
|
||||
(self.server_host, self.server_port), self,
|
||||
server_name='Home Assistant')
|
||||
if self.cors is not None:
|
||||
for route in list(self.app.router.routes()):
|
||||
self.cors.add(route)
|
||||
|
||||
if self.ssl_certificate:
|
||||
context = ssl.SSLContext(SSL_VERSION)
|
||||
context.options |= SSL_OPTS
|
||||
context.set_ciphers(CIPHERS)
|
||||
context.load_cert_chain(self.ssl_certificate, self.ssl_key)
|
||||
self.server.ssl_adapter = ContextSSLAdapter(context)
|
||||
else:
|
||||
context = None
|
||||
|
||||
threading.Thread(
|
||||
target=self.server.start, daemon=True, name='WSGI-server').start()
|
||||
self._handler = self.app.make_handler()
|
||||
self.server = yield from self.hass.loop.create_server(
|
||||
self._handler, self.server_host, self.server_port, ssl=context)
|
||||
|
||||
@asyncio.coroutine
|
||||
def stop(self):
|
||||
"""Stop the wsgi server."""
|
||||
self.server.stop()
|
||||
|
||||
def dispatch_request(self, request):
|
||||
"""Handle incoming request."""
|
||||
from werkzeug.exceptions import (
|
||||
MethodNotAllowed, NotFound, BadRequest, Unauthorized,
|
||||
)
|
||||
from werkzeug.routing import RequestRedirect
|
||||
|
||||
with request:
|
||||
adapter = self.url_map.bind_to_environ(request.environ)
|
||||
try:
|
||||
endpoint, values = adapter.match()
|
||||
return self.views[endpoint].handle_request(request, **values)
|
||||
except RequestRedirect as ex:
|
||||
return ex
|
||||
except (BadRequest, NotFound, MethodNotAllowed,
|
||||
Unauthorized) as ex:
|
||||
resp = ex.get_response(request.environ)
|
||||
if request.accept_mimetypes.accept_json:
|
||||
resp.data = json.dumps({
|
||||
'result': 'error',
|
||||
'message': str(ex),
|
||||
})
|
||||
resp.mimetype = CONTENT_TYPE_JSON
|
||||
return resp
|
||||
|
||||
def base_app(self, environ, start_response):
|
||||
"""WSGI Handler of requests to base app."""
|
||||
request = self.Request(environ)
|
||||
response = self.dispatch_request(request)
|
||||
|
||||
if self.cors_origins:
|
||||
cors_check = (environ.get('HTTP_ORIGIN') in self.cors_origins)
|
||||
cors_headers = ", ".join(ALLOWED_CORS_HEADERS)
|
||||
if cors_check:
|
||||
response.headers[HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN] = \
|
||||
environ.get('HTTP_ORIGIN')
|
||||
response.headers[HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS] = \
|
||||
cors_headers
|
||||
|
||||
return response(environ, start_response)
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
"""Handle a request for base app + extra apps."""
|
||||
from werkzeug.wsgi import DispatcherMiddleware
|
||||
|
||||
if not self.hass.is_running:
|
||||
from werkzeug.exceptions import BadRequest
|
||||
return BadRequest()(environ, start_response)
|
||||
|
||||
app = DispatcherMiddleware(self.base_app, self.extra_apps)
|
||||
# Strip out any cachebusting MD5 fingerprints
|
||||
fingerprinted = _FINGERPRINT.match(environ.get('PATH_INFO', ''))
|
||||
if fingerprinted:
|
||||
environ['PATH_INFO'] = '{}.{}'.format(*fingerprinted.groups())
|
||||
return app(environ, start_response)
|
||||
self.server.close()
|
||||
yield from self.server.wait_closed()
|
||||
yield from self.app.shutdown()
|
||||
yield from self._handler.finish_connections(60.0)
|
||||
yield from self.app.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def get_real_ip(request):
|
||||
"""Return the clients correct ip address, even in proxied setups."""
|
||||
if request.access_route:
|
||||
return request.access_route[-1]
|
||||
else:
|
||||
return request.remote_addr
|
||||
peername = request.transport.get_extra_info('peername')
|
||||
return peername[0] if peername is not None else None
|
||||
|
||||
def is_trusted_ip(self, remote_addr):
|
||||
"""Match an ip address against trusted CIDR networks."""
|
||||
return any(ip_address(remote_addr) in trusted_network
|
||||
for trusted_network in self.hass.wsgi.trusted_networks)
|
||||
for trusted_network in self.hass.http.trusted_networks)
|
||||
|
||||
|
||||
class HomeAssistantView(object):
|
||||
"""Base view for all views."""
|
||||
|
||||
url = None
|
||||
extra_urls = []
|
||||
requires_auth = True # Views inheriting from this class can override this
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initilalize the base view."""
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
if not hasattr(self, 'url'):
|
||||
class_name = self.__class__.__name__
|
||||
raise AttributeError(
|
||||
@ -472,59 +403,99 @@ class HomeAssistantView(object):
|
||||
)
|
||||
|
||||
self.hass = hass
|
||||
# pylint: disable=invalid-name
|
||||
self.Response = Response
|
||||
|
||||
def handle_request(self, request, **values):
|
||||
"""Handle request to url."""
|
||||
from werkzeug.exceptions import MethodNotAllowed, Unauthorized
|
||||
def json(self, result, status_code=200): # pylint: disable=no-self-use
|
||||
"""Return a JSON response."""
|
||||
msg = json.dumps(
|
||||
result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8')
|
||||
return web.Response(
|
||||
body=msg, content_type=CONTENT_TYPE_JSON, status=status_code)
|
||||
|
||||
if request.method == "OPTIONS":
|
||||
# For CORS preflight requests.
|
||||
return self.options(request)
|
||||
def json_message(self, error, status_code=200):
|
||||
"""Return a JSON message response."""
|
||||
return self.json({'message': error}, status_code)
|
||||
|
||||
try:
|
||||
handler = getattr(self, request.method.lower())
|
||||
except AttributeError:
|
||||
raise MethodNotAllowed
|
||||
@asyncio.coroutine
|
||||
def file(self, request, fil): # pylint: disable=no-self-use
|
||||
"""Return a file."""
|
||||
assert isinstance(fil, str), 'only string paths allowed'
|
||||
response = yield from _GZIP_FILE_SENDER.send(request, Path(fil))
|
||||
return response
|
||||
|
||||
def register(self, router):
|
||||
"""Register the view with a router."""
|
||||
assert self.url is not None, 'No url set for view'
|
||||
urls = [self.url] + self.extra_urls
|
||||
|
||||
for method in ('get', 'post', 'delete', 'put'):
|
||||
handler = getattr(self, method, None)
|
||||
|
||||
if not handler:
|
||||
continue
|
||||
|
||||
handler = request_handler_factory(self, handler)
|
||||
|
||||
for url in urls:
|
||||
router.add_route(method, url, handler)
|
||||
|
||||
# aiohttp_cors does not work with class based views
|
||||
# self.app.router.add_route('*', self.url, self, name=self.name)
|
||||
|
||||
# for url in self.extra_urls:
|
||||
# self.app.router.add_route('*', url, self)
|
||||
|
||||
|
||||
def request_handler_factory(view, handler):
|
||||
"""Factory to wrap our handler classes.
|
||||
|
||||
Eventually authentication should be managed by middleware.
|
||||
"""
|
||||
@asyncio.coroutine
|
||||
def handle(request):
|
||||
"""Handle incoming request."""
|
||||
remote_addr = HomeAssistantWSGI.get_real_ip(request)
|
||||
|
||||
# Auth code verbose on purpose
|
||||
authenticated = False
|
||||
|
||||
if self.hass.wsgi.api_password is None:
|
||||
if view.hass.http.api_password is None:
|
||||
authenticated = True
|
||||
|
||||
elif self.hass.wsgi.is_trusted_ip(remote_addr):
|
||||
elif view.hass.http.is_trusted_ip(remote_addr):
|
||||
authenticated = True
|
||||
|
||||
elif hmac.compare_digest(request.headers.get(HTTP_HEADER_HA_AUTH, ''),
|
||||
self.hass.wsgi.api_password):
|
||||
view.hass.http.api_password):
|
||||
# A valid auth header has been set
|
||||
authenticated = True
|
||||
|
||||
elif hmac.compare_digest(request.args.get(DATA_API_PASSWORD, ''),
|
||||
self.hass.wsgi.api_password):
|
||||
elif hmac.compare_digest(request.GET.get(DATA_API_PASSWORD, ''),
|
||||
view.hass.http.api_password):
|
||||
authenticated = True
|
||||
|
||||
if self.requires_auth and not authenticated:
|
||||
if view.requires_auth and not authenticated:
|
||||
_LOGGER.warning('Login attempt or request with an invalid '
|
||||
'password from %s', remote_addr)
|
||||
persistent_notification.create(
|
||||
self.hass,
|
||||
persistent_notification.async_create(
|
||||
view.hass,
|
||||
'Invalid password used from {}'.format(remote_addr),
|
||||
'Login attempt failed', NOTIFICATION_ID_LOGIN)
|
||||
raise Unauthorized()
|
||||
raise HTTPUnauthorized()
|
||||
|
||||
request.authenticated = authenticated
|
||||
|
||||
_LOGGER.info('Serving %s to %s (auth: %s)',
|
||||
request.path, remote_addr, authenticated)
|
||||
|
||||
result = handler(request, **values)
|
||||
assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \
|
||||
"Handler should be a coroutine or a callback."
|
||||
|
||||
if isinstance(result, self.Response):
|
||||
result = handler(request, **request.match_info)
|
||||
|
||||
if asyncio.iscoroutine(result):
|
||||
result = yield from result
|
||||
|
||||
if isinstance(result, web.StreamResponse):
|
||||
# The method handler returned a ready-made Response, how nice of it
|
||||
return result
|
||||
|
||||
@ -533,36 +504,14 @@ class HomeAssistantView(object):
|
||||
if isinstance(result, tuple):
|
||||
result, status_code = result
|
||||
|
||||
return self.Response(result, status=status_code)
|
||||
if isinstance(result, str):
|
||||
result = result.encode('utf-8')
|
||||
elif result is None:
|
||||
result = b''
|
||||
elif not isinstance(result, bytes):
|
||||
assert False, ('Result should be None, string, bytes or Response. '
|
||||
'Got: {}').format(result)
|
||||
|
||||
def json(self, result, status_code=200):
|
||||
"""Return a JSON response."""
|
||||
msg = json.dumps(
|
||||
result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8')
|
||||
return self.Response(
|
||||
msg, mimetype=CONTENT_TYPE_JSON, status=status_code)
|
||||
return web.Response(body=result, status=status_code)
|
||||
|
||||
def json_message(self, error, status_code=200):
|
||||
"""Return a JSON message response."""
|
||||
return self.json({'message': error}, status_code)
|
||||
|
||||
def file(self, request, fil, mimetype=None):
|
||||
"""Return a file."""
|
||||
from werkzeug.wsgi import wrap_file
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
if isinstance(fil, str):
|
||||
if mimetype is None:
|
||||
mimetype = mimetypes.guess_type(fil)[0]
|
||||
|
||||
try:
|
||||
fil = open(fil, mode='br')
|
||||
except IOError:
|
||||
raise NotFound()
|
||||
|
||||
return self.Response(wrap_file(request.environ, fil),
|
||||
mimetype=mimetype, direct_passthrough=True)
|
||||
|
||||
def options(self, request):
|
||||
"""Default handler for OPTIONS (necessary for CORS preflight)."""
|
||||
return self.Response('', status=200)
|
||||
return handle
|
||||
|
@ -247,7 +247,7 @@ def setup(hass, config):
|
||||
|
||||
discovery.load_platform(hass, "sensor", DOMAIN, {}, config)
|
||||
|
||||
hass.wsgi.register_view(iOSIdentifyDeviceView(hass))
|
||||
hass.http.register_view(iOSIdentifyDeviceView(hass))
|
||||
|
||||
app_config = config.get(DOMAIN, {})
|
||||
hass.wsgi.register_view(iOSPushConfigView(hass,
|
||||
|
@ -11,6 +11,7 @@ from itertools import groupby
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.components import recorder, sun
|
||||
@ -19,7 +20,7 @@ from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.const import (EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED,
|
||||
STATE_NOT_HOME, STATE_OFF, STATE_ON,
|
||||
ATTR_HIDDEN)
|
||||
ATTR_HIDDEN, HTTP_BAD_REQUEST)
|
||||
from homeassistant.core import State, split_entity_id, DOMAIN as HA_DOMAIN
|
||||
from homeassistant.util.async import run_callback_threadsafe
|
||||
|
||||
@ -88,7 +89,7 @@ def async_log_entry(hass, name, message, domain=None, entity_id=None):
|
||||
|
||||
def setup(hass, config):
|
||||
"""Listen for download events to download files."""
|
||||
@asyncio.coroutine
|
||||
@callback
|
||||
def log_message(service):
|
||||
"""Handle sending notification message service calls."""
|
||||
message = service.data[ATTR_MESSAGE]
|
||||
@ -100,7 +101,7 @@ def setup(hass, config):
|
||||
message = message.async_render()
|
||||
async_log_entry(hass, name, message, domain, entity_id)
|
||||
|
||||
hass.wsgi.register_view(LogbookView(hass, config))
|
||||
hass.http.register_view(LogbookView(hass, config))
|
||||
|
||||
register_built_in_panel(hass, 'logbook', 'Logbook',
|
||||
'mdi:format-list-bulleted-type')
|
||||
@ -115,24 +116,37 @@ class LogbookView(HomeAssistantView):
|
||||
|
||||
url = '/api/logbook'
|
||||
name = 'api:logbook'
|
||||
extra_urls = ['/api/logbook/<datetime:datetime>']
|
||||
extra_urls = ['/api/logbook/{datetime}']
|
||||
|
||||
def __init__(self, hass, config):
|
||||
"""Initilalize the logbook view."""
|
||||
super().__init__(hass)
|
||||
self.config = config
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request, datetime=None):
|
||||
"""Retrieve logbook entries."""
|
||||
start_day = dt_util.as_utc(datetime or dt_util.start_of_local_day())
|
||||
if datetime:
|
||||
datetime = dt_util.parse_datetime(datetime)
|
||||
|
||||
if datetime is None:
|
||||
return self.json_message('Invalid datetime', HTTP_BAD_REQUEST)
|
||||
else:
|
||||
datetime = dt_util.start_of_local_day()
|
||||
|
||||
start_day = dt_util.as_utc(datetime)
|
||||
end_day = start_day + timedelta(days=1)
|
||||
|
||||
events = recorder.get_model('Events')
|
||||
query = recorder.query('Events').filter(
|
||||
(events.time_fired > start_day) &
|
||||
(events.time_fired < end_day))
|
||||
events = recorder.execute(query)
|
||||
events = _exclude_events(events, self.config)
|
||||
def get_results():
|
||||
"""Query DB for results."""
|
||||
events = recorder.get_model('Events')
|
||||
query = recorder.query('Events').filter(
|
||||
(events.time_fired > start_day) &
|
||||
(events.time_fired < end_day))
|
||||
events = recorder.execute(query)
|
||||
return _exclude_events(events, self.config)
|
||||
|
||||
events = yield from self.hass.loop.run_in_executor(None, get_results)
|
||||
|
||||
return self.json(humanify(events))
|
||||
|
||||
|
@ -4,11 +4,13 @@ Component to interface with various media players.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/media_player/
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import requests
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
@ -291,7 +293,7 @@ def setup(hass, config):
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
|
||||
|
||||
hass.wsgi.register_view(MediaPlayerImageView(hass, component.entities))
|
||||
hass.http.register_view(MediaPlayerImageView(hass, component.entities))
|
||||
|
||||
component.setup(config)
|
||||
|
||||
@ -677,7 +679,7 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
"""Media player view to serve an image."""
|
||||
|
||||
requires_auth = False
|
||||
url = "/api/media_player_proxy/<entity(domain=media_player):entity_id>"
|
||||
url = "/api/media_player_proxy/{entity_id}"
|
||||
name = "api:media_player:image"
|
||||
|
||||
def __init__(self, hass, entities):
|
||||
@ -685,26 +687,34 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
super().__init__(hass)
|
||||
self.entities = entities
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request, entity_id):
|
||||
"""Start a get request."""
|
||||
player = self.entities.get(entity_id)
|
||||
|
||||
if player is None:
|
||||
return self.Response(status=404)
|
||||
return web.Response(status=404)
|
||||
|
||||
authenticated = (request.authenticated or
|
||||
request.args.get('token') == player.access_token)
|
||||
request.GET.get('token') == player.access_token)
|
||||
|
||||
if not authenticated:
|
||||
return self.Response(status=401)
|
||||
return web.Response(status=401)
|
||||
|
||||
image_url = player.media_image_url
|
||||
if image_url:
|
||||
response = requests.get(image_url)
|
||||
else:
|
||||
response = None
|
||||
|
||||
if image_url is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
def fetch_image():
|
||||
"""Helper method to fetch image."""
|
||||
try:
|
||||
return requests.get(image_url).content
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
response = yield from self.hass.loop.run_in_executor(None, fetch_image)
|
||||
|
||||
if response is None:
|
||||
return self.Response(status=500)
|
||||
return web.Response(status=500)
|
||||
|
||||
return self.Response(response)
|
||||
return web.Response(body=response)
|
||||
|
@ -4,6 +4,7 @@ HTML5 Push Messaging notification service.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/notify.html5/
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
@ -107,9 +108,9 @@ def get_service(hass, config):
|
||||
if registrations is None:
|
||||
return None
|
||||
|
||||
hass.wsgi.register_view(
|
||||
hass.http.register_view(
|
||||
HTML5PushRegistrationView(hass, registrations, json_path))
|
||||
hass.wsgi.register_view(HTML5PushCallbackView(hass, registrations))
|
||||
hass.http.register_view(HTML5PushCallbackView(hass, registrations))
|
||||
|
||||
gcm_api_key = config.get(ATTR_GCM_API_KEY)
|
||||
gcm_sender_id = config.get(ATTR_GCM_SENDER_ID)
|
||||
@ -163,12 +164,18 @@ class HTML5PushRegistrationView(HomeAssistantView):
|
||||
self.registrations = registrations
|
||||
self.json_path = json_path
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Accept the POST request for push registrations from a browser."""
|
||||
try:
|
||||
data = REGISTER_SCHEMA(request.json)
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
data = REGISTER_SCHEMA(data)
|
||||
except vol.Invalid as ex:
|
||||
return self.json_message(humanize_error(request.json, ex),
|
||||
return self.json_message(humanize_error(data, ex),
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
name = ensure_unique_string('unnamed device',
|
||||
@ -182,9 +189,15 @@ class HTML5PushRegistrationView(HomeAssistantView):
|
||||
|
||||
return self.json_message('Push notification subscriber registered.')
|
||||
|
||||
@asyncio.coroutine
|
||||
def delete(self, request):
|
||||
"""Delete a registration."""
|
||||
subscription = request.json.get(ATTR_SUBSCRIPTION)
|
||||
try:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
||||
|
||||
subscription = data.get(ATTR_SUBSCRIPTION)
|
||||
|
||||
found = None
|
||||
|
||||
@ -270,23 +283,29 @@ class HTML5PushCallbackView(HomeAssistantView):
|
||||
status_code=HTTP_UNAUTHORIZED)
|
||||
return payload
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Accept the POST request for push registrations event callback."""
|
||||
auth_check = self.check_authorization_header(request)
|
||||
if not isinstance(auth_check, dict):
|
||||
return auth_check
|
||||
|
||||
try:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
||||
|
||||
event_payload = {
|
||||
ATTR_TAG: request.json.get(ATTR_TAG),
|
||||
ATTR_TYPE: request.json[ATTR_TYPE],
|
||||
ATTR_TAG: data.get(ATTR_TAG),
|
||||
ATTR_TYPE: data[ATTR_TYPE],
|
||||
ATTR_TARGET: auth_check[ATTR_TARGET],
|
||||
}
|
||||
|
||||
if request.json.get(ATTR_ACTION) is not None:
|
||||
event_payload[ATTR_ACTION] = request.json.get(ATTR_ACTION)
|
||||
if data.get(ATTR_ACTION) is not None:
|
||||
event_payload[ATTR_ACTION] = data.get(ATTR_ACTION)
|
||||
|
||||
if request.json.get(ATTR_DATA) is not None:
|
||||
event_payload[ATTR_DATA] = request.json.get(ATTR_DATA)
|
||||
if data.get(ATTR_DATA) is not None:
|
||||
event_payload[ATTR_DATA] = data.get(ATTR_DATA)
|
||||
|
||||
try:
|
||||
event_payload = CALLBACK_EVENT_PAYLOAD_SCHEMA(event_payload)
|
||||
|
@ -153,7 +153,7 @@ def setup(hass, config):
|
||||
# Create Alpr device / render engine
|
||||
if render == RENDER_FFMPEG:
|
||||
use_render_fffmpeg = True
|
||||
if not run_test(input_source):
|
||||
if not run_test(hass, input_source):
|
||||
_LOGGER.error("'%s' is not valid ffmpeg input", input_source)
|
||||
continue
|
||||
|
||||
|
@ -4,6 +4,7 @@ A component which is collecting configuration errors.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/persistent_notification/
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
|
||||
@ -14,6 +15,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.util.async import run_coroutine_threadsafe
|
||||
|
||||
DOMAIN = 'persistent_notification'
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
@ -35,6 +37,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create(hass, message, title=None, notification_id=None):
|
||||
"""Generate a notification."""
|
||||
run_coroutine_threadsafe(
|
||||
async_create(hass, message, title, notification_id), hass.loop
|
||||
).result()
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_create(hass, message, title=None, notification_id=None):
|
||||
"""Generate a notification."""
|
||||
data = {
|
||||
key: value for key, value in [
|
||||
@ -44,7 +54,7 @@ def create(hass, message, title=None, notification_id=None):
|
||||
] if value is not None
|
||||
}
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_CREATE, data)
|
||||
yield from hass.services.async_call(DOMAIN, SERVICE_CREATE, data)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
|
@ -12,6 +12,7 @@ import time
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||
from homeassistant.helpers.entity import Entity
|
||||
@ -273,8 +274,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
scope=['activity', 'heartrate', 'nutrition', 'profile',
|
||||
'settings', 'sleep', 'weight'])
|
||||
|
||||
hass.wsgi.register_redirect(FITBIT_AUTH_START, fitbit_auth_start_url)
|
||||
hass.wsgi.register_view(FitbitAuthCallbackView(
|
||||
hass.http.register_redirect(FITBIT_AUTH_START, fitbit_auth_start_url)
|
||||
hass.http.register_view(FitbitAuthCallbackView(
|
||||
hass, config, add_devices, oauth))
|
||||
|
||||
request_oauth_completion(hass)
|
||||
@ -294,12 +295,13 @@ class FitbitAuthCallbackView(HomeAssistantView):
|
||||
self.add_devices = add_devices
|
||||
self.oauth = oauth
|
||||
|
||||
@callback
|
||||
def get(self, request):
|
||||
"""Finish OAuth callback request."""
|
||||
from oauthlib.oauth2.rfc6749.errors import MismatchingStateError
|
||||
from oauthlib.oauth2.rfc6749.errors import MissingTokenError
|
||||
|
||||
data = request.args
|
||||
data = request.GET
|
||||
|
||||
response_message = """Fitbit has been successfully authorized!
|
||||
You can close this window now!"""
|
||||
@ -340,7 +342,8 @@ class FitbitAuthCallbackView(HomeAssistantView):
|
||||
config_contents):
|
||||
_LOGGER.error("Failed to save config file")
|
||||
|
||||
setup_platform(self.hass, self.config, self.add_devices)
|
||||
self.hass.async_add_job(setup_platform, self.hass, self.config,
|
||||
self.add_devices)
|
||||
|
||||
return html_response
|
||||
|
||||
|
@ -9,6 +9,7 @@ import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||
from homeassistant.const import (CONF_EMAIL, CONF_NAME)
|
||||
@ -57,7 +58,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
email = config.get(CONF_EMAIL)
|
||||
sensors = {}
|
||||
|
||||
hass.wsgi.register_view(TorqueReceiveDataView(
|
||||
hass.http.register_view(TorqueReceiveDataView(
|
||||
hass, email, vehicle, sensors, add_devices))
|
||||
return True
|
||||
|
||||
@ -77,9 +78,10 @@ class TorqueReceiveDataView(HomeAssistantView):
|
||||
self.sensors = sensors
|
||||
self.add_devices = add_devices
|
||||
|
||||
@callback
|
||||
def get(self, request):
|
||||
"""Handle Torque data request."""
|
||||
data = request.args
|
||||
data = request.GET
|
||||
|
||||
if self.email is not None and self.email != data[SENSOR_EMAIL_FIELD]:
|
||||
return
|
||||
@ -100,14 +102,14 @@ class TorqueReceiveDataView(HomeAssistantView):
|
||||
elif is_value:
|
||||
pid = convert_pid(is_value.group(1))
|
||||
if pid in self.sensors:
|
||||
self.sensors[pid].on_update(data[key])
|
||||
self.sensors[pid].async_on_update(data[key])
|
||||
|
||||
for pid in names:
|
||||
if pid not in self.sensors:
|
||||
self.sensors[pid] = TorqueSensor(
|
||||
ENTITY_NAME_FORMAT.format(self.vehicle, names[pid]),
|
||||
units.get(pid, None))
|
||||
self.add_devices([self.sensors[pid]])
|
||||
self.hass.async_add_job(self.add_devices, [self.sensors[pid]])
|
||||
|
||||
return None
|
||||
|
||||
@ -141,7 +143,8 @@ class TorqueSensor(Entity):
|
||||
"""Return the default icon of the sensor."""
|
||||
return 'mdi:car'
|
||||
|
||||
def on_update(self, value):
|
||||
@callback
|
||||
def async_on_update(self, value):
|
||||
"""Receive an update."""
|
||||
self._state = value
|
||||
self.update_ha_state()
|
||||
self.hass.loop.create_task(self.async_update_ha_state())
|
||||
|
@ -10,6 +10,7 @@ from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant import util
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.const import (
|
||||
@ -40,7 +41,7 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
|
||||
REQ_CONF = [CONF_HOST, CONF_OUTLETS]
|
||||
|
||||
URL_API_NETIO_EP = '/api/netio/<host>'
|
||||
URL_API_NETIO_EP = '/api/netio/{host}'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
@ -61,7 +62,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
port = config.get(CONF_PORT)
|
||||
|
||||
if len(DEVICES) == 0:
|
||||
hass.wsgi.register_view(NetioApiView)
|
||||
hass.http.register_view(NetioApiView)
|
||||
|
||||
dev = Netio(host, port, username, password)
|
||||
|
||||
@ -93,9 +94,10 @@ class NetioApiView(HomeAssistantView):
|
||||
url = URL_API_NETIO_EP
|
||||
name = 'api:netio'
|
||||
|
||||
@callback
|
||||
def get(self, request, host):
|
||||
"""Request handler."""
|
||||
data = request.args
|
||||
data = request.GET
|
||||
states, consumptions, cumulated_consumptions, start_dates = \
|
||||
[], [], [], []
|
||||
|
||||
@ -117,7 +119,7 @@ class NetioApiView(HomeAssistantView):
|
||||
ndev.start_dates = start_dates
|
||||
|
||||
for dev in DEVICES[host].entities:
|
||||
dev.update_ha_state()
|
||||
self.hass.loop.create_task(dev.async_update_ha_state())
|
||||
|
||||
return self.json(True)
|
||||
|
||||
|
@ -83,12 +83,14 @@ SERVICE_TO_STATE = {
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods, attribute-defined-outside-init
|
||||
class TrackStates(object):
|
||||
class AsyncTrackStates(object):
|
||||
"""
|
||||
Record the time when the with-block is entered.
|
||||
|
||||
Add all states that have changed since the start time to the return list
|
||||
when with-block is exited.
|
||||
|
||||
Must be run within the event loop.
|
||||
"""
|
||||
|
||||
def __init__(self, hass):
|
||||
@ -103,7 +105,8 @@ class TrackStates(object):
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
"""Add changes states to changes list."""
|
||||
self.states.extend(get_changed_since(self.hass.states.all(), self.now))
|
||||
self.states.extend(get_changed_since(self.hass.states.async_all(),
|
||||
self.now))
|
||||
|
||||
|
||||
def get_changed_since(states, utc_point_in_time):
|
||||
|
@ -213,35 +213,35 @@ class EventForwarder(object):
|
||||
self._targets = {}
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._unsub_listener = None
|
||||
self._async_unsub_listener = None
|
||||
|
||||
def connect(self, api):
|
||||
@ha.callback
|
||||
def async_connect(self, api):
|
||||
"""Attach to a Home Assistant instance and forward events.
|
||||
|
||||
Will overwrite old target if one exists with same host/port.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._unsub_listener is None:
|
||||
self._unsub_listener = self.hass.bus.listen(
|
||||
ha.MATCH_ALL, self._event_listener)
|
||||
if self._async_unsub_listener is None:
|
||||
self._async_unsub_listener = self.hass.bus.async_listen(
|
||||
ha.MATCH_ALL, self._event_listener)
|
||||
|
||||
key = (api.host, api.port)
|
||||
key = (api.host, api.port)
|
||||
|
||||
self._targets[key] = api
|
||||
self._targets[key] = api
|
||||
|
||||
def disconnect(self, api):
|
||||
@ha.callback
|
||||
def async_disconnect(self, api):
|
||||
"""Remove target from being forwarded to."""
|
||||
with self._lock:
|
||||
key = (api.host, api.port)
|
||||
key = (api.host, api.port)
|
||||
|
||||
did_remove = self._targets.pop(key, None) is None
|
||||
did_remove = self._targets.pop(key, None) is None
|
||||
|
||||
if len(self._targets) == 0:
|
||||
# Remove event listener if no forwarding targets present
|
||||
self._unsub_listener()
|
||||
self._unsub_listener = None
|
||||
if len(self._targets) == 0:
|
||||
# Remove event listener if no forwarding targets present
|
||||
self._async_unsub_listener()
|
||||
self._async_unsub_listener = None
|
||||
|
||||
return did_remove
|
||||
return did_remove
|
||||
|
||||
def _event_listener(self, event):
|
||||
"""Listen and forward all events."""
|
||||
|
@ -6,6 +6,8 @@ pip>=7.0.0
|
||||
jinja2>=2.8
|
||||
voluptuous==0.9.2
|
||||
typing>=3,<4
|
||||
aiohttp==1.0.5
|
||||
async_timeout==1.0.0
|
||||
|
||||
# homeassistant.components.nuimo_controller
|
||||
--only-binary=all git+https://github.com/getSenic/nuimo-linux-python#nuimo==1.0.0
|
||||
@ -28,9 +30,8 @@ SoCo==0.12
|
||||
# homeassistant.components.notify.twitter
|
||||
TwitterAPI==2.4.2
|
||||
|
||||
# homeassistant.components.emulated_hue
|
||||
# homeassistant.components.http
|
||||
Werkzeug==0.11.11
|
||||
aiohttp_cors==0.4.0
|
||||
|
||||
# homeassistant.components.apcupsd
|
||||
apcaccess==0.0.4
|
||||
@ -62,10 +63,6 @@ blockchain==1.3.3
|
||||
# homeassistant.components.notify.aws_sqs
|
||||
boto3==1.3.1
|
||||
|
||||
# homeassistant.components.emulated_hue
|
||||
# homeassistant.components.http
|
||||
cherrypy==8.1.2
|
||||
|
||||
# homeassistant.components.sensor.coinmarketcap
|
||||
coinmarketcap==2.0.1
|
||||
|
||||
@ -136,7 +133,7 @@ gps3==0.33.3
|
||||
ha-alpr==0.3
|
||||
|
||||
# homeassistant.components.ffmpeg
|
||||
ha-ffmpeg==0.13
|
||||
ha-ffmpeg==0.14
|
||||
|
||||
# homeassistant.components.mqtt.server
|
||||
hbmqtt==0.7.1
|
||||
@ -483,10 +480,6 @@ speedtest-cli==0.3.4
|
||||
# homeassistant.scripts.db_migrator
|
||||
sqlalchemy==1.1.1
|
||||
|
||||
# homeassistant.components.emulated_hue
|
||||
# homeassistant.components.http
|
||||
static3==0.7.0
|
||||
|
||||
# homeassistant.components.statsd
|
||||
statsd==3.2.1
|
||||
|
||||
|
@ -2,6 +2,7 @@ flake8>=3.0.4
|
||||
pylint>=1.5.6
|
||||
coveralls>=1.1
|
||||
pytest>=2.9.2
|
||||
pytest-aiohttp>=0.1.3
|
||||
pytest-asyncio>=0.5.0
|
||||
pytest-cov>=2.3.1
|
||||
pytest-timeout>=1.0.0
|
||||
@ -9,3 +10,4 @@ pytest-catchlog>=1.2.2
|
||||
pydocstyle>=1.0.0
|
||||
requests_mock>=1.0
|
||||
mypy-lang>=0.4
|
||||
mock-open>=1.3.1
|
||||
|
2
setup.py
2
setup.py
@ -21,6 +21,8 @@ REQUIRES = [
|
||||
'jinja2>=2.8',
|
||||
'voluptuous==0.9.2',
|
||||
'typing>=3,<4',
|
||||
'aiohttp==1.0.5',
|
||||
'async_timeout==1.0.0',
|
||||
]
|
||||
|
||||
setup(
|
||||
|
@ -1,29 +1 @@
|
||||
"""Setup some common test helper things."""
|
||||
import functools
|
||||
import logging
|
||||
|
||||
from homeassistant import util
|
||||
from homeassistant.util import location
|
||||
|
||||
logging.basicConfig()
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
|
||||
|
||||
|
||||
def test_real(func):
|
||||
"""Force a function to require a keyword _test_real to be passed in."""
|
||||
@functools.wraps(func)
|
||||
def guard_func(*args, **kwargs):
|
||||
real = kwargs.pop('_test_real', None)
|
||||
|
||||
if not real:
|
||||
raise Exception('Forgot to mock or pass "_test_real=True" to %s',
|
||||
func.__name__)
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return guard_func
|
||||
|
||||
# Guard a few functions that would make network connections
|
||||
location.detect_location_info = test_real(location.detect_location_info)
|
||||
location.elevation = test_real(location.elevation)
|
||||
util.get_local_ip = lambda: '127.0.0.1'
|
||||
"""Tests for Home Assistant."""
|
||||
|
@ -38,23 +38,11 @@ def get_test_home_assistant(num_threads=None):
|
||||
orig_num_threads = ha.MIN_WORKER_THREAD
|
||||
ha.MIN_WORKER_THREAD = num_threads
|
||||
|
||||
hass = ha.HomeAssistant(loop)
|
||||
hass = loop.run_until_complete(async_test_home_assistant(loop))
|
||||
|
||||
if num_threads:
|
||||
ha.MIN_WORKER_THREAD = orig_num_threads
|
||||
|
||||
hass.config.location_name = 'test home'
|
||||
hass.config.config_dir = get_test_config_dir()
|
||||
hass.config.latitude = 32.87336
|
||||
hass.config.longitude = -117.22743
|
||||
hass.config.elevation = 0
|
||||
hass.config.time_zone = date_util.get_time_zone('US/Pacific')
|
||||
hass.config.units = METRIC_SYSTEM
|
||||
hass.config.skip_pip = True
|
||||
|
||||
if 'custom_components.test' not in loader.AVAILABLE_COMPONENTS:
|
||||
loader.prepare(hass)
|
||||
|
||||
# FIXME should not be a daemon. Means hass.stop() not called in teardown
|
||||
stop_event = threading.Event()
|
||||
|
||||
@ -98,6 +86,35 @@ def get_test_home_assistant(num_threads=None):
|
||||
return hass
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_test_home_assistant(loop):
|
||||
"""Return a Home Assistant object pointing at test config dir."""
|
||||
loop._thread_ident = threading.get_ident()
|
||||
|
||||
def get_hass():
|
||||
"""Temp while we migrate core HASS over to be async constructors."""
|
||||
hass = ha.HomeAssistant(loop)
|
||||
|
||||
hass.config.location_name = 'test home'
|
||||
hass.config.config_dir = get_test_config_dir()
|
||||
hass.config.latitude = 32.87336
|
||||
hass.config.longitude = -117.22743
|
||||
hass.config.elevation = 0
|
||||
hass.config.time_zone = date_util.get_time_zone('US/Pacific')
|
||||
hass.config.units = METRIC_SYSTEM
|
||||
hass.config.skip_pip = True
|
||||
|
||||
if 'custom_components.test' not in loader.AVAILABLE_COMPONENTS:
|
||||
loader.prepare(hass)
|
||||
|
||||
hass.state = ha.CoreState.running
|
||||
return hass
|
||||
|
||||
hass = yield from loop.run_in_executor(None, get_hass)
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
def get_test_instance_port():
|
||||
"""Return unused port for running test instance.
|
||||
|
||||
@ -181,8 +198,19 @@ def mock_state_change_event(hass, new_state, old_state=None):
|
||||
|
||||
def mock_http_component(hass):
|
||||
"""Mock the HTTP component."""
|
||||
hass.wsgi = mock.MagicMock()
|
||||
hass.http = mock.MagicMock()
|
||||
hass.config.components.append('http')
|
||||
hass.http.views = {}
|
||||
|
||||
def mock_register_view(view):
|
||||
"""Store registered view."""
|
||||
if isinstance(view, type):
|
||||
# Instantiate the view, if needed
|
||||
view = view(hass)
|
||||
|
||||
hass.http.views[view.name] = view
|
||||
|
||||
hass.http.register_view = mock_register_view
|
||||
|
||||
|
||||
def mock_mqtt_component(hass):
|
||||
|
@ -1,36 +1,18 @@
|
||||
"""The tests for generic camera component."""
|
||||
import unittest
|
||||
import asyncio
|
||||
from unittest import mock
|
||||
|
||||
import requests_mock
|
||||
from werkzeug.test import EnvironBuilder
|
||||
|
||||
from homeassistant.bootstrap import setup_component
|
||||
from homeassistant.components.http import request_class
|
||||
|
||||
from tests.common import get_test_home_assistant
|
||||
|
||||
|
||||
class TestGenericCamera(unittest.TestCase):
|
||||
"""Test the generic camera platform."""
|
||||
@asyncio.coroutine
|
||||
def test_fetching_url(aioclient_mock, hass, test_client):
|
||||
"""Test that it fetches the given url."""
|
||||
aioclient_mock.get('http://example.com', text='hello world')
|
||||
|
||||
def setUp(self):
|
||||
"""Setup things to be run when tests are started."""
|
||||
self.hass = get_test_home_assistant()
|
||||
self.hass.wsgi = mock.MagicMock()
|
||||
self.hass.config.components.append('http')
|
||||
|
||||
def tearDown(self):
|
||||
"""Stop everything that was started."""
|
||||
self.hass.stop()
|
||||
|
||||
@requests_mock.Mocker()
|
||||
def test_fetching_url(self, m):
|
||||
"""Test that it fetches the given url."""
|
||||
self.hass.wsgi = mock.MagicMock()
|
||||
m.get('http://example.com', text='hello world')
|
||||
|
||||
assert setup_component(self.hass, 'camera', {
|
||||
def setup_platform():
|
||||
"""Setup the platform."""
|
||||
assert setup_component(hass, 'camera', {
|
||||
'camera': {
|
||||
'name': 'config_test',
|
||||
'platform': 'generic',
|
||||
@ -39,32 +21,32 @@ class TestGenericCamera(unittest.TestCase):
|
||||
'password': 'pass'
|
||||
}})
|
||||
|
||||
image_view = self.hass.wsgi.mock_calls[0][1][0]
|
||||
yield from hass.loop.run_in_executor(None, setup_platform)
|
||||
|
||||
builder = EnvironBuilder(method='GET')
|
||||
Request = request_class()
|
||||
request = Request(builder.get_environ())
|
||||
request.authenticated = True
|
||||
resp = image_view.get(request, 'camera.config_test')
|
||||
client = yield from test_client(hass.http.app)
|
||||
|
||||
assert m.call_count == 1
|
||||
assert resp.status_code == 200, resp.response
|
||||
assert resp.response[0].decode('utf-8') == 'hello world'
|
||||
resp = yield from client.get('/api/camera_proxy/camera.config_test')
|
||||
|
||||
image_view.get(request, 'camera.config_test')
|
||||
assert m.call_count == 2
|
||||
assert aioclient_mock.call_count == 1
|
||||
assert resp.status == 200
|
||||
body = yield from resp.text()
|
||||
assert body == 'hello world'
|
||||
|
||||
@requests_mock.Mocker()
|
||||
def test_limit_refetch(self, m):
|
||||
"""Test that it fetches the given url."""
|
||||
self.hass.wsgi = mock.MagicMock()
|
||||
from requests.exceptions import Timeout
|
||||
m.get('http://example.com/5a', text='hello world')
|
||||
m.get('http://example.com/10a', text='hello world')
|
||||
m.get('http://example.com/15a', text='hello planet')
|
||||
m.get('http://example.com/20a', status_code=404)
|
||||
resp = yield from client.get('/api/camera_proxy/camera.config_test')
|
||||
assert aioclient_mock.call_count == 2
|
||||
|
||||
assert setup_component(self.hass, 'camera', {
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_limit_refetch(aioclient_mock, hass, test_client):
|
||||
"""Test that it fetches the given url."""
|
||||
aioclient_mock.get('http://example.com/5a', text='hello world')
|
||||
aioclient_mock.get('http://example.com/10a', text='hello world')
|
||||
aioclient_mock.get('http://example.com/15a', text='hello planet')
|
||||
aioclient_mock.get('http://example.com/20a', status=404)
|
||||
|
||||
def setup_platform():
|
||||
"""Setup the platform."""
|
||||
assert setup_component(hass, 'camera', {
|
||||
'camera': {
|
||||
'name': 'config_test',
|
||||
'platform': 'generic',
|
||||
@ -73,43 +55,47 @@ class TestGenericCamera(unittest.TestCase):
|
||||
'limit_refetch_to_url_change': True,
|
||||
}})
|
||||
|
||||
image_view = self.hass.wsgi.mock_calls[0][1][0]
|
||||
yield from hass.loop.run_in_executor(None, setup_platform)
|
||||
|
||||
builder = EnvironBuilder(method='GET')
|
||||
Request = request_class()
|
||||
request = Request(builder.get_environ())
|
||||
request.authenticated = True
|
||||
client = yield from test_client(hass.http.app)
|
||||
|
||||
self.hass.states.set('sensor.temp', '5')
|
||||
resp = yield from client.get('/api/camera_proxy/camera.config_test')
|
||||
|
||||
with mock.patch('requests.get', side_effect=Timeout()):
|
||||
resp = image_view.get(request, 'camera.config_test')
|
||||
assert m.call_count == 0
|
||||
assert resp.status_code == 500, resp.response
|
||||
hass.states.async_set('sensor.temp', '5')
|
||||
|
||||
self.hass.states.set('sensor.temp', '10')
|
||||
with mock.patch('async_timeout.timeout',
|
||||
side_effect=asyncio.TimeoutError()):
|
||||
resp = yield from client.get('/api/camera_proxy/camera.config_test')
|
||||
assert aioclient_mock.call_count == 0
|
||||
assert resp.status == 500
|
||||
|
||||
resp = image_view.get(request, 'camera.config_test')
|
||||
assert m.call_count == 1
|
||||
assert resp.status_code == 200, resp.response
|
||||
assert resp.response[0].decode('utf-8') == 'hello world'
|
||||
hass.states.async_set('sensor.temp', '10')
|
||||
|
||||
resp = image_view.get(request, 'camera.config_test')
|
||||
assert m.call_count == 1
|
||||
assert resp.status_code == 200, resp.response
|
||||
assert resp.response[0].decode('utf-8') == 'hello world'
|
||||
resp = yield from client.get('/api/camera_proxy/camera.config_test')
|
||||
assert aioclient_mock.call_count == 1
|
||||
assert resp.status == 200
|
||||
body = yield from resp.text()
|
||||
assert body == 'hello world'
|
||||
|
||||
self.hass.states.set('sensor.temp', '15')
|
||||
resp = yield from client.get('/api/camera_proxy/camera.config_test')
|
||||
assert aioclient_mock.call_count == 1
|
||||
assert resp.status == 200
|
||||
body = yield from resp.text()
|
||||
assert body == 'hello world'
|
||||
|
||||
# Url change = fetch new image
|
||||
resp = image_view.get(request, 'camera.config_test')
|
||||
assert m.call_count == 2
|
||||
assert resp.status_code == 200, resp.response
|
||||
assert resp.response[0].decode('utf-8') == 'hello planet'
|
||||
hass.states.async_set('sensor.temp', '15')
|
||||
|
||||
# Cause a template render error
|
||||
self.hass.states.remove('sensor.temp')
|
||||
resp = image_view.get(request, 'camera.config_test')
|
||||
assert m.call_count == 2
|
||||
assert resp.status_code == 200, resp.response
|
||||
assert resp.response[0].decode('utf-8') == 'hello planet'
|
||||
# Url change = fetch new image
|
||||
resp = yield from client.get('/api/camera_proxy/camera.config_test')
|
||||
assert aioclient_mock.call_count == 2
|
||||
assert resp.status == 200
|
||||
body = yield from resp.text()
|
||||
assert body == 'hello planet'
|
||||
|
||||
# Cause a template render error
|
||||
hass.states.async_remove('sensor.temp')
|
||||
resp = yield from client.get('/api/camera_proxy/camera.config_test')
|
||||
assert aioclient_mock.call_count == 2
|
||||
assert resp.status == 200
|
||||
body = yield from resp.text()
|
||||
assert body == 'hello planet'
|
||||
|
@ -1,70 +1,60 @@
|
||||
"""The tests for local file camera component."""
|
||||
import unittest
|
||||
import asyncio
|
||||
from unittest import mock
|
||||
|
||||
from werkzeug.test import EnvironBuilder
|
||||
# Using third party package because of a bug reading binary data in Python 3.4
|
||||
# https://bugs.python.org/issue23004
|
||||
from mock_open import MockOpen
|
||||
|
||||
from homeassistant.bootstrap import setup_component
|
||||
from homeassistant.components.http import request_class
|
||||
|
||||
from tests.common import get_test_home_assistant, assert_setup_component
|
||||
from tests.common import assert_setup_component, mock_http_component
|
||||
|
||||
|
||||
class TestLocalCamera(unittest.TestCase):
|
||||
"""Test the local file camera component."""
|
||||
@asyncio.coroutine
|
||||
def test_loading_file(hass, test_client):
|
||||
"""Test that it loads image from disk."""
|
||||
@mock.patch('os.path.isfile', mock.Mock(return_value=True))
|
||||
@mock.patch('os.access', mock.Mock(return_value=True))
|
||||
def setup_platform():
|
||||
"""Setup platform inside callback."""
|
||||
assert setup_component(hass, 'camera', {
|
||||
'camera': {
|
||||
'name': 'config_test',
|
||||
'platform': 'local_file',
|
||||
'file_path': 'mock.file',
|
||||
}})
|
||||
|
||||
def setUp(self):
|
||||
"""Setup things to be run when tests are started."""
|
||||
self.hass = get_test_home_assistant()
|
||||
self.hass.wsgi = mock.MagicMock()
|
||||
self.hass.config.components.append('http')
|
||||
yield from hass.loop.run_in_executor(None, setup_platform)
|
||||
|
||||
def tearDown(self):
|
||||
"""Stop everything that was started."""
|
||||
self.hass.stop()
|
||||
client = yield from test_client(hass.http.app)
|
||||
|
||||
def test_loading_file(self):
|
||||
"""Test that it loads image from disk."""
|
||||
test_string = 'hello'
|
||||
self.hass.wsgi = mock.MagicMock()
|
||||
m_open = MockOpen(read_data=b'hello')
|
||||
with mock.patch(
|
||||
'homeassistant.components.camera.local_file.open',
|
||||
m_open, create=True
|
||||
):
|
||||
resp = yield from client.get('/api/camera_proxy/camera.config_test')
|
||||
|
||||
with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \
|
||||
mock.patch('os.access', mock.Mock(return_value=True)):
|
||||
assert setup_component(self.hass, 'camera', {
|
||||
'camera': {
|
||||
'name': 'config_test',
|
||||
'platform': 'local_file',
|
||||
'file_path': 'mock.file',
|
||||
}})
|
||||
assert resp.status == 200
|
||||
body = yield from resp.text()
|
||||
assert body == 'hello'
|
||||
|
||||
image_view = self.hass.wsgi.mock_calls[0][1][0]
|
||||
|
||||
m_open = mock.mock_open(read_data=test_string)
|
||||
with mock.patch(
|
||||
'homeassistant.components.camera.local_file.open',
|
||||
m_open, create=True
|
||||
):
|
||||
builder = EnvironBuilder(method='GET')
|
||||
Request = request_class() # pylint: disable=invalid-name
|
||||
request = Request(builder.get_environ())
|
||||
request.authenticated = True
|
||||
resp = image_view.get(request, 'camera.config_test')
|
||||
|
||||
assert resp.status_code == 200, resp.response
|
||||
assert resp.response[0].decode('utf-8') == test_string
|
||||
|
||||
def test_file_not_readable(self):
|
||||
"""Test local file will not setup when file is not readable."""
|
||||
self.hass.wsgi = mock.MagicMock()
|
||||
@asyncio.coroutine
|
||||
def test_file_not_readable(hass):
|
||||
"""Test local file will not setup when file is not readable."""
|
||||
mock_http_component(hass)
|
||||
|
||||
def run_test():
|
||||
with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \
|
||||
mock.patch('os.access', return_value=False), \
|
||||
assert_setup_component(0):
|
||||
assert setup_component(self.hass, 'camera', {
|
||||
assert_setup_component(0, 'camera'):
|
||||
assert setup_component(hass, 'camera', {
|
||||
'camera': {
|
||||
'name': 'config_test',
|
||||
'platform': 'local_file',
|
||||
'file_path': 'mock.file',
|
||||
}})
|
||||
|
||||
assert [] == self.hass.states.all()
|
||||
yield from hass.loop.run_in_executor(None, run_test)
|
||||
|
@ -18,7 +18,7 @@ class TestUVCSetup(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Setup things to be run when tests are started."""
|
||||
self.hass = get_test_home_assistant()
|
||||
self.hass.wsgi = mock.MagicMock()
|
||||
self.hass.http = mock.MagicMock()
|
||||
self.hass.config.components = ['http']
|
||||
|
||||
def tearDown(self):
|
||||
|
@ -18,42 +18,19 @@ HTTP_BASE_URL = 'http://127.0.0.1:{}'.format(SERVER_PORT)
|
||||
API_PASSWORD = "test1234"
|
||||
HA_HEADERS = {HTTP_HEADER_HA_AUTH: API_PASSWORD}
|
||||
|
||||
hass = None
|
||||
|
||||
entity_id = 'media_player.walkman'
|
||||
|
||||
|
||||
def setUpModule(): # pylint: disable=invalid-name
|
||||
"""Initalize a Home Assistant server."""
|
||||
global hass
|
||||
|
||||
hass = get_test_home_assistant()
|
||||
setup_component(hass, http.DOMAIN, {
|
||||
http.DOMAIN: {
|
||||
http.CONF_SERVER_PORT: SERVER_PORT,
|
||||
http.CONF_API_PASSWORD: API_PASSWORD,
|
||||
},
|
||||
})
|
||||
|
||||
hass.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def tearDownModule(): # pylint: disable=invalid-name
|
||||
"""Stop the Home Assistant server."""
|
||||
hass.stop()
|
||||
|
||||
|
||||
class TestDemoMediaPlayer(unittest.TestCase):
|
||||
"""Test the media_player module."""
|
||||
|
||||
def setUp(self): # pylint: disable=invalid-name
|
||||
"""Setup things to be run when tests are started."""
|
||||
self.hass = hass
|
||||
try:
|
||||
self.hass.config.components.remove(mp.DOMAIN)
|
||||
except ValueError:
|
||||
pass
|
||||
self.hass = get_test_home_assistant()
|
||||
|
||||
def tearDown(self):
|
||||
"""Shut down test instance."""
|
||||
self.hass.stop()
|
||||
|
||||
def test_source_select(self):
|
||||
"""Test the input source service."""
|
||||
@ -226,21 +203,6 @@ class TestDemoMediaPlayer(unittest.TestCase):
|
||||
assert 0 == (mp.SUPPORT_PREVIOUS_TRACK &
|
||||
state.attributes.get('supported_media_commands'))
|
||||
|
||||
@requests_mock.Mocker(real_http=True)
|
||||
def test_media_image_proxy(self, m):
|
||||
"""Test the media server image proxy server ."""
|
||||
fake_picture_data = 'test.test'
|
||||
m.get('https://graph.facebook.com/v2.5/107771475912710/'
|
||||
'picture?type=large', text=fake_picture_data)
|
||||
assert setup_component(
|
||||
self.hass, mp.DOMAIN,
|
||||
{'media_player': {'platform': 'demo'}})
|
||||
assert self.hass.states.is_state(entity_id, 'playing')
|
||||
state = self.hass.states.get(entity_id)
|
||||
req = requests.get(HTTP_BASE_URL +
|
||||
state.attributes.get('entity_picture'))
|
||||
assert req.text == fake_picture_data
|
||||
|
||||
@patch('homeassistant.components.media_player.demo.DemoYoutubePlayer.'
|
||||
'media_seek')
|
||||
def test_play_media(self, mock_seek):
|
||||
@ -275,3 +237,42 @@ class TestDemoMediaPlayer(unittest.TestCase):
|
||||
mp.media_seek(self.hass, 100, ent_id)
|
||||
self.hass.block_till_done()
|
||||
assert mock_seek.called
|
||||
|
||||
|
||||
class TestMediaPlayerWeb(unittest.TestCase):
|
||||
"""Test the media player web views sensor."""
|
||||
|
||||
def setUp(self):
|
||||
"""Setup things to be run when tests are started."""
|
||||
self.hass = get_test_home_assistant()
|
||||
|
||||
setup_component(self.hass, http.DOMAIN, {
|
||||
http.DOMAIN: {
|
||||
http.CONF_SERVER_PORT: SERVER_PORT,
|
||||
http.CONF_API_PASSWORD: API_PASSWORD,
|
||||
},
|
||||
})
|
||||
|
||||
self.hass.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
def tearDown(self):
|
||||
"""Stop everything that was started."""
|
||||
self.hass.stop()
|
||||
|
||||
@requests_mock.Mocker(real_http=True)
|
||||
def test_media_image_proxy(self, m):
|
||||
"""Test the media server image proxy server ."""
|
||||
fake_picture_data = 'test.test'
|
||||
m.get('https://graph.facebook.com/v2.5/107771475912710/'
|
||||
'picture?type=large', text=fake_picture_data)
|
||||
self.hass.block_till_done()
|
||||
assert setup_component(
|
||||
self.hass, mp.DOMAIN,
|
||||
{'media_player': {'platform': 'demo'}})
|
||||
assert self.hass.states.is_state(entity_id, 'playing')
|
||||
state = self.hass.states.get(entity_id)
|
||||
req = requests.get(HTTP_BASE_URL +
|
||||
state.attributes.get('entity_picture'))
|
||||
assert req.status_code == 200
|
||||
assert req.text == fake_picture_data
|
||||
|
@ -1,10 +1,10 @@
|
||||
"""Test HTML5 notify platform."""
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock, mock_open
|
||||
|
||||
from werkzeug.test import EnvironBuilder
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components.http import request_class
|
||||
from homeassistant.components.notify import html5
|
||||
|
||||
SUBSCRIPTION_1 = {
|
||||
@ -35,6 +35,9 @@ SUBSCRIPTION_3 = {
|
||||
},
|
||||
}
|
||||
|
||||
REGISTER_URL = '/api/notify.html5'
|
||||
PUBLISH_URL = '/api/notify.html5/callback'
|
||||
|
||||
|
||||
class TestHtml5Notify(object):
|
||||
"""Tests for HTML5 notify platform."""
|
||||
@ -94,9 +97,13 @@ class TestHtml5Notify(object):
|
||||
assert payload['body'] == 'Hello'
|
||||
assert payload['icon'] == 'beer.png'
|
||||
|
||||
def test_registering_new_device_view(self):
|
||||
@asyncio.coroutine
|
||||
def test_registering_new_device_view(self, loop, test_client):
|
||||
"""Test that the HTML view works."""
|
||||
hass = MagicMock()
|
||||
expected = {
|
||||
'unnamed device': SUBSCRIPTION_1,
|
||||
}
|
||||
|
||||
m = mock_open()
|
||||
with patch(
|
||||
@ -114,21 +121,20 @@ class TestHtml5Notify(object):
|
||||
assert view.json_path == hass.config.path.return_value
|
||||
assert view.registrations == {}
|
||||
|
||||
builder = EnvironBuilder(method='POST',
|
||||
data=json.dumps(SUBSCRIPTION_1))
|
||||
Request = request_class()
|
||||
resp = view.post(Request(builder.get_environ()))
|
||||
app = web.Application(loop=loop)
|
||||
view.register(app.router)
|
||||
client = yield from test_client(app)
|
||||
resp = yield from client.post(REGISTER_URL,
|
||||
data=json.dumps(SUBSCRIPTION_1))
|
||||
|
||||
expected = {
|
||||
'unnamed device': SUBSCRIPTION_1,
|
||||
}
|
||||
|
||||
assert resp.status_code == 200, resp.response
|
||||
content = yield from resp.text()
|
||||
assert resp.status == 200, content
|
||||
assert view.registrations == expected
|
||||
handle = m()
|
||||
assert json.loads(handle.write.call_args[0][0]) == expected
|
||||
|
||||
def test_registering_new_device_validation(self):
|
||||
@asyncio.coroutine
|
||||
def test_registering_new_device_validation(self, loop, test_client):
|
||||
"""Test various errors when registering a new device."""
|
||||
hass = MagicMock()
|
||||
|
||||
@ -146,34 +152,34 @@ class TestHtml5Notify(object):
|
||||
|
||||
view = hass.mock_calls[1][1][0]
|
||||
|
||||
Request = request_class()
|
||||
app = web.Application(loop=loop)
|
||||
view.register(app.router)
|
||||
client = yield from test_client(app)
|
||||
|
||||
builder = EnvironBuilder(method='POST', data=json.dumps({
|
||||
resp = yield from client.post(REGISTER_URL, data=json.dumps({
|
||||
'browser': 'invalid browser',
|
||||
'subscription': 'sub info',
|
||||
}))
|
||||
resp = view.post(Request(builder.get_environ()))
|
||||
assert resp.status_code == 400, resp.response
|
||||
assert resp.status == 400
|
||||
|
||||
builder = EnvironBuilder(method='POST', data=json.dumps({
|
||||
resp = yield from client.post(REGISTER_URL, data=json.dumps({
|
||||
'browser': 'chrome',
|
||||
}))
|
||||
resp = view.post(Request(builder.get_environ()))
|
||||
assert resp.status_code == 400, resp.response
|
||||
assert resp.status == 400
|
||||
|
||||
builder = EnvironBuilder(method='POST', data=json.dumps({
|
||||
'browser': 'chrome',
|
||||
'subscription': 'sub info',
|
||||
}))
|
||||
with patch('homeassistant.components.notify.html5._save_config',
|
||||
return_value=False):
|
||||
resp = view.post(Request(builder.get_environ()))
|
||||
assert resp.status_code == 400, resp.response
|
||||
# resp = view.post(Request(builder.get_environ()))
|
||||
resp = yield from client.post(REGISTER_URL, data=json.dumps({
|
||||
'browser': 'chrome',
|
||||
'subscription': 'sub info',
|
||||
}))
|
||||
|
||||
@patch('homeassistant.components.notify.html5.os')
|
||||
def test_unregistering_device_view(self, mock_os):
|
||||
assert resp.status == 400
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_unregistering_device_view(self, loop, test_client):
|
||||
"""Test that the HTML unregister view works."""
|
||||
mock_os.path.isfile.return_value = True
|
||||
hass = MagicMock()
|
||||
|
||||
config = {
|
||||
@ -182,11 +188,14 @@ class TestHtml5Notify(object):
|
||||
}
|
||||
|
||||
m = mock_open(read_data=json.dumps(config))
|
||||
with patch(
|
||||
'homeassistant.components.notify.html5.open', m, create=True
|
||||
):
|
||||
|
||||
with patch('homeassistant.components.notify.html5.open', m,
|
||||
create=True):
|
||||
hass.config.path.return_value = 'file.conf'
|
||||
service = html5.get_service(hass, {})
|
||||
|
||||
with patch('homeassistant.components.notify.html5.os.path.isfile',
|
||||
return_value=True):
|
||||
service = html5.get_service(hass, {})
|
||||
|
||||
assert service is not None
|
||||
|
||||
@ -197,23 +206,25 @@ class TestHtml5Notify(object):
|
||||
assert view.json_path == hass.config.path.return_value
|
||||
assert view.registrations == config
|
||||
|
||||
builder = EnvironBuilder(method='DELETE', data=json.dumps({
|
||||
app = web.Application(loop=loop)
|
||||
view.register(app.router)
|
||||
client = yield from test_client(app)
|
||||
|
||||
resp = yield from client.delete(REGISTER_URL, data=json.dumps({
|
||||
'subscription': SUBSCRIPTION_1['subscription'],
|
||||
}))
|
||||
Request = request_class()
|
||||
resp = view.delete(Request(builder.get_environ()))
|
||||
|
||||
config.pop('some device')
|
||||
|
||||
assert resp.status_code == 200, resp.response
|
||||
assert resp.status == 200, resp.response
|
||||
assert view.registrations == config
|
||||
handle = m()
|
||||
assert json.loads(handle.write.call_args[0][0]) == config
|
||||
|
||||
@patch('homeassistant.components.notify.html5.os')
|
||||
def test_unregister_device_view_handle_unknown_subscription(self, mock_os):
|
||||
@asyncio.coroutine
|
||||
def test_unregister_device_view_handle_unknown_subscription(self, loop,
|
||||
test_client):
|
||||
"""Test that the HTML unregister view handles unknown subscriptions."""
|
||||
mock_os.path.isfile.return_value = True
|
||||
hass = MagicMock()
|
||||
|
||||
config = {
|
||||
@ -226,7 +237,9 @@ class TestHtml5Notify(object):
|
||||
'homeassistant.components.notify.html5.open', m, create=True
|
||||
):
|
||||
hass.config.path.return_value = 'file.conf'
|
||||
service = html5.get_service(hass, {})
|
||||
with patch('homeassistant.components.notify.html5.os.path.isfile',
|
||||
return_value=True):
|
||||
service = html5.get_service(hass, {})
|
||||
|
||||
assert service is not None
|
||||
|
||||
@ -237,21 +250,23 @@ class TestHtml5Notify(object):
|
||||
assert view.json_path == hass.config.path.return_value
|
||||
assert view.registrations == config
|
||||
|
||||
builder = EnvironBuilder(method='DELETE', data=json.dumps({
|
||||
app = web.Application(loop=loop)
|
||||
view.register(app.router)
|
||||
client = yield from test_client(app)
|
||||
|
||||
resp = yield from client.delete(REGISTER_URL, data=json.dumps({
|
||||
'subscription': SUBSCRIPTION_3['subscription']
|
||||
}))
|
||||
Request = request_class()
|
||||
resp = view.delete(Request(builder.get_environ()))
|
||||
|
||||
assert resp.status_code == 200, resp.response
|
||||
assert resp.status == 200, resp.response
|
||||
assert view.registrations == config
|
||||
handle = m()
|
||||
assert handle.write.call_count == 0
|
||||
|
||||
@patch('homeassistant.components.notify.html5.os')
|
||||
def test_unregistering_device_view_handles_json_safe_error(self, mock_os):
|
||||
@asyncio.coroutine
|
||||
def test_unregistering_device_view_handles_json_safe_error(self, loop,
|
||||
test_client):
|
||||
"""Test that the HTML unregister view handles JSON write errors."""
|
||||
mock_os.path.isfile.return_value = True
|
||||
hass = MagicMock()
|
||||
|
||||
config = {
|
||||
@ -264,7 +279,9 @@ class TestHtml5Notify(object):
|
||||
'homeassistant.components.notify.html5.open', m, create=True
|
||||
):
|
||||
hass.config.path.return_value = 'file.conf'
|
||||
service = html5.get_service(hass, {})
|
||||
with patch('homeassistant.components.notify.html5.os.path.isfile',
|
||||
return_value=True):
|
||||
service = html5.get_service(hass, {})
|
||||
|
||||
assert service is not None
|
||||
|
||||
@ -275,21 +292,23 @@ class TestHtml5Notify(object):
|
||||
assert view.json_path == hass.config.path.return_value
|
||||
assert view.registrations == config
|
||||
|
||||
builder = EnvironBuilder(method='DELETE', data=json.dumps({
|
||||
'subscription': SUBSCRIPTION_1['subscription'],
|
||||
}))
|
||||
Request = request_class()
|
||||
app = web.Application(loop=loop)
|
||||
view.register(app.router)
|
||||
client = yield from test_client(app)
|
||||
|
||||
with patch('homeassistant.components.notify.html5._save_config',
|
||||
return_value=False):
|
||||
resp = view.delete(Request(builder.get_environ()))
|
||||
resp = yield from client.delete(REGISTER_URL, data=json.dumps({
|
||||
'subscription': SUBSCRIPTION_1['subscription'],
|
||||
}))
|
||||
|
||||
assert resp.status_code == 500, resp.response
|
||||
assert resp.status == 500, resp.response
|
||||
assert view.registrations == config
|
||||
handle = m()
|
||||
assert handle.write.call_count == 0
|
||||
|
||||
def test_callback_view_no_jwt(self):
|
||||
@asyncio.coroutine
|
||||
def test_callback_view_no_jwt(self, loop, test_client):
|
||||
"""Test that the notification callback view works without JWT."""
|
||||
hass = MagicMock()
|
||||
|
||||
@ -307,20 +326,20 @@ class TestHtml5Notify(object):
|
||||
|
||||
view = hass.mock_calls[2][1][0]
|
||||
|
||||
builder = EnvironBuilder(method='POST', data=json.dumps({
|
||||
app = web.Application(loop=loop)
|
||||
view.register(app.router)
|
||||
client = yield from test_client(app)
|
||||
|
||||
resp = yield from client.post(PUBLISH_URL, data=json.dumps({
|
||||
'type': 'push',
|
||||
'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72'
|
||||
}))
|
||||
Request = request_class()
|
||||
resp = view.post(Request(builder.get_environ()))
|
||||
|
||||
assert resp.status_code == 401, resp.response
|
||||
assert resp.status == 401, resp.response
|
||||
|
||||
@patch('homeassistant.components.notify.html5.os')
|
||||
@patch('pywebpush.WebPusher')
|
||||
def test_callback_view_with_jwt(self, mock_wp, mock_os):
|
||||
@asyncio.coroutine
|
||||
def test_callback_view_with_jwt(self, loop, test_client):
|
||||
"""Test that the notification callback view works with JWT."""
|
||||
mock_os.path.isfile.return_value = True
|
||||
hass = MagicMock()
|
||||
|
||||
data = {
|
||||
@ -332,15 +351,18 @@ class TestHtml5Notify(object):
|
||||
'homeassistant.components.notify.html5.open', m, create=True
|
||||
):
|
||||
hass.config.path.return_value = 'file.conf'
|
||||
service = html5.get_service(hass, {'gcm_sender_id': '100'})
|
||||
with patch('homeassistant.components.notify.html5.os.path.isfile',
|
||||
return_value=True):
|
||||
service = html5.get_service(hass, {'gcm_sender_id': '100'})
|
||||
|
||||
assert service is not None
|
||||
|
||||
# assert hass.called
|
||||
assert len(hass.mock_calls) == 3
|
||||
|
||||
service.send_message('Hello', target=['device'],
|
||||
data={'icon': 'beer.png'})
|
||||
with patch('pywebpush.WebPusher') as mock_wp:
|
||||
service.send_message('Hello', target=['device'],
|
||||
data={'icon': 'beer.png'})
|
||||
|
||||
assert len(mock_wp.mock_calls) == 2
|
||||
|
||||
@ -359,13 +381,14 @@ class TestHtml5Notify(object):
|
||||
|
||||
bearer_token = "Bearer {}".format(push_payload['data']['jwt'])
|
||||
|
||||
builder = EnvironBuilder(method='POST', data=json.dumps({
|
||||
app = web.Application(loop=loop)
|
||||
view.register(app.router)
|
||||
client = yield from test_client(app)
|
||||
|
||||
resp = yield from client.post(PUBLISH_URL, data=json.dumps({
|
||||
'type': 'push',
|
||||
}), headers={'Authorization': bearer_token})
|
||||
Request = request_class()
|
||||
resp = view.post(Request(builder.get_environ()))
|
||||
|
||||
assert resp.status_code == 200, resp.response
|
||||
returned = resp.response[0].decode('utf-8')
|
||||
expected = '{"event": "push", "status": "ok"}'
|
||||
assert json.loads(returned) == json.loads(expected)
|
||||
assert resp.status == 200
|
||||
body = yield from resp.json()
|
||||
assert body == {"event": "push", "status": "ok"}
|
||||
|
@ -1,33 +1,29 @@
|
||||
"""The tests for the Yr sensor platform."""
|
||||
from datetime import datetime
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests_mock
|
||||
|
||||
from homeassistant.bootstrap import _setup_component
|
||||
import homeassistant.util.dt as dt_util
|
||||
from tests.common import get_test_home_assistant, load_fixture
|
||||
|
||||
|
||||
class TestSensorYr(TestCase):
|
||||
class TestSensorYr:
|
||||
"""Test the Yr sensor."""
|
||||
|
||||
def setUp(self):
|
||||
def setup_method(self):
|
||||
"""Setup things to be run when tests are started."""
|
||||
self.hass = get_test_home_assistant()
|
||||
self.hass.config.latitude = 32.87336
|
||||
self.hass.config.longitude = 117.22743
|
||||
|
||||
def tearDown(self):
|
||||
def teardown_method(self):
|
||||
"""Stop everything that was started."""
|
||||
self.hass.stop()
|
||||
|
||||
@requests_mock.Mocker()
|
||||
def test_default_setup(self, m):
|
||||
def test_default_setup(self, requests_mock):
|
||||
"""Test the default setup."""
|
||||
m.get('http://api.yr.no/weatherapi/locationforecast/1.9/',
|
||||
text=load_fixture('yr.no.json'))
|
||||
requests_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/',
|
||||
text=load_fixture('yr.no.json'))
|
||||
now = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC)
|
||||
|
||||
with patch('homeassistant.components.sensor.yr.dt_util.utcnow',
|
||||
@ -42,11 +38,10 @@ class TestSensorYr(TestCase):
|
||||
assert state.state.isnumeric()
|
||||
assert state.attributes.get('unit_of_measurement') is None
|
||||
|
||||
@requests_mock.Mocker()
|
||||
def test_custom_setup(self, m):
|
||||
def test_custom_setup(self, requests_mock):
|
||||
"""Test a custom setup."""
|
||||
m.get('http://api.yr.no/weatherapi/locationforecast/1.9/',
|
||||
text=load_fixture('yr.no.json'))
|
||||
requests_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/',
|
||||
text=load_fixture('yr.no.json'))
|
||||
now = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC)
|
||||
|
||||
with patch('homeassistant.components.sensor.yr.dt_util.utcnow',
|
||||
|
@ -1,11 +1,13 @@
|
||||
"""The tests for the Home Assistant API component."""
|
||||
# pylint: disable=protected-access,too-many-public-methods
|
||||
import asyncio
|
||||
from contextlib import closing
|
||||
import json
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from aiohttp import web
|
||||
import requests
|
||||
|
||||
from homeassistant import bootstrap, const
|
||||
@ -243,20 +245,18 @@ class TestAPI(unittest.TestCase):
|
||||
|
||||
def test_api_get_error_log(self):
|
||||
"""Test the return of the error log."""
|
||||
test_string = 'Test String°'.encode('UTF-8')
|
||||
test_string = 'Test String°'
|
||||
|
||||
# Can't use read_data with wsgiserver in Python 3.4.2. Due to a
|
||||
# bug in read_data, it can't handle byte types ('Type str doesn't
|
||||
# support the buffer API'), but wsgiserver requires byte types
|
||||
# ('WSGI Applications must yield bytes'). So just mock our own
|
||||
# read method.
|
||||
m_open = Mock(return_value=Mock(
|
||||
read=Mock(side_effect=[test_string]))
|
||||
)
|
||||
with patch('homeassistant.components.http.open', m_open, create=True):
|
||||
@asyncio.coroutine
|
||||
def mock_send():
|
||||
"""Mock file send."""
|
||||
return web.Response(text=test_string)
|
||||
|
||||
with patch('homeassistant.components.http.HomeAssistantView.file',
|
||||
Mock(return_value=mock_send())):
|
||||
req = requests.get(_url(const.URL_API_ERROR_LOG),
|
||||
headers=HA_HEADERS)
|
||||
self.assertEqual(test_string, req.text.encode('UTF-8'))
|
||||
self.assertEqual(test_string, req.text)
|
||||
self.assertIsNone(req.headers.get('expires'))
|
||||
|
||||
def test_api_get_event_listeners(self):
|
||||
|
@ -34,12 +34,12 @@ def setUpModule(): # pylint: disable=invalid-name
|
||||
hass.bus.listen('test_event', lambda _: _)
|
||||
hass.states.set('test.test', 'a_state')
|
||||
|
||||
bootstrap.setup_component(
|
||||
assert bootstrap.setup_component(
|
||||
hass, http.DOMAIN,
|
||||
{http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD,
|
||||
http.CONF_SERVER_PORT: SERVER_PORT}})
|
||||
|
||||
bootstrap.setup_component(hass, 'frontend')
|
||||
assert bootstrap.setup_component(hass, 'frontend')
|
||||
|
||||
hass.start()
|
||||
time.sleep(0.05)
|
||||
@ -71,7 +71,7 @@ class TestFrontend(unittest.TestCase):
|
||||
|
||||
self.assertIsNotNone(frontendjs)
|
||||
|
||||
req = requests.head(_url(frontendjs.groups(0)[0]))
|
||||
req = requests.get(_url(frontendjs.groups(0)[0]))
|
||||
|
||||
self.assertEqual(200, req.status_code)
|
||||
|
||||
|
@ -56,7 +56,7 @@ def setUpModule():
|
||||
|
||||
bootstrap.setup_component(hass, 'api')
|
||||
|
||||
hass.wsgi.trusted_networks = [
|
||||
hass.http.trusted_networks = [
|
||||
ip_network(trusted_network)
|
||||
for trusted_network in TRUSTED_NETWORKS]
|
||||
|
||||
@ -159,12 +159,9 @@ class TestHttp:
|
||||
headers={const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL})
|
||||
|
||||
allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN
|
||||
allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS
|
||||
all_allow_headers = ', '.join(const.ALLOWED_CORS_HEADERS)
|
||||
|
||||
assert req.status_code == 200
|
||||
assert req.headers.get(allow_origin) == HTTP_BASE_URL
|
||||
assert req.headers.get(allow_headers) == all_allow_headers
|
||||
|
||||
def test_cors_allowed_with_password_in_header(self):
|
||||
"""Test cross origin resource sharing with password in header."""
|
||||
@ -175,12 +172,9 @@ class TestHttp:
|
||||
req = requests.get(_url(const.URL_API), headers=headers)
|
||||
|
||||
allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN
|
||||
allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS
|
||||
all_allow_headers = ', '.join(const.ALLOWED_CORS_HEADERS)
|
||||
|
||||
assert req.status_code == 200
|
||||
assert req.headers.get(allow_origin) == HTTP_BASE_URL
|
||||
assert req.headers.get(allow_headers) == all_allow_headers
|
||||
|
||||
def test_cors_denied_without_origin_header(self):
|
||||
"""Test cross origin resource sharing with password in header."""
|
||||
@ -207,8 +201,8 @@ class TestHttp:
|
||||
|
||||
allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN
|
||||
allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS
|
||||
all_allow_headers = ', '.join(const.ALLOWED_CORS_HEADERS)
|
||||
|
||||
assert req.status_code == 200
|
||||
assert req.headers.get(allow_origin) == HTTP_BASE_URL
|
||||
assert req.headers.get(allow_headers) == all_allow_headers
|
||||
assert req.headers.get(allow_headers) == \
|
||||
const.HTTP_HEADER_HA_AUTH.upper()
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""The tests for the InfluxDB component."""
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
|
||||
import influxdb as influx_client
|
||||
|
||||
@ -60,6 +61,8 @@ class TestInfluxDB(unittest.TestCase):
|
||||
|
||||
assert setup_component(self.hass, influxdb.DOMAIN, config)
|
||||
|
||||
@patch('homeassistant.components.persistent_notification.create',
|
||||
mock.MagicMock())
|
||||
def test_setup_missing_password(self, mock_client):
|
||||
"""Test the setup with existing username and missing password."""
|
||||
config = {
|
||||
|
59
tests/conftest.py
Normal file
59
tests/conftest.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Setup some common test helper things."""
|
||||
import functools
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
import requests_mock as _requests_mock
|
||||
|
||||
from homeassistant import util
|
||||
from homeassistant.util import location
|
||||
|
||||
from .common import async_test_home_assistant
|
||||
from .test_util.aiohttp import mock_aiohttp_client
|
||||
|
||||
logging.basicConfig()
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
|
||||
|
||||
|
||||
def test_real(func):
|
||||
"""Force a function to require a keyword _test_real to be passed in."""
|
||||
@functools.wraps(func)
|
||||
def guard_func(*args, **kwargs):
|
||||
real = kwargs.pop('_test_real', None)
|
||||
|
||||
if not real:
|
||||
raise Exception('Forgot to mock or pass "_test_real=True" to %s',
|
||||
func.__name__)
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return guard_func
|
||||
|
||||
# Guard a few functions that would make network connections
|
||||
location.detect_location_info = test_real(location.detect_location_info)
|
||||
location.elevation = test_real(location.elevation)
|
||||
util.get_local_ip = lambda: '127.0.0.1'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hass(loop):
|
||||
"""Fixture to provide a test instance of HASS."""
|
||||
hass = loop.run_until_complete(async_test_home_assistant(loop))
|
||||
|
||||
yield hass
|
||||
|
||||
loop.run_until_complete(hass.async_stop())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def requests_mock():
|
||||
"""Fixture to provide a requests mocker."""
|
||||
with _requests_mock.mock() as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def aioclient_mock():
|
||||
"""Fixture to mock aioclient calls."""
|
||||
with mock_aiohttp_client() as mock_session:
|
||||
yield mock_session
|
@ -1,4 +1,5 @@
|
||||
"""Test state helpers."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
@ -20,6 +21,42 @@ from homeassistant.components.sun import (STATE_ABOVE_HORIZON,
|
||||
from tests.common import get_test_home_assistant, mock_service
|
||||
|
||||
|
||||
def test_async_track_states(event_loop):
|
||||
"""Test AsyncTrackStates context manager."""
|
||||
hass = get_test_home_assistant()
|
||||
|
||||
try:
|
||||
point1 = dt_util.utcnow()
|
||||
point2 = point1 + timedelta(seconds=5)
|
||||
point3 = point2 + timedelta(seconds=5)
|
||||
|
||||
@asyncio.coroutine
|
||||
@patch('homeassistant.core.dt_util.utcnow')
|
||||
def run_test(mock_utcnow):
|
||||
"""Run the test."""
|
||||
mock_utcnow.return_value = point2
|
||||
|
||||
with state.AsyncTrackStates(hass) as states:
|
||||
mock_utcnow.return_value = point1
|
||||
hass.states.set('light.test', 'on')
|
||||
|
||||
mock_utcnow.return_value = point2
|
||||
hass.states.set('light.test2', 'on')
|
||||
state2 = hass.states.get('light.test2')
|
||||
|
||||
mock_utcnow.return_value = point3
|
||||
hass.states.set('light.test3', 'on')
|
||||
state3 = hass.states.get('light.test3')
|
||||
|
||||
assert [state2, state3] == \
|
||||
sorted(states, key=lambda state: state.entity_id)
|
||||
|
||||
event_loop.run_until_complete(run_test())
|
||||
|
||||
finally:
|
||||
hass.stop()
|
||||
|
||||
|
||||
class TestStateHelpers(unittest.TestCase):
|
||||
"""Test the Home Assistant event helpers."""
|
||||
|
||||
@ -54,31 +91,6 @@ class TestStateHelpers(unittest.TestCase):
|
||||
[state2, state3],
|
||||
state.get_changed_since([state1, state2, state3], point2))
|
||||
|
||||
def test_track_states(self):
|
||||
"""Test tracking of states."""
|
||||
point1 = dt_util.utcnow()
|
||||
point2 = point1 + timedelta(seconds=5)
|
||||
point3 = point2 + timedelta(seconds=5)
|
||||
|
||||
with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow:
|
||||
mock_utcnow.return_value = point2
|
||||
|
||||
with state.TrackStates(self.hass) as states:
|
||||
mock_utcnow.return_value = point1
|
||||
self.hass.states.set('light.test', 'on')
|
||||
|
||||
mock_utcnow.return_value = point2
|
||||
self.hass.states.set('light.test2', 'on')
|
||||
state2 = self.hass.states.get('light.test2')
|
||||
|
||||
mock_utcnow.return_value = point3
|
||||
self.hass.states.set('light.test3', 'on')
|
||||
state3 = self.hass.states.get('light.test3')
|
||||
|
||||
self.assertEqual(
|
||||
sorted([state2, state3], key=lambda state: state.entity_id),
|
||||
sorted(states, key=lambda state: state.entity_id))
|
||||
|
||||
def test_reproduce_with_no_entity(self):
|
||||
"""Test reproduce_state with no entity."""
|
||||
calls = mock_service(self.hass, 'light', SERVICE_TURN_ON)
|
||||
|
112
tests/test_util/aiohttp.py
Normal file
112
tests/test_util/aiohttp.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""Aiohttp test utils."""
|
||||
import asyncio
|
||||
from contextlib import contextmanager
|
||||
import functools
|
||||
import json as _json
|
||||
from unittest import mock
|
||||
|
||||
|
||||
class AiohttpClientMocker:
|
||||
"""Mock Aiohttp client requests."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the request mocker."""
|
||||
self._mocks = []
|
||||
self.mock_calls = []
|
||||
|
||||
def request(self, method, url, *,
|
||||
status=200,
|
||||
text=None,
|
||||
content=None,
|
||||
json=None):
|
||||
"""Mock a request."""
|
||||
if json:
|
||||
text = _json.dumps(json)
|
||||
if text:
|
||||
content = text.encode('utf-8')
|
||||
if content is None:
|
||||
content = b''
|
||||
|
||||
self._mocks.append(AiohttpClientMockResponse(
|
||||
method, url, status, content))
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
"""Register a mock get request."""
|
||||
self.request('get', *args, **kwargs)
|
||||
|
||||
def put(self, *args, **kwargs):
|
||||
"""Register a mock put request."""
|
||||
self.request('put', *args, **kwargs)
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
"""Register a mock post request."""
|
||||
self.request('post', *args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Register a mock delete request."""
|
||||
self.request('delete', *args, **kwargs)
|
||||
|
||||
def options(self, *args, **kwargs):
|
||||
"""Register a mock options request."""
|
||||
self.request('options', *args, **kwargs)
|
||||
|
||||
@property
|
||||
def call_count(self):
|
||||
"""Number of requests made."""
|
||||
return len(self.mock_calls)
|
||||
|
||||
@asyncio.coroutine
|
||||
def match_request(self, method, url):
|
||||
"""Match a request against pre-registered requests."""
|
||||
for response in self._mocks:
|
||||
if response.match_request(method, url):
|
||||
self.mock_calls.append((method, url))
|
||||
return response
|
||||
|
||||
assert False, "No mock registered for {} {}".format(method.upper(),
|
||||
url)
|
||||
|
||||
|
||||
class AiohttpClientMockResponse:
|
||||
"""Mock Aiohttp client response."""
|
||||
|
||||
def __init__(self, method, url, status, response):
|
||||
"""Initialize a fake response."""
|
||||
self.method = method
|
||||
self.url = url
|
||||
self.status = status
|
||||
self.response = response
|
||||
|
||||
def match_request(self, method, url):
|
||||
"""Test if response answers request."""
|
||||
return method == self.method and url == self.url
|
||||
|
||||
@asyncio.coroutine
|
||||
def read(self):
|
||||
"""Return mock response."""
|
||||
return self.response
|
||||
|
||||
@asyncio.coroutine
|
||||
def text(self, encoding='utf-8'):
|
||||
"""Return mock response as a string."""
|
||||
return self.response.decode(encoding)
|
||||
|
||||
@asyncio.coroutine
|
||||
def release(self):
|
||||
"""Mock release."""
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mock_aiohttp_client():
|
||||
"""Context manager to mock aiohttp client."""
|
||||
mocker = AiohttpClientMocker()
|
||||
|
||||
with mock.patch('aiohttp.ClientSession') as mock_session:
|
||||
instance = mock_session()
|
||||
|
||||
for method in ('get', 'post', 'put', 'options', 'delete'):
|
||||
setattr(instance, method,
|
||||
functools.partial(mocker.match_request, method))
|
||||
|
||||
yield mocker
|
Loading…
x
Reference in New Issue
Block a user