Remote instances are now 100% operational

pull/5/head
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`
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
------------
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'.
@ -238,3 +111,175 @@ Registers service `downloader/download_file` that will download files. File to d
**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
longitude=-117.22743
[httpinterface]
[http]
api_password=mypass
[light.hue]

View File

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

View File

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

View File

@ -73,13 +73,13 @@ import logging
import re
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
from urllib.parse import urlparse, parse_qs
import homeassistant as ha
import homeassistant.remote as rem
import homeassistant.util as util
SERVER_PORT = 8123
HTTP_OK = 200
HTTP_CREATED = 201
HTTP_MOVED_PERMANENTLY = 301
@ -92,46 +92,49 @@ HTTP_UNPROCESSABLE_ENTITY = 422
URL_ROOT = "/"
URL_CHANGE_STATE = "/change_state"
URL_FIRE_EVENT = "/fire_event"
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_CALL_SERVICE = "/call_service"
URL_STATIC = "/static/{}"
class HTTPInterface(threading.Thread):
""" Provides an HTTP interface for Home Assistant. """
def setup(hass, api_password, server_port=None, server_host=None):
""" Sets up the HTTP API and debug interface. """
server_port = server_port or rem.SERVER_PORT
# pylint: disable=too-many-arguments
def __init__(self, hass, api_password, server_port=None, server_host=None):
threading.Thread.__init__(self)
# If no server host is given, accept all incoming requests
server_host = server_host or '0.0.0.0'
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
self.server.logger = logging.getLogger(__name__)
self.server.hass = hass
self.server.api_password = api_password
def __init__(self, server_address, RequestHandlerClass,
hass, api_password):
super().__init__(server_address, RequestHandlerClass)
hass.listen_once_event(ha.EVENT_HOMEASSISTANT_START,
lambda event: self.start())
self.hass = hass
self.api_password = api_password
self.logger = logging.getLogger(__name__)
def run(self):
""" Start the HTTP interface. """
self.server.logger.info("Starting")
# To store flash messages between sessions
self.flash_message = None
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
@ -139,13 +142,15 @@ class RequestHandler(BaseHTTPRequestHandler):
""" Handles incoming HTTP requests """
PATHS = [ # debug interface
('GET', '/', '_handle_get_root'),
('POST', re.compile(r'/change_state'), '_handle_change_state'),
('POST', re.compile(r'/fire_event'), '_handle_fire_event'),
('POST', re.compile(r'/call_service'), '_handle_call_service'),
('GET', URL_ROOT, '_handle_get_root'),
# These get compiled as RE because these methods are reused
# by other urls that use url parameters
('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
('GET', '/api/states', '_handle_get_api_states'),
('GET', rem.URL_API_STATES, '_handle_get_api_states'),
('GET',
re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
'_handle_get_api_states_entity'),
@ -154,19 +159,24 @@ class RequestHandler(BaseHTTPRequestHandler):
'_handle_change_state'),
# /events
('GET', '/api/events', '_handle_get_api_events'),
('GET', rem.URL_API_EVENTS, '_handle_get_api_events'),
('POST',
re.compile(r'/api/events/(?P<event_type>[a-zA-Z\._0-9]+)'),
'_handle_fire_event'),
# /services
('GET', '/api/services', '_handle_get_api_services'),
('GET', rem.URL_API_SERVICES, '_handle_get_api_services'),
('POST',
re.compile((r'/api/services/'
r'(?P<domain>[a-zA-Z\._0-9]+)/'
r'(?P<service>[a-zA-Z\._0-9]+)')),
'_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
('GET', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'),
'_handle_get_static')
@ -193,6 +203,9 @@ class RequestHandler(BaseHTTPRequestHandler):
except KeyError:
api_password = ''
if '_METHOD' in data:
method = data['_METHOD'][0]
if url.path.startswith('/api/'):
self.use_json = True
@ -327,11 +340,9 @@ class RequestHandler(BaseHTTPRequestHandler):
"<th>Attributes</th><th>Last Changed</th>"
"</tr>").format(self.server.api_password))
for entity_id in \
sorted(self.server.hass.states.entity_ids,
key=lambda key: key.lower()):
state = self.server.hass.states.get(entity_id)
for entity_id, state in \
sorted(self.server.hass.states.all().items(),
key=lambda item: item[0].lower()):
attributes = "<br>".join(
["{}: {}".format(attr, state.attributes[attr])
@ -512,7 +523,7 @@ class RequestHandler(BaseHTTPRequestHandler):
self._write_json(state.as_dict(),
status_code=HTTP_CREATED,
location=
URL_API_STATES_ENTITY.format(entity_id))
rem.URL_API_STATES_ENTITY.format(entity_id))
else:
self._message(
"State of {} changed to {}".format(entity_id, new_state))
@ -534,21 +545,33 @@ class RequestHandler(BaseHTTPRequestHandler):
This handles the following paths:
/fire_event
/api/events/<event_type>
Events from /api are threated as remote events.
"""
try:
try:
event_type = path_match.group('event_type')
event_origin = ha.EventOrigin.remote
except IndexError:
# If group event_type does not exist in path_match
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])
except KeyError:
# Happens if key 'event_data' does not exist
else:
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))
@ -598,9 +621,8 @@ class RequestHandler(BaseHTTPRequestHandler):
# pylint: disable=unused-argument
def _handle_get_api_states(self, path_match, data):
""" Returns the entitie ids which state are being tracked. """
self._write_json(
{'entity_ids': list(self.server.hass.states.entity_ids)})
""" Returns a dict containing all entity ids and their state. """
self._write_json(self.server.hass.states.all())
# pylint: disable=unused-argument
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)
try:
self._write_json(state.as_dict())
except AttributeError:
# If state for entity_id does not exist
if state:
self._write_json(state)
else:
self._message("State does not exist.", HTTP_UNPROCESSABLE_ENTITY)
def _handle_get_api_events(self, path_match, data):
@ -623,6 +644,60 @@ class RequestHandler(BaseHTTPRequestHandler):
""" Handles getting overview of 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):
""" Returns a static file. """
req_file = util.sanitize_filename(path_match.group('file'))
@ -680,4 +755,5 @@ class RequestHandler(BaseHTTPRequestHandler):
if data:
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 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_POST = "post"
def _setup_call_api(host, port, api_password):
""" Helper method to setup a call api method. """
port = port or hah.SERVER_PORT
class API(object):
""" Object to pass around Home Assistant API location and credentials. """
# 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. """
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:
if method == METHOD_GET:
@ -46,7 +59,134 @@ def _setup_call_api(host, port, api_password):
logging.getLogger(__name__).exception("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):
@ -61,212 +201,168 @@ class JSONEncoder(json.JSONEncoder):
return json.JSONEncoder.default(self, obj)
class EventBus(object):
""" Allows to interface with a Home Assistant EventBus via the API. """
def connect_remote_events(from_api, to_api):
""" Sets up from_api to forward all events to to_api. """
def __init__(self, host, api_password, port=None):
self.logger = logging.getLogger(__name__)
data = {'host': to_api.host, 'api_password': to_api.api_password}
self._call_api = _setup_call_api(host, port, api_password)
if to_api.port is not None:
data['port'] = to_api.port
@property
def listeners(self):
""" List of events that is being listened for. """
try:
req = self._call_api(METHOD_GET, hah.URL_API_EVENTS)
try:
from_api(METHOD_POST, URL_API_EVENT_FORWARD, data)
if req.status_code == 200:
data = req.json()
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)
except ha.HomeAssistantError:
pass
class StateMachine(object):
""" Allows to interface with a Home Assistant StateMachine via the API. """
def disconnect_remote_events(from_api, to_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):
self._call_api = _setup_call_api(host, port, api_password)
if to_api.port is not None:
data['port'] = to_api.port
self.lock = threading.Lock()
self.logger = logging.getLogger(__name__)
try:
from_api(METHOD_POST, URL_API_EVENT_FORWARD, data)
@property
def entity_ids(self):
""" 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
except ha.HomeAssistantError:
pass
class ServiceRegistry(object):
""" Allows to interface with a Home Assistant ServiceRegistry
via the API. """
def get_event_listeners(api, logger=None):
""" List of events that is being listened for. """
try:
req = api(METHOD_GET, URL_API_EVENTS)
def __init__(self, host, api_password, port=None):
self.logger = logging.getLogger(__name__)
return req.json()['event_listeners'] if req.status_code == 200 else {}
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
def services(self):
""" List the available services. """
try:
req = self._call_api(METHOD_GET, hah.URL_API_SERVICES)
return {}
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:
raise ha.HomeAssistantError(
"Got unexpected result (3): {}.".format(req.text))
if event_data:
data = {'event_data': json.dumps(event_data, cls=JSONEncoder)}
else:
data = None
except ValueError: # If req.json() can't parse the json
self.logger.exception("ServiceRegistry:Got unexpected result")
raise ha.HomeAssistantError(
"Got unexpected result: {}".format(req.text))
try:
req = api(METHOD_POST, URL_API_EVENTS_EVENT.format(event_type), data)
except KeyError: # If not all expected keys are in the returned JSON
self.logger.exception("ServiceRegistry:Got unexpected result (2)")
raise ha.HomeAssistantError(
"Got unexpected result (2): {}".format(req.text))
if req.status_code != 200 and logger:
logger.error(
"Error firing event: {} - {}".format(
req.status_code, req.text))
def call_service(self, domain, service, service_data=None):
""" Calls a service. """
except ha.HomeAssistantError:
pass
if service_data:
data = {'service_data': json.dumps(service_data)}
else:
data = None
req = self._call_api(METHOD_POST,
hah.URL_API_SERVICES_SERVICE.format(
domain, service),
data)
def get_state(api, entity_id, logger=None):
""" Queries given API for state of entity_id. """
if req.status_code != 200:
error = "Error calling service: {} - {}".format(
req.status_code, req.text)
try:
req = api(METHOD_GET,
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.remote as remote
import homeassistant.components.httpinterface as hah
import homeassistant.components.http as http
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=""):
@ -28,6 +28,7 @@ def _url(path=""):
class HAHelper(object): # pylint: disable=too-few-public-methods
""" Helper class to keep track of current running HA instance. """
hass = None
slave = None
def ensure_homeassistant_started():
@ -39,9 +40,9 @@ def ensure_homeassistant_started():
hass.bus.listen('test_event', len)
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
time.sleep(1)
@ -51,6 +52,26 @@ def ensure_homeassistant_started():
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
class TestHTTPInterface(unittest.TestCase):
""" 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
a wrong api password. """
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)
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"})
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. """
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",
"new_state": "debug_state_change2",
"api_password": API_PASSWORD})
@ -110,7 +131,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.listen_once_event("test_event_with_data", listener)
requests.post(
_url(hah.URL_FIRE_EVENT),
_url(http.URL_FIRE_EVENT),
data={"event_type": "test_event_with_data",
"event_data": '{"test": 1}',
"api_password": API_PASSWORD})
@ -122,18 +143,20 @@ class TestHTTPInterface(unittest.TestCase):
def test_api_list_state_entities(self):
""" 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 = req.json()
remote_data = req.json()
self.assertEqual(list(self.hass.states.entity_ids),
data['entity_ids'])
local_data = {entity_id: state.as_dict() for entity_id, state
in self.hass.states.all().items()}
self.assertEqual(local_data, remote_data)
def test_api_get(self):
""" Test if the debug interface allows us to get a state. """
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 = ha.State.from_dict(req.json())
@ -147,7 +170,7 @@ class TestHTTPInterface(unittest.TestCase):
def test_api_get_non_existing_state(self):
""" Test if the debug interface allows us to get a state. """
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})
self.assertEqual(req.status_code, 422)
@ -157,7 +180,7 @@ class TestHTTPInterface(unittest.TestCase):
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",
"api_password": API_PASSWORD})
@ -172,7 +195,7 @@ class TestHTTPInterface(unittest.TestCase):
new_state = "debug_state_change"
req = requests.post(
_url(hah.URL_API_STATES_ENTITY.format(
_url(remote.URL_API_STATES_ENTITY.format(
"test_entity_that_does_not_exist")),
data={"new_state": new_state,
"api_password": API_PASSWORD})
@ -195,7 +218,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.listen_once_event("test.event_no_data", listener)
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})
# Allow the event to take place
@ -217,7 +240,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.listen_once_event("test_event_with_data", listener)
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}',
"api_password": API_PASSWORD})
@ -238,7 +261,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.listen_once_event("test_event_with_bad_data", listener)
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',
"api_password": API_PASSWORD})
@ -250,7 +273,7 @@ class TestHTTPInterface(unittest.TestCase):
def test_api_get_event_listeners(self):
""" 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})
data = req.json()
@ -259,7 +282,7 @@ class TestHTTPInterface(unittest.TestCase):
def test_api_get_services(self):
""" 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})
data = req.json()
@ -277,7 +300,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.services.register("test_domain", "test_service", listener)
requests.post(
_url(hah.URL_API_SERVICES_SERVICE.format(
_url(remote.URL_API_SERVICES_SERVICE.format(
"test_domain", "test_service")),
data={"api_password": API_PASSWORD})
@ -299,7 +322,7 @@ class TestHTTPInterface(unittest.TestCase):
self.hass.services.register("test_domain", "test_service", listener)
requests.post(
_url(hah.URL_API_SERVICES_SERVICE.format(
_url(remote.URL_API_SERVICES_SERVICE.format(
"test_domain", "test_service")),
data={"service_data": '{"test": 1}',
"api_password": API_PASSWORD})
@ -310,7 +333,7 @@ class TestHTTPInterface(unittest.TestCase):
self.assertEqual(len(test_value), 1)
class TestRemote(unittest.TestCase):
class TestRemoteMethods(unittest.TestCase):
""" Test the homeassistant.remote module. """
@classmethod
@ -318,134 +341,115 @@ class TestRemote(unittest.TestCase):
""" things to be run when tests are started. """
cls.hass = ensure_homeassistant_started()
cls.remote_sm = remote.StateMachine("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")
cls.api = remote.API("127.0.0.1", API_PASSWORD)
# pylint: disable=invalid-name
def test_remote_sm_list_state_entities(self):
""" Test if the debug interface allows us to list state entity ids. """
def test_get_event_listeners(self):
""" Test Python API get_event_listeners. """
self.assertEqual(list(self.hass.states.entity_ids),
self.remote_sm.entity_ids)
self.assertEqual(
remote.get_event_listeners(self.api), self.hass.bus.listeners)
def test_remote_sm_get(self):
""" Test if debug interface allows us to get state of an entity. """
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. """
def test_fire_event(self):
""" Test Python API fire_event. """
test_value = []
def listener(event): # pylint: disable=unused-argument
""" Helper method that will verify our event got called. """
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
time.sleep(1)
self.assertEqual(len(test_value), 1)
# pylint: disable=invalid-name
def test_remote_eb_fire_event_with_data(self):
""" Test if the remote bus allows us to fire an event. """
test_value = []
def test_get_state(self):
""" Test Python API get_state. """
def listener(event): # pylint: disable=unused-argument
""" Helper method that will verify our event got called. """
if event.data["test"] == 1:
test_value.append(1)
self.assertEqual(
remote.get_state(self.api, 'test'), self.hass.states.get('test'))
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
time.sleep(1)
def test_set_state(self):
""" 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_remote_sr_call_service_with_no_data(self):
""" Test if the remote bus allows us to fire a service. """
def test_is_state(self):
""" Test Python API is_state. """
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 = []
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)
self.hass.services.register("test_domain", "test_service", listener)
self.remote_sr.call_service("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})
remote.call_service(self.api, "test_domain", "test_service")
# Allow the event to take place
time.sleep(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
StateMachine connected to a remote bus. """
class TestRemoteClasses(unittest.TestCase):
""" 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 = []
def listener(event): # pylint: disable=unused-argument
""" Helper method that will verify our event got called. """
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
time.sleep(1)

View File

@ -3,4 +3,6 @@
import homeassistant
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()