Initial commit

pull/2/head
Paulus Schoutsen 2013-09-17 00:32:51 -07:00
commit d55e4d53cc
17 changed files with 669 additions and 0 deletions

53
.gitignore vendored Normal file
View File

@ -0,0 +1,53 @@
home-assistant.conf
tomato_known_devices.csv
# Hide sublime text stuff
*.sublime-project
*.sublime-workspace
# Hide some OS X stuff
.DS_Store
.AppleDouble
.LSOverride
Icon
# Thumbnails
._*
# GITHUB Proposed Python stuff:
*.py[cod]
# C extensions
*.so
# Packages
*.egg
*.egg-info
dist
build
eggs
parts
bin
var
sdist
develop-eggs
.installed.cfg
lib
lib64
# Installer logs
pip-log.txt
# Unit test / coverage reports
.coverage
.tox
nosetests.xml
# Translations
*.mo
# Mr Developer
.mr.developer.cfg
.project
.pydevproject

46
README.md Normal file
View File

@ -0,0 +1,46 @@
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.
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)
It currently works with any wireless router with Tomato firmware and Philips Hue lightning system. The system is built modular so support for other wireless routers or other actions can be implemented easily.
Installation instructions
-------------------------
* install python modules [PyEphem](http://rhodesmill.org/pyephem/), [Requests](http://python-requests.org) and [PHue](https://github.com/studioimaginaire/phue)
* 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
* 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 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.
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.
This allows us to implement simple business rules to easily customize or extend functionality:
In the event that the state of device 'Paulus Nexus 4' changes to the 'Home' state:
If the sun has set and the lights are not on:
Turn on the lights
In the event that the combined state of all tracked devices changes to 'Not Home':
If the lights are on:
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':
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).

34
app/Dependencies.py Normal file
View File

@ -0,0 +1,34 @@
from ConfigParser import SafeConfigParser
from app.StateMachine import StateMachine
from app.EventBus import EventBus
from app.Logging import EventLogger
class Dependencies:
def __init__(self):
self.config = None
self.eventbus = None
self.statemachine = None
def get_config(self):
if self.config is None:
self.config = SafeConfigParser()
self.config.read("home-assistant.conf")
return self.config
def get_event_bus(self):
if self.eventbus is None:
self.eventbus = EventBus()
#EventLogger(self.eventbus)
return self.eventbus
def get_state_machine(self):
if self.statemachine is None:
self.statemachine = StateMachine(self.get_event_bus())
return self.statemachine

62
app/EventBus.py Normal file
View File

@ -0,0 +1,62 @@
import logging
import time
from collections import defaultdict
from itertools import chain
from threading import Thread, RLock
ALL_EVENTS = '*'
class EventBus:
def __init__(self):
self.listeners = defaultdict(list)
self.lock = RLock()
self.logger =logging.getLogger("EventBus")
def fire(self, event):
assert isinstance(event, Event), "event needs to be an instance of Event"
# We dont want the eventbus to be blocking, run in a thread
def run():
self.lock.acquire()
self.logger.info("[{}] {} event received: {}".format(time.strftime("%H:%M:%S"), event.eventType, event.data))
for callback in chain(self.listeners[ALL_EVENTS], self.listeners[event.eventType]):
callback(event)
if event.removeListener:
if callback in self.listeners[ALL_EVENTS]:
self.listeners[ALL_EVENTS].remove(callback)
if callback in self.listeners[event.eventType]:
self.listeners[event.eventType].remove(callback)
event.removeListener = False
if event.stopPropegating:
break
self.lock.release()
Thread(target=run).start()
def listen(self, event_type, callback):
self.lock.acquire()
self.listeners[event_type].append(callback)
self.logger.info("New listener added for event {}. Total: {}".format(event_type, len(self.listeners[event_type])))
self.lock.release()
class Event:
def __init__(self, eventType, data):
self.eventType = eventType
self.data = data
self.stopPropegating = False
self.removeListener = False
def __str__(self):
return str([self.eventType, self.data])

14
app/Logging.py Normal file
View File

@ -0,0 +1,14 @@
import time
import logging
from app.EventBus import ALL_EVENTS
class EventLogger:
def __init__(self, eventbus):
eventbus.listen(ALL_EVENTS, self.log)
self.logger = logging.getLogger("EventLogger")
def log(self, event):
self.logger.info("[{}] {} event received: {}".format(time.strftime("%H:%M:%S"), event.eventType, event.data))

53
app/StateMachine.py Normal file
View File

@ -0,0 +1,53 @@
from collections import defaultdict
from threading import RLock
from app.EventBus import Event
from app.util import ensure_list, matcher
EVENT_STATE_CHANGED = "state_changed"
class StateMachine:
def __init__(self, eventBus):
self.states = dict()
self.eventBus = eventBus
self.lock = RLock()
def add_category(self, category, initialState):
self.states[category] = initialState
def set_state(self, category, newState):
self.lock.acquire()
assert category in self.states, "Category does not exist: {}".format(category)
oldState = self.states[category]
if oldState != newState:
self.states[category] = newState
self.eventBus.fire(Event(EVENT_STATE_CHANGED, {'category':category, 'oldState':oldState, 'newState':newState}))
self.lock.release()
def get_state(self, category):
assert category in self.states, "Category does not exist: {}".format(category)
return self.states[category]
def track_state_change(eventBus, category, fromState, toState, action):
fromState = ensure_list(fromState)
toState = ensure_list(toState)
def listener(event):
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):
action(event.data['category'], event.data['oldState'], event.data['newState'])
eventBus.listen(EVENT_STATE_CHANGED, listener)

0
app/__init__.py Normal file
View File

38
app/actor/HueTrigger.py Normal file
View File

@ -0,0 +1,38 @@
import logging
from phue import Bridge
from app.observer.WeatherWatcher import 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
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")
for category in device_tracker.device_state_categories():
track_state_change(eventbus, category, '*', STATE_H, self.handle_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_state_change)
def handle_state_change(self, category, oldState, newState):
# print "Hue Trigger - {}: {}->{}".format(category, oldState, newState)
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
if newState == STATE_H and light_needed:
self.logger.info("Home coming event for {}. Turning lights on".format(category))
self.bridge.set_light([1,2,3], 'on', True)
self.bridge.set_light([1,2,3], 'xy', [0.4595, 0.4105])
elif newState == STATE_NH and lights_are_on:
self.logger.info("Everyone has left. Turning lights off")
self.bridge.set_light([1,2,3], 'on', False)

0
app/actor/__init__.py Normal file
View File

View File

@ -0,0 +1,122 @@
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_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)
# After how much time do we consider a device not home if
# it does not show up on scans
# 70 seconds is to ensure 2 scans
TIME_SPAN_FOR_ERROR_IN_SCANNING = timedelta(seconds=70)
STATE_CATEGORY_ALL_DEVICES = 'device.alldevices'
STATE_CATEGORY_DEVICE_FORMAT = 'device.{}'
class DeviceTracker:
def __init__(self, eventbus, statemachine, device_scanner):
self.statemachine = statemachine
self.eventbus = eventbus
self.device_scanner = device_scanner
default_last_seen = datetime(1990, 1, 1)
now = datetime.now()
temp_devices_to_track = device_scanner.get_devices_to_track()
self.devices_to_track = { device: { 'name': temp_devices_to_track[device],
'state': STATE_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()
# Add categories to state machine
statemachine.add_category(STATE_CATEGORY_ALL_DEVICES, STATE_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)
track_time_change(eventbus, lambda time: self.update_devices(device_scanner.scan_devices(time)))
def device_state_categories(self):
for device in self.devices_to_track:
yield STATE_CATEGORY_DEVICE_FORMAT.format(self.devices_to_track[device]['name'])
def set_state(self, device, state):
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.statemachine.set_state(STATE_CATEGORY_DEVICE_FORMAT.format(self.devices_to_track[device]['name']), state)
def update_devices(self, found_devices):
temp_tracking_devices = self.devices_to_track.keys()
for device in found_devices:
# Are we tracking this device?
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)
# 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 \
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)
# Get the set of currently used statuses
states_of_devices = set( [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.statemachine.set_state(STATE_CATEGORY_ALL_DEVICES, self.all_devices_state)

64
app/observer/Timer.py Normal file
View File

@ -0,0 +1,64 @@
from datetime import datetime, timedelta
import threading
import time
from app.EventBus import Event
from app.util import ensure_list, matcher
TIME_INTERVAL = 10 # seconds
assert 60 % TIME_INTERVAL == 0, "60 % TIME_INTERVAL should be 0!"
EVENT_TIME_CHANGED = "time_changed"
class Timer(threading.Thread):
def __init__(self, eventbus):
threading.Thread.__init__(self)
self.eventbus = eventbus
self._stop = threading.Event()
def stop(self):
self._stop.set()
def run(self):
now = datetime.now()
while True:
if self._stop.isSet():
break
self.eventbus.fire(Event(EVENT_TIME_CHANGED, {'now':now}))
while True:
time.sleep(1)
now = datetime.now()
if self._stop.isSet() or now.second % TIME_INTERVAL == 0:
break
def track_time_change(eventBus, action, year='*', month='*', day='*', hour='*', minute='*', second='*', datetime=None, listen_once=False):
year, month, day = ensure_list(year), ensure_list(month), ensure_list(day)
hour, minute, second = ensure_list(hour), ensure_list(minute), ensure_list(second)
def listener(event):
assert isinstance(event, Event), "event needs to be of Event type"
if (datetime is not None and event.data['now'] > datetime) or \
datetime is None and \
matcher(event.data['now'].year, year) and \
matcher(event.data['now'].month, month) and \
matcher(event.data['now'].day, day) and \
matcher(event.data['now'].hour, hour) and \
matcher(event.data['now'].minute, minute) and \
matcher(event.data['now'].second, second):
# datetime are exact points in time so we always remove it after fire
event.removeListener = listen_once or datetime is not None
action(event.data['now'])
eventBus.listen(EVENT_TIME_CHANGED, listener)

View File

@ -0,0 +1,79 @@
import logging
import csv
import requests
class TomatoDeviceScanner:
# self.logger
def __init__(self, config):
self.config = config
self.logger = logging.getLogger("TomatoDeviceScanner")
# Read known devices
with open('tomato_known_devices.csv') as inp:
known_devices = { row['mac']: row for row in csv.DictReader(inp) }
# Update known devices csv file for future use
with open('tomato_known_devices.csv', 'a') as outp:
writer = csv.writer(outp)
# Query for new devices
exec(self.tomato_request("devlist"))
for name, _, mac, _ in dhcpd_lease:
if mac not in known_devices:
writer.writerow((mac, name, 0))
# Create a dict with ID: NAME of the devices to track
self.devices_to_track = dict()
for mac in [mac for mac in known_devices if known_devices[mac]['track'] == '1']:
self.devices_to_track[mac] = known_devices[mac]['name']
# Doesn't go together with exec: unqualified exec is not allowed in function '__init__' it contains a nested function with free variables
# self.devices_to_track = {mac: known_devices[mac]['name'] for mac in known_devices if known_devices[mac]['track'] == '1'}
def get_devices_to_track(self):
return self.devices_to_track
def scan_devices(self, triggered_time):
self.logger.info("Scanning for new devices")
# Query for new devices
exec(self.tomato_request("devlist"))
return [mac for iface, mac, rssi, tx, rx, quality, unknown_num in wldev]
def tomato_request(self, action):
# Get router info
r = requests.post('http://{}/update.cgi'.format(self.config.get('tomato','host')),
data={'_http_id':self.config.get('tomato','http_id'), 'exec':action},
auth=requests.auth.HTTPBasicAuth(self.config.get('tomato','username'), self.config.get('tomato','password')))
return r.text
"""
for ip, mac, iface in arplist:
pass
# print wlnoise
# print dhcpd_static
for iface, mac, rssi, tx, rx, quality, unknown_num in wldev:
print mac, quality
print ""
for name, ip, mac, lease in dhcpd_lease:
if name:
print name, ip
else:
print ip
"""

View File

@ -0,0 +1,51 @@
import logging
from datetime import datetime, timedelta
import ephem
from app.observer.Timer import track_time_change
STATE_CATEGORY_TEMPLATE_SOLAR = "solar.{}"
STATE_CATEGORY_SUN = STATE_CATEGORY_TEMPLATE_SOLAR.format("sun")
SOLAR_STATE_ABOVE_HORIZON = "above_horizon"
SOLAR_STATE_BELOW_HORIZON = "below_horizon"
class WeatherWatcher:
def __init__(self, config, eventbus, statemachine):
self.logger = logging.getLogger("WeatherWatcher")
self.config = config
self.eventbus = eventbus
self.statemachine = statemachine
statemachine.add_category(STATE_CATEGORY_SUN, SOLAR_STATE_BELOW_HORIZON)
self.update_sun_state()
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):
# 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))
if next_rising > next_setting:
new_state = SOLAR_STATE_ABOVE_HORIZON
next_change = next_setting
else:
new_state = SOLAR_STATE_BELOW_HORIZON
next_change = next_rising
self.logger.info("Updating solar state for {} to {}. Next change: {}".format(state_category, new_state, next_change))
self.statemachine.set_state(state_category, new_state)
# +10 seconds to be sure that the change has occured
track_time_change(self.eventbus, update_callback, datetime=next_change + timedelta(seconds=10))

0
app/observer/__init__.py Normal file
View File

7
app/util.py Normal file
View File

@ -0,0 +1,7 @@
from EventBus import ALL_EVENTS
def ensure_list(parameter):
return parameter if isinstance(parameter, list) else [parameter]
def matcher(subject, pattern):
return '*' in pattern or subject in pattern

View File

@ -0,0 +1,12 @@
[common]
latitude=32.87336
longitude=-117.22743
[tomato]
host=192.168.1.1
username=admin
password=PASSWORD
http_id=aaaaaaaaaaaaaaa
[hue]
host=192.168.1.107

34
start.py Normal file
View File

@ -0,0 +1,34 @@
import time
from app.Dependencies import Dependencies
from app.observer.WeatherWatcher import WeatherWatcher
from app.observer.DeviceTracker import DeviceTracker
from app.observer.TomatoDeviceScanner import TomatoDeviceScanner
from app.observer.Timer import Timer
from app.actor.HueTrigger import HueTrigger
deps = Dependencies()
weather = WeatherWatcher(deps.get_config(), deps.get_event_bus(), deps.get_state_machine())
tomato = TomatoDeviceScanner(deps.get_config())
device_tracker = DeviceTracker(deps.get_event_bus(), deps.get_state_machine(), tomato)
HueTrigger(deps.get_config(), deps.get_event_bus(), deps.get_state_machine(), device_tracker)
timer = Timer(deps.get_event_bus())
timer.start()
while True:
try:
time.sleep(1)
except:
print ""
print "Interrupt received. Wrapping up and quiting.."
timer.stop()
break