Remote instances are now 100% operational

This commit is contained in:
Paulus Schoutsen 2014-04-29 00:30:31 -07:00
parent 8e65afa994
commit 50b492c64a
12 changed files with 770 additions and 529 deletions

303
README.md
View file

@ -40,138 +40,11 @@ Installation instructions
Done. Start it now by running `python start.py` Done. Start it now by running `python start.py`
Web interface and API
---------------------
Home Assistent runs a webserver accessible on port 8123.
* At http://localhost:8123/ it will provide a debug interface showing the current state of the system and an overview of registered services.
* At http://localhost:8123/api/ it provides a password protected API.
A screenshot of the debug interface:
![screenshot-debug-interface](https://raw.github.com/balloob/home-assistant/master/docs/screenshot-debug-interface.png)
All API calls have to be accompanied by an 'api_password' parameter (as specified in `home-assistant.conf`) and will
return JSON encoded objects. If successful calls will return status code 200 or 201.
Other status codes that can occur are:
- 400 (Bad Request)
- 401 (Unauthorized)
- 404 (Not Found)
- 405 (Method not allowed)
The api supports the following actions:
**/api/states - GET**<br>
Returns a list of entity ids for which a state is available
```json
{
"entity_ids": [
"Paulus_Nexus_4",
"weather.sun",
"all_devices"
]
}
```
**/api/events - GET**<br>
Returns a dict with as keys the events and as value the number of listeners.
```json
{
"event_listeners": {
"state_changed": 5,
"time_changed": 2
}
}
```
**/api/services - GET**<br>
Returns a dict with as keys the domain and as value a list of published services.
```json
{
"services": {
"browser": [
"browse_url"
],
"keyboard": [
"volume_up",
"volume_down"
]
}
}
```
**/api/states/&lt;entity_id>** - GET<br>
Returns the current state from an entity
```json
{
"attributes": {
"next_rising": "07:04:15 29-10-2013",
"next_setting": "18:00:31 29-10-2013"
},
"entity_id": "weather.sun",
"last_changed": "23:24:33 28-10-2013",
"state": "below_horizon"
}
```
**/api/states/&lt;entity_id>** - POST<br>
Updates the current state of an entity. Returns status code 201 if successful with location header of updated resource and the new state in the body.<br>
parameter: new_state - string<br>
optional parameter: attributes - JSON encoded object
```json
{
"attributes": {
"next_rising": "07:04:15 29-10-2013",
"next_setting": "18:00:31 29-10-2013"
},
"entity_id": "weather.sun",
"last_changed": "23:24:33 28-10-2013",
"state": "below_horizon"
}
```
**/api/events/&lt;event_type>** - POST<br>
Fires an event with event_type<br>
optional parameter: event_data - JSON encoded object
```json
{
"message": "Event download_file fired."
}
```
**/api/services/&lt;domain>/&lt;service>** - POST<br>
Calls a service within a specific domain.<br>
optional parameter: service_data - JSON encoded object
```json
{
"message": "Service keyboard/volume_up called."
}
```
Android remote control
----------------------
An app has been built using [Tasker for Android](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm) that:
* Provides buttons to control the lights and the chromecast
* Reports the charging state and battery level of the phone
The [APK](https://raw.github.com/balloob/home-assistant/master/android-tasker/Home_Assistant.apk) and [Tasker project XML](https://raw.github.com/balloob/home-assistant/master/android-tasker/Home_Assistant.prj.xml) can be found in [/android-tasker/](https://github.com/balloob/home-assistant/tree/master/android-tasker)
![screenshot-android-tasker.jpg](https://raw.github.com/balloob/home-assistant/master/docs/screenshot-android-tasker.png)
Architecture Architecture
------------ ------------
The core of Home Assistant exists of two parts; a Bus for calling services and firing events and a State Machine that keeps track of the state of things. The core of Home Assistant exists of three parts; an EventBus for firing events, a StateMachine that keeps track of the state of things and a ServiceRegistry to manage services.
![screenshot-android-tasker.jpg](https://raw.github.com/balloob/home-assistant/master/docs/architecture.png) ![home assistant architecture](https://raw.github.com/balloob/home-assistant/master/docs/architecture.png)
For example to control the lights there are two components. One is the device_tracker that polls the wireless router for connected devices and updates the state of the tracked devices in the State Machine to be either 'Home' or 'Not Home'. For example to control the lights there are two components. One is the device_tracker that polls the wireless router for connected devices and updates the state of the tracked devices in the State Machine to be either 'Home' or 'Not Home'.
@ -238,3 +111,175 @@ Registers service `downloader/download_file` that will download files. File to d
**browser** **browser**
Registers service `browser/browse_url` that opens `url` as specified in event_data in the system default browser. Registers service `browser/browse_url` that opens `url` as specified in event_data in the system default browser.
### Multiple connected instances
Home Assistant supports running multiple synchronzied instances using a master-slave model. Slaves forward all local events fired and states set to the master instance which will then replicate it to each slave.
Because each slave maintains it's own ServiceRegistry it is possible to have multiple slaves respond to one service call.
![home assistant master-slave architecture](https://raw.github.com/balloob/home-assistant/master/docs/architecture-remote.png)
Web interface and API
---------------------
Home Assistent runs a webserver accessible on port 8123.
* At http://localhost:8123/ it will provide a debug interface showing the current state of the system and an overview of registered services.
* At http://localhost:8123/api/ it provides a password protected API.
A screenshot of the debug interface:
![screenshot-debug-interface](https://raw.github.com/balloob/home-assistant/master/docs/screenshot-debug-interface.png)
In the package `homeassistant.remote` a Python API on top of the HTTP API can be found.
All API calls have to be accompanied by an 'api_password' parameter (as specified in `home-assistant.conf`) and will
return JSON encoded objects. If successful calls will return status code 200 or 201.
Other status codes that can occur are:
- 400 (Bad Request)
- 401 (Unauthorized)
- 404 (Not Found)
- 405 (Method not allowed)
The api supports the following actions:
**/api/events - GET**<br>
Returns a dict with as keys the events and as value the number of listeners.
```json
{
"event_listeners": {
"state_changed": 5,
"time_changed": 2
}
}
```
**/api/services - GET**<br>
Returns a dict with as keys the domain and as value a list of published services.
```json
{
"services": {
"browser": [
"browse_url"
],
"keyboard": [
"volume_up",
"volume_down"
]
}
}
```
**/api/states - GET**<br>
Returns a dict with as keys the entity_ids and as value the state.
```json
{
"sun.sun": {
"attributes": {
"next_rising": "07:04:15 29-10-2013",
"next_setting": "18:00:31 29-10-2013"
},
"entity_id": "sun.sun",
"last_changed": "23:24:33 28-10-2013",
"state": "below_horizon"
},
"process.Dropbox": {
"attributes": {},
"entity_id": "process.Dropbox",
"last_changed": "23:24:33 28-10-2013",
"state": "on"
}
}
```
**/api/states/&lt;entity_id>** - GET<br>
Returns the current state from an entity
```json
{
"attributes": {
"next_rising": "07:04:15 29-10-2013",
"next_setting": "18:00:31 29-10-2013"
},
"entity_id": "sun.sun",
"last_changed": "23:24:33 28-10-2013",
"state": "below_horizon"
}
```
**/api/states/&lt;entity_id>** - POST<br>
Updates the current state of an entity. Returns status code 201 if successful with location header of updated resource and the new state in the body.<br>
parameter: new_state - string<br>
optional parameter: attributes - JSON encoded object
```json
{
"attributes": {
"next_rising": "07:04:15 29-10-2013",
"next_setting": "18:00:31 29-10-2013"
},
"entity_id": "weather.sun",
"last_changed": "23:24:33 28-10-2013",
"state": "below_horizon"
}
```
**/api/events/&lt;event_type>** - POST<br>
Fires an event with event_type<br>
optional parameter: event_data - JSON encoded object
```json
{
"message": "Event download_file fired."
}
```
**/api/services/&lt;domain>/&lt;service>** - POST<br>
Calls a service within a specific domain.<br>
optional parameter: service_data - JSON encoded object
```json
{
"message": "Service keyboard/volume_up called."
}
```
**/api/event_forwarding** - POST<br>
Setup event forwarding to another Home Assistant instance.<br>
parameter: host - string<br>
parameter: api_password - string<br>
optional parameter: port - int<br>
```json
{
"message": "Event forwarding setup."
}
```
**/api/event_forwarding** - DELETE<br>
Cancel event forwarding to another Home Assistant instance.<br>
parameter: host - string<br>
optional parameter: port - int<br>
If your client does not support DELETE HTTP requests you can add an optional attribute _METHOD and set its value to DELETE.
```json
{
"message": "Event forwarding cancelled."
}
```
Android remote control
----------------------
An app has been built using [Tasker for Android](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm) that:
* Provides buttons to control the lights and the chromecast
* Reports the charging state and battery level of the phone
The [APK](https://raw.github.com/balloob/home-assistant/master/android-tasker/Home_Assistant.apk) and [Tasker project XML](https://raw.github.com/balloob/home-assistant/master/android-tasker/Home_Assistant.prj.xml) can be found in [/android-tasker/](https://github.com/balloob/home-assistant/tree/master/android-tasker)
![screenshot-android-tasker.jpg](https://raw.github.com/balloob/home-assistant/master/docs/screenshot-android-tasker.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View file

@ -2,7 +2,7 @@
latitude=32.87336 latitude=32.87336
longitude=-117.22743 longitude=-117.22743
[httpinterface] [http]
api_password=mypass api_password=mypass
[light.hue] [light.hue]

View file

@ -9,6 +9,7 @@ of entities and react to changes.
import time import time
import logging import logging
import threading import threading
import enum
import datetime as dt import datetime as dt
import functools as ft import functools as ft
@ -40,24 +41,21 @@ class HomeAssistant(object):
""" Core class to route all communication to right components. """ """ Core class to route all communication to right components. """
def __init__(self): def __init__(self):
self._pool = pool = _create_worker_pool() self._pool = pool = create_worker_pool()
self.bus = EventBus(pool) self.bus = EventBus(pool)
self.states = StateMachine(self.bus)
self.services = ServiceRegistry(self.bus, pool) self.services = ServiceRegistry(self.bus, pool)
self.states = StateMachine(self.bus)
def start(self, non_blocking=False): def start(self):
""" Start home assistant. """ Start home assistant. """
Set non_blocking to True if you don't want this method to block
as long as Home Assistant is running. """
Timer(self) Timer(self)
self.bus.fire(EVENT_HOMEASSISTANT_START) self.bus.fire(EVENT_HOMEASSISTANT_START)
if non_blocking: def block_till_stopped(self):
return """ Will register service homeassistant/stop and
will block until called. """
request_shutdown = threading.Event() request_shutdown = threading.Event()
self.services.register(DOMAIN, SERVICE_HOMEASSISTANT_STOP, self.services.register(DOMAIN, SERVICE_HOMEASSISTANT_STOP,
@ -96,6 +94,7 @@ class HomeAssistant(object):
def state_listener(event): def state_listener(event):
""" The listener that listens for specific state changes. """ """ The listener that listens for specific state changes. """
if entity_id == event.data['entity_id'] and \ if entity_id == event.data['entity_id'] and \
'old_state' in event.data and \
_matcher(event.data['old_state'].state, from_state) and \ _matcher(event.data['old_state'].state, from_state) and \
_matcher(event.data['new_state'].state, to_state): _matcher(event.data['new_state'].state, to_state):
@ -235,7 +234,7 @@ class JobPriority(util.OrderedEnum):
return JobPriority.EVENT_DEFAULT return JobPriority.EVENT_DEFAULT
def _create_worker_pool(thread_count=POOL_NUM_THREAD): def create_worker_pool(thread_count=POOL_NUM_THREAD):
""" Creates a worker pool to be used. """ """ Creates a worker pool to be used. """
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -264,22 +263,37 @@ def _create_worker_pool(thread_count=POOL_NUM_THREAD):
return util.ThreadPool(thread_count, job_handler, busy_callback) return util.ThreadPool(thread_count, job_handler, busy_callback)
class EventOrigin(enum.Enum):
""" Distinguish between origin of event. """
# pylint: disable=no-init
local = "LOCAL"
remote = "REMOTE"
def __str__(self):
return self.value
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
class Event(object): class Event(object):
""" Represents an event within the Bus. """ """ Represents an event within the Bus. """
__slots__ = ['event_type', 'data'] __slots__ = ['event_type', 'data', 'origin']
def __init__(self, event_type, data=None): def __init__(self, event_type, data=None, origin=EventOrigin.local):
self.event_type = event_type self.event_type = event_type
self.data = data or {} self.data = data or {}
self.origin = origin
def __repr__(self): def __repr__(self):
# pylint: disable=maybe-no-member
if self.data: if self.data:
return "<Event {}: {}>".format( return "<Event {}[{}]: {}>".format(
self.event_type, util.repr_helper(self.data)) self.event_type, self.origin.value[0],
util.repr_helper(self.data))
else: else:
return "<Event {}>".format(self.event_type) return "<Event {}[{}]>".format(self.event_type,
self.origin.value[0])
class EventBus(object): class EventBus(object):
@ -291,7 +305,7 @@ class EventBus(object):
self._listeners = {} self._listeners = {}
self._logger = logging.getLogger(__name__) self._logger = logging.getLogger(__name__)
self._lock = threading.Lock() self._lock = threading.Lock()
self._pool = pool or _create_worker_pool() self._pool = pool or create_worker_pool()
@property @property
def listeners(self): def listeners(self):
@ -302,7 +316,7 @@ class EventBus(object):
return {key: len(self._listeners[key]) return {key: len(self._listeners[key])
for key in self._listeners} for key in self._listeners}
def fire(self, event_type, event_data=None): def fire(self, event_type, event_data=None, origin=EventOrigin.local):
""" Fire an event. """ """ Fire an event. """
with self._lock: with self._lock:
# Copy the list of the current listeners because some listeners # Copy the list of the current listeners because some listeners
@ -311,7 +325,7 @@ class EventBus(object):
get = self._listeners.get get = self._listeners.get
listeners = get(MATCH_ALL, []) + get(event_type, []) listeners = get(MATCH_ALL, []) + get(event_type, [])
event = Event(event_type, event_data) event = Event(event_type, event_data, origin)
self._logger.info("Bus:Handling {}".format(event)) self._logger.info("Bus:Handling {}".format(event))
@ -390,7 +404,9 @@ class State(object):
""" Static method to create a state from a dict. """ Static method to create a state from a dict.
Ensures: state == State.from_json_dict(state.to_json_dict()) """ Ensures: state == State.from_json_dict(state.to_json_dict()) """
if 'entity_id' not in json_dict and 'state' not in json_dict: if not (json_dict and
'entity_id' in json_dict and
'state' in json_dict):
return None return None
last_changed = json_dict.get('last_changed') last_changed = json_dict.get('last_changed')
@ -429,6 +445,11 @@ class StateMachine(object):
""" List of entity ids that are being tracked. """ """ List of entity ids that are being tracked. """
return list(self._states.keys()) return list(self._states.keys())
def all(self):
""" Returns a dict mapping all entity_ids to their state. """
return {entity_id: state.copy() for entity_id, state
in self._states.items()}
def get(self, entity_id): def get(self, entity_id):
""" Returns the state of the specified entity. """ """ Returns the state of the specified entity. """
state = self._states.get(entity_id) state = self._states.get(entity_id)
@ -456,24 +477,22 @@ class StateMachine(object):
attributes = attributes or {} attributes = attributes or {}
with self._lock: with self._lock:
if entity_id in self._states: old_state = self._states.get(entity_id)
old_state = self._states[entity_id]
if old_state.state != new_state or \ # If state did not exist or is different, set it
old_state.attributes != attributes: if not old_state or \
old_state.state != new_state or \
old_state.attributes != attributes:
state = self._states[entity_id] = \ state = self._states[entity_id] = \
State(entity_id, new_state, attributes) State(entity_id, new_state, attributes)
self._bus.fire(EVENT_STATE_CHANGED, event_data = {'entity_id': entity_id, 'new_state': state}
{'entity_id': entity_id,
'old_state': old_state,
'new_state': state})
else: if old_state:
# If state did not exist yet event_data['old_state'] = old_state
self._states[entity_id] = State(entity_id, new_state,
attributes) self._bus.fire(EVENT_STATE_CHANGED, event_data)
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
@ -501,7 +520,7 @@ class ServiceRegistry(object):
def __init__(self, bus, pool=None): def __init__(self, bus, pool=None):
self._services = {} self._services = {}
self._lock = threading.Lock() self._lock = threading.Lock()
self._pool = pool or _create_worker_pool() self._pool = pool or create_worker_pool()
bus.listen(EVENT_CALL_SERVICE, self._event_to_service_call) bus.listen(EVENT_CALL_SERVICE, self._event_to_service_call)
@property @property

View file

@ -194,13 +194,12 @@ def from_config_file(config_path, enable_logging=True):
add_status("Keyboard", load_module('keyboard').setup(hass)) add_status("Keyboard", load_module('keyboard').setup(hass))
# Init HTTP interface # Init HTTP interface
if has_opt("httpinterface", "api_password"): if has_opt("http", "api_password"):
httpinterface = load_module('httpinterface') http = load_module('http')
httpinterface.HTTPInterface( http.setup(hass, get_opt("http", "api_password"))
hass, get_opt("httpinterface", "api_password"))
add_status("HTTPInterface", True) add_status("HTTP", True)
# Init groups # Init groups
if has_section("group"): if has_section("group"):

View file

@ -73,13 +73,13 @@ import logging
import re import re
import os import os
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import homeassistant as ha import homeassistant as ha
import homeassistant.remote as rem
import homeassistant.util as util import homeassistant.util as util
SERVER_PORT = 8123
HTTP_OK = 200 HTTP_OK = 200
HTTP_CREATED = 201 HTTP_CREATED = 201
HTTP_MOVED_PERMANENTLY = 301 HTTP_MOVED_PERMANENTLY = 301
@ -92,46 +92,49 @@ HTTP_UNPROCESSABLE_ENTITY = 422
URL_ROOT = "/" URL_ROOT = "/"
URL_CHANGE_STATE = "/change_state" URL_CHANGE_STATE = "/change_state"
URL_FIRE_EVENT = "/fire_event" URL_FIRE_EVENT = "/fire_event"
URL_CALL_SERVICE = "/call_service"
URL_API_STATES = "/api/states"
URL_API_STATES_ENTITY = "/api/states/{}"
URL_API_EVENTS = "/api/events"
URL_API_EVENTS_EVENT = "/api/events/{}"
URL_API_SERVICES = "/api/services"
URL_API_SERVICES_SERVICE = "/api/services/{}/{}"
URL_STATIC = "/static/{}" URL_STATIC = "/static/{}"
class HTTPInterface(threading.Thread): def setup(hass, api_password, server_port=None, server_host=None):
""" Provides an HTTP interface for Home Assistant. """ """ Sets up the HTTP API and debug interface. """
server_port = server_port or rem.SERVER_PORT
# pylint: disable=too-many-arguments # If no server host is given, accept all incoming requests
def __init__(self, hass, api_password, server_port=None, server_host=None): server_host = server_host or '0.0.0.0'
threading.Thread.__init__(self)
self.daemon = True server = HomeAssistantHTTPServer((server_host, server_port),
RequestHandler, hass, api_password)
server_port = server_port or SERVER_PORT hass.listen_once_event(
ha.EVENT_HOMEASSISTANT_START,
lambda event:
threading.Thread(target=server.start, daemon=True).start())
# If no server host is given, accept all incoming requests
server_host = server_host or '0.0.0.0'
self.server = HTTPServer((server_host, server_port), RequestHandler) class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer):
""" Handle HTTP requests in a threaded fashion. """
self.server.flash_message = None def __init__(self, server_address, RequestHandlerClass,
self.server.logger = logging.getLogger(__name__) hass, api_password):
self.server.hass = hass super().__init__(server_address, RequestHandlerClass)
self.server.api_password = api_password
hass.listen_once_event(ha.EVENT_HOMEASSISTANT_START, self.hass = hass
lambda event: self.start()) self.api_password = api_password
self.logger = logging.getLogger(__name__)
def run(self): # To store flash messages between sessions
""" Start the HTTP interface. """ self.flash_message = None
self.server.logger.info("Starting")
self.server.serve_forever() # We will lazy init this one if needed
self.event_forwarder = None
def start(self):
""" Starts the server. """
self.logger.info("Starting")
self.serve_forever()
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
@ -139,13 +142,15 @@ class RequestHandler(BaseHTTPRequestHandler):
""" Handles incoming HTTP requests """ """ Handles incoming HTTP requests """
PATHS = [ # debug interface PATHS = [ # debug interface
('GET', '/', '_handle_get_root'), ('GET', URL_ROOT, '_handle_get_root'),
('POST', re.compile(r'/change_state'), '_handle_change_state'), # These get compiled as RE because these methods are reused
('POST', re.compile(r'/fire_event'), '_handle_fire_event'), # by other urls that use url parameters
('POST', re.compile(r'/call_service'), '_handle_call_service'), ('POST', re.compile(URL_CHANGE_STATE), '_handle_change_state'),
('POST', re.compile(URL_FIRE_EVENT), '_handle_fire_event'),
('POST', re.compile(URL_CALL_SERVICE), '_handle_call_service'),
# /states # /states
('GET', '/api/states', '_handle_get_api_states'), ('GET', rem.URL_API_STATES, '_handle_get_api_states'),
('GET', ('GET',
re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'), re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
'_handle_get_api_states_entity'), '_handle_get_api_states_entity'),
@ -154,19 +159,24 @@ class RequestHandler(BaseHTTPRequestHandler):
'_handle_change_state'), '_handle_change_state'),
# /events # /events
('GET', '/api/events', '_handle_get_api_events'), ('GET', rem.URL_API_EVENTS, '_handle_get_api_events'),
('POST', ('POST',
re.compile(r'/api/events/(?P<event_type>[a-zA-Z\._0-9]+)'), re.compile(r'/api/events/(?P<event_type>[a-zA-Z\._0-9]+)'),
'_handle_fire_event'), '_handle_fire_event'),
# /services # /services
('GET', '/api/services', '_handle_get_api_services'), ('GET', rem.URL_API_SERVICES, '_handle_get_api_services'),
('POST', ('POST',
re.compile((r'/api/services/' re.compile((r'/api/services/'
r'(?P<domain>[a-zA-Z\._0-9]+)/' r'(?P<domain>[a-zA-Z\._0-9]+)/'
r'(?P<service>[a-zA-Z\._0-9]+)')), r'(?P<service>[a-zA-Z\._0-9]+)')),
'_handle_call_service'), '_handle_call_service'),
# /event_forwarding
('POST', rem.URL_API_EVENT_FORWARD, '_handle_post_api_event_forward'),
('DELETE', rem.URL_API_EVENT_FORWARD,
'_handle_delete_api_event_forward'),
# Statis files # Statis files
('GET', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'), ('GET', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'),
'_handle_get_static') '_handle_get_static')
@ -193,6 +203,9 @@ class RequestHandler(BaseHTTPRequestHandler):
except KeyError: except KeyError:
api_password = '' api_password = ''
if '_METHOD' in data:
method = data['_METHOD'][0]
if url.path.startswith('/api/'): if url.path.startswith('/api/'):
self.use_json = True self.use_json = True
@ -327,11 +340,9 @@ class RequestHandler(BaseHTTPRequestHandler):
"<th>Attributes</th><th>Last Changed</th>" "<th>Attributes</th><th>Last Changed</th>"
"</tr>").format(self.server.api_password)) "</tr>").format(self.server.api_password))
for entity_id in \ for entity_id, state in \
sorted(self.server.hass.states.entity_ids, sorted(self.server.hass.states.all().items(),
key=lambda key: key.lower()): key=lambda item: item[0].lower()):
state = self.server.hass.states.get(entity_id)
attributes = "<br>".join( attributes = "<br>".join(
["{}: {}".format(attr, state.attributes[attr]) ["{}: {}".format(attr, state.attributes[attr])
@ -512,7 +523,7 @@ class RequestHandler(BaseHTTPRequestHandler):
self._write_json(state.as_dict(), self._write_json(state.as_dict(),
status_code=HTTP_CREATED, status_code=HTTP_CREATED,
location= location=
URL_API_STATES_ENTITY.format(entity_id)) rem.URL_API_STATES_ENTITY.format(entity_id))
else: else:
self._message( self._message(
"State of {} changed to {}".format(entity_id, new_state)) "State of {} changed to {}".format(entity_id, new_state))
@ -534,21 +545,33 @@ class RequestHandler(BaseHTTPRequestHandler):
This handles the following paths: This handles the following paths:
/fire_event /fire_event
/api/events/<event_type> /api/events/<event_type>
Events from /api are threated as remote events.
""" """
try: try:
try: try:
event_type = path_match.group('event_type') event_type = path_match.group('event_type')
event_origin = ha.EventOrigin.remote
except IndexError: except IndexError:
# If group event_type does not exist in path_match # If group event_type does not exist in path_match
event_type = data['event_type'][0] event_type = data['event_type'][0]
event_origin = ha.EventOrigin.local
try: if 'event_data' in data:
event_data = json.loads(data['event_data'][0]) event_data = json.loads(data['event_data'][0])
except KeyError: else:
# Happens if key 'event_data' does not exist
event_data = None event_data = None
self.server.hass.bus.fire(event_type, event_data) # Special case handling for event STATE_CHANGED
# We will try to convert state dicts back to State objects
if event_type == ha.EVENT_STATE_CHANGED and event_data:
for key in ('old_state', 'new_state'):
state = ha.State.from_dict(event_data.get(key))
if state:
event_data[key] = state
self.server.hass.bus.fire(event_type, event_data, event_origin)
self._message("Event {} fired.".format(event_type)) self._message("Event {} fired.".format(event_type))
@ -598,9 +621,8 @@ class RequestHandler(BaseHTTPRequestHandler):
# pylint: disable=unused-argument # pylint: disable=unused-argument
def _handle_get_api_states(self, path_match, data): def _handle_get_api_states(self, path_match, data):
""" Returns the entitie ids which state are being tracked. """ """ Returns a dict containing all entity ids and their state. """
self._write_json( self._write_json(self.server.hass.states.all())
{'entity_ids': list(self.server.hass.states.entity_ids)})
# pylint: disable=unused-argument # pylint: disable=unused-argument
def _handle_get_api_states_entity(self, path_match, data): def _handle_get_api_states_entity(self, path_match, data):
@ -609,10 +631,9 @@ class RequestHandler(BaseHTTPRequestHandler):
state = self.server.hass.states.get(entity_id) state = self.server.hass.states.get(entity_id)
try: if state:
self._write_json(state.as_dict()) self._write_json(state)
except AttributeError: else:
# If state for entity_id does not exist
self._message("State does not exist.", HTTP_UNPROCESSABLE_ENTITY) self._message("State does not exist.", HTTP_UNPROCESSABLE_ENTITY)
def _handle_get_api_events(self, path_match, data): def _handle_get_api_events(self, path_match, data):
@ -623,6 +644,60 @@ class RequestHandler(BaseHTTPRequestHandler):
""" Handles getting overview of services. """ """ Handles getting overview of services. """
self._write_json({'services': self.server.hass.services.services}) self._write_json({'services': self.server.hass.services.services})
def _handle_post_api_event_forward(self, path_match, data):
""" Handles adding an event forwarding target. """
try:
host = data['host'][0]
api_password = data['api_password'][0]
port = int(data['port'][0]) if 'port' in data else None
if self.server.event_forwarder is None:
self.server.event_forwarder = \
rem.EventForwarder(self.server.hass)
api = rem.API(host, api_password, port)
self.server.event_forwarder.connect(api)
self._message("Event forwarding setup.")
except KeyError:
# Occurs if domain or service does not exist in data
self._message("No host or api_password received.",
HTTP_BAD_REQUEST)
except ValueError:
# Occurs during error parsing port
self._message(
"Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY)
def _handle_delete_api_event_forward(self, path_match, data):
""" Handles deleting an event forwarding target. """
try:
host = data['host'][0]
port = int(data['port'][0]) if 'port' in data else None
if self.server.event_forwarder is not None:
api = rem.API(host, None, port)
self.server.event_forwarder.disconnect(api)
self._message("Event forwarding cancelled.")
except KeyError:
# Occurs if domain or service does not exist in data
self._message("No host or api_password received.",
HTTP_BAD_REQUEST)
except ValueError:
# Occurs during error parsing port
self._message(
"Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY)
def _handle_get_static(self, path_match, data): def _handle_get_static(self, path_match, data):
""" Returns a static file. """ """ Returns a static file. """
req_file = util.sanitize_filename(path_match.group('file')) req_file = util.sanitize_filename(path_match.group('file'))
@ -680,4 +755,5 @@ class RequestHandler(BaseHTTPRequestHandler):
if data: if data:
self.wfile.write( self.wfile.write(
json.dumps(data, indent=4, sort_keys=True).encode("UTF-8")) json.dumps(data, indent=4, sort_keys=True,
cls=rem.JSONEncoder).encode("UTF-8"))

View file

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 318 B

View file

@ -17,24 +17,37 @@ import urllib.parse
import requests import requests
import homeassistant as ha import homeassistant as ha
import homeassistant.components.httpinterface as hah
SERVER_PORT = 8123
URL_API_STATES = "/api/states"
URL_API_STATES_ENTITY = "/api/states/{}"
URL_API_EVENTS = "/api/events"
URL_API_EVENTS_EVENT = "/api/events/{}"
URL_API_SERVICES = "/api/services"
URL_API_SERVICES_SERVICE = "/api/services/{}/{}"
URL_API_EVENT_FORWARD = "/api/event_forwarding"
METHOD_GET = "get" METHOD_GET = "get"
METHOD_POST = "post" METHOD_POST = "post"
def _setup_call_api(host, port, api_password): class API(object):
""" Helper method to setup a call api method. """ """ Object to pass around Home Assistant API location and credentials. """
port = port or hah.SERVER_PORT # pylint: disable=too-few-public-methods
base_url = "http://{}:{}".format(host, port) def __init__(self, host, api_password, port=None):
self.host = host
self.port = port or SERVER_PORT
self.api_password = api_password
self.base_url = "http://{}:{}".format(host, self.port)
def _call_api(method, path, data=None): def __call__(self, method, path, data=None):
""" Makes a call to the Home Assistant api. """ """ Makes a call to the Home Assistant api. """
data = data or {} data = data or {}
data['api_password'] = api_password data['api_password'] = self.api_password
url = urllib.parse.urljoin(base_url, path) url = urllib.parse.urljoin(self.base_url, path)
try: try:
if method == METHOD_GET: if method == METHOD_GET:
@ -46,7 +59,134 @@ def _setup_call_api(host, port, api_password):
logging.getLogger(__name__).exception("Error connecting to server") logging.getLogger(__name__).exception("Error connecting to server")
raise ha.HomeAssistantError("Error connecting to server") raise ha.HomeAssistantError("Error connecting to server")
return _call_api
class HomeAssistant(ha.HomeAssistant):
""" Home Assistant that forwards work. """
# pylint: disable=super-init-not-called
def __init__(self, local_api, remote_api):
self.local_api = local_api
self.remote_api = remote_api
self._pool = pool = ha.create_worker_pool()
self.bus = EventBus(remote_api, pool)
self.services = ha.ServiceRegistry(self.bus, pool)
self.states = StateMachine(self.bus, self.remote_api)
def start(self):
ha.Timer(self)
# Setup that events from remote_api get forwarded to local_api
connect_remote_events(self.remote_api, self.local_api)
self.bus.fire(ha.EVENT_HOMEASSISTANT_START,
origin=ha.EventOrigin.remote)
class EventBus(ha.EventBus):
""" EventBus implementation that forwards fire_event to remote API. """
def __init__(self, api, pool=None):
super().__init__(pool)
self._api = api
def fire(self, event_type, event_data=None, origin=ha.EventOrigin.local):
""" Forward local events to remote target,
handles remote event as usual. """
# All local events that are not TIME_CHANGED are forwarded to API
if origin == ha.EventOrigin.local and \
event_type != ha.EVENT_TIME_CHANGED:
fire_event(self._api, event_type, event_data)
else:
super().fire(event_type, event_data, origin)
class EventForwarder(object):
""" Listens for events and forwards to specified APIs. """
def __init__(self, hass, restrict_origin=None):
self.hass = hass
self.restrict_origin = restrict_origin
self.logger = logging.getLogger(__name__)
# We use a tuple (host, port) as key to ensure
# that we do not forward to the same host twice
self._targets = {}
self._lock = threading.Lock()
def connect(self, api):
"""
Attach to a HA instance and forward events.
Will overwrite old target if one exists with same host/port.
"""
with self._lock:
if len(self._targets) == 0:
# First target we get, setup listener for events
self.hass.bus.listen(ha.MATCH_ALL, self._event_listener)
key = (api.host, api.port)
self._targets[key] = api
def disconnect(self, api):
""" Removes target from being forwarded to. """
with self._lock:
key = (api.host, api.port)
did_remove = self._targets.pop(key, None) is None
if len(self._targets) == 0:
# Remove event listener if no forwarding targets present
self.hass.bus.remove_listener(ha.MATCH_ALL,
self._event_listener)
return did_remove
def _event_listener(self, event):
""" Listen and forwards all events. """
with self._lock:
# We don't forward time events or, if enabled, non-local events
if event.event_type == ha.EVENT_TIME_CHANGED or \
(self.restrict_origin and event.origin != self.restrict_origin):
return
for api in self._targets.values():
fire_event(api, event.event_type, event.data, self.logger)
class StateMachine(ha.StateMachine):
"""
Fires set events to an API.
Uses state_change events to track states.
"""
def __init__(self, bus, api):
super().__init__(None)
self.logger = logging.getLogger(__name__)
self._api = api
self.mirror()
bus.listen(ha.EVENT_STATE_CHANGED, self._state_changed_listener)
def set(self, entity_id, new_state, attributes=None):
""" Calls set_state on remote API . """
set_state(self._api, entity_id, new_state, attributes)
def mirror(self):
""" Discards current data and mirrors the remote state machine. """
self._states = get_states(self._api, self.logger)
def _state_changed_listener(self, event):
""" Listens for state changed events and applies them. """
self._states[event.data['entity_id']] = event.data['new_state']
class JSONEncoder(json.JSONEncoder): class JSONEncoder(json.JSONEncoder):
@ -61,212 +201,168 @@ class JSONEncoder(json.JSONEncoder):
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, obj)
class EventBus(object): def connect_remote_events(from_api, to_api):
""" Allows to interface with a Home Assistant EventBus via the API. """ """ Sets up from_api to forward all events to to_api. """
def __init__(self, host, api_password, port=None): data = {'host': to_api.host, 'api_password': to_api.api_password}
self.logger = logging.getLogger(__name__)
self._call_api = _setup_call_api(host, port, api_password) if to_api.port is not None:
data['port'] = to_api.port
@property try:
def listeners(self): from_api(METHOD_POST, URL_API_EVENT_FORWARD, data)
""" List of events that is being listened for. """
try:
req = self._call_api(METHOD_GET, hah.URL_API_EVENTS)
if req.status_code == 200: except ha.HomeAssistantError:
data = req.json() pass
return data['event_listeners']
else:
raise ha.HomeAssistantError(
"Got unexpected result (3): {}.".format(req.text))
except ValueError: # If req.json() can't parse the json
self.logger.exception("Bus:Got unexpected result")
raise ha.HomeAssistantError(
"Got unexpected result: {}".format(req.text))
except KeyError: # If not all expected keys are in the returned JSON
self.logger.exception("Bus:Got unexpected result (2)")
raise ha.HomeAssistantError(
"Got unexpected result (2): {}".format(req.text))
def fire(self, event_type, event_data=None):
""" Fire an event. """
if event_data:
data = {'event_data': json.dumps(event_data, cls=JSONEncoder)}
else:
data = None
req = self._call_api(METHOD_POST,
hah.URL_API_EVENTS_EVENT.format(event_type),
data)
if req.status_code != 200:
error = "Error firing event: {} - {}".format(
req.status_code, req.text)
self.logger.error("Bus:{}".format(error))
raise ha.HomeAssistantError(error)
class StateMachine(object): def disconnect_remote_events(from_api, to_api):
""" Allows to interface with a Home Assistant StateMachine via the API. """ """ Disconnects forwarding events from from_api to to_api. """
data = {'host': to_api.host, '_METHOD': 'DELETE'}
def __init__(self, host, api_password, port=None): if to_api.port is not None:
self._call_api = _setup_call_api(host, port, api_password) data['port'] = to_api.port
self.lock = threading.Lock() try:
self.logger = logging.getLogger(__name__) from_api(METHOD_POST, URL_API_EVENT_FORWARD, data)
@property except ha.HomeAssistantError:
def entity_ids(self): pass
""" List of entity ids which states are being tracked. """
try:
req = self._call_api(METHOD_GET, hah.URL_API_STATES)
return req.json()['entity_ids']
except requests.exceptions.ConnectionError:
self.logger.exception("StateMachine:Error connecting to server")
return []
except ValueError: # If req.json() can't parse the json
self.logger.exception("StateMachine:Got unexpected result")
return []
except KeyError: # If 'entity_ids' key not in parsed json
self.logger.exception("StateMachine:Got unexpected result (2)")
return []
def set(self, entity_id, new_state, attributes=None):
""" Set the state of a entity, add entity if it does not exist.
Attributes is an optional dict to specify attributes of this state. """
attributes = attributes or {}
self.lock.acquire()
data = {'new_state': new_state,
'attributes': json.dumps(attributes)}
try:
req = self._call_api(METHOD_POST,
hah.URL_API_STATES_ENTITY.format(entity_id),
data)
if req.status_code != 201:
error = "Error changing state: {} - {}".format(
req.status_code, req.text)
self.logger.error("StateMachine:{}".format(error))
raise ha.HomeAssistantError(error)
except requests.exceptions.ConnectionError:
self.logger.exception("StateMachine:Error connecting to server")
raise ha.HomeAssistantError("Error connecting to server")
finally:
self.lock.release()
def get(self, entity_id):
""" Returns the state of the specified entity. """
try:
req = self._call_api(METHOD_GET,
hah.URL_API_STATES_ENTITY.format(entity_id))
if req.status_code == 200:
data = req.json()
return ha.State.from_dict(data)
elif req.status_code == 422:
# Entity does not exist
return None
else:
raise ha.HomeAssistantError(
"Got unexpected result (3): {}.".format(req.text))
except requests.exceptions.ConnectionError:
self.logger.exception("StateMachine:Error connecting to server")
raise ha.HomeAssistantError("Error connecting to server")
except ValueError: # If req.json() can't parse the json
self.logger.exception("StateMachine:Got unexpected result")
raise ha.HomeAssistantError(
"Got unexpected result: {}".format(req.text))
except KeyError: # If not all expected keys are in the returned JSON
self.logger.exception("StateMachine:Got unexpected result (2)")
raise ha.HomeAssistantError(
"Got unexpected result (2): {}".format(req.text))
def is_state(self, entity_id, state):
""" Returns True if entity exists and is specified state. """
try:
return self.get(entity_id).state == state
except AttributeError:
# get returned None
return False
class ServiceRegistry(object): def get_event_listeners(api, logger=None):
""" Allows to interface with a Home Assistant ServiceRegistry """ List of events that is being listened for. """
via the API. """ try:
req = api(METHOD_GET, URL_API_EVENTS)
def __init__(self, host, api_password, port=None): return req.json()['event_listeners'] if req.status_code == 200 else {}
self.logger = logging.getLogger(__name__)
self._call_api = _setup_call_api(host, port, api_password) except (ha.HomeAssistantError, ValueError, KeyError):
# ValueError if req.json() can't parse the json
# KeyError if 'event_listeners' not found in parsed json
if logger:
logger.exception("Bus:Got unexpected result")
@property return {}
def services(self):
""" List the available services. """
try:
req = self._call_api(METHOD_GET, hah.URL_API_SERVICES)
if req.status_code == 200:
data = req.json()
return data['services'] def fire_event(api, event_type, event_data=None, logger=None):
""" Fire an event at remote API. """
else: if event_data:
raise ha.HomeAssistantError( data = {'event_data': json.dumps(event_data, cls=JSONEncoder)}
"Got unexpected result (3): {}.".format(req.text)) else:
data = None
except ValueError: # If req.json() can't parse the json try:
self.logger.exception("ServiceRegistry:Got unexpected result") req = api(METHOD_POST, URL_API_EVENTS_EVENT.format(event_type), data)
raise ha.HomeAssistantError(
"Got unexpected result: {}".format(req.text))
except KeyError: # If not all expected keys are in the returned JSON if req.status_code != 200 and logger:
self.logger.exception("ServiceRegistry:Got unexpected result (2)") logger.error(
raise ha.HomeAssistantError( "Error firing event: {} - {}".format(
"Got unexpected result (2): {}".format(req.text)) req.status_code, req.text))
def call_service(self, domain, service, service_data=None): except ha.HomeAssistantError:
""" Calls a service. """ pass
if service_data:
data = {'service_data': json.dumps(service_data)}
else:
data = None
req = self._call_api(METHOD_POST, def get_state(api, entity_id, logger=None):
hah.URL_API_SERVICES_SERVICE.format( """ Queries given API for state of entity_id. """
domain, service),
data)
if req.status_code != 200: try:
error = "Error calling service: {} - {}".format( req = api(METHOD_GET,
req.status_code, req.text) URL_API_STATES_ENTITY.format(entity_id))
self.logger.error("ServiceRegistry:{}".format(error)) # req.status_code == 422 if entity does not exist
return ha.State.from_dict(req.json()) \
if req.status_code == 200 else None
except (ha.HomeAssistantError, ValueError):
# ValueError if req.json() can't parse the json
if logger:
logger.exception("Error getting state")
return None
def get_states(api, logger=None):
""" Queries given API for all states. """
try:
req = api(METHOD_GET,
URL_API_STATES)
json_result = req.json()
states = {}
for entity_id, state_dict in json_result.items():
state = ha.State.from_dict(state_dict)
if state:
states[entity_id] = state
return states
except (ha.HomeAssistantError, ValueError, AttributeError):
# ValueError if req.json() can't parse the json
# AttributeError if parsed JSON was not a dict
if logger:
logger.exception("Error getting state")
return {}
def set_state(api, entity_id, new_state, attributes=None, logger=None):
""" Tells API to update state for entity_id. """
attributes = attributes or {}
data = {'new_state': new_state,
'attributes': json.dumps(attributes)}
try:
req = api(METHOD_POST,
URL_API_STATES_ENTITY.format(entity_id),
data)
if req.status_code != 201 and logger:
logger.error(
"Error changing state: {} - {}".format(
req.status_code, req.text))
except ha.HomeAssistantError:
if logger:
logger.exception("Error setting state to server")
def is_state(api, entity_id, state, logger=None):
""" Queries API to see if entity_id is specified state. """
cur_state = get_state(api, entity_id, logger)
return cur_state and cur_state.state == state
def get_services(api, logger=None):
""" Returns a dict with per domain the available services at API. """
try:
req = api(METHOD_GET, URL_API_SERVICES)
return req.json()['services'] if req.status_code == 200 else {}
except (ha.HomeAssistantError, ValueError, KeyError):
# ValueError if req.json() can't parse the json
# KeyError if not all expected keys are in the returned JSON
if logger:
logger.exception("ServiceRegistry:Got unexpected result")
return {}
def call_service(api, domain, service, service_data=None, logger=None):
""" Calls a service at the remote API. """
event_data = service_data or {}
event_data[ha.ATTR_DOMAIN] = domain
event_data[ha.ATTR_SERVICE] = service
fire_event(api, ha.EVENT_CALL_SERVICE, event_data, logger)

View file

@ -13,11 +13,11 @@ import requests
import homeassistant as ha import homeassistant as ha
import homeassistant.remote as remote import homeassistant.remote as remote
import homeassistant.components.httpinterface as hah import homeassistant.components.http as http
API_PASSWORD = "test1234" API_PASSWORD = "test1234"
HTTP_BASE_URL = "http://127.0.0.1:{}".format(hah.SERVER_PORT) HTTP_BASE_URL = "http://127.0.0.1:{}".format(remote.SERVER_PORT)
def _url(path=""): def _url(path=""):
@ -28,6 +28,7 @@ def _url(path=""):
class HAHelper(object): # pylint: disable=too-few-public-methods class HAHelper(object): # pylint: disable=too-few-public-methods
""" Helper class to keep track of current running HA instance. """ """ Helper class to keep track of current running HA instance. """
hass = None hass = None
slave = None
def ensure_homeassistant_started(): def ensure_homeassistant_started():
@ -39,9 +40,9 @@ def ensure_homeassistant_started():
hass.bus.listen('test_event', len) hass.bus.listen('test_event', len)
hass.states.set('test', 'a_state') hass.states.set('test', 'a_state')
hah.HTTPInterface(hass, API_PASSWORD) http.setup(hass, API_PASSWORD)
hass.bus.fire(ha.EVENT_HOMEASSISTANT_START) hass.start()
# Give objects time to startup # Give objects time to startup
time.sleep(1) time.sleep(1)
@ -51,6 +52,26 @@ def ensure_homeassistant_started():
return HAHelper.hass return HAHelper.hass
def ensure_slave_started():
""" Ensure a home assistant slave is started. """
if not HAHelper.slave:
local_api = remote.API("127.0.0.1", API_PASSWORD, 8124)
remote_api = remote.API("127.0.0.1", API_PASSWORD)
slave = remote.HomeAssistant(local_api, remote_api)
http.setup(slave, API_PASSWORD, 8124)
slave.start()
# Give objects time to startup
time.sleep(1)
HAHelper.slave = slave
return HAHelper.slave
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
class TestHTTPInterface(unittest.TestCase): class TestHTTPInterface(unittest.TestCase):
""" Test the HTTP debug interface and API. """ """ Test the HTTP debug interface and API. """
@ -75,12 +96,12 @@ class TestHTTPInterface(unittest.TestCase):
""" Test if we get access denied if we omit or provide """ Test if we get access denied if we omit or provide
a wrong api password. """ a wrong api password. """
req = requests.get( req = requests.get(
_url(hah.URL_API_STATES_ENTITY.format("test"))) _url(remote.URL_API_STATES_ENTITY.format("test")))
self.assertEqual(req.status_code, 401) self.assertEqual(req.status_code, 401)
req = requests.get( req = requests.get(
_url(hah.URL_API_STATES_ENTITY.format("test")), _url(remote.URL_API_STATES_ENTITY.format("test")),
params={"api_password": "not the password"}) params={"api_password": "not the password"})
self.assertEqual(req.status_code, 401) self.assertEqual(req.status_code, 401)
@ -89,7 +110,7 @@ class TestHTTPInterface(unittest.TestCase):
""" Test if we can change a state from the debug interface. """ """ Test if we can change a state from the debug interface. """
self.hass.states.set("test.test", "not_to_be_set") self.hass.states.set("test.test", "not_to_be_set")
requests.post(_url(hah.URL_CHANGE_STATE), requests.post(_url(http.URL_CHANGE_STATE),
data={"entity_id": "test.test", data={"entity_id": "test.test",
"new_state": "debug_state_change2", "new_state": "debug_state_change2",
"api_password": API_PASSWORD}) "api_password": API_PASSWORD})
@ -110,7 +131,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.listen_once_event("test_event_with_data", listener) self.hass.listen_once_event("test_event_with_data", listener)
requests.post( requests.post(
_url(hah.URL_FIRE_EVENT), _url(http.URL_FIRE_EVENT),
data={"event_type": "test_event_with_data", data={"event_type": "test_event_with_data",
"event_data": '{"test": 1}', "event_data": '{"test": 1}',
"api_password": API_PASSWORD}) "api_password": API_PASSWORD})
@ -122,18 +143,20 @@ class TestHTTPInterface(unittest.TestCase):
def test_api_list_state_entities(self): def test_api_list_state_entities(self):
""" Test if the debug interface allows us to list state entities. """ """ Test if the debug interface allows us to list state entities. """
req = requests.get(_url(hah.URL_API_STATES), req = requests.get(_url(remote.URL_API_STATES),
data={"api_password": API_PASSWORD}) data={"api_password": API_PASSWORD})
data = req.json() remote_data = req.json()
self.assertEqual(list(self.hass.states.entity_ids), local_data = {entity_id: state.as_dict() for entity_id, state
data['entity_ids']) in self.hass.states.all().items()}
self.assertEqual(local_data, remote_data)
def test_api_get(self): def test_api_get(self):
""" Test if the debug interface allows us to get a state. """ """ Test if the debug interface allows us to get a state. """
req = requests.get( req = requests.get(
_url(hah.URL_API_STATES_ENTITY.format("test")), _url(remote.URL_API_STATES_ENTITY.format("test")),
data={"api_password": API_PASSWORD}) data={"api_password": API_PASSWORD})
data = ha.State.from_dict(req.json()) data = ha.State.from_dict(req.json())
@ -147,7 +170,7 @@ class TestHTTPInterface(unittest.TestCase):
def test_api_get_non_existing_state(self): def test_api_get_non_existing_state(self):
""" Test if the debug interface allows us to get a state. """ """ Test if the debug interface allows us to get a state. """
req = requests.get( req = requests.get(
_url(hah.URL_API_STATES_ENTITY.format("does_not_exist")), _url(remote.URL_API_STATES_ENTITY.format("does_not_exist")),
params={"api_password": API_PASSWORD}) params={"api_password": API_PASSWORD})
self.assertEqual(req.status_code, 422) self.assertEqual(req.status_code, 422)
@ -157,7 +180,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.states.set("test.test", "not_to_be_set") self.hass.states.set("test.test", "not_to_be_set")
requests.post(_url(hah.URL_API_STATES_ENTITY.format("test.test")), requests.post(_url(remote.URL_API_STATES_ENTITY.format("test.test")),
data={"new_state": "debug_state_change2", data={"new_state": "debug_state_change2",
"api_password": API_PASSWORD}) "api_password": API_PASSWORD})
@ -172,7 +195,7 @@ class TestHTTPInterface(unittest.TestCase):
new_state = "debug_state_change" new_state = "debug_state_change"
req = requests.post( req = requests.post(
_url(hah.URL_API_STATES_ENTITY.format( _url(remote.URL_API_STATES_ENTITY.format(
"test_entity_that_does_not_exist")), "test_entity_that_does_not_exist")),
data={"new_state": new_state, data={"new_state": new_state,
"api_password": API_PASSWORD}) "api_password": API_PASSWORD})
@ -195,7 +218,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.listen_once_event("test.event_no_data", listener) self.hass.listen_once_event("test.event_no_data", listener)
requests.post( requests.post(
_url(hah.URL_API_EVENTS_EVENT.format("test.event_no_data")), _url(remote.URL_API_EVENTS_EVENT.format("test.event_no_data")),
data={"api_password": API_PASSWORD}) data={"api_password": API_PASSWORD})
# Allow the event to take place # Allow the event to take place
@ -217,7 +240,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.listen_once_event("test_event_with_data", listener) self.hass.listen_once_event("test_event_with_data", listener)
requests.post( requests.post(
_url(hah.URL_API_EVENTS_EVENT.format("test_event_with_data")), _url(remote.URL_API_EVENTS_EVENT.format("test_event_with_data")),
data={"event_data": '{"test": 1}', data={"event_data": '{"test": 1}',
"api_password": API_PASSWORD}) "api_password": API_PASSWORD})
@ -238,7 +261,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.listen_once_event("test_event_with_bad_data", listener) self.hass.listen_once_event("test_event_with_bad_data", listener)
req = requests.post( req = requests.post(
_url(hah.URL_API_EVENTS_EVENT.format("test_event")), _url(remote.URL_API_EVENTS_EVENT.format("test_event")),
data={"event_data": 'not json', data={"event_data": 'not json',
"api_password": API_PASSWORD}) "api_password": API_PASSWORD})
@ -250,7 +273,7 @@ class TestHTTPInterface(unittest.TestCase):
def test_api_get_event_listeners(self): def test_api_get_event_listeners(self):
""" Test if we can get the list of events being listened for. """ """ Test if we can get the list of events being listened for. """
req = requests.get(_url(hah.URL_API_EVENTS), req = requests.get(_url(remote.URL_API_EVENTS),
params={"api_password": API_PASSWORD}) params={"api_password": API_PASSWORD})
data = req.json() data = req.json()
@ -259,7 +282,7 @@ class TestHTTPInterface(unittest.TestCase):
def test_api_get_services(self): def test_api_get_services(self):
""" Test if we can get a dict describing current services. """ """ Test if we can get a dict describing current services. """
req = requests.get(_url(hah.URL_API_SERVICES), req = requests.get(_url(remote.URL_API_SERVICES),
params={"api_password": API_PASSWORD}) params={"api_password": API_PASSWORD})
data = req.json() data = req.json()
@ -277,7 +300,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.services.register("test_domain", "test_service", listener) self.hass.services.register("test_domain", "test_service", listener)
requests.post( requests.post(
_url(hah.URL_API_SERVICES_SERVICE.format( _url(remote.URL_API_SERVICES_SERVICE.format(
"test_domain", "test_service")), "test_domain", "test_service")),
data={"api_password": API_PASSWORD}) data={"api_password": API_PASSWORD})
@ -299,7 +322,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.services.register("test_domain", "test_service", listener) self.hass.services.register("test_domain", "test_service", listener)
requests.post( requests.post(
_url(hah.URL_API_SERVICES_SERVICE.format( _url(remote.URL_API_SERVICES_SERVICE.format(
"test_domain", "test_service")), "test_domain", "test_service")),
data={"service_data": '{"test": 1}', data={"service_data": '{"test": 1}',
"api_password": API_PASSWORD}) "api_password": API_PASSWORD})
@ -310,7 +333,7 @@ class TestHTTPInterface(unittest.TestCase):
self.assertEqual(len(test_value), 1) self.assertEqual(len(test_value), 1)
class TestRemote(unittest.TestCase): class TestRemoteMethods(unittest.TestCase):
""" Test the homeassistant.remote module. """ """ Test the homeassistant.remote module. """
@classmethod @classmethod
@ -318,134 +341,115 @@ class TestRemote(unittest.TestCase):
""" things to be run when tests are started. """ """ things to be run when tests are started. """
cls.hass = ensure_homeassistant_started() cls.hass = ensure_homeassistant_started()
cls.remote_sm = remote.StateMachine("127.0.0.1", API_PASSWORD) cls.api = remote.API("127.0.0.1", API_PASSWORD)
cls.remote_eb = remote.EventBus("127.0.0.1", API_PASSWORD)
cls.remote_sr = remote.ServiceRegistry("127.0.0.1", API_PASSWORD)
cls.sm_with_remote_eb = ha.StateMachine(cls.remote_eb)
cls.sm_with_remote_eb.set("test", "a_state")
# pylint: disable=invalid-name def test_get_event_listeners(self):
def test_remote_sm_list_state_entities(self): """ Test Python API get_event_listeners. """
""" Test if the debug interface allows us to list state entity ids. """
self.assertEqual(list(self.hass.states.entity_ids), self.assertEqual(
self.remote_sm.entity_ids) remote.get_event_listeners(self.api), self.hass.bus.listeners)
def test_remote_sm_get(self): def test_fire_event(self):
""" Test if debug interface allows us to get state of an entity. """ """ Test Python API fire_event. """
remote_state = self.remote_sm.get("test")
state = self.hass.states.get("test")
self.assertEqual(remote_state.state, state.state)
self.assertEqual(remote_state.last_changed, state.last_changed)
self.assertEqual(remote_state.attributes, state.attributes)
def test_remote_sm_get_non_existing_state(self):
""" Test remote state machine to get state of non existing entity. """
self.assertEqual(self.remote_sm.get("test_does_not_exist"), None)
def test_remote_sm_state_change(self):
""" Test if we can change the state of an existing entity. """
self.remote_sm.set("test", "set_remotely", {"test": 1})
state = self.hass.states.get("test")
self.assertEqual(state.state, "set_remotely")
self.assertEqual(state.attributes['test'], 1)
def test_remote_eb_listening_for_same(self):
""" Test if remote EB correctly reports listener overview. """
self.assertEqual(self.hass.bus.listeners,
self.remote_eb.listeners)
# pylint: disable=invalid-name
def test_remote_eb_fire_event_with_no_data(self):
""" Test if the remote bus allows us to fire an event. """
test_value = [] test_value = []
def listener(event): # pylint: disable=unused-argument def listener(event): # pylint: disable=unused-argument
""" Helper method that will verify our event got called. """ """ Helper method that will verify our event got called. """
test_value.append(1) test_value.append(1)
self.hass.listen_once_event("test_event_no_data", listener) self.hass.listen_once_event("test.event_no_data", listener)
self.remote_eb.fire("test_event_no_data") remote.fire_event(self.api, "test.event_no_data")
# Allow the event to take place # Allow the event to take place
time.sleep(1) time.sleep(1)
self.assertEqual(len(test_value), 1) self.assertEqual(len(test_value), 1)
# pylint: disable=invalid-name def test_get_state(self):
def test_remote_eb_fire_event_with_data(self): """ Test Python API get_state. """
""" Test if the remote bus allows us to fire an event. """
test_value = []
def listener(event): # pylint: disable=unused-argument self.assertEqual(
""" Helper method that will verify our event got called. """ remote.get_state(self.api, 'test'), self.hass.states.get('test'))
if event.data["test"] == 1:
test_value.append(1)
self.hass.listen_once_event("test_event_with_data", listener) def test_get_states(self):
""" Test Python API get_state_entity_ids. """
self.remote_eb.fire("test_event_with_data", {"test": 1}) self.assertEqual(
remote.get_states(self.api), self.hass.states.all())
# Allow the event to take place def test_set_state(self):
time.sleep(1) """ Test Python API set_state. """
remote.set_state(self.api, 'test', 'set_test')
self.assertEqual(len(test_value), 1) self.assertEqual(self.hass.states.get('test').state, 'set_test')
# pylint: disable=invalid-name def test_is_state(self):
def test_remote_sr_call_service_with_no_data(self): """ Test Python API is_state. """
""" Test if the remote bus allows us to fire a service. """
self.assertEqual(
remote.is_state(self.api, 'test',
self.hass.states.get('test').state),
True)
def test_get_services(self):
""" Test Python API get_services. """
self.assertEqual(
remote.get_services(self.api), self.hass.services.services)
def test_call_service(self):
""" Test Python API call_service. """
test_value = [] test_value = []
def listener(service_call): # pylint: disable=unused-argument def listener(service_call): # pylint: disable=unused-argument
""" Helper method that will verify our service got called. """ """ Helper method that will verify that our service got called. """
test_value.append(1) test_value.append(1)
self.hass.services.register("test_domain", "test_service", listener) self.hass.services.register("test_domain", "test_service", listener)
self.remote_sr.call_service("test_domain", "test_service") remote.call_service(self.api, "test_domain", "test_service")
# Allow the service call to take place
time.sleep(1)
self.assertEqual(len(test_value), 1)
# pylint: disable=invalid-name
def test_remote_sr_call_service_with_data(self):
""" Test if the remote bus allows us to fire an event. """
test_value = []
def listener(service_call): # pylint: disable=unused-argument
""" Helper method that will verify our service got called. """
if service_call.data["test"] == 1:
test_value.append(1)
self.hass.services.register("test_domain", "test_service", listener)
self.remote_sr.call_service("test_domain", "test_service", {"test": 1})
# Allow the event to take place # Allow the event to take place
time.sleep(1) time.sleep(1)
self.assertEqual(len(test_value), 1) self.assertEqual(len(test_value), 1)
def test_local_sm_with_remote_eb(self):
""" Test if we get the event if we change a state on a class TestRemoteClasses(unittest.TestCase):
StateMachine connected to a remote bus. """ """ Test the homeassistant.remote module. """
@classmethod
def setUpClass(cls): # pylint: disable=invalid-name
""" things to be run when tests are started. """
cls.hass = ensure_homeassistant_started()
cls.slave = ensure_slave_started()
def test_statemachine_init(self):
""" Tests if remote.StateMachine copies all states on init. """
self.assertEqual(self.hass.states.all(), self.slave.states.all())
def test_statemachine_set(self):
""" Tests if setting the state on a slave is recorded. """
self.slave.states.set("test", "remote.statemachine test")
# Allow interaction between 2 instances
time.sleep(1)
self.assertEqual(self.slave.states.get("test").state,
"remote.statemachine test")
def test_eventbus_fire(self):
""" Test if events fired from the eventbus get fired. """
test_value = [] test_value = []
def listener(event): # pylint: disable=unused-argument def listener(event): # pylint: disable=unused-argument
""" Helper method that will verify our event got called. """ """ Helper method that will verify our event got called. """
test_value.append(1) test_value.append(1)
self.hass.listen_once_event(ha.EVENT_STATE_CHANGED, listener) self.slave.listen_once_event("test.event_no_data", listener)
self.sm_with_remote_eb.set("test", "local sm with remote eb") self.slave.bus.fire("test.event_no_data")
# Allow the event to take place # Allow the event to take place
time.sleep(1) time.sleep(1)

View file

@ -3,4 +3,6 @@
import homeassistant import homeassistant
import homeassistant.bootstrap import homeassistant.bootstrap
homeassistant.bootstrap.from_config_file("home-assistant.conf").start() hass = homeassistant.bootstrap.from_config_file("home-assistant.conf")
hass.start()
hass.block_till_stopped()