diff --git a/homeassistant/__init__.py b/homeassistant/__init__.py index 28a1e22a6c7..93e9b02a9e9 100644 --- a/homeassistant/__init__.py +++ b/homeassistant/__init__.py @@ -6,70 +6,242 @@ Module to control the lights based on devices at home and the state of the sun. """ -import logging import time +import logging +import threading +from collections import defaultdict, namedtuple +from itertools import chain +from datetime import datetime -from .core import EventBus, StateMachine, Event, EVENT_START, EVENT_SHUTDOWN -from .httpinterface import HTTPInterface -from .observers import DeviceTracker, WeatherWatcher, Timer -from .actors import LightTrigger +ALL_EVENTS = '*' +EVENT_START = "start" +EVENT_SHUTDOWN = "shutdown" +EVENT_STATE_CHANGED = "state_changed" +EVENT_TIME_CHANGED = "time_changed" +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!" -class HomeAssistant(object): - """ Class to tie all modules together and handle dependencies. """ +State = namedtuple("State", ['state','last_changed']) - def __init__(self, latitude=None, longitude=None): - self.latitude = latitude - self.longitude = longitude +def start_home_assistant(eventbus): + """ Start home assistant. """ + Timer(eventbus) + eventbus.fire(Event(EVENT_START)) + + while True: + try: + time.sleep(1) + + except KeyboardInterrupt: + print "" + eventbus.fire(Event(EVENT_SHUTDOWN)) + + break + +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) + +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 EventBus(object): + """ Class provides an eventbus. Allows code to listen for events and fire them. """ + + def __init__(self): + self.listeners = defaultdict(list) + self.lock = threading.RLock() self.logger = logging.getLogger(__name__) - self.eventbus = EventBus() - self.statemachine = StateMachine(self.eventbus) + def fire(self, event): + """ Fire an event. """ + assert isinstance(event, Event), "event needs to be an instance of Event" - self.httpinterface = None - self.weatherwatcher = None + 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() - def setup_light_trigger(self, device_scanner, light_control): - """ Sets up the light trigger system. """ - self.logger.info("Setting up light trigger") + self.logger.info("EventBus:Event {}: {}".format(event.event_type, event.data)) - devicetracker = DeviceTracker(self.eventbus, self.statemachine, device_scanner) + for callback in chain(self.listeners[ALL_EVENTS], self.listeners[event.event_type]): + callback(event) - LightTrigger(self.eventbus, self.statemachine, self._setup_weather_watcher(), devicetracker, light_control) + 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) - def setup_http_interface(self, api_password): - """ Sets up the HTTP interface. """ - if self.httpinterface is None: - self.logger.info("Setting up HTTP interface") - self.httpinterface = HTTPInterface(self.eventbus, self.statemachine, api_password) + event.remove_listener = False - return self.httpinterface + if event.stop_propegating: + break + self.lock.release() - def start(self): - """ Start home assistant. """ - Timer(self.eventbus) + threading.Thread(target=run).start() - self.eventbus.fire(Event(EVENT_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 = threading.RLock() + + def set_state(self, category, new_state): + """ Set the state of a category, add category is it does not exist. """ + + self.lock.acquire() + + # Add category is it does not exist + if category not in self.states: + self.states[category] = State(new_state, datetime.now()) + + # Change state and fire listeners + else: + 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. """ + self._validate_category(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. """ + self._validate_category(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 _validate_category(self, category): + """ Helper function to throw an exception when the category does not exist. """ + if category not in self.states: + raise CategoryDoesNotExistException("Category {} does not exist.".format(category)) + +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: - try: + while True: time.sleep(1) - except KeyboardInterrupt: - print "" - self.eventbus.fire(Event(EVENT_SHUTDOWN)) + now = datetime.now() + if self._stop.isSet() or now.second % TIMER_INTERVAL == 0: + break + + if self._stop.isSet(): 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) + self.eventbus.fire(Event(EVENT_TIME_CHANGED, {'now':now})) - return self.weatherwatcher +class HomeAssistantException(Exception): + """ General Home Assistant exception occured. """ + +class CategoryDoesNotExistException(HomeAssistantException): + """ Specified category does not exist within the state machine. """ diff --git a/homeassistant/actors.py b/homeassistant/actors.py index 11178b193f1..4a44da04e66 100644 --- a/homeassistant/actors.py +++ b/homeassistant/actors.py @@ -11,7 +11,7 @@ from datetime import timedelta from phue import Bridge -from .core import track_state_change +from . 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, diff --git a/homeassistant/core.py b/homeassistant/core.py deleted file mode 100644 index b9899c5cf25..00000000000 --- a/homeassistant/core.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -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 set_state(self, category, new_state): - """ Set the state of a category, add category is it does not exist. """ - - self.lock.acquire() - - # Add category is it does not exist - if category not in self.states: - self.states[category] = State(new_state, datetime.now()) - - # Change state and fire listeners - else: - 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. """ - self._validate_category(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. """ - self._validate_category(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 _validate_category(self, category): - """ Helper function to throw an exception when the category does not exist. """ - if category not in self.states: - raise CategoryDoesNotExistException("Category {} does not exist.".format(category)) - - -class HomeAssistantException(Exception): - """ General Home Assistant exception occured. """ - -class CategoryDoesNotExistException(HomeAssistantException): - """ Specified category does not exist within the state machine. """ diff --git a/homeassistant/httpinterface.py b/homeassistant/httpinterface.py index 391d4c4da5b..0afeaa32054 100644 --- a/homeassistant/httpinterface.py +++ b/homeassistant/httpinterface.py @@ -30,7 +30,7 @@ from urlparse import urlparse, parse_qs import requests -from .core import EVENT_START, EVENT_SHUTDOWN, Event, CategoryDoesNotExistException +from . import EVENT_START, EVENT_SHUTDOWN, Event, CategoryDoesNotExistException SERVER_PORT = 8080 @@ -168,9 +168,6 @@ class RequestHandler(BaseHTTPRequestHandler): action = self.path[1:] use_json = False - self.server.logger.info(post_data) - self.server.logger.info(action) - given_api_password = post_data.get("api_password", [''])[0] # Action to change the state @@ -206,11 +203,12 @@ class RequestHandler(BaseHTTPRequestHandler): def _verify_api_password(self, api_password, use_json): + """ Helper method to verify the API password and take action if incorrect. """ if api_password == self.server.api_password: return True elif use_json: - self._message(True, "API password missing or incorrect.", MESSAGE_STATUS_UNAUTHORIZED) + self._message(True, "API password missing or incorrect.", MESSAGE_STATUS_UNAUTHORIZED) else: self.send_response(200) @@ -218,7 +216,7 @@ class RequestHandler(BaseHTTPRequestHandler): self.end_headers() write = lambda txt: self.wfile.write(txt+"\n") - + write("") write("
") write("API password: ") diff --git a/homeassistant/observers.py b/homeassistant/observers.py index 74a430d249c..89b0e7fe6b0 100644 --- a/homeassistant/observers.py +++ b/homeassistant/observers.py @@ -12,25 +12,13 @@ import csv import os from datetime import datetime, timedelta import threading -import time import re import json 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" - +from . import track_time_change STATE_CATEGORY_SUN = "weather.sun" STATE_CATEGORY_ALL_DEVICES = 'device.alldevices' @@ -42,7 +30,6 @@ 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) @@ -50,66 +37,6 @@ 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. """ diff --git a/homeassistant/test.py b/homeassistant/test.py index 301fba4f411..39efd1e14b3 100644 --- a/homeassistant/test.py +++ b/homeassistant/test.py @@ -11,7 +11,7 @@ import time import requests -from .core import EventBus, StateMachine, Event, EVENT_START, EVENT_SHUTDOWN +from . import EventBus, StateMachine, Event, EVENT_START, EVENT_SHUTDOWN from .httpinterface import HTTPInterface, SERVER_PORT @@ -29,6 +29,14 @@ class HomeAssistantTestCase(unittest.TestCase): cls.statemachine = StateMachine(cls.eventbus) cls.init_ha = False + def start_ha(self): + cls.eventbus.fire(Event(EVENT_START)) + + # Give objects time to startup + time.sleep(1) + + cls.start_ha = start_ha + @classmethod def tearDownClass(cls): cls.eventbus.fire(Event(EVENT_SHUTDOWN)) @@ -50,10 +58,7 @@ class TestHTTPInterface(HomeAssistantTestCase): self.statemachine.set_state("test", "INIT_STATE") - self.eventbus.fire(Event(EVENT_START)) - - # Give objects time to start up - time.sleep(1) + self.start_ha() def test_debug_interface(self): diff --git a/start.py b/start.py index d5184f19375..68a590571bb 100644 --- a/start.py +++ b/start.py @@ -1,21 +1,32 @@ from ConfigParser import SafeConfigParser -from homeassistant import HomeAssistant - -from homeassistant.actors import HueLightControl -from homeassistant.observers import TomatoDeviceScanner +from homeassistant import StateMachine, EventBus, start_home_assistant +from homeassistant.observers import TomatoDeviceScanner, DeviceTracker, WeatherWatcher +from homeassistant.actors import HueLightControl, LightTrigger +from homeassistant.httpinterface import HTTPInterface +# Read config config = SafeConfigParser() config.read("home-assistant.conf") -tomato = TomatoDeviceScanner(config.get('tomato','host'), config.get('tomato','username'), +# Init core +eventbus = EventBus() +statemachine = StateMachine(eventbus) + +# Init observers +tomato = TomatoDeviceScanner(config.get('tomato','host'), config.get('tomato','username'), config.get('tomato','password'), config.get('tomato','http_id')) +devicetracker = DeviceTracker(eventbus, statemachine, tomato) -ha = HomeAssistant(config.get("common","latitude"), config.get("common","longitude")) +weatherwatcher = WeatherWatcher(eventbus, statemachine, + config.get("common","latitude"), + config.get("common","longitude")) -ha.setup_light_trigger(tomato, HueLightControl()) +# Init actors +LightTrigger(eventbus, statemachine, weatherwatcher, devicetracker, HueLightControl()) -ha.setup_http_interface(config.get("common","api_password")) +# Init HTTP interface +HTTPInterface(eventbus, statemachine, config.get("common","api_password")) -ha.start() +start_home_assistant(eventbus)