Further reorg of code and bug fixes
parent
b20bd6c9c0
commit
a96919f902
22
README.md
22
README.md
|
@ -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).
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'])
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue