Further reorg of code and bug fixes

pull/2/head
Paulus Schoutsen 2013-09-18 00:07:39 -07:00
parent b20bd6c9c0
commit a96919f902
6 changed files with 95 additions and 87 deletions

View File

@ -1,14 +1,14 @@
Home Assistant
==============
Home Assistant is a framework I wrote in Python to automate switching the lights on and off based on devices at home and the position of the sun.
Home Assistant automatically switches the lights on and off based on nearby devices and the sun.
It is currently able to do the following things:
* Turn on the lights when one of the tracked devices gets home
* Turn off the lights when everybody leaves home
* Turn on the lights when the sun sets and one of the tracked devices is home (in progress)
* Turn on the lights when one of the tracked devices is nearby
* Turn off the lights when everybody leaves
* Turn on the lights when the sun sets and one of the tracked devices is home
It currently works with any wireless router with Tomato firmware in combination with the Philips Hue lightning system. The system is built modular so support for other wireless routers or other actions can be implemented easily.
It currently works with any wireless router with [Tomato firmware](http://www.polarcloud.com/tomato) in combination with [Philips Hue](http://meethue.com). The system is built modular so support for other wireless routers or other actions can be implemented easily.
Installation instructions
-------------------------
@ -17,17 +17,17 @@ Installation instructions
* Clone the repository `git clone https://github.com/balloob/home-assistant.git`
* Copy home-assistant.conf.default to home-assistant.conf and adjust the config values to match your setup
* For Tomato you will have to not only setup your host, username and password but also a http_id. This one can be found by inspecting your request to the tomato server.
* Setup PHue by running [this example script](https://github.com/studioimaginaire/phue/blob/master/examples/random_colors.py) with `b.connect()` uncommented
* Setup PHue by running `python -m phue --host HUE_BRIDGE_IP_ADDRESS` from the commandline
* The first time the script will start it will create a file called `tomato_known_devices.csv` which will contain the detected devices. Adjust the track variable for the devices you want the script to act on and restart the script.
Done. Start it now by running `python start.py`
Home Assistent Architecture and how to customize
------------------------------------------------
Home Assistent architecture
---------------------------
Home Assistent has been built from the ground up with extensibility and modularity in mind. It should be easy to swap in a different device tracker if I would move away from using a Tomato based firmware for my router for example. That is why all of Home Assistant has been built up on top of an event bus.
Home Assistent has been built from the ground up with extensibility and modularity in mind. It is easy to swap in a different device tracker that polls another wireless router for example.
Different modules are able to fire and listen for specific events. On top of this is a state machine that allows modules to track the state of different things. Each device that is being tracked will have a state. Either home, home for 5 minutes, not home or not home for 5 minutes. On top of that there is also a state that is a combined state of all tracked devices.
The beating heart of Home Assistant is an event bus. Different modules are able to fire and listen for specific events. On top of this is a state machine that allows modules to track the state of different things. For example each device that is being tracked will have a state of either 'Home' or 'Not Home'.
This allows us to implement simple business rules to easily customize or extend functionality:
@ -40,7 +40,7 @@ This allows us to implement simple business rules to easily customize or extend
Turn off the lights
In the event of the sun setting:
If the lights are off and the combined state of all tracked device is either 'Home' or 'Home for 5+ minutes':
If the lights are off and the combined state of all tracked device equals 'Home':
Turn on the lights
These rules are currently implemented in the file [HueTrigger.py](https://github.com/balloob/home-assistant/blob/master/app/actor/HueTrigger.py).

View File

@ -2,15 +2,10 @@ from datetime import datetime, timedelta
from app.observer.Timer import track_time_change
STATE_NH = 'NH'
STATE_NH5 = 'NH5'
STATE_H = 'H'
STATE_H5 = 'H5'
STATE_DEVICE_NOT_HOME = 'device_not_home'
STATE_DEVICE_HOME = 'device_home'
STATE_DEFAULT = STATE_NH5
# After how much time will we switch form NH to NH5 and H to H5 ?
TIME_SPAN_FOR_EXTRA_STATE = timedelta(minutes=5)
STATE_DEVICE_DEFAULT = STATE_DEVICE_NOT_HOME
# After how much time do we consider a device not home if
# it does not show up on scans
@ -21,7 +16,6 @@ STATE_CATEGORY_ALL_DEVICES = 'device.alldevices'
STATE_CATEGORY_DEVICE_FORMAT = 'device.{}'
class DeviceTracker:
def __init__(self, eventbus, statemachine, device_scanner):
@ -35,23 +29,22 @@ class DeviceTracker:
temp_devices_to_track = device_scanner.get_devices_to_track()
self.devices_to_track = { device: { 'name': temp_devices_to_track[device],
'state': STATE_DEFAULT,
'state': STATE_DEVICE_DEFAULT,
'last_seen': default_last_seen,
'state_changed': now }
for device in temp_devices_to_track }
self.all_devices_state = STATE_DEFAULT
self.all_devices_state_changed = datetime.now()
self.all_devices_state = STATE_DEVICE_DEFAULT
# Add categories to state machine
statemachine.add_category(STATE_CATEGORY_ALL_DEVICES, STATE_DEFAULT)
statemachine.add_category(STATE_CATEGORY_ALL_DEVICES, STATE_DEVICE_DEFAULT)
for device in self.devices_to_track:
self.statemachine.add_category(STATE_CATEGORY_DEVICE_FORMAT.format(self.devices_to_track[device]['name']), STATE_DEFAULT)
self.statemachine.add_category(STATE_CATEGORY_DEVICE_FORMAT.format(self.devices_to_track[device]['name']), STATE_DEVICE_DEFAULT)
track_time_change(eventbus, lambda time: self.update_devices(device_scanner.scan_devices(time)))
track_time_change(eventbus, lambda time: self.update_devices(device_scanner.scan_devices()))
@ -61,17 +54,20 @@ class DeviceTracker:
def set_state(self, device, state):
now = datetime.now()
if state == STATE_DEVICE_HOME:
self.devices_to_track[device]['last_seen'] = now
if self.devices_to_track[device]['state'] != state:
self.devices_to_track[device]['state'] = state
self.devices_to_track[device]['state_changed'] = datetime.now()
if state in [STATE_H]:
self.devices_to_track[device]['last_seen'] = self.devices_to_track[device]['state_changed']
self.devices_to_track[device]['state_changed'] = now
self.statemachine.set_state(STATE_CATEGORY_DEVICE_FORMAT.format(self.devices_to_track[device]['name']), state)
def update_devices(self, found_devices):
# Keep track of devices that are home, all that are not will be marked not home
temp_tracking_devices = self.devices_to_track.keys()
for device in found_devices:
@ -79,44 +75,22 @@ class DeviceTracker:
if device in temp_tracking_devices:
temp_tracking_devices.remove(device)
# If home, check if for 5+ minutes, then change state to H5
if self.devices_to_track[device]['state'] == STATE_H and \
datetime.now() - self.devices_to_track[device]['state_changed'] > TIME_SPAN_FOR_EXTRA_STATE:
self.set_state(device, STATE_H5)
elif not self.devices_to_track[device]['state'] in [STATE_H, STATE_H5]:
self.set_state(device, STATE_H)
self.set_state(device, STATE_DEVICE_HOME)
# For all devices we did not find, set state to NH
# But only if they have been gone for longer then the error time span
# Because we do not want to have stuff happening when the device does
# not show up for 1 scan beacuse of reboot etc
for device in temp_tracking_devices:
if self.devices_to_track[device]['state'] in [STATE_H, STATE_H5] and \
if self.devices_to_track[device]['state'] == STATE_DEVICE_HOME and \
datetime.now() - self.devices_to_track[device]['last_seen'] > TIME_SPAN_FOR_ERROR_IN_SCANNING:
self.set_state(device, STATE_NH)
elif self.devices_to_track[device]['state'] == STATE_NH and \
datetime.now() - self.devices_to_track[device]['last_seen'] > TIME_SPAN_FOR_EXTRA_STATE:
self.set_state(device, STATE_NH5)
self.set_state(device, STATE_DEVICE_NOT_HOME)
# Get the set of currently used statuses
states_of_devices = set( [self.devices_to_track[device]['state'] for device in self.devices_to_track] )
states_of_devices = [self.devices_to_track[device]['state'] for device in self.devices_to_track]
# If there is only one status in use, that is the status of all devices
if len(states_of_devices) == 1:
self.all_devices_state = states_of_devices.pop()
# Else if there is atleast 1 device home, the status is HOME
elif STATE_H in states_of_devices or STATE_H5 in states_of_devices:
self.all_devices_state = STATE_H
# Else status is not home
else:
self.all_devices_state = STATE_NH
self.all_devices_state = STATE_DEVICE_HOME if STATE_DEVICE_HOME in states_of_devices else STATE_DEVICE_NOT_HOME
self.statemachine.set_state(STATE_CATEGORY_ALL_DEVICES, self.all_devices_state)

View File

@ -1,11 +1,14 @@
from collections import defaultdict
from collections import defaultdict, namedtuple
from threading import RLock
from datetime import datetime
from app.EventBus import Event
from app.util import ensure_list, matcher
EVENT_STATE_CHANGED = "state_changed"
state = namedtuple("State", ['state','last_changed'])
class StateMachine:
def __init__(self, eventBus):
@ -14,7 +17,7 @@ class StateMachine:
self.lock = RLock()
def add_category(self, category, initialState):
self.states[category] = initialState
self.states[category] = state(initialState, datetime.now())
def set_state(self, category, newState):
self.lock.acquire()
@ -23,17 +26,17 @@ class StateMachine:
oldState = self.states[category]
if oldState != newState:
self.states[category] = newState
if oldState.state != newState:
self.states[category] = state(newState, datetime.now())
self.eventBus.fire(Event(EVENT_STATE_CHANGED, {'category':category, 'oldState':oldState, 'newState':newState}))
self.eventBus.fire(Event(EVENT_STATE_CHANGED, {'category':category, 'oldState':oldState, 'newState':self.states[category]}))
self.lock.release()
def get_state(self, category):
assert category in self.states, "Category does not exist: {}".format(category)
return self.states[category]
return self.states[category]
def track_state_change(eventBus, category, fromState, toState, action):
@ -44,8 +47,8 @@ def track_state_change(eventBus, category, fromState, toState, action):
assert isinstance(event, Event), "event needs to be of Event type"
if category == event.data['category'] and \
matcher(event.data['oldState'], fromState) and \
matcher(event.data['newState'], toState):
matcher(event.data['oldState'].state, fromState) and \
matcher(event.data['newState'].state, toState):
action(event.data['category'], event.data['oldState'], event.data['newState'])

View File

@ -1,63 +1,81 @@
import logging
from datetime import datetime
from phue import Bridge
from app.observer.WeatherWatcher import STATE_CATEGORY_SUN, SOLAR_STATE_BELOW_HORIZON, SOLAR_STATE_ABOVE_HORIZON
from app.observer.WeatherWatcher import EVENT_PRE_SUN_SET_WARNING, STATE_CATEGORY_SUN, SOLAR_STATE_BELOW_HORIZON
from app.StateMachine import track_state_change
from app.observer.DeviceTracker import STATE_CATEGORY_ALL_DEVICES, STATE_H, STATE_H5, STATE_NH
from app.DeviceTracker import STATE_CATEGORY_ALL_DEVICES, STATE_DEVICE_HOME, STATE_DEVICE_NOT_HOME
class HueTrigger:
def __init__(self, config, eventbus, statemachine, device_tracker):
self.statemachine = statemachine
self.bridge = Bridge(config.get("hue","host"))
self.lights = self.bridge.get_light_objects()
self.logger = logging.getLogger("HueTrigger")
# Track home coming of each seperate device
for category in device_tracker.device_state_categories():
track_state_change(eventbus, category, '*', STATE_H, self.handle_device_state_change)
track_state_change(eventbus, category, STATE_DEVICE_NOT_HOME, STATE_DEVICE_HOME, self.handle_device_state_change)
# Track when all devices are gone to shut down lights
track_state_change(eventbus, STATE_CATEGORY_ALL_DEVICES, [STATE_H,STATE_H5], STATE_NH, self.handle_device_state_change)
track_state_change(eventbus, STATE_CATEGORY_ALL_DEVICES, STATE_DEVICE_HOME, STATE_DEVICE_NOT_HOME, self.handle_device_state_change)
# Track when sun sets
track_state_change(eventbus, STATE_CATEGORY_SUN, SOLAR_STATE_ABOVE_HORIZON, SOLAR_STATE_BELOW_HORIZON, self.handle_sun_state_change)
# Listen for when sun is about to set
eventbus.listen(EVENT_PRE_SUN_SET_WARNING, self.handle_sun_setting)
def get_lights_status(self):
lights_are_on = sum([1 for light in self.lights if light.on]) > 0
light_needed = not lights_are_on and self.statemachine.get_state(STATE_CATEGORY_SUN) == SOLAR_STATE_BELOW_HORIZON
light_needed = not lights_are_on and self.statemachine.get_state(STATE_CATEGORY_SUN).state == SOLAR_STATE_BELOW_HORIZON
return lights_are_on, light_needed
def turn_lights_on(self):
self.bridge.set_light([1,2,3], 'on', True)
self.bridge.set_light([1,2,3], 'xy', [0.4595, 0.4105])
def turn_lights_on(self, transitiontime=None):
command = {'on': True, 'xy': [0.5119, 0.4147], 'bri':164}
if transitiontime is not None:
command['transitiontime'] = transitiontime
self.bridge.set_light([1,2,3], command)
def turn_lights_off(self):
self.bridge.set_light([1,2,3], 'on', False)
def turn_lights_off(self, transitiontime=None):
command = {'on': False}
if transitiontime is not None:
command['transitiontime'] = transitiontime
self.bridge.set_light([1,2,3], command)
# If sun sets, the lights are
def handle_sun_state_change(self, category, oldState, newState):
# Gets called when darkness starts falling in, slowly turn on the lights
def handle_sun_setting(self, event):
lights_are_on, light_needed = self.get_lights_status()
if light_needed and self.statemachine.get_state(STATE_CATEGORY_ALL_DEVICES) in [STATE_H, STATE_H5]:
self.turn_lights_on()
if light_needed and self.statemachine.get_state(STATE_CATEGORY_ALL_DEVICES).state == STATE_DEVICE_HOME:
self.logger.info("Sun setting and devices home. Turning on lights.")
# We will start the lights now and by the time the sun sets
# the lights will be at full brightness
transitiontime = (event.data['sun_setting'] - datetime.now()).seconds * 10
self.turn_lights_on(transitiontime)
def handle_device_state_change(self, category, oldState, newState):
lights_are_on, light_needed = self.get_lights_status()
# Specific device came home ?
if category != STATE_CATEGORY_ALL_DEVICES and newState == STATE_H and light_needed:
if category != STATE_CATEGORY_ALL_DEVICES and newState.state == STATE_DEVICE_HOME and light_needed:
self.logger.info("Home coming event for {}. Turning lights on".format(category))
self.turn_lights_on()
# Did all devices leave the house?
elif category == STATE_CATEGORY_ALL_DEVICES and newState == STATE_NH and lights_are_on:
elif category == STATE_CATEGORY_ALL_DEVICES and newState.state == STATE_DEVICE_NOT_HOME and lights_are_on:
self.logger.info("Everyone has left. Turning lights off")
self.turn_lights_off()

View File

@ -39,7 +39,7 @@ class TomatoDeviceScanner:
def get_devices_to_track(self):
return self.devices_to_track
def scan_devices(self, triggered_time):
def scan_devices(self):
self.logger.info("Scanning for new devices")
# Query for new devices

View File

@ -3,8 +3,14 @@ from datetime import datetime, timedelta
import ephem
from app.EventBus import Event
from app.observer.Timer import track_time_change
PRE_SUN_SET_WARNING_TIME = 20 # minutes
EVENT_PRE_SUN_SET_WARNING = "sun_set_soon"
STATE_CATEGORY_TEMPLATE_SOLAR = "solar.{}"
STATE_CATEGORY_SUN = STATE_CATEGORY_TEMPLATE_SOLAR.format("sun")
@ -26,14 +32,14 @@ class WeatherWatcher:
def update_sun_state(self, now=datetime.now()):
self.update_solar_state(ephem.Sun(), STATE_CATEGORY_SUN, self.update_sun_state)
def update_solar_state(self, solar_object, state_category, update_callback):
def update_solar_state(self, solar_body, state_category, update_callback):
# We don't cache these objects because we use them so rarely
observer = ephem.Observer()
observer.lat = self.config.get('common','latitude')
observer.long = self.config.get('common','longitude')
next_rising = ephem.localtime(observer.next_rising(solar_object))
next_setting = ephem.localtime(observer.next_setting(solar_object))
next_rising = ephem.localtime(observer.next_rising(solar_body))
next_setting = ephem.localtime(observer.next_setting(solar_body))
if next_rising > next_setting:
new_state = SOLAR_STATE_ABOVE_HORIZON
@ -49,3 +55,10 @@ class WeatherWatcher:
# +10 seconds to be sure that the change has occured
track_time_change(self.eventbus, update_callback, datetime=next_change + timedelta(seconds=10))
# If the sun is visible, schedule to fire an event X minutes before sun set
if solar_body.name == 'Sun' and new_state == SOLAR_STATE_ABOVE_HORIZON:
track_time_change(self.eventbus, lambda time: self.eventbus.fire(Event(EVENT_PRE_SUN_SET_WARNING, {'sun_setting':next_change})),
datetime=next_change - timedelta(minutes=PRE_SUN_SET_WARNING_TIME))