Remote instances are now 100% operational
This commit is contained in:
parent
8e65afa994
commit
50b492c64a
12 changed files with 770 additions and 529 deletions
303
README.md
303
README.md
|
@ -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:
|
|
||||||

|
|
||||||
|
|
||||||
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/<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/<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/<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/<domain>/<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)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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:
|
||||||
|

|
||||||
|
|
||||||
|
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/<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/<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/<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/<domain>/<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)
|
||||||
|
|
||||||
|

|
||||||
|
|
BIN
docs/architecture-remote.png
Normal file
BIN
docs/architecture-remote.png
Normal file
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 |
|
@ -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]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"):
|
||||||
|
|
|
@ -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"))
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 318 B |
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
4
start.py
4
start.py
|
@ -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()
|
||||||
|
|
Loading…
Add table
Reference in a new issue