Code reorganized into a more Pythonic structure.

This commit is contained in:
Paulus Schoutsen 2013-09-24 18:39:58 -07:00
parent 0981ea49d1
commit a1f238816b
17 changed files with 678 additions and 686 deletions

View File

@ -1,71 +0,0 @@
import logging
from collections import defaultdict
from itertools import chain
from threading import Thread, RLock
ALL_EVENTS = '*'
class EventBus(object):
""" Class provides an eventbus. Allows code to listen for events and fire them. """
def __init__(self):
self.listeners = defaultdict(list)
self.lock = RLock()
self.logger = logging.getLogger(__name__)
def fire(self, event):
""" Fire an event. """
assert isinstance(event, Event), "event needs to be an instance of Event"
# We dont want the eventbus to be blocking,
# We dont want the eventbus to crash when one of its listeners throws an Exception
# So run in a thread
def run():
self.lock.acquire()
self.logger.info("Event {}: {}".format(event.event_type, event.data))
for callback in chain(self.listeners[ALL_EVENTS], self.listeners[event.event_type]):
callback(event)
if event.remove_listener:
if callback in self.listeners[ALL_EVENTS]:
self.listeners[ALL_EVENTS].remove(callback)
if callback in self.listeners[event.event_type]:
self.listeners[event.event_type].remove(callback)
event.remove_listener = False
if event.stop_propegating:
break
self.lock.release()
Thread(target=run).start()
def listen(self, event_type, callback):
""" Listen for all events or events of a specific type.
To listen to all events specify the constant ``ALL_EVENTS`` as event_type. """
self.lock.acquire()
self.listeners[event_type].append(callback)
self.logger.info("New listener for event {}. Total: {}".format(event_type, len(self.listeners[event_type])))
self.lock.release()
class Event(object):
""" An event to be sent over the eventbus. """
def __init__(self, event_type, data={}):
self.event_type = event_type
self.data = data
self.stop_propegating = False
self.remove_listener = False
def __str__(self):
return str([self.event_type, self.data])

View File

@ -1,117 +0,0 @@
import logging
from ConfigParser import SafeConfigParser
import time
from homeassistant.common import EVENT_START, EVENT_SHUTDOWN
from homeassistant.StateMachine import StateMachine
from homeassistant.EventBus import EventBus, Event
from homeassistant.HttpInterface import HttpInterface
from homeassistant.observer.DeviceTracker import DeviceTracker
from homeassistant.observer.WeatherWatcher import WeatherWatcher
from homeassistant.observer.Timer import Timer
from homeassistant.actor.LightTrigger import LightTrigger
CONFIG_FILE = "home-assistant.conf"
class HomeAssistant(object):
""" Class to tie all modules together and handle dependencies. """
def __init__(self):
self.logger = logging.getLogger(__name__)
self.config = None
self.eventbus = None
self.statemachine = None
self.timer = None
self.weatherwatcher = None
self.devicetracker = None
self.lighttrigger = None
self.httpinterface = None
def get_config(self):
if self.config is None:
self.logger.info("Loading HomeAssistant config")
self.config = SafeConfigParser()
self.config.read(CONFIG_FILE)
return self.config
def get_event_bus(self):
if self.eventbus is None:
self.logger.info("Setting up event bus")
self.eventbus = EventBus()
return self.eventbus
def get_state_machine(self):
if self.statemachine is None:
self.logger.info("Setting up state machine")
self.statemachine = StateMachine(self.get_event_bus())
return self.statemachine
def setup_timer(self):
if self.timer is None:
self.logger.info("Setting up timer")
self.timer = Timer(self.get_event_bus())
return self.timer
def setup_weather_watcher(self):
if self.weatherwatcher is None:
self.logger.info("Setting up weather watcher")
self.weatherwatcher = WeatherWatcher(self.get_config(), self.get_event_bus(), self.get_state_machine())
return self.weatherwatcher
def setup_device_tracker(self, device_scanner):
if self.devicetracker is None:
self.logger.info("Setting up device tracker")
self.devicetracker = DeviceTracker(self.get_event_bus(), self.get_state_machine(), device_scanner)
return self.devicetracker
def setup_light_trigger(self, light_control):
if self.lighttrigger is None:
self.logger.info("Setting up light trigger")
assert self.devicetracker is not None, "Cannot setup light trigger without a device tracker being setup"
self.lighttrigger = LightTrigger(self.get_event_bus(), self.get_state_machine(), self.devicetracker, self.setup_weather_watcher(), light_control)
return self.lighttrigger
def setup_http_interface(self):
if self.httpinterface is None:
self.logger.info("Setting up HTTP interface")
self.httpinterface = HttpInterface(self.get_event_bus(), self.get_state_machine())
return self.httpinterface
def start(self):
self.setup_timer()
self.get_event_bus().fire(Event(EVENT_START))
while True:
try:
time.sleep(1)
except KeyboardInterrupt:
print ""
self.eventbus.fire(Event(EVENT_SHUTDOWN))
break

View File

@ -1,3 +1,11 @@
"""
homeassistant.httpinterface
~~~~~~~~~~~~~~~~~~~~~~~~~~~
This module provides an HTTP interface for debug purposes.
"""
import threading
import urlparse
import logging
@ -5,12 +13,12 @@ from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
import requests
from homeassistant.common import EVENT_START, EVENT_SHUTDOWN
from .core import EVENT_START, EVENT_SHUTDOWN
SERVER_HOST = '127.0.0.1'
SERVER_PORT = 8080
class HttpInterface(threading.Thread):
class HTTPInterface(threading.Thread):
""" Provides an HTTP interface for Home Assistant. """
def __init__(self, eventbus, statemachine):

View File

@ -1,70 +0,0 @@
from collections import namedtuple
from threading import RLock
from datetime import datetime
from homeassistant.EventBus import Event
from homeassistant.common import ensure_list, matcher
EVENT_STATE_CHANGED = "state_changed"
State = namedtuple("State", ['state','last_changed'])
class StateMachine(object):
""" Helper class that tracks the state of different objects. """
def __init__(self, eventbus):
self.states = dict()
self.eventbus = eventbus
self.lock = RLock()
def add_category(self, category, initial_state):
""" Add a category which state we will keep track off. """
self.states[category] = State(initial_state, datetime.now())
def set_state(self, category, new_state):
""" Set the state of a category. """
self.lock.acquire()
assert category in self.states, "Category does not exist: {}".format(category)
old_state = self.states[category]
if old_state.state != new_state:
self.states[category] = State(new_state, datetime.now())
self.eventbus.fire(Event(EVENT_STATE_CHANGED, {'category':category, 'old_state':old_state, 'new_state':self.states[category]}))
self.lock.release()
def is_state(self, category, state):
""" Returns True if category is specified state. """
assert category in self.states, "Category does not exist: {}".format(category)
return self.get_state(category).state == state
def get_state(self, category):
""" Returns a tuple (state,last_changed) describing the state of the specified category. """
assert category in self.states, "Category does not exist: {}".format(category)
return self.states[category]
def get_states(self):
""" Returns a list of tuples (category, state, last_changed) sorted by category. """
return [(category, self.states[category].state, self.states[category].last_changed) for category in sorted(self.states.keys())]
def track_state_change(eventbus, category, from_state, to_state, action):
""" Helper method to track specific state changes. """
from_state = ensure_list(from_state)
to_state = ensure_list(to_state)
def listener(event):
assert isinstance(event, Event), "event needs to be of Event type"
if category == event.data['category'] and \
matcher(event.data['old_state'].state, from_state) and \
matcher(event.data['new_state'].state, to_state):
action(event.data['category'], event.data['old_state'], event.data['new_state'])
eventbus.listen(EVENT_STATE_CHANGED, listener)

View File

@ -0,0 +1,75 @@
"""
homeassistant
~~~~~~~~~~~~~
Module to control the lights based on devices at home and the state of the sun.
"""
import logging
import time
from .core import EventBus, StateMachine, Event, EVENT_START, EVENT_SHUTDOWN
from .httpinterface import HTTPInterface
from .observers import DeviceTracker, WeatherWatcher, Timer
from .actors import LightTrigger
class HomeAssistant(object):
""" Class to tie all modules together and handle dependencies. """
def __init__(self, latitude=None, longitude=None):
self.latitude = latitude
self.longitude = longitude
self.logger = logging.getLogger(__name__)
self.eventbus = EventBus()
self.statemachine = StateMachine(self.eventbus)
self.httpinterface = None
self.weatherwatcher = None
def setup_light_trigger(self, device_scanner, light_control):
""" Sets up the light trigger system. """
self.logger.info("Setting up light trigger")
devicetracker = DeviceTracker(self.eventbus, self.statemachine, device_scanner)
LightTrigger(self.eventbus, self.statemachine, self._setup_weather_watcher(), devicetracker, light_control)
def setup_http_interface(self):
""" Sets up the HTTP interface. """
if self.httpinterface is None:
self.logger.info("Setting up HTTP interface")
self.httpinterface = HTTPInterface(self.eventbus, self.statemachine)
return self.httpinterface
def start(self):
""" Start home assistant. """
Timer(self.eventbus)
self.eventbus.fire(Event(EVENT_START))
while True:
try:
time.sleep(1)
except KeyboardInterrupt:
print ""
self.eventbus.fire(Event(EVENT_SHUTDOWN))
break
def _setup_weather_watcher(self):
""" Sets up the weather watcher. """
if self.weatherwatcher is None:
self.weatherwatcher = WeatherWatcher(self.eventbus, self.statemachine, self.latitude, self.longitude)
return self.weatherwatcher

View File

@ -1,52 +0,0 @@
from phue import Bridge
MAX_TRANSITION_TIME = 9000
def process_transition_time(transition_seconds):
""" Transition time is in 1/10th seconds and cannot exceed MAX_TRANSITION_TIME. """
return min(MAX_TRANSITION_TIME, transition_seconds * 10)
class HueLightControl(object):
""" Class to interface with the Hue light system. """
def __init__(self, config=None):
self.bridge = Bridge(config.get("hue","host") if config is not None and config.has_option("hue","host") else None)
self.lights = self.bridge.get_light_objects()
self.light_ids = [light.light_id for light in self.lights]
def is_light_on(self, light_id=None):
""" Returns if specified light is on.
If light_id not specified will report on combined status of all lights. """
if light_id is None:
return sum([1 for light in self.lights if light.on]) > 0
else:
return self.bridge.get_light(light_id, 'on')
def turn_light_on(self, light_id=None, transition_seconds=None):
if light_id is None:
light_id = self.light_ids
command = {'on': True, 'xy': [0.5119, 0.4147], 'bri':164}
if transition_seconds is not None:
command['transitiontime'] = process_transition_time(transition_seconds)
self.bridge.set_light(light_id, command)
def turn_light_off(self, light_id=None, transition_seconds=None):
if light_id is None:
light_id = self.light_ids
command = {'on': False}
if transition_seconds is not None:
command['transitiontime'] = process_transition_time(transition_seconds)
self.bridge.set_light(light_id, command)

View File

@ -1,17 +1,36 @@
import logging
from datetime import datetime, timedelta
"""
homeassistant.actors
~~~~~~~~~~~~~~~~~~~~
from homeassistant.observer.WeatherWatcher import STATE_CATEGORY_SUN, SUN_STATE_BELOW_HORIZON, SUN_STATE_ABOVE_HORIZON
from homeassistant.observer.DeviceTracker import STATE_CATEGORY_ALL_DEVICES, STATE_DEVICE_HOME, STATE_DEVICE_NOT_HOME
from homeassistant.StateMachine import track_state_change
from homeassistant.observer.Timer import track_time_change
This module provides actors that will react to events happening within homeassistant.
"""
import logging
from datetime import timedelta
from phue import Bridge
from .core import track_state_change
from .observers import (STATE_CATEGORY_SUN, SUN_STATE_BELOW_HORIZON, SUN_STATE_ABOVE_HORIZON,
STATE_CATEGORY_ALL_DEVICES, DEVICE_STATE_HOME, DEVICE_STATE_NOT_HOME,
track_time_change)
LIGHT_TRANSITION_TIME = timedelta(minutes=15)
HUE_MAX_TRANSITION_TIME = 9000
def _hue_process_transition_time(transition_seconds):
""" Transition time is in 1/10th seconds and cannot exceed MAX_TRANSITION_TIME. """
return min(HUE_MAX_TRANSITION_TIME, transition_seconds * 10)
class LightTrigger(object):
""" Class to turn on lights based on available devices and state of the sun. """
def __init__(self, eventbus, statemachine, device_tracker, weather, light_control):
def __init__(self, eventbus, statemachine, weather, device_tracker, light_control):
self.eventbus = eventbus
self.statemachine = statemachine
self.weather = weather
@ -21,10 +40,10 @@ class LightTrigger(object):
# Track home coming of each seperate device
for category in device_tracker.device_state_categories():
track_state_change(eventbus, category, STATE_DEVICE_NOT_HOME, STATE_DEVICE_HOME, self._handle_device_state_change)
track_state_change(eventbus, category, DEVICE_STATE_NOT_HOME, DEVICE_STATE_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_DEVICE_HOME, STATE_DEVICE_NOT_HOME, self._handle_device_state_change)
track_state_change(eventbus, STATE_CATEGORY_ALL_DEVICES, DEVICE_STATE_HOME, DEVICE_STATE_NOT_HOME, self._handle_device_state_change)
# Track every time sun rises so we can schedule a time-based pre-sun set event
track_state_change(eventbus, STATE_CATEGORY_SUN, SUN_STATE_BELOW_HORIZON, SUN_STATE_ABOVE_HORIZON, self._handle_sun_rising)
@ -41,10 +60,10 @@ class LightTrigger(object):
start_point = self.weather.next_sun_setting() - LIGHT_TRANSITION_TIME * len(self.light_control.light_ids)
# Lambda can keep track of function parameters, not from local parameters
# If we put the lambda directly in the below statement only the last light
# would be turned on..
def turn_on(light_id):
""" Lambda can keep track of function parameters, not from local parameters
If we put the lambda directly in the below statement only the last light
would be turned on.. """
return lambda now: self._turn_light_on_before_sunset(light_id)
for index, light_id in enumerate(self.light_control.light_ids):
@ -54,7 +73,7 @@ class LightTrigger(object):
def _turn_light_on_before_sunset(self, light_id=None):
""" Helper function to turn on lights slowly if there are devices home and the light is not on yet. """
if self.statemachine.is_state(STATE_CATEGORY_ALL_DEVICES, STATE_DEVICE_HOME) and not self.light_control.is_light_on(light_id):
if self.statemachine.is_state(STATE_CATEGORY_ALL_DEVICES, DEVICE_STATE_HOME) and not self.light_control.is_light_on(light_id):
self.light_control.turn_light_on(light_id, LIGHT_TRANSITION_TIME.seconds)
@ -65,11 +84,55 @@ class LightTrigger(object):
light_needed = not lights_are_on and self.statemachine.is_state(STATE_CATEGORY_SUN, SUN_STATE_BELOW_HORIZON)
# Specific device came home ?
if category != STATE_CATEGORY_ALL_DEVICES and new_state.state == STATE_DEVICE_HOME and light_needed:
if category != STATE_CATEGORY_ALL_DEVICES and new_state.state == DEVICE_STATE_HOME and light_needed:
self.logger.info("Home coming event for {}. Turning lights on".format(category))
self.light_control.turn_light_on()
# Did all devices leave the house?
elif category == STATE_CATEGORY_ALL_DEVICES and new_state.state == STATE_DEVICE_NOT_HOME and lights_are_on:
elif category == STATE_CATEGORY_ALL_DEVICES and new_state.state == DEVICE_STATE_NOT_HOME and lights_are_on:
self.logger.info("Everyone has left but lights are on. Turning lights off")
self.light_control.turn_light_off()
class HueLightControl(object):
""" Class to interface with the Hue light system. """
def __init__(self, host=None):
self.bridge = Bridge(host)
self.lights = self.bridge.get_light_objects()
self.light_ids = [light.light_id for light in self.lights]
def is_light_on(self, light_id=None):
""" Returns if specified or all light are on. """
if light_id is None:
return sum([1 for light in self.lights if light.on]) > 0
else:
return self.bridge.get_light(light_id, 'on')
def turn_light_on(self, light_id=None, transition_seconds=None):
""" Turn the specified or all lights on. """
if light_id is None:
light_id = self.light_ids
command = {'on': True, 'xy': [0.5119, 0.4147], 'bri':164}
if transition_seconds is not None:
command['transitiontime'] = _hue_process_transition_time(transition_seconds)
self.bridge.set_light(light_id, command)
def turn_light_off(self, light_id=None, transition_seconds=None):
""" Turn the specified or all lights off. """
if light_id is None:
light_id = self.light_ids
command = {'on': False}
if transition_seconds is not None:
command['transitiontime'] = _hue_process_transition_time(transition_seconds)
self.bridge.set_light(light_id, command)

View File

@ -1,10 +0,0 @@
EVENT_START = "start"
EVENT_SHUTDOWN = "shutdown"
def ensure_list(parameter):
return parameter if isinstance(parameter, list) else [parameter]
def matcher(subject, pattern):
""" Returns True if subject matches the pattern.
Pattern is either a list of allowed subjects or a '*'. """
return '*' in pattern or subject in pattern

151
homeassistant/core.py Normal file
View File

@ -0,0 +1,151 @@
"""
homeassistant.common
~~~~~~~~~~~~~~~~~~~~~~~~~~~
This module provides the core components of homeassistant.
"""
import logging
from collections import defaultdict, namedtuple
from itertools import chain
from threading import Thread, RLock
from datetime import datetime
ALL_EVENTS = '*'
EVENT_STATE_CHANGED = "state_changed"
EVENT_START = "start"
EVENT_SHUTDOWN = "shutdown"
State = namedtuple("State", ['state','last_changed'])
def ensure_list(parameter):
""" Wraps parameter in a list if it is not one and returns it. """
return parameter if isinstance(parameter, list) else [parameter]
def matcher(subject, pattern):
""" Returns True if subject matches the pattern.
Pattern is either a list of allowed subjects or a '*'. """
return '*' in pattern or subject in pattern
def track_state_change(eventbus, category, from_state, to_state, action):
""" Helper method to track specific state changes. """
from_state = ensure_list(from_state)
to_state = ensure_list(to_state)
def listener(event):
""" State change listener that listens for specific state changes. """
assert isinstance(event, Event), "event needs to be of Event type"
if category == event.data['category'] and \
matcher(event.data['old_state'].state, from_state) and \
matcher(event.data['new_state'].state, to_state):
action(event.data['category'], event.data['old_state'], event.data['new_state'])
eventbus.listen(EVENT_STATE_CHANGED, listener)
class EventBus(object):
""" Class provides an eventbus. Allows code to listen for events and fire them. """
def __init__(self):
self.listeners = defaultdict(list)
self.lock = RLock()
self.logger = logging.getLogger(__name__)
def fire(self, event):
""" Fire an event. """
assert isinstance(event, Event), "event needs to be an instance of Event"
def run():
""" We dont want the eventbus to be blocking,
We dont want the eventbus to crash when one of its listeners throws an Exception
So run in a thread. """
self.lock.acquire()
self.logger.info("EventBus:Event {}: {}".format(event.event_type, event.data))
for callback in chain(self.listeners[ALL_EVENTS], self.listeners[event.event_type]):
callback(event)
if event.remove_listener:
if callback in self.listeners[ALL_EVENTS]:
self.listeners[ALL_EVENTS].remove(callback)
if callback in self.listeners[event.event_type]:
self.listeners[event.event_type].remove(callback)
event.remove_listener = False
if event.stop_propegating:
break
self.lock.release()
Thread(target=run).start()
def listen(self, event_type, callback):
""" Listen for all events or events of a specific type.
To listen to all events specify the constant ``ALL_EVENTS`` as event_type. """
self.lock.acquire()
self.listeners[event_type].append(callback)
self.lock.release()
class Event(object):
""" An event to be sent over the eventbus. """
def __init__(self, event_type, data=None):
self.event_type = event_type
self.data = {} if data is None else data
self.stop_propegating = False
self.remove_listener = False
def __str__(self):
return str([self.event_type, self.data])
class StateMachine(object):
""" Helper class that tracks the state of different objects. """
def __init__(self, eventbus):
self.states = dict()
self.eventbus = eventbus
self.lock = RLock()
def add_category(self, category, initial_state):
""" Add a category which state we will keep track off. """
self.states[category] = State(initial_state, datetime.now())
def set_state(self, category, new_state):
""" Set the state of a category. """
self.lock.acquire()
assert category in self.states, "Category does not exist: {}".format(category)
old_state = self.states[category]
if old_state.state != new_state:
self.states[category] = State(new_state, datetime.now())
self.eventbus.fire(Event(EVENT_STATE_CHANGED, {'category':category, 'old_state':old_state, 'new_state':self.states[category]}))
self.lock.release()
def is_state(self, category, state):
""" Returns True if category is specified state. """
assert category in self.states, "Category does not exist: {}".format(category)
return self.get_state(category).state == state
def get_state(self, category):
""" Returns a tuple (state,last_changed) describing the state of the specified category. """
assert category in self.states, "Category does not exist: {}".format(category)
return self.states[category]
def get_states(self):
""" Returns a list of tuples (category, state, last_changed) sorted by category. """
return [(category, self.states[category].state, self.states[category].last_changed) for category in sorted(self.states.keys())]

View File

@ -1,84 +0,0 @@
from datetime import datetime, timedelta
from homeassistant.observer.Timer import track_time_change
STATE_DEVICE_NOT_HOME = 'device_not_home'
STATE_DEVICE_HOME = 'device_home'
# After how much time do we consider a device not home if
# it does not show up on scans
TIME_SPAN_FOR_ERROR_IN_SCANNING = timedelta(minutes=1)
STATE_CATEGORY_ALL_DEVICES = 'device.alldevices'
STATE_CATEGORY_DEVICE_FORMAT = 'device.{}'
class DeviceTracker(object):
""" Class that tracks which devices are home and which are not. """
def __init__(self, eventbus, statemachine, device_scanner):
self.statemachine = statemachine
self.eventbus = eventbus
temp_devices_to_track = device_scanner.get_devices_to_track()
self.devices_to_track = { device: { 'name': temp_devices_to_track[device],
'category': STATE_CATEGORY_DEVICE_FORMAT.format(temp_devices_to_track[device]) }
for device in temp_devices_to_track }
# Add categories to state machine and update last_seen attribute
# If we don't update now a change event will be fired on boot.
initial_search = device_scanner.scan_devices()
default_last_seen = datetime(1990, 1, 1)
for device in self.devices_to_track:
if device in initial_search:
new_state = STATE_DEVICE_HOME
new_last_seen = datetime.now()
else:
new_state = STATE_DEVICE_NOT_HOME
new_last_seen = default_last_seen
self.devices_to_track[device]['last_seen'] = new_last_seen
self.statemachine.add_category(self.devices_to_track[device]['category'], new_state)
# Update all devices state
statemachine.add_category(STATE_CATEGORY_ALL_DEVICES, STATE_DEVICE_HOME if len(initial_search) > 0 else STATE_DEVICE_NOT_HOME)
track_time_change(eventbus, lambda time: self.update_devices(device_scanner.scan_devices()))
def device_state_categories(self):
""" Returns a list of categories of devices that are being tracked by this class. """
return [self.devices_to_track[device]['category'] for device in self.devices_to_track]
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:
# Are we tracking this device?
if device in temp_tracking_devices:
temp_tracking_devices.remove(device)
self.devices_to_track[device]['last_seen'] = datetime.now()
self.statemachine.set_state(self.devices_to_track[device]['category'], 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 datetime.now() - self.devices_to_track[device]['last_seen'] > TIME_SPAN_FOR_ERROR_IN_SCANNING:
self.statemachine.set_state(self.devices_to_track[device]['category'], STATE_DEVICE_NOT_HOME)
# Get the currently used statuses
states_of_devices = [self.statemachine.get_state(self.devices_to_track[device]['category']).state for device in self.devices_to_track]
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, all_devices_state)

View File

@ -1,74 +0,0 @@
import logging
from datetime import datetime
import threading
import time
from homeassistant.common import EVENT_START, EVENT_SHUTDOWN
from homeassistant.EventBus import Event
from homeassistant.common import ensure_list, matcher
TIME_INTERVAL = 10 # seconds
# We want to be able to fire every time a minute starts (seconds=0).
# We want this so other modules can use that to make sure they fire
# every minute.
assert 60 % TIME_INTERVAL == 0, "60 % TIME_INTERVAL should be 0!"
EVENT_TIME_CHANGED = "time_changed"
class Timer(threading.Thread):
""" Timer will sent out an event every TIME_INTERVAL seconds. """
def __init__(self, eventbus):
threading.Thread.__init__(self)
self.eventbus = eventbus
self._stop = threading.Event()
eventbus.listen(EVENT_START, lambda event: self.start())
eventbus.listen(EVENT_SHUTDOWN, lambda event: self._stop.set())
def run(self):
""" Start the timer. """
logging.getLogger(__name__).info("Starting")
now = datetime.now()
while True:
while True:
time.sleep(1)
now = datetime.now()
if self._stop.isSet() or now.second % TIME_INTERVAL == 0:
break
if self._stop.isSet():
break
self.eventbus.fire(Event(EVENT_TIME_CHANGED, {'now':now}))
def track_time_change(eventbus, action, year='*', month='*', day='*', hour='*', minute='*', second='*', point_in_time=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 (point_in_time is not None and event.data['now'] > point_in_time) or \
(point_in_time 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)):
# point_in_time are exact points in time so we always remove it after fire
event.remove_listener = listen_once or point_in_time is not None
action(event.data['now'])
eventbus.listen(EVENT_TIME_CHANGED, listener)

View File

@ -1,108 +0,0 @@
import logging
import csv
import os
from datetime import datetime, timedelta
from threading import Lock
import requests
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
KNOWN_DEVICES_FILE = "tomato_known_devices.csv"
class TomatoDeviceScanner(object):
""" This class tracks devices connected to a wireless router running Tomato firmware. """
def __init__(self, config):
self.config = config
self.logger = logging.getLogger(__name__)
self.lock = Lock()
# Read known devices
if os.path.isfile(KNOWN_DEVICES_FILE):
with open(KNOWN_DEVICES_FILE) as inp:
known_devices = { row['mac']: row for row in csv.DictReader(inp) }
# Update known devices csv file for future use
with open(KNOWN_DEVICES_FILE, '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))
self.last_results = [mac for iface, mac, rssi, tx, rx, quality, unknown_num in wldev]
self.date_updated = datetime.now()
# Create a dict with ID: NAME of the devices to track
self.devices_to_track = dict()
for mac in known_devices:
if known_devices[mac]['track'] == '1':
self.devices_to_track[mac] = known_devices[mac]['name']
# Quicker way of the previous statement but it 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'}
if len(self.devices_to_track) == 0:
self.logger.warning("No devices to track. Please update {}.".format(KNOWN_DEVICES_FILE))
def get_devices_to_track(self):
""" Returns a ``dict`` with device_id: device_name values. """
return self.devices_to_track
def scan_devices(self):
""" Scans for new devices and returns a list containing device_ids. """
self.lock.acquire()
# We don't want to hammer the router. Only update if MIN_TIME_BETWEEN_SCANS has passed
if self.date_updated is None or datetime.now() - self.date_updated > MIN_TIME_BETWEEN_SCANS:
self.logger.info("Scanning")
try:
# Query for new devices
exec(self._tomato_request("devlist"))
self.last_results = [mac for iface, mac, rssi, tx, rx, quality, unknown_num in wldev]
self.date_updated = datetime.now()
except Exception as e:
self.logger.exception("Scanning failed")
self.lock.release()
return self.last_results
def _tomato_request(self, action):
# Get router info
req = 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 req.text
"""
Tomato API:
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
for name, ip, mac, lease in dhcpd_lease:
if name:
print name, ip
else:
print ip
"""

View File

@ -1,77 +0,0 @@
import logging
from datetime import timedelta
import ephem
from homeassistant.observer.Timer import track_time_change
STATE_CATEGORY_SUN = "weather.sun"
SUN_STATE_ABOVE_HORIZON = "above_horizon"
SUN_STATE_BELOW_HORIZON = "below_horizon"
class WeatherWatcher(object):
""" Class that keeps track of the state of the sun. """
def __init__(self, config, eventbus, statemachine):
self.logger = logging.getLogger(__name__)
self.config = config
self.eventbus = eventbus
self.statemachine = statemachine
self.sun = ephem.Sun()
statemachine.add_category(STATE_CATEGORY_SUN, SUN_STATE_BELOW_HORIZON)
self._update_sun_state()
def next_sun_rising(self, observer=None):
""" Returns a datetime object that points at the next sun rising. """
if observer is None:
observer = self._get_observer()
return ephem.localtime(observer.next_rising(self.sun))
def next_sun_setting(self, observer=None):
""" Returns a datetime object that points at the next sun setting. """
if observer is None:
observer = self._get_observer()
return ephem.localtime(observer.next_setting(self.sun))
def _update_sun_state(self, now=None):
""" Updates the state of the sun and schedules when to check next. """
observer = self._get_observer()
next_rising = self.next_sun_rising(observer)
next_setting = self.next_sun_setting(observer)
if next_rising > next_setting:
new_state = SUN_STATE_ABOVE_HORIZON
next_change = next_setting
else:
new_state = SUN_STATE_BELOW_HORIZON
next_change = next_rising
self.logger.info("Sun:{}. Next change: {}".format(new_state, next_change.strftime("%H:%M")))
self.statemachine.set_state(STATE_CATEGORY_SUN, new_state)
# +10 seconds to be sure that the change has occured
track_time_change(self.eventbus, self._update_sun_state, point_in_time=next_change + timedelta(seconds=10))
def _get_observer(self):
""" Creates an observer representing the location from the config and the current time. """
observer = ephem.Observer()
observer.lat = self.config.get('common','latitude')
observer.long = self.config.get('common','longitude')
return observer

349
homeassistant/observers.py Normal file
View File

@ -0,0 +1,349 @@
"""
homeassistant.observers
~~~~~~~~~~~~~~~~~~~~~~~
This module provides observers that can change the state or fire
events based on observations.
"""
import logging
import csv
import os
from datetime import datetime, timedelta
import threading
import time
import requests
import ephem
from .core import ensure_list, matcher, Event, EVENT_START, EVENT_SHUTDOWN
TIMER_INTERVAL = 10 # seconds
# We want to be able to fire every time a minute starts (seconds=0).
# We want this so other modules can use that to make sure they fire
# every minute.
assert 60 % TIMER_INTERVAL == 0, "60 % TIMER_INTERVAL should be 0!"
EVENT_TIME_CHANGED = "time_changed"
STATE_CATEGORY_SUN = "weather.sun"
STATE_CATEGORY_ALL_DEVICES = 'device.alldevices'
STATE_CATEGORY_DEVICE_FORMAT = 'device.{}'
SUN_STATE_ABOVE_HORIZON = "above_horizon"
SUN_STATE_BELOW_HORIZON = "below_horizon"
DEVICE_STATE_NOT_HOME = 'device_not_home'
DEVICE_STATE_HOME = 'device_home'
# After how much time do we consider a device not home if
# it does not show up on scans
TOMATO_TIME_SPAN_FOR_ERROR_IN_SCANNING = timedelta(minutes=1)
TOMATO_MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
TOMATO_KNOWN_DEVICES_FILE = "tomato_known_devices.csv"
class Timer(threading.Thread):
""" Timer will sent out an event every TIMER_INTERVAL seconds. """
def __init__(self, eventbus):
threading.Thread.__init__(self)
self.eventbus = eventbus
self._stop = threading.Event()
eventbus.listen(EVENT_START, lambda event: self.start())
eventbus.listen(EVENT_SHUTDOWN, lambda event: self._stop.set())
def run(self):
""" Start the timer. """
logging.getLogger(__name__).info("Timer:starting")
now = datetime.now()
while True:
while True:
time.sleep(1)
now = datetime.now()
if self._stop.isSet() or now.second % TIMER_INTERVAL == 0:
break
if self._stop.isSet():
break
self.eventbus.fire(Event(EVENT_TIME_CHANGED, {'now':now}))
def track_time_change(eventbus, action, year='*', month='*', day='*', hour='*', minute='*', second='*', point_in_time=None, listen_once=False):
""" Adds a listener that will listen for a specified or matching time. """
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):
""" Listens for matching time_changed events. """
assert isinstance(event, Event), "event needs to be of Event type"
if (point_in_time is not None and event.data['now'] > point_in_time) or \
(point_in_time 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)):
# point_in_time are exact points in time so we always remove it after fire
event.remove_listener = listen_once or point_in_time is not None
action(event.data['now'])
eventbus.listen(EVENT_TIME_CHANGED, listener)
class WeatherWatcher(object):
""" Class that keeps track of the state of the sun. """
def __init__(self, eventbus, statemachine, latitude, longitude):
self.logger = logging.getLogger(__name__)
self.eventbus = eventbus
self.statemachine = statemachine
self.latitude = latitude
self.longitude = longitude
self.sun = ephem.Sun()
self._update_sun_state(create_state=True)
def next_sun_rising(self, observer=None):
""" Returns a datetime object that points at the next sun rising. """
if observer is None:
observer = self._get_observer()
return ephem.localtime(observer.next_rising(self.sun))
def next_sun_setting(self, observer=None):
""" Returns a datetime object that points at the next sun setting. """
if observer is None:
observer = self._get_observer()
return ephem.localtime(observer.next_setting(self.sun))
def _update_sun_state(self, now=None, create_state=False):
""" Updates the state of the sun and schedules when to check next. """
observer = self._get_observer()
next_rising = self.next_sun_rising(observer)
next_setting = self.next_sun_setting(observer)
if next_rising > next_setting:
new_state = SUN_STATE_ABOVE_HORIZON
next_change = next_setting
else:
new_state = SUN_STATE_BELOW_HORIZON
next_change = next_rising
self.logger.info("Sun:{}. Next change: {}".format(new_state, next_change.strftime("%H:%M")))
if create_state:
self.statemachine.add_category(STATE_CATEGORY_SUN, new_state)
else:
self.statemachine.set_state(STATE_CATEGORY_SUN, new_state)
# +10 seconds to be sure that the change has occured
track_time_change(self.eventbus, self._update_sun_state, point_in_time=next_change + timedelta(seconds=10))
def _get_observer(self):
""" Creates an observer representing the location and the current time. """
observer = ephem.Observer()
observer.lat = self.latitude
observer.long = self.longitude
return observer
class DeviceTracker(object):
""" Class that tracks which devices are home and which are not. """
def __init__(self, eventbus, statemachine, device_scanner):
self.statemachine = statemachine
self.eventbus = eventbus
temp_devices_to_track = device_scanner.get_devices_to_track()
self.devices_to_track = { device: { 'name': temp_devices_to_track[device],
'category': STATE_CATEGORY_DEVICE_FORMAT.format(temp_devices_to_track[device]) }
for device in temp_devices_to_track }
# Add categories to state machine and update last_seen attribute
# If we don't update now a change event will be fired on boot.
initial_search = device_scanner.scan_devices()
default_last_seen = datetime(1990, 1, 1)
for device in self.devices_to_track:
if device in initial_search:
new_state = DEVICE_STATE_HOME
new_last_seen = datetime.now()
else:
new_state = DEVICE_STATE_NOT_HOME
new_last_seen = default_last_seen
self.devices_to_track[device]['last_seen'] = new_last_seen
self.statemachine.add_category(self.devices_to_track[device]['category'], new_state)
# Update all devices state
statemachine.add_category(STATE_CATEGORY_ALL_DEVICES, DEVICE_STATE_HOME if len(initial_search) > 0 else DEVICE_STATE_NOT_HOME)
track_time_change(eventbus, lambda time: self.update_devices(device_scanner.scan_devices()))
def device_state_categories(self):
""" Returns a list of categories of devices that are being tracked by this class. """
return [self.devices_to_track[device]['category'] for device in self.devices_to_track]
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:
# Are we tracking this device?
if device in temp_tracking_devices:
temp_tracking_devices.remove(device)
self.devices_to_track[device]['last_seen'] = datetime.now()
self.statemachine.set_state(self.devices_to_track[device]['category'], DEVICE_STATE_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 datetime.now() - self.devices_to_track[device]['last_seen'] > TOMATO_TIME_SPAN_FOR_ERROR_IN_SCANNING:
self.statemachine.set_state(self.devices_to_track[device]['category'], DEVICE_STATE_NOT_HOME)
# Get the currently used statuses
states_of_devices = [self.statemachine.get_state(self.devices_to_track[device]['category']).state for device in self.devices_to_track]
all_devices_state = DEVICE_STATE_HOME if DEVICE_STATE_HOME in states_of_devices else DEVICE_STATE_NOT_HOME
self.statemachine.set_state(STATE_CATEGORY_ALL_DEVICES, all_devices_state)
class TomatoDeviceScanner(object):
""" This class tracks devices connected to a wireless router running Tomato firmware. """
def __init__(self, host, username, password, http_id):
self.host = host
self.username = username
self.password = password
self.http_id = http_id
self.logger = logging.getLogger(__name__)
self.lock = threading.Lock()
# Read known devices
if os.path.isfile(TOMATO_KNOWN_DEVICES_FILE):
with open(TOMATO_KNOWN_DEVICES_FILE) 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_FILE, '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))
self.last_results = [mac for iface, mac, rssi, tx, rx, quality, unknown_num in wldev]
self.date_updated = datetime.now()
# Create a dict with ID: NAME of the devices to track
self.devices_to_track = dict()
for mac in known_devices:
if known_devices[mac]['track'] == '1':
self.devices_to_track[mac] = known_devices[mac]['name']
# Quicker way of the previous statement but it 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'}
if len(self.devices_to_track) == 0:
self.logger.warning("No devices to track. Please update {}.".format(TOMATO_KNOWN_DEVICES_FILE))
def get_devices_to_track(self):
""" Returns a ``dict`` with device_id: device_name values. """
return self.devices_to_track
def scan_devices(self):
""" Scans for new devices and returns a list containing device_ids. """
self.lock.acquire()
# We don't want to hammer the router. Only update if TOMATO_MIN_TIME_BETWEEN_SCANS has passed
if self.date_updated is None or datetime.now() - self.date_updated > TOMATO_MIN_TIME_BETWEEN_SCANS:
self.logger.info("Tomato:Scanning")
try:
# Query for new devices
exec(self._tomato_request("devlist"))
self.last_results = [mac for iface, mac, rssi, tx, rx, quality, unknown_num in wldev]
self.date_updated = datetime.now()
except Exception as e:
self.logger.exception("Scanning failed")
self.lock.release()
return self.last_results
def _tomato_request(self, action):
""" Talk to the Tomato API. """
# Get router info
req = requests.post('http://{}/update.cgi'.format(self.host),
data={'_http_id':self.http_id, 'exec':action},
auth=requests.auth.HTTPBasicAuth(self.username, self.password))
return req.text
"""
Tomato API:
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
for name, ip, mac, lease in dhcpd_lease:
if name:
print name, ip
else:
print ip
"""

View File

@ -1,12 +1,21 @@
from homeassistant.HomeAssistant import HomeAssistant
from ConfigParser import SafeConfigParser
from homeassistant.actor.HueLightControl import HueLightControl
from homeassistant.observer.TomatoDeviceScanner import TomatoDeviceScanner
from homeassistant import HomeAssistant
ha = HomeAssistant()
from homeassistant.actors import HueLightControl
from homeassistant.observers import TomatoDeviceScanner
config = SafeConfigParser()
config.read("home-assistant.conf")
tomato = TomatoDeviceScanner(config.get('tomato','host'), config.get('tomato','username'),
config.get('tomato','password'), config.get('tomato','http_id'))
ha = HomeAssistant(config.get("common","latitude"), config.get("common","longitude"))
ha.setup_light_trigger(tomato, HueLightControl())
ha.setup_device_tracker(TomatoDeviceScanner(ha.get_config()))
ha.setup_light_trigger(HueLightControl(ha.get_config()))
ha.setup_http_interface()
ha.start()