diff --git a/homeassistant/__init__.py b/homeassistant/__init__.py index cc3185f06dd..d0a677ba49f 100644 --- a/homeassistant/__init__.py +++ b/homeassistant/__init__.py @@ -2,19 +2,21 @@ homeassistant ~~~~~~~~~~~~~ -Module to control the lights based on devices at home and the state of the sun. - +Home Assistant is a Home Automation framework for observing the state +of objects and react to changes. """ import time import logging import threading from collections import defaultdict, namedtuple -from datetime import datetime +import datetime as dt + +import homeassistant.util as util logging.basicConfig(level=logging.INFO) -ALL_EVENTS = '*' +MATCH_ALL = '*' DOMAIN = "homeassistant" @@ -38,8 +40,6 @@ TIMER_INTERVAL = 10 # seconds # every minute. assert 60 % TIMER_INTERVAL == 0, "60 % TIMER_INTERVAL should be 0!" -DATE_STR_FORMAT = "%H:%M:%S %d-%m-%Y" - def start_home_assistant(bus): """ Start home assistant. """ @@ -60,37 +60,22 @@ def start_home_assistant(bus): break -def datetime_to_str(dattim): - """ Converts datetime to a string format. - - @rtype : str - """ - return dattim.strftime(DATE_STR_FORMAT) - - -def str_to_datetime(dt_str): - """ Converts a string to a datetime object. - - @rtype: datetime - """ - return datetime.strptime(dt_str, DATE_STR_FORMAT) - - -def _ensure_list(parameter): - """ Wraps parameter in a list if it is not one and returns it. - - @rtype : list - """ - return parameter if isinstance(parameter, list) else [parameter] +def _process_match_param(parameter): + """ Wraps parameter in a list if it is not one and returns it. """ + if parameter is None: + return MATCH_ALL + elif isinstance(parameter, list): + return parameter + else: + return [parameter] def _matcher(subject, pattern): """ Returns True if subject matches the pattern. - Pattern is either a list of allowed subjects or a '*'. - @rtype : bool + Pattern is either a list of allowed subjects or a `MATCH_ALL`. """ - return '*' in pattern or subject in pattern + return MATCH_ALL == pattern or subject in pattern def split_state_category(category): @@ -98,36 +83,26 @@ def split_state_category(category): return category.split(".", 1) -def filter_categories(categories, domain_filter=None, object_id_only=False): - """ Filter a list of categories based on domain. Setting object_id_only +def filter_categories(categories, domain_filter=None, strip_domain=False): + """ Filter a list of categories based on domain. Setting strip_domain will only return the object_ids. """ return [ - split_state_category(cat)[1] if object_id_only else cat + split_state_category(cat)[1] if strip_domain else cat for cat in categories if not domain_filter or cat.startswith(domain_filter) ] -def create_state(state, attributes=None, last_changed=None): - """ Creates a new state and initializes defaults where necessary. """ - attributes = attributes or {} - last_changed = last_changed or datetime.now() - - return {'state': state, - 'attributes': attributes, - 'last_changed': datetime_to_str(last_changed)} - - def track_state_change(bus, category, action, from_state=None, to_state=None): """ Helper method to track specific state changes. """ - from_state = _ensure_list(from_state) if from_state else [ALL_EVENTS] - to_state = _ensure_list(to_state) if to_state else [ALL_EVENTS] + from_state = _process_match_param(from_state) + to_state = _process_match_param(to_state) def listener(event): """ State change listener that listens for specific state changes. """ if category == event.data['category'] and \ - _matcher(event.data['old_state']['state'], from_state) and \ - _matcher(event.data['new_state']['state'], to_state): + _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'], @@ -138,19 +113,19 @@ def track_state_change(bus, category, action, from_state=None, to_state=None): # pylint: disable=too-many-arguments def track_time_change(bus, action, - year='*', month='*', day='*', - hour='*', minute='*', second='*', + year=None, month=None, day=None, + hour=None, minute=None, second=None, point_in_time=None, listen_once=False): """ Adds a listener that will listen for a specified or matching time. """ - year, month = _ensure_list(year), _ensure_list(month) - day = _ensure_list(day) + year, month = _process_match_param(year), _process_match_param(month) + day = _process_match_param(day) - hour, minute = _ensure_list(hour), _ensure_list(minute) - second = _ensure_list(second) + hour, minute = _process_match_param(hour), _process_match_param(minute) + second = _process_match_param(second) def listener(event): """ Listens for matching time_changed events. """ - now = str_to_datetime(event.data['now']) + now = event.data['now'] if (point_in_time and now > point_in_time) or \ (not point_in_time and @@ -180,7 +155,7 @@ class Bus(object): """ def __init__(self): - self._event_listeners = defaultdict(list) + self._event_listeners = {} self._services = {} self.logger = logging.getLogger(__name__) @@ -196,8 +171,7 @@ class Bus(object): of listeners. """ return {key: len(self._event_listeners[key]) - for key in self._event_listeners.keys() - if len(self._event_listeners[key]) > 0} + for key in self._event_listeners} def call_service(self, domain, service, service_data=None): """ Calls a service. """ @@ -236,8 +210,16 @@ class Bus(object): def fire_event(self, event_type, event_data=None): """ Fire an event. """ - if not event_data: - event_data = {} + # Copy the list of the current listeners because some listeners + # choose to remove themselves as a listener while being executed + # which causes the iterator to be confused. + listeners = self._event_listeners.get(MATCH_ALL, []) + \ + self._event_listeners.get(event_type, []) + + if not listeners: + return + + event_data = event_data or {} self.logger.info("Bus:Event {}: {}".format( event_type, event_data)) @@ -246,10 +228,7 @@ class Bus(object): """ Fire listeners for event. """ event = Event(self, event_type, event_data) - # We do not use itertools.chain() because some listeners might - # choose to remove themselves as a listener while being executed - for listener in self._event_listeners[ALL_EVENTS] + \ - self._event_listeners[event.event_type]: + for listener in listeners: try: listener(event) @@ -262,15 +241,19 @@ class Bus(object): def listen_event(self, event_type, listener): """ Listen for all events or events of a specific type. - To listen to all events specify the constant ``ALL_EVENTS`` + To listen to all events specify the constant ``MATCH_ALL`` as event_type. """ - self._event_listeners[event_type].append(listener) + try: + self._event_listeners[event_type].append(listener) + except KeyError: # event_type did not exist + self._event_listeners[event_type] = [listener] + def listen_once_event(self, event_type, listener): """ Listen once for event of a specific type. - To listen to all events specify the constant ``ALL_EVENTS`` + To listen to all events specify the constant ``MATCH_ALL`` as event_type. Note: at the moment it is impossible to remove a one time listener. @@ -292,10 +275,67 @@ class Bus(object): if len(self._event_listeners[event_type]) == 0: del self._event_listeners[event_type] - except ValueError: + except (KeyError, ValueError): pass +class State(object): + """ Object to represent a state within the state machine. """ + + def __init__(self, state, attributes=None, last_changed=None): + self.state = state + self.attributes = attributes or {} + last_changed = last_changed or dt.datetime.now() + + # Strip microsecond from last_changed else we cannot guarantee + # state == State.from_json_dict(state.to_json_dict()) + # This behavior occurs because to_json_dict strips microseconds + if last_changed.microsecond: + self.last_changed = last_changed - dt.timedelta( + microseconds=last_changed.microsecond) + else: + self.last_changed = last_changed + + def to_json_dict(self, category=None): + """ Converts State to a dict to be used within JSON. + Ensures: state == State.from_json_dict(state.to_json_dict()) """ + + json_dict = {'state': self.state, + 'attributes': self.attributes, + 'last_changed': util.datetime_to_str(self.last_changed)} + + if category: + json_dict['category'] = category + + return json_dict + + def copy(self): + """ Creates a copy of itself. """ + return State(self.state, dict(self.attributes), self.last_changed) + + @staticmethod + def from_json_dict(json_dict): + """ Static method to create a state from a dict. + Ensures: state == State.from_json_dict(state.to_json_dict()) """ + + try: + last_changed = json_dict.get('last_changed') + + if last_changed: + last_changed = util.str_to_datetime(last_changed) + + return State(json_dict['state'], + json_dict.get('attributes'), + last_changed) + except KeyError: # if key 'state' did not exist + return None + + def __repr__(self): + return "{}({}, {})".format( + self.state, self.attributes, + util.datetime_to_str(self.last_changed)) + + class StateMachine(object): """ Helper class that tracks the state of different categories. """ @@ -333,16 +373,16 @@ class StateMachine(object): # Add category if it does not exist if category not in self.states: - self.states[category] = create_state(new_state, attributes) + self.states[category] = State(new_state, attributes) # Change state and fire listeners else: old_state = self.states[category] - if old_state['state'] != new_state or \ - old_state['attributes'] != attributes: + if old_state.state != new_state or \ + old_state.attributes != attributes: - self.states[category] = create_state(new_state, attributes) + self.states[category] = State(new_state, attributes) self.bus.fire_event(EVENT_STATE_CHANGED, {'category': category, @@ -356,7 +396,7 @@ class StateMachine(object): the state of the specified category. """ try: # Make a copy so people won't mutate the state - return dict(self.states[category]) + return self.states[category].copy() except KeyError: # If category does not exist @@ -366,7 +406,7 @@ class StateMachine(object): """ Returns True if category exists and is specified state. """ cur_state = self.get_state(category) - return cur_state and cur_state['state'] == state + return cur_state and cur_state.state == state class Timer(threading.Thread): @@ -389,7 +429,7 @@ class Timer(threading.Thread): last_fired_on_second = -1 while True: - now = datetime.now() + now = dt.datetime.now() # First check checks if we are not on a second matching the # timer interval. Second check checks if we did not already fire @@ -407,12 +447,12 @@ class Timer(threading.Thread): time.sleep(slp_seconds) - now = datetime.now() + now = dt.datetime.now() last_fired_on_second = now.second self.bus.fire_event(EVENT_TIME_CHANGED, - {'now': datetime_to_str(now)}) + {'now': now}) class HomeAssistantException(Exception): diff --git a/homeassistant/components/chromecast.py b/homeassistant/components/chromecast.py index 49666301505..a2384c09153 100644 --- a/homeassistant/components/chromecast.py +++ b/homeassistant/components/chromecast.py @@ -36,10 +36,10 @@ def turn_off(statemachine, cc_id=None): state = statemachine.get_state(cat) if state and \ - state['state'] != STATE_NO_APP or \ - state['state'] != pychromecast.APP_ID_HOME: + state.state != STATE_NO_APP or \ + state.state != pychromecast.APP_ID_HOME: - pychromecast.quit_app(state['attributes'][ATTR_HOST]) + pychromecast.quit_app(state.attributes[ATTR_HOST]) def setup(bus, statemachine, host): diff --git a/homeassistant/components/device_sun_light_trigger.py b/homeassistant/components/device_sun_light_trigger.py index 5c7d6fd63d7..d3867cec9a8 100644 --- a/homeassistant/components/device_sun_light_trigger.py +++ b/homeassistant/components/device_sun_light_trigger.py @@ -92,7 +92,7 @@ def setup(bus, statemachine, light_group=None): # Specific device came home ? if (category != device_tracker.STATE_CATEGORY_ALL_DEVICES and - new_state['state'] == ha.STATE_HOME): + new_state.state == ha.STATE_HOME): # These variables are needed for the elif check now = datetime.now() @@ -128,7 +128,7 @@ def setup(bus, statemachine, light_group=None): # Did all devices leave the house? elif (category == device_tracker.STATE_CATEGORY_ALL_DEVICES and - new_state['state'] == ha.STATE_NOT_HOME and lights_are_on): + new_state.state == ha.STATE_NOT_HOME and lights_are_on): logger.info( "Everyone has left but there are devices on. Turning them off") diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 6d8d208af40..628bb6fb663 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -35,12 +35,11 @@ def is_on(statemachine, group): state = statemachine.get_state(group) if state: - group_type = _get_group_type(state['state']) + group_type = _get_group_type(state.state) if group_type: - group_on = _GROUP_TYPES[group_type][0] - - return state['state'] == group_on + # We found group_type, compare to ON-state + return state.state == _GROUP_TYPES[group_type][0] else: return False else: @@ -51,7 +50,7 @@ def get_categories(statemachine, group): """ Get the categories that make up this group. """ state = statemachine.get_state(group) - return state['attributes'][STATE_ATTR_CATEGORIES] if state else [] + return state.attributes[STATE_ATTR_CATEGORIES] if state else [] # pylint: disable=too-many-branches @@ -73,7 +72,7 @@ def setup(bus, statemachine, name, categories): # Try to determine group type if we didn't yet if not group_type and state: - group_type = _get_group_type(state['state']) + group_type = _get_group_type(state.state) if group_type: group_on, group_off = _GROUP_TYPES[group_type] @@ -82,7 +81,7 @@ def setup(bus, statemachine, name, categories): else: # We did not find a matching group_type errors.append("Found unexpected state '{}'".format( - name, state['state'])) + name, state.state)) break @@ -91,13 +90,13 @@ def setup(bus, statemachine, name, categories): errors.append("Category {} does not exist".format(cat)) # Check if category is valid state - elif state['state'] != group_off and state['state'] != group_on: + elif state.state != group_off and state.state != group_on: errors.append("State of {} is {} (expected: {}, {})".format( - cat, state['state'], group_off, group_on)) + cat, state.state, group_off, group_on)) # Keep track of the group state to init later on - elif group_state == group_off and state['state'] == group_on: + elif group_state == group_off and state.state == group_on: group_state = group_on if errors: @@ -114,17 +113,17 @@ def setup(bus, statemachine, name, categories): """ Updates the group state based on a state change by a tracked category. """ - cur_group_state = statemachine.get_state(group_cat)['state'] + cur_group_state = statemachine.get_state(group_cat).state # if cur_group_state = OFF and new_state = ON: set ON # if cur_group_state = ON and new_state = OFF: research # else: ignore - if cur_group_state == group_off and new_state['state'] == group_on: + if cur_group_state == group_off and new_state.state == group_on: statemachine.set_state(group_cat, group_on, state_attr) - elif cur_group_state == group_on and new_state['state'] == group_off: + elif cur_group_state == group_on and new_state.state == group_off: # Check if any of the other states is still on if not any([statemachine.is_state(cat, group_on) diff --git a/homeassistant/components/httpinterface/__init__.py b/homeassistant/components/httpinterface/__init__.py index 74f624bc33c..862b48fcdbb 100644 --- a/homeassistant/components/httpinterface/__init__.py +++ b/homeassistant/components/httpinterface/__init__.py @@ -341,16 +341,16 @@ class RequestHandler(BaseHTTPRequestHandler): state = self.server.statemachine.get_state(category) attributes = "
".join( - ["{}: {}".format(attr, state['attributes'][attr]) - for attr in state['attributes']]) + ["{}: {}".format(attr, state.attributes[attr]) + for attr in state.attributes]) write(("" "{}{}{}{}" "").format( category, - state['state'], + state.state, attributes, - state['last_changed'])) + state.last_changed)) # Change state form write(("